Identify the owner when both sides defines a many to many relationship (#7741)

This commit is contained in:
Harminder Virk
2024-06-17 14:47:14 +05:30
committed by GitHub
parent 3ecbec9685
commit 0b9a6d5a52
2 changed files with 905 additions and 292 deletions

View File

@@ -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",
},
})
})

View File

@@ -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<any>,
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<string, boolean> = {}
/**
* Defines a DML entity schema field as a Mikro ORM property
*/
function defineProperty(
MikroORMEntity: EntityConstructor<any>,
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>,
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>,
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>,
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>,
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>,
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
cascades: EntityCascades<string[]>
) {
/**
* 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<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>
) {
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<T extends DmlEntity<any>>(
entity: T
): Infer<T> {
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<T>
function defineManyToManyRelationship(
MikroORMEntity: EntityConstructor<any>,
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>,
cascades: EntityCascades<string[]>
) {
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<any>,
relationship: RelationshipMetadata,
cascades: EntityCascades<string[]>
) {
/**
* 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<T extends DmlEntity<any>>(entity: T): Infer<T> {
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<T>
}
}