feat: Make tags unique, clean up searchability of product entities (#7014)

This commit is contained in:
Stevche Radevski
2024-04-08 17:55:40 +02:00
committed by GitHub
parent 9b5e025863
commit db2f0ef53f
21 changed files with 105 additions and 299 deletions

View File

@@ -658,8 +658,7 @@ medusaIntegrationTestRunner({
}
})
// TODO: Enforce tag uniqueness in product module
it.skip("returns a list of products with tags", async () => {
it("returns a list of products with tags", async () => {
const response = await api.get(
`/admin/products?tags[]=${baseProduct.tags[0].id}`,
adminHeaders
@@ -668,26 +667,25 @@ medusaIntegrationTestRunner({
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(2)
expect(response.data.products).toEqual(
expect.arrayContaining(
[
expect.objectContaining({
id: baseProduct.id,
tags: [
expect.objectContaining({ id: baseProduct.tags[0].id }),
],
}),
],
expect.arrayContaining([
expect.objectContaining({
id: baseProduct.id,
tags: expect.arrayContaining([
expect.objectContaining({ id: baseProduct.tags[0].id }),
]),
}),
expect.objectContaining({
id: publishedProduct.id,
// It should be the same tag instance in both products
tags: [expect.objectContaining({ id: baseProduct.tags[0].id })],
})
)
tags: expect.arrayContaining([
expect.objectContaining({ id: baseProduct.tags[0].id }),
]),
}),
])
)
})
// TODO: Enforce tag uniqueness in product module
it.skip("returns a list of products with tags in a collection", async () => {
it("returns a list of products with tags in a collection", async () => {
const response = await api.get(
`/admin/products?collection_id[]=${baseCollection.id}&tags[]=${baseProduct.tags[0].id}`,
adminHeaders
@@ -700,7 +698,9 @@ medusaIntegrationTestRunner({
expect.objectContaining({
id: baseProduct.id,
collection_id: baseCollection.id,
tags: [expect.objectContaining({ id: baseProduct.tags[0].id })],
tags: expect.arrayContaining([
expect.objectContaining({ id: baseProduct.tags[0].id }),
]),
}),
])
)
@@ -2655,8 +2655,7 @@ medusaIntegrationTestRunner({
expect(response2.data.id).toEqual(res.data.product.id)
})
// TODO: We just need to return the correct error message
it.skip("should fail when creating a product with a handle that already exists", async () => {
it("should fail when creating a product with a handle that already exists", async () => {
// Lets try to create a product with same handle as deleted one
const payload = {
title: baseProduct.title,
@@ -2675,7 +2674,10 @@ medusaIntegrationTestRunner({
await api.post("/admin/products", payload, adminHeaders)
} catch (error) {
expect(error.response.data.message).toMatch(
"Product with handle test-product already exists."
breaking(
() => "Product with handle base-product already exists.",
() => "Product with handle: base-product already exists."
)
)
}
})

View File

@@ -84,6 +84,7 @@ export const AdminGetProductOptionsParams = createFindParams({
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
title: z.string().optional(),
$and: z.lazy(() => AdminGetProductsParams.array()).optional(),

View File

@@ -30,7 +30,7 @@ export const productsData = [
tags: [
{
id: "tag-3",
value: "Germany",
value: "Netherlands",
},
],
},

View File

@@ -825,6 +825,7 @@ moduleIntegrationTestRunner({
const productTwoData = buildProductAndRelationsData({
collection_id: productCollectionTwo.id,
tags: [],
})
await service.create([productOneData, productTwoData])

View File

@@ -341,7 +341,7 @@ moduleIntegrationTestRunner({
const productOptions = await service.list(
{
title: "US%",
q: "US%",
},
{
relations: ["product"],

View File

@@ -99,7 +99,7 @@ moduleIntegrationTestRunner({
})
it("list product tags by value matching string", async () => {
const tagsResults = await service.list({ value: "united kingdom" })
const tagsResults = await service.list({ q: "united kingdom" })
expect(tagsResults).toEqual([
expect.objectContaining({

View File

@@ -24,6 +24,7 @@ import { SqlEntityManager } from "@mikro-orm/postgresql"
import { createProductCategories } from "../../../__fixtures__/product-category"
import { Modules } from "@medusajs/modules-sdk"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { ProductTag } from "../../../../src/models"
jest.setTimeout(30000)

View File

@@ -48,8 +48,7 @@ export class InitialSetup20240315083440 extends Migration {
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 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");');
this.addSql('create table if not exists "product_type" ("id" text not null, "value" text not null, "metadata" json null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));');

View File

@@ -1,5 +1,6 @@
import {
DALUtils,
Searchable,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
@@ -35,6 +36,7 @@ class ProductOption {
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
title: string

View File

@@ -12,6 +12,7 @@ import {
import {
DALUtils,
Searchable,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
@@ -33,6 +34,7 @@ class ProductTag {
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
value: string

View File

@@ -10,6 +10,7 @@ import {
import {
DALUtils,
Searchable,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
@@ -30,6 +31,7 @@ class ProductType {
@PrimaryKey({ columnType: "text" })
id!: string
@Searchable()
@Property({ columnType: "text" })
value: string

View File

@@ -85,12 +85,15 @@ class ProductVariant {
@Property({ columnType: "text", nullable: true })
sku?: string | null
@Searchable()
@Property({ columnType: "text", nullable: true })
barcode?: string | null
@Searchable()
@Property({ columnType: "text", nullable: true })
ean?: string | null
@Searchable()
@Property({ columnType: "text", nullable: true })
upc?: string | null

View File

@@ -72,6 +72,7 @@ class Product {
@Property({ columnType: "text" })
handle?: string
@Searchable()
@Property({ columnType: "text", nullable: true })
subtitle?: string | null

View File

@@ -1,7 +1,3 @@
export { default as ProductService } from "./product"
export { default as ProductCategoryService } from "./product-category"
export { default as ProductCollectionService } from "./product-collection"
export { default as ProductModuleService } from "./product-module-service"
export { default as ProductTagService } from "./product-tag"
export { default as ProductTypeService } from "./product-type"
export { default as ProductOptionService } from "./product-option"

View File

@@ -1,63 +0,0 @@
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
import { ProductCollection } from "@models"
type InjectedDependencies = {
productCollectionRepository: DAL.RepositoryService
}
export default class ProductCollectionService<
TEntity extends ProductCollection = ProductCollection
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
ProductCollection
)<TEntity> {
// eslint-disable-next-line max-len
protected readonly productCollectionRepository_: DAL.RepositoryService<TEntity>
constructor(container: InjectedDependencies) {
super(container)
this.productCollectionRepository_ = container.productCollectionRepository
}
@InjectManager("productCollectionRepository_")
async list(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this.productCollectionRepository_.find(
this.buildListQueryOptions(filters, config),
sharedContext
)
}
@InjectManager("productCollectionRepository_")
async listAndCount(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
return await this.productCollectionRepository_.findAndCount(
this.buildListQueryOptions(filters, config),
sharedContext
)
}
protected buildListQueryOptions(
filters: ProductTypes.FilterableProductCollectionProps = {},
config: FindConfig<TEntity> = {}
): DAL.FindOptions<TEntity> {
const queryOptions = ModulesSdkUtils.buildQuery<TEntity>(filters, config)
queryOptions.where ??= {}
if (filters.title) {
queryOptions.where.title = {
$like: `%${filters.title}%`,
} as DAL.FindOptions<TEntity>["where"]["title"]
}
return queryOptions
}
}

View File

@@ -18,14 +18,7 @@ import {
ProductType,
ProductVariant,
} from "@models"
import {
ProductCategoryService,
ProductCollectionService,
ProductOptionService,
ProductService,
ProductTagService,
ProductTypeService,
} from "@services"
import { ProductCategoryService, ProductService } from "@services"
import {
arrayDifference,
@@ -58,12 +51,12 @@ type InjectedDependencies = {
baseRepository: DAL.RepositoryService
productService: ProductService<any>
productVariantService: ModulesSdkTypes.InternalModuleService<any, any>
productTagService: ProductTagService<any>
productTagService: ModulesSdkTypes.InternalModuleService<any>
productCategoryService: ProductCategoryService<any>
productCollectionService: ProductCollectionService<any>
productCollectionService: ModulesSdkTypes.InternalModuleService<any>
productImageService: ModulesSdkTypes.InternalModuleService<any>
productTypeService: ProductTypeService<any>
productOptionService: ProductOptionService<any>
productTypeService: ModulesSdkTypes.InternalModuleService<any>
productOptionService: ModulesSdkTypes.InternalModuleService<any>
productOptionValueService: ModulesSdkTypes.InternalModuleService<any>
eventBusModuleService?: IEventBusModuleService
}
@@ -133,13 +126,13 @@ export default class ProductModuleService<
// eslint-disable-next-line max-len
protected readonly productCategoryService_: ProductCategoryService<TProductCategory>
protected readonly productTagService_: ProductTagService<TProductTag>
protected readonly productTagService_: ModulesSdkTypes.InternalModuleService<TProductTag>
// eslint-disable-next-line max-len
protected readonly productCollectionService_: ProductCollectionService<TProductCollection>
protected readonly productCollectionService_: ModulesSdkTypes.InternalModuleService<TProductCollection>
// eslint-disable-next-line max-len
protected readonly productImageService_: ModulesSdkTypes.InternalModuleService<TProductImage>
protected readonly productTypeService_: ProductTypeService<TProductType>
protected readonly productOptionService_: ProductOptionService<TProductOption>
protected readonly productTypeService_: ModulesSdkTypes.InternalModuleService<TProductType>
protected readonly productOptionService_: ModulesSdkTypes.InternalModuleService<TProductOption>
// eslint-disable-next-line max-len
protected readonly productOptionValueService_: ModulesSdkTypes.InternalModuleService<TProductOptionValue>
protected readonly eventBusModuleService_?: IEventBusModuleService
@@ -1122,8 +1115,8 @@ export default class ProductModuleService<
data: ProductTypes.CreateProductDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const normalizedInput = data.map(
ProductModuleService.normalizeCreateProductInput
const normalizedInput = await Promise.all(
data.map((d) => this.normalizeCreateProductInput(d, sharedContext))
)
const productData = await this.productService_.upsertWithReplace(
@@ -1178,8 +1171,8 @@ export default class ProductModuleService<
data: UpdateProductInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const normalizedInput = data.map(
ProductModuleService.normalizeUpdateProductInput
const normalizedInput = await Promise.all(
data.map((d) => this.normalizeUpdateProductInput(d, sharedContext))
)
const productData = await this.productService_.upsertWithReplace(
@@ -1260,12 +1253,13 @@ export default class ProductModuleService<
return productData
}
protected static normalizeCreateProductInput(
product: ProductTypes.CreateProductDTO
): ProductTypes.CreateProductDTO {
const productData = ProductModuleService.normalizeUpdateProductInput(
protected async normalizeCreateProductInput(
product: ProductTypes.CreateProductDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.CreateProductDTO> {
const productData = (await this.normalizeUpdateProductInput(
product as UpdateProductInput
) as ProductTypes.CreateProductDTO
)) as ProductTypes.CreateProductDTO
if (!productData.handle && productData.title) {
productData.handle = kebabCase(productData.title)
@@ -1282,14 +1276,35 @@ export default class ProductModuleService<
return productData
}
protected static normalizeUpdateProductInput(
product: UpdateProductInput
): UpdateProductInput {
protected async normalizeUpdateProductInput(
product: UpdateProductInput,
@MedusaContext() sharedContext: Context = {}
): Promise<UpdateProductInput> {
const productData = { ...product }
if (productData.is_giftcard) {
productData.discountable = false
}
if (productData.tags?.length && productData.tags.some((t) => !t.id)) {
const dbTags = await this.productTagService_.list(
{
value: productData.tags
.map((t) => t.value)
.filter((v) => !!v) as string[],
},
{ take: null },
sharedContext
)
productData.tags = productData.tags.map((tag) => {
const dbTag = dbTags.find((t) => t.value === tag.value)
return {
...tag,
...(dbTag ? { id: dbTag.id } : {}),
}
})
}
if (productData.options?.length) {
;(productData as any).options = productData.options?.map((option) => {
return {

View File

@@ -1,59 +0,0 @@
import { ProductOption } from "@models"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
type InjectedDependencies = {
productOptionRepository: DAL.RepositoryService
}
export default class ProductOptionService<
TEntity extends ProductOption = ProductOption
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
ProductOption
)<TEntity> {
protected readonly productOptionRepository_: DAL.RepositoryService<TEntity>
constructor(container: InjectedDependencies) {
super(container)
this.productOptionRepository_ = container.productOptionRepository
}
@InjectManager("productOptionRepository_")
async list(
filters: ProductTypes.FilterableProductOptionProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext?: Context
): Promise<TEntity[]> {
return await this.productOptionRepository_.find(
this.buildQueryForList(filters, config),
sharedContext
)
}
@InjectManager("productOptionRepository_")
async listAndCount(
filters: ProductTypes.FilterableProductOptionProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext?: Context
): Promise<[TEntity[], number]> {
return await this.productOptionRepository_.findAndCount(
this.buildQueryForList(filters, config),
sharedContext
)
}
private buildQueryForList(
filters: ProductTypes.FilterableProductOptionProps = {},
config: FindConfig<TEntity> = {}
): DAL.FindOptions<TEntity> {
const queryOptions = ModulesSdkUtils.buildQuery<TEntity>(filters, config)
if (filters.title) {
queryOptions.where.title = {
$ilike: filters.title,
} as DAL.FindOptions<TEntity>["where"]["title"]
}
return queryOptions
}
}

View File

@@ -1,58 +0,0 @@
import { ProductTag } from "@models"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
type InjectedDependencies = {
productTagRepository: DAL.RepositoryService
}
export default class ProductTagService<
TEntity extends ProductTag = ProductTag
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
ProductTag
)<TEntity> {
protected readonly productTagRepository_: DAL.RepositoryService<TEntity>
constructor(container: InjectedDependencies) {
super(container)
this.productTagRepository_ = container.productTagRepository
}
@InjectManager("productTagRepository_")
async list(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this.productTagRepository_.find(
this.buildQueryForList(filters, config),
sharedContext
)
}
@InjectManager("productTagRepository_")
async listAndCount(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
return await this.productTagRepository_.findAndCount(
this.buildQueryForList(filters, config),
sharedContext
)
}
private buildQueryForList(
filters: ProductTypes.FilterableProductTagProps = {},
config: FindConfig<TEntity> = {}
): DAL.FindOptions<TEntity> {
const queryOptions = ModulesSdkUtils.buildQuery<TEntity>(filters, config)
if (filters.value) {
queryOptions.where.value = {
$ilike: filters.value,
} as DAL.FindOptions<TEntity>["where"]["value"]
}
return queryOptions
}
}

View File

@@ -1,59 +0,0 @@
import { ProductType } from "@models"
import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types"
import { InjectManager, MedusaContext, ModulesSdkUtils } from "@medusajs/utils"
type InjectedDependencies = {
productTypeRepository: DAL.RepositoryService
}
export default class ProductTypeService<
TEntity extends ProductType = ProductType
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
ProductType
)<TEntity> {
protected readonly productTypeRepository_: DAL.RepositoryService<TEntity>
constructor(container: InjectedDependencies) {
super(container)
this.productTypeRepository_ = container.productTypeRepository
}
@InjectManager("productTypeRepository_")
async list(
filters: ProductTypes.FilterableProductTypeProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
return await this.productTypeRepository_.find(
this.buildQueryForList(filters, config),
sharedContext
)
}
@InjectManager("productTypeRepository_")
async listAndCount(
filters: ProductTypes.FilterableProductTypeProps = {},
config: FindConfig<TEntity> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
return await this.productTypeRepository_.findAndCount(
this.buildQueryForList(filters, config),
sharedContext
)
}
private buildQueryForList(
filters: ProductTypes.FilterableProductTypeProps = {},
config: FindConfig<TEntity> = {}
): DAL.FindOptions<TEntity> {
const queryOptions = ModulesSdkUtils.buildQuery<TEntity>(filters, config)
if (filters.value) {
queryOptions.where.value = {
$ilike: filters.value,
} as DAL.FindOptions<TEntity>["where"]["value"]
}
return queryOptions
}
}

View File

@@ -724,6 +724,10 @@ export interface FilterableProductProps
*/
export interface FilterableProductTagProps
extends BaseFilterable<FilterableProductTagProps> {
/**
* Search through the tags' values.
*/
q?: string
/**
* The IDs to filter product tags by.
*/
@@ -731,7 +735,7 @@ export interface FilterableProductTagProps
/**
* The value to filter product tags by.
*/
value?: string
value?: string | string[]
}
/**
@@ -744,6 +748,10 @@ export interface FilterableProductTagProps
*/
export interface FilterableProductTypeProps
extends BaseFilterable<FilterableProductTypeProps> {
/**
* Search through the types' values.
*/
q?: string
/**
* The IDs to filter product types by.
*/
@@ -765,6 +773,10 @@ export interface FilterableProductTypeProps
*/
export interface FilterableProductOptionProps
extends BaseFilterable<FilterableProductOptionProps> {
/**
* Search through the options' titles.
*/
q?: string
/**
* The IDs to filter product options by.
*/
@@ -789,6 +801,10 @@ export interface FilterableProductOptionProps
*/
export interface FilterableProductCollectionProps
extends BaseFilterable<FilterableProductCollectionProps> {
/**
* Search through the collections' titles.
*/
q?: string
/**
* The IDs to filter product collections by.
*/
@@ -815,6 +831,10 @@ export interface FilterableProductCollectionProps
*/
export interface FilterableProductVariantProps
extends BaseFilterable<FilterableProductVariantProps> {
/**
* Search through the title and different code attributes on the variant
*/
q?: string
/**
* The IDs to filter product variants by.
*/

View File

@@ -24,7 +24,7 @@ export const dbErrorMapper = (err: Error) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`${upperCaseFirst(info.table)} with ${info.keys
`${upperCaseFirst(info.table.split("_").join(" "))} with ${info.keys
.map((key, i) => `${key}: ${info.values[i]}`)
.join(", ")} already exists.`
)
@@ -37,7 +37,7 @@ export const dbErrorMapper = (err: Error) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot set field '${(err as any).column}' of ${upperCaseFirst(
(err as any).table
(err as any).table.split("_").join(" ")
)} to null`
)
}