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
co-authored by Riqwan Thamir
parent 26963acc0a
commit 9518efccae
9 changed files with 827 additions and 139 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): Revert product repo to prevent typeorm issues + cleanup and improvements
@@ -1,27 +1,14 @@
const path = require("path") const path = require("path")
const { ProductCategory } = require("@medusajs/medusa") 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 setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../../helpers/use-api") const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../../helpers/use-db") const { initDb, useDb } = require("../../../../helpers/use-db")
const adminSeeder = require("../../../helpers/admin-seeder") const adminSeeder = require("../../../helpers/admin-seeder")
const productSeeder = require("../../../helpers/product-seeder") const productSeeder = require("../../../helpers/product-seeder")
const priceListSeeder = require("../../../helpers/price-list-seeder")
const { const {
simpleProductFactory,
simpleDiscountFactory,
simpleProductCategoryFactory, simpleProductCategoryFactory,
simpleSalesChannelFactory, simpleSalesChannelFactory,
simpleRegionFactory,
} = require("../../../factories") } = require("../../../factories")
const testProductId = "test-product" 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 () => { it("returns a list of products in product category without category children", async () => {
const api = useApi() const api = useApi()
const params = `category_id[]=${categoryWithProductId}` const params = `category_id[]=${categoryWithProductId}`
const response = await api.get( const response = await api.get(`/admin/products?${params}`, adminHeaders)
`/admin/products?${params}`,
adminHeaders
)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1) 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 () => { it("returns a list of products in product category without category children explicitly set to false", async () => {
const api = useApi() const api = useApi()
const params = `category_id[]=${categoryWithProductId}&include_category_children=false` const params = `category_id[]=${categoryWithProductId}&include_category_children=false`
const response = await api.get( const response = await api.get(`/admin/products?${params}`, adminHeaders)
`/admin/products?${params}`,
adminHeaders
)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1) expect(response.data.products).toHaveLength(1)
@@ -159,10 +140,7 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => {
const api = useApi() const api = useApi()
const params = `category_id[]=${categoryWithProductId}&include_category_children=true` const params = `category_id[]=${categoryWithProductId}&include_category_children=true`
const response = await api.get( const response = await api.get(`/admin/products?${params}`, adminHeaders)
`/admin/products?${params}`,
adminHeaders
)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(3) expect(response.data.products).toHaveLength(3)
@@ -185,10 +163,7 @@ describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => {
const api = useApi() const api = useApi()
const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true`
const response = await api.get( const response = await api.get(`/admin/products?${params}`, adminHeaders)
`/admin/products?${params}`,
adminHeaders
)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(0) expect(response.data.products).toHaveLength(0)
@@ -98,7 +98,7 @@ describe("/store/products", () => {
internalCategoryWithProduct = await simpleProductCategoryFactory( internalCategoryWithProduct = await simpleProductCategoryFactory(
dbConnection, dbConnection,
{ {
id: inactiveCategoryWithProductId, id: internalCategoryWithProductId,
name: "inactive category with Product", name: "inactive category with Product",
products: [{ id: testProductFilteringId2 }], products: [{ id: testProductFilteringId2 }],
parent_category: nestedCategoryWithProduct, 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 () => { it("does not query products with category that are inactive", async () => {
const api = useApi() const api = useApi()
@@ -1,10 +1,4 @@
import { import { DeleteResult, FindOperator, FindOptionsRelations, In } from "typeorm"
DeleteResult,
FindOperator,
FindOptionsRelations,
In,
SelectQueryBuilder,
} from "typeorm"
import { CustomerGroup } from "../models" import { CustomerGroup } from "../models"
import { ExtendedFindConfig } from "../types/common" import { ExtendedFindConfig } from "../types/common"
import { import {
@@ -15,6 +9,7 @@ import {
} from "../utils/repository" } from "../utils/repository"
import { objectToStringPath } from "@medusajs/utils" import { objectToStringPath } from "@medusajs/utils"
import { dataSource } from "../loaders/database" import { dataSource } from "../loaders/database"
import { cloneDeep } from "lodash"
export type DefaultWithoutRelations = Omit< export type DefaultWithoutRelations = Omit<
ExtendedFindConfig<CustomerGroup>, ExtendedFindConfig<CustomerGroup>,
@@ -71,45 +66,66 @@ export const CustomerGroupRepository = dataSource
async findWithRelationsAndCount( async findWithRelationsAndCount(
relations: FindOptionsRelations<CustomerGroup> = {}, relations: FindOptionsRelations<CustomerGroup> = {},
idsOrOptionsWithoutRelations: FindWithoutRelationsOptions = { where: {} } idsOrOptionsWithoutRelations: string[] | FindWithoutRelationsOptions = {
where: {},
}
): Promise<[CustomerGroup[], number]> { ): 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 count: number
let entities: CustomerGroup[] let entities: CustomerGroup[]
if (Array.isArray(idsOrOptionsWithoutRelations)) { if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.find({ entities = await this.find({
where: { id: In(idsOrOptionsWithoutRelations) }, where: { id: In(idsOrOptionsWithoutRelations) },
withDeleted: idsOrOptionsWithoutRelations.withDeleted ?? false, withDeleted,
}) })
count = entities.length count = entities.length
} else { } else {
const customJoinsBuilders: (( const discountConditionId = (
qb: SelectQueryBuilder<CustomerGroup>, clonedOptions as FindWithoutRelationsOptions
alias: string )?.where?.discount_condition_id
) => void)[] = [] delete (clonedOptions as FindWithoutRelationsOptions)?.where
?.discount_condition_id
if (idsOrOptionsWithoutRelations?.where?.discount_condition_id) { const result = await queryEntityWithoutRelations({
const discountConditionId = repository: this,
idsOrOptionsWithoutRelations?.where?.discount_condition_id optionsWithoutRelations: clonedOptions as FindWithoutRelationsOptions,
delete idsOrOptionsWithoutRelations?.where?.discount_condition_id 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( return {
(qb: SelectQueryBuilder<CustomerGroup>, alias: string) => { relation: "discount_condition",
qb.innerJoin( preventOrderJoin: true,
"discount_condition_customer_group", }
"dc_cg", }
`dc_cg.customer_group_id = ${alias}.id AND dc_cg.condition_id = :dcId`,
{ dcId: discountConditionId }
)
}
)
}
const result = await queryEntityWithoutRelations( return
this, },
idsOrOptionsWithoutRelations, ],
true, })
customJoinsBuilders
)
entities = result[0] entities = result[0]
count = result[1] count = result[1]
} }
@@ -121,16 +137,21 @@ export const CustomerGroupRepository = dataSource
} }
if (Object.keys(relations).length === 0) { 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 // 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 // applying skip/take. Remove those options to avoid getting no results
delete options.skip if (!Array.isArray(clonedOptions)) {
delete options.take delete clonedOptions.skip
delete clonedOptions.take
}
const toReturn = await this.find({ const toReturn = await this.find({
...options, ...(isOptionsArray
where: { id: In(entitiesIds) }, ? {}
: (clonedOptions as FindWithoutRelationsOptions)),
where: {
id: In(entitiesIds),
...(Array.isArray(clonedOptions) ? {} : clonedOptions.where),
},
}) })
return [toReturn, toReturn.length] return [toReturn, toReturn.length]
} }
@@ -138,16 +159,13 @@ export const CustomerGroupRepository = dataSource
const legacyRelations = objectToStringPath(relations) const legacyRelations = objectToStringPath(relations)
const groupedRelations = getGroupedRelations(legacyRelations) const groupedRelations = getGroupedRelations(legacyRelations)
const legacySelect = objectToStringPath( const entitiesIdsWithRelations = await queryEntityWithIds({
idsOrOptionsWithoutRelations.select repository: this,
) entityIds: entitiesIds,
const entitiesIdsWithRelations = await queryEntityWithIds(
this,
entitiesIds,
groupedRelations, groupedRelations,
idsOrOptionsWithoutRelations.withDeleted, select: originalSelect,
legacySelect withDeleted,
) })
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesToReturn = const entitiesToReturn =
+536 -10
View File
@@ -1,21 +1,299 @@
import { import {
Brackets,
FindOperator, FindOperator,
FindOptionsWhere, FindOptionsWhere,
ILike,
In, In,
SelectQueryBuilder, SelectQueryBuilder,
} from "typeorm" } from "typeorm"
import { Product, ProductCategory, ProductVariant } from "../models"
import { ExtendedFindConfig } from "../types/common"
import { dataSource } from "../loaders/database"
import { ProductFilterOptions } from "../types/product"
import { import {
isObject, PriceList,
fetchCategoryDescendantsIds, Product,
} from "../utils" 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" 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({ 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( async bulkAddToCollection(
productIds: string[], productIds: string[],
collectionId: string collectionId: string
@@ -42,6 +320,235 @@ export const ProductRepository = dataSource.getRepository(Product).extend({
return this.findByIds(productIds) 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( async isProductInSalesChannels(
id: string, id: string,
salesChannelIds: 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>, options: ExtendedFindConfig<Product & ProductFilterOptions>,
q?: string q?: string
): Promise<[Product[], number]> { ): Promise<[Product[], number]> {
@@ -293,7 +819,7 @@ export const ProductRepository = dataSource.getRepository(Product).extend({
queryBuilder.setFindOptions(options_) queryBuilder.setFindOptions(options_)
return queryBuilder return queryBuilder
}, },*/
/** /**
* Upserts shipping profile for products * Upserts shipping profile for products
@@ -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 ProductService from "../product"
import { FlagRouter } from "../../utils/flag-router" import { FlagRouter } from "../../utils/flag-router"
@@ -29,7 +29,7 @@ const mockUpsertType = jest.fn().mockImplementation((value) => {
describe("ProductService", () => { describe("ProductService", () => {
describe("retrieve", () => { describe("retrieve", () => {
const productRepo = MockRepository({ const productRepo = MockRepository({
findOne: (query) => { findOneWithRelations: (rels, query) => {
if (query.where.id === "test id with variants") { if (query.where.id === "test id with variants") {
return { return {
id: "test id with variants", id: "test id with variants",
@@ -62,8 +62,8 @@ describe("ProductService", () => {
it("successfully retrieves a product", async () => { it("successfully retrieves a product", async () => {
const result = await productService.retrieve(IdMap.getId("ironman")) const result = await productService.retrieve(IdMap.getId("ironman"))
expect(productRepo.findOne).toHaveBeenCalledTimes(1) expect(productRepo.findOneWithRelations).toHaveBeenCalledTimes(1)
expect(productRepo.findOne).toHaveBeenCalledWith({ expect(productRepo.findOneWithRelations).toHaveBeenCalledWith([], {
where: { id: IdMap.getId("ironman") }, where: { id: IdMap.getId("ironman") },
}) })
@@ -80,7 +80,7 @@ describe("ProductService", () => {
collection: { id: IdMap.getId("cat"), title: "Suits" }, collection: { id: IdMap.getId("cat"), title: "Suits" },
variants: product.variants, variants: product.variants,
}), }),
findOne: () => ({ findOneWithRelations: () => ({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
title: "Suit", title: "Suit",
options: [], options: [],
@@ -210,7 +210,7 @@ describe("ProductService", () => {
describe("update", () => { describe("update", () => {
const productRepository = MockRepository({ const productRepository = MockRepository({
findOne: (query) => { findOneWithRelations: (rels, query) => {
if (query.where.id === IdMap.getId("ironman&co")) { if (query.where.id === IdMap.getId("ironman&co")) {
return Promise.resolve({ return Promise.resolve({
id: IdMap.getId("ironman&co"), id: IdMap.getId("ironman&co"),
@@ -414,7 +414,7 @@ describe("ProductService", () => {
describe("addOption", () => { describe("addOption", () => {
const productRepository = MockRepository({ const productRepository = MockRepository({
findOne: (query) => findOneWithRelations: (query) =>
Promise.resolve({ Promise.resolve({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
options: [{ title: "Color" }], options: [{ title: "Color" }],
@@ -487,7 +487,7 @@ describe("ProductService", () => {
describe("reorderVariants", () => { describe("reorderVariants", () => {
const productRepository = MockRepository({ const productRepository = MockRepository({
findOne: (query) => findOneWithRelations: (query) =>
Promise.resolve({ Promise.resolve({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
variants: [{ id: IdMap.getId("green") }, { id: IdMap.getId("blue") }], variants: [{ id: IdMap.getId("green") }, { id: IdMap.getId("blue") }],
@@ -546,7 +546,7 @@ describe("ProductService", () => {
describe("updateOption", () => { describe("updateOption", () => {
const productRepository = MockRepository({ const productRepository = MockRepository({
findOne: (query) => findOneWithRelations: (query) =>
Promise.resolve({ Promise.resolve({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
options: [ options: [
@@ -622,7 +622,7 @@ describe("ProductService", () => {
describe("deleteOption", () => { describe("deleteOption", () => {
const productRepository = MockRepository({ const productRepository = MockRepository({
findOne: (query) => findOneWithRelations: (query) =>
Promise.resolve({ Promise.resolve({
id: IdMap.getId("ironman"), id: IdMap.getId("ironman"),
variants: [ variants: [
+102 -7
View File
@@ -13,24 +13,33 @@ import {
SalesChannel, SalesChannel,
} from "../models" } from "../models"
import { ImageRepository } from "../repositories/image" import { ImageRepository } from "../repositories/image"
import { ProductRepository } from "../repositories/product" import {
FindWithoutRelationsOptions,
ProductRepository,
} from "../repositories/product"
import { ProductCategoryRepository } from "../repositories/product-category" import { ProductCategoryRepository } from "../repositories/product-category"
import { ProductOptionRepository } from "../repositories/product-option" import { ProductOptionRepository } from "../repositories/product-option"
import { ProductTagRepository } from "../repositories/product-tag" import { ProductTagRepository } from "../repositories/product-tag"
import { ProductTypeRepository } from "../repositories/product-type" import { ProductTypeRepository } from "../repositories/product-type"
import { ProductVariantRepository } from "../repositories/product-variant" import { ProductVariantRepository } from "../repositories/product-variant"
import { ExtendedFindConfig, FindConfig, Selector } from "../types/common" import { Selector } from "../types/common"
import { import {
CreateProductInput, CreateProductInput,
FilterableProductProps,
FindProductConfig, FindProductConfig,
ProductFilterOptions,
ProductOptionInput, ProductOptionInput,
ProductSelector, ProductSelector,
UpdateProductInput, UpdateProductInput,
} from "../types/product" } from "../types/product"
import { buildQuery, isString, setMetadata } from "../utils" import {
buildQuery,
buildRelationsOrSelect,
isString,
setMetadata,
} from "../utils"
import { FlagRouter } from "../utils/flag-router" import { FlagRouter } from "../utils/flag-router"
import EventBusService from "./event-bus" import EventBusService from "./event-bus"
import { objectToStringPath } from "@medusajs/utils"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -138,7 +147,7 @@ class ProductService extends TransactionBaseService {
include_discount_prices: false, include_discount_prices: false,
} }
): Promise<[Product[], number]> { ): Promise<[Product[], number]> {
const productRepo = this.activeManager_.withRepository( /* const productRepo = this.activeManager_.withRepository(
this.productRepository_ this.productRepository_
) )
@@ -147,7 +156,26 @@ class ProductService extends TransactionBaseService {
Product & ProductFilterOptions 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 include_discount_prices: false, // TODO: this seams to be unused from the repository
} }
): Promise<Product> { ): Promise<Product> {
const productRepo = this.activeManager_.withRepository( /* const productRepo = this.activeManager_.withRepository(
this.productRepository_ this.productRepository_
) )
const query = buildQuery(selector, config as FindConfig<Product>) 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 return product
} }
@@ -888,6 +942,47 @@ class ProductService extends TransactionBaseService {
return products 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 export default ProductService
+1 -1
View File
@@ -274,7 +274,7 @@ export function addOrderToSelect<TEntity>(
* } * }
* @param collection * @param collection
*/ */
function buildRelationsOrSelect<TEntity>( export function buildRelationsOrSelect<TEntity>(
collection: string[] collection: string[]
): FindOptionsRelations<TEntity> | FindOptionsSelect<TEntity> { ): FindOptionsRelations<TEntity> | FindOptionsSelect<TEntity> {
const output: FindOptionsRelations<TEntity> | FindOptionsSelect<TEntity> = {} const output: FindOptionsRelations<TEntity> | FindOptionsSelect<TEntity> = {}
+56 -32
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. * Custom query entity, it is part of the creation of a custom findWithRelationsAndCount needs.
* Allow to query the relations for the specified entity ids * Allow to query the relations for the specified entity ids
*
* @param repository * @param repository
* @param entityIds * @param entityIds
* @param groupedRelations * @param groupedRelations
@@ -17,18 +18,25 @@ import { ExtendedFindConfig } from "../types/common"
* @param select * @param select
* @param customJoinBuilders * @param customJoinBuilders
*/ */
export async function queryEntityWithIds<T extends ObjectLiteral>( export async function queryEntityWithIds<T extends ObjectLiteral>({
repository: Repository<T>, repository,
entityIds: string[], entityIds,
groupedRelations: { [toplevel: string]: string[] }, groupedRelations,
withDeleted = false, withDeleted = false,
select: (keyof T)[] = [], select = [],
customJoinBuilders: (( customJoinBuilders = [],
}: {
repository: Repository<T>
entityIds: string[]
groupedRelations: { [toplevel: string]: string[] }
withDeleted?: boolean
select?: (keyof T)[]
customJoinBuilders?: ((
qb: SelectQueryBuilder<T>, qb: SelectQueryBuilder<T>,
alias: string, alias: string,
toplevel: string toplevel: string
) => boolean)[] = [] ) => boolean)[]
): Promise<T[]> { }): Promise<T[]> {
const alias = repository.metadata.name.toLowerCase() const alias = repository.metadata.name.toLowerCase()
return await Promise.all( return await Promise.all(
Object.entries(groupedRelations).map(async ([toplevel, rels]) => { Object.entries(groupedRelations).map(async ([toplevel, rels]) => {
@@ -58,10 +66,11 @@ export async function queryEntityWithIds<T extends ObjectLiteral>(
if (!rest) { if (!rest) {
continue continue
} }
// Regex matches all '.' except the rightmost
querybuilder = querybuilder.leftJoinAndSelect( querybuilder = querybuilder.leftJoinAndSelect(
// Regex matches all '.' except the rightmost
rel.replace(/\.(?=[^.]*\.)/g, "__"), 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. * 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 * Allow to query the entities without taking into account the relations. The relations will be queried separately
* using the queryEntityWithIds util * using the queryEntityWithIds util
*
* @param repository * @param repository
* @param optionsWithoutRelations * @param optionsWithoutRelations
* @param shouldCount * @param shouldCount
* @param customJoinBuilders * @param customJoinBuilders
*/ */
export async function queryEntityWithoutRelations<T extends ObjectLiteral>( export async function queryEntityWithoutRelations<T extends ObjectLiteral>({
repository: Repository<T>, repository,
optionsWithoutRelations: Omit<ExtendedFindConfig<T>, "relations">, optionsWithoutRelations,
shouldCount = false, shouldCount = false,
customJoinBuilders = [],
}: {
repository: Repository<T>
optionsWithoutRelations: Omit<ExtendedFindConfig<T>, "relations">
shouldCount: boolean
customJoinBuilders: (( customJoinBuilders: ((
qb: SelectQueryBuilder<T>, qb: SelectQueryBuilder<T>,
alias: string alias: string
) => void)[] = [] ) => Promise<{ relation: string; preventOrderJoin: boolean } | void>)[]
): Promise<[T[], number]> { }): Promise<[T[], number]> {
const alias = repository.metadata.name.toLowerCase() const alias = repository.metadata.name.toLowerCase()
const qb = repository const qb = repository
@@ -115,24 +130,30 @@ export async function queryEntityWithoutRelations<T extends ObjectLiteral>(
qb.where(optionsWithoutRelations.where) qb.where(optionsWithoutRelations.where)
} }
if (optionsWithoutRelations.order) { const shouldJoins: { relation: string; shouldJoin: boolean }[] = []
const toSelect: string[] = [] for (const customJoinBuilder of customJoinBuilders) {
const parsed = Object.entries(optionsWithoutRelations.order).reduce( const result = await customJoinBuilder(qb, alias)
(acc, [k, v]) => { if (result) {
const key = `${alias}.${k}` shouldJoins.push({
toSelect.push(key) relation: result.relation,
acc[key] = v shouldJoin: !result.preventOrderJoin,
return acc })
}, }
{}
)
qb.addSelect(toSelect)
qb.orderBy(parsed)
} }
for (const customJoinBuilder of customJoinBuilders) { applyOrdering({
customJoinBuilder(qb, alias) repository,
} order: (optionsWithoutRelations.order as any) ?? {},
qb,
alias,
shouldJoin: (relationToJoin) => {
return shouldJoins.every(
({ relation, shouldJoin }) =>
relation !== relationToJoin ||
(relation === relationToJoin && shouldJoin)
)
},
})
if (optionsWithoutRelations.withDeleted) { if (optionsWithoutRelations.withDeleted) {
qb.withDeleted() qb.withDeleted()
@@ -252,7 +273,10 @@ export function applyOrdering<T extends ObjectLiteral>({
} }
const key = `${alias}.${orderPath}` 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 acc[key] = orderDirection
return acc return acc
}, },