Add support for pivot table and entity in manyToMany relationships (#7779)
This commit is contained in:
@@ -22,6 +22,7 @@ describe("Base relationship", () => {
|
||||
name: "user",
|
||||
type: "hasOne",
|
||||
nullable: false,
|
||||
options: { mappedBy: "user_id" },
|
||||
mappedBy: "user_id",
|
||||
entity: entityRef,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("HasMany relationship", () => {
|
||||
name: "user",
|
||||
type: "hasMany",
|
||||
nullable: false,
|
||||
options: {},
|
||||
entity: entityRef,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("ManyToMany relationship", () => {
|
||||
name: "user",
|
||||
type: "manyToMany",
|
||||
nullable: false,
|
||||
options: {},
|
||||
entity: entityRef,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user