Browse Source

feat(elasticsearch-plugin): Allow querying by price range

Michael Bromley 6 years ago
parent
commit
573f345005

+ 132 - 0
packages/elasticsearch-plugin/src/build-elastic-body.spec.ts

@@ -175,4 +175,136 @@ describe('buildElasticBody()', () => {
             },
         });
     });
+
+    describe('price ranges', () => {
+        it('not grouped by product', () => {
+            const result = buildElasticBody(
+                { priceRange: { min: 500, max: 1500 }, groupByProduct: false },
+                searchConfig,
+            );
+            expect(result.query).toEqual({
+                bool: {
+                    filter: [
+                        {
+                            range: {
+                                price: {
+                                    gte: 500,
+                                    lte: 1500,
+                                },
+                            },
+                        },
+                    ],
+                },
+            });
+        });
+
+        it('not grouped by product, with tax', () => {
+            const result = buildElasticBody(
+                { priceRangeWithTax: { min: 500, max: 1500 }, groupByProduct: false },
+                searchConfig,
+            );
+            expect(result.query).toEqual({
+                bool: {
+                    filter: [
+                        {
+                            range: {
+                                priceWithTax: {
+                                    gte: 500,
+                                    lte: 1500,
+                                },
+                            },
+                        },
+                    ],
+                },
+            });
+        });
+
+        it('grouped by product', () => {
+            const result = buildElasticBody(
+                { priceRange: { min: 500, max: 1500 }, groupByProduct: true },
+                searchConfig,
+            );
+            expect(result.query).toEqual({
+                bool: {
+                    filter: [
+                        {
+                            range: {
+                                priceMin: {
+                                    gte: 500,
+                                },
+                            },
+                        },
+                        {
+                            range: {
+                                priceMax: {
+                                    lte: 1500,
+                                },
+                            },
+                        },
+                    ],
+                },
+            });
+        });
+
+        it('grouped by product, with tax', () => {
+            const result = buildElasticBody(
+                { priceRangeWithTax: { min: 500, max: 1500 }, groupByProduct: true },
+                searchConfig,
+            );
+            expect(result.query).toEqual({
+                bool: {
+                    filter: [
+                        {
+                            range: {
+                                priceWithTaxMin: {
+                                    gte: 500,
+                                },
+                            },
+                        },
+                        {
+                            range: {
+                                priceWithTaxMax: {
+                                    lte: 1500,
+                                },
+                            },
+                        },
+                    ],
+                },
+            });
+        });
+
+        it('combined with collectionId and facetValueIds filters', () => {
+            const result = buildElasticBody(
+                {
+                    priceRangeWithTax: { min: 500, max: 1500 },
+                    groupByProduct: true,
+                    collectionId: '3',
+                    facetValueIds: ['5'],
+                },
+                searchConfig,
+            );
+            expect(result.query).toEqual({
+                bool: {
+                    filter: [
+                        { term: { facetValueIds: '5' } },
+                        { term: { collectionIds: '3' } },
+                        {
+                            range: {
+                                priceWithTaxMin: {
+                                    gte: 500,
+                                },
+                            },
+                        },
+                        {
+                            range: {
+                                priceWithTaxMax: {
+                                    lte: 1500,
+                                },
+                            },
+                        },
+                    ],
+                },
+            });
+        });
+    });
 });

+ 57 - 4
packages/elasticsearch-plugin/src/build-elastic-body.ts

@@ -1,18 +1,28 @@
-import { SearchInput, SortOrder } from '@vendure/common/lib/generated-types';
+import { PriceRange, SortOrder } from '@vendure/common/lib/generated-types';
 import { DeepRequired } from '@vendure/core';
 
 import { SearchConfig } from './options';
-import { SearchRequestBody } from './types';
+import { ElasticSearchInput, SearchRequestBody } from './types';
 
 /**
  * Given a SearchInput object, returns the corresponding Elasticsearch body.
  */
 export function buildElasticBody(
-    input: SearchInput,
+    input: ElasticSearchInput,
     searchConfig: DeepRequired<SearchConfig>,
     enabledOnly: boolean = false,
 ): SearchRequestBody {
-    const { term, facetValueIds, collectionId, groupByProduct, skip, take, sort } = input;
+    const {
+        term,
+        facetValueIds,
+        collectionId,
+        groupByProduct,
+        skip,
+        take,
+        sort,
+        priceRangeWithTax,
+        priceRange,
+    } = input;
     const query: any = {
         bool: {},
     };
@@ -46,6 +56,16 @@ export function buildElasticBody(
         ensureBoolFilterExists(query);
         query.bool.filter.push({ term: { enabled: true } });
     }
+    if (priceRange) {
+        ensureBoolFilterExists(query);
+        query.bool.filter = query.bool.filter.concat(createPriceFilters(priceRange, false, !!groupByProduct));
+    }
+    if (priceRangeWithTax) {
+        ensureBoolFilterExists(query);
+        query.bool.filter = query.bool.filter.concat(
+            createPriceFilters(priceRangeWithTax, true, !!groupByProduct),
+        );
+    }
 
     const sortArray = [];
     if (sort) {
@@ -70,3 +90,36 @@ function ensureBoolFilterExists(query: { bool: { filter?: any } }) {
         query.bool.filter = [];
     }
 }
+
+function createPriceFilters(range: PriceRange, withTax: boolean, groupByProduct: boolean): any[] {
+    const withTaxFix = withTax ? 'WithTax' : '';
+    if (groupByProduct) {
+        return [
+            {
+                range: {
+                    [`price${withTaxFix}Min`]: {
+                        gte: range.min,
+                    },
+                },
+            },
+            {
+                range: {
+                    [`price${withTaxFix}Max`]: {
+                        lte: range.max,
+                    },
+                },
+            },
+        ];
+    } else {
+        return [
+            {
+                range: {
+                    ['price' + withTaxFix]: {
+                        gte: range.min,
+                        lte: range.max,
+                    },
+                },
+            },
+        ];
+    }
+}

+ 5 - 5
packages/elasticsearch-plugin/src/elasticsearch-resolver.ts

@@ -10,7 +10,7 @@ import { Omit } from '@vendure/common/lib/omit';
 import { Allow, Ctx, FacetValue, RequestContext, SearchResolver } from '@vendure/core';
 
 import { ElasticsearchService } from './elasticsearch.service';
-import { SearchPriceRange } from './types';
+import { ElasticSearchInput, SearchPriceData } from './types';
 
 @Resolver('SearchResponse')
 export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'> {
@@ -31,16 +31,16 @@ export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'
     @ResolveProperty()
     async facetValues(
         @Ctx() ctx: RequestContext,
-        @Parent() parent: { input: SearchInput },
+        @Parent() parent: { input: ElasticSearchInput },
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
         return this.elasticsearchService.facetValues(ctx, parent.input, true);
     }
 
     @ResolveProperty()
-    async priceRange(
+    async prices(
         @Ctx() ctx: RequestContext,
-        @Parent() parent: { input: SearchInput },
-    ): Promise<SearchPriceRange> {
+        @Parent() parent: { input: ElasticSearchInput },
+    ): Promise<SearchPriceData> {
         return this.elasticsearchService.priceRange(ctx, parent.input);
     }
 }

+ 13 - 8
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -24,10 +24,11 @@ import {
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { ElasticsearchOptions } from './options';
 import {
+    ElasticSearchInput,
     ElasticSearchResponse,
     ProductIndexItem,
     SearchHit,
-    SearchPriceRange,
+    SearchPriceData,
     SearchResponseBody,
     VariantIndexItem,
 } from './types';
@@ -72,7 +73,7 @@ export class ElasticsearchService {
      */
     async search(
         ctx: RequestContext,
-        input: SearchInput,
+        input: ElasticSearchInput,
         enabledOnly: boolean = false,
     ): Promise<Omit<ElasticSearchResponse, 'facetValues' | 'priceRange'>> {
         const { indexPrefix } = this.options;
@@ -106,7 +107,7 @@ export class ElasticsearchService {
      */
     async facetValues(
         ctx: RequestContext,
-        input: SearchInput,
+        input: ElasticSearchInput,
         enabledOnly: boolean = false,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
         const { indexPrefix } = this.options;
@@ -138,7 +139,7 @@ export class ElasticsearchService {
         });
     }
 
-    async priceRange(ctx: RequestContext, input: SearchInput): Promise<SearchPriceRange> {
+    async priceRange(ctx: RequestContext, input: ElasticSearchInput): Promise<SearchPriceData> {
         const { indexPrefix, searchConfig } = this.options;
         const { groupByProduct } = input;
         const elasticSearchBody = buildElasticBody(input, searchConfig, true);
@@ -196,10 +197,14 @@ export class ElasticsearchService {
         });
 
         return {
-            min: aggregations.minPrice.value,
-            minWithTax: aggregations.minPriceWithTax.value,
-            max: aggregations.maxPrice.value,
-            maxWithTax: aggregations.maxPriceWithTax.value,
+            range: {
+                min: aggregations.minPrice.value || 0,
+                max: aggregations.maxPrice.value || 0,
+            },
+            rangeWithTax: {
+                min: aggregations.minPriceWithTax.value || 0,
+                max: aggregations.maxPriceWithTax.value || 0,
+            },
             buckets: aggregations.prices.buckets.map(mapPriceBuckets).filter(x => 0 < x.count),
             bucketsWithTax: aggregations.prices.buckets.map(mapPriceBuckets).filter(x => 0 < x.count),
         };

+ 48 - 21
packages/elasticsearch-plugin/src/plugin.ts

@@ -26,14 +26,12 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
 
 const schemaExtension = gql`
     extend type SearchResponse {
-        priceRange: SearchResponsePriceRange!
+        prices: SearchResponsePriceData!
     }
 
-    type SearchResponsePriceRange {
-        min: Int!
-        minWithTax: Int!
-        max: Int!
-        maxWithTax: Int!
+    type SearchResponsePriceData {
+        range: PriceRange!
+        rangeWithTax: PriceRange!
         buckets: [PriceRangeBucket!]!
         bucketsWithTax: [PriceRangeBucket!]!
     }
@@ -42,6 +40,16 @@ const schemaExtension = gql`
         to: Int!
         count: Int!
     }
+
+    extend input SearchInput {
+        priceRange: PriceRangeInput
+        priceRangeWithTax: PriceRangeInput
+    }
+
+    input PriceRangeInput {
+        min: Int!
+        max: Int!
+    }
 `;
 
 /**
@@ -77,20 +85,18 @@ const schemaExtension = gql`
  * ```
  *
  * ## Search API Extensions
- * This plugin extends the default search API, allowing richer querying of your product data.
+ * This plugin extends the default search query of the Shop API, allowing richer querying of your product data.
  *
  * The [SearchResponse](/docs/graphql-api/admin/object-types/#searchresponse) type is extended with information
  * about price ranges in the result set:
  * ```SDL
  * extend type SearchResponse {
- *     priceRange: SearchResponsePriceRange!
+ *     prices: SearchResponsePriceData!
  * }
  *
- * type SearchResponsePriceRange {
- *     min: Int!
- *     minWithTax: Int!
- *     max: Int!
- *     maxWithTax: Int!
+ * type SearchResponsePriceData {
+ *     range: PriceRange!
+ *     rangeWithTax: PriceRange!
  *     buckets: [PriceRangeBucket!]!
  *     bucketsWithTax: [PriceRangeBucket!]!
  * }
@@ -99,19 +105,38 @@ const schemaExtension = gql`
  *     to: Int!
  *     count: Int!
  * }
+ *
+ * extend input SearchInput {
+ *     priceRange: PriceRangeInput
+ *     priceRangeWithTax: PriceRangeInput
+ * }
+ *
+ * input PriceRangeInput {
+ *     min: Int!
+ *     max: Int!
+ * }
  * ```
  *
- * This `SearchResponsePriceRange` type allows you to query data about the range of prices in the result set.
+ * This `SearchResponsePriceData` type allows you to query data about the range of prices in the result set.
  *
  * ## Example Request & Response
  *
  * ```SDL
  * {
- *   search (input: { term: "table easel", groupByProduct: true }){
+ *   search (input: {
+ *     term: "table easel"
+ *     groupByProduct: true
+ *     priceRange: {
+         min: 500
+         max: 7000
+       }
+ *   }) {
  *     totalItems
- *     priceRange {
- *       min
- *       max
+ *     prices {
+ *       range {
+ *         min
+ *         max
+ *       }
  *       buckets {
  *         to
  *         count
@@ -136,9 +161,11 @@ const schemaExtension = gql`
  *  "data": {
  *    "search": {
  *      "totalItems": 9,
- *      "priceRange": {
- *        "min": 999,
- *        "max": 6396,
+ *      "prices": {
+ *        "range": {
+ *          "min": 999,
+ *          "max": 6396,
+ *        },
  *        "buckets": [
  *          {
  *            "to": 1000,

+ 16 - 7
packages/elasticsearch-plugin/src/types.ts

@@ -1,15 +1,24 @@
-import { CurrencyCode, SearchResponse, SearchResult } from '@vendure/common/lib/generated-types';
+import {
+    CurrencyCode,
+    PriceRange,
+    SearchInput,
+    SearchResponse,
+    SearchResult,
+} from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
+export type ElasticSearchInput = SearchInput & {
+    priceRange?: PriceRange;
+    priceRangeWithTax?: PriceRange;
+};
+
 export type ElasticSearchResponse = SearchResponse & {
-    priceRange: SearchPriceRange;
+    priceRange: SearchPriceData;
 };
 
-export type SearchPriceRange = {
-    min: number;
-    minWithTax: number;
-    max: number;
-    maxWithTax: number;
+export type SearchPriceData = {
+    range: PriceRange;
+    rangeWithTax: PriceRange;
     buckets: PriceRangeBucket[];
     bucketsWithTax: PriceRangeBucket[];
 };