feat: Improve how options are defined and handled for product (#7007)

* feat: Improve how options are defined and handled for product

* fix: Updating option values supports passing without id
This commit is contained in:
Stevche Radevski
2024-04-08 15:29:41 +02:00
committed by GitHub
parent 7b4ff1ae57
commit fa5c9bbe8f
18 changed files with 320 additions and 437 deletions

View File

@@ -806,10 +806,8 @@ medusaIntegrationTestRunner({
() =>
expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^varopt_*/),
option_value: expect.objectContaining({
value: "100",
}),
id: expect.stringMatching(/^optval_*/),
value: "100",
}),
])
),
@@ -910,10 +908,8 @@ medusaIntegrationTestRunner({
() =>
expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^varopt_*/),
option_value: expect.objectContaining({
value: "large",
}),
id: expect.stringMatching(/^optval_*/),
value: "large",
}),
])
),
@@ -963,10 +959,8 @@ medusaIntegrationTestRunner({
() =>
expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^varopt_*/),
option_value: expect.objectContaining({
value: "green",
}),
id: expect.stringMatching(/^optval_*/),
value: "green",
}),
])
),
@@ -1394,21 +1388,17 @@ medusaIntegrationTestRunner({
() =>
expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^varopt_*/),
option_value: expect.objectContaining({
value: "large",
option: expect.objectContaining({
title: "size",
}),
id: expect.stringMatching(/^optval_*/),
value: "large",
option: expect.objectContaining({
title: "size",
}),
}),
expect.objectContaining({
id: expect.stringMatching(/^varopt_*/),
option_value: expect.objectContaining({
value: "green",
option: expect.objectContaining({
title: "color",
}),
id: expect.stringMatching(/^optval_*/),
value: "green",
option: expect.objectContaining({
title: "color",
}),
}),
])
@@ -1661,12 +1651,10 @@ medusaIntegrationTestRunner({
() =>
expect.arrayContaining([
expect.objectContaining({
id: expect.stringMatching(/^varopt_*/),
option_value: expect.objectContaining({
value: "large",
option: expect.objectContaining({
title: "size",
}),
id: expect.stringMatching(/^optval_*/),
value: "large",
option: expect.objectContaining({
title: "size",
}),
}),
])

View File

@@ -82,7 +82,7 @@ export const useProductVariantTableColumns = (product?: Product) => {
),
cell: ({ row }) => {
const variantOpt: any = row.original.options.find(
(opt: any) => opt.option_value.option_id === option.id
(opt: any) => opt.option_id === option.id
)
if (!variantOpt) {
return <PlaceholderCell />
@@ -94,7 +94,7 @@ export const useProductVariantTableColumns = (product?: Product) => {
size="2xsmall"
className="flex min-w-[20px] items-center justify-center"
>
{variantOpt.option_value.value}
{variantOpt.value}
</Badge>
</div>
)

View File

@@ -53,10 +53,8 @@ export const ProductEditVariantForm = ({
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const defaultOptions = product.options.reduce((acc: any, option: any) => {
const varOpt = variant.options.find(
(o: any) => o.option_value.option_id === option.id
)
acc[option.title] = varOpt?.option_value?.value
const varOpt = variant.options.find((o: any) => o.option_id === option.id)
acc[option.title] = varOpt?.value
return acc
}, {})

View File

@@ -0,0 +1,34 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { queryClient } from "../../../lib/medusa"
import { productsQueryKeys } from "../../../hooks/api/products"
import { client } from "../../../lib/client"
const queryKey = (id: string) => {
return [productsQueryKeys.detail(id)]
}
const queryFn = async (id: string) => {
const productRes = await client.products.retrieve(id)
return {
initialData: productRes,
isStockAndInventoryEnabled: false,
}
}
const editProductVariantQuery = (id: string) => ({
queryKey: queryKey(id),
queryFn: async () => queryFn(id),
})
export const editProductVariantLoader = async ({
params,
}: LoaderFunctionArgs) => {
const id = params.id
const query = editProductVariantQuery(id!)
return (
queryClient.getQueryData<ReturnType<typeof queryFn>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -24,7 +24,6 @@ export const defaultAdminProductsVariantFields = [
"barcode",
"*prices",
"*options",
"*options.option_value",
]
export const retrieveVariantConfig = {
@@ -85,7 +84,6 @@ export const defaultAdminProductFields = [
"*variants",
"*variants.prices",
"*variants.options",
"*variants.options.option_value",
"*sales_channels",
]

View File

@@ -28,6 +28,10 @@ moduleIntegrationTestRunner({
title: "size",
values: ["large", "small"],
},
{
title: "color",
values: ["red", "blue"],
},
],
})
@@ -182,7 +186,7 @@ moduleIntegrationTestRunner({
expect(productVariant.title).toEqual("new test")
})
it("should update the options of a variant successfully", async () => {
it("should upsert the options of a variant successfully", async () => {
await service.upsertVariants([
{
id: variantOne.id,
@@ -191,12 +195,33 @@ moduleIntegrationTestRunner({
])
const productVariant = await service.retrieveVariant(variantOne.id, {
relations: ["options", "options.option_value", "options.variant"],
relations: ["options"],
})
expect(productVariant.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
option_value: expect.objectContaining({ value: "small" }),
value: "small",
}),
])
)
})
it("should do a partial update on the options of a variant successfully", async () => {
await service.updateVariants(variantOne.id, {
options: { size: "small", color: "red" },
})
const productVariant = await service.retrieveVariant(variantOne.id, {
relations: ["options"],
})
expect(productVariant.options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "small",
}),
expect.objectContaining({
value: "red",
}),
])
)
@@ -222,7 +247,7 @@ moduleIntegrationTestRunner({
const beforeDeletedVariants = await service.listVariants(
{ id: variantOne.id },
{
relations: ["options", "options.option_value", "options.variant"],
relations: ["options"],
}
)
@@ -230,7 +255,7 @@ moduleIntegrationTestRunner({
const deletedVariants = await service.listVariants(
{ id: variantOne.id },
{
relations: ["options", "options.option_value", "options.variant"],
relations: ["options"],
withDeleted: true,
}
)
@@ -239,9 +264,7 @@ moduleIntegrationTestRunner({
expect(deletedVariants[0].deleted_at).not.toBeNull()
for (const variantOption of deletedVariants[0].options) {
expect(variantOption.deleted_at).not.toBeNull()
// The value itself should not be affected
expect(variantOption?.option_value?.deleted_at).toBeNull()
expect(variantOption?.deleted_at).toBeNull()
}
})
})

View File

@@ -54,7 +54,6 @@ moduleIntegrationTestRunner({
let productTwo: Product
let productCategoryOne: ProductCategory
let productCategoryTwo: ProductCategory
let variantTwo: ProductVariant
let productTypeOne: ProductType
let productTypeTwo: ProductType
let images = [{ url: "image-1" }]
@@ -112,43 +111,57 @@ moduleIntegrationTestRunner({
productCategoryOne = categories[0]
productCategoryTwo = categories[1]
productOne = testManager.create(Product, {
productOne = service.create({
id: "product-1",
title: "product 1",
status: ProductTypes.ProductStatus.PUBLISHED,
variants: [
{
id: "variant-1",
title: "variant 1",
inventory_quantity: 10,
},
],
})
productTwo = testManager.create(Product, {
productTwo = service.create({
id: "product-2",
title: "product 2",
status: ProductTypes.ProductStatus.PUBLISHED,
categories: [productCategoryOne],
categories: [{ id: productCategoryOne.id }],
collection_id: productCollectionOne.id,
tags: tagsData,
options: [
{
title: "size",
values: ["large", "small"],
},
{
title: "color",
values: ["red", "blue"],
},
],
variants: [
{
id: "variant-2",
title: "variant 2",
inventory_quantity: 10,
},
{
id: "variant-3",
title: "variant 3",
inventory_quantity: 10,
options: {
size: "small",
color: "red",
},
},
],
})
testManager.create(ProductVariant, {
id: "variant-1",
title: "variant 1",
inventory_quantity: 10,
product: productOne,
})
variantTwo = testManager.create(ProductVariant, {
id: "variant-2",
title: "variant 2",
inventory_quantity: 10,
product: productTwo,
})
testManager.create(ProductVariant, {
id: "variant-3",
title: "variant 3",
inventory_quantity: 10,
product: productTwo,
})
await testManager.persistAndFlush([productOne, productTwo])
const res = await Promise.all([productOne, productTwo])
productOne = res[0]
productTwo = res[1]
})
it("should update a product and upsert relations that are not created yet", async () => {
@@ -250,9 +263,7 @@ moduleIntegrationTestRunner({
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
option_value: expect.objectContaining({
value: data.options[0].values[0],
}),
value: data.options[0].values[0],
}),
]),
}),
@@ -435,7 +446,7 @@ moduleIntegrationTestRunner({
// Note: VariantThree is already assigned to productTwo, that should be deleted
variants: [
{
id: variantTwo.id,
id: productTwo.variants[0].id,
title: "updated-variant",
},
{
@@ -456,7 +467,7 @@ moduleIntegrationTestRunner({
id: expect.any(String),
variants: expect.arrayContaining([
expect.objectContaining({
id: variantTwo.id,
id: productTwo.variants[0].id,
title: "updated-variant",
}),
expect.objectContaining({
@@ -468,6 +479,32 @@ moduleIntegrationTestRunner({
)
})
it("should do a partial update on the options of a variant successfully", async () => {
await service.update(productTwo.id, {
variants: [
{
id: "variant-3",
options: { size: "small", color: "blue" },
},
],
})
const fetchedProduct = await service.retrieve(productTwo.id, {
relations: ["variants", "variants.options"],
})
expect(fetchedProduct.variants[0].options).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "small",
}),
expect.objectContaining({
value: "blue",
}),
])
)
})
it("should createa variant with id that was passed if it does not exist", async () => {
const updateData = {
id: productTwo.id,
@@ -583,9 +620,7 @@ moduleIntegrationTestRunner({
options: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
option_value: expect.objectContaining({
value: data.options[0].values[0],
}),
value: data.options[0].values[0],
}),
]),
}),

View File

@@ -112,17 +112,27 @@
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "product_category",
"schema": "public",
"indexes": [
{
"keyName": "IDX_product_category_path",
"columnNames": [
"mpath"
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_category_deleted_at",
"primary": false,
"unique": false
},
@@ -314,15 +324,6 @@
"name": "image",
"schema": "public",
"indexes": [
{
"columnNames": [
"url"
],
"composite": false,
"keyName": "IDX_product_image_url",
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
@@ -754,15 +755,6 @@
"name": "product",
"schema": "public",
"indexes": [
{
"columnNames": [
"type_id"
],
"composite": false,
"keyName": "IDX_product_type_id",
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
@@ -996,15 +988,6 @@
"name": "product_option_value",
"schema": "public",
"indexes": [
{
"columnNames": [
"option_id"
],
"composite": false,
"keyName": "IDX_product_option_value_option_id",
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
@@ -1458,15 +1441,6 @@
"name": "product_variant",
"schema": "public",
"indexes": [
{
"columnNames": [
"product_id"
],
"composite": false,
"keyName": "IDX_product_variant_product_id",
"primary": false,
"unique": false
},
{
"columnNames": [
"deleted_at"
@@ -1505,8 +1479,8 @@
},
{
"columns": {
"id": {
"name": "id",
"variant_id": {
"name": "variant_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
@@ -1520,88 +1494,26 @@
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"variant_id": {
"name": "variant_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
"mappedType": "text"
}
},
"name": "product_variant_option",
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"composite": false,
"keyName": "IDX_product_variant_option_deleted_at",
"primary": false,
"unique": false
},
{
"keyName": "product_variant_option_pkey",
"columnNames": [
"id"
"variant_id",
"option_value_id"
],
"composite": false,
"composite": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"product_variant_option_option_value_id_foreign": {
"constraintName": "product_variant_option_option_value_id_foreign",
"columnNames": [
"option_value_id"
],
"localTableName": "public.product_variant_option",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product_option_value",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"product_variant_option_variant_id_foreign": {
"constraintName": "product_variant_option_variant_id_foreign",
"columnNames": [
@@ -1614,6 +1526,19 @@
"referencedTableName": "public.product_variant",
"deleteRule": "cascade",
"updateRule": "cascade"
},
"product_variant_option_option_value_id_foreign": {
"constraintName": "product_variant_option_option_value_id_foreign",
"columnNames": [
"option_value_id"
],
"localTableName": "public.product_variant_option",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.product_option_value",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
}

View File

@@ -42,15 +42,12 @@ export class InitialSetup20240315083440 extends Migration {
this.addSql('create unique index if not exists "IDX_option_value_option_id_unique" on "product_option_value" (option_id, value) where deleted_at is null;')
this.addSql('create index if not exists "IDX_product_option_value_deleted_at" on "product_option_value" ("deleted_at");');
this.addSql('create table if not exists "product_variant_option" ("id" text not null, "option_value_id" text null, "variant_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_variant_option_pkey" primary key ("id"));');
this.addSql('create unique index if not exists "IDX_variant_option_option_value_unique" on "product_variant_option" (variant_id, option_value_id) where deleted_at is null;')
this.addSql('create index if not exists "IDX_product_variant_option_deleted_at" on "product_variant_option" ("deleted_at");');
this.addSql('create table if not exists "image" ("id" text not null, "url" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "image_pkey" primary key ("id"));');
this.addSql('create index if not exists "IDX_product_image_url" on "image" ("url") where deleted_at is null;');
this.addSql('create index if not exists "IDX_product_image_deleted_at" on "image" ("deleted_at");');
this.addSql('create table if not exists "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));');
// TODO: We need to modify upsertWithReplace to handle unique constraints
// this.addSql('create unique index if not exists "IDX_tag_value_unique" on "product_tag" (value) where deleted_at is null;')
this.addSql('create index if not exists "IDX_product_tag_deleted_at" on "product_tag" ("deleted_at");');
@@ -72,7 +69,7 @@ export class InitialSetup20240315083440 extends Migration {
this.addSql('create table if not exists "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));');
this.addSql('create table if not exists "product_images" ("product_id" text not null, "image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "image_id"));');
this.addSql('create table if not exists "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));');
this.addSql('create table if not exists "product_variant_option" ("variant_id" text not null, "option_value_id" text not null, constraint "product_variant_option_pkey" primary key ("variant_id", "option_value_id"));');
/* --- FOREIGN KEYS AND CONSTRAINTS --- */
this.addSql('alter table if exists "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;');
@@ -84,8 +81,8 @@ export class InitialSetup20240315083440 extends Migration {
this.addSql('alter table if exists "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_variant_option" add constraint "product_variant_option_option_value_id_foreign" foreign key ("option_value_id") references "product_option_value" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_variant_option" add constraint "product_variant_option_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_variant_option" add constraint "product_variant_option_option_value_id_foreign" foreign key ("option_value_id") references "product_option_value" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;');
this.addSql('alter table if exists "product_images" add constraint "product_images_image_id_foreign" foreign key ("image_id") references "image" ("id") on update cascade on delete cascade;');

View File

@@ -6,5 +6,4 @@ export { default as ProductType } from "./product-type"
export { default as ProductVariant } from "./product-variant"
export { default as ProductOption } from "./product-option"
export { default as ProductOptionValue } from "./product-option-value"
export { default as ProductVariantOption } from "./product-variant-option"
export { default as Image } from "./product-image"

View File

@@ -9,13 +9,13 @@ import {
Entity,
Filter,
Index,
ManyToMany,
ManyToOne,
OnInit,
OneToMany,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { ProductOption, ProductVariantOption } from "./index"
import { ProductOption, ProductVariant } from "./index"
const optionValueOptionIdIndexName = "IDX_option_value_option_id_unique"
const optionValueOptionIdIndexStatement = createPsqlIndexStatementHelper({
@@ -51,8 +51,8 @@ class ProductOptionValue {
})
option: ProductOption | null
@OneToMany(() => ProductVariantOption, (value) => value.option_value, {})
variant_options = new Collection<ProductVariantOption>(this)
@ManyToMany(() => ProductVariant, (variant) => variant.options)
variants = new Collection<ProductVariant>(this)
@Property({ columnType: "jsonb", nullable: true })
metadata?: Record<string, unknown> | null

View File

@@ -1,92 +0,0 @@
import {
DALUtils,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
Entity,
Filter,
Index,
ManyToOne,
OnInit,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { ProductOptionValue, ProductVariant } from "./index"
const variantOptionValueIndexName = "IDX_variant_option_option_value_unique"
const variantOptionValueIndexStatement = createPsqlIndexStatementHelper({
name: variantOptionValueIndexName,
tableName: "product_variant_option",
columns: ["variant_id", "option_value_id"],
unique: true,
where: "deleted_at IS NULL",
})
variantOptionValueIndexStatement.MikroORMIndex()
@Entity({ tableName: "product_variant_option" })
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
class ProductVariantOption {
@PrimaryKey({ columnType: "text" })
id!: string
@ManyToOne(() => ProductOptionValue, {
columnType: "text",
nullable: true,
fieldName: "option_value_id",
mapToPk: true,
onDelete: "cascade",
})
option_value_id!: string
@ManyToOne(() => ProductOptionValue, {
persist: false,
nullable: true,
})
option_value!: ProductOptionValue
@ManyToOne(() => ProductVariant, {
columnType: "text",
nullable: true,
fieldName: "variant_id",
mapToPk: true,
onDelete: "cascade",
})
variant_id: string | null
@ManyToOne(() => ProductVariant, {
persist: false,
nullable: true,
})
variant!: ProductVariant
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@Index({ name: "IDX_product_variant_option_deleted_at" })
@Property({ columnType: "timestamptz", nullable: true })
deleted_at?: Date
@OnInit()
@BeforeCreate()
onInit() {
this.id = generateEntityId(this.id, "varopt")
this.variant_id ??= this.variant?.id ?? null
this.option_value_id ??= this.option_value?.id ?? null
}
}
export default ProductVariantOption

View File

@@ -12,14 +12,14 @@ import {
Entity,
Filter,
Index,
ManyToMany,
ManyToOne,
OneToMany,
OnInit,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { Product } from "@models"
import ProductVariantOption from "./product-variant-option"
import { Product, ProductOptionValue } from "@models"
const variantSkuIndexName = "IDX_product_variant_sku_unique"
const variantSkuIndexStatement = createPsqlIndexStatementHelper({
@@ -162,14 +162,13 @@ class ProductVariant {
})
product: Product | null
@OneToMany(
() => ProductVariantOption,
(variantOption) => variantOption.variant,
{
cascade: [Cascade.PERSIST, "soft-remove" as any],
}
)
options = new Collection<ProductVariantOption>(this)
@ManyToMany(() => ProductOptionValue, "variants", {
owner: true,
pivotTable: "product_variant_option",
joinColumn: "variant_id",
inverseJoinColumn: "option_value_id",
})
options = new Collection<ProductOptionValue>(this)
@Property({
onCreate: () => new Date(),

View File

@@ -17,7 +17,6 @@ import {
ProductTag,
ProductType,
ProductVariant,
ProductVariantOption,
} from "@models"
import {
ProductCategoryService,
@@ -66,7 +65,6 @@ type InjectedDependencies = {
productTypeService: ProductTypeService<any>
productOptionService: ProductOptionService<any>
productOptionValueService: ModulesSdkTypes.InternalModuleService<any>
productVariantOptionService: ModulesSdkTypes.InternalModuleService<any>
eventBusModuleService?: IEventBusModuleService
}
@@ -77,11 +75,6 @@ const generateMethodForModels = [
{ model: ProductTag, singular: "Tag", plural: "Tags" },
{ model: ProductType, singular: "Type", plural: "Types" },
{ model: ProductVariant, singular: "Variant", plural: "Variants" },
{
model: ProductVariantOption,
singular: "VariantOption",
plural: "VariantOptions",
},
]
export default class ProductModuleService<
@@ -93,8 +86,7 @@ export default class ProductModuleService<
TProductImage extends Image = Image,
TProductType extends ProductType = ProductType,
TProductOption extends ProductOption = ProductOption,
TProductOptionValue extends ProductOptionValue = ProductOptionValue,
TProductVariantOption extends ProductVariantOption = ProductVariantOption
TProductOptionValue extends ProductOptionValue = ProductOptionValue
>
extends ModulesSdkUtils.abstractModuleServiceFactory<
InjectedDependencies,
@@ -130,11 +122,6 @@ export default class ProductModuleService<
singular: "Variant"
plural: "Variants"
}
VariantOption: {
dto: ProductTypes.ProductVariantOptionDTO
singular: "VariantOption"
plural: "VariantOptions"
}
}
>(Product, generateMethodForModels, entityNameToLinkableKeysMap)
implements ProductTypes.IProductModuleService
@@ -154,8 +141,6 @@ export default class ProductModuleService<
protected readonly productTypeService_: ProductTypeService<TProductType>
protected readonly productOptionService_: ProductOptionService<TProductOption>
// eslint-disable-next-line max-len
protected readonly productVariantOptionService_: ModulesSdkTypes.InternalModuleService<TProductVariantOption>
// eslint-disable-next-line max-len
protected readonly productOptionValueService_: ModulesSdkTypes.InternalModuleService<TProductOptionValue>
protected readonly eventBusModuleService_?: IEventBusModuleService
@@ -171,7 +156,6 @@ export default class ProductModuleService<
productTypeService,
productOptionService,
productOptionValueService,
productVariantOptionService,
eventBusModuleService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
@@ -190,7 +174,6 @@ export default class ProductModuleService<
this.productTypeService_ = productTypeService
this.productOptionService_ = productOptionService
this.productOptionValueService_ = productOptionValueService
this.productVariantOptionService_ = productVariantOptionService
this.eventBusModuleService_ = eventBusModuleService
}
@@ -357,7 +340,7 @@ export default class ProductModuleService<
const variantIdsToUpdate = data.map(({ id }) => id)
const variants = await this.productVariantService_.list(
{ id: variantIdsToUpdate },
{ relations: ["options"], take: null },
{ take: null },
sharedContext
)
if (variants.length !== data.length) {
@@ -686,26 +669,63 @@ export default class ProductModuleService<
@MedusaContext() sharedContext: Context = {}
): Promise<ProductOption[]> {
// Validation step
const normalizedInput = data.map((opt) => {
return {
...opt,
...(opt.values
? {
values: opt.values.map((v) => {
return typeof v === "string" ? { value: v } : v
}),
}
: {}),
} as UpdateProductOptionInput
})
if (normalizedInput.some((option) => !option.id)) {
if (data.some((option) => !option.id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Tried to update options without specifying an ID"
)
}
const dbOptions = await this.productOptionService_.list(
{ id: data.map(({ id }) => id) },
{ take: null, relations: ["values"] },
sharedContext
)
if (dbOptions.length !== data.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot update non-existing options with ids: ${arrayDifference(
data.map(({ id }) => id),
dbOptions.map(({ id }) => id)
).join(", ")}`
)
}
// Data normalization
const normalizedInput = data.map((opt) => {
const dbValues = dbOptions.find(({ id }) => id === opt.id)?.values || []
const normalizedValues = opt.values?.map((v) => {
return typeof v === "string" ? { value: v } : v
})
return {
...opt,
...(normalizedValues
? {
// Oftentimes the options are only passed by value without an id, even if they exist in the DB
values: normalizedValues.map((normVal) => {
if ("id" in normVal) {
return normVal
}
const dbVal = dbValues.find(
(dbVal) => dbVal.value === normVal.value
)
if (!dbVal) {
return normVal
}
return {
id: dbVal.id,
value: normVal.value,
}
}),
}
: {}),
} as UpdateProductOptionInput
})
return await this.productOptionService_.upsertWithReplace(
normalizedInput,
{ relations: ["values"] },
@@ -1188,7 +1208,7 @@ export default class ProductModuleService<
sharedContext
)
// Since we handle the options and variants outside of the product upsert, we need to clean up manuallys
// Since we handle the options and variants outside of the product upsert, we need to clean up manually
await this.productOptionService_.delete(
{
product_id: upsertedProduct.id,
@@ -1345,8 +1365,7 @@ export default class ProductModuleService<
}
return {
variant_id: variant.id,
option_value_id: optionValue.id,
id: optionValue.id,
}
}
)

View File

@@ -237,7 +237,7 @@ export interface ProductVariantDTO {
*
* @expandable
*/
options: ProductVariantOptionDTO[]
options: ProductOptionValueDTO[]
/**
* Holds custom data in key-value pairs.
*/
@@ -554,45 +554,6 @@ export interface ProductOptionDTO {
deleted_at?: string | Date
}
export interface ProductVariantOptionDTO {
/**
* The ID of the product variant option.
*/
id: string
/**
* The value of the product variant option.
*
* @expandable
*/
option_value?: ProductOptionValueDTO | null
/**
* The value of the product variant option id.
*/
option_value_id?: string | null
/**
* The associated product variant.
*
* @expandable
*/
variant?: ProductVariantDTO | null
/**
* The associated product variant id.
*/
variant_id?: string | null
/**
* When the product variant option was created.
*/
created_at: string | Date
/**
* When the product variant option was updated.
*/
updated_at: string | Date
/**
* When the product variant option was deleted.
*/
deleted_at?: string | Date
}
/**
* @interface
*

View File

@@ -13,7 +13,10 @@ export const dbErrorMapper = (err: Error) => {
throw new MedusaError(MedusaError.Types.NOT_FOUND, err.message)
}
if (err instanceof UniqueConstraintViolationException) {
if (
err instanceof UniqueConstraintViolationException ||
(err as any).code === "23505"
) {
const info = getConstraintInfo(err)
if (!info) {
throw err
@@ -27,7 +30,10 @@ export const dbErrorMapper = (err: Error) => {
)
}
if (err instanceof NotNullConstraintViolationException) {
if (
err instanceof NotNullConstraintViolationException ||
(err as any).code === "23502"
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot set field '${(err as any).column}' of ${upperCaseFirst(
@@ -36,7 +42,10 @@ export const dbErrorMapper = (err: Error) => {
)
}
if (err instanceof InvalidFieldNameException) {
if (
err instanceof InvalidFieldNameException ||
(err as any).code === "42703"
) {
const userFriendlyMessage = err.message.match(/(column.*)/)?.[0]
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -44,7 +53,10 @@ export const dbErrorMapper = (err: Error) => {
)
}
if (err instanceof ForeignKeyConstraintViolationException) {
if (
err instanceof ForeignKeyConstraintViolationException ||
(err as any).code === "23503"
) {
const info = getConstraintInfo(err)
throw new MedusaError(
MedusaError.Types.NOT_FOUND,

View File

@@ -656,8 +656,7 @@ describe("mikroOrmRepository", () => {
expect(listedEntities[0].entity3.getItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
// title: "en3-1",
title: "newen3-1",
title: "en3-1",
}),
expect.objectContaining({
title: "en3-4",
@@ -744,8 +743,7 @@ describe("mikroOrmRepository", () => {
expect(listedEntities[0].entity3.getItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
// title: "en3-1",
title: "newen3-1",
title: "en3-1",
}),
expect.objectContaining({
title: "en3-4",
@@ -817,31 +815,34 @@ describe("mikroOrmRepository", () => {
)
})
// it("should correctly handle many-to-many upserts with a uniqueness constriant on a non-primary key", async () => {
// const entity1 = {
// id: "1",
// title: "en1",
// entity3: [{ title: "en3-1" }, { title: "en3-2" }] as any,
// }
it("should correctly handle many-to-many upserts with a uniqueness constriant on a non-primary key", async () => {
const entity1 = {
id: "1",
title: "en1",
entity3: [
{ id: "4", title: "en3-1" },
{ id: "5", title: "en3-2" },
] as any,
}
// await manager1().upsertWithReplace([entity1], {
// relations: ["entity3"],
// })
await manager1().upsertWithReplace([entity1], {
relations: ["entity3"],
})
// await manager1().upsertWithReplace([{ ...entity1, id: "2" }], {
// relations: ["entity3"],
// })
await manager1().upsertWithReplace([{ ...entity1, id: "2" }], {
relations: ["entity3"],
})
// const listedEntities = await manager1().find({
// where: {},
// options: { populate: ["entity3"] },
// })
const listedEntities = await manager1().find({
where: {},
options: { populate: ["entity3"] },
})
// expect(listedEntities).toHaveLength(2)
// expect(listedEntities[0].entity3.getItems()).toEqual(
// listedEntities[1].entity3.getItems()
// )
// })
expect(listedEntities).toHaveLength(2)
expect(listedEntities[0].entity3.getItems()).toEqual(
listedEntities[1].entity3.getItems()
)
})
})
describe("error mapping", () => {

View File

@@ -602,8 +602,7 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
return normalizedData
}
// TODO: Currently we will also do an update of the data on the other side of the many-to-many relationship. Reevaluate if we should avoid that.
await this.upsertMany_(manager, relation.type, normalizedData)
await this.upsertMany_(manager, relation.type, normalizedData, true)
const pivotData = normalizedData.map((currModel) => {
return {
@@ -721,39 +720,17 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
return { id: (created as any).id, ...data }
}
protected async upsertManyToOneRelations_(
manager: SqlEntityManager,
upsertsPerType: Record<string, any[]>
) {
const typesToUpsert = Object.entries(upsertsPerType)
if (!typesToUpsert.length) {
return []
}
return (
await promiseAll(
typesToUpsert.map(([type, data]) => {
return this.upsertMany_(manager, type, data)
})
)
).flat()
}
protected async upsertMany_(
manager: SqlEntityManager,
entityName: string,
entries: any[]
entries: any[],
skipUpdate: boolean = false
) {
const existingEntities: any[] = await manager.find(
entityName,
{
id: { $in: entries.map((d) => d.id) },
},
{
populate: [],
disableIdentityMap: true,
}
)
const selectQb = manager.qb(entityName)
const existingEntities: any[] = await selectQb.select("*").where({
id: { $in: entries.map((d) => d.id) },
})
const existingEntitiesMap = new Map(
existingEntities.map((e) => [e.id, e])
)
@@ -762,12 +739,21 @@ export function mikroOrmBaseRepositoryFactory<T extends object = object>(
await promiseAll(
entries.map(async (data) => {
orderedEntities.push(data)
const existingEntity = existingEntitiesMap.get(data.id)
orderedEntities.push(data)
if (existingEntity) {
if (skipUpdate) {
return
}
await manager.nativeUpdate(entityName, { id: data.id }, data)
} else {
await manager.insert(entityName, data)
const qb = manager.qb(entityName)
if (skipUpdate) {
await qb.insert(data).onConflict().ignore().execute()
} else {
await manager.insert(entityName, data)
// await manager.insert(entityName, data)
}
}
})
)