chore(product): revamp upsertWithReplace and Remove its usage from product creation (#11585)

**What**
- Move create product to use native create by structuring the data appropriately, it means no more `upsertWithReplace` being very poorly performant and got 20x better performances on staging
- Improvements in `upsertWithReplace` to still get performance boost for places that still relies on it. Mostly bulking the operations when possible

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-02-26 10:53:13 +01:00
committed by GitHub
parent a35c9ed741
commit eeebb35758
9 changed files with 277 additions and 130 deletions

View File

@@ -48,11 +48,11 @@ export const buildProductAndRelationsData = ({
images,
status,
type_id,
tags,
tag_ids,
options,
variants,
collection_id,
}: Partial<ProductTypes.CreateProductDTO> & { tags: { value: string }[] }) => {
}: Partial<ProductTypes.CreateProductDTO>) => {
const defaultOptionTitle = "test-option"
const defaultOptionValue = "test-value"
@@ -66,7 +66,7 @@ export const buildProductAndRelationsData = ({
status: status ?? ProductStatus.PUBLISHED,
images: (images ?? []) as ProductImage[],
type_id,
tags: tags ?? [{ value: "tag-1" }],
tag_ids,
collection_id,
options: options ?? [
{

View File

@@ -30,7 +30,7 @@ import {
createTypes,
} from "../../__fixtures__/product"
jest.setTimeout(300000)
jest.setTimeout(3000000)
moduleIntegrationTestRunner<IProductModuleService>({
moduleName: Modules.PRODUCT,
@@ -181,6 +181,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
})
it("should update a product and upsert relations that are not created yet", async () => {
const tags = await service.createProductTags([{ value: "tag-1" }])
const data = buildProductAndRelationsData({
images,
thumbnail: images[0].url,
@@ -190,6 +191,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
values: ["val-1", "val-2"],
},
],
tag_ids: [tags[0].id],
})
const variantTitle = data.variants[0].title
@@ -217,7 +219,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
productBefore.options = data.options
productBefore.images = data.images
productBefore.thumbnail = data.thumbnail
productBefore.tags = data.tags
productBefore.tag_ids = data.tag_ids
const updatedProducts = await service.upsertProducts([productBefore])
expect(updatedProducts).toHaveLength(1)
@@ -273,7 +275,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
tags: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: productBefore.tags?.[0].value,
value: tags[0].value,
}),
]),
variants: expect.arrayContaining([
@@ -856,9 +858,11 @@ moduleIntegrationTestRunner<IProductModuleService>({
describe("create", function () {
let images = [{ url: "image-1" }]
it("should create a product", async () => {
const tags = await service.createProductTags([{ value: "tag-1" }])
const data = buildProductAndRelationsData({
images,
thumbnail: images[0].url,
tag_ids: [tags[0].id],
})
const productsCreated = await service.createProducts([data])
@@ -917,7 +921,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
tags: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
value: data.tags[0].value,
value: tags[0].value,
}),
]),
variants: expect.arrayContaining([
@@ -1164,15 +1168,17 @@ moduleIntegrationTestRunner<IProductModuleService>({
productCollectionOne = collections[0]
productCollectionTwo = collections[1]
const tags = await service.createProductTags([{ value: "tag-1" }])
const resp = await service.createProducts([
buildProductAndRelationsData({
collection_id: productCollectionOne.id,
options: [{ title: "size", values: ["large", "small"] }],
variants: [{ title: "variant 1", options: { size: "small" } }],
tag_ids: [tags[0].id],
}),
buildProductAndRelationsData({
collection_id: productCollectionTwo.id,
tags: [],
}),
])

View File

@@ -29,7 +29,7 @@
"resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json",
"build": "rimraf dist && tsc --build && npm run resolve:aliases",
"test": "jest --runInBand --bail --forceExit -- src/**/__tests__/**/*.ts",
"test:integration": "jest --forceExit -- integration-tests/__tests__/**/*.ts",
"test:integration": "jest --runInBand --bail --forceExit -- integration-tests/__tests__/**/*.ts",
"migration:initial": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
"migration:create": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",

View File

@@ -26,6 +26,7 @@ import { ProductCategoryService } from "@services"
import {
arrayDifference,
EmitEvents,
generateEntityId,
InjectManager,
InjectTransactionManager,
isDefined,
@@ -1568,75 +1569,82 @@ export default class ProductModuleService
})
)
const { entities: productData } =
await this.productService_.upsertWithReplace(
normalizedInput,
const tagIds = normalizedInput
.flatMap((d) => (d as any).tags ?? [])
.map((t) => t.id)
let existingTags: InferEntityType<typeof ProductTag>[] = []
if (tagIds.length) {
existingTags = await this.productTagService_.list(
{
relations: ["tags", "categories"],
id: tagIds,
},
{},
sharedContext
)
}
await promiseAll(
// Note: It's safe to rely on the order here as `upsertWithReplace` preserves the order of the input
normalizedInput.map(async (product, i) => {
const upsertedProduct: any = productData[i]
upsertedProduct.options = []
upsertedProduct.variants = []
const existingTagsMap = new Map(existingTags.map((tag) => [tag.id, tag]))
if (product.options?.length) {
const { entities: productOptions } =
await this.productOptionService_.upsertWithReplace(
product.options?.map((option) => ({
...option,
product_id: upsertedProduct.id,
})) ?? [],
{ relations: ["values"] },
sharedContext
)
upsertedProduct.options = productOptions
}
const productsToCreate = normalizedInput.map((product) => {
const productId = generateEntityId(product.id, "prod")
product.id = productId
if (product.variants?.length) {
const { entities: productVariants } =
await this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
product.variants?.map((v) => ({
...v,
product_id: upsertedProduct.id,
})) ?? [],
upsertedProduct.options
),
{ relations: ["options"] },
sharedContext
)
upsertedProduct.variants = productVariants
}
if ((product as any).categories?.length) {
;(product as any).categories = (product as any).categories.map(
(category: { id: string }) => category.id
)
}
if (Array.isArray(product.images)) {
if (product.images.length) {
const { entities: productImages } =
await this.productImageService_.upsertWithReplace(
product.images.map((image, rank) => ({
...image,
product_id: upsertedProduct.id,
rank,
})),
{},
sharedContext
)
upsertedProduct.images = productImages
} else {
await this.productImageService_.delete(
{ product_id: upsertedProduct.id },
sharedContext
if (product.variants?.length) {
const normalizedVariants = product.variants.map((variant) => {
const variantId = generateEntityId((variant as any).id, "variant")
;(variant as any).id = variantId
Object.entries(variant.options ?? {}).forEach(([key, value]) => {
const productOption = product.options?.find(
(option) => option.title === key
)!
const productOptionValue = productOption.values?.find(
(optionValue) => (optionValue as any).value === value
)!
;(productOptionValue as any).variants ??= []
;(productOptionValue as any).variants.push(variant)
})
delete variant.options
return variant
})
product.variants = normalizedVariants
}
if ((product as any).tags?.length) {
;(product as any).tags = (product as any).tags.map(
(tag: { id: string }) => {
const existingTag = existingTagsMap.get(tag.id)
if (existingTag) {
return existingTag
}
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Tag with id ${tag.id} not found. Please create the tag before associating it with the product.`
)
}
}
})
)
}
return product
})
const createdProducts = await this.productService_.create(
productsToCreate,
sharedContext
)
return productData
return createdProducts
}
@InjectTransactionManager()
@@ -1916,6 +1924,17 @@ export default class ProductModuleService
productData.thumbnail = productData.images[0].url
}
if (productData.images?.length) {
productData.images = productData.images.map((image, index) =>
(image as { rank?: number }).rank != null
? image
: {
...image,
rank: index,
}
)
}
return productData
}
@@ -1929,6 +1948,7 @@ export default class ProductModuleService
}
if (productData.options?.length) {
// TODO: Instead of fetching per product, this should fetch for all product allowing for only one query instead of X
const dbOptions = await this.productOptionService_.list(
{ product_id: productData.id },
{ relations: ["values"] },