Add support for pivot table and entity in manyToMany relationships (#7779)

This commit is contained in:
Harminder Virk
2024-06-20 14:13:31 +05:30
committed by GitHub
parent 79a8f0ef2c
commit 45ad70e96b
9 changed files with 512 additions and 25 deletions

View File

@@ -22,6 +22,7 @@ describe("Base relationship", () => {
name: "user",
type: "hasOne",
nullable: false,
options: { mappedBy: "user_id" },
mappedBy: "user_id",
entity: entityRef,
})

View File

@@ -3023,7 +3023,7 @@ describe("Entity builder", () => {
const user = model.define("user", {
id: model.number(),
username: model.text(),
email: model.manyToMany(() => email),
email: model.belongsTo(() => email),
})
const entityBuilder = createMikrORMEntity()
@@ -4526,5 +4526,435 @@ describe("Entity builder", () => {
},
})
})
test("define custom pivot table name", () => {
const model = new EntityBuilder()
const team = model.define("team", {
id: model.number(),
name: model.text(),
users: model.manyToMany(() => user, {
pivotTable: "users_teams",
}),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
teams: model.manyToMany(() => team, { pivotTable: "users_teams" }),
})
const entityBuilder = createMikrORMEntity()
const User = entityBuilder(user)
const Team = entityBuilder(team)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
teams: EntityConstructor<{
id: number
name: string
users: EntityConstructor<{
id: number
username: string
}>
}>
}>()
expectTypeOf(new Team()).toMatchTypeOf<{
id: number
name: string
users: EntityConstructor<{
id: number
username: string
teams: EntityConstructor<{
id: number
name: string
}>
}>
}>()
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
nullable: false,
getter: false,
setter: false,
},
teams: {
reference: "m:n",
name: "teams",
entity: "Team",
pivotTable: "users_teams",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
const teamMetaData = MetadataStorage.getMetadataFromDecorator(Team)
expect(teamMetaData.className).toEqual("Team")
expect(teamMetaData.path).toEqual("Team")
expect(teamMetaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
nullable: false,
getter: false,
setter: false,
},
name: {
reference: "scalar",
type: "string",
columnType: "text",
name: "name",
nullable: false,
getter: false,
setter: false,
},
users: {
reference: "m:n",
name: "users",
entity: "User",
pivotTable: "users_teams",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
test("define custom pivot entity", () => {
const model = new EntityBuilder()
const team = model.define("team", {
id: model.number(),
name: model.text(),
users: model.manyToMany(() => user, {
pivotEntity: () => squad,
}),
})
const squad = model.define("teamUsers", {
id: model.number(),
user: model.belongsTo(() => user, { mappedBy: "teams" }),
team: model.belongsTo(() => team, { mappedBy: "users" }),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
teams: model.manyToMany(() => team, { pivotEntity: () => squad }),
})
const entityBuilder = createMikrORMEntity()
const User = entityBuilder(user)
const Team = entityBuilder(team)
const Squad = entityBuilder(squad)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
teams: EntityConstructor<{
id: number
name: string
users: EntityConstructor<{
id: number
username: string
}>
}>
}>()
expectTypeOf(new Team()).toMatchTypeOf<{
id: number
name: string
users: EntityConstructor<{
id: number
username: string
teams: EntityConstructor<{
id: number
name: string
}>
}>
}>()
const squadMetaData = MetadataStorage.getMetadataFromDecorator(Squad)
expect(squadMetaData.className).toEqual("TeamUsers")
expect(squadMetaData.path).toEqual("TeamUsers")
expect(squadMetaData.tableName).toEqual("team_users")
expect(squadMetaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
nullable: false,
getter: false,
setter: false,
},
team: {
entity: "Team",
name: "team",
persist: false,
reference: "m:1",
},
team_id: {
columnType: "text",
entity: "Team",
fieldName: "team_id",
mapToPk: true,
name: "team_id",
nullable: false,
onDelete: undefined,
reference: "m:1",
},
user: {
entity: "User",
name: "user",
persist: false,
reference: "m:1",
},
user_id: {
columnType: "text",
entity: "User",
fieldName: "user_id",
mapToPk: true,
name: "user_id",
nullable: false,
onDelete: undefined,
reference: "m:1",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
const metaData = MetadataStorage.getMetadataFromDecorator(User)
expect(metaData.className).toEqual("User")
expect(metaData.path).toEqual("User")
expect(metaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
nullable: false,
getter: false,
setter: false,
},
username: {
reference: "scalar",
type: "string",
columnType: "text",
name: "username",
nullable: false,
getter: false,
setter: false,
},
teams: {
reference: "m:n",
name: "teams",
entity: "Team",
pivotEntity: "TeamUsers",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
const teamMetaData = MetadataStorage.getMetadataFromDecorator(Team)
expect(teamMetaData.className).toEqual("Team")
expect(teamMetaData.path).toEqual("Team")
expect(teamMetaData.properties).toEqual({
id: {
reference: "scalar",
type: "number",
columnType: "integer",
name: "id",
nullable: false,
getter: false,
setter: false,
},
name: {
reference: "scalar",
type: "string",
columnType: "text",
name: "name",
nullable: false,
getter: false,
setter: false,
},
users: {
reference: "m:n",
name: "users",
entity: "User",
pivotEntity: "TeamUsers",
},
created_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "created_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
updated_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "updated_at",
defaultRaw: "now()",
onCreate: expect.any(Function),
onUpdate: expect.any(Function),
nullable: false,
getter: false,
setter: false,
},
deleted_at: {
reference: "scalar",
type: "date",
columnType: "timestamptz",
name: "deleted_at",
nullable: true,
getter: false,
setter: false,
},
})
})
})
})

View File

@@ -16,6 +16,7 @@ describe("HasMany relationship", () => {
name: "user",
type: "hasMany",
nullable: false,
options: {},
entity: entityRef,
})
})

View File

@@ -16,6 +16,7 @@ describe("HasOne relationship", () => {
name: "user",
type: "hasOne",
nullable: false,
options: {},
entity: entityRef,
})
})
@@ -35,6 +36,7 @@ describe("HasOne relationship", () => {
name: "user",
type: "hasOne",
nullable: true,
options: {},
entity: entityRef,
})
})

View File

@@ -16,6 +16,7 @@ describe("ManyToMany relationship", () => {
name: "user",
type: "manyToMany",
nullable: false,
options: {},
entity: entityRef,
})
})

View File

@@ -68,7 +68,7 @@ export class EntityBuilder {
* Define an id property. Id properties are marked
* primary by default
*/
id(options: ConstructorParameters<typeof IdProperty>[0]) {
id(options?: ConstructorParameters<typeof IdProperty>[0]) {
return new IdProperty(options)
}
@@ -162,7 +162,20 @@ export class EntityBuilder {
* relationship requires a pivot table to establish a many to many
* relationship between two entities
*/
manyToMany<T>(entityBuilder: T, options?: RelationshipOptions) {
manyToMany<T>(
entityBuilder: T,
options?: RelationshipOptions &
(
| {
pivotTable?: string
pivotEntity?: never
}
| {
pivotTable?: never
pivotEntity?: () => DmlEntity<any>
}
)
) {
return new ManyToMany<T>(entityBuilder, options || {})
}
}

View File

@@ -327,7 +327,10 @@ export function createMikrORMEntity() {
/**
* Otherside is a has many. Hence we should defined a ManyToOne
*/
if (otherSideRelation instanceof HasMany) {
if (
otherSideRelation instanceof HasMany ||
otherSideRelation instanceof DmlManyToMany
) {
ManyToOne({
entity: relatedModelName,
columnType: "text",
@@ -382,28 +385,12 @@ export function createMikrORMEntity() {
) {
let mappedBy = relationship.mappedBy
let inversedBy: undefined | string
let pivotEntityName: undefined | string
let pivotTableName: undefined | string
/**
* A consistent pivot table name is created by:
*
* - Combining both the entity's names.
* - Sorting them by alphabetical order
* - Converting them from camelCase to snake_case.
* - And finally pluralizing the second entity name.
* Validating other side of relationship when mapped by is defined
*/
const pivotTableName = [
MikroORMEntity.name.toLowerCase(),
relatedModelName.toLowerCase(),
]
.sort()
.map((token, index) => {
if (index === 1) {
return pluralize(camelToSnakeCase(token))
}
return camelToSnakeCase(token)
})
.join("_")
if (mappedBy) {
const otherSideRelation = relatedEntity.parse().schema[mappedBy]
if (!otherSideRelation) {
@@ -438,9 +425,59 @@ export function createMikrORMEntity() {
}
}
/**
* Validating pivot entity when it is defined and computing
* its name
*/
if (relationship.options.pivotEntity) {
if (typeof relationship.options.pivotEntity !== "function") {
throw new Error(
`Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to define the pivotEntity using a factory function`
)
}
const pivotEntity = relationship.options.pivotEntity()
if (!(pivotEntity instanceof DmlEntity)) {
throw new Error(
`Invalid pivotEntity reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the pivotEntity callback`
)
}
pivotEntityName = parseEntityName(pivotEntity.parse().name).modelName
}
if (!pivotEntityName) {
/**
* Pivot table name is created as follows (when not explicitly provided)
*
* - Combining both the entity's names.
* - Sorting them by alphabetical order
* - Converting them from camelCase to snake_case.
* - And finally pluralizing the second entity name.
*/
pivotTableName =
relationship.options.pivotTable ??
[MikroORMEntity.name.toLowerCase(), relatedModelName.toLowerCase()]
.sort()
.map((token, index) => {
if (index === 1) {
return pluralize(camelToSnakeCase(token))
}
return camelToSnakeCase(token)
})
.join("_")
}
ManyToMany({
entity: relatedModelName,
pivotTable: pgSchema ? `${pgSchema}.${pivotTableName}` : pivotTableName,
...(pivotTableName
? {
pivotTable: pgSchema
? `${pgSchema}.${pivotTableName}`
: pivotTableName,
}
: {}),
...(pivotEntityName ? { pivotEntity: pivotEntityName } : {}),
...(mappedBy ? { mappedBy: mappedBy as any } : {}),
...(inversedBy ? { inversedBy: inversedBy as any } : {}),
})(MikroORMEntity.prototype, relationship.name)

View File

@@ -41,6 +41,7 @@ export abstract class BaseRelationship<T> implements RelationshipType<T> {
name: relationshipName,
nullable: false,
mappedBy: this.options.mappedBy,
options: this.options,
entity: this.#referencedEntity,
type: this.type,
}

View File

@@ -54,7 +54,7 @@ export type PropertyType<T> = {
*/
export type RelationshipOptions = {
mappedBy?: string
}
} & Record<string, any>
/**
* The meta-data returned by the relationship parse
@@ -66,6 +66,7 @@ export type RelationshipMetadata = {
entity: unknown
nullable: boolean
mappedBy?: string
options: Record<string, any>
}
/**