Bladeren bron

feat(elasticsearch-plugin): Extend response with price range data

Michael Bromley 6 jaren geleden
bovenliggende
commit
81eff4679a

+ 0 - 2
packages/elasticsearch-plugin/README.md

@@ -5,5 +5,3 @@ The `ElasticsearchPlugin` uses Elasticsearch to power the the Vendure product se
 `npm install @vendure/elasticsearch-plugin`
 
 For documentation, see [www.vendure.io/docs/plugins/elasticsearch-plugin](https://www.vendure.io/docs/plugins/elasticsearch-plugin)
-
-Status: work in progress

+ 9 - 0
packages/elasticsearch-plugin/src/elasticsearch-resolver.ts

@@ -10,6 +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';
 
 @Resolver('SearchResponse')
 export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'> {
@@ -34,6 +35,14 @@ export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
         return this.elasticsearchService.facetValues(ctx, parent.input, true);
     }
+
+    @ResolveProperty()
+    async priceRange(
+        @Ctx() ctx: RequestContext,
+        @Parent() parent: { input: SearchInput },
+    ): Promise<SearchPriceRange> {
+        return this.elasticsearchService.priceRange(ctx, parent.input);
+    }
 }
 
 @Resolver('SearchResponse')

+ 77 - 2
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -5,6 +5,7 @@ import {
     DeepRequired,
     FacetValue,
     FacetValueService,
+    InternalServerError,
     Logger,
     RequestContext,
     SearchService,
@@ -22,7 +23,14 @@ import {
 } from './constants';
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { ElasticsearchOptions } from './options';
-import { ProductIndexItem, SearchHit, SearchResponseBody, VariantIndexItem } from './types';
+import {
+    ElasticSearchResponse,
+    ProductIndexItem,
+    SearchHit,
+    SearchPriceRange,
+    SearchResponseBody,
+    VariantIndexItem,
+} from './types';
 
 @Injectable()
 export class ElasticsearchService {
@@ -66,7 +74,7 @@ export class ElasticsearchService {
         ctx: RequestContext,
         input: SearchInput,
         enabledOnly: boolean = false,
-    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+    ): Promise<Omit<ElasticSearchResponse, 'facetValues' | 'priceRange'>> {
         const { indexPrefix } = this.options;
         const { groupByProduct } = input;
         const elasticSearchBody = buildElasticBody(input, this.options.searchConfig, enabledOnly);
@@ -130,6 +138,73 @@ export class ElasticsearchService {
         });
     }
 
+    async priceRange(ctx: RequestContext, input: SearchInput): Promise<SearchPriceRange> {
+        const { indexPrefix, searchConfig } = this.options;
+        const { groupByProduct } = input;
+        const elasticSearchBody = buildElasticBody(input, searchConfig, true);
+        elasticSearchBody.from = 0;
+        elasticSearchBody.size = 0;
+        elasticSearchBody.aggs = {
+            minPrice: {
+                min: {
+                    field: groupByProduct ? 'priceMin' : 'price',
+                },
+            },
+            minPriceWithTax: {
+                min: {
+                    field: groupByProduct ? 'priceWithTaxMin' : 'priceWithTax',
+                },
+            },
+            maxPrice: {
+                max: {
+                    field: groupByProduct ? 'priceMax' : 'price',
+                },
+            },
+            maxPriceWithTax: {
+                max: {
+                    field: groupByProduct ? 'priceWithTaxMax' : 'priceWithTax',
+                },
+            },
+            prices: {
+                histogram: {
+                    field: groupByProduct ? 'priceMin' : 'price',
+                    interval: searchConfig.priceRangeBucketInterval,
+                },
+            },
+            pricesWithTax: {
+                histogram: {
+                    field: groupByProduct ? 'priceWithTaxMin' : 'priceWithTax',
+                    interval: searchConfig.priceRangeBucketInterval,
+                },
+            },
+        };
+        const { body }: { body: SearchResponseBody<VariantIndexItem> } = await this.client.search({
+            index: indexPrefix + (input.groupByProduct ? PRODUCT_INDEX_NAME : VARIANT_INDEX_NAME),
+            type: input.groupByProduct ? PRODUCT_INDEX_TYPE : VARIANT_INDEX_TYPE,
+            body: elasticSearchBody,
+        });
+
+        const { aggregations } = body;
+        if (!aggregations) {
+            throw new InternalServerError(
+                'An error occurred when querying Elasticsearch for priceRange aggregations',
+            );
+        }
+        const mapPriceBuckets = (b: { key: string; doc_count: number }) => ({
+            to: Number.parseInt(b.key, 10) + searchConfig.priceRangeBucketInterval,
+            count: b.doc_count,
+        });
+
+        return {
+            min: aggregations.minPrice.value,
+            minWithTax: aggregations.minPriceWithTax.value,
+            max: aggregations.maxPrice.value,
+            maxWithTax: aggregations.maxPriceWithTax.value,
+            buckets: aggregations.prices.buckets.map(mapPriceBuckets).filter(x => 0 < x.count),
+            bucketsWithTax: aggregations.prices.buckets.map(mapPriceBuckets).filter(x => 0 < x.count),
+        };
+    }
+
     /**
      * Rebuilds the full search index.
      */

+ 37 - 0
packages/elasticsearch-plugin/src/options.ts

@@ -76,6 +76,42 @@ export interface SearchConfig {
      * Set custom boost values for particular fields when matching against a search term.
      */
     boostFields?: BoostFieldsConfig;
+    /**
+     * @description
+     * The interval used to group search results into buckets according to price range. For example, setting this to
+     * `2000` will group into buckets every $20.00:
+     *
+     * ```JSON
+     * {
+     *   "data": {
+     *     "search": {
+     *       "totalItems": 32,
+     *       "priceRange": {
+     *         "buckets": [
+     *           {
+     *             "to": 2000,
+     *             "count": 21
+     *           },
+     *           {
+     *             "to": 4000,
+     *             "count": 7
+     *           },
+     *           {
+     *             "to": 6000,
+     *             "count": 3
+     *           },
+     *           {
+     *             "to": 12000,
+     *             "count": 1
+     *           }
+     *         ]
+     *       }
+     *     }
+     *   }
+     * }
+     * ```
+     */
+    priceRangeBucketInterval: number;
 }
 
 /**
@@ -133,6 +169,7 @@ export const defaultOptions: DeepRequired<ElasticsearchOptions> = {
             description: 1,
             sku: 1,
         },
+        priceRangeBucketInterval: 1000,
     },
 };
 

+ 132 - 1
packages/elasticsearch-plugin/src/plugin.ts

@@ -15,6 +15,7 @@ import {
     Type,
     VendurePlugin,
 } from '@vendure/core';
+import { gql } from 'apollo-server-core';
 
 import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
@@ -23,6 +24,26 @@ import { ElasticsearchService } from './elasticsearch.service';
 import { ElasticsearchIndexerController } from './indexer.controller';
 import { ElasticsearchOptions, mergeWithDefaults } from './options';
 
+const schemaExtension = gql`
+    extend type SearchResponse {
+        priceRange: SearchResponsePriceRange!
+    }
+
+    type SearchResponsePriceRange {
+        min: Int!
+        minWithTax: Int!
+        max: Int!
+        maxWithTax: Int!
+        buckets: [PriceRangeBucket!]!
+        bucketsWithTax: [PriceRangeBucket!]!
+    }
+
+    type PriceRangeBucket {
+        to: Int!
+        count: Int!
+    }
+`;
+
 /**
  * @description
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
@@ -36,6 +57,10 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
  *
  * `npm install \@vendure/elasticsearch-plugin`
  *
+ * Make sure to remove the `DefaultSearchPlugin` if it is still in the VendureConfig plugins array.
+ *
+ * Then add the `ElasticsearchPlugin`, calling the `.init()` method with {@link ElasticsearchOptions}:
+ *
  * @example
  * ```ts
  * import { ElasticsearchPlugin } from '\@vendure/elasticsearch-plugin';
@@ -51,6 +76,112 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
  * };
  * ```
  *
+ * ## Search API Extensions
+ * This plugin extends the default search 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!
+ * }
+ *
+ * type SearchResponsePriceRange {
+ *     min: Int!
+ *     minWithTax: Int!
+ *     max: Int!
+ *     maxWithTax: Int!
+ *     buckets: [PriceRangeBucket!]!
+ *     bucketsWithTax: [PriceRangeBucket!]!
+ * }
+ *
+ * type PriceRangeBucket {
+ *     to: Int!
+ *     count: Int!
+ * }
+ * ```
+ *
+ * This `SearchResponsePriceRange` 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 }){
+ *     totalItems
+ *     priceRange {
+ *       min
+ *       max
+ *       buckets {
+ *         to
+ *         count
+ *       }
+ *     }
+ *     items {
+ *       productName
+ *       score
+ *       price {
+ *         ...on PriceRange {
+ *           min
+ *           max
+ *         }
+ *       }
+ *     }
+ *   }
+ * }
+ * ```
+ *
+ * ```JSON
+ *{
+ *  "data": {
+ *    "search": {
+ *      "totalItems": 9,
+ *      "priceRange": {
+ *        "min": 999,
+ *        "max": 6396,
+ *        "buckets": [
+ *          {
+ *            "to": 1000,
+ *            "count": 1
+ *          },
+ *          {
+ *            "to": 2000,
+ *            "count": 2
+ *          },
+ *          {
+ *            "to": 3000,
+ *            "count": 3
+ *          },
+ *          {
+ *            "to": 4000,
+ *            "count": 1
+ *          },
+ *          {
+ *            "to": 5000,
+ *            "count": 1
+ *          },
+ *          {
+ *            "to": 7000,
+ *            "count": 1
+ *          }
+ *        ]
+ *      },
+ *      "items": [
+ *        {
+ *          "productName": "Loxley Yorkshire Table Easel",
+ *          "score": 30.58831,
+ *          "price": {
+ *            "min": 4984,
+ *            "max": 4984
+ *          }
+ *        },
+ *        // ... truncated
+ *      ]
+ *    }
+ *  }
+ *}
+ * ```
+ *
  * @docsCategory ElasticsearchPlugin
  */
 @VendurePlugin({
@@ -62,7 +193,7 @@ import { ElasticsearchOptions, mergeWithDefaults } from './options';
         { provide: ELASTIC_SEARCH_CLIENT, useFactory: () => ElasticsearchPlugin.client },
     ],
     adminApiExtensions: { resolvers: [AdminElasticSearchResolver] },
-    shopApiExtensions: { resolvers: [ShopElasticSearchResolver] },
+    shopApiExtensions: { resolvers: [ShopElasticSearchResolver], schema: schemaExtension },
     workers: [ElasticsearchIndexerController],
 })
 export class ElasticsearchPlugin implements OnVendureBootstrap, OnVendureClose {

+ 43 - 24
packages/elasticsearch-plugin/src/types.ts

@@ -1,25 +1,43 @@
-import { CurrencyCode, SearchResult } from '@vendure/common/lib/generated-types';
+import { CurrencyCode, SearchResponse, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
+export type ElasticSearchResponse = SearchResponse & {
+    priceRange: SearchPriceRange;
+};
+
+export type SearchPriceRange = {
+    min: number;
+    minWithTax: number;
+    max: number;
+    maxWithTax: number;
+    buckets: PriceRangeBucket[];
+    bucketsWithTax: PriceRangeBucket[];
+};
+
+export type PriceRangeBucket = {
+    to: number;
+    count: number;
+};
+
 export type VariantIndexItem = Omit<SearchResult, 'score' | 'price' | 'priceWithTax'> & {
     price: number;
     priceWithTax: number;
 };
 export type ProductIndexItem = {
-    sku: string[],
-    slug: string[],
-    productId: ID,
-    productName: string[],
-    productPreview: string,
-    productVariantId: ID[],
-    productVariantName: string[],
-    productVariantPreview: string[],
-    currencyCode: CurrencyCode,
-    description: string,
-    facetIds: ID[],
-    facetValueIds: ID[],
-    collectionIds: ID[],
-    enabled: boolean,
+    sku: string[];
+    slug: string[];
+    productId: ID;
+    productName: string[];
+    productPreview: string;
+    productVariantId: ID[];
+    productVariantName: string[];
+    productVariantPreview: string[];
+    currencyCode: CurrencyCode;
+    description: string;
+    facetIds: ID[];
+    facetValueIds: ID[];
+    collectionIds: ID[];
+    enabled: boolean;
     priceMin: number;
     priceMax: number;
     priceWithTaxMin: number;
@@ -61,16 +79,17 @@ export type SearchResponseBody<T = any> = {
     };
     aggregations?: {
         [key: string]: {
-            doc_count_error_upper_bound: 0,
-            sum_other_doc_count: 89,
-            buckets: Array<{ key: string; doc_count: number; }>;
-        },
-    }
+            doc_count_error_upper_bound: 0;
+            sum_other_doc_count: 89;
+            buckets: Array<{ key: string; doc_count: number }>;
+            value: any;
+        };
+    };
 };
 
 export type BulkOperationType = 'index' | 'update' | 'delete';
-export type BulkOperation = { [operation in BulkOperationType]?: { _id: string; }; };
-export type BulkOperationDoc<T> = T | { doc: T; };
+export type BulkOperation = { [operation in BulkOperationType]?: { _id: string } };
+export type BulkOperationDoc<T> = T | { doc: T };
 export type BulkResponseResult = {
     [operation in BulkOperationType]?: {
         _index: string;
@@ -87,10 +106,10 @@ export type BulkResponseResult = {
         _seq_no?: number;
         _primary_term?: number;
         error?: any;
-    };
+    }
 };
 export type BulkResponseBody = {
     took: number;
     errors: boolean;
-    items: BulkResponseResult[]
+    items: BulkResponseResult[];
 };