Browse Source

feat(server): Add categoryId filter to the products query

Relates to #43
Michael Bromley 7 years ago
parent
commit
b5fe9df784

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 1 - 0
server/src/api/types/product.api.graphql

@@ -33,6 +33,7 @@ input ProductListOptions {
     skip: Int
     sort: ProductSortParameter
     filter: ProductFilterParameter
+    categoryId: ID
 }
 
 input ProductSortParameter {

+ 57 - 14
server/src/service/services/product-category.service.ts

@@ -14,14 +14,13 @@ import { IllegalOperationError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
-import { FacetValue, Product } from '../../entity';
 import { ProductCategoryTranslation } from '../../entity/product-category/product-category-translation.entity';
 import { ProductCategory } from '../../entity/product-category/product-category.entity';
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
-import { translateDeep, translateTree } from '../helpers/utils/translate-entity';
+import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { ChannelService } from './channel.service';
 import { FacetValueService } from './facet-value.service';
@@ -78,18 +77,24 @@ export class ProductCategoryService {
         return translateDeep(productCategory, ctx.languageCode, ['facetValues', 'parent']);
     }
 
-    async findProductsByCategory(ctx: RequestContext, categoryId: ID) {
-        const ancestors = await this.connection
-            .getTreeRepository(ProductCategory)
-            .createAncestorsQueryBuilder('category', 'categoryClosure', { id: categoryId } as any)
-            .leftJoinAndSelect('category.facetValues', 'facetValue')
-            .getMany();
-        this.connection
-            .getRepository(Product)
-            .createQueryBuilder('products')
-            .innerJoin(FacetValue, 'facetValues');
-
-        return {} as any;
+    /**
+     * Given a categoryId, returns an array of all the facetValueIds assigned to that
+     * category and its ancestors. A Product is considered to be "in" a category when it has *all*
+     * of these facetValues assigned to it.
+     */
+    async getFacetValueIdsForCategory(categoryId: ID): Promise<ID[]> {
+        const category = await this.connection
+            .getRepository(ProductCategory)
+            .findOne(categoryId, { relations: ['facetValues'] });
+        if (!category) {
+            return [];
+        }
+        const ancestors = await this.getAncestors(categoryId);
+        const facetValueIds = [category, ...ancestors].reduce(
+            (flat, c) => [...flat, ...c.facetValues.map(fv => fv.id)],
+            [] as ID[],
+        );
+        return facetValueIds;
     }
 
     /**
@@ -111,6 +116,44 @@ export class ProductCategoryService {
         return descendants.map(c => translateDeep(c, ctx.languageCode));
     }
 
+    /**
+     * Gets the ancestors of a given category. Note that since ProductCategories are implemented as an adjacency list, this method
+     * will produce more queries the deeper the category is in the tree.
+     * @param categoryId
+     */
+    getAncestors(categoryId: ID): Promise<ProductCategory[]>;
+    getAncestors(categoryId: ID, ctx: RequestContext): Promise<Array<Translated<ProductCategory>>>;
+    async getAncestors(
+        categoryId: ID,
+        ctx?: RequestContext,
+    ): Promise<Array<Translated<ProductCategory> | ProductCategory>> {
+        const getParent = async (id, _ancestors: ProductCategory[] = []): Promise<ProductCategory[]> => {
+            const parent = await this.connection
+                .getRepository(ProductCategory)
+                .createQueryBuilder()
+                .relation(ProductCategory, 'parent')
+                .of(id)
+                .loadOne();
+            if (parent) {
+                if (!parent.isRoot) {
+                    _ancestors.push(parent);
+                    return getParent(parent.id, _ancestors);
+                }
+            }
+            return _ancestors;
+        };
+        const ancestors = await getParent(categoryId);
+
+        return this.connection
+            .getRepository(ProductCategory)
+            .findByIds(ancestors.map(c => c.id), {
+                relations: ['facetValues'],
+            })
+            .then(categories => {
+                return ctx ? categories.map(c => translateDeep(c, ctx.languageCode)) : categories;
+            });
+    }
+
     async create(
         ctx: RequestContext,
         input: CreateProductCategoryInput,

+ 44 - 15
server/src/service/services/product.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { Connection } from 'typeorm';
+import { Connection, FindConditions, In } from 'typeorm';
 
 import { CreateProductInput, UpdateProductInput } from '../../../../shared/generated-types';
 import { ID, PaginatedList } from '../../../../shared/shared-types';
@@ -21,16 +21,27 @@ import { translateDeep } from '../helpers/utils/translate-entity';
 
 import { ChannelService } from './channel.service';
 import { FacetValueService } from './facet-value.service';
+import { ProductCategoryService } from './product-category.service';
 import { ProductVariantService } from './product-variant.service';
 import { TaxRateService } from './tax-rate.service';
 
 @Injectable()
 export class ProductService {
+    private readonly relations = [
+        'featuredAsset',
+        'assets',
+        'optionGroups',
+        'channels',
+        'facetValues',
+        'facetValues.facet',
+    ];
+
     constructor(
         @InjectConnection() private connection: Connection,
         private channelService: ChannelService,
         private assetUpdater: AssetUpdater,
         private productVariantService: ProductVariantService,
+        private productCategoryService: ProductCategoryService,
         private facetValueService: FacetValueService,
         private taxRateService: TaxRateService,
         private listQueryBuilder: ListQueryBuilder,
@@ -38,21 +49,22 @@ export class ProductService {
         private eventBus: EventBus,
     ) {}
 
-    findAll(
+    async findAll(
         ctx: RequestContext,
-        options?: ListQueryOptions<Product>,
+        options?: ListQueryOptions<Product> & { categoryId?: ID },
     ): Promise<PaginatedList<Translated<Product>>> {
-        const relations = [
-            'featuredAsset',
-            'assets',
-            'optionGroups',
-            'channels',
-            'facetValues',
-            'facetValues.facet',
-        ];
-
+        let where: FindConditions<Product> | undefined;
+        if (options && options.categoryId) {
+            where = {
+                id: In(await this.getProductIdsInCategory(options.categoryId)),
+            };
+        }
         return this.listQueryBuilder
-            .build(Product, options, { relations, channelId: ctx.channelId })
+            .build(Product, options, {
+                relations: this.relations,
+                channelId: ctx.channelId,
+                where,
+            })
             .getManyAndCount()
             .then(async ([products, totalItems]) => {
                 const items = products.map(product =>
@@ -70,8 +82,9 @@ export class ProductService {
     }
 
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
-        const relations = ['featuredAsset', 'assets', 'optionGroups', 'facetValues', 'facetValues.facet'];
-        const product = await this.connection.manager.findOne(Product, productId, { relations });
+        const product = await this.connection.manager.findOne(Product, productId, {
+            relations: this.relations,
+        });
         if (!product) {
             return;
         }
@@ -147,6 +160,22 @@ export class ProductService {
         return assertFound(this.findOne(ctx, productId));
     }
 
+    private async getProductIdsInCategory(categoryId: ID): Promise<ID[]> {
+        const facetValueIds = await this.productCategoryService.getFacetValueIdsForCategory(categoryId);
+        const qb = this.connection
+            .getRepository(Product)
+            .createQueryBuilder('product')
+            .select(['product.id'])
+            .innerJoin('product.facetValues', 'facetValue', 'facetValue.id IN (:...facetValueIds)', {
+                facetValueIds,
+            })
+            .groupBy('product.id')
+            .having('count(distinct facetValue.id) = :idCount', { idCount: facetValueIds.length });
+
+        const productIds = await qb.getRawMany().then(rows => rows.map(r => r.product_id));
+        return productIds;
+    }
+
     private async getProductWithOptionGroups(productId: ID): Promise<Product> {
         const product = await this.connection
             .getRepository(Product)

+ 1 - 0
shared/generated-types.ts

@@ -928,6 +928,7 @@ export interface ProductListOptions {
     skip?: number | null;
     sort?: ProductSortParameter | null;
     filter?: ProductFilterParameter | null;
+    categoryId?: string | null;
 }
 
 export interface ProductSortParameter {

Some files were not shown because too many files changed in this diff