fix(medusa): Product repo typeorm issues (#4084)

* fix(medusa): Product repo typeorm issues

* chore: fixed category scopes

* WIP fix categories

* fix product repo to attach categories

* fix uni tests

* Create eighty-icons-exercise.md

* revert package.json

* fix change set

* last fixes

* cleanup iteration

* fix repository deep relations joining aliasing

* improve response time

* improve category test case

* fix free texts search

* fix repo

* centralise repository manipulation into utils and use the utils in the product repo

* fix product repo

* fix customer group

* update changeset

* fix customer group

* include feedback

* fix repo

* remove query strategy

---------

Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
This commit is contained in:
Adrien de Peretti
2023-05-16 09:36:52 +02:00
committed by GitHub
parent 26963acc0a
commit 9518efccae
9 changed files with 827 additions and 139 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): Revert product repo to prevent typeorm issues + cleanup and improvements

View File

@@ -1,27 +1,14 @@
const path = require("path")
const { ProductCategory } = require("@medusajs/medusa")
const { DiscountRuleType, AllocationType } = require("@medusajs/medusa/dist")
const { IdMap } = require("medusa-test-utils")
const {
ProductVariant,
ProductOptionValue,
MoneyAmount,
DiscountConditionType,
DiscountConditionOperator,
} = require("@medusajs/medusa")
const setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../../helpers/use-db")
const adminSeeder = require("../../../helpers/admin-seeder")
const productSeeder = require("../../../helpers/product-seeder")
const priceListSeeder = require("../../../helpers/price-list-seeder")
const {
simpleProductFactory,
simpleDiscountFactory,
simpleProductCategoryFactory,
simpleSalesChannelFactory,
simpleRegionFactory,
} = require("../../../factories")
const testProductId = "test-product"
@@ -124,10 +111,7 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => {
it("returns a list of products in product category without category children", async () => {
const api = useApi()
const params = `category_id[]=${categoryWithProductId}`
const response = await api.get(
`/admin/products?${params}`,
adminHeaders
)
const response = await api.get(`/admin/products?${params}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1)
@@ -141,10 +125,7 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => {
it("returns a list of products in product category without category children explicitly set to false", async () => {
const api = useApi()
const params = `category_id[]=${categoryWithProductId}&include_category_children=false`
const response = await api.get(
`/admin/products?${params}`,
adminHeaders
)
const response = await api.get(`/admin/products?${params}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1)
@@ -159,10 +140,7 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => {
const api = useApi()
const params = `category_id[]=${categoryWithProductId}&include_category_children=true`
const response = await api.get(
`/admin/products?${params}`,
adminHeaders
)
const response = await api.get(`/admin/products?${params}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(3)
@@ -185,10 +163,7 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => {
const api = useApi()
const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true`
const response = await api.get(
`/admin/products?${params}`,
adminHeaders
)
const response = await api.get(`/admin/products?${params}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(0)

View File

@@ -98,7 +98,7 @@ describe("/store/products", () => {
internalCategoryWithProduct = await simpleProductCategoryFactory(
dbConnection,
{
id: inactiveCategoryWithProductId,
id: internalCategoryWithProductId,
name: "inactive category with Product",
products: [{ id: testProductFilteringId2 }],
parent_category: nestedCategoryWithProduct,
@@ -219,6 +219,51 @@ describe("/store/products", () => {
)
})
it("returns only active and public products with include_category_children when categories are expanded", async () => {
const api = useApi()
const params = `id[]=${testProductFilteringId2}&expand=categories`
let response = await api.get(`/store/products?${params}`)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: testProductFilteringId2,
categories: [],
}),
])
)
const category = await simpleProductCategoryFactory(dbConnection, {
id: categoryWithProductId,
name: "category with Product 2",
products: [{ id: response.data.products[0].id }],
is_active: true,
is_internal: false,
})
response = await api.get(`/store/products?${params}`)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: testProductFilteringId2,
categories: expect.arrayContaining([
expect.objectContaining({
id: category.id,
}),
]),
}),
])
)
})
it("does not query products with category that are inactive", async () => {
const api = useApi()

View File

@@ -1,10 +1,4 @@
import {
DeleteResult,
FindOperator,
FindOptionsRelations,
In,
SelectQueryBuilder,
} from "typeorm"
import { DeleteResult, FindOperator, FindOptionsRelations, In } from "typeorm"
import { CustomerGroup } from "../models"
import { ExtendedFindConfig } from "../types/common"
import {
@@ -15,6 +9,7 @@ import {
} from "../utils/repository"
import { objectToStringPath } from "@medusajs/utils"
import { dataSource } from "../loaders/database"
import { cloneDeep } from "lodash"
export type DefaultWithoutRelations = Omit<
ExtendedFindConfig<CustomerGroup>,
@@ -71,45 +66,66 @@ export const CustomerGroupRepository = dataSource
async findWithRelationsAndCount(
relations: FindOptionsRelations<CustomerGroup> = {},
idsOrOptionsWithoutRelations: FindWithoutRelationsOptions = { where: {} }
idsOrOptionsWithoutRelations: string[] | FindWithoutRelationsOptions = {
where: {},
}
): Promise<[CustomerGroup[], number]> {
const withDeleted = Array.isArray(idsOrOptionsWithoutRelations)
? false
: idsOrOptionsWithoutRelations.withDeleted ?? false
const isOptionsArray = Array.isArray(idsOrOptionsWithoutRelations)
const originalWhere = isOptionsArray
? undefined
: cloneDeep(idsOrOptionsWithoutRelations.where)
const originalOrder: any = isOptionsArray
? undefined
: { ...idsOrOptionsWithoutRelations.order }
const originalSelect = isOptionsArray
? undefined
: objectToStringPath(idsOrOptionsWithoutRelations.select)
const clonedOptions = isOptionsArray
? idsOrOptionsWithoutRelations
: cloneDeep(idsOrOptionsWithoutRelations)
let count: number
let entities: CustomerGroup[]
if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.find({
where: { id: In(idsOrOptionsWithoutRelations) },
withDeleted: idsOrOptionsWithoutRelations.withDeleted ?? false,
withDeleted,
})
count = entities.length
} else {
const customJoinsBuilders: ((
qb: SelectQueryBuilder<CustomerGroup>,
alias: string
) => void)[] = []
const discountConditionId = (
clonedOptions as FindWithoutRelationsOptions
)?.where?.discount_condition_id
delete (clonedOptions as FindWithoutRelationsOptions)?.where
?.discount_condition_id
if (idsOrOptionsWithoutRelations?.where?.discount_condition_id) {
const discountConditionId =
idsOrOptionsWithoutRelations?.where?.discount_condition_id
delete idsOrOptionsWithoutRelations?.where?.discount_condition_id
const result = await queryEntityWithoutRelations({
repository: this,
optionsWithoutRelations: clonedOptions as FindWithoutRelationsOptions,
shouldCount: true,
customJoinBuilders: [
async (qb, alias) => {
if (discountConditionId) {
qb.innerJoin(
"discount_condition_customer_group",
"dc_cg",
`dc_cg.customer_group_id = ${alias}.id AND dc_cg.condition_id = :dcId`,
{ dcId: discountConditionId }
)
customJoinsBuilders.push(
(qb: SelectQueryBuilder<CustomerGroup>, alias: string) => {
qb.innerJoin(
"discount_condition_customer_group",
"dc_cg",
`dc_cg.customer_group_id = ${alias}.id AND dc_cg.condition_id = :dcId`,
{ dcId: discountConditionId }
)
}
)
}
return {
relation: "discount_condition",
preventOrderJoin: true,
}
}
const result = await queryEntityWithoutRelations(
this,
idsOrOptionsWithoutRelations,
true,
customJoinsBuilders
)
return
},
],
})
entities = result[0]
count = result[1]
}
@@ -121,16 +137,21 @@ export const CustomerGroupRepository = dataSource
}
if (Object.keys(relations).length === 0) {
const options = { ...idsOrOptionsWithoutRelations }
// Since we are finding by the ids that have been retrieved above and those ids are already
// applying skip/take. Remove those options to avoid getting no results
delete options.skip
delete options.take
if (!Array.isArray(clonedOptions)) {
delete clonedOptions.skip
delete clonedOptions.take
}
const toReturn = await this.find({
...options,
where: { id: In(entitiesIds) },
...(isOptionsArray
? {}
: (clonedOptions as FindWithoutRelationsOptions)),
where: {
id: In(entitiesIds),
...(Array.isArray(clonedOptions) ? {} : clonedOptions.where),
},
})
return [toReturn, toReturn.length]
}
@@ -138,16 +159,13 @@ export const CustomerGroupRepository = dataSource
const legacyRelations = objectToStringPath(relations)
const groupedRelations = getGroupedRelations(legacyRelations)
const legacySelect = objectToStringPath(
idsOrOptionsWithoutRelations.select
)
const entitiesIdsWithRelations = await queryEntityWithIds(
this,
entitiesIds,
const entitiesIdsWithRelations = await queryEntityWithIds({
repository: this,
entityIds: entitiesIds,
groupedRelations,
idsOrOptionsWithoutRelations.withDeleted,
legacySelect
)
select: originalSelect,
withDeleted,
})
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesToReturn =

View File

@@ -1,21 +1,299 @@
import {
Brackets,
FindOperator,
FindOptionsWhere,
ILike,
In,
SelectQueryBuilder,
} from "typeorm"
import { Product, ProductCategory, ProductVariant } from "../models"
import { ExtendedFindConfig } from "../types/common"
import { dataSource } from "../loaders/database"
import { ProductFilterOptions } from "../types/product"
import {
isObject,
fetchCategoryDescendantsIds,
} from "../utils"
PriceList,
Product,
ProductCategory,
ProductTag,
SalesChannel,
} from "../models"
import { dataSource } from "../loaders/database"
import { cloneDeep, groupBy, map, merge } from "lodash"
import { ExtendedFindConfig } from "../types/common"
import {
applyOrdering,
getGroupedRelations,
queryEntityWithIds,
queryEntityWithoutRelations,
} from "../utils/repository"
import { objectToStringPath } from "@medusajs/utils"
export type DefaultWithoutRelations = Omit<
ExtendedFindConfig<Product>,
"relations"
>
export type FindWithoutRelationsOptions = DefaultWithoutRelations & {
where: DefaultWithoutRelations["where"] & {
price_list_id?: FindOperator<PriceList>
sales_channel_id?: FindOperator<SalesChannel>
category_id?: {
value: string[]
}
categories?: FindOptionsWhere<ProductCategory>
tags?: FindOperator<ProductTag>
include_category_children?: boolean
discount_condition_id?: string
}
}
export const ProductRepository = dataSource.getRepository(Product).extend({
async queryProducts(
optionsWithoutRelations: FindWithoutRelationsOptions,
shouldCount = false
): Promise<[Product[], number]> {
const tags = optionsWithoutRelations?.where?.tags
delete optionsWithoutRelations?.where?.tags
const price_lists = optionsWithoutRelations?.where?.price_list_id
delete optionsWithoutRelations?.where?.price_list_id
const sales_channels = optionsWithoutRelations?.where?.sales_channel_id
delete optionsWithoutRelations?.where?.sales_channel_id
const categoryId = optionsWithoutRelations?.where?.category_id
delete optionsWithoutRelations?.where?.category_id
const categoriesQuery = optionsWithoutRelations.where.categories || {}
delete optionsWithoutRelations?.where?.categories
const include_category_children =
optionsWithoutRelations?.where?.include_category_children
delete optionsWithoutRelations?.where?.include_category_children
const discount_condition_id =
optionsWithoutRelations?.where?.discount_condition_id
delete optionsWithoutRelations?.where?.discount_condition_id
return queryEntityWithoutRelations<Product>({
repository: this,
optionsWithoutRelations,
shouldCount,
customJoinBuilders: [
async (qb, alias) => {
if (tags) {
qb.leftJoin(`${alias}.tags`, "tags").andWhere(
`tags.id IN (:...tag_ids)`,
{
tag_ids: tags.value,
}
)
return { relation: "tags", preventOrderJoin: true }
}
return
},
async (qb, alias) => {
if (price_lists) {
qb.leftJoin(`${alias}.variants`, "variants")
.leftJoin("variants.prices", "prices")
.andWhere("prices.price_list_id IN (:...price_list_ids)", {
price_list_ids: price_lists.value,
})
return { relation: "prices", preventOrderJoin: true }
}
return
},
async (qb, alias) => {
if (sales_channels) {
qb.innerJoin(
`${alias}.sales_channels`,
"sales_channels",
"sales_channels.id IN (:...sales_channels_ids)",
{ sales_channels_ids: sales_channels.value }
)
return { relation: "sales_channels", preventOrderJoin: true }
}
return
},
async (qb, alias) => {
let categoryIds: string[] = []
if (categoryId) {
categoryIds = categoryId?.value
if (include_category_children) {
const categoryRepository =
this.manager.getTreeRepository(ProductCategory)
const categories = await categoryRepository.find({
where: { id: In(categoryIds) },
})
for (const category of categories) {
const categoryChildren =
await categoryRepository.findDescendantsTree(category)
const getAllIdsRecursively = (
productCategory: ProductCategory
) => {
let result = [productCategory.id]
;(productCategory.category_children || []).forEach(
(child) => {
result = result.concat(getAllIdsRecursively(child))
}
)
return result
}
categoryIds = categoryIds.concat(
getAllIdsRecursively(categoryChildren)
)
}
}
}
if (categoryIds.length || categoriesQuery) {
const joinScope = {}
if (categoryIds.length) {
Object.assign(joinScope, { id: categoryIds })
}
if (categoriesQuery) {
Object.assign(joinScope, categoriesQuery)
}
this._applyCategoriesQuery(qb, {
alias,
categoryAlias: "categories",
where: joinScope,
joinName: categoryIds.length ? "innerJoin" : "leftJoin",
})
return { relation: "categories", preventOrderJoin: true }
}
return
},
async (qb, alias) => {
if (discount_condition_id) {
qb.innerJoin(
"discount_condition_product",
"dc_product",
`dc_product.product_id = ${alias}.id AND dc_product.condition_id = :dcId`,
{ dcId: discount_condition_id }
)
}
return
},
],
})
},
async queryProductsWithIds({
entityIds,
groupedRelations,
withDeleted = false,
select = [],
order = {},
where = {},
}: {
entityIds: string[]
groupedRelations: { [toplevel: string]: string[] }
withDeleted?: boolean
select?: (keyof Product)[]
order?: { [column: string]: "ASC" | "DESC" }
where?: FindOptionsWhere<Product>
}): Promise<Product[]> {
return await queryEntityWithIds({
repository: this,
entityIds,
groupedRelations,
withDeleted,
select,
customJoinBuilders: [
(queryBuilder, alias, topLevel) => {
if (topLevel === "variants") {
queryBuilder.leftJoinAndSelect(
`${alias}.${topLevel}`,
topLevel,
`${topLevel}.deleted_at IS NULL`
)
if (
!Object.keys(order!).some((key) => key.startsWith("variants"))
) {
// variant_rank being select false, apply the filter here directly
queryBuilder.addOrderBy(`${topLevel}.variant_rank`, "ASC")
}
return false
}
return true
},
(queryBuilder, alias, topLevel) => {
if (topLevel === "categories") {
const joinScope = where!
.categories as FindOptionsWhere<ProductCategory>
this._applyCategoriesQuery(queryBuilder, {
alias,
categoryAlias: "categories",
where: joinScope,
joinName: "leftJoinAndSelect",
})
return false
}
return true
},
],
})
},
async findWithRelationsAndCount(
relations: string[] = [],
idsOrOptionsWithoutRelations: FindWithoutRelationsOptions = { where: {} }
): Promise<[Product[], number]> {
return await this._findWithRelations({
relations,
idsOrOptionsWithoutRelations,
withDeleted: false,
shouldCount: true,
})
},
async findWithRelations(
relations: string[] = [],
idsOrOptionsWithoutRelations: FindWithoutRelationsOptions | string[] = {
where: {},
},
withDeleted = false
): Promise<Product[]> {
const [products] = await this._findWithRelations({
relations,
idsOrOptionsWithoutRelations,
withDeleted,
shouldCount: false,
})
return products
},
async findOneWithRelations(
relations: string[] = [],
optionsWithoutRelations: FindWithoutRelationsOptions = { where: {} }
): Promise<Product> {
// Limit 1
optionsWithoutRelations.take = 1
const result = await this.findWithRelations(
relations,
optionsWithoutRelations
)
return result[0]
},
async bulkAddToCollection(
productIds: string[],
collectionId: string
@@ -42,6 +320,235 @@ export const ProductRepository = dataSource.getRepository(Product).extend({
return this.findByIds(productIds)
},
async getFreeTextSearchResultsAndCount(
q: string,
options: FindWithoutRelationsOptions = { where: {} },
relations: string[] = []
): Promise<[Product[], number]> {
const option_ = cloneDeep(options)
const productAlias = "product"
const pricesAlias = "prices"
const variantsAlias = "variants"
const collectionAlias = "collection"
const tagsAlias = "tags"
if ("description" in option_.where) {
delete option_.where.description
}
if ("title" in option_.where) {
delete option_.where.title
}
const tags = option_.where.tags
delete option_.where.tags
const price_lists = option_.where.price_list_id
delete option_.where.price_list_id
const sales_channels = option_.where.sales_channel_id
delete option_.where.sales_channel_id
const discount_condition_id = option_.where.discount_condition_id
delete option_.where.discount_condition_id
const categoriesQuery = option_.where.categories
delete option_.where.categories
let qb = this.createQueryBuilder(`${productAlias}`)
.leftJoinAndSelect(`${productAlias}.variants`, variantsAlias)
.leftJoinAndSelect(`${productAlias}.collection`, `${collectionAlias}`)
.select([`${productAlias}.id`])
.where(option_.where)
.andWhere(
new Brackets((qb) => {
qb.where(`${productAlias}.description ILIKE :q`, { q: `%${q}%` })
.orWhere(`${productAlias}.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`${variantsAlias}.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`${variantsAlias}.sku ILIKE :q`, { q: `%${q}%` })
.orWhere(`${collectionAlias}.title ILIKE :q`, { q: `%${q}%` })
})
)
.skip(option_.skip)
.take(option_.take)
if (discount_condition_id) {
qb.innerJoin(
"discount_condition_product",
"dc_product",
`dc_product.product_id = ${productAlias}.id AND dc_product.condition_id = :dcId`,
{ dcId: discount_condition_id }
)
}
if (tags) {
qb.leftJoin(`${productAlias}.tags`, tagsAlias).andWhere(
`${tagsAlias}.id IN (:...tag_ids)`,
{
tag_ids: tags.value,
}
)
}
if (price_lists) {
const variantPricesAlias = `${variantsAlias}_prices`
qb.leftJoin(`${productAlias}.variants`, variantPricesAlias)
.leftJoin(`${variantPricesAlias}.prices`, pricesAlias)
.andWhere(`${pricesAlias}.price_list_id IN (:...price_list_ids)`, {
price_list_ids: price_lists.value,
})
}
if (sales_channels) {
qb.innerJoin(
`${productAlias}.sales_channels`,
"sales_channels",
"sales_channels.id IN (:...sales_channels_ids)",
{ sales_channels_ids: sales_channels.value }
)
}
if (categoriesQuery) {
this._applyCategoriesQuery(qb, {
alias: productAlias,
categoryAlias: "categories",
where: categoriesQuery,
joinName: "leftJoin",
})
}
const joinedWithTags = !!tags
const joinedWithPriceLists = !!price_lists
applyOrdering({
repository: this,
order: (options.order as any) ?? {},
qb,
alias: productAlias,
shouldJoin: (relation) =>
relation !== variantsAlias &&
(relation !== pricesAlias || !joinedWithPriceLists) &&
(relation !== tagsAlias || !joinedWithTags),
})
if (option_.withDeleted) {
qb = qb.withDeleted()
}
const [results, count] = await qb.getManyAndCount()
const orderedResultsSet = new Set(results.map((p) => p.id))
const products = await this.findWithRelations(
relations,
[...orderedResultsSet],
option_.withDeleted
)
const productsMap = new Map(products.map((p) => [p.id, p]))
// Looping through the orderedResultsSet in order to maintain the original order and assign the data returned by findWithRelations
const orderedProducts: Product[] = []
orderedResultsSet.forEach((id) => {
orderedProducts.push(productsMap.get(id)!)
})
return [orderedProducts, count]
},
async _findWithRelations({
relations = [],
idsOrOptionsWithoutRelations = {
where: {},
},
withDeleted = false,
shouldCount = false,
}: {
relations: string[]
idsOrOptionsWithoutRelations: string[] | FindWithoutRelationsOptions
withDeleted: boolean
shouldCount: boolean
}): Promise<[Product[], number]> {
withDeleted = Array.isArray(idsOrOptionsWithoutRelations)
? withDeleted
: idsOrOptionsWithoutRelations.withDeleted ?? false
const isOptionsArray = Array.isArray(idsOrOptionsWithoutRelations)
const originalWhere = isOptionsArray
? undefined
: cloneDeep(idsOrOptionsWithoutRelations.where)
const originalOrder: any = isOptionsArray
? undefined
: { ...idsOrOptionsWithoutRelations.order }
const originalSelect = isOptionsArray
? undefined
: objectToStringPath(idsOrOptionsWithoutRelations.select)
const clonedOptions = isOptionsArray
? idsOrOptionsWithoutRelations
: cloneDeep(idsOrOptionsWithoutRelations)
let count: number
let entities: Product[]
if (isOptionsArray) {
entities = await this.find({
where: {
id: In(clonedOptions as string[]),
},
withDeleted,
})
count = entities.length
} else {
const result = await this.queryProducts(
clonedOptions as FindWithoutRelationsOptions,
shouldCount
)
entities = result[0]
count = result[1]
}
const entitiesIds = entities.map(({ id }) => id)
if (entitiesIds.length === 0) {
// no need to continue
return [[], count]
}
if (relations.length === 0) {
// Since we are finding by the ids that have been retrieved above and those ids are already
// applying skip/take. Remove those options to avoid getting no results
if (!Array.isArray(clonedOptions)) {
delete clonedOptions.skip
delete clonedOptions.take
}
const toReturn = await this.find({
...(isOptionsArray
? {}
: (clonedOptions as FindWithoutRelationsOptions)),
where: {
id: In(entitiesIds),
...(Array.isArray(clonedOptions) ? {} : clonedOptions.where),
},
})
return [toReturn, toReturn.length]
}
const groupedRelations = getGroupedRelations(relations)
const entitiesIdsWithRelations = await this.queryProductsWithIds({
entityIds: entitiesIds,
groupedRelations,
select: originalSelect,
order: originalOrder,
where: originalWhere,
withDeleted,
})
const entitiesAndRelations = groupBy(entitiesIdsWithRelations, "id")
const entitiesToReturn = map(entitiesIds, (id) =>
merge({}, ...entitiesAndRelations[id])
)
return [entitiesToReturn, count]
},
async isProductInSalesChannels(
id: string,
salesChannelIds: string[]
@@ -58,7 +565,26 @@ export const ProductRepository = dataSource.getRepository(Product).extend({
)
},
async findAndCount(
_applyCategoriesQuery(
qb: SelectQueryBuilder<Product>,
{ alias, categoryAlias, where, joinName }
) {
const joinWhere = Object.entries(where ?? {})
.map(([column, condition]) => {
if (Array.isArray(condition)) {
return `${categoryAlias}.${column} IN (:...${column})`
} else {
return `${categoryAlias}.${column} = :${column}`
}
})
.join(" AND ")
qb[joinName](`${alias}.${categoryAlias}`, categoryAlias, joinWhere, where)
return qb
},
/* async findAndCount(
options: ExtendedFindConfig<Product & ProductFilterOptions>,
q?: string
): Promise<[Product[], number]> {
@@ -293,7 +819,7 @@ export const ProductRepository = dataSource.getRepository(Product).extend({
queryBuilder.setFindOptions(options_)
return queryBuilder
},
},*/
/**
* Upserts shipping profile for products

View File

@@ -1,4 +1,4 @@
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import ProductService from "../product"
import { FlagRouter } from "../../utils/flag-router"
@@ -29,7 +29,7 @@ const mockUpsertType = jest.fn().mockImplementation((value) => {
describe("ProductService", () => {
describe("retrieve", () => {
const productRepo = MockRepository({
findOne: (query) => {
findOneWithRelations: (rels, query) => {
if (query.where.id === "test id with variants") {
return {
id: "test id with variants",
@@ -62,8 +62,8 @@ describe("ProductService", () => {
it("successfully retrieves a product", async () => {
const result = await productService.retrieve(IdMap.getId("ironman"))
expect(productRepo.findOne).toHaveBeenCalledTimes(1)
expect(productRepo.findOne).toHaveBeenCalledWith({
expect(productRepo.findOneWithRelations).toHaveBeenCalledTimes(1)
expect(productRepo.findOneWithRelations).toHaveBeenCalledWith([], {
where: { id: IdMap.getId("ironman") },
})
@@ -80,7 +80,7 @@ describe("ProductService", () => {
collection: { id: IdMap.getId("cat"), title: "Suits" },
variants: product.variants,
}),
findOne: () => ({
findOneWithRelations: () => ({
id: IdMap.getId("ironman"),
title: "Suit",
options: [],
@@ -210,7 +210,7 @@ describe("ProductService", () => {
describe("update", () => {
const productRepository = MockRepository({
findOne: (query) => {
findOneWithRelations: (rels, query) => {
if (query.where.id === IdMap.getId("ironman&co")) {
return Promise.resolve({
id: IdMap.getId("ironman&co"),
@@ -414,7 +414,7 @@ describe("ProductService", () => {
describe("addOption", () => {
const productRepository = MockRepository({
findOne: (query) =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
options: [{ title: "Color" }],
@@ -487,7 +487,7 @@ describe("ProductService", () => {
describe("reorderVariants", () => {
const productRepository = MockRepository({
findOne: (query) =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
variants: [{ id: IdMap.getId("green") }, { id: IdMap.getId("blue") }],
@@ -546,7 +546,7 @@ describe("ProductService", () => {
describe("updateOption", () => {
const productRepository = MockRepository({
findOne: (query) =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
options: [
@@ -622,7 +622,7 @@ describe("ProductService", () => {
describe("deleteOption", () => {
const productRepository = MockRepository({
findOne: (query) =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
variants: [

View File

@@ -13,24 +13,33 @@ import {
SalesChannel,
} from "../models"
import { ImageRepository } from "../repositories/image"
import { ProductRepository } from "../repositories/product"
import {
FindWithoutRelationsOptions,
ProductRepository,
} from "../repositories/product"
import { ProductCategoryRepository } from "../repositories/product-category"
import { ProductOptionRepository } from "../repositories/product-option"
import { ProductTagRepository } from "../repositories/product-tag"
import { ProductTypeRepository } from "../repositories/product-type"
import { ProductVariantRepository } from "../repositories/product-variant"
import { ExtendedFindConfig, FindConfig, Selector } from "../types/common"
import { Selector } from "../types/common"
import {
CreateProductInput,
FilterableProductProps,
FindProductConfig,
ProductFilterOptions,
ProductOptionInput,
ProductSelector,
UpdateProductInput,
} from "../types/product"
import { buildQuery, isString, setMetadata } from "../utils"
import {
buildQuery,
buildRelationsOrSelect,
isString,
setMetadata,
} from "../utils"
import { FlagRouter } from "../utils/flag-router"
import EventBusService from "./event-bus"
import { objectToStringPath } from "@medusajs/utils"
type InjectedDependencies = {
manager: EntityManager
@@ -138,7 +147,7 @@ class ProductService extends TransactionBaseService {
include_discount_prices: false,
}
): Promise<[Product[], number]> {
const productRepo = this.activeManager_.withRepository(
/* const productRepo = this.activeManager_.withRepository(
this.productRepository_
)
@@ -147,7 +156,26 @@ class ProductService extends TransactionBaseService {
Product & ProductFilterOptions
>
return await productRepo.findAndCount(query, q)
return await productRepo.findAndCount(query, q)*/
/**
* TODO: The below code is a temporary fix for the issue with the typeorm idle transaction in query strategy mode
*/
const manager = this.activeManager_
const productRepo = manager.withRepository(this.productRepository_)
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
return await productRepo.getFreeTextSearchResultsAndCount(
q,
query,
relations
)
}
return await productRepo.findWithRelationsAndCount(relations, query)
}
/**
@@ -243,7 +271,7 @@ class ProductService extends TransactionBaseService {
include_discount_prices: false, // TODO: this seams to be unused from the repository
}
): Promise<Product> {
const productRepo = this.activeManager_.withRepository(
/* const productRepo = this.activeManager_.withRepository(
this.productRepository_
)
const query = buildQuery(selector, config as FindConfig<Product>)
@@ -260,6 +288,32 @@ class ProductService extends TransactionBaseService {
)
}
return product*/
/**
* TODO: The below code is a temporary fix for the issue with the typeorm idle transaction in query strategy mode
*/
const manager = this.activeManager_
const productRepo = manager.withRepository(this.productRepository_)
const { relations, ...query } = buildQuery(selector, config)
const product = await productRepo.findOneWithRelations(
objectToStringPath(relations),
query as FindWithoutRelationsOptions
)
if (!product) {
const selectorConstraints = Object.entries(selector)
.map(([key, value]) => `${key}: ${value}`)
.join(", ")
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with ${selectorConstraints} was not found`
)
}
return product
}
@@ -888,6 +942,47 @@ class ProductService extends TransactionBaseService {
return products
})
}
/**
* Temporary method to be used in place we need custom query strategy to prevent typeorm bug
* @param selector
* @param config
* @protected
*/
protected prepareListQuery_(
selector: FilterableProductProps | Selector<Product>,
config: FindProductConfig
): {
q: string
relations: (keyof Product)[]
query: FindWithoutRelationsOptions
} {
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query = buildQuery(selector, config)
query.order = config.order
if (config.relations && config.relations.length > 0) {
query.relations = buildRelationsOrSelect(config.relations)
}
if (config.select && config.select.length > 0) {
query.select = buildRelationsOrSelect(config.select)
}
const rels = objectToStringPath(query.relations)
delete query.relations
return {
query: query as FindWithoutRelationsOptions,
relations: rels as (keyof Product)[],
q,
}
}
}
export default ProductService

View File

@@ -274,7 +274,7 @@ export function addOrderToSelect<TEntity>(
* }
* @param collection
*/
function buildRelationsOrSelect<TEntity>(
export function buildRelationsOrSelect<TEntity>(
collection: string[]
): FindOptionsRelations<TEntity> | FindOptionsSelect<TEntity> {
const output: FindOptionsRelations<TEntity> | FindOptionsSelect<TEntity> = {}

View File

@@ -10,6 +10,7 @@ import { ExtendedFindConfig } from "../types/common"
/**
* Custom query entity, it is part of the creation of a custom findWithRelationsAndCount needs.
* Allow to query the relations for the specified entity ids
*
* @param repository
* @param entityIds
* @param groupedRelations
@@ -17,18 +18,25 @@ import { ExtendedFindConfig } from "../types/common"
* @param select
* @param customJoinBuilders
*/
export async function queryEntityWithIds<T extends ObjectLiteral>(
repository: Repository<T>,
entityIds: string[],
groupedRelations: { [toplevel: string]: string[] },
export async function queryEntityWithIds<T extends ObjectLiteral>({
repository,
entityIds,
groupedRelations,
withDeleted = false,
select: (keyof T)[] = [],
customJoinBuilders: ((
select = [],
customJoinBuilders = [],
}: {
repository: Repository<T>
entityIds: string[]
groupedRelations: { [toplevel: string]: string[] }
withDeleted?: boolean
select?: (keyof T)[]
customJoinBuilders?: ((
qb: SelectQueryBuilder<T>,
alias: string,
toplevel: string
) => boolean)[] = []
): Promise<T[]> {
) => boolean)[]
}): Promise<T[]> {
const alias = repository.metadata.name.toLowerCase()
return await Promise.all(
Object.entries(groupedRelations).map(async ([toplevel, rels]) => {
@@ -58,10 +66,11 @@ export async function queryEntityWithIds<T extends ObjectLiteral>(
if (!rest) {
continue
}
// Regex matches all '.' except the rightmost
querybuilder = querybuilder.leftJoinAndSelect(
// Regex matches all '.' except the rightmost
rel.replace(/\.(?=[^.]*\.)/g, "__"),
rel.replace(".", "__")
// Replace all '.' with '__' to avoid typeorm's automatic aliasing
rel.replace(/\./g, "__")
)
}
@@ -89,20 +98,26 @@ export async function queryEntityWithIds<T extends ObjectLiteral>(
* Custom query entity without relations, it is part of the creation of a custom findWithRelationsAndCount needs.
* Allow to query the entities without taking into account the relations. The relations will be queried separately
* using the queryEntityWithIds util
*
* @param repository
* @param optionsWithoutRelations
* @param shouldCount
* @param customJoinBuilders
*/
export async function queryEntityWithoutRelations<T extends ObjectLiteral>(
repository: Repository<T>,
optionsWithoutRelations: Omit<ExtendedFindConfig<T>, "relations">,
export async function queryEntityWithoutRelations<T extends ObjectLiteral>({
repository,
optionsWithoutRelations,
shouldCount = false,
customJoinBuilders = [],
}: {
repository: Repository<T>
optionsWithoutRelations: Omit<ExtendedFindConfig<T>, "relations">
shouldCount: boolean
customJoinBuilders: ((
qb: SelectQueryBuilder<T>,
alias: string
) => void)[] = []
): Promise<[T[], number]> {
) => Promise<{ relation: string; preventOrderJoin: boolean } | void>)[]
}): Promise<[T[], number]> {
const alias = repository.metadata.name.toLowerCase()
const qb = repository
@@ -115,24 +130,30 @@ export async function queryEntityWithoutRelations<T extends ObjectLiteral>(
qb.where(optionsWithoutRelations.where)
}
if (optionsWithoutRelations.order) {
const toSelect: string[] = []
const parsed = Object.entries(optionsWithoutRelations.order).reduce(
(acc, [k, v]) => {
const key = `${alias}.${k}`
toSelect.push(key)
acc[key] = v
return acc
},
{}
)
qb.addSelect(toSelect)
qb.orderBy(parsed)
const shouldJoins: { relation: string; shouldJoin: boolean }[] = []
for (const customJoinBuilder of customJoinBuilders) {
const result = await customJoinBuilder(qb, alias)
if (result) {
shouldJoins.push({
relation: result.relation,
shouldJoin: !result.preventOrderJoin,
})
}
}
for (const customJoinBuilder of customJoinBuilders) {
customJoinBuilder(qb, alias)
}
applyOrdering({
repository,
order: (optionsWithoutRelations.order as any) ?? {},
qb,
alias,
shouldJoin: (relationToJoin) => {
return shouldJoins.every(
({ relation, shouldJoin }) =>
relation !== relationToJoin ||
(relation === relationToJoin && shouldJoin)
)
},
})
if (optionsWithoutRelations.withDeleted) {
qb.withDeleted()
@@ -252,7 +273,10 @@ export function applyOrdering<T extends ObjectLiteral>({
}
const key = `${alias}.${orderPath}`
toSelect.push(key)
// Prevent ambiguous column error when top level entity id is ordered
if (orderPath !== "id") {
toSelect.push(key)
}
acc[key] = orderDirection
return acc
},