chore(util): Detect circular dependencies on soft delete (#6489)

This commit is contained in:
Adrien de Peretti
2024-02-24 14:21:43 +01:00
committed by GitHub
parent 5b85c3103e
commit 168f02f138
5 changed files with 441 additions and 62 deletions

View File

@@ -0,0 +1,201 @@
import {
Collection,
Entity,
ManyToOne,
OneToMany,
PrimaryKey,
Property,
} from "@mikro-orm/core"
// Circular dependency one level
@Entity()
class RecursiveEntity1 {
constructor(props: { id: string; deleted_at: Date | null }) {
this.id = props.id
this.deleted_at = props.deleted_at
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@OneToMany(() => RecursiveEntity2, (entity2) => entity2.entity1, {
cascade: ["soft-remove"] as any,
})
entity2 = new Collection<RecursiveEntity2>(this)
}
@Entity()
class RecursiveEntity2 {
constructor(props: {
id: string
deleted_at: Date | null
entity1: RecursiveEntity1
}) {
this.id = props.id
this.deleted_at = props.deleted_at
this.entity1 = props.entity1
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@ManyToOne(() => RecursiveEntity1, {
cascade: ["soft-remove"] as any,
})
entity1: RecursiveEntity1
}
// No circular dependency
@Entity()
class Entity1 {
constructor(props: { id: string; deleted_at: Date | null }) {
this.id = props.id
this.deleted_at = props.deleted_at
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@OneToMany(() => Entity2, (entity2) => entity2.entity1, {
cascade: ["soft-remove"] as any,
})
entity2 = new Collection<Entity2>(this)
}
@Entity()
class Entity2 {
constructor(props: {
id: string
deleted_at: Date | null
entity1: Entity1
}) {
this.id = props.id
this.deleted_at = props.deleted_at
this.entity1 = props.entity1
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@ManyToOne(() => Entity1)
entity1: Entity1
}
// Circular dependency deep level
@Entity()
class DeepRecursiveEntity1 {
constructor(props: { id: string; deleted_at: Date | null }) {
this.id = props.id
this.deleted_at = props.deleted_at
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@OneToMany(() => DeepRecursiveEntity2, (entity2) => entity2.entity1, {
cascade: ["soft-remove"] as any,
})
entity2 = new Collection<DeepRecursiveEntity2>(this)
}
@Entity()
class DeepRecursiveEntity2 {
constructor(props: {
id: string
deleted_at: Date | null
entity1: DeepRecursiveEntity1
entity3: DeepRecursiveEntity3
}) {
this.id = props.id
this.deleted_at = props.deleted_at
this.entity3 = props.entity3
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@ManyToOne(() => DeepRecursiveEntity1)
entity1: DeepRecursiveEntity1
@ManyToOne(() => DeepRecursiveEntity3, {
cascade: ["soft-remove"] as any,
})
entity3: DeepRecursiveEntity3
}
@Entity()
class DeepRecursiveEntity3 {
constructor(props: {
id: string
deleted_at: Date | null
entity1: DeepRecursiveEntity1
}) {
this.id = props.id
this.deleted_at = props.deleted_at
this.entity1 = props.entity1
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@ManyToOne(() => DeepRecursiveEntity1, {
cascade: ["soft-remove"] as any,
})
entity1: DeepRecursiveEntity1
}
@Entity()
class DeepRecursiveEntity4 {
constructor(props: {
id: string
deleted_at: Date | null
entity1: DeepRecursiveEntity1
}) {
this.id = props.id
this.deleted_at = props.deleted_at
this.entity1 = props.entity1
}
@PrimaryKey()
id: string
@Property()
deleted_at: Date | null
@ManyToOne(() => DeepRecursiveEntity1)
entity1: DeepRecursiveEntity1
}
export {
RecursiveEntity1,
RecursiveEntity2,
Entity1,
Entity2,
DeepRecursiveEntity1,
DeepRecursiveEntity2,
DeepRecursiveEntity3,
DeepRecursiveEntity4,
}

View File

@@ -0,0 +1,123 @@
import { mikroOrmUpdateDeletedAtRecursively } from "../utils"
import { MikroORM } from "@mikro-orm/core"
import { SqlEntityManager } from "@mikro-orm/postgresql"
import {
DeepRecursiveEntity1,
DeepRecursiveEntity2,
DeepRecursiveEntity3,
DeepRecursiveEntity4,
Entity1,
Entity2,
RecursiveEntity1,
RecursiveEntity2,
} from "../__fixtures__/utils"
jest.mock("@mikro-orm/core", () => ({
...jest.requireActual("@mikro-orm/core"),
wrap: jest.fn().mockImplementation((entity) => ({
...entity,
init: jest.fn().mockResolvedValue(entity),
__helper: {
isInitialized: jest.fn().mockReturnValue(true),
},
})),
}))
describe("mikroOrmUpdateDeletedAtRecursively", () => {
describe("using circular cascading", () => {
let orm!: MikroORM
beforeEach(async () => {
orm = await MikroORM.init({
entities: [
Entity1,
Entity2,
RecursiveEntity1,
RecursiveEntity2,
DeepRecursiveEntity1,
DeepRecursiveEntity2,
DeepRecursiveEntity3,
DeepRecursiveEntity4,
],
dbName: "test",
type: "postgresql",
})
})
afterEach(async () => {
await orm.close()
})
it("should successfully mark the entities deleted_at recursively", async () => {
const manager = orm.em.fork() as SqlEntityManager
const entity1 = new Entity1({ id: "1", deleted_at: null })
const entity2 = new Entity2({
id: "2",
deleted_at: null,
entity1: entity1,
})
entity1.entity2.add(entity2)
const deletedAt = new Date()
await mikroOrmUpdateDeletedAtRecursively(manager, [entity1], deletedAt)
expect(entity1.deleted_at).toEqual(deletedAt)
expect(entity2.deleted_at).toEqual(deletedAt)
})
it("should throw an error when a circular dependency is detected", async () => {
const manager = orm.em.fork() as SqlEntityManager
const entity1 = new RecursiveEntity1({ id: "1", deleted_at: null })
const entity2 = new RecursiveEntity2({
id: "2",
deleted_at: null,
entity1: entity1,
})
await expect(
mikroOrmUpdateDeletedAtRecursively(manager, [entity2], new Date())
).rejects.toThrow(
"Unable to soft delete the entity1. Circular dependency detected: RecursiveEntity2 -> entity1 -> RecursiveEntity1 -> entity2 -> RecursiveEntity2"
)
})
it("should throw an error when a circular dependency is detected even at a deeper level", async () => {
const manager = orm.em.fork() as SqlEntityManager
const entity1 = new DeepRecursiveEntity1({ id: "1", deleted_at: null })
const entity3 = new DeepRecursiveEntity3({
id: "3",
deleted_at: null,
entity1: entity1,
})
const entity2 = new DeepRecursiveEntity2({
id: "2",
deleted_at: null,
entity1: entity1,
entity3: entity3,
})
const entity4 = new DeepRecursiveEntity4({
id: "4",
deleted_at: null,
entity1: entity1,
})
await expect(
mikroOrmUpdateDeletedAtRecursively(manager, [entity1], new Date())
).rejects.toThrow(
"Unable to soft delete the entity2. Circular dependency detected: DeepRecursiveEntity1 -> entity2 -> DeepRecursiveEntity2 -> entity3 -> DeepRecursiveEntity3 -> entity1 -> DeepRecursiveEntity1"
)
await expect(
mikroOrmUpdateDeletedAtRecursively(manager, [entity2], new Date())
).rejects.toThrow(
"Unable to soft delete the entity3. Circular dependency detected: DeepRecursiveEntity2 -> entity3 -> DeepRecursiveEntity3 -> entity1 -> DeepRecursiveEntity1 -> entity2 -> DeepRecursiveEntity2"
)
await mikroOrmUpdateDeletedAtRecursively(manager, [entity4], new Date())
expect(entity4.deleted_at).not.toBeNull()
expect(entity1.deleted_at).toBeNull()
expect(entity2.deleted_at).toBeNull()
expect(entity3.deleted_at).toBeNull()
})
})
})

View File

@@ -30,6 +30,7 @@ import {
transactionWrapper,
} from "../utils"
import { mikroOrmSerializer, mikroOrmUpdateDeletedAtRecursively } from "./utils"
import { SqlEntityManager } from "@mikro-orm/postgresql"
export class MikroOrmBase<T = any> {
readonly manager_: any
@@ -137,7 +138,7 @@ export class MikroOrmBaseRepository<T extends object = object>
const entities = await this.find({ where: filter as any }, sharedContext)
const date = new Date()
const manager = this.getActiveManager(sharedContext)
const manager = this.getActiveManager<SqlEntityManager>(sharedContext)
await mikroOrmUpdateDeletedAtRecursively<T>(
manager,
entities as any[],
@@ -173,7 +174,7 @@ export class MikroOrmBaseRepository<T extends object = object>
const entities = await this.find(query, sharedContext)
const manager = this.getActiveManager(sharedContext)
const manager = this.getActiveManager<SqlEntityManager>(sharedContext)
await mikroOrmUpdateDeletedAtRecursively(manager, entities as any[], null)
const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({

View File

@@ -1,74 +1,123 @@
import { buildQuery } from "../../modules-sdk"
import { EntityMetadata, FindOptions, wrap } from "@mikro-orm/core"
import { SqlEntityManager } from "@mikro-orm/postgresql"
function detectCircularDependency(
manager: SqlEntityManager,
entityMetadata: EntityMetadata,
visited: Set<string> = new Set()
) {
visited.add(entityMetadata.className)
const relations = entityMetadata.relations
const relationsToCascade = relations.filter((relation) =>
relation.cascade.includes("soft-remove" as any)
)
for (const relation of relationsToCascade) {
const branchVisited = new Set(Array.from(visited))
if (branchVisited.has(relation.name)) {
const dependencies = Array.from(visited)
dependencies.push(entityMetadata.className)
const circularDependencyStr = dependencies.join(" -> ")
throw new Error(
`Unable to soft delete the ${relation.name}. Circular dependency detected: ${circularDependencyStr}`
)
}
branchVisited.add(relation.name)
const relationEntityMetadata = manager
.getDriver()
.getMetadata()
.get(relation.type)
detectCircularDependency(manager, relationEntityMetadata, branchVisited)
}
}
async function performCascadingSoftDeletion<T>(
manager: SqlEntityManager,
entity: T & { id: string; deleted_at?: string | Date | null },
value: Date | null
) {
if (!("deleted_at" in entity)) return
entity.deleted_at = value
const entityName = entity.constructor.name
const relations = manager.getDriver().getMetadata().get(entityName).relations
const relationsToCascade = relations.filter((relation) =>
relation.cascade.includes("soft-remove" as any)
)
for (const relation of relationsToCascade) {
let entityRelation = entity[relation.name]
// Handle optional relationships
if (relation.nullable && !entityRelation) {
continue
}
const retrieveEntity = async () => {
const query = buildQuery(
{
id: entity.id,
},
{
relations: [relation.name],
withDeleted: true,
}
)
return await manager.findOne(
entity.constructor.name,
query.where,
query.options as FindOptions<any>
)
}
if (!entityRelation) {
// Fixes the case of many to many through pivot table
entityRelation = await retrieveEntity()
}
const isCollection = "toArray" in entityRelation
let relationEntities: any[] = []
if (isCollection) {
if (!entityRelation.isInitialized()) {
entityRelation = await retrieveEntity()
entityRelation = entityRelation[relation.name]
}
relationEntities = entityRelation.getItems()
} else {
const initializedEntityRelation = await wrap(entityRelation).init()
relationEntities = [initializedEntityRelation]
}
await mikroOrmUpdateDeletedAtRecursively(manager, relationEntities, value)
}
await manager.persist(entity)
}
export const mikroOrmUpdateDeletedAtRecursively = async <
T extends object = any
>(
manager: any,
manager: SqlEntityManager,
entities: (T & { id: string; deleted_at?: string | Date | null })[],
value: Date | null
) => {
for (const entity of entities) {
if (!("deleted_at" in entity)) continue
entity.deleted_at = value
const relations = manager
const entityMetadata = manager
.getDriver()
.getMetadata()
.get(entity.constructor.name).relations
const relationsToCascade = relations.filter((relation) =>
relation.cascade.includes("soft-remove" as any)
)
for (const relation of relationsToCascade) {
let entityRelation = entity[relation.name]
// Handle optional relationships
if (relation.nullable && !entityRelation) {
continue
}
const retrieveEntity = async () => {
const query = buildQuery(
{
id: entity.id,
},
{
relations: [relation.name],
withDeleted: true,
}
)
return await manager.findOne(
entity.constructor.name,
query.where,
query.options
)
}
if (!entityRelation) {
// Fixes the case of many to many through pivot table
entityRelation = await retrieveEntity()
}
const isCollection = "toArray" in entityRelation
let relationEntities: any[] = []
if (isCollection) {
if (!entityRelation.isInitialized()) {
entityRelation = await retrieveEntity()
entityRelation = entityRelation[relation.name]
}
relationEntities = entityRelation.getItems()
} else {
const initializedEntityRelation = await entityRelation.__helper?.init()
relationEntities = [initializedEntityRelation]
}
await mikroOrmUpdateDeletedAtRecursively(manager, relationEntities, value)
}
await manager.persist(entity)
.get(entity.constructor.name)
detectCircularDependency(manager, entityMetadata)
await performCascadingSoftDeletion(manager, entity, value)
}
}