Browse Source

feat(core): Search by facetValueId allows operator argument

Closes #357
Michael Bromley 5 years ago
parent
commit
2eca24e40c

+ 6 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1743,6 +1743,11 @@ export type LocalizedString = {
   value: Scalars['String'];
 };
 
+export enum LogicalOperator {
+  AND = 'AND',
+  OR = 'OR'
+}
+
 export type LoginResult = {
    __typename?: 'LoginResult';
   user: CurrentUser;
@@ -3249,6 +3254,7 @@ export type Sale = Node & StockMovement & {
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
+  facetValueOperator?: Maybe<LogicalOperator>;
   collectionId?: Maybe<Scalars['ID']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;
   take?: Maybe<Scalars['Int']>;

+ 6 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1736,6 +1736,11 @@ export type LocalizedString = {
     value: Scalars['String'];
 };
 
+export enum LogicalOperator {
+    AND = 'AND',
+    OR = 'OR',
+}
+
 export type LoginResult = {
     __typename?: 'LoginResult';
     user: CurrentUser;
@@ -3085,6 +3090,7 @@ export type Sale = Node &
 export type SearchInput = {
     term?: Maybe<Scalars['String']>;
     facetValueIds?: Maybe<Array<Scalars['ID']>>;
+    facetValueOperator?: Maybe<LogicalOperator>;
     collectionId?: Maybe<Scalars['ID']>;
     groupByProduct?: Maybe<Scalars['Boolean']>;
     take?: Maybe<Scalars['Int']>;

+ 6 - 0
packages/common/src/generated-shop-types.ts

@@ -1307,6 +1307,11 @@ export type LocalizedString = {
     value: Scalars['String'];
 };
 
+export enum LogicalOperator {
+    AND = 'AND',
+    OR = 'OR',
+}
+
 export type LoginResult = {
     __typename?: 'LoginResult';
     user: CurrentUser;
@@ -2023,6 +2028,7 @@ export type Sale = Node &
 export type SearchInput = {
     term?: Maybe<Scalars['String']>;
     facetValueIds?: Maybe<Array<Scalars['ID']>>;
+    facetValueOperator?: Maybe<LogicalOperator>;
     collectionId?: Maybe<Scalars['ID']>;
     groupByProduct?: Maybe<Scalars['Boolean']>;
     take?: Maybe<Scalars['Int']>;

+ 6 - 0
packages/common/src/generated-types.ts

@@ -1735,6 +1735,11 @@ export type LocalizedString = {
   value: Scalars['String'];
 };
 
+export enum LogicalOperator {
+  AND = 'AND',
+  OR = 'OR'
+}
+
 export type LoginResult = {
    __typename?: 'LoginResult';
   user: CurrentUser;
@@ -3206,6 +3211,7 @@ export type Sale = Node & StockMovement & {
 export type SearchInput = {
   term?: Maybe<Scalars['String']>;
   facetValueIds?: Maybe<Array<Scalars['ID']>>;
+  facetValueOperator?: Maybe<LogicalOperator>;
   collectionId?: Maybe<Scalars['ID']>;
   groupByProduct?: Maybe<Scalars['Boolean']>;
   take?: Maybe<Scalars['Int']>;

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

@@ -36,7 +36,7 @@ import {
     UpdateProductVariants,
     UpdateTaxRate,
 } from './graphql/generated-e2e-admin-types';
-import { SearchProductsShop } from './graphql/generated-e2e-shop-types';
+import { LogicalOperator, SearchProductsShop } from './graphql/generated-e2e-shop-types';
 import {
     ASSIGN_PRODUCT_TO_CHANNEL,
     CREATE_CHANNEL,
@@ -123,12 +123,13 @@ describe('Default search plugin', () => {
         ]);
     }
 
-    async function testMatchFacetIds(client: SimpleGraphQLClient) {
+    async function testMatchFacetIdsAnd(client: SimpleGraphQLClient) {
         const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
             SEARCH_PRODUCTS_SHOP,
             {
                 input: {
                     facetValueIds: ['T_1', 'T_2'],
+                    facetValueOperator: LogicalOperator.AND,
                     groupByProduct: true,
                 },
             },
@@ -143,6 +144,34 @@ describe('Default search plugin', () => {
         ]);
     }
 
+    async function testMatchFacetIdsOr(client: SimpleGraphQLClient) {
+        const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
+            SEARCH_PRODUCTS_SHOP,
+            {
+                input: {
+                    facetValueIds: ['T_1', 'T_5'],
+                    facetValueOperator: LogicalOperator.OR,
+                    groupByProduct: true,
+                },
+            },
+        );
+        expect(result.search.items.map(i => i.productName)).toEqual([
+            'Laptop',
+            'Curvy Monitor',
+            'Gaming PC',
+            'Hard Drive',
+            'Clacky Keyboard',
+            'USB Cable',
+            'Instant Camera',
+            'Camera Lens',
+            'Tripod',
+            'Slr Camera',
+            'Spiky Cactus',
+            'Orchid',
+            'Bonsai Tree',
+        ]);
+    }
+
     async function testMatchCollectionId(client: SimpleGraphQLClient) {
         const result = await client.query<SearchProductsShop.Query, SearchProductsShop.Variables>(
             SEARCH_PRODUCTS_SHOP,
@@ -219,7 +248,9 @@ describe('Default search plugin', () => {
 
         it('matches search term', () => testMatchSearchTerm(shopClient));
 
-        it('matches by facetId', () => testMatchFacetIds(shopClient));
+        it('matches by facetId with AND operator', () => testMatchFacetIdsAnd(shopClient));
+
+        it('matches by facetId with OR operator', () => testMatchFacetIdsOr(shopClient));
 
         it('matches by collectionId', () => testMatchCollectionId(shopClient));
 
@@ -369,7 +400,9 @@ describe('Default search plugin', () => {
 
         it('matches search term', () => testMatchSearchTerm(adminClient));
 
-        it('matches by facetId', () => testMatchFacetIds(adminClient));
+        it('matches by facetId with AND operator', () => testMatchFacetIdsAnd(adminClient));
+
+        it('matches by facetId with OR operator', () => testMatchFacetIdsOr(adminClient));
 
         it('matches by collectionId', () => testMatchCollectionId(adminClient));
 

+ 6 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1736,6 +1736,11 @@ export type LocalizedString = {
     value: Scalars['String'];
 };
 
+export enum LogicalOperator {
+    AND = 'AND',
+    OR = 'OR',
+}
+
 export type LoginResult = {
     __typename?: 'LoginResult';
     user: CurrentUser;
@@ -3085,6 +3090,7 @@ export type Sale = Node &
 export type SearchInput = {
     term?: Maybe<Scalars['String']>;
     facetValueIds?: Maybe<Array<Scalars['ID']>>;
+    facetValueOperator?: Maybe<LogicalOperator>;
     collectionId?: Maybe<Scalars['ID']>;
     groupByProduct?: Maybe<Scalars['Boolean']>;
     take?: Maybe<Scalars['Int']>;

+ 6 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1307,6 +1307,11 @@ export type LocalizedString = {
     value: Scalars['String'];
 };
 
+export enum LogicalOperator {
+    AND = 'AND',
+    OR = 'OR',
+}
+
 export type LoginResult = {
     __typename?: 'LoginResult';
     user: CurrentUser;
@@ -2023,6 +2028,7 @@ export type Sale = Node &
 export type SearchInput = {
     term?: Maybe<Scalars['String']>;
     facetValueIds?: Maybe<Array<Scalars['ID']>>;
+    facetValueOperator?: Maybe<LogicalOperator>;
     collectionId?: Maybe<Scalars['ID']>;
     groupByProduct?: Maybe<Scalars['Boolean']>;
     take?: Maybe<Scalars['Int']>;

+ 6 - 0
packages/core/src/api/schema/common/common-types.graphql

@@ -117,9 +117,15 @@ input DateOperators {
     between: DateRange
 }
 
+enum LogicalOperator {
+    AND
+    OR
+}
+
 input SearchInput {
     term: String
     facetValueIds: [ID!]
+    facetValueOperator: LogicalOperator
     collectionId: ID
     groupByProduct: Boolean
     take: Int

+ 21 - 11
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -1,4 +1,4 @@
-import { SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
+import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 import { Brackets, Connection, SelectQueryBuilder } from 'typeorm';
@@ -79,7 +79,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
             .take(take)
             .skip(skip)
             .getRawMany()
-            .then((res) => res.map((r) => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -100,7 +100,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
             .select('COUNT(*) as total')
             .from(`(${innerQb.getQuery()})`, 'inner')
             .setParameters(innerQb.getParameters());
-        return totalItemsQb.getRawOne().then((res) => res.total);
+        return totalItemsQb.getRawOne().then(res => res.total);
     }
 
     private applyTermAndFilters(
@@ -108,7 +108,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
         qb: SelectQueryBuilder<SearchIndexItem>,
         input: SearchInput,
     ): SelectQueryBuilder<SearchIndexItem> {
-        const { term, facetValueIds, collectionId } = input;
+        const { term, facetValueIds, facetValueOperator, collectionId } = input;
 
         qb.where('1 = 1');
         if (term && term.length > this.minTermLength) {
@@ -122,7 +122,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
                     'score',
                 )
                 .andWhere(
-                    new Brackets((qb1) => {
+                    new Brackets(qb1 => {
                         qb1.where('sku LIKE :like_term')
                             .orWhere('MATCH (productName) AGAINST (:term)')
                             .orWhere('MATCH (productVariantName) AGAINST (:term)')
@@ -131,11 +131,21 @@ export class MysqlSearchStrategy implements SearchStrategy {
                 )
                 .setParameters({ term, like_term: `%${term}%` });
         }
-        if (facetValueIds) {
-            for (const id of facetValueIds) {
-                const placeholder = '_' + id;
-                qb.andWhere(`FIND_IN_SET(:${placeholder}, facetValueIds)`, { [placeholder]: id });
-            }
+        if (facetValueIds?.length) {
+            qb.andWhere(
+                new Brackets(qb1 => {
+                    for (const id of facetValueIds) {
+                        const placeholder = '_' + id;
+                        const clause = `FIND_IN_SET(:${placeholder}, facetValueIds)`;
+                        const params = { [placeholder]: id };
+                        if (facetValueOperator === LogicalOperator.AND) {
+                            qb1.andWhere(clause, params);
+                        } else {
+                            qb1.orWhere(clause, params);
+                        }
+                    }
+                }),
+            );
         }
         if (collectionId) {
             qb.andWhere(`FIND_IN_SET (:collectionId, collectionIds)`, { collectionId });
@@ -155,7 +165,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
      */
     private createMysqlsSelect(groupByProduct: boolean): string {
         return fieldsToSelect
-            .map((col) => {
+            .map(col => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;
                 if (groupByProduct && col !== 'productId') {

+ 21 - 13
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -1,4 +1,4 @@
-import { SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
+import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, Connection, SelectQueryBuilder } from 'typeorm';
 
@@ -81,7 +81,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .take(take)
             .skip(skip)
             .getRawMany()
-            .then((res) => res.map((r) => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
@@ -101,7 +101,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .select('COUNT(*) as total')
             .from(`(${innerQb.getQuery()})`, 'inner')
             .setParameters(innerQb.getParameters());
-        return totalItemsQb.getRawOne().then((res) => res.total);
+        return totalItemsQb.getRawOne().then(res => res.total);
     }
 
     private applyTermAndFilters(
@@ -110,7 +110,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         input: SearchInput,
         forceGroup: boolean = false,
     ): SelectQueryBuilder<SearchIndexItem> {
-        const { term, facetValueIds, collectionId } = input;
+        const { term, facetValueIds, facetValueOperator, collectionId } = input;
         // join multiple words with the logical AND operator
         const termLogicalAnd = term ? term.trim().replace(/\s+/, ' & ') : '';
 
@@ -130,7 +130,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 'score',
             )
                 .andWhere(
-                    new Brackets((qb1) => {
+                    new Brackets(qb1 => {
                         qb1.where('to_tsvector(si.sku) @@ to_tsquery(:term)')
                             .orWhere('to_tsvector(si.productName) @@ to_tsquery(:term)')
                             .orWhere('to_tsvector(si.productVariantName) @@ to_tsquery(:term)')
@@ -139,13 +139,21 @@ export class PostgresSearchStrategy implements SearchStrategy {
                 )
                 .setParameters({ term: termLogicalAnd });
         }
-        if (facetValueIds) {
-            for (const id of facetValueIds) {
-                const placeholder = '_' + id;
-                qb.andWhere(`:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`, {
-                    [placeholder]: id,
-                });
-            }
+        if (facetValueIds?.length) {
+            qb.andWhere(
+                new Brackets(qb1 => {
+                    for (const id of facetValueIds) {
+                        const placeholder = '_' + id;
+                        const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
+                        const params = { [placeholder]: id };
+                        if (facetValueOperator === LogicalOperator.AND) {
+                            qb1.andWhere(clause, params);
+                        } else {
+                            qb1.orWhere(clause, params);
+                        }
+                    }
+                }),
+            );
         }
         if (collectionId) {
             qb.andWhere(`:collectionId = ANY (string_to_array(si.collectionIds, ','))`, { collectionId });
@@ -165,7 +173,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
      */
     private createPostgresSelect(groupByProduct: boolean): string {
         return fieldsToSelect
-            .map((col) => {
+            .map(col => {
                 const qualifiedName = `si.${col}`;
                 const alias = `si_${col}`;
                 if (groupByProduct && col !== 'productId') {

+ 17 - 9
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -1,4 +1,4 @@
-import { SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
+import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 import { Brackets, Connection, SelectQueryBuilder } from 'typeorm';
@@ -105,7 +105,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
         qb: SelectQueryBuilder<SearchIndexItem>,
         input: SearchInput,
     ): SelectQueryBuilder<SearchIndexItem> {
-        const { term, facetValueIds, collectionId } = input;
+        const { term, facetValueIds, facetValueOperator, collectionId } = input;
 
         qb.where('1 = 1');
         if (term && term.length > this.minTermLength) {
@@ -129,13 +129,21 @@ export class SqliteSearchStrategy implements SearchStrategy {
                 )
                 .setParameters({ term, like_term: `%${term}%` });
         }
-        if (facetValueIds) {
-            for (const id of facetValueIds) {
-                const placeholder = '_' + id;
-                qb.andWhere(`(',' || facetValueIds || ',') LIKE :${placeholder}`, {
-                    [placeholder]: `%,${id},%`,
-                });
-            }
+        if (facetValueIds?.length) {
+            qb.andWhere(
+                new Brackets(qb1 => {
+                    for (const id of facetValueIds) {
+                        const placeholder = '_' + id;
+                        const clause = `(',' || facetValueIds || ',') LIKE :${placeholder}`;
+                        const params = { [placeholder]: `%,${id},%` };
+                        if (facetValueOperator === LogicalOperator.AND) {
+                            qb1.andWhere(clause, params);
+                        } else {
+                            qb1.orWhere(clause, params);
+                        }
+                    }
+                }),
+            );
         }
         if (collectionId) {
             qb.andWhere(`(',' || collectionIds || ',') LIKE :collectionId`, {

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


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


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