Browse Source

feat(elasticsearch-plugin): Allow the SearchInput to be extended

Michael Bromley 4 years ago
parent
commit
5981619cdb

+ 41 - 16
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -141,15 +141,19 @@ describe('Elasticsearch plugin', () => {
                     },
                     },
                     searchConfig: {
                     searchConfig: {
                         scriptFields: {
                         scriptFields: {
-                            answerDouble: {
+                            answerMultiplied: {
                                 graphQlType: 'Int!',
                                 graphQlType: 'Int!',
                                 context: 'product',
                                 context: 'product',
-                                scriptFn: input => ({
-                                    script: `doc['product-answer'].value * 2`,
-                                }),
+                                scriptFn: input => {
+                                    const factor = input.factor ?? 2;
+                                    return { script: `doc['product-answer'].value * ${factor}` };
+                                },
                             },
                             },
                         },
                         },
                     },
                     },
+                    extendSearchInputType: {
+                        factor: 'Int',
+                    },
                 }),
                 }),
                 DefaultJobQueuePlugin,
                 DefaultJobQueuePlugin,
             ],
             ],
@@ -316,7 +320,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('encodes the productId and productVariantId', async () => {
         it('encodes the productId and productVariantId', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -340,7 +344,7 @@ describe('Elasticsearch plugin', () => {
                 },
                 },
             );
             );
             await awaitRunningJobs(adminClient);
             await awaitRunningJobs(adminClient);
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -353,7 +357,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('encodes collectionIds', async () => {
         it('encodes collectionIds', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -368,7 +372,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('inStock is false and not grouped by product', async () => {
         it('inStock is false and not grouped by product', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -381,7 +385,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('inStock is false and grouped by product', async () => {
         it('inStock is false and grouped by product', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -394,7 +398,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('inStock is true and not grouped by product', async () => {
         it('inStock is true and not grouped by product', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -407,7 +411,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('inStock is true and grouped by product', async () => {
         it('inStock is true and grouped by product', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -420,7 +424,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('inStock is undefined and not grouped by product', async () => {
         it('inStock is undefined and not grouped by product', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -433,7 +437,7 @@ describe('Elasticsearch plugin', () => {
         });
         });
 
 
         it('inStock is undefined and grouped by product', async () => {
         it('inStock is undefined and grouped by product', async () => {
-            const result = await shopClient.query<SearchProductsShop.Query, SearchProductsShopVariables>(
+            const result = await shopClient.query<SearchProductsShop.Query, SearchProductShopVariables>(
                 SEARCH_PRODUCTS_SHOP,
                 SEARCH_PRODUCTS_SHOP,
                 {
                 {
                     input: {
                     input: {
@@ -1177,7 +1181,7 @@ describe('Elasticsearch plugin', () => {
                     adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                     adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                     const { search } = await adminClient.query<
                     const { search } = await adminClient.query<
                         SearchProductsShop.Query,
                         SearchProductsShop.Query,
-                        SearchProductsShopVariables
+                        SearchProductShopVariables
                     >(
                     >(
                         SEARCH_PRODUCTS,
                         SEARCH_PRODUCTS,
                         {
                         {
@@ -1349,7 +1353,28 @@ describe('Elasticsearch plugin', () => {
                     items {
                     items {
                       productVariantName
                       productVariantName
                       customScriptFields {
                       customScriptFields {
-                        answerDouble
+                        answerMultiplied
+                      }
+                    }
+                  }
+                }`;
+            const { search } = await shopClient.query(gql(query));
+
+            expect(search.items[0]).toEqual({
+                productVariantName: 'Bonsai Tree',
+                customScriptFields: {
+                    answerMultiplied: 84,
+                },
+            });
+        });
+
+        it('can use the custom search input field', async () => {
+            const query = `{
+                search(input: { take: 1, groupByProduct: true, sort: { name: ASC }, factor: 10 }) {
+                    items {
+                      productVariantName
+                      customScriptFields {
+                        answerMultiplied
                       }
                       }
                     }
                     }
                   }
                   }
@@ -1359,7 +1384,7 @@ describe('Elasticsearch plugin', () => {
             expect(search.items[0]).toEqual({
             expect(search.items[0]).toEqual({
                 productVariantName: 'Bonsai Tree',
                 productVariantName: 'Bonsai Tree',
                 customScriptFields: {
                 customScriptFields: {
-                    answerDouble: 84,
+                    answerMultiplied: 420,
                 },
                 },
             });
             });
         });
         });

+ 8 - 5
packages/elasticsearch-plugin/src/api/api-extensions.ts

@@ -5,6 +5,7 @@ import { ElasticsearchOptions } from '../options';
 
 
 export function generateSchemaExtensions(options: ElasticsearchOptions): DocumentNode {
 export function generateSchemaExtensions(options: ElasticsearchOptions): DocumentNode {
     const customMappingTypes = generateCustomMappingTypes(options);
     const customMappingTypes = generateCustomMappingTypes(options);
+    const inputExtensions = Object.entries(options.extendSearchInputType || {});
     return gql`
     return gql`
         extend type SearchResponse {
         extend type SearchResponse {
             prices: SearchResponsePriceData!
             prices: SearchResponsePriceData!
@@ -30,6 +31,7 @@ export function generateSchemaExtensions(options: ElasticsearchOptions): Documen
             priceRange: PriceRangeInput
             priceRange: PriceRangeInput
             priceRangeWithTax: PriceRangeInput
             priceRangeWithTax: PriceRangeInput
             inStock: Boolean
             inStock: Boolean
+            ${inputExtensions.map(([name, type]) => `${name}: ${type}`).join('\n            ')}
         }
         }
 
 
         input PriceRangeInput {
         input PriceRangeInput {
@@ -44,6 +46,7 @@ export function generateSchemaExtensions(options: ElasticsearchOptions): Documen
 function generateCustomMappingTypes(options: ElasticsearchOptions): DocumentNode | undefined {
 function generateCustomMappingTypes(options: ElasticsearchOptions): DocumentNode | undefined {
     const productMappings = Object.entries(options.customProductMappings || {});
     const productMappings = Object.entries(options.customProductMappings || {});
     const variantMappings = Object.entries(options.customProductVariantMappings || {});
     const variantMappings = Object.entries(options.customProductVariantMappings || {});
+    const searchInputTypeExtensions = Object.entries(options.extendSearchInputType || {});
     const scriptProductFields = Object.entries(options.searchConfig?.scriptFields || {}).filter(
     const scriptProductFields = Object.entries(options.searchConfig?.scriptFields || {}).filter(
         ([, scriptField]) => scriptField.context !== 'variant',
         ([, scriptField]) => scriptField.context !== 'variant',
     );
     );
@@ -126,10 +129,10 @@ function generateCustomMappingTypes(options: ElasticsearchOptions): DocumentNode
                 }
                 }
             `;
             `;
         }
         }
-
-        return gql`
-            ${sdl}
-        `;
     }
     }
-    return;
+    return sdl.length
+        ? gql`
+              ${sdl}
+          `
+        : undefined;
 }
 }

+ 79 - 28
packages/elasticsearch-plugin/src/options.ts

@@ -2,7 +2,13 @@ import { ClientOptions } from '@elastic/elasticsearch';
 import { DeepRequired, EntityRelationPaths, ID, LanguageCode, Product, ProductVariant } from '@vendure/core';
 import { DeepRequired, EntityRelationPaths, ID, LanguageCode, Product, ProductVariant } from '@vendure/core';
 import deepmerge from 'deepmerge';
 import deepmerge from 'deepmerge';
 
 
-import { CustomMapping, CustomScriptMapping, ElasticSearchInput } from './types';
+import {
+    CustomMapping,
+    CustomScriptMapping,
+    ElasticSearchInput,
+    GraphQlPrimitive,
+    PrimitiveTypeVariations,
+} from './types';
 
 
 /**
 /**
  * @description
  * @description
@@ -275,6 +281,44 @@ export interface ElasticsearchOptions {
      * @since 1.3.0
      * @since 1.3.0
      */
      */
     hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
     hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
+    /**
+     * @description
+     * Allows the `SearchInput` type to be extended with new input fields. This allows arbitrary
+     * data to be passed in, which can then be used e.g. in the `mapQuery()` function or
+     * custom `scriptFields` functions.
+     *
+     * @example
+     * ```TypeScript
+     * extendSearchInputType: {
+     *   longitude: 'Float',
+     *   latitude: 'Float',
+     *   radius: 'Float',
+     * }
+     * ```
+     *
+     * This allows the search query to include these new fields:
+     *
+     * @example
+     * ```GraphQl
+     * query {
+     *   search(input: {
+     *     longitude: 101.7117,
+     *     latitude: 3.1584,
+     *     radius: 50.00
+     *   }) {
+     *     items {
+     *       productName
+     *     }
+     *   }
+     * }
+     * ```
+     *
+     * @default {}
+     * @since 1.3.0
+     */
+    extendSearchInputType?: {
+        [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
+    };
 }
 }
 
 
 /**
 /**
@@ -380,32 +424,32 @@ export interface SearchConfig {
      * @example
      * @example
      * ```TypeScript
      * ```TypeScript
      * mapQuery: (query, input, searchConfig, channelId, enabledOnly){
      * mapQuery: (query, input, searchConfig, channelId, enabledOnly){
-     *     if(query.bool.must){
-     *         delete query.bool.must;
-     *     }
-     *     query.bool.should = [
-     *         {
-     *             query_string: {
-     *                 query: "*" + term + "*",
-     *                 fields: [
-     *                     `productName^${searchConfig.boostFields.productName}`,
-     *                     `productVariantName^${searchConfig.boostFields.productVariantName}`,
-     *                 ]
-     *             }
-     *         },
-     *         {
-     *             multi_match: {
-     *                 query: term,
-     *                 type: searchConfig.multiMatchType,
-     *                 fields: [
-     *                     `description^${searchConfig.boostFields.description}`,
-     *                     `sku^${searchConfig.boostFields.sku}`,
-     *                 ],
-     *             },
-     *         },
-     *     ];
-     *
-     *     return query;
+     *   if(query.bool.must){
+     *     delete query.bool.must;
+     *   }
+     *   query.bool.should = [
+     *     {
+     *       query_string: {
+     *         query: "*" + term + "*",
+     *         fields: [
+     *           `productName^${searchConfig.boostFields.productName}`,
+     *           `productVariantName^${searchConfig.boostFields.productVariantName}`,
+     *         ]
+     *       }
+     *     },
+     *     {
+     *       multi_match: {
+     *         query: term,
+     *         type: searchConfig.multiMatchType,
+     *         fields: [
+     *           `description^${searchConfig.boostFields.description}`,
+     *           `sku^${searchConfig.boostFields.sku}`,
+     *         ],
+     *       },
+     *     },
+     *   ];
+     *
+     *   return query;
      * }
      * }
      * ```
      * ```
      */
      */
@@ -422,6 +466,10 @@ export interface SearchConfig {
      *
      *
      * @example
      * @example
      * ```TypeScript
      * ```TypeScript
+     * extendSearchInputType: {
+     *   latitude: 'Float',
+     *   longitude: 'Float',
+     * },
      * indexMappingProperties: {
      * indexMappingProperties: {
      *   // The `product-location` field corresponds to the `location` customProductMapping
      *   // The `product-location` field corresponds to the `location` customProductMapping
      *   // defined below. Here we specify that it would be index as a `geo_point` type,
      *   // defined below. Here we specify that it would be index as a `geo_point` type,
@@ -434,6 +482,7 @@ export interface SearchConfig {
      *   location: {
      *   location: {
      *     graphQlType: 'String',
      *     graphQlType: 'String',
      *     valueFn: (product: Product) => {
      *     valueFn: (product: Product) => {
+     *       // Assume that the Product entity has this customField defined
      *       const custom = product.customFields.location;
      *       const custom = product.customFields.location;
      *       return `${custom.latitude},${custom.longitude}`;
      *       return `${custom.latitude},${custom.longitude}`;
      *     },
      *     },
@@ -446,7 +495,8 @@ export interface SearchConfig {
      *       // Run this script only when grouping results by product
      *       // Run this script only when grouping results by product
      *       context: 'product',
      *       context: 'product',
      *       scriptFn: (input) => {
      *       scriptFn: (input) => {
-     *         // assuming SearchInput was extended with latitude and longitude
+     *         // The SearchInput was extended with latitude and longitude
+     *         // via the `extendSearchInputType` option above.
      *         const lat = input.latitude;
      *         const lat = input.latitude;
      *         const lon = input.longitude;
      *         const lon = input.longitude;
      *         return {
      *         return {
@@ -537,6 +587,7 @@ export const defaultOptions: ElasticsearchRuntimeOptions = {
     bufferUpdates: false,
     bufferUpdates: false,
     hydrateProductRelations: [],
     hydrateProductRelations: [],
     hydrateProductVariantRelations: [],
     hydrateProductVariantRelations: [],
+    extendSearchInputType: {},
 };
 };
 
 
 export function mergeWithDefaults(userOptions: ElasticsearchOptions): ElasticsearchRuntimeOptions {
 export function mergeWithDefaults(userOptions: ElasticsearchOptions): ElasticsearchRuntimeOptions {

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

@@ -39,7 +39,9 @@ import { ElasticsearchOptions, ElasticsearchRuntimeOptions, mergeWithDefaults }
 /**
 /**
  * @description
  * @description
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
- * engine. This is a drop-in replacement for the DefaultSearchPlugin.
+ * engine. This is a drop-in replacement for the DefaultSearchPlugin which exposes many powerful configuration options enabling your storefront
+ * to support a wide range of use-cases such as indexing of custom properties, fine control over search index configuration, and to leverage
+ * advanced Elasticsearch features like spacial search.
  *
  *
  * ## Installation
  * ## Installation
  *
  *

+ 3 - 2
packages/elasticsearch-plugin/src/types.ts

@@ -18,6 +18,7 @@ export type ElasticSearchInput = SearchInput & {
     priceRange?: PriceRange;
     priceRange?: PriceRange;
     priceRangeWithTax?: PriceRange;
     priceRangeWithTax?: PriceRange;
     inStock?: boolean;
     inStock?: boolean;
+    [extendedInputField: string]: any;
 };
 };
 
 
 export type ElasticSearchResponse = SearchResponse & {
 export type ElasticSearchResponse = SearchResponse & {
@@ -242,8 +243,8 @@ export type UpdateIndexQueueJobData =
     | AssignVariantToChannelJobData
     | AssignVariantToChannelJobData
     | RemoveVariantFromChannelJobData;
     | RemoveVariantFromChannelJobData;
 
 
-type GraphQlPrimitive = 'ID' | 'String' | 'Int' | 'Float' | 'Boolean';
-type PrimitiveTypeVariations<T extends GraphQlPrimitive> = T | `${T}!` | `[${T}!]` | `[${T}!]!`;
+export type GraphQlPrimitive = 'ID' | 'String' | 'Int' | 'Float' | 'Boolean';
+export type PrimitiveTypeVariations<T extends GraphQlPrimitive> = T | `${T}!` | `[${T}!]` | `[${T}!]!`;
 type GraphQlPermittedReturnType = PrimitiveTypeVariations<GraphQlPrimitive>;
 type GraphQlPermittedReturnType = PrimitiveTypeVariations<GraphQlPrimitive>;
 
 
 type CustomMappingDefinition<Args extends any[], T extends GraphQlPermittedReturnType, R> = {
 type CustomMappingDefinition<Args extends any[], T extends GraphQlPermittedReturnType, R> = {