diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/list-products.js b/packages/medusa/src/api/routes/admin/products/__tests__/list-products.js index c4b8bc82b0..1f50468872 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/list-products.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/list-products.js @@ -21,14 +21,12 @@ describe("GET /admin/products", () => { it("returns 200 and decorated products", () => { expect(subject.status).toEqual(200) - expect(subject.body.products[0]._id).toEqual(products.product1._id) - expect(subject.body.products[0].decorated).toEqual(true) - expect(subject.body.products[1]._id).toEqual(products.product2._id) - expect(subject.body.products[1].decorated).toEqual(true) + expect(subject.body.products[0].id).toEqual(products.product1.id) + expect(subject.body.products[1].id).toEqual(products.product2.id) }) it("calls update", () => { - expect(ProductServiceMock.list).toHaveBeenCalledTimes(1) + expect(ProductServiceMock.listAndCount).toHaveBeenCalledTimes(1) }) }) }) diff --git a/packages/medusa/src/api/routes/admin/products/list-products.js b/packages/medusa/src/api/routes/admin/products/list-products.js index 25d1595a3e..47935a5a58 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.js +++ b/packages/medusa/src/api/routes/admin/products/list-products.js @@ -82,7 +82,10 @@ export default async (req, res) => { take: limit, } - const products = await productService.list(selector, listConfig) + const [products, count] = await productService.listAndCount( + selector, + listConfig + ) - res.json({ products, count: products.length, offset, limit }) + res.json({ products, count, offset, limit }) } diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index e6e5df9724..de987e6919 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -20,45 +20,50 @@ type FindWithRelationsOptions = CustomOptions @EntityRepository(Product) export class ProductRepository extends Repository { - public async findWithRelations( - relations: Array = [], - idsOrOptionsWithoutRelations: FindWithRelationsOptions = {} - ): Promise { + private mergeEntitiesWithRelations( + entitiesAndRelations: Array> + ): Product[] { + const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") + return map(entitiesAndRelationsById, (entityAndRelations) => + merge({}, ...entityAndRelations) + ) + } + + private async queryProducts( + optionsWithoutRelations: FindWithRelationsOptions, + shouldCount: boolean = false + ): Promise<[Product[], number]> { + const tags = optionsWithoutRelations.where.tags + delete optionsWithoutRelations.where.tags + let qb = this.createQueryBuilder("product") + .select(["product.id"]) + .where(optionsWithoutRelations.where) + .skip(optionsWithoutRelations.skip) + .take(optionsWithoutRelations.take) + .orderBy(optionsWithoutRelations.order) + + if (tags) { + qb = qb + .leftJoinAndSelect("product.tags", "tags") + .andWhere(`tags.id IN (:...ids)`, { ids: tags._value }) + } + let entities: Product[] - if (Array.isArray(idsOrOptionsWithoutRelations)) { - entities = await this.findByIds(idsOrOptionsWithoutRelations) + let count = null + if (shouldCount) { + const result = await qb.getManyAndCount() + entities = result[0] + count = result[1] } else { - // Since tags are in a one-to-many realtion they cant be included in a - // regular query, to solve this add the join on tags seperately if - // the query exists - const tags = idsOrOptionsWithoutRelations.where.tags - delete idsOrOptionsWithoutRelations.where.tags - let qb = this.createQueryBuilder("product") - .select(["product.id"]) - .where(idsOrOptionsWithoutRelations.where) - .skip(idsOrOptionsWithoutRelations.skip) - .take(idsOrOptionsWithoutRelations.take) - .orderBy(idsOrOptionsWithoutRelations.order) - - if (tags) { - qb = qb - .leftJoinAndSelect("product.tags", "tags") - .andWhere(`tags.id IN (:...ids)`, { ids: tags._value }) - } - entities = await qb.getMany() } - const entitiesIds = entities.map(({ id }) => id) - if (entitiesIds.length === 0) { - // no need to continue - return [] - } - - if (relations.length === 0) { - return this.findByIds(entitiesIds, idsOrOptionsWithoutRelations) - } + return [entities, count] + } + private getGroupedRelations(relations: Array): { + [toplevel: string]: string[] + } { const groupedRelations: { [toplevel: string]: string[] } = {} for (const rel of relations) { const [topLevel] = rel.split(".") @@ -69,6 +74,13 @@ export class ProductRepository extends Repository { } } + return groupedRelations + } + + private async queryProductsWithIds( + entityIds: string[], + groupedRelations: { [toplevel: string]: string[] } + ): Promise { const entitiesIdsWithRelations = await Promise.all( Object.entries(groupedRelations).map(([toplevel, rels]) => { let querybuilder = this.createQueryBuilder("products") @@ -105,18 +117,94 @@ export class ProductRepository extends Repository { return querybuilder .where( "products.deleted_at IS NULL AND products.id IN (:...entitiesIds)", - { entitiesIds } + { entitiesIds: entityIds } ) .getMany() }) ).then(flatten) - const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + return entitiesIdsWithRelations + } - const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") - return map(entitiesAndRelationsById, (entityAndRelations) => - merge({}, ...entityAndRelations) + public async findWithRelationsAndCount( + relations: Array = [], + idsOrOptionsWithoutRelations: FindWithRelationsOptions = {} + ): Promise<[Product[], number]> { + let count: number + let entities: Product[] + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations) + count = entities.length + } else { + const result = await this.queryProducts( + idsOrOptionsWithoutRelations, + true + ) + 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) { + const toReturn = await this.findByIds( + entitiesIds, + idsOrOptionsWithoutRelations + ) + return [toReturn, toReturn.length] + } + + const groupedRelations = this.getGroupedRelations(relations) + const entitiesIdsWithRelations = await this.queryProductsWithIds( + entitiesIds, + groupedRelations ) + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + const entitiesToReturn = + this.mergeEntitiesWithRelations(entitiesAndRelations) + + return [entitiesToReturn, count] + } + + public async findWithRelations( + relations: Array = [], + idsOrOptionsWithoutRelations: FindWithRelationsOptions = {} + ): Promise { + let entities: Product[] + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations) + } else { + const result = await this.queryProducts( + idsOrOptionsWithoutRelations, + false + ) + entities = result[0] + } + const entitiesIds = entities.map(({ id }) => id) + + if (entitiesIds.length === 0) { + // no need to continue + return [] + } + + if (relations.length === 0) { + return await this.findByIds(entitiesIds, idsOrOptionsWithoutRelations) + } + + const groupedRelations = this.getGroupedRelations(relations) + const entitiesIdsWithRelations = await this.queryProductsWithIds( + entitiesIds, + groupedRelations + ) + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + const entitiesToReturn = + this.mergeEntitiesWithRelations(entitiesAndRelations) + + return entitiesToReturn } public async findOneWithRelations( diff --git a/packages/medusa/src/services/__mocks__/product.js b/packages/medusa/src/services/__mocks__/product.js index e4769998c3..5642ed4831 100644 --- a/packages/medusa/src/services/__mocks__/product.js +++ b/packages/medusa/src/services/__mocks__/product.js @@ -63,10 +63,10 @@ export const products = { } export const ProductServiceMock = { - withTransaction: function() { + withTransaction: function () { return this }, - create: jest.fn().mockImplementation(data => { + create: jest.fn().mockImplementation((data) => { if (data.title === "Test Product") { return Promise.resolve(products.product1) } @@ -76,14 +76,14 @@ export const ProductServiceMock = { return Promise.resolve({ ...data }) }), count: jest.fn().mockReturnValue(4), - publish: jest.fn().mockImplementation(_ => { + publish: jest.fn().mockImplementation((_) => { return Promise.resolve({ id: IdMap.getId("publish"), name: "Product 1", published: true, }) }), - delete: jest.fn().mockImplementation(_ => { + delete: jest.fn().mockImplementation((_) => { return Promise.resolve() }), createVariant: jest.fn().mockImplementation((productId, value) => { @@ -111,7 +111,7 @@ export const ProductServiceMock = { .mockReturnValue( Promise.resolve([{ id: IdMap.getId("1") }, { id: IdMap.getId("2") }]) ), - retrieve: jest.fn().mockImplementation(productId => { + retrieve: jest.fn().mockImplementation((productId) => { if (productId === IdMap.getId("product1")) { return Promise.resolve(products.product1) } @@ -135,7 +135,10 @@ export const ProductServiceMock = { update: jest.fn().mockImplementation((product, data) => { return Promise.resolve(products.product1) }), - list: jest.fn().mockImplementation(data => { + listAndCount: jest.fn().mockImplementation((data) => { + return Promise.resolve([[products.product1, products.product2], 2]) + }), + list: jest.fn().mockImplementation((data) => { // Used to retrieve a product based on a variant id see // ProductVariantService.addOptionValue if (data.variants === IdMap.getId("giftCardVar")) { diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index bdb1d83ded..d6d9572e18 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -87,82 +87,86 @@ class ProductService extends BaseService { } /** - * @param {object} selector - selector for query - * @param {Object} config - config for query object for find - * @return {Promise} the result of the find operation + * Lists products based on the provided parameters. + * @param {object} selector - an object that defines rules to filter products + * by + * @param {object} config - object that defines the scope for what should be + * returned + * @return {Promise} the result of the find operation */ async list(selector = {}, config = { relations: [], skip: 0, take: 20 }) { const productRepo = this.manager_.getCustomRepository( this.productRepository_ ) - let q - if ("q" in selector) { - q = selector.q - delete selector.q - } - - const query = this.buildQuery_(selector, config) - - if (config.relations && config.relations.length > 0) { - query.relations = config.relations - } - - if (config.select && config.select.length > 0) { - query.select = config.select - } - - const rels = query.relations - delete query.relations + const { q, query, relations } = this.prepareListQuery_(selector, config) if (q) { - const where = query.where - - delete where.description - delete where.title - - const raw = await productRepo - .createQueryBuilder("product") - .leftJoinAndSelect("product.variants", "variant") - .leftJoinAndSelect("product.collection", "collection") - .select(["product.id"]) - .where(where) - .andWhere( - new Brackets((qb) => { - qb.where(`product.description ILIKE :q`, { q: `%${q}%` }) - .orWhere(`product.title ILIKE :q`, { q: `%${q}%` }) - .orWhere(`variant.title ILIKE :q`, { q: `%${q}%` }) - .orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` }) - .orWhere(`collection.title ILIKE :q`, { q: `%${q}%` }) - }) - ) - .getMany() - + const qb = this.getFreeTextQueryBuilder_(productRepo, query, q) + const raw = await qb.getMany() return productRepo.findWithRelations( - rels, + relations, raw.map((i) => i.id) ) } - return productRepo.findWithRelations(rels, query) + return productRepo.findWithRelations(relations, query) + } + + /** + * Lists products based on the provided parameters and includes the count of + * products that match the query. + * @param {object} selector - an object that defines rules to filter products + * by + * @param {object} config - object that defines the scope for what should be + * returned + * @return {[Promise, number]} an array containing the products as + * the first element and the total count of products that matches the query + * as the second element. + */ + async listAndCount( + selector = {}, + config = { relations: [], skip: 0, take: 20 } + ) { + const productRepo = this.manager_.getCustomRepository( + this.productRepository_ + ) + + const { q, query, relations } = this.prepareListQuery_(selector, config) + + if (q) { + const qb = this.getFreeTextQueryBuilder_(productRepo, query, q) + const [raw, count] = await qb.getManyAndCount() + + const products = await productRepo.findWithRelations( + relations, + raw.map((i) => i.id) + ) + return [products, count] + } + + return await productRepo.findWithRelationsAndCount(relations, query) } /** * Return the total number of documents in database + * @param {object} selector - the selector to choose products by * @return {Promise} the result of the count operation */ - count() { + count(selector = {}) { const productRepo = this.manager_.getCustomRepository( this.productRepository_ ) - return productRepo.count() + const query = this.buildQuery_(selector) + return productRepo.count(query) } /** * Gets a product by id. * Throws in case of DB Error and if product was not found. * @param {string} productId - id of the product to get. - * @param {object} config - config of the product to get. + * @param {object} config - object that defines what should be included in the + * query response * @return {Promise} the result of the find one operation. */ async retrieve(productId, config = {}) { @@ -747,6 +751,70 @@ class ProductService extends BaseService { // const final = await this.runDecorators_(decorated) return product } + + /** + * Creates a query object to be used for list queries. + * @param {object} selector - the selector to create the query from + * @param {object} config - the config to use for the query + * @return {object} an object containing the query, relations and free-text + * search param. + */ + prepareListQuery_(selector, config) { + let q + if ("q" in selector) { + q = selector.q + delete selector.q + } + + const query = this.buildQuery_(selector, config) + + if (config.relations && config.relations.length > 0) { + query.relations = config.relations + } + + if (config.select && config.select.length > 0) { + query.select = config.select + } + + const rels = query.relations + delete query.relations + + return { + query, + relations: rels, + q, + } + } + + /** + * Creates a QueryBuilder that can fetch products based on free text. + * @param {ProductRepository} productRepo - an instance of a ProductRepositry + * @param {FindOptions} query - the query to get products by + * @param {string} q - the text to perform free text search from + * @return {QueryBuilder} a query builder that can fetch products + */ + getFreeTextQueryBuilder_(productRepo, query, q) { + const where = query.where + + delete where.description + delete where.title + + return productRepo + .createQueryBuilder("product") + .leftJoinAndSelect("product.variants", "variant") + .leftJoinAndSelect("product.collection", "collection") + .select(["product.id"]) + .where(where) + .andWhere( + new Brackets((qb) => { + qb.where(`product.description ILIKE :q`, { q: `%${q}%` }) + .orWhere(`product.title ILIKE :q`, { q: `%${q}%` }) + .orWhere(`variant.title ILIKE :q`, { q: `%${q}%` }) + .orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` }) + .orWhere(`collection.title ILIKE :q`, { q: `%${q}%` }) + }) + ) + } } export default ProductService