diff --git a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts index 390542be01..97060b59c2 100644 --- a/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts +++ b/packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts @@ -130,6 +130,17 @@ describe('Elasticsearch plugin', () => { }, }, }, + searchConfig: { + scriptFields: { + answerDouble: { + graphQlType: 'Int!', + environment: 'product', + scriptFn: input => ({ + script: `doc['answer'].value * 2`, + }), + }, + }, + }, }), DefaultJobQueuePlugin, ], @@ -1243,6 +1254,29 @@ describe('Elasticsearch plugin', () => { }); }); }); + + describe('scriptFields', () => { + it('script mapping', async () => { + const query = `{ + search(input: { take: 1, groupByProduct: true, sort: { name: ASC } }) { + items { + productVariantName + customScriptFields { + answerDouble + } + } + } + }`; + const { search } = await shopClient.query(gql(query)); + + expect(search.items[0]).toEqual({ + productVariantName: 'Bonsai Tree', + customScriptFields: { + answerDouble: 84, + }, + }); + }); + }); }); export const SEARCH_PRODUCTS = gql` diff --git a/packages/elasticsearch-plugin/src/build-elastic-body.spec.ts b/packages/elasticsearch-plugin/src/build-elastic-body.spec.ts index 2dab4a9446..a0ca6ebde1 100644 --- a/packages/elasticsearch-plugin/src/build-elastic-body.spec.ts +++ b/packages/elasticsearch-plugin/src/build-elastic-body.spec.ts @@ -389,6 +389,29 @@ describe('buildElasticBody()', () => { }); }); + it('scriptFields option', () => { + const config: DeepRequired = { + ...searchConfig, + ...{ + scriptFields: { + test: { + graphQlType: 'String', + environment: 'both', + scriptFn: input => ({ + script: `doc['property'].dummyScript(${input.term})`, + }), + }, + }, + }, + }; + const result = buildElasticBody({ term: 'test' }, config, CHANNEL_ID, LanguageCode.en); + expect(result.script_fields).toEqual({ + test: { + script: `doc['property'].dummyScript(test)`, + }, + }); + }); + describe('price ranges', () => { it('not grouped by product', () => { const result = buildElasticBody( diff --git a/packages/elasticsearch-plugin/src/build-elastic-body.ts b/packages/elasticsearch-plugin/src/build-elastic-body.ts index 2c0e7a43c4..4a1687311f 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 { ElasticSearchInput, SearchRequestBody } from './types'; +import { CustomScriptMapping, ElasticSearchInput, SearchRequestBody } from './types'; /** * Given a SearchInput object, returns the corresponding Elasticsearch body. @@ -90,7 +90,6 @@ 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)); @@ -112,6 +111,11 @@ export function buildElasticBody( sortArray.push({ [priceField]: { order: sort.price === SortOrder.ASC ? 'asc' : 'desc' } }); } } + const scriptFields: any | undefined = createScriptFields( + searchConfig.scriptFields, + input, + groupByProduct, + ); const body: SearchRequestBody = { query: searchConfig.mapQuery @@ -121,6 +125,12 @@ export function buildElasticBody( from: skip || 0, size: take || 10, track_total_hits: searchConfig.totalItemsMaxSize, + ...(scriptFields !== undefined + ? { + _source: true, + script_fields: scriptFields, + } + : undefined), }; if (groupByProduct) { body.collapse = { field: `productId` }; @@ -134,6 +144,33 @@ function ensureBoolFilterExists(query: { bool: { filter?: any } }) { } } +function createScriptFields( + scriptFields: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> }, + input: ElasticSearchInput, + groupByProduct?: boolean, +): any | undefined { + if (scriptFields) { + const fields = Object.keys(scriptFields); + if (fields.length) { + const result: any = {}; + for (const name of fields) { + const scriptField = scriptFields[name]; + if (scriptField.environment === 'product' && groupByProduct === true) { + (result as any)[name] = scriptField.scriptFn(input); + } + if (scriptField.environment === 'variant' && groupByProduct === false) { + (result as any)[name] = scriptField.scriptFn(input); + } + if (scriptField.environment === 'both' || scriptField.environment === undefined) { + (result as any)[name] = scriptField.scriptFn(input); + } + } + return result; + } + } + return undefined; +} + function createPriceFilters(range: PriceRange, withTax: boolean): any[] { const withTaxFix = withTax ? 'WithTax' : ''; return [ diff --git a/packages/elasticsearch-plugin/src/custom-script-fields.resolver.ts b/packages/elasticsearch-plugin/src/custom-script-fields.resolver.ts new file mode 100644 index 0000000000..d5d5fe335d --- /dev/null +++ b/packages/elasticsearch-plugin/src/custom-script-fields.resolver.ts @@ -0,0 +1,26 @@ +import { Inject } from '@nestjs/common'; +import { ResolveField, Resolver } from '@nestjs/graphql'; +import { DeepRequired } from '@vendure/common/lib/shared-types'; + +import { ELASTIC_SEARCH_OPTIONS } from './constants'; +import { ElasticsearchOptions } from './options'; + +/** + * This resolver is only required if scriptFields are defined for both products and product variants. + * This particular configuration will result in a union type for the + * `SearchResult.customScriptFields` GraphQL field. + */ +@Resolver('CustomScriptFields') +export class CustomScriptFieldsResolver { + constructor(@Inject(ELASTIC_SEARCH_OPTIONS) private options: DeepRequired) {} + + @ResolveField() + __resolveType(value: any): string { + const productScriptFields = Object.entries(this.options.searchConfig?.scriptFields || {}) + .filter(([, scriptField]) => scriptField.environment !== 'variant') + .map(([k]) => k); + return Object.keys(value).every(k => productScriptFields.includes(k)) + ? 'CustomProductScriptFields' + : 'CustomProductVariantScriptFields'; + } +} diff --git a/packages/elasticsearch-plugin/src/elasticsearch.service.ts b/packages/elasticsearch-plugin/src/elasticsearch.service.ts index ad6312914d..c5c50d78a6 100644 --- a/packages/elasticsearch-plugin/src/elasticsearch.service.ts +++ b/packages/elasticsearch-plugin/src/elasticsearch.service.ts @@ -23,6 +23,8 @@ import { createIndices, getClient } from './indexing-utils'; import { ElasticsearchOptions } from './options'; import { CustomMapping, + CustomScriptEnvironment, + CustomScriptMapping, ElasticSearchInput, ElasticSearchResponse, ProductIndexItem, @@ -193,7 +195,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { totalItems, }; } catch (e) { - Logger.error(e.message, loggerCtx, e.stack); + if (e.meta.body.error.type && e.meta.body.error.type === 'search_phase_execution_exception') { + // Log runtime error of the script exception instead of stacktrace + Logger.error( + e.message, + loggerCtx, + JSON.stringify(e.meta.body.error.root_cause || [], null, 2), + ); + Logger.verbose(JSON.stringify(e.meta.body.error.failed_shards || [], null, 2), loggerCtx); + } else { + Logger.error(e.message, loggerCtx, e.stack); + } throw e; } } else { @@ -207,7 +219,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { totalItems: body.hits.total ? body.hits.total.value : 0, }; } catch (e) { - Logger.error(e.message, loggerCtx, e.stack); + if (e.meta.body.error.type && e.meta.body.error.type === 'search_phase_execution_exception') { + // Log runtime error of the script exception instead of stacktrace + Logger.error( + e.message, + loggerCtx, + JSON.stringify(e.meta.body.error.root_cause || [], null, 2), + ); + Logger.verbose(JSON.stringify(e.meta.body.error.failed_shards || [], null, 2), loggerCtx); + } else { + Logger.error(e.message, loggerCtx, e.stack); + } throw e; } } @@ -459,6 +481,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { private mapVariantToSearchResult(hit: SearchHit): SearchResult { const source = hit._source; + const fields = hit.fields; const { productAsset, productVariantAsset } = this.getSearchResultAssets(source); const result = { ...source, @@ -479,11 +502,18 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { this.options.customProductVariantMappings, false, ); + ElasticsearchService.addScriptMappings( + result, + fields, + this.options.searchConfig?.scriptFields, + 'variant', + ); return result; } private mapProductToSearchResult(hit: SearchHit): SearchResult { const source = hit._source; + const fields = hit.fields; const { productAsset, productVariantAsset } = this.getSearchResultAssets(source); const result = { ...source, @@ -511,6 +541,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { score: hit._score || 0, }; ElasticsearchService.addCustomMappings(result, source, this.options.customProductMappings, true); + ElasticsearchService.addScriptMappings( + result, + fields, + this.options.searchConfig?.scriptFields, + 'product', + ); return result; } @@ -553,4 +589,34 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy { } return result; } + + private static addScriptMappings( + result: any, + fields: any, + mappings: { [fieldName: string]: CustomScriptMapping }, + environment: CustomScriptEnvironment, + ): any { + const customMappings = Object.keys(mappings || {}); + if (customMappings.length) { + const customScriptFieldsResult: any = {}; + for (const name of customMappings) { + const env = mappings[name].environment; + if (env === environment || env === 'both') { + const fieldVal = (fields as any)[name] || undefined; + if (Array.isArray(fieldVal)) { + if (fieldVal.length === 1) { + customScriptFieldsResult[name] = fieldVal[0]; + } + if (fieldVal.length > 1) { + customScriptFieldsResult[name] = JSON.stringify(fieldVal); + } + } else { + customScriptFieldsResult[name] = fieldVal; + } + } + } + (result as any).customScriptFields = customScriptFieldsResult; + } + return result; + } } diff --git a/packages/elasticsearch-plugin/src/graphql-schema-extensions.ts b/packages/elasticsearch-plugin/src/graphql-schema-extensions.ts index e295d6595f..2cfb44afb6 100644 --- a/packages/elasticsearch-plugin/src/graphql-schema-extensions.ts +++ b/packages/elasticsearch-plugin/src/graphql-schema-extensions.ts @@ -39,8 +39,53 @@ export function generateSchemaExtensions(options: ElasticsearchOptions): Documen function generateCustomMappingTypes(options: ElasticsearchOptions): DocumentNode | undefined { const productMappings = Object.entries(options.customProductMappings || {}); const variantMappings = Object.entries(options.customProductVariantMappings || {}); + const scriptProductFields = Object.entries(options.searchConfig?.scriptFields || {}).filter( + ([, scriptField]) => scriptField.environment !== 'variant', + ); + const scriptVariantFields = Object.entries(options.searchConfig?.scriptFields || {}).filter( + ([, scriptField]) => scriptField.environment !== 'product', + ); + let sdl = ``; + + if (scriptProductFields.length || scriptVariantFields.length) { + if (scriptProductFields.length) { + sdl += ` + type CustomProductScriptFields { + ${scriptProductFields.map(([name, def]) => `${name}: ${def.graphQlType}`)} + } + `; + } + if (scriptVariantFields.length) { + sdl += ` + type CustomProductVariantScriptFields { + ${scriptVariantFields.map(([name, def]) => `${name}: ${def.graphQlType}`)} + } + `; + } + if (scriptProductFields.length && scriptVariantFields.length) { + sdl += ` + union CustomScriptFields = CustomProductScriptFields | CustomProductVariantScriptFields + + extend type SearchResult { + customScriptFields: CustomScriptFields! + } + `; + } else if (scriptProductFields.length) { + sdl += ` + extend type SearchResult { + customScriptFields: CustomProductScriptFields! + } + `; + } else if (scriptVariantFields.length) { + sdl += ` + extend type SearchResult { + customScriptFields: CustomProductVariantScriptFields! + } + `; + } + } + if (productMappings.length || variantMappings.length) { - let sdl = ``; if (productMappings.length) { sdl += ` type CustomProductMappings { diff --git a/packages/elasticsearch-plugin/src/indexer.controller.ts b/packages/elasticsearch-plugin/src/indexer.controller.ts index 06240c819f..2097c07f25 100644 --- a/packages/elasticsearch-plugin/src/indexer.controller.ts +++ b/packages/elasticsearch-plugin/src/indexer.controller.ts @@ -769,7 +769,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes const productTranslation = this.getTranslation(product, languageCode); const productAsset = product.featuredAsset; - return { + const item: VariantIndexItem = { channelId: ctx.channelId, languageCode, productVariantId: 0, @@ -805,6 +805,11 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes productCollectionSlugs: [], productChannelIds: product.channels.map(c => c.id), }; + const customMappings = Object.entries(this.options.customProductMappings); + for (const [name, def] of customMappings) { + item[name] = def.valueFn(product, [], languageCode); + } + return item; } private getTranslation( diff --git a/packages/elasticsearch-plugin/src/options.ts b/packages/elasticsearch-plugin/src/options.ts index e5462a6d5f..e5b4051466 100644 --- a/packages/elasticsearch-plugin/src/options.ts +++ b/packages/elasticsearch-plugin/src/options.ts @@ -2,7 +2,7 @@ import { ClientOptions } from '@elastic/elasticsearch'; import { DeepRequired, ID, LanguageCode, Product, ProductVariant } from '@vendure/core'; import deepmerge from 'deepmerge'; -import { CustomMapping, ElasticSearchInput } from './types'; +import { CustomMapping, CustomScriptMapping, ElasticSearchInput } from './types'; /** * @description @@ -348,6 +348,42 @@ export interface SearchConfig { channelId: ID, enabledOnly: boolean, ) => any; + /** + * @description + * Sets `script_fields` inside the elasticsearch body which allows returning a script evaluation for each hit + * @since 1.2.4 + * @example + * ```TypeScript + * indexMappingProperties: { + * location: { + * type: 'geo_point', // contains function arcDistance + * }, + * }, + * customProductMappings: { + * location: { + * graphQlType: 'String', + * valueFn: (product: Product) => { + * const custom = product.customFields.location; + * return `${custom.latitude},${location.longitude}`; + * }, + * } + * }, + * scriptFields: { + * distance: { + * graphQlType: 'Number' + * valFn: (input) => { + * // assuming SearchInput was extended with latitude and longitude + * const lat = input.latitude; + * const lon = input.longitude; + * return { + * script: `doc['location'].arcDistance(${lat}, ${lon})`, + * } + * } + * } + * } + * ``` + */ + scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> }; } /** @@ -417,6 +453,7 @@ export const defaultOptions: ElasticsearchRuntimeOptions = { }, priceRangeBucketInterval: 1000, mapQuery: query => query, + scriptFields: {}, }, customProductMappings: {}, customProductVariantMappings: {}, diff --git a/packages/elasticsearch-plugin/src/plugin.ts b/packages/elasticsearch-plugin/src/plugin.ts index b1851f2bd6..6faaf5a48e 100644 --- a/packages/elasticsearch-plugin/src/plugin.ts +++ b/packages/elasticsearch-plugin/src/plugin.ts @@ -23,6 +23,7 @@ import { buffer, debounceTime, delay, filter, map } from 'rxjs/operators'; import { ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants'; import { CustomMappingsResolver } from './custom-mappings.resolver'; +import { CustomScriptFieldsResolver } from './custom-script-fields.resolver'; import { ElasticsearchIndexService } from './elasticsearch-index.service'; import { AdminElasticSearchResolver, @@ -217,9 +218,21 @@ import { ElasticsearchOptions, ElasticsearchRuntimeOptions, mergeWithDefaults } const requiresUnionResolver = 0 < Object.keys(options.customProductMappings || {}).length && 0 < Object.keys(options.customProductVariantMappings || {}).length; - return requiresUnionResolver - ? [ShopElasticSearchResolver, EntityElasticSearchResolver, CustomMappingsResolver] - : [ShopElasticSearchResolver, EntityElasticSearchResolver]; + const requiresUnionScriptResolver = + 0 < + Object.values(options.searchConfig.scriptFields || {}).filter( + field => field.environment !== 'product', + ).length && + 0 < + Object.values(options.searchConfig.scriptFields || {}).filter( + field => field.environment !== 'variant', + ).length; + return [ + ShopElasticSearchResolver, + EntityElasticSearchResolver, + ...(requiresUnionResolver ? [CustomMappingsResolver] : []), + ...(requiresUnionScriptResolver ? [CustomScriptFieldsResolver] : []), + ]; }, // `any` cast is there due to a strange error "Property '[Symbol.iterator]' is missing in type... URLSearchParams" // which looks like possibly a TS/definitions bug. diff --git a/packages/elasticsearch-plugin/src/types.ts b/packages/elasticsearch-plugin/src/types.ts index aa7748f1c3..a7d680e617 100644 --- a/packages/elasticsearch-plugin/src/types.ts +++ b/packages/elasticsearch-plugin/src/types.ts @@ -94,6 +94,7 @@ export type SearchHit = { _score: number; _source: T; _type: string; + fields?: any; }; export type SearchRequestBody = { @@ -104,6 +105,8 @@ export type SearchRequestBody = { track_total_hits?: number | boolean; aggs?: any; collapse?: any; + _source?: boolean; + script_fields?: any; }; export type SearchResponseBody = { @@ -203,7 +206,8 @@ export interface UpdateAssetMessageData { } type Maybe = T | undefined; -type CustomMappingDefinition = { +type CustomMappingTypes = 'String' | 'String!' | 'Int' | 'Int!' | 'Float' | 'Float!' | 'Boolean' | 'Boolean!'; +type CustomMappingDefinition = { graphQlType: T; valueFn: (...args: Args) => R; }; @@ -258,3 +262,37 @@ export type CustomMapping = | CustomFloatMappingNullable | CustomBooleanMapping | CustomBooleanMappingNullable; + +export type CustomScriptEnvironment = 'product' | 'variant' | 'both'; +type CustomScriptMappingDefinition = { + graphQlType: T; + environment: CustomScriptEnvironment; + scriptFn: (...args: Args) => R; +}; + +type CustomScriptStringMapping = CustomScriptMappingDefinition; +type CustomScriptStringMappingNullable = CustomScriptMappingDefinition< + Args, + 'String', + any +>; +type CustomScriptIntMapping = CustomScriptMappingDefinition; +type CustomScriptIntMappingNullable = CustomScriptMappingDefinition; +type CustomScriptFloatMapping = CustomScriptMappingDefinition; +type CustomScriptFloatMappingNullable = CustomScriptMappingDefinition; +type CustomScriptBooleanMapping = CustomScriptMappingDefinition; +type CustomScriptBooleanMappingNullable = CustomScriptMappingDefinition< + Args, + 'Boolean', + any +>; + +export type CustomScriptMapping = + | CustomScriptStringMapping + | CustomScriptStringMappingNullable + | CustomScriptIntMapping + | CustomScriptIntMappingNullable + | CustomScriptFloatMapping + | CustomScriptFloatMappingNullable + | CustomScriptBooleanMapping + | CustomScriptBooleanMappingNullable;