diff --git a/.changeset/eighty-icons-exercise.md b/.changeset/eighty-icons-exercise.md new file mode 100644 index 0000000000..b2587f259d --- /dev/null +++ b/.changeset/eighty-icons-exercise.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): Revert product repo to prevent typeorm issues + cleanup and improvements diff --git a/integration-tests/api/__tests__/admin/products/ff-product-categories.js b/integration-tests/api/__tests__/admin/products/ff-product-categories.js index 93305985da..f366d182d6 100644 --- a/integration-tests/api/__tests__/admin/products/ff-product-categories.js +++ b/integration-tests/api/__tests__/admin/products/ff-product-categories.js @@ -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) diff --git a/integration-tests/api/__tests__/store/products/ff-product-categories.ts b/integration-tests/api/__tests__/store/products/ff-product-categories.ts index af2d4dd060..b2b946990d 100644 --- a/integration-tests/api/__tests__/store/products/ff-product-categories.ts +++ b/integration-tests/api/__tests__/store/products/ff-product-categories.ts @@ -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() diff --git a/packages/medusa/src/repositories/customer-group.ts b/packages/medusa/src/repositories/customer-group.ts index 0ad2a96bb3..7052f3b7f3 100644 --- a/packages/medusa/src/repositories/customer-group.ts +++ b/packages/medusa/src/repositories/customer-group.ts @@ -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, @@ -71,45 +66,66 @@ export const CustomerGroupRepository = dataSource async findWithRelationsAndCount( relations: FindOptionsRelations = {}, - 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, - 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, 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 = diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 34b77f97c9..b4b15a0a92 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -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, + "relations" +> + +export type FindWithoutRelationsOptions = DefaultWithoutRelations & { + where: DefaultWithoutRelations["where"] & { + price_list_id?: FindOperator + sales_channel_id?: FindOperator + category_id?: { + value: string[] + } + categories?: FindOptionsWhere + tags?: FindOperator + 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({ + 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 + }): Promise { + 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 + + 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 { + const [products] = await this._findWithRelations({ + relations, + idsOrOptionsWithoutRelations, + withDeleted, + shouldCount: false, + }) + + return products + }, + + async findOneWithRelations( + relations: string[] = [], + optionsWithoutRelations: FindWithoutRelationsOptions = { where: {} } + ): Promise { + // 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, + { 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, 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 diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index bd87e29306..0bd61e6642 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -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: [ diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index 9f2440d702..7fdb49fa92 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -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 { - const productRepo = this.activeManager_.withRepository( + /* const productRepo = this.activeManager_.withRepository( this.productRepository_ ) const query = buildQuery(selector, config as FindConfig) @@ -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, + 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 diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts index 2de8e5295a..35a6aefcc9 100644 --- a/packages/medusa/src/utils/build-query.ts +++ b/packages/medusa/src/utils/build-query.ts @@ -274,7 +274,7 @@ export function addOrderToSelect( * } * @param collection */ -function buildRelationsOrSelect( +export function buildRelationsOrSelect( collection: string[] ): FindOptionsRelations | FindOptionsSelect { const output: FindOptionsRelations | FindOptionsSelect = {} diff --git a/packages/medusa/src/utils/repository.ts b/packages/medusa/src/utils/repository.ts index 36a13c6a6d..19e2d8b00b 100644 --- a/packages/medusa/src/utils/repository.ts +++ b/packages/medusa/src/utils/repository.ts @@ -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( - repository: Repository, - entityIds: string[], - groupedRelations: { [toplevel: string]: string[] }, +export async function queryEntityWithIds({ + repository, + entityIds, + groupedRelations, withDeleted = false, - select: (keyof T)[] = [], - customJoinBuilders: (( + select = [], + customJoinBuilders = [], +}: { + repository: Repository + entityIds: string[] + groupedRelations: { [toplevel: string]: string[] } + withDeleted?: boolean + select?: (keyof T)[] + customJoinBuilders?: (( qb: SelectQueryBuilder, alias: string, toplevel: string - ) => boolean)[] = [] -): Promise { + ) => boolean)[] +}): Promise { 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( 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( * 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( - repository: Repository, - optionsWithoutRelations: Omit, "relations">, +export async function queryEntityWithoutRelations({ + repository, + optionsWithoutRelations, shouldCount = false, + customJoinBuilders = [], +}: { + repository: Repository + optionsWithoutRelations: Omit, "relations"> + shouldCount: boolean customJoinBuilders: (( qb: SelectQueryBuilder, 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( 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({ } 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 },