Skip to content

Commit 7ef1349

Browse files
authored
Merge pull request medusajs#12367 from medusajs/fix/refactor-update-products
fix: refactor batch product update
2 parents f81eb51 + 1ff5c4b commit 7ef1349

File tree

3 files changed

+302
-152
lines changed

3 files changed

+302
-152
lines changed

packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts

Lines changed: 206 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import {
1212
ProductStatus,
1313
} from "@medusajs/framework/utils"
1414
import {
15-
ProductImage,
1615
Product,
1716
ProductCategory,
1817
ProductCollection,
18+
ProductImage,
1919
ProductType,
2020
} from "@models"
2121

@@ -180,6 +180,22 @@ moduleIntegrationTestRunner<IProductModuleService>({
180180
productTwo = res[1]
181181
})
182182

183+
it("should update multiple products", async () => {
184+
await service.upsertProducts([
185+
{ id: productOne.id, title: "updated title 1" },
186+
{ id: productTwo.id, title: "updated title 2" },
187+
])
188+
189+
const products = await service.listProducts(
190+
{ id: [productOne.id, productTwo.id] },
191+
{ relations: ["*"] }
192+
)
193+
194+
expect(products).toHaveLength(2)
195+
expect(products[0].title).toEqual("updated title 1")
196+
expect(products[1].title).toEqual("updated title 2")
197+
})
198+
183199
it("should update a product and upsert relations that are not created yet", async () => {
184200
const tags = await service.createProductTags([{ value: "tag-1" }])
185201
const data = buildProductAndRelationsData({
@@ -400,9 +416,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
400416
options: { size: "x", color: "red" }, // update options
401417
},
402418
{
403-
id: existingVariant2.id,
404-
title: "new variant 2",
405-
options: { size: "l", color: "green" }, // just preserve old one
419+
id: existingVariant2.id, // just preserve old one
406420
},
407421
{
408422
product_id: product.id,
@@ -722,30 +736,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
722736
expect(error).toEqual(`Product with id: does-not-exist was not found`)
723737
})
724738

725-
it("should throw because variant doesn't have all options set", async () => {
726-
const error = await service
727-
.createProducts([
728-
{
729-
title: "Product with variants and options",
730-
options: [
731-
{ title: "opt1", values: ["1", "2"] },
732-
{ title: "opt2", values: ["3", "4"] },
733-
],
734-
variants: [
735-
{
736-
title: "missing option",
737-
options: { opt1: "1" },
738-
},
739-
],
740-
},
741-
])
742-
.catch((e) => e)
743-
744-
expect(error.message).toEqual(
745-
`Product "Product with variants and options" has variants with missing options: [missing option]`
746-
)
747-
})
748-
749739
it("should update, create and delete variants", async () => {
750740
const updateData = {
751741
id: productTwo.id,
@@ -849,6 +839,148 @@ moduleIntegrationTestRunner<IProductModuleService>({
849839
])
850840
)
851841
})
842+
843+
it("should simultaneously update options and variants", async () => {
844+
const updateData = {
845+
id: productTwo.id,
846+
options: [{ title: "material", values: ["cotton", "silk"] }],
847+
variants: [{ title: "variant 1", options: { material: "cotton" } }],
848+
}
849+
850+
await service.upsertProducts([updateData])
851+
852+
const product = await service.retrieveProduct(productTwo.id, {
853+
relations: ["*"],
854+
})
855+
856+
expect(product.options).toHaveLength(1)
857+
expect(product.options[0].title).toEqual("material")
858+
expect(product.options[0].values).toEqual(
859+
expect.arrayContaining([
860+
expect.objectContaining({
861+
value: "cotton",
862+
}),
863+
expect.objectContaining({
864+
value: "silk",
865+
}),
866+
])
867+
)
868+
869+
expect(product.variants).toHaveLength(1)
870+
expect(product.variants[0].options).toEqual(
871+
expect.arrayContaining([
872+
expect.objectContaining({
873+
value: "cotton",
874+
}),
875+
])
876+
)
877+
})
878+
879+
it("should throw an error when some tag id does not exist", async () => {
880+
const error = await service
881+
.updateProducts(productOne.id, {
882+
tag_ids: ["does-not-exist"],
883+
})
884+
.catch((e) => e)
885+
886+
expect(error?.message).toEqual(
887+
`You tried to set relationship product_tag_id: does-not-exist, but such entity does not exist`
888+
)
889+
})
890+
891+
it("should throw an error when some category id does not exist", async () => {
892+
const error = await service
893+
.updateProducts(productOne.id, {
894+
category_ids: ["does-not-exist"],
895+
})
896+
.catch((e) => e)
897+
898+
expect(error?.message).toEqual(
899+
`You tried to set relationship product_category_id: does-not-exist, but such entity does not exist`
900+
)
901+
})
902+
903+
it("should throw an error when collection id does not exist", async () => {
904+
const error = await service
905+
.updateProducts(productOne.id, {
906+
collection_id: "does-not-exist",
907+
})
908+
.catch((e) => e)
909+
910+
expect(error?.message).toEqual(
911+
`You tried to set relationship collection_id: does-not-exist, but such entity does not exist`
912+
)
913+
})
914+
915+
it("should throw an error when type id does not exist", async () => {
916+
const error = await service
917+
.updateProducts(productOne.id, {
918+
type_id: "does-not-exist",
919+
})
920+
.catch((e) => e)
921+
922+
expect(error?.message).toEqual(
923+
`You tried to set relationship type_id: does-not-exist, but such entity does not exist`
924+
)
925+
})
926+
927+
it("should throw if two variants have the same options combination", async () => {
928+
const error = await service
929+
.updateProducts(productTwo.id, {
930+
variants: [
931+
{
932+
title: "variant 1",
933+
options: { size: "small", color: "blue" },
934+
},
935+
{
936+
title: "variant 2",
937+
options: { size: "small", color: "blue" },
938+
},
939+
],
940+
})
941+
.catch((e) => e)
942+
943+
expect(error?.message).toEqual(
944+
`Variant "variant 1" has same combination of option values as "variant 2".`
945+
)
946+
})
947+
948+
it("should throw if a variant doesn't have all options set", async () => {
949+
const error = await service
950+
.updateProducts(productTwo.id, {
951+
variants: [
952+
{
953+
title: "variant 1",
954+
options: { size: "small" },
955+
},
956+
],
957+
})
958+
.catch((e) => e)
959+
960+
expect(error?.message).toEqual(
961+
`Product has 2 option values but there were 1 provided option values for the variant: variant 1.`
962+
)
963+
})
964+
965+
it("should throw if a variant uses a non-existing option", async () => {
966+
const error = await service
967+
.updateProducts(productTwo.id, {
968+
variants: [
969+
{
970+
title: "variant 1",
971+
options: {
972+
size: "small",
973+
non_existing_option: "non_existing_value",
974+
},
975+
},
976+
],
977+
})
978+
.catch((e) => e)
979+
980+
expect(error?.message).toEqual(
981+
`Option value non_existing_value does not exist for option non_existing_option`
982+
)
983+
})
852984
})
853985

854986
describe("create", function () {
@@ -963,6 +1095,30 @@ moduleIntegrationTestRunner<IProductModuleService>({
9631095
}
9641096
)
9651097
})
1098+
1099+
it("should throw because variant doesn't have all options set", async () => {
1100+
const error = await service
1101+
.createProducts([
1102+
{
1103+
title: "Product with variants and options",
1104+
options: [
1105+
{ title: "opt1", values: ["1", "2"] },
1106+
{ title: "opt2", values: ["3", "4"] },
1107+
],
1108+
variants: [
1109+
{
1110+
title: "missing option",
1111+
options: { opt1: "1" },
1112+
},
1113+
],
1114+
},
1115+
])
1116+
.catch((e) => e)
1117+
1118+
expect(error.message).toEqual(
1119+
`Product "Product with variants and options" has variants with missing options: [missing option]`
1120+
)
1121+
})
9661122
})
9671123

9681124
describe("softDelete", function () {
@@ -1408,6 +1564,28 @@ moduleIntegrationTestRunner<IProductModuleService>({
14081564
])
14091565
})
14101566

1567+
it("should delete images if empty array is passed on update", async () => {
1568+
const images = [
1569+
{ url: "image-1" },
1570+
{ url: "image-2" },
1571+
{ url: "image-3" },
1572+
]
1573+
1574+
const [product] = await service.createProducts([
1575+
buildProductAndRelationsData({ images }),
1576+
])
1577+
1578+
await service.updateProducts(product.id, {
1579+
images: [],
1580+
})
1581+
1582+
const productAfterUpdate = await service.retrieveProduct(product.id, {
1583+
relations: ["*"],
1584+
})
1585+
1586+
expect(productAfterUpdate.images).toHaveLength(0)
1587+
})
1588+
14111589
it("should retrieve images in the correct order consistently", async () => {
14121590
const images = Array.from({ length: 1000 }, (_, i) => ({
14131591
url: `image-${i + 1}`,

packages/modules/product/src/repositories/product.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Product } from "@models"
1+
import { Product, ProductOption } from "@models"
22

3-
import { Context, DAL } from "@medusajs/framework/types"
4-
import { DALUtils } from "@medusajs/framework/utils"
5-
import { SqlEntityManager } from "@mikro-orm/postgresql"
3+
import { Context, DAL, InferEntityType } from "@medusajs/framework/types"
4+
import { buildQuery, DALUtils } from "@medusajs/framework/utils"
5+
import { SqlEntityManager, wrap } from "@mikro-orm/postgresql"
66

77
// eslint-disable-next-line max-len
88
export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory(
@@ -13,6 +13,68 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory(
1313
super(...arguments)
1414
}
1515

16+
async deepUpdate(
17+
updates: any[],
18+
validateVariantOptions: (
19+
variants: any[],
20+
options: InferEntityType<typeof ProductOption>[]
21+
) => void,
22+
context: Context = {}
23+
): Promise<InferEntityType<typeof Product>[]> {
24+
const products = await this.find(
25+
buildQuery({ id: updates.map((p) => p.id) }, { relations: ["*"] }),
26+
context
27+
)
28+
const productsMap = new Map(products.map((p) => [p.id, p]))
29+
30+
for (const update of updates) {
31+
const product = productsMap.get(update.id)!
32+
33+
// Assign the options first, so they'll be available for the variants loop below
34+
if (update.options) {
35+
wrap(product).assign({ options: update.options })
36+
delete update.options // already assigned above, so no longer necessary
37+
}
38+
39+
if (update.variants) {
40+
validateVariantOptions(update.variants, product.options)
41+
42+
update.variants.forEach((variant: any) => {
43+
if (variant.options) {
44+
variant.options = Object.entries(variant.options).map(
45+
([key, value]) => {
46+
const productOption = product.options.find(
47+
(option) => option.title === key
48+
)!
49+
const productOptionValue = productOption.values?.find(
50+
(optionValue) => optionValue.value === value
51+
)!
52+
return productOptionValue.id
53+
}
54+
)
55+
}
56+
})
57+
}
58+
59+
if (update.tags) {
60+
update.tags = update.tags.map((t: { id: string }) => t.id)
61+
}
62+
if (update.categories) {
63+
update.categories = update.categories.map((c: { id: string }) => c.id)
64+
}
65+
if (update.images) {
66+
update.images = update.images.map((image: any, index: number) => ({
67+
...image,
68+
rank: index,
69+
}))
70+
}
71+
72+
wrap(product!).assign(update)
73+
}
74+
75+
return products
76+
}
77+
1678
/**
1779
* In order to be able to have a strict not in categories, and prevent a product
1880
* to be return in the case it also belongs to other categories, we need to

0 commit comments

Comments
 (0)