From a81227fa74d956af42fa6b90dc91f4b2d43caff7 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 2 Feb 2022 19:29:06 +0100 Subject: [PATCH] Fix/count variants (#1020) * fix: adds listAndCount to product variants * fix: adds list and count with relations * lint fix * Update packages/medusa/src/services/product-variant.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * Update packages/medusa/src/services/product-variant.ts Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../routes/admin/variants/list-variants.ts | 9 +- .../src/repositories/product-variant.ts | 237 +++++++++++++++++- .../medusa/src/services/product-variant.ts | 124 ++++++++- 3 files changed, 363 insertions(+), 7 deletions(-) diff --git a/packages/medusa/src/api/routes/admin/variants/list-variants.ts b/packages/medusa/src/api/routes/admin/variants/list-variants.ts index d4b56bc3a4..b7366d790f 100644 --- a/packages/medusa/src/api/routes/admin/variants/list-variants.ts +++ b/packages/medusa/src/api/routes/admin/variants/list-variants.ts @@ -44,7 +44,7 @@ export default async (req, res) => { const selector: FilterableProductVariantProps = {} if ("q" in req.query) { - selector.q = req.query.q + selector.q = q } const listConfig: FindConfig = { @@ -54,9 +54,12 @@ export default async (req, res) => { take: limit, } - const variants = await variantService.list(selector, listConfig) + const [variants, count] = await variantService.listAndCount( + selector, + listConfig + ) - res.json({ variants, count: variants.length, offset, limit }) + res.json({ variants, count, offset, limit }) } export class AdminGetVariantsParams { diff --git a/packages/medusa/src/repositories/product-variant.ts b/packages/medusa/src/repositories/product-variant.ts index cf1d970606..117881bd1c 100644 --- a/packages/medusa/src/repositories/product-variant.ts +++ b/packages/medusa/src/repositories/product-variant.ts @@ -1,5 +1,238 @@ -import { EntityRepository, Repository } from "typeorm" +import { + EntityRepository, + FindConditions, + FindManyOptions, + FindOperator, + OrderByCondition, + Repository, +} from "typeorm" +import { flatten, groupBy, map, merge } from "lodash" import { ProductVariant } from "../models/product-variant" +export type FindWithRelationsOptions = FindManyOptions & { + order?: OrderByCondition + withDeleted?: boolean +} + @EntityRepository(ProductVariant) -export class ProductVariantRepository extends Repository {} +export class ProductVariantRepository extends Repository { + private mergeEntitiesWithRelations( + entitiesAndRelations: Array> + ): ProductVariant[] { + const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") + return map(entitiesAndRelationsById, (entityAndRelations) => + merge({}, ...entityAndRelations) + ) + } + + private async queryProductsVariants( + optionsWithoutRelations: FindWithRelationsOptions, + shouldCount = false + ): Promise<[ProductVariant[], number]> { + let qb = this.createQueryBuilder("pv") + .select(["pv.id"]) + .skip(optionsWithoutRelations.skip) + .take(optionsWithoutRelations.take) + + qb = optionsWithoutRelations.where + ? qb.where(optionsWithoutRelations.where) + : qb + + qb = optionsWithoutRelations.order + ? qb.orderBy(optionsWithoutRelations.order) + : qb + + if (optionsWithoutRelations.withDeleted) { + qb = qb.withDeleted() + } + + let entities: ProductVariant[] + let count = 0 + if (shouldCount) { + const result = await qb.getManyAndCount() + entities = result[0] + count = result[1] + } else { + entities = await qb.getMany() + } + + return [entities, count] + } + + private getGroupedRelations(relations: Array): { + [toplevel: string]: string[] + } { + const groupedRelations: { [toplevel: string]: string[] } = {} + for (const rel of relations) { + const [topLevel] = rel.split(".") + if (groupedRelations[topLevel]) { + groupedRelations[topLevel].push(rel) + } else { + groupedRelations[topLevel] = [rel] + } + } + + return groupedRelations + } + + private async queryProductVariantsWithIds( + entityIds: string[], + groupedRelations: { [toplevel: string]: string[] }, + withDeleted = false + ): Promise { + const entitiesIdsWithRelations = await Promise.all( + Object.entries(groupedRelations).map(([toplevel, rels]) => { + let querybuilder = this.createQueryBuilder("pv") + querybuilder = querybuilder.leftJoinAndSelect( + `pv.${toplevel}`, + toplevel + ) + + for (const rel of rels) { + const [_, rest] = rel.split(".") + if (!rest) { + continue + } + // Regex matches all '.' except the rightmost + querybuilder = querybuilder.leftJoinAndSelect( + rel.replace(/\.(?=[^.]*\.)/g, "__"), + rel.replace(".", "__") + ) + } + + if (withDeleted) { + querybuilder = querybuilder + .where("pv.id IN (:...entitiesIds)", { + entitiesIds: entityIds, + }) + .withDeleted() + } else { + querybuilder = querybuilder.where( + "pv.deleted_at IS NULL AND pv.id IN (:...entitiesIds)", + { + entitiesIds: entityIds, + } + ) + } + + return querybuilder.getMany() + }) + ).then(flatten) + + return entitiesIdsWithRelations + } + + public async findWithRelationsAndCount( + relations: string[] = [], + idsOrOptionsWithoutRelations: FindWithRelationsOptions | string[] = { + where: {}, + }, + withDeleted?: boolean + ): Promise<[ProductVariant[], number]> { + let count: number + let entities: ProductVariant[] + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations, { + withDeleted: withDeleted ?? false, + }) + count = entities.length + } else { + const result = await this.queryProductsVariants( + 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 as FindConditions + ) + return [toReturn, toReturn.length] + } + + const groupedRelations = this.getGroupedRelations( + relations as (keyof ProductVariant)[] + ) + const entitiesIdsWithRelations = await this.queryProductVariantsWithIds( + entitiesIds, + groupedRelations, + withDeleted + ) + + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + const entitiesToReturn = + this.mergeEntitiesWithRelations(entitiesAndRelations) + + return [entitiesToReturn, count] + } + + public async findWithRelations( + relations: string[] = [], + idsOrOptionsWithoutRelations: FindWithRelationsOptions | string[] = {}, + withDeleted = false + ): Promise { + let entities: ProductVariant[] + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations, { + withDeleted, + }) + } else { + const result = await this.queryProductsVariants( + 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 as FindConditions + ) + } + + const groupedRelations = this.getGroupedRelations( + relations as (keyof ProductVariant)[] + ) + const entitiesIdsWithRelations = await this.queryProductVariantsWithIds( + entitiesIds, + groupedRelations, + withDeleted + ) + + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + const entitiesToReturn = + this.mergeEntitiesWithRelations(entitiesAndRelations) + + return entitiesToReturn + } + + public async findOneWithRelations( + relations: Array = [], + optionsWithoutRelations: FindWithRelationsOptions = { where: {} } + ): Promise { + // Limit 1 + optionsWithoutRelations.take = 1 + + const result = await this.findWithRelations( + relations, + optionsWithoutRelations + ) + return result[0] + } +} diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index 900aacea8a..7bb4be7466 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -1,6 +1,12 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" -import { EntityManager, ILike, IsNull, SelectQueryBuilder } from "typeorm" +import { + Brackets, + EntityManager, + ILike, + IsNull, + SelectQueryBuilder, +} from "typeorm" import { MoneyAmount } from "../models/money-amount" import { Product } from "../models/product" import { ProductOptionValue } from "../models/product-option-value" @@ -8,7 +14,10 @@ import { ProductVariant } from "../models/product-variant" import { MoneyAmountRepository } from "../repositories/money-amount" import { ProductRepository } from "../repositories/product" import { ProductOptionValueRepository } from "../repositories/product-option-value" -import { ProductVariantRepository } from "../repositories/product-variant" +import { + FindWithRelationsOptions, + ProductVariantRepository, +} from "../repositories/product-variant" import EventBusService from "../services/event-bus" import RegionService from "../services/region" import { FindConfig } from "../types/common" @@ -542,6 +551,36 @@ class ProductVariantService extends BaseService { }) } + /** + * @param {object} selector - the query object for find + * @param {FindConfig} config - query config object for variant retrieval + * @return {Promise} the result of the find operation + */ + async listAndCount( + selector: FilterableProductVariantProps, + config: FindConfig = { relations: [], skip: 0, take: 20 } + ): Promise<[ProductVariant[], number]> { + const variantRepo = this.manager_.getCustomRepository( + this.productVariantRepository_ + ) + + const { q, query, relations } = this.prepareListQuery_(selector, config) + + if (q) { + const qb = this.getFreeTextQueryBuilder_(variantRepo, query, q) + const [raw, count] = await qb.getManyAndCount() + + const variants = await variantRepo.findWithRelations( + relations, + raw.map((i) => i.id), + query.withDeleted ?? false + ) + return [variants, count] + } + + return await variantRepo.findWithRelationsAndCount(relations, query) + } + /** * @param {FilterableProductVariantProps} selector - the query object for find * @param {FindConfig} config - query config object for variant retrieval @@ -648,6 +687,87 @@ class ProductVariantService extends BaseService { return updated } + + /** + * 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: FilterableProductVariantProps, + config: FindConfig + ): { query: FindWithRelationsOptions; relations: string[]; q?: string } { + let q: string | undefined + if (typeof selector.q !== "undefined") { + 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, + } + } + + /** + * Lists variants based on the provided parameters and includes the count of + * variants that match the query. + * @param {object} variantRepo - the variant repository + * @param {object} query - object that defines the scope for what should be returned + * @param {object} q - free text query + * @return {Promise<[ProductVariant[], number]>} an array containing the products as the first element and the total + * count of products that matches the query as the second element. + */ + getFreeTextQueryBuilder_( + variantRepo: ProductVariantRepository, + query: FindWithRelationsOptions, + q?: string + ): SelectQueryBuilder { + const where = query.where + + if (typeof where === "object") { + if ("title" in where) { + delete where.title + } + } + + let qb = variantRepo + .createQueryBuilder("pv") + .take(query.take) + .skip(Math.max(query.skip ?? 0, 0)) + .leftJoinAndSelect("pv.product", "product") + .select(["pv.id"]) + .where(where!) + .andWhere( + new Brackets((qb) => { + qb.where(`product.title ILIKE :q`, { q: `%${q}%` }) + .orWhere(`pv.title ILIKE :q`, { q: `%${q}%` }) + .orWhere(`pv.sku ILIKE :q`, { q: `%${q}%` }) + }) + ) + + if (query.withDeleted) { + qb = qb.withDeleted() + } + + return qb + } } export default ProductVariantService