chore(): Improve cascade soft deletetion/restoration and update (#11618)

**What**
- Fix soft deletion and restoration emitted events
- Improve soft deleted/restore algorithm
- Fix big number field handling null value during partial hydration from mikro orm
This commit is contained in:
Adrien de Peretti
2025-02-26 18:01:36 +00:00
committed by GitHub
parent caf83cf78c
commit d254b2ddba
9 changed files with 337 additions and 202 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/utils": patch
---
chore(): Improve cascade soft deletetion and update
@@ -29,34 +29,42 @@ export function MikroOrmBigNumberProperty(
this.__helper.__data[rawColumnName] = null this.__helper.__data[rawColumnName] = null
this[rawColumnName] = null this[rawColumnName] = null
} else { } else {
let bigNumber: BigNumber // When mikro orm create and hydrate the entity with partial selection it can happen
// that null value is being passed.
if (!options?.nullable && value === null) {
this.__helper.__data[columnName] = undefined
this.__helper.__data[rawColumnName] = undefined
this[rawColumnName] = undefined
} else {
let bigNumber: BigNumber
try { try {
if (value instanceof BigNumber) { if (value instanceof BigNumber) {
bigNumber = value bigNumber = value
} else if (this[rawColumnName]) { } else if (this[rawColumnName]) {
const precision = this[rawColumnName].precision const precision = this[rawColumnName].precision
bigNumber = new BigNumber(value, { bigNumber = new BigNumber(value, {
precision, precision,
}) })
} else { } else {
bigNumber = new BigNumber(value) bigNumber = new BigNumber(value)
}
} catch (e) {
throw new Error(`Cannot set value ${value} for ${columnName}.`)
} }
} catch (e) {
throw new Error(`Cannot set value ${value} for ${columnName}.`) const raw = bigNumber.raw!
raw.value = trimZeros(raw.value as string)
// Note: this.__helper isn't present when directly working with the entity
// Adding this in optionally for it not to break.
if (isDefined(this.__helper)) {
this.__helper.__data[columnName] = bigNumber.numeric
this.__helper.__data[rawColumnName] = raw
}
this[rawColumnName] = raw
} }
const raw = bigNumber.raw!
raw.value = trimZeros(raw.value as string)
// Note: this.__helper isn't present when directly working with the entity
// Adding this in optionally for it not to break.
if (isDefined(this.__helper)) {
this.__helper.__data[columnName] = bigNumber.numeric
this.__helper.__data[rawColumnName] = raw
}
this[rawColumnName] = raw
} }
// Note: this.__helper isn't present when directly working with the entity // Note: this.__helper isn't present when directly working with the entity
@@ -31,10 +31,7 @@ import {
} from "../../common" } from "../../common"
import { toMikroORMEntity } from "../../dml" import { toMikroORMEntity } from "../../dml"
import { buildQuery } from "../../modules-sdk/build-query" import { buildQuery } from "../../modules-sdk/build-query"
import { import { transactionWrapper } from "../utils"
getSoftDeletedCascadedEntitiesIdsMappedBy,
transactionWrapper,
} from "../utils"
import { dbErrorMapper } from "./db-error-mapper" import { dbErrorMapper } from "./db-error-mapper"
import { mikroOrmSerializer } from "./mikro-orm-serializer" import { mikroOrmSerializer } from "./mikro-orm-serializer"
import { mikroOrmUpdateDeletedAtRecursively } from "./utils" import { mikroOrmUpdateDeletedAtRecursively } from "./utils"
@@ -215,17 +212,13 @@ export class MikroOrmBaseRepository<const T extends object = object>
const date = new Date() const date = new Date()
const manager = this.getActiveManager<SqlEntityManager>(sharedContext) const manager = this.getActiveManager<SqlEntityManager>(sharedContext)
await mikroOrmUpdateDeletedAtRecursively<T>( const softDeletedEntitiesMap = await mikroOrmUpdateDeletedAtRecursively<T>(
manager, manager,
entities as any[], entities as any[],
date date
) )
const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({ return [entities, Object.fromEntries(softDeletedEntitiesMap)]
entities,
})
return [entities, softDeletedEntitiesMap]
} }
async restore( async restore(
@@ -239,14 +232,13 @@ export class MikroOrmBaseRepository<const T extends object = object>
const entities = await this.find(query, sharedContext) const entities = await this.find(query, sharedContext)
const manager = this.getActiveManager<SqlEntityManager>(sharedContext) const manager = this.getActiveManager<SqlEntityManager>(sharedContext)
await mikroOrmUpdateDeletedAtRecursively(manager, entities as any[], null) const softDeletedEntitiesMap = await mikroOrmUpdateDeletedAtRecursively(
manager,
entities as any[],
null
)
const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({ return [entities, Object.fromEntries(softDeletedEntitiesMap)]
entities,
restored: true,
})
return [entities, softDeletedEntitiesMap]
} }
} }
@@ -398,38 +390,41 @@ export function mikroOrmBaseRepositoryFactory<const T extends object>(
descriptor, descriptor,
] of collectionsToRemoveAllFrom) { ] of collectionsToRemoveAllFrom) {
await promiseAll( await promiseAll(
data.map(async ({ entity }) => { data.flatMap(async ({ entity }) => {
if (!descriptor.mappedBy) { if (!descriptor.mappedBy) {
return await entity[collectionToRemoveAllFrom].init() return await entity[collectionToRemoveAllFrom].init()
} }
const promises: Promise<any>[] = []
await entity[collectionToRemoveAllFrom].init() await entity[collectionToRemoveAllFrom].init()
const items = entity[collectionToRemoveAllFrom] const items = entity[collectionToRemoveAllFrom]
for (const item of items) { for (const item of items) {
await item[descriptor.mappedBy!].init() promises.push(item[descriptor.mappedBy!].init())
} }
return promises
}) })
) )
} }
} }
async update( async update(
data: { entity; update }[], data: { entity: any; update: any }[],
context?: Context context?: Context
): Promise<InferRepositoryReturnType<T>[]> { ): Promise<InferRepositoryReturnType<T>[]> {
const manager = this.getActiveManager<EntityManager>(context) const manager = this.getActiveManager<EntityManager>(context)
await this.initManyToManyToDetachAllItemsIfNeeded(data, context) await this.initManyToManyToDetachAllItemsIfNeeded(data, context)
data.map((_, index) => { data.forEach(({ entity, update }) => {
manager.assign(data[index].entity, data[index].update, { manager.assign(entity, update, {
mergeObjectProperties: true, mergeObjectProperties: true,
}) })
manager.persist(data[index].entity) manager.persist(entity)
}) })
return data.map((d) => d.entity) return data.map((d) => d.entity) as InferRepositoryReturnType<T>[]
} }
async delete( async delete(
+126 -51
View File
@@ -1,7 +1,8 @@
import { Collection, EntityMetadata, FindOptions, wrap } from "@mikro-orm/core" import { EntityMetadata, FindOptions } from "@mikro-orm/core"
import { SqlEntityManager } from "@mikro-orm/postgresql" import { SqlEntityManager } from "@mikro-orm/postgresql"
import { buildQuery } from "../../modules-sdk/build-query" import { promiseAll } from "../../common"
import { isString } from "../../common/is-string" import { isString } from "../../common/is-string"
import { buildQuery } from "../../modules-sdk/build-query"
function detectCircularDependency( function detectCircularDependency(
manager: SqlEntityManager, manager: SqlEntityManager,
@@ -60,47 +61,80 @@ function detectCircularDependency(
async function performCascadingSoftDeletion<T>( async function performCascadingSoftDeletion<T>(
manager: SqlEntityManager, manager: SqlEntityManager,
entity: T & { id: string; deleted_at?: string | Date | null }, entity: T & { id: string; deleted_at?: string | Date | null },
value: Date | null value: Date | null,
softDeletedEntitiesMap: Map<
string,
(T & { id: string; deleted_at?: string | Date | null })[]
> = new Map()
) { ) {
if (!("deleted_at" in entity)) return if (!("deleted_at" in entity)) return
entity.deleted_at = value entity.deleted_at = value
const entityName = entity.constructor.name const softDeletedEntityMapItem = softDeletedEntitiesMap.get(
entity.constructor.name
)
if (!softDeletedEntityMapItem) {
softDeletedEntitiesMap.set(entity.constructor.name, [entity])
} else {
softDeletedEntityMapItem.push(entity)
}
const relations = manager.getDriver().getMetadata().get(entityName).relations const entityName = entity.constructor.name
const entityMetadata = manager.getDriver().getMetadata().get(entityName)
const relations = entityMetadata.relations
const relationsToCascade = relations.filter((relation) => const relationsToCascade = relations.filter((relation) =>
relation.cascade?.includes("soft-remove" as any) relation.cascade?.includes("soft-remove" as any)
) )
// If there are no relations to cascade, just persist the entity and return
if (!relationsToCascade.length) {
manager.persist(entity)
return
}
// Fetch the entity with all cascading relations in a single query
const relationNames = relationsToCascade.map((r) => r.name)
const query = buildQuery(
{
id: entity.id,
},
{
select: [
"id",
"deleted_at",
...relationNames.flatMap((r) => [`${r}.id`, `${r}.deleted_at`]),
],
relations: relationNames,
withDeleted: true,
}
)
const entityWithRelations = await manager.findOne(entityName, query.where, {
...query.options,
populateFilter: {
withDeleted: true,
},
} as FindOptions<any>)
if (!entityWithRelations) {
manager.persist(entity)
return
}
// Create a map to group related entities by their type
const relatedEntitiesByType = new Map<
string,
T & { id: string; deleted_at?: string | Date | null }[]
>()
// Collect all related entities by type
for (const relation of relationsToCascade) { for (const relation of relationsToCascade) {
let entityRelation = entity[relation.name] const entityRelation = entityWithRelations[relation.name]
// Handle optional relationships // Skip if relation is null or undefined
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>
)
}
entityRelation = await retrieveEntity()
entityRelation = entityRelation[relation.name]
if (!entityRelation) { if (!entityRelation) {
continue continue
} }
@@ -109,45 +143,86 @@ async function performCascadingSoftDeletion<T>(
let relationEntities: any[] = [] let relationEntities: any[] = []
if (isCollection) { if (isCollection) {
if (!(entityRelation as Collection<any, any>).isInitialized()) {
entityRelation = await retrieveEntity()
entityRelation = entityRelation[relation.name]
}
relationEntities = entityRelation.getItems() relationEntities = entityRelation.getItems()
} else { } else {
const wrappedEntity = wrap(entityRelation) relationEntities = [entityRelation]
let initializedEntityRelation = entityRelation
if (!wrappedEntity.isInitialized()) {
initializedEntityRelation = await wrap(entityRelation).init()
}
relationEntities = [initializedEntityRelation]
} }
if (!relationEntities.length) { if (!relationEntities.length) {
continue continue
} }
await mikroOrmUpdateDeletedAtRecursively(manager, relationEntities, value) // Add to the map of entities by type
if (!relatedEntitiesByType.has(relation.type)) {
relatedEntitiesByType.set(relation.type, [] as any)
}
relatedEntitiesByType.get(relation.type)!.push(...relationEntities)
} }
await manager.persist(entity) // Process each type of related entity in batch
const promises: Promise<void>[] = []
for (const [, entities] of relatedEntitiesByType.entries()) {
if (entities.length === 0) continue
// Process cascading relations for these entities
promises.push(
...entities.map((entity) =>
performCascadingSoftDeletion(
manager,
entity as any,
value,
softDeletedEntitiesMap
)
)
)
}
await promiseAll(promises)
manager.persist(entity)
} }
/**
* Updates the deleted_at field for all entities in the given array and their
* cascaded relations and returns a map of entity IDs to their corresponding
* entity types.
*
* @param manager - The Mikro ORM manager instance.
* @param entities - An array of entities to update.
* @param value - The value to set for the deleted_at field.
* @returns A map of entity IDs to their corresponding entity types.
*/
export const mikroOrmUpdateDeletedAtRecursively = async < export const mikroOrmUpdateDeletedAtRecursively = async <
T extends object = any T extends object = any
>( >(
manager: SqlEntityManager, manager: SqlEntityManager,
entities: (T & { id: string; deleted_at?: string | Date | null })[], entities: (T & { id: string; deleted_at?: string | Date | null })[],
value: Date | null value: Date | null
) => { ): Promise<
Map<string, (T & { id: string; deleted_at?: string | Date | null })[]>
> => {
const softDeletedEntitiesMap = new Map<
string,
(T & { id: string; deleted_at?: string | Date | null })[]
>()
if (!entities.length) return softDeletedEntitiesMap
const entityMetadata = manager
.getDriver()
.getMetadata()
.get(entities[0].constructor.name)
detectCircularDependency(manager, entityMetadata)
// Process each entity type
for (const entity of entities) { for (const entity of entities) {
const entityMetadata = manager await performCascadingSoftDeletion(
.getDriver() manager,
.getMetadata() entity,
.get(entity.constructor.name) value,
detectCircularDependency(manager, entityMetadata) softDeletedEntitiesMap
await performCascadingSoftDeletion(manager, entity, value) )
} }
return softDeletedEntitiesMap
} }
-54
View File
@@ -1,5 +1,3 @@
import { isObject } from "../common"
export async function transactionWrapper<TManager = unknown>( export async function transactionWrapper<TManager = unknown>(
manager: any, manager: any,
task: (transactionManager: any) => Promise<any>, task: (transactionManager: any) => Promise<any>,
@@ -32,58 +30,6 @@ export async function transactionWrapper<TManager = unknown>(
return await transactionMethod.bind(manager)(task, options) return await transactionMethod.bind(manager)(task, options)
} }
/**
* Can be used to create a new Object that collect the entities
* based on the columnLookup. This is useful when you want to soft delete entities and return
* an object where the keys are the entities name and the values are the entities
* that were soft deleted.
*
* @param entities
* @param deletedEntitiesMap
* @param getEntityName
*/
export function getSoftDeletedCascadedEntitiesIdsMappedBy({
entities,
deletedEntitiesMap,
getEntityName,
restored,
}: {
entities: any[]
deletedEntitiesMap?: Map<string, any[]>
getEntityName?: (entity: any) => string
restored?: boolean
}): Record<string, any[]> {
deletedEntitiesMap ??= new Map<string, any[]>()
getEntityName ??= (entity) => entity.constructor.name
for (const entity of entities) {
const entityName = getEntityName(entity)
const shouldSkip = !!deletedEntitiesMap
.get(entityName)
?.some((e) => e.id === entity.id)
if ((restored ? !!entity.deleted_at : !entity.deleted_at) || shouldSkip) {
continue
}
const values = deletedEntitiesMap.get(entityName) ?? []
values.push(entity)
deletedEntitiesMap.set(entityName, values)
Object.values(entity).forEach((propValue: any) => {
if (propValue != null && isObject(propValue[0])) {
getSoftDeletedCascadedEntitiesIdsMappedBy({
entities: propValue,
deletedEntitiesMap,
getEntityName,
})
}
})
}
return Object.fromEntries(deletedEntitiesMap)
}
export function normalizeMigrationSQL(sql: string) { export function normalizeMigrationSQL(sql: string) {
sql = sql.replace( sql = sql.replace(
/create table (?!if not exists)/g, /create table (?!if not exists)/g,
@@ -259,7 +259,7 @@ export function MedusaInternalService<
const primaryKeys = AbstractService_.retrievePrimaryKeys(model) const primaryKeys = AbstractService_.retrievePrimaryKeys(model)
const inputArray = Array.isArray(input) ? input : [input] const inputArray = Array.isArray(input) ? input : [input]
const toUpdateData: { entity; update }[] = [] const toUpdateData: { entity: TEntity; update: Partial<TEntity> }[] = []
// Only used when we receive data and no selector // Only used when we receive data and no selector
const keySelectorForDataOnly: any = { const keySelectorForDataOnly: any = {
@@ -353,10 +353,17 @@ export function MedusaInternalService<
// Manage metadata if needed // Manage metadata if needed
toUpdateData.forEach(({ entity, update }) => { toUpdateData.forEach(({ entity, update }) => {
if (isPresent(update.metadata)) { const update_ = update as (typeof toUpdateData)[number]["update"] & {
entity.metadata = update.metadata = mergeMetadata( metadata: Record<string, unknown>
entity.metadata ?? {}, }
update.metadata const entity_ = entity as InferEntityType<TEntity> & {
metadata?: Record<string, unknown>
}
if (isPresent(update_.metadata)) {
entity_.metadata = update_.metadata = mergeMetadata(
entity_.metadata ?? {},
update_.metadata
) )
} }
}) })
@@ -137,6 +137,37 @@ export function MedusaService<
? ModelConfigurationsToConfigTemplate<TModels> ? ModelConfigurationsToConfigTemplate<TModels>
: ModelsConfig : ModelsConfig
> { > {
function emitSoftDeleteRestoreEvents(
this: AbstractModuleService_,
klassPrototype: any,
cascadedModelsMap: Record<string, string[]>,
action: string,
sharedContext: Context
) {
const joinerConfig = (
typeof this.__joinerConfig === "function"
? this.__joinerConfig()
: this.__joinerConfig
) as ModuleJoinerConfig
const emittedEntities = new Set<string>()
Object.entries(cascadedModelsMap).forEach(([linkableKey, ids]) => {
const entity = joinerConfig.linkableKeys?.[linkableKey]!
if (entity && !emittedEntities.has(entity)) {
emittedEntities.add(entity)
const linkableKeyEntity = camelToSnakeCase(entity).toLowerCase()
klassPrototype.aggregatedEvents.bind(this)({
action: action,
object: linkableKeyEntity,
data: { id: ids },
context: sharedContext,
})
}
})
}
const buildAndAssignMethodImpl = function ( const buildAndAssignMethodImpl = function (
klassPrototype: any, klassPrototype: any,
method: string, method: string,
@@ -321,27 +352,11 @@ export function MedusaService<
) )
if (mappedCascadedModelsMap) { if (mappedCascadedModelsMap) {
const joinerConfig = ( emitSoftDeleteRestoreEvents.bind(this)(
typeof this.__joinerConfig === "function" klassPrototype,
? this.__joinerConfig() mappedCascadedModelsMap,
: this.__joinerConfig CommonEvents.DELETED,
) as ModuleJoinerConfig sharedContext
Object.entries(mappedCascadedModelsMap).forEach(
([linkableKey, ids]) => {
const entity = joinerConfig.linkableKeys?.[linkableKey]!
if (entity) {
const linkableKeyEntity =
camelToSnakeCase(entity).toLowerCase()
klassPrototype.aggregatedEvents.bind(this)({
action: CommonEvents.DELETED,
object: linkableKeyEntity,
data: { id: ids },
context: sharedContext,
})
}
}
) )
} }
@@ -378,26 +393,11 @@ export function MedusaService<
) )
if (mappedCascadedModelsMap) { if (mappedCascadedModelsMap) {
const joinerConfig = ( emitSoftDeleteRestoreEvents.bind(this)(
typeof this.__joinerConfig === "function" klassPrototype,
? this.__joinerConfig() mappedCascadedModelsMap,
: this.__joinerConfig CommonEvents.CREATED,
) as ModuleJoinerConfig sharedContext
Object.entries(mappedCascadedModelsMap).forEach(
([linkableKey, ids]) => {
const entity = joinerConfig.linkableKeys?.[linkableKey]!
if (entity) {
const linkableKeyEntity =
camelToSnakeCase(entity).toLowerCase()
klassPrototype.aggregatedEvents.bind(this)({
action: CommonEvents.CREATED,
object: linkableKeyEntity,
data: { id: ids },
context: sharedContext,
})
}
}
) )
} }
@@ -52,7 +52,7 @@ export const buildProductAndRelationsData = ({
options, options,
variants, variants,
collection_id, collection_id,
}: Partial<ProductTypes.CreateProductDTO>) => { }: Partial<ProductTypes.CreateProductDTO> & { tags?: { value: string }[] }) => {
const defaultOptionTitle = "test-option" const defaultOptionTitle = "test-option"
const defaultOptionValue = "test-value" const defaultOptionValue = "test-value"
@@ -30,7 +30,7 @@ import {
createTypes, createTypes,
} from "../../__fixtures__/product" } from "../../__fixtures__/product"
jest.setTimeout(3000000) jest.setTimeout(300000)
moduleIntegrationTestRunner<IProductModuleService>({ moduleIntegrationTestRunner<IProductModuleService>({
moduleName: Modules.PRODUCT, moduleName: Modules.PRODUCT,
@@ -975,6 +975,85 @@ moduleIntegrationTestRunner<IProductModuleService>({
const data = buildProductAndRelationsData({ const data = buildProductAndRelationsData({
images, images,
thumbnail: images[0].url, thumbnail: images[0].url,
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["red", "blue"] },
{ title: "material", values: ["cotton", "polyester"] },
],
variants: [
{
title: "Large Red Cotton",
sku: "LRG-RED-CTN",
options: {
size: "large",
color: "red",
material: "cotton",
},
},
{
title: "Large Red Polyester",
sku: "LRG-RED-PLY",
options: {
size: "large",
color: "red",
material: "polyester",
},
},
{
title: "Large Blue Cotton",
sku: "LRG-BLU-CTN",
options: {
size: "large",
color: "blue",
material: "cotton",
},
},
{
title: "Large Blue Polyester",
sku: "LRG-BLU-PLY",
options: {
size: "large",
color: "blue",
material: "polyester",
},
},
{
title: "Small Red Cotton",
sku: "SML-RED-CTN",
options: {
size: "small",
color: "red",
material: "cotton",
},
},
{
title: "Small Red Polyester",
sku: "SML-RED-PLY",
options: {
size: "small",
color: "red",
material: "polyester",
},
},
{
title: "Small Blue Cotton",
sku: "SML-BLU-CTN",
options: {
size: "small",
color: "blue",
material: "cotton",
},
},
{
title: "Small Blue Polyester",
sku: "SML-BLU-PLY",
options: {
size: "small",
color: "blue",
material: "polyester",
},
},
],
}) })
const products = await service.createProducts([data]) const products = await service.createProducts([data])
@@ -1074,6 +1153,26 @@ moduleIntegrationTestRunner<IProductModuleService>({
source: Modules.PRODUCT, source: Modules.PRODUCT,
action: CommonEvents.DELETED, action: CommonEvents.DELETED,
}), }),
composeMessage(ProductEvents.PRODUCT_VARIANT_DELETED, {
data: { id: [products[0].variants[0].id] },
object: "product_variant",
source: Modules.PRODUCT,
action: CommonEvents.DELETED,
}),
composeMessage(ProductEvents.PRODUCT_OPTION_DELETED, {
data: { id: [products[0].options[0].id] },
object: "product_option",
source: Modules.PRODUCT,
action: CommonEvents.DELETED,
}),
composeMessage(ProductEvents.PRODUCT_OPTION_VALUE_DELETED, {
data: {
id: [products[0].options[0].values[0].id],
},
object: "product_option_value",
source: Modules.PRODUCT,
action: CommonEvents.DELETED,
}),
], ],
{ {
internal: true, internal: true,