From 0b9a6d5a521f05e810d9bb53ec332674378a362d Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 17 Jun 2024 14:47:14 +0530 Subject: [PATCH] Identify the owner when both sides defines a many to many relationship (#7741) --- .../src/dml/__tests__/entity-builder.spec.ts | 594 ++++++++++++++++- .../dml/helpers/create-mikro-orm-entity.ts | 603 ++++++++++-------- 2 files changed, 905 insertions(+), 292 deletions(-) diff --git a/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts b/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts index 66bae76ef1..c62142fa51 100644 --- a/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts +++ b/packages/core/utils/src/dml/__tests__/entity-builder.spec.ts @@ -18,7 +18,8 @@ describe("Entity builder", () => { email: model.text(), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -67,7 +68,8 @@ describe("Entity builder", () => { email: model.text(), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -117,7 +119,8 @@ describe("Entity builder", () => { email: model.text(), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string | null @@ -167,7 +170,8 @@ describe("Entity builder", () => { role: model.enum(["moderator", "admin", "guest"]), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -231,7 +235,8 @@ describe("Entity builder", () => { role: model.enum(["moderator", "admin", "guest"]).default("guest"), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -296,7 +301,8 @@ describe("Entity builder", () => { role: model.enum(["moderator", "admin", "guest"]).nullable(), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -366,7 +372,8 @@ describe("Entity builder", () => { email: model.hasOne(() => email), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -418,7 +425,8 @@ describe("Entity builder", () => { emails: model.hasOne(() => email).nullable(), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -471,7 +479,8 @@ describe("Entity builder", () => { email: model.hasOne(() => email, { mappedBy: "owner" }), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -527,7 +536,8 @@ describe("Entity builder", () => { delete: ["email"], }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -566,7 +576,7 @@ describe("Entity builder", () => { }, }) - const Email = createMikrORMEntity(email) + const Email = entityBuilder(email) const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email) expect(emailMetaData.className).toEqual("Email") expect(emailMetaData.path).toEqual("Email") @@ -610,7 +620,8 @@ describe("Entity builder", () => { delete: ["email"], }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -656,7 +667,7 @@ describe("Entity builder", () => { }, }) - const Email = createMikrORMEntity(email) + const Email = entityBuilder(email) const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email) expect(emailMetaData.className).toEqual("Email") expect(emailMetaData.path).toEqual("Email") @@ -706,7 +717,8 @@ describe("Entity builder", () => { emails: model.hasMany(() => email), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -760,7 +772,8 @@ describe("Entity builder", () => { }), }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -817,7 +830,8 @@ describe("Entity builder", () => { delete: ["emails"], }) - const User = createMikrORMEntity(user) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -875,8 +889,9 @@ describe("Entity builder", () => { delete: ["emails"], }) - const User = createMikrORMEntity(user) - const Email = createMikrORMEntity(email) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) + const Email = entityBuilder(email) expectTypeOf(new User()).toMatchTypeOf<{ id: number username: string @@ -973,8 +988,9 @@ describe("Entity builder", () => { email: model.hasOne(() => email), }) - const User = createMikrORMEntity(user) - const Email = createMikrORMEntity(email) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) + const Email = entityBuilder(email) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -1081,8 +1097,9 @@ describe("Entity builder", () => { email: model.hasOne(() => email), }) - const User = createMikrORMEntity(user) - const Email = createMikrORMEntity(email) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) + const Email = entityBuilder(email) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -1189,8 +1206,9 @@ describe("Entity builder", () => { emails: model.hasMany(() => email), }) - const User = createMikrORMEntity(user) - const Email = createMikrORMEntity(email) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) + const Email = entityBuilder(email) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -1304,8 +1322,9 @@ describe("Entity builder", () => { emails: model.hasMany(() => email), }) - const User = createMikrORMEntity(user) - const Email = createMikrORMEntity(email) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) + const Email = entityBuilder(email) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -1418,7 +1437,8 @@ describe("Entity builder", () => { username: model.text(), }) - expect(() => createMikrORMEntity(email)).toThrow( + const entityBuilder = createMikrORMEntity() + expect(() => entityBuilder(email)).toThrow( 'Missing property "email" on "user" entity. Make sure to define it as a relationship' ) }) @@ -1438,7 +1458,8 @@ describe("Entity builder", () => { email: model.manyToMany(() => email), }) - expect(() => createMikrORMEntity(email)).toThrow( + const entityBuilder = createMikrORMEntity() + expect(() => entityBuilder(email)).toThrow( 'Invalid relationship reference for "email" on "user" entity. Make sure to define a hasOne or hasMany relationship' ) }) @@ -1484,8 +1505,9 @@ describe("Entity builder", () => { teams: model.manyToMany(() => team), }) - const User = createMikrORMEntity(user) - const Team = createMikrORMEntity(team) + const entityBuilder = createMikrORMEntity() + const User = entityBuilder(user) + const Team = entityBuilder(team) expectTypeOf(new User()).toMatchTypeOf<{ id: number @@ -1539,6 +1561,7 @@ describe("Entity builder", () => { reference: "m:n", name: "teams", entity: "Team", + pivotTable: "team_users", }, }) @@ -1568,6 +1591,517 @@ describe("Entity builder", () => { reference: "m:n", name: "users", entity: "User", + pivotTable: "team_users", + }, + }) + }) + + test("define mappedBy on one side", () => { + const model = new EntityBuilder() + const team = model.define("team", { + id: model.number(), + name: model.text(), + users: model.manyToMany(() => user), + }) + + const user = model.define("user", { + id: model.number(), + username: model.text(), + teams: model.manyToMany(() => team, { mappedBy: "users" }), + }) + + 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: "team_users", + mappedBy: "users", + }, + }) + + 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: "team_users", + }, + }) + }) + + test("throw error when unable to locate relationship via mappedBy", () => { + const model = new EntityBuilder() + const team = model.define("team", { + id: model.number(), + name: model.text(), + }) + + const user = model.define("user", { + id: model.number(), + username: model.text(), + teams: model.manyToMany(() => team, { mappedBy: "users" }), + }) + + const entityBuilder = createMikrORMEntity() + expect(() => entityBuilder(user)).toThrow( + 'Missing property "users" on "team" entity. Make sure to define it as a relationship' + ) + }) + + test("throw error when mappedBy relationship is not a manyToMany", () => { + const model = new EntityBuilder() + const team = model.define("team", { + id: model.number(), + name: model.text(), + users: model.belongsTo(() => team, { mappedBy: "teams" }), + }) + + const user = model.define("user", { + id: model.number(), + username: model.text(), + teams: model.manyToMany(() => team, { mappedBy: "users" }), + }) + + const entityBuilder = createMikrORMEntity() + expect(() => entityBuilder(user)).toThrow( + 'Invalid relationship reference for "users" on "team" entity. Make sure to define a manyToMany relationship' + ) + }) + + test("define mappedBy on both sides", () => { + const model = new EntityBuilder() + const team = model.define("team", { + id: model.number(), + name: model.text(), + users: model.manyToMany(() => user, { mappedBy: "teams" }), + }) + + const user = model.define("user", { + id: model.number(), + username: model.text(), + teams: model.manyToMany(() => team, { mappedBy: "users" }), + }) + + 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: "team_users", + mappedBy: "users", + }, + }) + + 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: "team_users", + /** + * The other side should be inversed in order for Mikro ORM + * to work. Both sides cannot have mappedBy. + */ + inversedBy: "teams", + }, + }) + }) + + test("define mappedBy on both sides and reverse order of registering entities", () => { + const model = new EntityBuilder() + const team = model.define("team", { + id: model.number(), + name: model.text(), + users: model.manyToMany(() => user, { mappedBy: "teams" }), + }) + + const user = model.define("user", { + id: model.number(), + username: model.text(), + teams: model.manyToMany(() => team, { mappedBy: "users" }), + }) + + const entityBuilder = createMikrORMEntity() + const Team = entityBuilder(team) + const User = entityBuilder(user) + + 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: "team_users", + /** + * The other side should be inversed in order for Mikro ORM + * to work. Both sides cannot have mappedBy. + */ + inversedBy: "users", + }, + }) + + 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: "team_users", + mappedBy: "teams", + }, + }) + }) + + test("define multiple many to many relationships to the same entity", () => { + const model = new EntityBuilder() + const team = model.define("team", { + id: model.number(), + name: model.text(), + activeTeamsUsers: model.manyToMany(() => user, { + mappedBy: "activeTeams", + }), + users: model.manyToMany(() => user, { mappedBy: "teams" }), + }) + + const user = model.define("user", { + id: model.number(), + username: model.text(), + activeTeams: model.manyToMany(() => team, { + mappedBy: "activeTeamsUsers", + }), + teams: model.manyToMany(() => team, { mappedBy: "users" }), + }) + + const entityBuilder = createMikrORMEntity() + const Team = entityBuilder(team) + const User = entityBuilder(user) + + expectTypeOf(new User()).toMatchTypeOf<{ + id: number + username: string + teams: EntityConstructor<{ + id: number + name: string + users: EntityConstructor<{ + id: number + username: string + }> + }> + activeTeams: 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 + }> + activeTeams: 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: "team_users", + /** + * The other side should be inversed in order for Mikro ORM + * to work. Both sides cannot have mappedBy. + */ + inversedBy: "users", + }, + activeTeams: { + reference: "m:n", + name: "activeTeams", + entity: "Team", + pivotTable: "team_users", + inversedBy: "activeTeamsUsers", + }, + }) + + 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: "team_users", + mappedBy: "teams", + }, + activeTeamsUsers: { + reference: "m:n", + name: "activeTeamsUsers", + entity: "User", + pivotTable: "team_users", + mappedBy: "activeTeams", }, }) }) diff --git a/packages/core/utils/src/dml/helpers/create-mikro-orm-entity.ts b/packages/core/utils/src/dml/helpers/create-mikro-orm-entity.ts index 379cd9f420..50b7b3e235 100644 --- a/packages/core/utils/src/dml/helpers/create-mikro-orm-entity.ts +++ b/packages/core/utils/src/dml/helpers/create-mikro-orm-entity.ts @@ -22,6 +22,7 @@ import type { } from "../types" import { HasOne } from "../relations/has-one" import { HasMany } from "../relations/has-many" +import { ManyToMany as DmlManyToMany } from "../relations/many-to-many" /** * DML entity data types to PostgreSQL data types via @@ -58,296 +59,374 @@ const PROPERTY_TYPES: { } /** - * Defines a DML entity schema field as a Mikro ORM property + * Factory function to create the mikro orm entity builder. The return + * value is a function that can be used to convert DML entities + * to Mikro ORM entities. */ -function defineProperty( - MikroORMEntity: EntityConstructor, - field: SchemaMetadata -) { +export function createMikrORMEntity() { /** - * Defining an enum property + * The following property is used to track many to many relationship + * between two entities. It is needed because we have to mark one + * of them as the owner of the relationship without exposing + * any user land APIs to explicitly define an owner. + * + * The object contains values as follows. + * - [entityname.relationship]: true // true means, it is already marked as owner + * + * Example: + * - [user.teams]: true // the teams relationship on user is an owner + * - [team.users] // cannot be an owner */ - if (field.dataType.name === "enum") { - Enum({ - items: () => field.dataType.options!.choices, + const MANY_TO_MANY_TRACKED_REALTIONS: Record = {} + + /** + * Defines a DML entity schema field as a Mikro ORM property + */ + function defineProperty( + MikroORMEntity: EntityConstructor, + field: SchemaMetadata + ) { + /** + * Defining an enum property + */ + if (field.dataType.name === "enum") { + Enum({ + items: () => field.dataType.options!.choices, + nullable: field.nullable, + default: field.defaultValue, + })(MikroORMEntity.prototype, field.fieldName) + return + } + + /** + * Define rest of properties + */ + const columnType = COLUMN_TYPES[field.dataType.name] + const propertyType = PROPERTY_TYPES[field.dataType.name] + + Property({ + columnType, + type: propertyType, nullable: field.nullable, default: field.defaultValue, })(MikroORMEntity.prototype, field.fieldName) - return } /** - * Define rest of properties + * Defines has one relationship on the Mikro ORM entity. */ - const columnType = COLUMN_TYPES[field.dataType.name] - const propertyType = PROPERTY_TYPES[field.dataType.name] + function defineHasOneRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + relatedEntity: DmlEntity< + Record | RelationshipType> + >, + cascades: EntityCascades + ) { + const relatedModelName = upperCaseFirst(relatedEntity.name) + const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) - Property({ - columnType, - type: propertyType, - nullable: field.nullable, - default: field.defaultValue, - })(MikroORMEntity.prototype, field.fieldName) -} - -/** - * Defines has one relationship on the Mikro ORM entity. - */ -function defineHasOneRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - relatedEntity: DmlEntity< - Record | RelationshipType> - >, - cascades: EntityCascades -) { - const relatedModelName = upperCaseFirst(relatedEntity.name) - const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) - - OneToOne({ - entity: relatedModelName, - nullable: relationship.nullable, - mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), - cascade: shouldRemoveRelated - ? (["perist", "soft-remove"] as any) - : undefined, - })(MikroORMEntity.prototype, relationship.name) -} - -/** - * Defines has many relationship on the Mikro ORM entity - */ -function defineHasManyRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - relatedEntity: DmlEntity< - Record | RelationshipType> - >, - cascades: EntityCascades -) { - const relatedModelName = upperCaseFirst(relatedEntity.name) - const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) - - OneToMany({ - entity: relatedModelName, - orphanRemoval: true, - mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), - cascade: shouldRemoveRelated - ? (["perist", "soft-remove"] as any) - : undefined, - })(MikroORMEntity.prototype, relationship.name) -} - -/** - * Defines belongs to relationship on the Mikro ORM entity. The belongsTo - * relationship inspects the related entity for the other side of - * the relationship and then uses one of the following Mikro ORM - * relationship. - * - * - OneToOne: When the other side uses "hasOne" with "owner: true" - * - ManyToOne: When the other side uses "hasMany" - */ -function defineBelongsToRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - relatedEntity: DmlEntity< - Record | RelationshipType> - > -) { - const mappedBy = - relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name) - const { schema: relationSchema, cascades: relationCascades } = - relatedEntity.parse() - - const otherSideRelation = relationSchema[mappedBy] - const relatedModelName = upperCaseFirst(relatedEntity.name) - - /** - * In DML the relationships are cascaded from parent to child. A belongsTo - * relationship is always a child, therefore we look at the parent and - * define a onDelete: cascade when we are included in the delete - * list of parent cascade. - */ - const shouldCascade = relationCascades.delete?.includes(mappedBy) - - /** - * Ensure the mapped by is defined as relationship on the other side - */ - if (!otherSideRelation) { - throw new Error( - `Missing property "${mappedBy}" on "${relatedEntity.name}" entity. Make sure to define it as a relationship` - ) - } - - /** - * Otherside is a has many. Hence we should defined a ManyToOne - */ - if (otherSideRelation instanceof HasMany) { - ManyToOne({ - entity: relatedModelName, - columnType: "text", - mapToPk: true, - fieldName: camelToSnakeCase(`${relationship.name}Id`), - nullable: relationship.nullable, - onDelete: shouldCascade ? "cascade" : undefined, - })(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`)) - - ManyToOne({ - entity: relatedModelName, - persist: false, - })(MikroORMEntity.prototype, relationship.name) - return - } - - /** - * Otherside is a has one. Hence we should defined a OneToOne - */ - if (otherSideRelation instanceof HasOne) { OneToOne({ entity: relatedModelName, nullable: relationship.nullable, - mappedBy: mappedBy, - owner: true, - onDelete: shouldCascade ? "cascade" : undefined, + mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), + cascade: shouldRemoveRelated + ? (["perist", "soft-remove"] as any) + : undefined, })(MikroORMEntity.prototype, relationship.name) - return } /** - * Other side is some unsupported data-type + * Defines has many relationship on the Mikro ORM entity */ - throw new Error( - `Invalid relationship reference for "${mappedBy}" on "${relatedEntity.name}" entity. Make sure to define a hasOne or hasMany relationship` - ) -} + function defineHasManyRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + relatedEntity: DmlEntity< + Record | RelationshipType> + >, + cascades: EntityCascades + ) { + const relatedModelName = upperCaseFirst(relatedEntity.name) + const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name) -/** - * Defines a many to many relationship on the Mikro ORM entity - */ -function defineManyToManyRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - relatedEntity: DmlEntity< - Record | RelationshipType> - >, - cascades: EntityCascades -) { - const relatedModelName = upperCaseFirst(relatedEntity.name) - ManyToMany({ - entity: relatedModelName, - mappedBy: relationship.mappedBy as any, - })(MikroORMEntity.prototype, relationship.name) -} - -/** - * Defines a DML entity schema field as a Mikro ORM relationship - */ -function defineRelationship( - MikroORMEntity: EntityConstructor, - relationship: RelationshipMetadata, - cascades: EntityCascades -) { - /** - * We expect the relationship.entity to be a function that - * lazily returns the related entity - */ - const relatedEntity = - typeof relationship.entity === "function" - ? (relationship.entity() as unknown) - : undefined - - /** - * Since we don't type-check relationships, we should validate - * them at runtime - */ - if (!relatedEntity) { - throw new Error( - `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to define the relationship using a factory function` - ) + OneToMany({ + entity: relatedModelName, + orphanRemoval: true, + mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name), + cascade: shouldRemoveRelated + ? (["perist", "soft-remove"] as any) + : undefined, + })(MikroORMEntity.prototype, relationship.name) } /** - * Ensure the return value is a DML entity instance + * Defines belongs to relationship on the Mikro ORM entity. The belongsTo + * relationship inspects the related entity for the other side of + * the relationship and then uses one of the following Mikro ORM + * relationship. + * + * - OneToOne: When the other side uses "hasOne" with "owner: true" + * - ManyToOne: When the other side uses "hasMany" */ - if (!(relatedEntity instanceof DmlEntity)) { - throw new Error( - `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the relationship callback` - ) - } + function defineBelongsToRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + relatedEntity: DmlEntity< + Record | RelationshipType> + > + ) { + const mappedBy = + relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name) + const { schema: relationSchema, cascades: relationCascades } = + relatedEntity.parse() - /** - * Defining relationships - */ - switch (relationship.type) { - case "hasOne": - defineHasOneRelationship( - MikroORMEntity, - relationship, - relatedEntity, - cascades + const otherSideRelation = relationSchema[mappedBy] + const relatedModelName = upperCaseFirst(relatedEntity.name) + + /** + * In DML the relationships are cascaded from parent to child. A belongsTo + * relationship is always a child, therefore we look at the parent and + * define a onDelete: cascade when we are included in the delete + * list of parent cascade. + */ + const shouldCascade = relationCascades.delete?.includes(mappedBy) + + /** + * Ensure the mapped by is defined as relationship on the other side + */ + if (!otherSideRelation) { + throw new Error( + `Missing property "${mappedBy}" on "${relatedEntity.name}" entity. Make sure to define it as a relationship` ) - break - case "hasMany": - defineHasManyRelationship( - MikroORMEntity, - relationship, - relatedEntity, - cascades - ) - break - case "belongsTo": - defineBelongsToRelationship(MikroORMEntity, relationship, relatedEntity) - break - case "manyToMany": - defineManyToManyRelationship( - MikroORMEntity, - relationship, - relatedEntity, - cascades - ) - break - } -} - -/** - * A helper function to define a Mikro ORM entity from a - * DML entity. - * @todo: Handle soft deleted indexes and filters - * @todo: Finalize if custom pivot entities are needed - */ -export function createMikrORMEntity>( - entity: T -): Infer { - class MikroORMEntity {} - const { name, schema, cascades } = entity.parse() - - const className = upperCaseFirst(name) - const tableName = pluralize(camelToSnakeCase(className)) - - /** - * Assigning name to the class constructor, so that it matches - * the DML entity name - */ - Object.defineProperty(MikroORMEntity, "name", { - get: function () { - return className - }, - }) - - /** - * Processing schema fields - */ - Object.entries(schema).forEach(([name, property]) => { - const field = property.parse(name) - if ("fieldName" in field) { - defineProperty(MikroORMEntity, field) - } else { - defineRelationship(MikroORMEntity, field, cascades) } - }) + + /** + * Otherside is a has many. Hence we should defined a ManyToOne + */ + if (otherSideRelation instanceof HasMany) { + ManyToOne({ + entity: relatedModelName, + columnType: "text", + mapToPk: true, + fieldName: camelToSnakeCase(`${relationship.name}Id`), + nullable: relationship.nullable, + onDelete: shouldCascade ? "cascade" : undefined, + })(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`)) + + ManyToOne({ + entity: relatedModelName, + persist: false, + })(MikroORMEntity.prototype, relationship.name) + return + } + + /** + * Otherside is a has one. Hence we should defined a OneToOne + */ + if (otherSideRelation instanceof HasOne) { + OneToOne({ + entity: relatedModelName, + nullable: relationship.nullable, + mappedBy: mappedBy, + owner: true, + onDelete: shouldCascade ? "cascade" : undefined, + })(MikroORMEntity.prototype, relationship.name) + return + } + + /** + * Other side is some unsupported data-type + */ + throw new Error( + `Invalid relationship reference for "${mappedBy}" on "${relatedEntity.name}" entity. Make sure to define a hasOne or hasMany relationship` + ) + } /** - * Converting class to a MikroORM entity + * Defines a many to many relationship on the Mikro ORM entity */ - return Entity({ tableName })(MikroORMEntity) as Infer + function defineManyToManyRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + relatedEntity: DmlEntity< + Record | RelationshipType> + >, + cascades: EntityCascades + ) { + const relatedModelName = upperCaseFirst(relatedEntity.name) + let mappedBy = relationship.mappedBy + let inversedBy: 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. + */ + const pivotTableName = [ + MikroORMEntity.name.toLowerCase(), + relatedEntity.name.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) { + throw new Error( + `Missing property "${mappedBy}" on "${relatedEntity.name}" entity. Make sure to define it as a relationship` + ) + } + + if (otherSideRelation instanceof DmlManyToMany === false) { + throw new Error( + `Invalid relationship reference for "${mappedBy}" on "${relatedEntity.name}" entity. Make sure to define a manyToMany relationship` + ) + } + + /** + * Check if the other side has defined a mapped by and if that + * mapping is already tracked as the owner. + * + * - If yes, we will inverse our mapped by + * - Otherwise, we will track ourselves as the owner. + */ + if ( + otherSideRelation.parse(mappedBy).mappedBy && + MANY_TO_MANY_TRACKED_REALTIONS[`${relatedModelName}.${mappedBy}`] + ) { + inversedBy = mappedBy + mappedBy = undefined + } else { + MANY_TO_MANY_TRACKED_REALTIONS[ + `${MikroORMEntity.name}.${relationship.name}` + ] = true + } + } + + ManyToMany({ + entity: relatedModelName, + pivotTable: pivotTableName, + ...(mappedBy ? { mappedBy: mappedBy as any } : {}), + ...(inversedBy ? { inversedBy: inversedBy as any } : {}), + })(MikroORMEntity.prototype, relationship.name) + } + + /** + * Defines a DML entity schema field as a Mikro ORM relationship + */ + function defineRelationship( + MikroORMEntity: EntityConstructor, + relationship: RelationshipMetadata, + cascades: EntityCascades + ) { + /** + * We expect the relationship.entity to be a function that + * lazily returns the related entity + */ + const relatedEntity = + typeof relationship.entity === "function" + ? (relationship.entity() as unknown) + : undefined + + /** + * Since we don't type-check relationships, we should validate + * them at runtime + */ + if (!relatedEntity) { + throw new Error( + `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to define the relationship using a factory function` + ) + } + + /** + * Ensure the return value is a DML entity instance + */ + if (!(relatedEntity instanceof DmlEntity)) { + throw new Error( + `Invalid relationship reference for "${MikroORMEntity.name}.${relationship.name}". Make sure to return a DML entity from the relationship callback` + ) + } + + /** + * Defining relationships + */ + switch (relationship.type) { + case "hasOne": + defineHasOneRelationship( + MikroORMEntity, + relationship, + relatedEntity, + cascades + ) + break + case "hasMany": + defineHasManyRelationship( + MikroORMEntity, + relationship, + relatedEntity, + cascades + ) + break + case "belongsTo": + defineBelongsToRelationship(MikroORMEntity, relationship, relatedEntity) + break + case "manyToMany": + defineManyToManyRelationship( + MikroORMEntity, + relationship, + relatedEntity, + cascades + ) + break + } + } + + /** + * A helper function to define a Mikro ORM entity from a + * DML entity. + */ + return function createEntity>(entity: T): Infer { + class MikroORMEntity {} + const { name, schema, cascades } = entity.parse() + + const className = upperCaseFirst(name) + const tableName = pluralize(camelToSnakeCase(className)) + + /** + * Assigning name to the class constructor, so that it matches + * the DML entity name + */ + Object.defineProperty(MikroORMEntity, "name", { + get: function () { + return className + }, + }) + + /** + * Processing schema fields + */ + Object.entries(schema).forEach(([name, property]) => { + const field = property.parse(name) + if ("fieldName" in field) { + defineProperty(MikroORMEntity, field) + } else { + defineRelationship(MikroORMEntity, field, cascades) + } + }) + + /** + * Converting class to a MikroORM entity + */ + return Entity({ tableName })(MikroORMEntity) as Infer + } }