Skip to content

Commit eeebb35

Browse files
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>
1 parent a35c9ed commit eeebb35

File tree

9 files changed

+277
-130
lines changed

9 files changed

+277
-130
lines changed

.changeset/clean-cars-build.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@medusajs/product": patch
3+
"@medusajs/utils": patch
4+
---
5+
6+
chore(product): Remove upsertWithReplace where needed

integration-tests/http/__tests__/product/admin/product-import.spec.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ medusaIntegrationTestRunner({
533533
)
534534
})
535535

536-
it("should fail on non-existent product fields being present in the CSV", async () => {
536+
it("should successfully skip non-existent product fields being present in the CSV", async () => {
537537
const subscriberExecution = TestEventUtils.waitSubscribersExecution(
538538
`${Modules.NOTIFICATION}.notification.${CommonEvents.CREATED}`,
539539
eventBus
@@ -582,10 +582,21 @@ medusaIntegrationTestRunner({
582582
expect.objectContaining({
583583
data: expect.objectContaining({
584584
title: "Product import",
585-
description: `Failed to import products from file test.csv`,
585+
description:
586+
"Product import of file test.csv completed successfully!",
586587
}),
587588
})
588589
)
590+
591+
const [importedProduct] = (
592+
await api.get("/admin/products?limit=1&order=-id", adminHeaders)
593+
).data.products
594+
595+
expect(importedProduct).not.toEqual(
596+
expect.objectContaining({
597+
field: "Test product",
598+
})
599+
)
589600
})
590601

591602
it("supports importing the v1 template", async () => {

packages/core/utils/src/dal/mikro-orm/integration-tests/__tests__/mikro-orm-repository.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,96 @@ describe("mikroOrmRepository", () => {
693693
)
694694
})
695695

696+
it("should successfully update, create, and delete subentities an entity with a one-to-many relation within a transaction", async () => {
697+
const entity1 = {
698+
id: "1",
699+
title: "en1",
700+
entity2: [
701+
{ id: "2", title: "en2-1", handle: "some-handle" },
702+
{ id: "3", title: "en2-2", handle: "some-other-handle" },
703+
] as any[],
704+
}
705+
706+
const { entities: entities1, performedActions: performedActions1 } =
707+
await manager1().transaction(async (txManager) => {
708+
return await manager1().upsertWithReplace(
709+
[entity1],
710+
{
711+
relations: ["entity2"],
712+
},
713+
{
714+
transactionManager: txManager,
715+
}
716+
)
717+
})
718+
719+
expect(performedActions1).toEqual({
720+
created: {
721+
[Entity1.name]: [expect.objectContaining({ id: entity1.id })],
722+
[Entity2.name]: entities1[0].entity2.map((entity2) =>
723+
expect.objectContaining({ id: entity2.id })
724+
),
725+
},
726+
updated: {},
727+
deleted: {},
728+
})
729+
730+
entity1.entity2 = [
731+
{ id: "2", title: "newen2-1" },
732+
{ title: "en2-3", handle: "some-new-handle" },
733+
]
734+
735+
const { entities: entities2, performedActions: performedActions2 } =
736+
await manager1().transaction(async (txManager) => {
737+
return await manager1().upsertWithReplace(
738+
[entity1],
739+
{
740+
relations: ["entity2"],
741+
},
742+
{ transactionManager: txManager }
743+
)
744+
})
745+
746+
const entity2En23 = entities2[0].entity2.find((e) => e.title === "en2-3")!
747+
748+
expect(performedActions2).toEqual({
749+
created: {
750+
[Entity2.name]: [expect.objectContaining({ id: entity2En23.id })],
751+
},
752+
updated: {
753+
[Entity1.name]: [expect.objectContaining({ id: entity1.id })],
754+
[Entity2.name]: [expect.objectContaining({ id: "2" })],
755+
},
756+
deleted: {
757+
[Entity2.name]: [expect.objectContaining({ id: "3" })],
758+
},
759+
})
760+
761+
const listedEntities = await manager1().find({
762+
where: { id: "1" },
763+
options: { populate: ["entity2"] },
764+
})
765+
766+
expect(listedEntities).toHaveLength(1)
767+
expect(listedEntities[0]).toEqual(
768+
expect.objectContaining({
769+
id: "1",
770+
title: "en1",
771+
})
772+
)
773+
expect(listedEntities[0].entity2.getItems()).toHaveLength(2)
774+
expect(listedEntities[0].entity2.getItems()).toEqual(
775+
expect.arrayContaining([
776+
expect.objectContaining({
777+
title: "newen2-1",
778+
}),
779+
expect.objectContaining({
780+
title: "en2-3",
781+
}),
782+
])
783+
)
784+
})
785+
696786
it("should update an entity with a one-to-many relation that has the same unique constraint key", async () => {
697787
const entity1 = {
698788
id: "1",
@@ -1106,7 +1196,9 @@ describe("mikroOrmRepository", () => {
11061196
describe("error mapping", () => {
11071197
it("should map UniqueConstraintViolationException to MedusaError on upsertWithReplace", async () => {
11081198
const entity3 = { title: "en3" }
1199+
11091200
await manager3().upsertWithReplace([entity3])
1201+
11101202
const err = await manager3()
11111203
.upsertWithReplace([entity3])
11121204
.catch((e) => e.message)

packages/core/utils/src/dal/mikro-orm/mikro-orm-repository.ts

Lines changed: 66 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -803,15 +803,20 @@ export function mikroOrmBaseRepositoryFactory<const T extends object>(
803803
}
804804
})
805805

806-
const qb = manager.qb(relation.pivotEntity)
807-
await qb.insert(pivotData).onConflict().ignore().execute()
808-
809-
await manager.nativeDelete(relation.pivotEntity, {
810-
[parentPivotColumn]: (data as any).id,
811-
[currentPivotColumn]: {
812-
$nin: pivotData.map((d) => d[currentPivotColumn]),
813-
},
814-
})
806+
await promiseAll([
807+
manager
808+
.qb(relation.pivotEntity)
809+
.insert(pivotData)
810+
.onConflict()
811+
.ignore()
812+
.execute(),
813+
manager.nativeDelete(relation.pivotEntity, {
814+
[parentPivotColumn]: (data as any).id,
815+
[currentPivotColumn]: {
816+
$nin: pivotData.map((d) => d[currentPivotColumn]),
817+
},
818+
}),
819+
])
815820

816821
return { entities: normalizedData, performedActions }
817822
}
@@ -826,27 +831,23 @@ export function mikroOrmBaseRepositoryFactory<const T extends object>(
826831
joinColumnsConstraints[joinColumn] = data[referencedColumnName]
827832
})
828833

829-
const toDeleteEntities = await manager.find<any, any, "id">(
830-
relation.type,
831-
{
832-
...joinColumnsConstraints,
833-
id: { $nin: normalizedData.map((d: any) => d.id) },
834-
},
835-
{
836-
fields: ["id"],
837-
}
834+
const deletedRelations = await (
835+
manager.getTransactionContext() ?? manager.getKnex()
838836
)
839-
const toDeleteIds = toDeleteEntities.map((d: any) => d.id)
840-
841-
await manager.nativeDelete(relation.type, {
842-
...joinColumnsConstraints,
843-
id: { $in: toDeleteIds },
844-
})
837+
.queryBuilder()
838+
.from(relation.targetMeta!.collection)
839+
.delete()
840+
.where(joinColumnsConstraints)
841+
.whereNotIn(
842+
"id",
843+
normalizedData.map((d: any) => d.id)
844+
)
845+
.returning("id")
845846

846-
if (toDeleteEntities.length) {
847+
if (deletedRelations.length) {
847848
performedActions.deleted[relation.type] ??= []
848849
performedActions.deleted[relation.type].push(
849-
...toDeleteEntities.map((d) => ({ id: d.id }))
850+
...deletedRelations.map((row) => ({ id: row.id }))
850851
)
851852
}
852853

@@ -970,38 +971,46 @@ export function mikroOrmBaseRepositoryFactory<const T extends object>(
970971
deleted: {},
971972
}
972973

973-
await promiseAll(
974-
entries.map(async (data) => {
975-
const existingEntity = existingEntitiesMap.get(data.id)
976-
orderedEntities.push(data)
977-
if (existingEntity) {
978-
if (skipUpdate) {
979-
return
980-
}
981-
await manager.nativeUpdate(entityName, { id: data.id }, data)
982-
performedActions.updated[entityName] ??= []
983-
performedActions.updated[entityName].push({ id: data.id })
984-
} else {
985-
const qb = manager.qb(entityName)
986-
if (skipUpdate) {
987-
const res = await qb
988-
.insert(data)
989-
.onConflict()
990-
.ignore()
991-
.execute("all", true)
992-
if (res) {
993-
performedActions.created[entityName] ??= []
994-
performedActions.created[entityName].push({ id: data.id })
995-
}
996-
} else {
997-
await manager.insert(entityName, data)
998-
performedActions.created[entityName] ??= []
999-
performedActions.created[entityName].push({ id: data.id })
1000-
// await manager.insert(entityName, data)
1001-
}
974+
const promises: Promise<any>[] = []
975+
const toInsert: unknown[] = []
976+
let shouldInsert = false
977+
978+
entries.map(async (data) => {
979+
const existingEntity = existingEntitiesMap.get(data.id)
980+
orderedEntities.push(data)
981+
if (existingEntity) {
982+
if (skipUpdate) {
983+
return
1002984
}
1003-
})
1004-
)
985+
const update = manager.nativeUpdate(entityName, { id: data.id }, data)
986+
promises.push(update)
987+
988+
performedActions.updated[entityName] ??= []
989+
performedActions.updated[entityName].push({ id: data.id })
990+
} else {
991+
shouldInsert = true
992+
toInsert.push(data)
993+
}
994+
})
995+
996+
if (shouldInsert) {
997+
let insertQb = manager.qb(entityName).insert(toInsert).returning("id")
998+
999+
if (skipUpdate) {
1000+
insertQb = insertQb.onConflict().ignore()
1001+
}
1002+
1003+
promises.push(
1004+
insertQb.execute("all", true).then((res: { id: string }[]) => {
1005+
performedActions.created[entityName] ??= []
1006+
performedActions.created[entityName].push(
1007+
...res.map((data) => ({ id: data.id }))
1008+
)
1009+
})
1010+
)
1011+
}
1012+
1013+
await promiseAll(promises)
10051014

10061015
return { orderedEntities, performedActions }
10071016
}

packages/core/utils/src/dml/helpers/entity-builder/define-relationship.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,17 @@ export function defineHasManyRelationship(
283283
) {
284284
const shouldRemoveRelated = !!cascades.delete?.includes(relationship.name)
285285

286-
OneToMany({
286+
const options: Parameters<typeof OneToMany>[0] = {
287287
entity: relatedModelName,
288288
orphanRemoval: true,
289289
mappedBy: relationship.mappedBy || camelToSnakeCase(MikroORMEntity.name),
290-
cascade: shouldRemoveRelated
291-
? (["persist", "soft-remove"] as any)
292-
: undefined,
293-
})(MikroORMEntity.prototype, relationship.name)
290+
}
291+
292+
if (shouldRemoveRelated) {
293+
options.cascade = ["persist", "soft-remove"] as any
294+
}
295+
296+
OneToMany(options)(MikroORMEntity.prototype, relationship.name)
294297
}
295298

296299
/**

packages/modules/product/integration-tests/__fixtures__/product/data/create-product.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@ export const buildProductAndRelationsData = ({
4848
images,
4949
status,
5050
type_id,
51-
tags,
51+
tag_ids,
5252
options,
5353
variants,
5454
collection_id,
55-
}: Partial<ProductTypes.CreateProductDTO> & { tags: { value: string }[] }) => {
55+
}: Partial<ProductTypes.CreateProductDTO>) => {
5656
const defaultOptionTitle = "test-option"
5757
const defaultOptionValue = "test-value"
5858

@@ -66,7 +66,7 @@ export const buildProductAndRelationsData = ({
6666
status: status ?? ProductStatus.PUBLISHED,
6767
images: (images ?? []) as ProductImage[],
6868
type_id,
69-
tags: tags ?? [{ value: "tag-1" }],
69+
tag_ids,
7070
collection_id,
7171
options: options ?? [
7272
{

0 commit comments

Comments
 (0)