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 837fd51 commit 4865c6e
Show file tree
Hide file tree
Showing 10 changed files with 330 additions and 15 deletions.
42 changes: 38 additions & 4 deletions packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
UpdateCollection,
UpdateProduct,
UpdateProductVariants,
UpdateTaxRate
UpdateTaxRate,
} from '../../core/e2e/graphql/generated-e2e-admin-types';
import { SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
import {
Expand Down 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 @@ -277,8 +288,8 @@ describe('Elasticsearch plugin', () => {
},
);
expect(result.search.collections).toEqual([
{collection: {id: 'T_2', name: 'Plants',},count: 3,},
]);
{ collection: { id: 'T_2', name: 'Plants' }, count: 3 },
]);
});

it('returns correct collections when grouped by product', async () => {
Expand All @@ -291,7 +302,7 @@ describe('Elasticsearch plugin', () => {
},
);
expect(result.search.collections).toEqual([
{collection: {id: 'T_2', name: 'Plants',},count: 3,},
{ collection: { id: 'T_2', name: 'Plants' }, count: 3 },
]);
});

Expand Down Expand Up @@ -1242,6 +1253,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
25 changes: 24 additions & 1 deletion packages/elasticsearch-plugin/src/build-elastic-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('buildElasticBody()', () => {

it('facetValueFilters OR', () => {
const result = buildElasticBody(
{ facetValueFilters: [ { or: ['1', '2'] }] },
{ facetValueFilters: [{ or: ['1', '2'] }] },
searchConfig,
CHANNEL_ID,
LanguageCode.en,
Expand Down Expand Up @@ -386,6 +386,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
40 changes: 39 additions & 1 deletion 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 @@ -113,6 +113,11 @@ export function buildElasticBody(
sortArray.push({ [priceField]: { order: sort.price === SortOrder.ASC ? 'asc' : 'desc' } });
}
}
const scriptFields: any | undefined = createScriptFields(
searchConfig.scriptFields,
input,
groupByProduct,
);
return {
query: searchConfig.mapQuery
? searchConfig.mapQuery(query, input, searchConfig, channelId, enabledOnly)
Expand All @@ -121,6 +126,12 @@ export function buildElasticBody(
from: skip || 0,
size: take || 10,
track_total_hits: searchConfig.totalItemsMaxSize,
...(scriptFields !== undefined
? {
_source: true,
script_fields: scriptFields,
}
: undefined),
};
}

Expand All @@ -130,6 +141,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, groupByProduct: boolean): any[] {
const withTaxFix = withTax ? 'WithTax' : '';
if (groupByProduct) {
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';
}
}
60 changes: 58 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 @@ -195,7 +197,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;
}
} else {
Expand All @@ -209,7 +221,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 @@ -409,6 +431,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 @@ -424,11 +447,13 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
};

this.addCustomMappings(result, source, this.options.customProductVariantMappings);
this.addScriptMappings(result, fields, this.options.searchConfig?.scriptFields, 'variant');
return result;
}

private mapProductToSearchResult(hit: SearchHit<ProductIndexItem>): SearchResult {
const source = hit._source;
const fields = hit.fields;
const { productAsset, productVariantAsset } = this.getSearchResultAssets(source);
const result = {
...source,
Expand All @@ -455,6 +480,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
score: hit._score || 0,
};
this.addCustomMappings(result, source, this.options.customProductMappings);
this.addScriptMappings(result, fields, this.options.searchConfig?.scriptFields, 'product');
return result;
}

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

private 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 @@ -882,7 +882,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
languageCode: LanguageCode,
): ProductIndexItem {
const productTranslation = this.getTranslation(product, ctx.languageCode);
return {
const item: ProductIndexItem = {
channelId: ctx.channelId,
languageCode,
sku: '',
Expand Down Expand Up @@ -910,6 +910,11 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
channelIds: [ctx.channelId],
enabled: false,
};
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 4865c6e

Please sign in to comment.