chore(util): Detect circular dependencies on soft delete (#6489)
This commit is contained in:
committed by
GitHub
parent
5b85c3103e
commit
168f02f138
5
.changeset/rude-knives-wait.md
Normal file
5
.changeset/rude-knives-wait.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
chore(util): Detect circular dependencies on soft delete
|
||||
201
packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts
Normal file
201
packages/utils/src/dal/mikro-orm/__fixtures__/utils.ts
Normal 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,
|
||||
}
|
||||
123
packages/utils/src/dal/mikro-orm/__tests__/utils.spec.ts
Normal file
123
packages/utils/src/dal/mikro-orm/__tests__/utils.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user