Переглянути джерело

feat(elasticsearch-plugin): Add custom sort parameter mapping (#1230)

Closes #1220 

* feat(elasticsearch-plugin): Added custom sort parameter mapping (#1220)

* fix(elasticsearch-plugin): Removed unwanted change of price and name sort

Co-authored-by: Kevin <kevin@fainin.com>
Drayke 4 роки тому
батько
коміт
0d1f687c48

+ 65 - 0
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -143,6 +143,12 @@ describe('Elasticsearch plugin', () => {
                                 return 'World';
                             },
                         },
+                        priority: {
+                            graphQlType: 'Int!',
+                            valueFn: args => {
+                                return ((args.id as number) % 2) + 1; // only 1 or 2
+                            },
+                        },
                     },
                     searchConfig: {
                         scriptFields: {
@@ -155,10 +161,25 @@ describe('Elasticsearch plugin', () => {
                                 },
                             },
                         },
+                        mapSort: (sort, input) => {
+                            const priority = (input.sort as any)?.priority;
+                            if (priority) {
+                                return [
+                                    ...sort,
+                                    {
+                                        ['product-priority']: {
+                                            order: priority === SortOrder.ASC ? 'asc' : 'desc',
+                                        },
+                                    },
+                                ];
+                            }
+                            return sort;
+                        },
                     },
                     extendSearchInputType: {
                         factor: 'Int',
                     },
+                    extendSearchSortType: ['priority'],
                 }),
                 DefaultJobQueuePlugin,
             ],
@@ -1417,6 +1438,50 @@ describe('Elasticsearch plugin', () => {
             });
         });
     });
+
+    describe('sort', () => {
+        it('sort ASC', async () => {
+            const query = `{
+                search(input: { take: 1, groupByProduct: true, sort: { priority: ASC } }) {
+                    items {
+                        customMappings {
+                            ...on CustomProductMappings {
+                              priority
+                            }
+                        }
+                    }
+                  }
+                }`;
+            const { search } = await shopClient.query(gql(query));
+
+            expect(search.items[0]).toEqual({
+                customMappings: {
+                    priority: 1,
+                },
+            });
+        });
+
+        it('sort DESC', async () => {
+            const query = `{
+                search(input: { take: 1, groupByProduct: true, sort: { priority: DESC } }) {
+                    items {
+                        customMappings {
+                            ...on CustomProductMappings {
+                              priority
+                            }
+                        }
+                    }
+                  }
+                }`;
+            const { search } = await shopClient.query(gql(query));
+
+            expect(search.items[0]).toEqual({
+                customMappings: {
+                    priority: 2,
+                },
+            });
+        });
+    });
 });
 
 export const SEARCH_PRODUCTS = gql`

+ 9 - 0
packages/elasticsearch-plugin/src/api/api-extensions.ts

@@ -6,6 +6,13 @@ import { ElasticsearchOptions } from '../options';
 export function generateSchemaExtensions(options: ElasticsearchOptions): DocumentNode {
     const customMappingTypes = generateCustomMappingTypes(options);
     const inputExtensions = Object.entries(options.extendSearchInputType || {});
+    const sortExtensions = options.extendSearchSortType || [];
+
+    const sortExtensionGql = `
+    extend input SearchResultSortParameter {
+        ${sortExtensions.map(key => `${key}: SortOrder`).join('\n            ')}
+    }`;
+
     return gql`
         extend type SearchResponse {
             prices: SearchResponsePriceData!
@@ -34,6 +41,8 @@ export function generateSchemaExtensions(options: ElasticsearchOptions): Documen
             ${inputExtensions.map(([name, type]) => `${name}: ${type}`).join('\n            ')}
         }
 
+        ${sortExtensions.length > 0 ? sortExtensionGql : ''}
+
         input PriceRangeInput {
             min: Int!
             max: Int!

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

@@ -2,7 +2,7 @@ import { LanguageCode, LogicalOperator, PriceRange, SortOrder } from '@vendure/c
 import { DeepRequired, ID, UserInputError } from '@vendure/core';
 
 import { SearchConfig } from './options';
-import { CustomScriptMapping, ElasticSearchInput, SearchRequestBody } from './types';
+import { CustomScriptMapping, ElasticSearchInput, ElasticSearchSortInput, SearchRequestBody } from './types';
 
 /**
  * Given a SearchInput object, returns the corresponding Elasticsearch body.
@@ -109,7 +109,7 @@ export function buildElasticBody(
         }
     }
 
-    const sortArray = [];
+    const sortArray: ElasticSearchSortInput = [];
     if (sort) {
         if (sort.name) {
             sortArray.push({
@@ -131,7 +131,7 @@ export function buildElasticBody(
         query: searchConfig.mapQuery
             ? searchConfig.mapQuery(query, input, searchConfig, channelId, enabledOnly)
             : query,
-        sort: sortArray,
+        sort: searchConfig.mapSort ? searchConfig.mapSort(sortArray, input) : sortArray,
         from: skip || 0,
         size: take || 10,
         track_total_hits: searchConfig.totalItemsMaxSize,

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

@@ -6,6 +6,8 @@ import {
     CustomMapping,
     CustomScriptMapping,
     ElasticSearchInput,
+    ElasticSearchSortInput,
+    ElasticSearchSortParameter,
     GraphQlPrimitive,
     PrimitiveTypeVariations,
 } from './types';
@@ -328,6 +330,30 @@ export interface ElasticsearchOptions {
     extendSearchInputType?: {
         [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
     };
+
+    /**
+     * @description
+     * Adds a list of sort parameters. This is mostly important to make the
+     * correct sort order values available inside `input` parameter of the `mapSort` option.
+     *
+     * @example
+     * ```TypeScript
+     * extendSearchSortType: ["distance"]
+     * ```
+     *
+     * will extend the `SearchResultSortParameter` input type like this:
+     *
+     * @example
+     * ```GraphQl
+     * extend input SearchResultSortParameter {
+     *      distance: SortOrder
+     * }
+     * ```
+     *
+     * @default []
+     * @since 1.4.0
+     */
+    extendSearchSortType?: string[];
 }
 
 /**
@@ -531,6 +557,82 @@ export interface SearchConfig {
      * @since 1.3.0
      */
     scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> };
+    /**
+     * @description
+     * Allows extending the `sort` input of the elasticsearch body as covered in
+     * [Elasticsearch sort docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
+     *
+     * @example
+     * ```TS
+     * mapSort: (sort, input) => {
+     *     // Assuming `extendSearchSortType: ["priority"]`
+     *     // Assuming priority is never undefined
+     *     const { priority } = input.sort;
+     *     return [
+     *          ...sort,
+     *          {
+     *              // The `product-priority` field corresponds to the `priority` customProductMapping
+     *              // Depending on the index type, this field might require a
+     *              // more detailed input (example: 'productName.keyword')
+     *              ["product-priority"]: {
+     *                  order: priority === SortOrder.ASC ? 'asc' : 'desc'
+     *              }
+     *          }
+     *      ];
+     * }
+     * ```
+     *
+     * A more generic example would be a sort function based on a product location like this:
+     * @example
+     * ```TS
+     * extendSearchInputType: {
+     *   latitude: 'Float',
+     *   longitude: 'Float',
+     * },
+     * extendSearchSortType: ["distance"],
+     * indexMappingProperties: {
+     *   // The `product-location` field corresponds to the `location` customProductMapping
+     *   // defined below. Here we specify that it would be index as a `geo_point` type,
+     *   // which will allow us to perform geo-spacial calculations on it in our script field.
+     *   'product-location': {
+     *     type: 'geo_point',
+     *   },
+     * },
+     * customProductMappings: {
+     *   location: {
+     *     graphQlType: 'String',
+     *     valueFn: (product: Product) => {
+     *       // Assume that the Product entity has this customField defined
+     *       const custom = product.customFields.location;
+     *       return `${custom.latitude},${custom.longitude}`;
+     *     },
+     *   }
+     * },
+     * searchConfig: {
+     *      mapSort: (sort, input) => {
+     *          // Assuming distance is never undefined
+     *          const { distance } = input.sort;
+     *          return [
+     *              ...sort,
+     *              {
+     *                  ["_geo_distance"]: {
+     *                      "product-location": [
+     *                          input.longitude,
+     *                          input.latitude
+     *                      ],
+     *                      order: distance === SortOrder.ASC ? 'asc' : 'desc',
+     *                      unit: "km"
+     *                  }
+     *              }
+     *          ];
+     *      }
+     * }
+     * ```
+     *
+     * @default {}
+     * @since 1.4.0
+     */
+    mapSort?: (sort: ElasticSearchSortInput, input: ElasticSearchInput) => ElasticSearchSortInput;
 }
 
 /**
@@ -600,6 +702,7 @@ export const defaultOptions: ElasticsearchRuntimeOptions = {
         },
         priceRangeBucketInterval: 1000,
         mapQuery: query => query,
+        mapSort: sort => sort,
         scriptFields: {},
     },
     customProductMappings: {},
@@ -608,6 +711,7 @@ export const defaultOptions: ElasticsearchRuntimeOptions = {
     hydrateProductRelations: [],
     hydrateProductVariantRelations: [],
     extendSearchInputType: {},
+    extendSearchSortType: [],
 };
 
 export function mergeWithDefaults(userOptions: ElasticsearchOptions): ElasticsearchRuntimeOptions {

+ 21 - 0
packages/elasticsearch-plugin/src/types.ts

@@ -38,6 +38,27 @@ export type PriceRangeBucket = {
     count: number;
 };
 
+export enum ElasticSearchSortMode {
+    /** Pick the lowest value */
+    MIN = 'min',
+    /** Pick the highest value */
+    MAX = 'max',
+    /** Use the sum of all values as sort value. Only applicable for number based array fields */
+    SUM = 'sum',
+    /** Use the average of all values as sort value. Only applicable for number based array fields */
+    AVG = 'avg',
+    /** Use the median of all values as sort value. Only applicable for number based array fields */
+    MEDIAN = 'median',
+}
+
+export type ElasticSearchSortParameter = {
+    missing?: '_last' | '_first' | string;
+    mode?: ElasticSearchSortMode;
+    order: 'asc' | 'desc';
+} & { [key: string]: any };
+
+export type ElasticSearchSortInput = Array<{ [key: string]: ElasticSearchSortParameter }>;
+
 export type IndexItemAssets = {
     productAssetId: ID | undefined;
     productPreview: string;