diff --git a/.changeset/rude-knives-wait.md b/.changeset/rude-knives-wait.md new file mode 100644 index 0000000000..6cf278a445 --- /dev/null +++ b/.changeset/rude-knives-wait.md @@ -0,0 +1,5 @@ +--- +"@medusajs/utils": patch +--- + +chore(util): Detect circular dependencies on soft delete diff --git a/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts b/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts new file mode 100644 index 0000000000..c4f28a95f6 --- /dev/null +++ b/packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts @@ -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(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(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(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, +} diff --git a/packages/utils/src/dal/mikro-orm/__tests__/utils.spec.ts b/packages/utils/src/dal/mikro-orm/__tests__/utils.spec.ts new file mode 100644 index 0000000000..b1e14f63a5 --- /dev/null +++ b/packages/utils/src/dal/mikro-orm/__tests__/utils.spec.ts @@ -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() + }) + }) +}) diff --git a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts index 745b047795..0f85aaa715 100644 --- a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts @@ -30,6 +30,7 @@ import { transactionWrapper, } from "../utils" import { mikroOrmSerializer, mikroOrmUpdateDeletedAtRecursively } from "./utils" +import { SqlEntityManager } from "@mikro-orm/postgresql" export class MikroOrmBase { readonly manager_: any @@ -137,7 +138,7 @@ export class MikroOrmBaseRepository const entities = await this.find({ where: filter as any }, sharedContext) const date = new Date() - const manager = this.getActiveManager(sharedContext) + const manager = this.getActiveManager(sharedContext) await mikroOrmUpdateDeletedAtRecursively( manager, entities as any[], @@ -173,7 +174,7 @@ export class MikroOrmBaseRepository const entities = await this.find(query, sharedContext) - const manager = this.getActiveManager(sharedContext) + const manager = this.getActiveManager(sharedContext) await mikroOrmUpdateDeletedAtRecursively(manager, entities as any[], null) const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({ diff --git a/packages/utils/src/dal/mikro-orm/utils.ts b/packages/utils/src/dal/mikro-orm/utils.ts index 43a5a5efa2..4596bdcb85 100644 --- a/packages/utils/src/dal/mikro-orm/utils.ts +++ b/packages/utils/src/dal/mikro-orm/utils.ts @@ -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 = 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( + 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 + ) + } + + 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) } }