Add support for cascades to DML (#7721)

This commit is contained in:
Harminder Virk
2024-06-14 13:45:11 +05:30
committed by GitHub
parent 3d33f06e77
commit 2af3f9e954
13 changed files with 615 additions and 47 deletions

View File

@@ -5,7 +5,7 @@ import { BaseRelationship } from "../relations/base"
describe("Base relationship", () => {
test("define a custom relationship", () => {
class HasOne<T> extends BaseRelationship<T> {
protected relationshipType: "hasOne" | "hasMany" | "manyToMany" = "hasOne"
type: "hasOne" = "hasOne"
}
const user = {

View File

@@ -400,6 +400,7 @@ describe("Entity builder", () => {
name: "email",
entity: "Email",
nullable: false,
mappedBy: "user",
},
})
})
@@ -452,6 +453,240 @@ describe("Entity builder", () => {
name: "emails",
entity: "Email",
nullable: true,
mappedBy: "user",
},
})
})
test("define custom mappedBy key for relationship", () => {
const model = new EntityBuilder()
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email, { mappedBy: "owner" }),
})
const User = createMikrORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: EntityConstructor<{ email: string; isVerified: boolean }>
}>()
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,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
mappedBy: "owner",
},
})
})
test("define delete cascades for the entity", () => {
const model = new EntityBuilder()
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model
.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email),
})
.cascades({
delete: ["email"],
})
const User = createMikrORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: EntityConstructor<{ email: string; isVerified: boolean }>
}>()
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,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
},
})
const Email = createMikrORMEntity(email)
const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email)
expect(emailMetaData.className).toEqual("Email")
expect(emailMetaData.path).toEqual("Email")
expect(emailMetaData.properties).toEqual({
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
nullable: false,
getter: false,
setter: false,
},
isVerified: {
reference: "scalar",
type: "boolean",
columnType: "boolean",
name: "isVerified",
nullable: false,
getter: false,
setter: false,
},
})
})
test("define delete cascades with belongsTo on the other end", () => {
const model = new EntityBuilder()
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
user: model.belongsTo(() => user),
})
const user = model
.define("user", {
id: model.number(),
username: model.text(),
email: model.hasOne(() => email),
})
.cascades({
delete: ["email"],
})
const User = createMikrORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
email: EntityConstructor<{
email: string
isVerified: boolean
user: EntityConstructor<{
id: number
username: 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,
},
email: {
reference: "1:1",
name: "email",
entity: "Email",
nullable: false,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
},
})
const Email = createMikrORMEntity(email)
const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email)
expect(emailMetaData.className).toEqual("Email")
expect(emailMetaData.path).toEqual("Email")
expect(emailMetaData.properties).toEqual({
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
nullable: false,
getter: false,
setter: false,
},
isVerified: {
reference: "scalar",
type: "boolean",
columnType: "boolean",
name: "isVerified",
nullable: false,
getter: false,
setter: false,
},
user: {
entity: "User",
mappedBy: "email",
name: "user",
nullable: false,
onDelete: "cascade",
owner: true,
reference: "1:1",
},
})
})
@@ -564,6 +799,162 @@ describe("Entity builder", () => {
},
})
})
test("define delete cascades for the entity", () => {
const model = new EntityBuilder()
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
})
const user = model
.define("user", {
id: model.number(),
username: model.text(),
emails: model.hasMany(() => email),
})
.cascades({
delete: ["emails"],
})
const User = createMikrORMEntity(user)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
emails: EntityConstructor<{ email: string; isVerified: boolean }>
}>()
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,
},
emails: {
reference: "1:m",
name: "emails",
entity: "Email",
orphanRemoval: true,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
},
})
})
test("define delete cascades with belongsTo on the other end", () => {
const model = new EntityBuilder()
const email = model.define("email", {
email: model.text(),
isVerified: model.boolean(),
user: model.belongsTo(() => user, { mappedBy: "emails" }),
})
const user = model
.define("user", {
id: model.number(),
username: model.text(),
emails: model.hasMany(() => email),
})
.cascades({
delete: ["emails"],
})
const User = createMikrORMEntity(user)
const Email = createMikrORMEntity(email)
expectTypeOf(new User()).toMatchTypeOf<{
id: number
username: string
emails: EntityConstructor<{ email: string; isVerified: boolean }>
}>()
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,
},
emails: {
reference: "1:m",
name: "emails",
entity: "Email",
orphanRemoval: true,
mappedBy: "user",
cascade: ["perist", "soft-remove"],
},
})
const emailMetaData = MetadataStorage.getMetadataFromDecorator(Email)
expect(emailMetaData.className).toEqual("Email")
expect(emailMetaData.path).toEqual("Email")
expect(emailMetaData.properties).toEqual({
email: {
reference: "scalar",
type: "string",
columnType: "text",
name: "email",
nullable: false,
getter: false,
setter: false,
},
isVerified: {
reference: "scalar",
type: "boolean",
columnType: "boolean",
name: "isVerified",
nullable: false,
getter: false,
setter: false,
},
user: {
reference: "m:1",
name: "user",
entity: "User",
persist: false,
},
user_id: {
columnType: "text",
entity: "User",
fieldName: "user_id",
mapToPk: true,
name: "user_id",
nullable: false,
reference: "m:1",
onDelete: "cascade",
},
})
})
})
describe("Entity builder | belongsTo", () => {
@@ -638,6 +1029,7 @@ describe("Entity builder", () => {
name: "email",
entity: "Email",
nullable: false,
mappedBy: "user",
},
})
@@ -745,6 +1137,7 @@ describe("Entity builder", () => {
name: "email",
entity: "Email",
nullable: false,
mappedBy: "user",
},
})
@@ -1049,6 +1442,31 @@ describe("Entity builder", () => {
'Invalid relationship reference for "email" on "user" entity. Make sure to define a hasOne or hasMany relationship'
)
})
test("throw error when cascading a parent from a child", () => {
const model = new EntityBuilder()
const user = model.define("user", {
id: model.number(),
username: model.text(),
})
const defineEmail = () =>
model
.define("email", {
email: model.text(),
isVerified: model.boolean(),
user: model.belongsTo(() => user),
})
.cascades({
// @ts-expect-error "User cannot be mentioned in cascades"
delete: ["user"],
})
expect(defineEmail).toThrow(
'Cannot cascade delete "user" relationship(s) from "email" entity. Child to parent cascades are not allowed'
)
})
})
describe("Entity builder | manyToMany", () => {

View File

@@ -1,4 +1,10 @@
import { RelationshipType, SchemaType } from "./types"
import { BelongsTo } from "./relations/belongs-to"
import {
SchemaType,
EntityCascades,
RelationshipType,
ExtractEntityRelations,
} from "./types"
/**
* Dml entity is a representation of a DML model with a unique
@@ -7,5 +13,50 @@ import { RelationshipType, SchemaType } from "./types"
export class DmlEntity<
Schema extends Record<string, SchemaType<any> | RelationshipType<any>>
> {
#cascades: EntityCascades<string[]> = {}
constructor(public name: string, public schema: Schema) {}
/**
* Parse entity to get its underlying information
*/
parse(): {
name: string
schema: SchemaType<any> | RelationshipType<any>
cascades: EntityCascades<string[]>
} {
return {
name: this.name,
schema: this.schema as unknown as SchemaType<any> | RelationshipType<any>,
cascades: this.#cascades,
}
}
/**
* Delete actions to be performed when the entity is deleted. For example:
*
* You can configure relationship data to be deleted when the current
* entity is deleted.
*/
cascades(
options: EntityCascades<
ExtractEntityRelations<Schema, "hasOne" | "hasMany">
>
) {
const childToParentCascades = options.delete?.filter((relationship) => {
return this.schema[relationship] instanceof BelongsTo
})
if (childToParentCascades?.length) {
throw new Error(
`Cannot cascade delete "${childToParentCascades.join(
", "
)}" relationship(s) from "${
this.name
}" entity. Child to parent cascades are not allowed`
)
}
this.#cascades = options
return this
}
}

View File

@@ -13,6 +13,7 @@ import { upperCaseFirst } from "../../common/upper-case-first"
import type {
Infer,
SchemaType,
EntityCascades,
KnownDataTypes,
SchemaMetadata,
RelationshipType,
@@ -97,13 +98,19 @@ function defineHasOneRelationship(
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 as any,
mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name),
cascade: shouldRemoveRelated
? (["perist", "soft-remove"] as any)
: undefined,
})(MikroORMEntity.prototype, relationship.name)
}
@@ -115,13 +122,19 @@ function defineHasManyRelationship(
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)
}
@@ -143,9 +156,20 @@ function defineBelongsToRelationship(
) {
const mappedBy =
relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name)
const otherSideRelation = relatedEntity.schema[mappedBy]
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
*/
@@ -165,6 +189,7 @@ function defineBelongsToRelationship(
mapToPk: true,
fieldName: camelToSnakeCase(`${relationship.name}Id`),
nullable: relationship.nullable,
onDelete: shouldCascade ? "cascade" : undefined,
})(MikroORMEntity.prototype, camelToSnakeCase(`${relationship.name}Id`))
ManyToOne({
@@ -178,12 +203,12 @@ function defineBelongsToRelationship(
* Otherside is a has one. Hence we should defined a OneToOne
*/
if (otherSideRelation instanceof HasOne) {
const relatedModelName = upperCaseFirst(relatedEntity.name)
OneToOne({
entity: relatedModelName,
nullable: relationship.nullable,
mappedBy: mappedBy,
owner: true,
onDelete: shouldCascade ? "cascade" : undefined,
})(MikroORMEntity.prototype, relationship.name)
return
}
@@ -204,7 +229,8 @@ function defineManyToManyRelationship(
relationship: RelationshipMetadata,
relatedEntity: DmlEntity<
Record<string, SchemaType<any> | RelationshipType<any>>
>
>,
cascades: EntityCascades<string[]>
) {
const relatedModelName = upperCaseFirst(relatedEntity.name)
ManyToMany({
@@ -218,7 +244,8 @@ function defineManyToManyRelationship(
*/
function defineRelationship(
MikroORMEntity: EntityConstructor<any>,
relationship: RelationshipMetadata
relationship: RelationshipMetadata,
cascades: EntityCascades<string[]>
) {
/**
* We expect the relationship.entity to be a function that
@@ -248,27 +275,36 @@ function defineRelationship(
)
}
/**
* Converting the related entity name (which should be in camelCase)
* to "PascalCase"
*/
const relatedModelName = upperCaseFirst(relatedEntity.name)
/**
* Defining relationships
*/
switch (relationship.type) {
case "hasOne":
defineHasOneRelationship(MikroORMEntity, relationship, relatedEntity)
defineHasOneRelationship(
MikroORMEntity,
relationship,
relatedEntity,
cascades
)
break
case "hasMany":
defineHasManyRelationship(MikroORMEntity, relationship, relatedEntity)
defineHasManyRelationship(
MikroORMEntity,
relationship,
relatedEntity,
cascades
)
break
case "belongsTo":
defineBelongsToRelationship(MikroORMEntity, relationship, relatedEntity)
break
case "manyToMany":
defineManyToManyRelationship(MikroORMEntity, relationship, relatedEntity)
defineManyToManyRelationship(
MikroORMEntity,
relationship,
relatedEntity,
cascades
)
break
}
}
@@ -279,12 +315,13 @@ function defineRelationship(
* @todo: Handle soft deleted indexes and filters
* @todo: Finalize if custom pivot entities are needed
*/
export function createMikrORMEntity<
T extends DmlEntity<Record<string, SchemaType<any> | RelationshipType<any>>>
>(entity: T): Infer<T> {
export function createMikrORMEntity<T extends DmlEntity<any>>(
entity: T
): Infer<T> {
class MikroORMEntity {}
const { name, schema, cascades } = entity.parse()
const className = upperCaseFirst(entity.name)
const className = upperCaseFirst(name)
const tableName = pluralize(camelToSnakeCase(className))
/**
@@ -300,12 +337,12 @@ export function createMikrORMEntity<
/**
* Processing schema fields
*/
Object.keys(entity.schema).forEach((property) => {
const field = entity.schema[property].parse(property)
Object.entries(schema).forEach(([name, property]) => {
const field = property.parse(name)
if ("fieldName" in field) {
defineProperty(MikroORMEntity, field)
} else {
defineRelationship(MikroORMEntity, field)
defineRelationship(MikroORMEntity, field, cascades)
}
})

View File

@@ -2,6 +2,7 @@ import {
RelationshipMetadata,
RelationshipOptions,
RelationshipType,
RelationshipTypes,
} from "../types"
/**
@@ -11,12 +12,15 @@ import {
export abstract class BaseRelationship<T> implements RelationshipType<T> {
#referencedEntity: T
/**
* Configuration options for the relationship
*/
protected options: RelationshipOptions
/**
* The relationship type.
* Relationship type
*/
protected abstract relationshipType: RelationshipMetadata["type"]
abstract type: RelationshipTypes
/**
* A type-only property to infer the JavScript data-type
@@ -38,7 +42,7 @@ export abstract class BaseRelationship<T> implements RelationshipType<T> {
nullable: false,
mappedBy: this.options.mappedBy,
entity: this.#referencedEntity,
type: this.relationshipType,
type: this.type,
}
}
}

View File

@@ -1,9 +1,9 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
import { NullableModifier } from "../modifiers/nullable"
import { RelationshipTypes } from "../types"
import { NullableModifier } from "./nullable"
export class BelongsTo<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "belongsTo"
type = "belongsTo" as const
/**
* Apply nullable modifier on the schema

View File

@@ -1,5 +1,5 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
import { RelationshipTypes } from "../types"
/**
* HasMany relationship defines a relationship between two entities
@@ -12,5 +12,5 @@ import { RelationshipMetadata } from "../types"
* - A user HasMany addresses
*/
export class HasMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasMany"
type = "hasMany" as const
}

View File

@@ -1,6 +1,6 @@
import { BaseRelationship } from "./base"
import { NullableModifier } from "../modifiers/nullable"
import { RelationshipMetadata } from "../types"
import { NullableModifier } from "./nullable"
import { RelationshipTypes } from "../types"
/**
* HasOne relationship defines a relationship between two entities
@@ -13,7 +13,7 @@ import { RelationshipMetadata } from "../types"
* of the "HasOne" relationship
*/
export class HasOne<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "hasOne"
type = "hasOne" as const
/**
* Apply nullable modifier on the schema

View File

@@ -1,5 +1,5 @@
import { BaseRelationship } from "./base"
import { RelationshipMetadata } from "../types"
import { RelationshipTypes } from "../types"
/**
* ManyToMany relationship defines a relationship between two entities
@@ -13,5 +13,5 @@ import { RelationshipMetadata } from "../types"
* relationship between two entities
*/
export class ManyToMany<T> extends BaseRelationship<T> {
protected relationshipType: RelationshipMetadata["type"] = "manyToMany"
type = "manyToMany" as const
}

View File

@@ -0,0 +1,36 @@
import { RelationshipType, SchemaType } from "../types"
/**
* Nullable modifier marks a schema node as nullable
*/
export class NullableModifier<T, Relation extends RelationshipType<T>>
implements RelationshipType<T | null>
{
declare type: RelationshipType<T>["type"]
/**
* A type-only property to infer the JavScript data-type
* of the schema property
*/
declare $dataType: T | null
/**
* The parent schema on which the nullable modifier is
* applied
*/
#relation: Relation
constructor(relation: Relation) {
this.#relation = relation
this.type = relation.type
}
/**
* Returns the serialized metadata
*/
parse(fieldName: string) {
const relation = this.#relation.parse(fieldName)
relation.nullable = true
return relation
}
}

View File

@@ -1,5 +1,5 @@
import { SchemaMetadata, SchemaType } from "../types"
import { NullableModifier } from "../modifiers/nullable"
import { NullableModifier } from "./nullable"
/**
* The base schema class offers shared affordances to define

View File

@@ -12,14 +12,13 @@ export type KnownDataTypes =
| "json"
/**
* The available on Delete actions
* List of available relationships at DML level
*/
export type OnDeleteActions =
| "cascade"
| "no action"
| "set null"
| "set default"
| (string & {})
export type RelationshipTypes =
| "hasOne"
| "hasMany"
| "belongsTo"
| "manyToMany"
/**
* Any field that contains "nullable" and "optional" properties
@@ -72,7 +71,7 @@ export type RelationshipOptions = {
*/
export type RelationshipMetadata = MaybeFieldMetadata & {
name: string
type: "hasOne" | "hasMany" | "belongsTo" | "manyToMany"
type: RelationshipTypes
entity: unknown
mappedBy?: string
}
@@ -84,6 +83,7 @@ export type RelationshipMetadata = MaybeFieldMetadata & {
*/
export type RelationshipType<T> = {
$dataType: T
type: RelationshipTypes
parse(relationshipName: string): RelationshipMetadata
}
@@ -108,3 +108,25 @@ export type Infer<T> = T extends DmlEntity<infer Schema>
: Schema[K]["$dataType"]
}>
: never
/**
* Extracts names of relationships from a schema
*/
export type ExtractEntityRelations<
Schema extends Record<string, any>,
OfType extends RelationshipTypes
> = {
[K in keyof Schema & string]: Schema[K] extends RelationshipType<any>
? Schema[K] extends { type: OfType }
? K
: never
: never
}[keyof Schema & string][]
/**
* The actions to cascade from a given entity to its
* relationship.
*/
export type EntityCascades<Relationships> = {
delete?: Relationships
}