Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 14 commits into from
Feb 24, 2024
5 changes: 5 additions & 0 deletions .changeset/rude-knives-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/utils": patch
---

chore(util): Detect circular dependencies on soft delete
201 changes: 201 additions & 0 deletions packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
}
123 changes: 123 additions & 0 deletions packages/utils/src/dal/mikro-orm/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
5 changes: 3 additions & 2 deletions packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading