From ce3326c5fb71d555d14750dd6c440b9f42cdada5 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Wed, 2 Aug 2023 19:29:01 +0200 Subject: [PATCH] feat(prduct, utils, types): Create soft delete pattern for link module (#4649) * feat(prouct, utils, types): Create soft delete pattern for link module * add comment * add comment * finalise * remove linkable keys * cleanup and tests * cleanup * add some comments and renaming * re work * fix tests --------- Co-authored-by: Riqwan Thamir --- .../integration-tests/__tests__/module.ts | 1 - .../__tests__/services/product/index.ts | 80 +++++++++++------ packages/product/src/joiner-config.ts | 54 +++++++++++ .../product/src/services/product-category.ts | 49 +++++----- .../src/services/product-collection.ts | 35 +++++--- .../src/services/product-module-service.ts | 42 +++++++-- packages/product/src/services/product.ts | 17 ++-- packages/types/src/dal/repository-service.ts | 13 ++- packages/types/src/product/service.ts | 64 ++++++------- .../src/common/__tests__/map-object-to.ts | 43 +++++++++ packages/utils/src/common/index.ts | 1 + packages/utils/src/common/map-object-to.ts | 53 +++++++++++ packages/utils/src/dal/index.ts | 1 + .../src/dal/mikro-orm/mikro-orm-repository.ts | 12 ++- packages/utils/src/dal/mikro-orm/utils.ts | 54 +++++++++++ packages/utils/src/dal/repository.ts | 5 +- packages/utils/src/dal/utils.ts | 90 +++++++++---------- 17 files changed, 448 insertions(+), 166 deletions(-) create mode 100644 packages/utils/src/common/__tests__/map-object-to.ts create mode 100644 packages/utils/src/common/map-object-to.ts create mode 100644 packages/utils/src/dal/mikro-orm/utils.ts diff --git a/packages/product/integration-tests/__tests__/module.ts b/packages/product/integration-tests/__tests__/module.ts index 7e1e27adc1..3b4b1ae8f0 100644 --- a/packages/product/integration-tests/__tests__/module.ts +++ b/packages/product/integration-tests/__tests__/module.ts @@ -230,7 +230,6 @@ describe("Product module", function () { const products = await module.create([data]) await module.softDelete([products[0].id]) - const deletedProducts = await module.list( { id: products[0].id }, { diff --git a/packages/product/integration-tests/__tests__/services/product/index.ts b/packages/product/integration-tests/__tests__/services/product/index.ts index 347caea3c4..7f989ee2d4 100644 --- a/packages/product/integration-tests/__tests__/services/product/index.ts +++ b/packages/product/integration-tests/__tests__/services/product/index.ts @@ -1,6 +1,7 @@ import { Image, Product, ProductCategory, ProductVariant } from "@models" import { assignCategoriesToProduct, + buildProductOnlyData, createImages, createProductAndTags, createProductVariants, @@ -16,7 +17,6 @@ import { ProductRepository } from "@repositories" import { ProductService } from "@services" import { SqlEntityManager } from "@mikro-orm/postgresql" import { TestDatabase } from "../../../utils" -import { buildProductOnlyData } from "../../../__fixtures__/product/data/create-product" import { createProductCategories } from "../../../__fixtures__/product-category" import { kebabCase } from "@medusajs/utils" @@ -81,15 +81,19 @@ describe("Product Service", () => { error = e } - expect(error.message).toEqual('Product with id: does-not-exist was not found') + expect(error.message).toEqual( + "Product with id: does-not-exist was not found" + ) }) it("should return a product when product with an id exists", async () => { const result = await service.retrieve(productOne.id) - expect(result).toEqual(expect.objectContaining({ - id: productOne.id - })) + expect(result).toEqual( + expect.objectContaining({ + id: productOne.id, + }) + ) }) }) @@ -150,18 +154,22 @@ describe("Product Service", () => { }) it("should update a product and its allowed relations", async () => { - const updateData = [{ - id: productOne.id, - title: "update test 1", - images: images, - thumbnail: images[0].url, - }] + const updateData = [ + { + id: productOne.id, + title: "update test 1", + images: images, + thumbnail: images[0].url, + }, + ] const products = await service.update(updateData) expect(products.length).toEqual(1) - let result = await service.retrieve(productOne.id, {relations: ["images", "thumbnail"]}) + let result = await service.retrieve(productOne.id, { + relations: ["images", "thumbnail"], + }) let serialized = JSON.parse(JSON.stringify(result)) expect(serialized).toEqual( @@ -183,13 +191,16 @@ describe("Product Service", () => { it("should throw an error when id is not present", async () => { let error - const updateData = [{ - id: productOne.id, - title: "update test 1", - }, { - id: undefined as unknown as string, - title: "update test 2", - }] + const updateData = [ + { + id: productOne.id, + title: "update test 1", + }, + { + id: undefined as unknown as string, + title: "update test 2", + }, + ] try { await service.update(updateData) @@ -206,10 +217,12 @@ describe("Product Service", () => { it("should throw an error when product with id does not exist", async () => { let error - const updateData = [{ - id: "does-not-exist", - title: "update test 1", - }] + const updateData = [ + { + id: "does-not-exist", + title: "update test 1", + }, + ] try { await service.update(updateData) @@ -217,13 +230,14 @@ describe("Product Service", () => { error = e } - expect(error.message).toEqual(`Product with id "does-not-exist" not found`) + expect(error.message).toEqual( + `Product with id "does-not-exist" not found` + ) }) }) describe("list", () => { describe("soft deleted", function () { - let deletedProduct let product beforeEach(async () => { @@ -232,7 +246,7 @@ describe("Product Service", () => { const products = await createProductAndTags(testManager, productsData) product = products[1] - deletedProduct = await service.softDelete([products[0].id]) + await service.softDelete([products[0].id]) }) it("should list all products that are not deleted", async () => { @@ -462,7 +476,19 @@ describe("Product Service", () => { }) const products = await service.create([data]) - const deleteProducts = await service.softDelete(products.map((p) => p.id)) + await service.softDelete(products.map((p) => p.id)) + const deleteProducts = await service.list( + { id: products.map((p) => p.id) }, + { + relations: [ + "variants", + "variants.options", + "options", + "options.values", + ], + withDeleted: true, + } + ) expect(deleteProducts).toHaveLength(1) expect(deleteProducts[0].deleted_at).not.toBeNull() diff --git a/packages/product/src/joiner-config.ts b/packages/product/src/joiner-config.ts index 54e7bbd105..f4b1bf92a7 100644 --- a/packages/product/src/joiner-config.ts +++ b/packages/product/src/joiner-config.ts @@ -1,5 +1,59 @@ import { Modules } from "@medusajs/modules-sdk" import { JoinerServiceConfig } from "@medusajs/types" +import { + Product, + ProductCategory, + ProductCollection, + ProductOption, + ProductTag, + ProductType, + ProductVariant, +} from "@models" +import ProductImage from "./models/product-image" +import { MapToConfig } from "@medusajs/utils" + +export enum LinkableKeys { + PRODUCT_ID = "product_id", + PRODUCT_HANDLE = "product_handle", + VARIANT_ID = "variant_id", + VARIANT_SKU = "variant_sku", + PRODUCT_OPTION_ID = "product_option_id", + PRODUCT_TYPE_ID = "product_type_id", + PRODUCT_CATEGORY_ID = "product_category_id", + PRODUCT_COLLECTION_ID = "product_collection_id", + PRODUCT_TAG_ID = "product_tag_id", + PRODUCT_IMAGE_ID = "product_image_id", +} + +export const entityNameToLinkableKeysMap: MapToConfig = { + [Product.name]: [ + { mapTo: LinkableKeys.PRODUCT_ID, valueFrom: "id" }, + { + mapTo: LinkableKeys.PRODUCT_HANDLE, + valueFrom: "handle", + }, + ], + [ProductVariant.name]: [ + { mapTo: LinkableKeys.VARIANT_ID, valueFrom: "id" }, + { mapTo: LinkableKeys.VARIANT_SKU, valueFrom: "sku" }, + ], + [ProductOption.name]: [ + { mapTo: LinkableKeys.PRODUCT_OPTION_ID, valueFrom: "id" }, + ], + [ProductType.name]: [ + { mapTo: LinkableKeys.PRODUCT_TYPE_ID, valueFrom: "id" }, + ], + [ProductCategory.name]: [ + { mapTo: LinkableKeys.PRODUCT_CATEGORY_ID, valueFrom: "id" }, + ], + [ProductCollection.name]: [ + { mapTo: LinkableKeys.PRODUCT_COLLECTION_ID, valueFrom: "id" }, + ], + [ProductTag.name]: [{ mapTo: LinkableKeys.PRODUCT_TAG_ID, valueFrom: "id" }], + [ProductImage.name]: [ + { mapTo: LinkableKeys.PRODUCT_IMAGE_ID, valueFrom: "id" }, + ], +} export const joinerConfig: JoinerServiceConfig = { serviceName: Modules.PRODUCT, diff --git a/packages/product/src/services/product-category.ts b/packages/product/src/services/product-category.ts index a6f9df6b54..1886fdd9b3 100644 --- a/packages/product/src/services/product-category.ts +++ b/packages/product/src/services/product-category.ts @@ -2,12 +2,12 @@ import { ProductCategory } from "@models" import { ProductCategoryRepository } from "@repositories" import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" import { - ModulesSdkUtils, - MedusaError, - isDefined, - InjectTransactionManager, InjectManager, - MedusaContext + InjectTransactionManager, + isDefined, + MedusaContext, + MedusaError, + ModulesSdkUtils, } from "@medusajs/utils" import { shouldForceTransaction } from "../utils" @@ -39,9 +39,12 @@ export default class ProductCategoryService< ) } - const queryOptions = ModulesSdkUtils.buildQuery({ - id: productCategoryId, - }, config) + const queryOptions = ModulesSdkUtils.buildQuery( + { + id: productCategoryId, + }, + config + ) const transformOptions = { includeDescendantsTree: true, @@ -111,31 +114,37 @@ export default class ProductCategoryService< )) as [TEntity[], number] } - @InjectTransactionManager(shouldForceTransaction, "productCategoryRepository_") + @InjectTransactionManager( + shouldForceTransaction, + "productCategoryRepository_" + ) async create( data: ProductCategoryServiceTypes.CreateProductCategoryDTO, @MedusaContext() sharedContext: Context = {} ): Promise { - return (await (this.productCategoryRepository_ as ProductCategoryRepository).create( - data, - sharedContext - )) as TEntity + return (await ( + this.productCategoryRepository_ as unknown as ProductCategoryRepository + ).create(data, sharedContext)) as TEntity } - @InjectTransactionManager(shouldForceTransaction, "productCategoryRepository_") + @InjectTransactionManager( + shouldForceTransaction, + "productCategoryRepository_" + ) async update( id: string, data: ProductCategoryServiceTypes.UpdateProductCategoryDTO, @MedusaContext() sharedContext: Context = {} ): Promise { - return (await (this.productCategoryRepository_ as ProductCategoryRepository).update( - id, - data, - sharedContext - )) as TEntity + return (await ( + this.productCategoryRepository_ as unknown as ProductCategoryRepository + ).update(id, data, sharedContext)) as TEntity } - @InjectTransactionManager(shouldForceTransaction, "productCategoryRepository_") + @InjectTransactionManager( + shouldForceTransaction, + "productCategoryRepository_" + ) async delete( id: string, @MedusaContext() sharedContext: Context = {} diff --git a/packages/product/src/services/product-collection.ts b/packages/product/src/services/product-collection.ts index 45d3cf5747..93f5d1ca70 100644 --- a/packages/product/src/services/product-collection.ts +++ b/packages/product/src/services/product-collection.ts @@ -1,10 +1,10 @@ import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" import { - ModulesSdkUtils, - retrieveEntity, + InjectManager, InjectTransactionManager, MedusaContext, - InjectManager, + ModulesSdkUtils, + retrieveEntity, } from "@medusajs/utils" import { shouldForceTransaction } from "../utils" @@ -85,29 +85,36 @@ export default class ProductCollectionService< return queryOptions } - @InjectTransactionManager(shouldForceTransaction, "productCollectionRepository_") + @InjectTransactionManager( + shouldForceTransaction, + "productCollectionRepository_" + ) async create( data: ProductTypes.CreateProductCollectionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - return (await (this.productCollectionRepository_ as ProductCollectionRepository).create( - data, - sharedContext - )) as TEntity[] + return (await ( + this.productCollectionRepository_ as ProductCollectionRepository + ).create(data, sharedContext)) as TEntity[] } - @InjectTransactionManager(shouldForceTransaction, "productCollectionRepository_") + @InjectTransactionManager( + shouldForceTransaction, + "productCollectionRepository_" + ) async update( data: ProductTypes.UpdateProductCollectionDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - return (await (this.productCollectionRepository_ as ProductCollectionRepository).update( - data, - sharedContext - )) as TEntity[] + return (await ( + this.productCollectionRepository_ as ProductCollectionRepository + ).update(data, sharedContext)) as TEntity[] } - @InjectTransactionManager(shouldForceTransaction, "productCollectionRepository_") + @InjectTransactionManager( + shouldForceTransaction, + "productCollectionRepository_" + ) async delete( ids: string[], @MedusaContext() sharedContext: Context = {} diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index b6282534b4..7596f2c777 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -38,12 +38,17 @@ import { isDefined, isString, kebabCase, + mapObjectTo, MedusaContext, MedusaError, } from "@medusajs/utils" import { shouldForceTransaction } from "../utils" -import { joinerConfig } from "./../joiner-config" +import { + entityNameToLinkableKeysMap, + joinerConfig, + LinkableKeys, +} from "./../joiner-config" import { ProductCategoryServiceTypes } from "../types" type InjectedDependencies = { @@ -958,7 +963,7 @@ export default class ProductModuleService< ) { if (isDefined(productData.type)) { const productType = await this.productTypeService_.upsert( - [productData.type], + [productData.type!], sharedContext ) @@ -974,22 +979,41 @@ export default class ProductModuleService< await this.productService_.delete(productIds, sharedContext) } - async softDelete( + async softDelete< + TReturnableLinkableKeys extends string = Lowercase< + keyof typeof LinkableKeys + > + >( productIds: string[], + { + returnLinkableKeys, + }: { returnLinkableKeys?: TReturnableLinkableKeys[] } = { + returnLinkableKeys: [], + }, sharedContext: Context = {} - ): Promise { - const products = await this.softDelete_(productIds, sharedContext) + ): Promise, string[]> | void> { + let [, cascadedEntitiesMap] = await this.softDelete_( + productIds, + sharedContext + ) - return this.baseRepository_.serialize(products, { - populate: true, - }) + let mappedCascadedEntitiesMap + if (returnLinkableKeys) { + mappedCascadedEntitiesMap = mapObjectTo< + Record, string[]> + >(cascadedEntitiesMap, entityNameToLinkableKeysMap, { + pick: returnLinkableKeys as string[], + }) + } + + return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0 } @InjectTransactionManager(shouldForceTransaction, "baseRepository_") protected async softDelete_( productIds: string[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise<[TProduct[], Record]> { return await this.productService_.softDelete(productIds, sharedContext) } diff --git a/packages/product/src/services/product.ts b/packages/product/src/services/product.ts index bbf8b6722e..e835b1d311 100644 --- a/packages/product/src/services/product.ts +++ b/packages/product/src/services/product.ts @@ -10,10 +10,10 @@ import { import { InjectManager, InjectTransactionManager, + isDefined, MedusaContext, MedusaError, ModulesSdkUtils, - isDefined, } from "@medusajs/utils" import { ProductRepository } from "@repositories" @@ -44,9 +44,12 @@ export default class ProductService { ) } - const queryOptions = ModulesSdkUtils.buildQuery({ - id: productId, - }, config) + const queryOptions = ModulesSdkUtils.buildQuery( + { + id: productId, + }, + config + ) const product = await this.productRepository_.find( queryOptions, @@ -140,7 +143,7 @@ export default class ProductService { data: ProductServiceTypes.UpdateProductDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - return await (this.productRepository_ as ProductRepository).update( + return (await (this.productRepository_ as ProductRepository).update( data as WithRequiredProperty< ProductServiceTypes.UpdateProductDTO, "id" @@ -148,7 +151,7 @@ export default class ProductService { { transactionManager: sharedContext.transactionManager, } - ) as TEntity[] + )) as TEntity[] } @InjectTransactionManager(doNotForceTransaction, "productRepository_") @@ -165,7 +168,7 @@ export default class ProductService { async softDelete( productIds: string[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise<[TEntity[], Record]> { return await this.productRepository_.softDelete(productIds, { transactionManager: sharedContext.transactionManager, }) diff --git a/packages/types/src/dal/repository-service.ts b/packages/types/src/dal/repository-service.ts index 57fd001333..af5cc0afa5 100644 --- a/packages/types/src/dal/repository-service.ts +++ b/packages/types/src/dal/repository-service.ts @@ -41,7 +41,18 @@ export interface RepositoryService extends BaseRepositoryService { delete(ids: string[], context?: Context): Promise - softDelete(ids: string[], context?: Context): Promise + /** + * Soft delete entities and cascade to related entities if configured. + * + * @param ids + * @param context + * + * @returns [T[], Record] the second value being the map of the entity names and ids that were soft deleted + */ + softDelete( + ids: string[], + context?: Context + ): Promise<[T[], Record]> restore(ids: string[], context?: Context): Promise } diff --git a/packages/types/src/product/service.ts b/packages/types/src/product/service.ts index 1685b71894..df92a281ed 100644 --- a/packages/types/src/product/service.ts +++ b/packages/types/src/product/service.ts @@ -1,30 +1,30 @@ import { + CreateProductCategoryDTO, + CreateProductCollectionDTO, CreateProductDTO, + CreateProductOptionDTO, CreateProductTagDTO, CreateProductTypeDTO, - CreateProductOptionDTO, - CreateProductCategoryDTO, - UpdateProductTagDTO, - UpdateProductTypeDTO, - UpdateProductOptionDTO, - UpdateProductCategoryDTO, - UpdateProductDTO, FilterableProductCategoryProps, FilterableProductCollectionProps, + FilterableProductOptionProps, FilterableProductProps, FilterableProductTagProps, FilterableProductTypeProps, - FilterableProductOptionProps, FilterableProductVariantProps, ProductCategoryDTO, ProductCollectionDTO, ProductDTO, + ProductOptionDTO, ProductTagDTO, ProductTypeDTO, - ProductOptionDTO, ProductVariantDTO, - CreateProductCollectionDTO, + UpdateProductCategoryDTO, UpdateProductCollectionDTO, + UpdateProductDTO, + UpdateProductOptionDTO, + UpdateProductTagDTO, + UpdateProductTypeDTO, } from "./common" import { Context } from "../shared-context" @@ -72,18 +72,15 @@ export interface IProductModuleService { createTags( data: CreateProductTagDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise updateTags( data: UpdateProductTagDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise - deleteTags( - productTagIds: string[], - sharedContext?: Context, - ): Promise + deleteTags(productTagIds: string[], sharedContext?: Context): Promise retrieveType( typeId: string, @@ -105,18 +102,15 @@ export interface IProductModuleService { createTypes( data: CreateProductTypeDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise updateTypes( data: UpdateProductTypeDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise - deleteTypes( - productTypeIds: string[], - sharedContext?: Context, - ): Promise + deleteTypes(productTypeIds: string[], sharedContext?: Context): Promise retrieveOption( optionId: string, @@ -138,17 +132,17 @@ export interface IProductModuleService { createOptions( data: CreateProductOptionDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise updateOptions( data: UpdateProductOptionDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise deleteOptions( productOptionIds: string[], - sharedContext?: Context, + sharedContext?: Context ): Promise retrieveVariant( @@ -189,17 +183,17 @@ export interface IProductModuleService { createCollections( data: CreateProductCollectionDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise updateCollections( data: UpdateProductCollectionDTO[], - sharedContext?: Context, + sharedContext?: Context ): Promise deleteCollections( productCollectionIds: string[], - sharedContext?: Context, + sharedContext?: Context ): Promise retrieveCategory( @@ -222,19 +216,16 @@ export interface IProductModuleService { createCategory( data: CreateProductCategoryDTO, - sharedContext?: Context, + sharedContext?: Context ): Promise updateCategory( categoryId: string, data: UpdateProductCategoryDTO, - sharedContext?: Context, + sharedContext?: Context ): Promise - deleteCategory( - categoryId: string, - sharedContext?: Context, - ): Promise + deleteCategory(categoryId: string, sharedContext?: Context): Promise create( data: CreateProductDTO[], @@ -248,10 +239,11 @@ export interface IProductModuleService { delete(productIds: string[], sharedContext?: Context): Promise - softDelete( + softDelete( productIds: string[], + config?: { returnLinkableKeys?: TReturnableLinkableKeys[] }, sharedContext?: Context - ): Promise + ): Promise | void> restore(productIds: string[], sharedContext?: Context): Promise } diff --git a/packages/utils/src/common/__tests__/map-object-to.ts b/packages/utils/src/common/__tests__/map-object-to.ts new file mode 100644 index 0000000000..374ba19db4 --- /dev/null +++ b/packages/utils/src/common/__tests__/map-object-to.ts @@ -0,0 +1,43 @@ +import { mapObjectTo, MapToConfig } from "../map-object-to" + +const input = { + a: [{ id: "1" }, { id: "2" }], + b: [{ id: "3" }, { id: "4", handle: "handle1" }], + c: [{ id: "5", sku: "sku1" }, { id: "6" }], +} + +const mapToConfig: MapToConfig = { + a: [{ mapTo: "a.id", valueFrom: "id" }], + b: [ + { mapTo: "b.id", valueFrom: "id" }, + { mapTo: "b.handle", valueFrom: "handle" }, + ], + c: [ + { mapTo: "c.id", valueFrom: "id" }, + { mapTo: "c.sku", valueFrom: "sku" }, + ], +} + +describe("mapObjectTo", function () { + it("should return a new object with the keys remapped and the values picked from the original object based on the map config", function () { + const remappedObject = mapObjectTo(input, mapToConfig) + + expect(remappedObject).toEqual({ + "a.id": ["1", "2"], + "b.id": ["3", "4"], + "b.handle": ["handle1"], + "c.id": ["5", "6"], + "c.sku": ["sku1"], + }) + }) + + it("should return a new object with only the picked properties", function () { + const remappedObject = mapObjectTo(input, mapToConfig, { + pick: ["a.id"], + }) + + expect(remappedObject).toEqual({ + "a.id": ["1", "2"], + }) + }) +}) diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 2d949d143c..e26d48589a 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -22,3 +22,4 @@ export * from "./stringify-circular" export * from "./to-kebab-case" export * from "./to-pascal-case" export * from "./wrap-handler" +export * from "./map-object-to" diff --git a/packages/utils/src/common/map-object-to.ts b/packages/utils/src/common/map-object-to.ts new file mode 100644 index 0000000000..02a85a35e3 --- /dev/null +++ b/packages/utils/src/common/map-object-to.ts @@ -0,0 +1,53 @@ +type RemapInputObject = Record +type RemapConfig = { mapTo: string; valueFrom: string } +export type MapToConfig = { + [key: string]: RemapConfig[] +} + +/** + * Create a new object with the keys remapped and the values picked from the original object based + * on the map config + * + * @param object input object + * @param mapTo configuration to map the output object + * @param removeIfNotRemapped if true, the keys that are not remapped will be removed from the output object + * @param pick if provided, only the keys in the array will be picked from the output object + */ +export function mapObjectTo< + TResult = any, + T extends RemapInputObject = RemapInputObject +>( + object: T, + mapTo: MapToConfig, + { + removeIfNotRemapped, + pick, + }: { removeIfNotRemapped?: boolean; pick?: string[] } = {} +): TResult { + removeIfNotRemapped ??= false + + const newObject: Record = {} + + for (const key in object) { + const remapConfig = mapTo[key as string]! + + if (!remapConfig) { + if (!removeIfNotRemapped) { + newObject[key] = object[key] + } + continue + } + + remapConfig.forEach((config) => { + if (pick?.length && !pick.includes(config.mapTo)) { + return + } + + newObject[config.mapTo] = object[key] + .map((obj: any) => obj[config.valueFrom]) + .filter(Boolean) + }) + } + + return newObject as TResult +} diff --git a/packages/utils/src/dal/index.ts b/packages/utils/src/dal/index.ts index 306a361663..2b2af46cb5 100644 --- a/packages/utils/src/dal/index.ts +++ b/packages/utils/src/dal/index.ts @@ -1,5 +1,6 @@ export * from "./mikro-orm/mikro-orm-repository" export * from "./repository" export * from "./utils" +export * from "./mikro-orm/utils" export * from "./mikro-orm/mikro-orm-create-connection" export * from "./mikro-orm/mikro-orm-soft-deletable-filter" 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 553db4fc63..f0c29eedc4 100644 --- a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts @@ -2,10 +2,10 @@ import { Context, DAL, RepositoryTransformOptions } from "@medusajs/types" import { MedusaContext } from "../../decorators" import { buildQuery, InjectTransactionManager } from "../../modules-sdk" import { - mikroOrmSerializer, - mikroOrmUpdateDeletedAtRecursively, + getSoftDeletedCascadedEntitiesIdsMappedBy, transactionWrapper, } from "../utils" +import { mikroOrmSerializer, mikroOrmUpdateDeletedAtRecursively } from "./utils" class MikroOrmBase { protected readonly manager_: any @@ -71,13 +71,17 @@ export abstract class MikroOrmAbstractBaseRepository ids: string[], @MedusaContext() { transactionManager: manager }: Context = {} - ): Promise { + ): Promise<[T[], Record]> { const entities = await this.find({ where: { id: { $in: ids } } as any }) const date = new Date() await mikroOrmUpdateDeletedAtRecursively(manager, entities, date) - return entities + const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({ + entities, + }) + + return [entities, softDeletedEntitiesMap] } @InjectTransactionManager() diff --git a/packages/utils/src/dal/mikro-orm/utils.ts b/packages/utils/src/dal/mikro-orm/utils.ts new file mode 100644 index 0000000000..63d599eb2c --- /dev/null +++ b/packages/utils/src/dal/mikro-orm/utils.ts @@ -0,0 +1,54 @@ +import { SoftDeletableFilterKey } from "./mikro-orm-soft-deletable-filter" + +export const mikroOrmUpdateDeletedAtRecursively = async < + T extends object = any +>( + manager: any, + 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 + .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 collectionRelation = entity[relation.name] + + if (!collectionRelation.isInitialized()) { + await collectionRelation.init() + } + + const relationEntities = await collectionRelation.getItems({ + filters: { + [SoftDeletableFilterKey]: { + withDeleted: true, + }, + }, + }) + + await mikroOrmUpdateDeletedAtRecursively(manager, relationEntities, value) + } + + await manager.persist(entity) + } +} + +export const mikroOrmSerializer = async ( + data: any, + options?: any +): Promise => { + options ??= {} + const { serialize } = await import("@mikro-orm/core") + const result = serialize(data, options) + return result as unknown as Promise +} diff --git a/packages/utils/src/dal/repository.ts b/packages/utils/src/dal/repository.ts index bcd213f86f..3fa990f7d3 100644 --- a/packages/utils/src/dal/repository.ts +++ b/packages/utils/src/dal/repository.ts @@ -49,7 +49,10 @@ export abstract class AbstractBaseRepository abstract delete(ids: string[], context?: Context): Promise - abstract softDelete(ids: string[], context?: Context): Promise + abstract softDelete( + ids: string[], + context?: Context + ): Promise<[T[], Record]> abstract restore(ids: string[], context?: Context): Promise diff --git a/packages/utils/src/dal/utils.ts b/packages/utils/src/dal/utils.ts index 2590f12d22..bf1dd9eefb 100644 --- a/packages/utils/src/dal/utils.ts +++ b/packages/utils/src/dal/utils.ts @@ -1,4 +1,4 @@ -import { SoftDeletableFilterKey } from "../dal" +import { isObject } from "../common" export async function transactionWrapper( this: any, @@ -33,54 +33,52 @@ export async function transactionWrapper( return await transactionMethod.bind(this.manager_)(task, options) } -export const mikroOrmUpdateDeletedAtRecursively = async < - T extends object = any ->( - manager: any, - entities: T[], - value: Date | null -) => { +/** + * 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, +}: { + entities: any[] + deletedEntitiesMap?: Map + getEntityName?: (entity: any) => string +}): Record { + deletedEntitiesMap ??= new Map() + getEntityName ??= (entity) => entity.constructor.name + for (const entity of entities) { - if (!("deleted_at" in entity)) continue - ;(entity as any).deleted_at = value + const entityName = getEntityName(entity) + const shouldSkip = !!deletedEntitiesMap + .get(entityName) + ?.some((e) => e.id === entity.id) - const relations = 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 collectionRelation = entity[relation.name] - - if (!collectionRelation.isInitialized()) { - await collectionRelation.init() - } - - const relationEntities = await collectionRelation.getItems({ - filters: { - [SoftDeletableFilterKey]: { - withDeleted: true, - }, - }, - }) - - await mikroOrmUpdateDeletedAtRecursively(manager, relationEntities, value) + if (!entity.deleted_at || shouldSkip) { + continue } - await manager.persist(entity) - } -} + const values = deletedEntitiesMap.get(entityName) ?? [] + values.push(entity) + deletedEntitiesMap.set(entityName, values) -export const mikroOrmSerializer = async ( - data: any, - options?: any -): Promise => { - options ??= {} - const { serialize } = await import("@mikro-orm/core") - const result = serialize(data, options) - return result as unknown as Promise + Object.values(entity).forEach((propValue: any) => { + if (propValue != null && isObject(propValue[0])) { + getSoftDeletedCascadedEntitiesIdsMappedBy({ + entities: propValue, + deletedEntitiesMap, + getEntityName, + }) + } + }) + } + + return Object.fromEntries(deletedEntitiesMap) }