Skip to content

Commit

Permalink
feat(elasticsearch): Extend search config with fields for script eval…
Browse files Browse the repository at this point in the history
…uation of every hit (vendure-ecommerce#1143)
  • Loading branch information
Draykee committed Oct 11, 2021
1 parent 24e1a60 commit a620755
Show file tree
Hide file tree
Showing 10 changed files with 335 additions and 11 deletions.
34 changes: 34 additions & 0 deletions packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ describe('Elasticsearch plugin', () => {
},
},
},
searchConfig: {
scriptFields: {
answerDouble: {
graphQlType: 'Int!',
environment: 'product',
scriptFn: input => ({
script: `doc['answer'].value * 2`,
}),
},
},
},
}),
DefaultJobQueuePlugin,
],
Expand Down Expand Up @@ -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`
Expand Down
23 changes: 23 additions & 0 deletions packages/elasticsearch-plugin/src/build-elastic-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,29 @@ describe('buildElasticBody()', () => {
});
});

it('scriptFields option', () => {
const config: DeepRequired<SearchConfig> = {
...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(
Expand Down
41 changes: 39 additions & 2 deletions packages/elasticsearch-plugin/src/build-elastic-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand All @@ -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
Expand All @@ -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` };
Expand All @@ -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 [
Expand Down
26 changes: 26 additions & 0 deletions packages/elasticsearch-plugin/src/custom-script-fields.resolver.ts
Original file line number Diff line number Diff line change
@@ -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<ElasticsearchOptions>) {}

@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';
}
}
70 changes: 68 additions & 2 deletions packages/elasticsearch-plugin/src/elasticsearch.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { createIndices, getClient } from './indexing-utils';
import { ElasticsearchOptions } from './options';
import {
CustomMapping,
CustomScriptEnvironment,
CustomScriptMapping,
ElasticSearchInput,
ElasticSearchResponse,
ProductIndexItem,
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -459,6 +481,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {

private mapVariantToSearchResult(hit: SearchHit<VariantIndexItem>): SearchResult {
const source = hit._source;
const fields = hit.fields;
const { productAsset, productVariantAsset } = this.getSearchResultAssets(source);
const result = {
...source,
Expand All @@ -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<VariantIndexItem>): SearchResult {
const source = hit._source;
const fields = hit.fields;
const { productAsset, productVariantAsset } = this.getSearchResultAssets(source);
const result = {
...source,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -553,4 +589,34 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
}
return result;
}

private static addScriptMappings(
result: any,
fields: any,
mappings: { [fieldName: string]: CustomScriptMapping<any> },
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;
}
}
47 changes: 46 additions & 1 deletion packages/elasticsearch-plugin/src/graphql-schema-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion packages/elasticsearch-plugin/src/indexer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T extends Translatable>(
Expand Down
Loading

0 comments on commit a620755

Please sign in to comment.