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 <rmthamir@gmail.com>
This commit is contained in:
Adrien de PerettiandRiqwan Thamir authored and GitHub committed 2023-08-02 19:29:01 +02:00
1 parent fc6c9df035
commit ce3326c5fb
17 files changed
+448 -166

No files matched your search

@@ -230,7 +230,6 @@ describe("Product module", function () {
const products = await module.create([data]) const products = await module.create([data])
await module.softDelete([products[0].id]) await module.softDelete([products[0].id])
const deletedProducts = await module.list( const deletedProducts = await module.list(
{ id: products[0].id }, { id: products[0].id },
{ {
@@ -1,6 +1,7 @@
import { Image, Product, ProductCategory, ProductVariant } from "@models" import { Image, Product, ProductCategory, ProductVariant } from "@models"
import { import {
assignCategoriesToProduct, assignCategoriesToProduct,
buildProductOnlyData,
createImages, createImages,
createProductAndTags, createProductAndTags,
createProductVariants, createProductVariants,
@@ -16,7 +17,6 @@ import { ProductRepository } from "@repositories"
import { ProductService } from "@services" import { ProductService } from "@services"
import { SqlEntityManager } from "@mikro-orm/postgresql" import { SqlEntityManager } from "@mikro-orm/postgresql"
import { TestDatabase } from "../../../utils" import { TestDatabase } from "../../../utils"
import { buildProductOnlyData } from "../../../__fixtures__/product/data/create-product"
import { createProductCategories } from "../../../__fixtures__/product-category" import { createProductCategories } from "../../../__fixtures__/product-category"
import { kebabCase } from "@medusajs/utils" import { kebabCase } from "@medusajs/utils"
@@ -81,15 +81,19 @@ describe("Product Service", () => {
error = e 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 () => { it("should return a product when product with an id exists", async () => {
const result = await service.retrieve(productOne.id) const result = await service.retrieve(productOne.id)
expect(result).toEqual(expect.objectContaining({ expect(result).toEqual(
id: productOne.id expect.objectContaining({
})) id: productOne.id,
})
)
}) })
}) })
@@ -150,18 +154,22 @@ describe("Product Service", () => {
}) })
it("should update a product and its allowed relations", async () => { it("should update a product and its allowed relations", async () => {
const updateData = [{ const updateData = [
id: productOne.id, {
title: "update test 1", id: productOne.id,
images: images, title: "update test 1",
thumbnail: images[0].url, images: images,
}] thumbnail: images[0].url,
},
]
const products = await service.update(updateData) const products = await service.update(updateData)
expect(products.length).toEqual(1) 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)) let serialized = JSON.parse(JSON.stringify(result))
expect(serialized).toEqual( expect(serialized).toEqual(
@@ -183,13 +191,16 @@ describe("Product Service", () => {
it("should throw an error when id is not present", async () => { it("should throw an error when id is not present", async () => {
let error let error
const updateData = [{ const updateData = [
id: productOne.id, {
title: "update test 1", id: productOne.id,
}, { title: "update test 1",
id: undefined as unknown as string, },
title: "update test 2", {
}] id: undefined as unknown as string,
title: "update test 2",
},
]
try { try {
await service.update(updateData) await service.update(updateData)
@@ -206,10 +217,12 @@ describe("Product Service", () => {
it("should throw an error when product with id does not exist", async () => { it("should throw an error when product with id does not exist", async () => {
let error let error
const updateData = [{ const updateData = [
id: "does-not-exist", {
title: "update test 1", id: "does-not-exist",
}] title: "update test 1",
},
]
try { try {
await service.update(updateData) await service.update(updateData)
@@ -217,13 +230,14 @@ describe("Product Service", () => {
error = e 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("list", () => {
describe("soft deleted", function () { describe("soft deleted", function () {
let deletedProduct
let product let product
beforeEach(async () => { beforeEach(async () => {
@@ -232,7 +246,7 @@ describe("Product Service", () => {
const products = await createProductAndTags(testManager, productsData) const products = await createProductAndTags(testManager, productsData)
product = products[1] 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 () => { it("should list all products that are not deleted", async () => {
@@ -462,7 +476,19 @@ describe("Product Service", () => {
}) })
const products = await service.create([data]) 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).toHaveLength(1)
expect(deleteProducts[0].deleted_at).not.toBeNull() expect(deleteProducts[0].deleted_at).not.toBeNull()
+54
View File
@@ -1,5 +1,59 @@
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { JoinerServiceConfig } from "@medusajs/types" 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 = { export const joinerConfig: JoinerServiceConfig = {
serviceName: Modules.PRODUCT, serviceName: Modules.PRODUCT,
@@ -2,12 +2,12 @@ import { ProductCategory } from "@models"
import { ProductCategoryRepository } from "@repositories" import { ProductCategoryRepository } from "@repositories"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { import {
ModulesSdkUtils,
MedusaError,
isDefined,
InjectTransactionManager,
InjectManager, InjectManager,
MedusaContext InjectTransactionManager,
isDefined,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils" } from "@medusajs/utils"
import { shouldForceTransaction } from "../utils" import { shouldForceTransaction } from "../utils"
@@ -39,9 +39,12 @@ export default class ProductCategoryService<
) )
} }
const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>({ const queryOptions = ModulesSdkUtils.buildQuery<ProductCategory>(
id: productCategoryId, {
}, config) id: productCategoryId,
},
config
)
const transformOptions = { const transformOptions = {
includeDescendantsTree: true, includeDescendantsTree: true,
@@ -111,31 +114,37 @@ export default class ProductCategoryService<
)) as [TEntity[], number] )) as [TEntity[], number]
} }
@InjectTransactionManager(shouldForceTransaction, "productCategoryRepository_") @InjectTransactionManager(
shouldForceTransaction,
"productCategoryRepository_"
)
async create( async create(
data: ProductCategoryServiceTypes.CreateProductCategoryDTO, data: ProductCategoryServiceTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity> { ): Promise<TEntity> {
return (await (this.productCategoryRepository_ as ProductCategoryRepository).create( return (await (
data, this.productCategoryRepository_ as unknown as ProductCategoryRepository
sharedContext ).create(data, sharedContext)) as TEntity
)) as TEntity
} }
@InjectTransactionManager(shouldForceTransaction, "productCategoryRepository_") @InjectTransactionManager(
shouldForceTransaction,
"productCategoryRepository_"
)
async update( async update(
id: string, id: string,
data: ProductCategoryServiceTypes.UpdateProductCategoryDTO, data: ProductCategoryServiceTypes.UpdateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity> { ): Promise<TEntity> {
return (await (this.productCategoryRepository_ as ProductCategoryRepository).update( return (await (
id, this.productCategoryRepository_ as unknown as ProductCategoryRepository
data, ).update(id, data, sharedContext)) as TEntity
sharedContext
)) as TEntity
} }
@InjectTransactionManager(shouldForceTransaction, "productCategoryRepository_") @InjectTransactionManager(
shouldForceTransaction,
"productCategoryRepository_"
)
async delete( async delete(
id: string, id: string,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
@@ -1,10 +1,10 @@
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { import {
ModulesSdkUtils, InjectManager,
retrieveEntity,
InjectTransactionManager, InjectTransactionManager,
MedusaContext, MedusaContext,
InjectManager, ModulesSdkUtils,
retrieveEntity,
} from "@medusajs/utils" } from "@medusajs/utils"
import { shouldForceTransaction } from "../utils" import { shouldForceTransaction } from "../utils"
@@ -85,29 +85,36 @@ export default class ProductCollectionService<
return queryOptions return queryOptions
} }
@InjectTransactionManager(shouldForceTransaction, "productCollectionRepository_") @InjectTransactionManager(
shouldForceTransaction,
"productCollectionRepository_"
)
async create( async create(
data: ProductTypes.CreateProductCollectionDTO[], data: ProductTypes.CreateProductCollectionDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> { ): Promise<TEntity[]> {
return (await (this.productCollectionRepository_ as ProductCollectionRepository).create( return (await (
data, this.productCollectionRepository_ as ProductCollectionRepository
sharedContext ).create(data, sharedContext)) as TEntity[]
)) as TEntity[]
} }
@InjectTransactionManager(shouldForceTransaction, "productCollectionRepository_") @InjectTransactionManager(
shouldForceTransaction,
"productCollectionRepository_"
)
async update( async update(
data: ProductTypes.UpdateProductCollectionDTO[], data: ProductTypes.UpdateProductCollectionDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> { ): Promise<TEntity[]> {
return (await (this.productCollectionRepository_ as ProductCollectionRepository).update( return (await (
data, this.productCollectionRepository_ as ProductCollectionRepository
sharedContext ).update(data, sharedContext)) as TEntity[]
)) as TEntity[]
} }
@InjectTransactionManager(shouldForceTransaction, "productCollectionRepository_") @InjectTransactionManager(
shouldForceTransaction,
"productCollectionRepository_"
)
async delete( async delete(
ids: string[], ids: string[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
@@ -38,12 +38,17 @@ import {
isDefined, isDefined,
isString, isString,
kebabCase, kebabCase,
mapObjectTo,
MedusaContext, MedusaContext,
MedusaError, MedusaError,
} from "@medusajs/utils" } from "@medusajs/utils"
import { shouldForceTransaction } from "../utils" import { shouldForceTransaction } from "../utils"
import { joinerConfig } from "./../joiner-config" import {
entityNameToLinkableKeysMap,
joinerConfig,
LinkableKeys,
} from "./../joiner-config"
import { ProductCategoryServiceTypes } from "../types" import { ProductCategoryServiceTypes } from "../types"
type InjectedDependencies = { type InjectedDependencies = {
@@ -958,7 +963,7 @@ export default class ProductModuleService<
) { ) {
if (isDefined(productData.type)) { if (isDefined(productData.type)) {
const productType = await this.productTypeService_.upsert( const productType = await this.productTypeService_.upsert(
[productData.type], [productData.type!],
sharedContext sharedContext
) )
@@ -974,22 +979,41 @@ export default class ProductModuleService<
await this.productService_.delete(productIds, sharedContext) await this.productService_.delete(productIds, sharedContext)
} }
async softDelete( async softDelete<
TReturnableLinkableKeys extends string = Lowercase<
keyof typeof LinkableKeys
>
>(
productIds: string[], productIds: string[],
{
returnLinkableKeys,
}: { returnLinkableKeys?: TReturnableLinkableKeys[] } = {
returnLinkableKeys: [],
},
sharedContext: Context = {} sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[]> { ): Promise<Record<Lowercase<keyof typeof LinkableKeys>, string[]> | void> {
const products = await this.softDelete_(productIds, sharedContext) let [, cascadedEntitiesMap] = await this.softDelete_(
productIds,
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO[]>(products, { let mappedCascadedEntitiesMap
populate: true, if (returnLinkableKeys) {
}) mappedCascadedEntitiesMap = mapObjectTo<
Record<Lowercase<keyof typeof LinkableKeys>, string[]>
>(cascadedEntitiesMap, entityNameToLinkableKeysMap, {
pick: returnLinkableKeys as string[],
})
}
return mappedCascadedEntitiesMap ? mappedCascadedEntitiesMap : void 0
} }
@InjectTransactionManager(shouldForceTransaction, "baseRepository_") @InjectTransactionManager(shouldForceTransaction, "baseRepository_")
protected async softDelete_( protected async softDelete_(
productIds: string[], productIds: string[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> { ): Promise<[TProduct[], Record<string, unknown[]>]> {
return await this.productService_.softDelete(productIds, sharedContext) return await this.productService_.softDelete(productIds, sharedContext)
} }
+10 -7
View File
@@ -10,10 +10,10 @@ import {
import { import {
InjectManager, InjectManager,
InjectTransactionManager, InjectTransactionManager,
isDefined,
MedusaContext, MedusaContext,
MedusaError, MedusaError,
ModulesSdkUtils, ModulesSdkUtils,
isDefined,
} from "@medusajs/utils" } from "@medusajs/utils"
import { ProductRepository } from "@repositories" import { ProductRepository } from "@repositories"
@@ -44,9 +44,12 @@ export default class ProductService<TEntity extends Product = Product> {
) )
} }
const queryOptions = ModulesSdkUtils.buildQuery<Product>({ const queryOptions = ModulesSdkUtils.buildQuery<Product>(
id: productId, {
}, config) id: productId,
},
config
)
const product = await this.productRepository_.find( const product = await this.productRepository_.find(
queryOptions, queryOptions,
@@ -140,7 +143,7 @@ export default class ProductService<TEntity extends Product = Product> {
data: ProductServiceTypes.UpdateProductDTO[], data: ProductServiceTypes.UpdateProductDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> { ): Promise<TEntity[]> {
return await (this.productRepository_ as ProductRepository).update( return (await (this.productRepository_ as ProductRepository).update(
data as WithRequiredProperty< data as WithRequiredProperty<
ProductServiceTypes.UpdateProductDTO, ProductServiceTypes.UpdateProductDTO,
"id" "id"
@@ -148,7 +151,7 @@ export default class ProductService<TEntity extends Product = Product> {
{ {
transactionManager: sharedContext.transactionManager, transactionManager: sharedContext.transactionManager,
} }
) as TEntity[] )) as TEntity[]
} }
@InjectTransactionManager(doNotForceTransaction, "productRepository_") @InjectTransactionManager(doNotForceTransaction, "productRepository_")
@@ -165,7 +168,7 @@ export default class ProductService<TEntity extends Product = Product> {
async softDelete( async softDelete(
productIds: string[], productIds: string[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> { ): Promise<[TEntity[], Record<string, unknown[]>]> {
return await this.productRepository_.softDelete(productIds, { return await this.productRepository_.softDelete(productIds, {
transactionManager: sharedContext.transactionManager, transactionManager: sharedContext.transactionManager,
}) })
+12 -1
View File
@@ -41,7 +41,18 @@ export interface RepositoryService<T = any> extends BaseRepositoryService<T> {
delete(ids: string[], context?: Context): Promise<void> delete(ids: string[], context?: Context): Promise<void>
softDelete(ids: string[], context?: Context): Promise<T[]> /**
* Soft delete entities and cascade to related entities if configured.
*
* @param ids
* @param context
*
* @returns [T[], Record<string, string[]>] the second value being the map of the entity names and ids that were soft deleted
*/
softDelete(
ids: string[],
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
restore(ids: string[], context?: Context): Promise<T[]> restore(ids: string[], context?: Context): Promise<T[]>
} }
+28 -36
View File
@@ -1,30 +1,30 @@
import { import {
CreateProductCategoryDTO,
CreateProductCollectionDTO,
CreateProductDTO, CreateProductDTO,
CreateProductOptionDTO,
CreateProductTagDTO, CreateProductTagDTO,
CreateProductTypeDTO, CreateProductTypeDTO,
CreateProductOptionDTO,
CreateProductCategoryDTO,
UpdateProductTagDTO,
UpdateProductTypeDTO,
UpdateProductOptionDTO,
UpdateProductCategoryDTO,
UpdateProductDTO,
FilterableProductCategoryProps, FilterableProductCategoryProps,
FilterableProductCollectionProps, FilterableProductCollectionProps,
FilterableProductOptionProps,
FilterableProductProps, FilterableProductProps,
FilterableProductTagProps, FilterableProductTagProps,
FilterableProductTypeProps, FilterableProductTypeProps,
FilterableProductOptionProps,
FilterableProductVariantProps, FilterableProductVariantProps,
ProductCategoryDTO, ProductCategoryDTO,
ProductCollectionDTO, ProductCollectionDTO,
ProductDTO, ProductDTO,
ProductOptionDTO,
ProductTagDTO, ProductTagDTO,
ProductTypeDTO, ProductTypeDTO,
ProductOptionDTO,
ProductVariantDTO, ProductVariantDTO,
CreateProductCollectionDTO, UpdateProductCategoryDTO,
UpdateProductCollectionDTO, UpdateProductCollectionDTO,
UpdateProductDTO,
UpdateProductOptionDTO,
UpdateProductTagDTO,
UpdateProductTypeDTO,
} from "./common" } from "./common"
import { Context } from "../shared-context" import { Context } from "../shared-context"
@@ -72,18 +72,15 @@ export interface IProductModuleService {
createTags( createTags(
data: CreateProductTagDTO[], data: CreateProductTagDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductTagDTO[]> ): Promise<ProductTagDTO[]>
updateTags( updateTags(
data: UpdateProductTagDTO[], data: UpdateProductTagDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductTagDTO[]> ): Promise<ProductTagDTO[]>
deleteTags( deleteTags(productTagIds: string[], sharedContext?: Context): Promise<void>
productTagIds: string[],
sharedContext?: Context,
): Promise<void>
retrieveType( retrieveType(
typeId: string, typeId: string,
@@ -105,18 +102,15 @@ export interface IProductModuleService {
createTypes( createTypes(
data: CreateProductTypeDTO[], data: CreateProductTypeDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductTypeDTO[]> ): Promise<ProductTypeDTO[]>
updateTypes( updateTypes(
data: UpdateProductTypeDTO[], data: UpdateProductTypeDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductTypeDTO[]> ): Promise<ProductTypeDTO[]>
deleteTypes( deleteTypes(productTypeIds: string[], sharedContext?: Context): Promise<void>
productTypeIds: string[],
sharedContext?: Context,
): Promise<void>
retrieveOption( retrieveOption(
optionId: string, optionId: string,
@@ -138,17 +132,17 @@ export interface IProductModuleService {
createOptions( createOptions(
data: CreateProductOptionDTO[], data: CreateProductOptionDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductOptionDTO[]> ): Promise<ProductOptionDTO[]>
updateOptions( updateOptions(
data: UpdateProductOptionDTO[], data: UpdateProductOptionDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductOptionDTO[]> ): Promise<ProductOptionDTO[]>
deleteOptions( deleteOptions(
productOptionIds: string[], productOptionIds: string[],
sharedContext?: Context, sharedContext?: Context
): Promise<void> ): Promise<void>
retrieveVariant( retrieveVariant(
@@ -189,17 +183,17 @@ export interface IProductModuleService {
createCollections( createCollections(
data: CreateProductCollectionDTO[], data: CreateProductCollectionDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductCollectionDTO[]> ): Promise<ProductCollectionDTO[]>
updateCollections( updateCollections(
data: UpdateProductCollectionDTO[], data: UpdateProductCollectionDTO[],
sharedContext?: Context, sharedContext?: Context
): Promise<ProductCollectionDTO[]> ): Promise<ProductCollectionDTO[]>
deleteCollections( deleteCollections(
productCollectionIds: string[], productCollectionIds: string[],
sharedContext?: Context, sharedContext?: Context
): Promise<void> ): Promise<void>
retrieveCategory( retrieveCategory(
@@ -222,19 +216,16 @@ export interface IProductModuleService {
createCategory( createCategory(
data: CreateProductCategoryDTO, data: CreateProductCategoryDTO,
sharedContext?: Context, sharedContext?: Context
): Promise<ProductCategoryDTO> ): Promise<ProductCategoryDTO>
updateCategory( updateCategory(
categoryId: string, categoryId: string,
data: UpdateProductCategoryDTO, data: UpdateProductCategoryDTO,
sharedContext?: Context, sharedContext?: Context
): Promise<ProductCategoryDTO> ): Promise<ProductCategoryDTO>
deleteCategory( deleteCategory(categoryId: string, sharedContext?: Context): Promise<void>
categoryId: string,
sharedContext?: Context,
): Promise<void>
create( create(
data: CreateProductDTO[], data: CreateProductDTO[],
@@ -248,10 +239,11 @@ export interface IProductModuleService {
delete(productIds: string[], sharedContext?: Context): Promise<void> delete(productIds: string[], sharedContext?: Context): Promise<void>
softDelete( softDelete<TReturnableLinkableKeys extends string = string>(
productIds: string[], productIds: string[],
config?: { returnLinkableKeys?: TReturnableLinkableKeys[] },
sharedContext?: Context sharedContext?: Context
): Promise<ProductDTO[]> ): Promise<Record<string, string[]> | void>
restore(productIds: string[], sharedContext?: Context): Promise<ProductDTO[]> restore(productIds: string[], sharedContext?: Context): Promise<ProductDTO[]>
} }
@@ -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"],
})
})
})
+1
View File
@@ -22,3 +22,4 @@ export * from "./stringify-circular"
export * from "./to-kebab-case" export * from "./to-kebab-case"
export * from "./to-pascal-case" export * from "./to-pascal-case"
export * from "./wrap-handler" export * from "./wrap-handler"
export * from "./map-object-to"
@@ -0,0 +1,53 @@
type RemapInputObject = Record<string, unknown[]>
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<string, any> = {}
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
}
+1
View File
@@ -1,5 +1,6 @@
export * from "./mikro-orm/mikro-orm-repository" export * from "./mikro-orm/mikro-orm-repository"
export * from "./repository" export * from "./repository"
export * from "./utils" export * from "./utils"
export * from "./mikro-orm/utils"
export * from "./mikro-orm/mikro-orm-create-connection" export * from "./mikro-orm/mikro-orm-create-connection"
export * from "./mikro-orm/mikro-orm-soft-deletable-filter" export * from "./mikro-orm/mikro-orm-soft-deletable-filter"
@@ -2,10 +2,10 @@ import { Context, DAL, RepositoryTransformOptions } from "@medusajs/types"
import { MedusaContext } from "../../decorators" import { MedusaContext } from "../../decorators"
import { buildQuery, InjectTransactionManager } from "../../modules-sdk" import { buildQuery, InjectTransactionManager } from "../../modules-sdk"
import { import {
mikroOrmSerializer, getSoftDeletedCascadedEntitiesIdsMappedBy,
mikroOrmUpdateDeletedAtRecursively,
transactionWrapper, transactionWrapper,
} from "../utils" } from "../utils"
import { mikroOrmSerializer, mikroOrmUpdateDeletedAtRecursively } from "./utils"
class MikroOrmBase<T = any> { class MikroOrmBase<T = any> {
protected readonly manager_: any protected readonly manager_: any
@@ -71,13 +71,17 @@ export abstract class MikroOrmAbstractBaseRepository<T = any>
ids: string[], ids: string[],
@MedusaContext() @MedusaContext()
{ transactionManager: manager }: Context = {} { transactionManager: manager }: Context = {}
): Promise<T[]> { ): Promise<[T[], Record<string, unknown[]>]> {
const entities = await this.find({ where: { id: { $in: ids } } as any }) const entities = await this.find({ where: { id: { $in: ids } } as any })
const date = new Date() const date = new Date()
await mikroOrmUpdateDeletedAtRecursively(manager, entities, date) await mikroOrmUpdateDeletedAtRecursively(manager, entities, date)
return entities const softDeletedEntitiesMap = getSoftDeletedCascadedEntitiesIdsMappedBy({
entities,
})
return [entities, softDeletedEntitiesMap]
} }
@InjectTransactionManager() @InjectTransactionManager()
+54
View File
@@ -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 <TOutput extends object>(
data: any,
options?: any
): Promise<TOutput> => {
options ??= {}
const { serialize } = await import("@mikro-orm/core")
const result = serialize(data, options)
return result as unknown as Promise<TOutput>
}
+4 -1
View File
@@ -49,7 +49,10 @@ export abstract class AbstractBaseRepository<T = any>
abstract delete(ids: string[], context?: Context): Promise<void> abstract delete(ids: string[], context?: Context): Promise<void>
abstract softDelete(ids: string[], context?: Context): Promise<T[]> abstract softDelete(
ids: string[],
context?: Context
): Promise<[T[], Record<string, unknown[]>]>
abstract restore(ids: string[], context?: Context): Promise<T[]> abstract restore(ids: string[], context?: Context): Promise<T[]>
+44 -46
View File
@@ -1,4 +1,4 @@
import { SoftDeletableFilterKey } from "../dal" import { isObject } from "../common"
export async function transactionWrapper<TManager = unknown>( export async function transactionWrapper<TManager = unknown>(
this: any, this: any,
@@ -33,54 +33,52 @@ export async function transactionWrapper<TManager = unknown>(
return await transactionMethod.bind(this.manager_)(task, options) return await transactionMethod.bind(this.manager_)(task, options)
} }
export const mikroOrmUpdateDeletedAtRecursively = async < /**
T extends object = any * 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
manager: any, * an object where the keys are the entities name and the values are the entities
entities: T[], * that were soft deleted.
value: Date | null *
) => { * @param entities
* @param deletedEntitiesMap
* @param getEntityName
*/
export function getSoftDeletedCascadedEntitiesIdsMappedBy({
entities,
deletedEntitiesMap,
getEntityName,
}: {
entities: any[]
deletedEntitiesMap?: Map<string, any[]>
getEntityName?: (entity: any) => string
}): Record<string, any[]> {
deletedEntitiesMap ??= new Map<string, any[]>()
getEntityName ??= (entity) => entity.constructor.name
for (const entity of entities) { for (const entity of entities) {
if (!("deleted_at" in entity)) continue const entityName = getEntityName(entity)
;(entity as any).deleted_at = value const shouldSkip = !!deletedEntitiesMap
.get(entityName)
?.some((e) => e.id === entity.id)
const relations = manager if (!entity.deleted_at || shouldSkip) {
.getDriver() continue
.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) const values = deletedEntitiesMap.get(entityName) ?? []
} values.push(entity)
} deletedEntitiesMap.set(entityName, values)
export const mikroOrmSerializer = async <TOutput extends object>( Object.values(entity).forEach((propValue: any) => {
data: any, if (propValue != null && isObject(propValue[0])) {
options?: any getSoftDeletedCascadedEntitiesIdsMappedBy({
): Promise<TOutput> => { entities: propValue,
options ??= {} deletedEntitiesMap,
const { serialize } = await import("@mikro-orm/core") getEntityName,
const result = serialize(data, options) })
return result as unknown as Promise<TOutput> }
})
}
return Object.fromEntries(deletedEntitiesMap)
} }