From 7b53cf47f02858d02f04fa8bd2ce13b7ed908351 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 17 Nov 2021 23:26:29 +0100 Subject: [PATCH] feat(elasticsearch-plugin): Added custom sort parameter mapping (#1220) --- .../e2e/elasticsearch-plugin.e2e-spec.ts | 65 +++++++++++ .../src/api/api-extensions.ts | 9 ++ .../src/build-elastic-body.ts | 9 +- packages/elasticsearch-plugin/src/options.ts | 104 ++++++++++++++++++ packages/elasticsearch-plugin/src/types.ts | 21 ++++ 5 files changed, 203 insertions(+), 5 deletions(-) diff --git a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts index 6b1f322a39..d7144ffd78 100644 --- a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts +++ b/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` diff --git a/packages/elasticsearch-plugin/src/api/api-extensions.ts b/packages/elasticsearch-plugin/src/api/api-extensions.ts index f8bd3482a6..f39880fd61 100644 --- a/packages/elasticsearch-plugin/src/api/api-extensions.ts +++ b/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! diff --git a/packages/elasticsearch-plugin/src/build-elastic-body.ts b/packages/elasticsearch-plugin/src/build-elastic-body.ts index 5f7424eab4..ba2c774b0d 100644 --- a/packages/elasticsearch-plugin/src/build-elastic-body.ts +++ b/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,14 +109,13 @@ export function buildElasticBody( } } - const sortArray = []; + const sortArray: ElasticSearchSortInput = []; if (sort) { if (sort.name) { sortArray.push({ 'productName.keyword': { order: sort.name === SortOrder.ASC ? 'asc' : 'desc' }, }); - } - if (sort.price) { + } else if (sort.price) { const priceField = 'price'; sortArray.push({ [priceField]: { order: sort.price === SortOrder.ASC ? 'asc' : 'desc' } }); } @@ -131,7 +130,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, diff --git a/packages/elasticsearch-plugin/src/options.ts b/packages/elasticsearch-plugin/src/options.ts index 5727c431fa..f903bc7a5d 100644 --- a/packages/elasticsearch-plugin/src/options.ts +++ b/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; }; + + /** + * @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 { diff --git a/packages/elasticsearch-plugin/src/types.ts b/packages/elasticsearch-plugin/src/types.ts index d1c73cd5c2..85a8c387e4 100644 --- a/packages/elasticsearch-plugin/src/types.ts +++ b/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;