Skip to content

Commit

Permalink
feat(medusa): Improve product update performances
Browse files Browse the repository at this point in the history
  • Loading branch information
adrien2p committed Mar 8, 2023
1 parent ce577f2 commit 8ddff58
Show file tree
Hide file tree
Showing 12 changed files with 771 additions and 190 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
import { EventBusServiceMock } from "../../../../../services/__mocks__/event-bus"

describe("POST /admin/products/:id", () => {
describe("successfully updates a product", () => {
Expand Down Expand Up @@ -49,7 +48,7 @@ describe("POST /admin/products/:id", () => {
})

it("successfully updates variants and create new ones", async () => {
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(2)
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(2)
})
Expand All @@ -74,7 +73,7 @@ describe("POST /admin/products/:id", () => {
)
expect(subject.status).toEqual(404)
expect(subject.error.text).toEqual(
`{"type":"not_found","message":"Variant with id: test_321 is not associated with this product"}`
`{"type":"not_found","message":"Variants with id: test_321 are not associated with this product"}`
)
})
})
Expand Down
131 changes: 80 additions & 51 deletions packages/medusa/src/api/routes/admin/products/update-product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,17 @@ import {
ValidateIf,
ValidateNested,
} from "class-validator"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import {
PricingService,
ProductService,
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../services"
import {
ProductProductCategoryReq,
ProductSalesChannelReq,
ProductTagReq,
ProductTypeReq,
ProductProductCategoryReq,
} from "../../../../types/product"

import { Type } from "class-transformer"
Expand All @@ -32,6 +31,7 @@ import { ProductStatus, ProductVariant } from "../../../../models"
import {
CreateProductVariantInput,
ProductVariantPricesUpdateReq,
UpdateProductVariantInput,
} from "../../../../types/product-variant"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
Expand All @@ -43,9 +43,13 @@ import {
} from "./transaction/create-product-variant"
import { IInventoryService } from "../../../../interfaces"
import { Logger } from "../../../../types/global"
import {
defaultAdminProductFields,
defaultAdminProductRelations,
} from "./index"

/**
* @oas [post] /products/{id}
* @oas [post] /admin/products/{id}
* operationId: "PostProductsProduct"
* summary: "Update a Product"
* description: "Updates a Product"
Expand Down Expand Up @@ -86,7 +90,7 @@ import { Logger } from "../../../../types/global"
* - api_token: []
* - cookie_auth: []
* tags:
* - Product
* - Products
* responses:
* 200:
* description: OK
Expand Down Expand Up @@ -125,31 +129,66 @@ export default async (req, res) => {

const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
const productServiceTx = productService.withTransaction(transactionManager)

const { variants } = validated
delete validated.variants

await productService
.withTransaction(transactionManager)
.update(id, validated)
await productServiceTx.update(id, validated)

if (!variants) {
return
}

const product = await productService
.withTransaction(transactionManager)
.retrieve(id, {
relations: ["variants"],
})
const product = await productServiceTx.retrieve(id, {
relations: ["variants"],
})

const productVariantMap = new Map(product.variants.map((v) => [v.id, v]))
const variantWithIdSet = new Set()

const variantIdsNotBelongingToProduct: string[] = []
const variantsToUpdate: {
variant: ProductVariant
updateData: UpdateProductVariantInput
}[] = []
const variantsToCreate: ProductVariantReq[] = []

// Preparing the data step
for (const [variantRank, variant] of variants.entries()) {
if (!variant.id) {
Object.assign(variant, {
variant_rank: variantRank,
options: variant.options || [],
prices: variant.prices || [],
})
variantsToCreate.push(variant)
continue
}

// Will be used to find the variants that should be removed during the next steps
variantWithIdSet.add(variant.id)

// Iterate product variants and update their properties accordingly
for (const variant of product.variants) {
const exists = variants.find((v) => v.id && variant.id === v.id)
if (!exists) {
await productVariantService
.withTransaction(transactionManager)
.delete(variant.id)
if (!productVariantMap.has(variant.id)) {
variantIdsNotBelongingToProduct.push(variant.id)
continue
}

const productVariant = productVariantMap.get(variant.id)!
Object.assign(variant, {
variant_rank: variantRank,
product_id: productVariant.product_id,
})
variantsToUpdate.push({ variant: productVariant, updateData: variant })
}

if (variantIdsNotBelongingToProduct.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Variants with id: ${variantIdsNotBelongingToProduct.join(
", "
)} are not associated with this product`
)
}

const allVariantTransactions: DistributedTransaction[] = []
Expand All @@ -160,44 +199,32 @@ export default async (req, res) => {
productVariantService,
}

for (const [index, newVariant] of variants.entries()) {
const variantRank = index
const promises: Promise<any>[] = []
const productVariantServiceTx =
productVariantService.withTransaction(transactionManager)

if (newVariant.id) {
const variant = product.variants.find((v) => v.id === newVariant.id)
// Delete the variant that does not exist anymore from the provided variants
const variantIdsToDelete = [...productVariantMap.keys()].filter(
(variantId) => !variantWithIdSet.has(variantId)
)

if (!variant) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Variant with id: ${newVariant.id} is not associated with this product`
)
}
if (variantIdsToDelete) {
promises.push(productVariantServiceTx.delete(variantIdsToDelete))
}

await productVariantService
.withTransaction(transactionManager)
.update(variant, {
...newVariant,
variant_rank: variantRank,
product_id: variant.product_id,
})
} else {
// If the provided variant does not have an id, we assume that it
// should be created
if (variantsToUpdate.length) {
promises.push(productVariantServiceTx.update(variantsToUpdate))
}

promises.push(
...variantsToCreate.map(async (data) => {
try {
const input = {
...newVariant,
variant_rank: variantRank,
options: newVariant.options || [],
prices: newVariant.prices || [],
}

const varTransation = await createVariantTransaction(
const varTransaction = await createVariantTransaction(
transactionDependencies,
product.id,
input as CreateProductVariantInput
data as CreateProductVariantInput
)
allVariantTransactions.push(varTransation)
allVariantTransactions.push(varTransaction)
} catch (e) {
await Promise.all(
allVariantTransactions.map(async (transaction) => {
Expand All @@ -210,8 +237,10 @@ export default async (req, res) => {

throw e
}
}
}
})
)

await Promise.all(promises)
})

const rawProduct = await productService.retrieve(id, {
Expand Down
47 changes: 43 additions & 4 deletions packages/medusa/src/repositories/image.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,42 @@
import { EntityRepository, In, Repository } from "typeorm"
import { Image } from "../models/image"
import { Image } from "../models"
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"
import { RelationIdLoader } from "typeorm/query-builder/relation-id/RelationIdLoader"
import { RawSqlResultsToEntityTransformer } from "typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer"

@EntityRepository(Image)
export class ImageRepository extends Repository<Image> {
async insertBulk(data: QueryDeepPartialEntity<Image>[]): Promise<Image[]> {
const queryBuilder = this.createQueryBuilder()
const rawMoneyAmounts = await queryBuilder
.insert()
.into(Image)
.values(data)
.returning("*")
.execute()

const relationIdLoader = new RelationIdLoader(
queryBuilder.connection,
this.queryRunner,
queryBuilder.expressionMap.relationIdAttributes
)
const rawRelationIdResults = await relationIdLoader.load(
rawMoneyAmounts.raw
)
const transformer = new RawSqlResultsToEntityTransformer(
queryBuilder.expressionMap,
queryBuilder.connection.driver,
rawRelationIdResults,
[],
this.queryRunner
)

return transformer.transform(
rawMoneyAmounts.raw,
queryBuilder.expressionMap.mainAlias!
) as Image[]
}

public async upsertImages(imageUrls: string[]) {
const existingImages = await this.find({
where: {
Expand All @@ -14,16 +48,21 @@ export class ImageRepository extends Repository<Image> {
)

const upsertedImgs: Image[] = []
const imageToCreate: QueryDeepPartialEntity<Image>[] = []

for (const url of imageUrls) {
imageUrls.map(async (url) => {
const aImg = existingImagesMap.get(url)
if (aImg) {
upsertedImgs.push(aImg)
} else {
const newImg = this.create({ url })
const savedImg = await this.save(newImg)
upsertedImgs.push(savedImg)
imageToCreate.push(newImg as QueryDeepPartialEntity<Image>)
}
})

if (imageToCreate.length) {
const newImgs = await this.insertBulk(imageToCreate)
upsertedImgs.push(...newImgs)
}

return upsertedImgs
Expand Down
Loading

0 comments on commit 8ddff58

Please sign in to comment.