Browse Source

feat(core): Add "enabled" field to search index, add & fix e2e tests

Relates to #62
Michael Bromley 6 years ago
parent
commit
fcd3086949

+ 1 - 1
packages/common/src/generated-shop-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-04-24T10:46:04+02:00
+// Generated in 2019-04-24T15:08:57+02:00
 export type Maybe<T> = T | null;
 
 export interface OrderListOptions {

+ 1 - 1
packages/common/src/generated-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-04-24T10:46:05+02:00
+// Generated in 2019-04-24T15:08:58+02:00
 export type Maybe<T> = T | null;
 
 

+ 3 - 0
packages/core/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -4,6 +4,7 @@ exports[`Product resolver product mutation createProduct creates a new Product 1
 Object {
   "assets": Array [],
   "description": "A baked potato",
+  "enabled": true,
   "facetValues": Array [],
   "featuredAsset": null,
   "id": "T_21",
@@ -33,6 +34,7 @@ exports[`Product resolver product mutation updateProduct updates a Product 1`] =
 Object {
   "assets": Array [],
   "description": "A blob of mashed potato",
+  "enabled": true,
   "facetValues": Array [],
   "featuredAsset": null,
   "id": "T_21",
@@ -72,6 +74,7 @@ Object {
     },
   ],
   "description": "Discover a truly immersive viewing experience with this monitor curved more deeply than any other. Wrapping around your field of vision the 1,800 R screencreates a wider field of view, enhances depth perception, and minimises peripheral distractions to draw you deeper in to your content.",
+  "enabled": true,
   "facetValues": Array [
     Object {
       "code": "electronics",

+ 37 - 8
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -8,15 +8,14 @@ import {
     UpdateProduct,
     UpdateTaxRate,
 } from '@vendure/common/lib/generated-types';
+import { pick } from '@vendure/common/lib/pick';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import {
-    CREATE_COLLECTION,
-    UPDATE_COLLECTION,
-} from '../../../admin-ui/src/app/data/definitions/collection-definitions';
-import { SEARCH_PRODUCTS, UPDATE_PRODUCT } from '../../../admin-ui/src/app/data/definitions/product-definitions';
+import { CREATE_COLLECTION, UPDATE_COLLECTION } from '../../../admin-ui/src/app/data/definitions/collection-definitions';
+import { SEARCH_PRODUCTS, UPDATE_PRODUCT, UPDATE_PRODUCT_VARIANTS } from '../../../admin-ui/src/app/data/definitions/product-definitions';
 import { UPDATE_TAX_RATE } from '../../../admin-ui/src/app/data/definitions/settings-definitions';
+import { UpdateProductVariants } from '../../common/src/generated-types';
 import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
 import { facetValueCollectionFilter } from '../src/config/collection/default-collection-filters';
 import { DefaultSearchPlugin } from '../src/plugin/default-search-plugin/default-search-plugin';
@@ -203,6 +202,36 @@ describe('Default search plugin', () => {
                 { count: 3, facetValue: { id: 'T_6', name: 'plants' } },
             ]);
         });
+
+        it('encodes the productId and productVariantId', async () => {
+            const result = await shopClient.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+                input: {
+                    groupByProduct: false,
+                    take: 1,
+                },
+            });
+            expect(pick(result.search.items[0], ['productId', 'productVariantId'])).toEqual(
+                {
+                    productId: 'T_1',
+                    productVariantId: 'T_1',
+                },
+            );
+        });
+
+        it('omits results for disabled ProductVariants', async () => {
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    { id: 'T_3', enabled: false },
+                ],
+            });
+            const result = await shopClient.query<SearchProducts.Query, SearchProducts.Variables>(SEARCH_PRODUCTS, {
+                input: {
+                    groupByProduct: false,
+                    take: 3,
+                },
+            });
+            expect(result.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
+        });
     });
 
     describe('admin api', () => {
@@ -216,9 +245,9 @@ describe('Default search plugin', () => {
 
         it('matches by collectionId', () => testMatchCollectionId(adminClient));
 
-        it('single prices', () => testSinglePrices(shopClient));
+        it('single prices', () => testSinglePrices(adminClient));
 
-        it('price ranges', () => testPriceRanges(shopClient));
+        it('price ranges', () => testPriceRanges(adminClient));
 
         it('updates index when a Product is changed', async () => {
             await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
@@ -292,7 +321,7 @@ describe('Default search plugin', () => {
             const { createCollection } = await adminClient.query<
                 CreateCollection.Mutation,
                 CreateCollection.Variables
-            >(CREATE_COLLECTION, {
+                >(CREATE_COLLECTION, {
                 input: {
                     translations: [
                         {

+ 4 - 2
packages/core/src/api/common/id-codec.ts

@@ -3,6 +3,8 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { EntityIdStrategy } from '../../config/entity-id-strategy/entity-id-strategy';
 import { VendureEntity } from '../../entity/base/base.entity';
 
+const ID_KEYS = ['id', 'productId', 'productVariantId'];
+
 /**
  * This service is responsible for encoding/decoding entity IDs according to the configured EntityIdStrategy.
  * It should only need to be used in resolvers - the design is that once a request hits the business logic layer
@@ -21,7 +23,7 @@ export class IdCodec {
      * @return A decoded clone of the target
      */
     decode<T extends string | number | object | undefined>(target: T, transformKeys?: string[]): T {
-        const transformKeysWithId = [...(transformKeys || []), 'id'];
+        const transformKeysWithId = [...(transformKeys || []), ...ID_KEYS];
         return this.transformRecursive(
             target,
             input => this.entityIdStrategy.decodeId(input),
@@ -39,7 +41,7 @@ export class IdCodec {
      * @return An encoded clone of the target
      */
     encode<T extends string | number | boolean | object | undefined>(target: T, transformKeys?: string[]): T {
-        const transformKeysWithId = [...(transformKeys || []), 'id'];
+        const transformKeysWithId = [...(transformKeys || []), ...ID_KEYS];
         return this.transformRecursive(
             target,
             input => this.entityIdStrategy.encodeId(input),

+ 13 - 11
packages/core/src/entity/product-variant/product-variant.subscriber.ts

@@ -26,17 +26,19 @@ export class ProductVariantSubscriber implements EntitySubscriberInterface<Produ
     }
 
     async afterUpdate(event: InsertEvent<ProductVariant>) {
-        const variantPrice = await event.connection.getRepository(ProductVariantPrice).findOne({
-            where: {
-                variant: event.entity.id,
-                channelId: event.queryRunner.data.channelId,
-            },
-        });
-        if (!variantPrice) {
-            throw new InternalServerError(`error.could-not-find-product-variant-price`);
-        }
+        if (event.entity.price !== undefined) {
+            const variantPrice = await event.connection.getRepository(ProductVariantPrice).findOne({
+                where: {
+                    variant: event.entity.id,
+                    channelId: event.queryRunner.data.channelId,
+                },
+            });
+            if (!variantPrice) {
+                throw new InternalServerError(`error.could-not-find-product-variant-price`);
+            }
 
-        variantPrice.price = event.entity.price || 0;
-        await event.manager.save(variantPrice);
+            variantPrice.price = event.entity.price || 0;
+            await event.manager.save(variantPrice);
+        }
     }
 }

+ 4 - 4
packages/core/src/plugin/default-search-plugin/fulltext-search.resolver.ts

@@ -24,7 +24,7 @@ export class ShopFulltextSearchResolver implements Omit<BaseSearchResolver, 'rei
         @Ctx() ctx: RequestContext,
         @Args() args: SearchQueryArgs,
     ): Promise<Omit<SearchResponse, 'facetValues'>> {
-        return this.fulltextSearchService.search(ctx, args.input);
+        return this.fulltextSearchService.search(ctx, args.input, true);
     }
 
     @ResolveProperty()
@@ -32,7 +32,7 @@ export class ShopFulltextSearchResolver implements Omit<BaseSearchResolver, 'rei
         @Ctx() ctx: RequestContext,
         @Context() context: any,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
-        return this.fulltextSearchService.facetValues(ctx, context.req.body.variables.input);
+        return this.fulltextSearchService.facetValues(ctx, context.req.body.variables.input, true);
     }
 }
 
@@ -47,7 +47,7 @@ export class AdminFulltextSearchResolver implements BaseSearchResolver {
         @Ctx() ctx: RequestContext,
         @Args() args: SearchQueryArgs,
     ): Promise<Omit<SearchResponse, 'facetValues'>> {
-        return this.fulltextSearchService.search(ctx, args.input);
+        return this.fulltextSearchService.search(ctx, args.input, false);
     }
 
     @ResolveProperty()
@@ -55,7 +55,7 @@ export class AdminFulltextSearchResolver implements BaseSearchResolver {
         @Ctx() ctx: RequestContext,
         @Context() context: any,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
-        return this.fulltextSearchService.facetValues(ctx, context.req.body.variables.input);
+        return this.fulltextSearchService.facetValues(ctx, context.req.body.variables.input, false);
     }
 
     @Mutation()

+ 6 - 4
packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts

@@ -55,9 +55,9 @@ export class FulltextSearchService implements SearchService {
     /**
      * Perform a fulltext search according to the provided input arguments.
      */
-    async search(ctx: RequestContext, input: SearchInput): Promise<Omit<SearchResponse, 'facetValues'>> {
-        const items = await this.searchStrategy.getSearchResults(ctx, input);
-        const totalItems = await this.searchStrategy.getTotalCount(ctx, input);
+    async search(ctx: RequestContext, input: SearchInput, enabledOnly: boolean = false): Promise<Omit<SearchResponse, 'facetValues'>> {
+        const items = await this.searchStrategy.getSearchResults(ctx, input, enabledOnly);
+        const totalItems = await this.searchStrategy.getTotalCount(ctx, input, enabledOnly);
         return {
             items,
             totalItems,
@@ -70,8 +70,9 @@ export class FulltextSearchService implements SearchService {
     async facetValues(
         ctx: RequestContext,
         input: SearchInput,
+        enabledOnly: boolean = false
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
-        const facetValueIdsMap = await this.searchStrategy.getFacetValueIds(ctx, input);
+        const facetValueIdsMap = await this.searchStrategy.getFacetValueIds(ctx, input, enabledOnly);
         const facetValues = await this.facetValueService.findByIds(
             Array.from(facetValueIdsMap.keys()),
             ctx.languageCode,
@@ -182,6 +183,7 @@ export class FulltextSearchService implements SearchService {
                 v =>
                     new SearchIndexItem({
                         sku: v.sku,
+                        enabled: v.enabled,
                         slug: v.product.slug,
                         price: v.price,
                         priceWithTax: v.priceWithTax,

+ 3 - 0
packages/core/src/plugin/default-search-plugin/search-index-item.entity.ts

@@ -23,6 +23,9 @@ export class SearchIndexItem {
     @Column({ type: idType() })
     productId: ID;
 
+    @Column()
+    enabled: boolean;
+
     @Index({ fulltext: true })
     @Column()
     productName: string;

+ 12 - 3
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -17,7 +17,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
 
     constructor(private connection: Connection) {}
 
-    async getFacetValueIds(ctx: RequestContext, input: SearchInput): Promise<Map<ID, number>> {
+    async getFacetValueIds(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<Map<ID, number>> {
         const facetValuesQb = this.connection
             .getRepository(SearchIndexItem)
             .createQueryBuilder('si')
@@ -28,11 +28,14 @@ export class MysqlSearchStrategy implements SearchStrategy {
         if (!input.groupByProduct) {
             facetValuesQb.groupBy('productVariantId');
         }
+        if (enabledOnly) {
+            facetValuesQb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
         const facetValuesResult = await facetValuesQb.getRawMany();
         return createFacetIdCountMap(facetValuesResult);
     }
 
-    async getSearchResults(ctx: RequestContext, input: SearchInput): Promise<SearchResult[]> {
+    async getSearchResults(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<SearchResult[]> {
         const take = input.take || 25;
         const skip = input.skip || 0;
         const sort = input.sort;
@@ -55,6 +58,9 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 qb.addOrderBy('price', sort.price);
             }
         }
+        if (enabledOnly) {
+            qb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
 
         return qb
             .take(take)
@@ -63,11 +69,14 @@ export class MysqlSearchStrategy implements SearchStrategy {
             .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
-    async getTotalCount(ctx: RequestContext, input: SearchInput): Promise<number> {
+    async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
         const innerQb = this.applyTermAndFilters(
             this.connection.getRepository(SearchIndexItem).createQueryBuilder('si'),
             input,
         );
+        if (enabledOnly) {
+            innerQb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
 
         const totalItemsQb = this.connection
             .createQueryBuilder()

+ 14 - 6
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -17,7 +17,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
 
     constructor(private connection: Connection) {}
 
-    async getFacetValueIds(ctx: RequestContext, input: SearchInput): Promise<Map<ID, number>> {
+    async getFacetValueIds(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<Map<ID, number>> {
         const facetValuesQb = this.connection
             .getRepository(SearchIndexItem)
             .createQueryBuilder('si')
@@ -28,11 +28,14 @@ export class PostgresSearchStrategy implements SearchStrategy {
         if (!input.groupByProduct) {
             facetValuesQb.groupBy('"si"."productVariantId", "si"."productId"');
         }
+        if (enabledOnly) {
+            facetValuesQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
+        }
         const facetValuesResult = await facetValuesQb.getRawMany();
         return createFacetIdCountMap(facetValuesResult);
     }
 
-    async getSearchResults(ctx: RequestContext, input: SearchInput): Promise<SearchResult[]> {
+    async getSearchResults(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<SearchResult[]> {
         const take = input.take || 25;
         const skip = input.skip || 0;
         const sort = input.sort;
@@ -59,6 +62,9 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 qb.addOrderBy('"si_price"', sort.price);
             }
         }
+        if (enabledOnly) {
+            qb.andWhere('"si"."enabled" = :enabled', { enabled: true });
+        }
 
         return qb
             .take(take)
@@ -67,7 +73,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
-    async getTotalCount(ctx: RequestContext, input: SearchInput): Promise<number> {
+    async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
         const innerQb = this.applyTermAndFilters(
             this.connection
                 .getRepository(SearchIndexItem)
@@ -75,7 +81,9 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 .select(this.createPostgresSelect(!!input.groupByProduct)),
             input,
         );
-
+        if (enabledOnly) {
+            innerQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
+        }
         const totalItemsQb = this.connection
             .createQueryBuilder()
             .select('COUNT(*) as total')
@@ -102,8 +110,8 @@ export class PostgresSearchStrategy implements SearchStrategy {
                     (ts_rank_cd(to_tsvector(${minIfGrouped('si.sku')}), to_tsquery(:term)) * 10 +
                     ts_rank_cd(to_tsvector(${minIfGrouped('si.productName')}), to_tsquery(:term)) * 2 +
                     ts_rank_cd(to_tsvector(${minIfGrouped(
-                        'si.productVariantName',
-                    )}), to_tsquery(:term)) * 1.5 +
+                    'si.productVariantName',
+                )}), to_tsquery(:term)) * 1.5 +
                     ts_rank_cd(to_tsvector(${minIfGrouped('si.description')}), to_tsquery(:term)) * 1)
                             `,
                 'score',

+ 1 - 0
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy-utils.ts

@@ -18,6 +18,7 @@ export function mapToSearchResult(raw: any, currencyCode: CurrencyCode): SearchR
         sku: raw.si_sku,
         slug: raw.si_slug,
         price,
+        enabled: raw.si_enabled,
         priceWithTax,
         currencyCode,
         productVariantId: raw.si_productVariantId,

+ 3 - 3
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy.ts

@@ -8,11 +8,11 @@ import { RequestContext } from '../../../api';
  * should follow.
  */
 export interface SearchStrategy {
-    getSearchResults(ctx: RequestContext, input: SearchInput): Promise<SearchResult[]>;
-    getTotalCount(ctx: RequestContext, input: SearchInput): Promise<number>;
+    getSearchResults(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<SearchResult[]>;
+    getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number>;
     /**
      * Returns a map of `facetValueId` => `count`, providing the number of times that
      * facetValue occurs in the result set.
      */
-    getFacetValueIds(ctx: RequestContext, input: SearchInput): Promise<Map<ID, number>>;
+    getFacetValueIds(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<Map<ID, number>>;
 }

+ 13 - 3
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -18,7 +18,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
 
     constructor(private connection: Connection) {}
 
-    async getFacetValueIds(ctx: RequestContext, input: SearchInput): Promise<Map<ID, number>> {
+    async getFacetValueIds(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<Map<ID, number>> {
         const facetValuesQb = this.connection
             .getRepository(SearchIndexItem)
             .createQueryBuilder('si')
@@ -29,11 +29,14 @@ export class SqliteSearchStrategy implements SearchStrategy {
         if (!input.groupByProduct) {
             facetValuesQb.groupBy('productVariantId');
         }
+        if (enabledOnly) {
+            facetValuesQb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
         const facetValuesResult = await facetValuesQb.getRawMany();
         return createFacetIdCountMap(facetValuesResult);
     }
 
-    async getSearchResults(ctx: RequestContext, input: SearchInput): Promise<SearchResult[]> {
+    async getSearchResults(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<SearchResult[]> {
         const take = input.take || 25;
         const skip = input.skip || 0;
         const sort = input.sort;
@@ -57,6 +60,9 @@ export class SqliteSearchStrategy implements SearchStrategy {
                 qb.addOrderBy('price', sort.price);
             }
         }
+        if (enabledOnly) {
+            qb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
 
         return await qb
             .take(take)
@@ -65,12 +71,16 @@ export class SqliteSearchStrategy implements SearchStrategy {
             .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
-    async getTotalCount(ctx: RequestContext, input: SearchInput): Promise<number> {
+    async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
         const innerQb = this.applyTermAndFilters(
             this.connection.getRepository(SearchIndexItem).createQueryBuilder('si'),
             input,
         );
 
+        if (enabledOnly) {
+            innerQb.andWhere('si.enabled = :enabled', { enabled: true });
+        }
+
         const totalItemsQb = this.connection
             .createQueryBuilder()
             .select('COUNT(*) as total')

+ 1 - 1
packages/core/src/service/services/product-variant.service.ts

@@ -101,7 +101,7 @@ export class ProductVariantService {
             .leftJoin('productvariant.collections', 'collection')
             .andWhere('collection.id = :collectionId', { collectionId });
 
-        if (options.filter && options.filter.enabled && options.filter.enabled.eq === true) {
+        if (options && options.filter && options.filter.enabled && options.filter.enabled.eq === true) {
             qb.leftJoin('productvariant.product', 'product')
                 .andWhere('product.enabled = :enabled', { enabled: true });
         }