Quellcode durchsuchen

feat(elasticsearch-plugin): Allow definition of custom mappings

This feature allows you to define custom data to store in the Elasticsearch index, and also dynamically extends the GraphQL API to expose these mappings in the SearchResult type.
Michael Bromley vor 6 Jahren
Ursprung
Commit
2c8b7dfef6

+ 1 - 0
packages/elasticsearch-plugin/index.ts

@@ -1,2 +1,3 @@
 export * from './src/plugin';
 export * from './src/options';
+export * from './src/types';

+ 25 - 0
packages/elasticsearch-plugin/src/custom-mappings.resolver.ts

@@ -0,0 +1,25 @@
+import { Inject } from '@nestjs/common';
+import { ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { DeepRequired } from '../../common/lib/shared-types';
+
+import { ELASTIC_SEARCH_OPTIONS } from './constants';
+import { ElasticsearchOptions } from './options';
+
+/**
+ * This resolver is only required if both customProductMappings and customProductVariantMappings are
+ * defined, since this particular configuration will result in a union type for the
+ * `SearchResult.customMappings` GraphQL field.
+ */
+@Resolver('CustomMappings')
+export class CustomMappingsResolver {
+    constructor(@Inject(ELASTIC_SEARCH_OPTIONS) private options: DeepRequired<ElasticsearchOptions>) {}
+
+    @ResolveProperty()
+    __resolveType(value: any): string {
+        const productPropertyNames = Object.keys(this.options.customProductMappings);
+        return Object.keys(value).every(k => productPropertyNames.includes(k))
+            ? 'CustomProductMappings'
+            : 'CustomProductVariantMappings';
+    }
+}

+ 26 - 4
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -24,6 +24,7 @@ import {
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { ElasticsearchOptions } from './options';
 import {
+    CustomMapping,
     ElasticSearchInput,
     ElasticSearchResponse,
     ProductIndexItem,
@@ -86,7 +87,7 @@ export class ElasticsearchService {
                 body: elasticSearchBody,
             });
             return {
-                items: body.hits.hits.map(this.mapProductToSearchResult),
+                items: body.hits.hits.map(hit => this.mapProductToSearchResult(hit)),
                 totalItems: body.hits.total.value,
             };
         } else {
@@ -96,7 +97,7 @@ export class ElasticsearchService {
                 body: elasticSearchBody,
             });
             return {
-                items: body.hits.hits.map(this.mapVariantToSearchResult),
+                items: body.hits.hits.map(hit => this.mapVariantToSearchResult(hit)),
                 totalItems: body.hits.total.value,
             };
         }
@@ -258,7 +259,7 @@ export class ElasticsearchService {
 
     private mapVariantToSearchResult(hit: SearchHit<VariantIndexItem>): SearchResult {
         const source = hit._source;
-        return {
+        const result = {
             ...source,
             price: {
                 value: source.price,
@@ -268,11 +269,14 @@ export class ElasticsearchService {
             },
             score: hit._score,
         };
+
+        this.addCustomMappings(result, source, this.options.customProductVariantMappings);
+        return result;
     }
 
     private mapProductToSearchResult(hit: SearchHit<ProductIndexItem>): SearchResult {
         const source = hit._source;
-        return {
+        const result = {
             ...source,
             productId: source.productId.toString(),
             productName: source.productName[0],
@@ -294,5 +298,23 @@ export class ElasticsearchService {
             },
             score: hit._score,
         };
+        this.addCustomMappings(result, source, this.options.customProductMappings);
+        return result;
+    }
+
+    private addCustomMappings(
+        result: any,
+        source: any,
+        mappings: { [fieldName: string]: CustomMapping<any> },
+    ): any {
+        const customMappings = Object.keys(mappings);
+        if (customMappings.length) {
+            const customMappingsResult: any = {};
+            for (const name of customMappings) {
+                customMappingsResult[name] = (source as any)[name];
+            }
+            (result as any).customMappings = customMappingsResult;
+        }
+        return result;
     }
 }

+ 83 - 0
packages/elasticsearch-plugin/src/graphql-schema-extensions.ts

@@ -0,0 +1,83 @@
+import { gql } from 'apollo-server-core';
+import { DocumentNode } from 'graphql';
+
+import { ElasticsearchOptions } from './options';
+
+export function generateSchemaExtensions(options: ElasticsearchOptions): DocumentNode {
+    return gql`
+        extend type SearchResponse {
+            prices: SearchResponsePriceData!
+        }
+
+        type SearchResponsePriceData {
+            range: PriceRange!
+            rangeWithTax: PriceRange!
+            buckets: [PriceRangeBucket!]!
+            bucketsWithTax: [PriceRangeBucket!]!
+        }
+
+        type PriceRangeBucket {
+            to: Int!
+            count: Int!
+        }
+
+        extend input SearchInput {
+            priceRange: PriceRangeInput
+            priceRangeWithTax: PriceRangeInput
+        }
+
+        input PriceRangeInput {
+            min: Int!
+            max: Int!
+        }
+
+        ${generateCustomMappingTypes(options)}
+    `;
+}
+
+function generateCustomMappingTypes(options: ElasticsearchOptions): DocumentNode | undefined {
+    const productMappings = Object.entries(options.customProductMappings || {});
+    const variantMappings = Object.entries(options.customProductVariantMappings || {});
+    if (productMappings.length || variantMappings.length) {
+        let sdl = ``;
+        if (productMappings.length) {
+            sdl += `
+            type CustomProductMappings {
+                ${productMappings.map(([name, def]) => `${name}: ${def.graphQlType}`)}
+            }
+            `;
+        }
+        if (variantMappings.length) {
+            sdl += `
+            type CustomProductVariantMappings {
+                ${variantMappings.map(([name, def]) => `${name}: ${def.graphQlType}`)}
+            }
+            `;
+        }
+        if (productMappings.length && variantMappings.length) {
+            sdl += `
+                union CustomMappings = CustomProductMappings | CustomProductVariantMappings
+
+                extend type SearchResult {
+                    customMappings: CustomMappings!
+                }
+            `;
+        } else if (productMappings.length) {
+            sdl += `
+                extend type SearchResult {
+                    customMappings: CustomProductMappings!
+                }
+            `;
+        } else if (variantMappings.length) {
+            sdl += `
+                extend type SearchResult {
+                    customMappings: CustomProductVariantMappings!
+                }
+            `;
+        }
+
+        return gql`
+            ${sdl}
+        `;
+    }
+}

+ 13 - 2
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -387,7 +387,7 @@ export class ElasticsearchIndexerController {
     }
 
     private createVariantIndexItem(v: ProductVariant): VariantIndexItem {
-        return {
+        const item: VariantIndexItem = {
             sku: v.sku,
             slug: v.product.slug,
             productId: v.product.id as string,
@@ -405,13 +405,18 @@ export class ElasticsearchIndexerController {
             collectionIds: v.collections.map(c => c.id.toString()),
             enabled: v.enabled && v.product.enabled,
         };
+        const customMappings = Object.entries(this.options.customProductVariantMappings);
+        for (const [name, def] of customMappings) {
+            item[name] = def.valueFn(v);
+        }
+        return item;
     }
 
     private createProductIndexItem(variants: ProductVariant[]): ProductIndexItem {
         const first = variants[0];
         const prices = variants.map(v => v.price);
         const pricesWithTax = variants.map(v => v.priceWithTax);
-        return {
+        const item: ProductIndexItem = {
             sku: variants.map(v => v.sku),
             slug: variants.map(v => v.product.slug),
             productId: first.product.id,
@@ -431,6 +436,12 @@ export class ElasticsearchIndexerController {
             collectionIds: variants.reduce((ids, v) => [...ids, ...v.collections.map(c => c.id)], [] as ID[]),
             enabled: first.product.enabled,
         };
+
+        const customMappings = Object.entries(this.options.customProductMappings);
+        for (const [name, def] of customMappings) {
+            item[name] = def.valueFn(variants[0].product, variants);
+        }
+        return item;
     }
 
     private getFacetIds(variants: ProductVariant[]): string[] {

+ 79 - 1
packages/elasticsearch-plugin/src/options.ts

@@ -1,6 +1,8 @@
-import { DeepRequired } from '@vendure/core';
+import { DeepRequired, Product, ProductVariant } from '@vendure/core';
 import deepmerge from 'deepmerge';
 
+import { CustomMapping } from './types';
+
 /**
  * @description
  * Configuration options for the {@link ElasticsearchPlugin}.
@@ -40,6 +42,80 @@ export interface ElasticsearchOptions {
      * Configuration of the internal Elasticseach query.
      */
     searchConfig?: SearchConfig;
+    /**
+     * @description
+     * Custom mappings may be defined which will add the defined data to the
+     * Elasticsearch index and expose that data via the SearchResult GraphQL type,
+     * adding a new `customMappings` field.
+     *
+     * The `graphQlType` property may be one of `String`, `Int`, `Float`, `Boolean` and
+     * can be appended with a `!` to indicate non-nullable fields.
+     *
+     * This config option defines custom mappings which are accessible when the "groupByProduct"
+     * input options is set to `true`.
+     *
+     * @example
+     * ```TypeScript
+     * customProductMappings: {
+     *    variantCount: {
+     *        graphQlType: 'Int!',
+     *        valueFn: (product, variants) => variants.length,
+     *    },
+     *    reviewRating: {
+     *        graphQlType: 'Float',
+     *        valueFn: product => (product.customFields as any).reviewRating,
+     *    },
+     * }
+     * ```
+     *
+     * @example
+     * ```SDL
+     * query SearchProducts($input: SearchInput!) {
+     *     search(input: $input) {
+     *         totalItems
+     *         items {
+     *             productId
+     *             productName
+     *             customMappings {
+     *                 ...on CustomProductMappings {
+     *                     variantCount
+     *                     reviewRating
+     *                 }
+     *             }
+     *         }
+     *     }
+     * }
+     * ```
+     */
+    customProductMappings?: {
+        [fieldName: string]: CustomMapping<[Product, ProductVariant[]]>;
+    };
+    /**
+     * @description
+     * This config option defines custom mappings which are accessible when the "groupByProduct"
+     * input options is set to `false`.
+     *
+     * @example
+     * ```SDL
+     * query SearchProducts($input: SearchInput!) {
+     *     search(input: $input) {
+     *         totalItems
+     *         items {
+     *             productId
+     *             productName
+     *             customMappings {
+     *                 ...on CustomProductVariantMappings {
+     *                     weight
+     *                 }
+     *             }
+     *         }
+     *     }
+     * }
+     * ```
+     */
+    customProductVariantMappings?: {
+        [fieldName: string]: CustomMapping<[ProductVariant]>;
+    };
 }
 
 /**
@@ -171,6 +247,8 @@ export const defaultOptions: DeepRequired<ElasticsearchOptions> = {
         },
         priceRangeBucketInterval: 1000,
     },
+    customProductMappings: {},
+    customProductVariantMappings: {},
 };
 
 export function mergeWithDefaults(userOptions: ElasticsearchOptions): DeepRequired<ElasticsearchOptions> {

+ 14 - 30
packages/elasticsearch-plugin/src/plugin.ts

@@ -16,44 +16,17 @@ import {
     Type,
     VendurePlugin,
 } from '@vendure/core';
-import { gql } from 'apollo-server-core';
 import { buffer, debounceTime, filter, map } from 'rxjs/operators';
 
 import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
+import { CustomMappingsResolver } from './custom-mappings.resolver';
 import { ElasticsearchIndexService } from './elasticsearch-index.service';
 import { AdminElasticSearchResolver, ShopElasticSearchResolver } from './elasticsearch-resolver';
 import { ElasticsearchService } from './elasticsearch.service';
+import { generateSchemaExtensions } from './graphql-schema-extensions';
 import { ElasticsearchIndexerController } from './indexer.controller';
 import { ElasticsearchOptions, mergeWithDefaults } from './options';
 
-const schemaExtension = gql`
-    extend type SearchResponse {
-        prices: SearchResponsePriceData!
-    }
-
-    type SearchResponsePriceData {
-        range: PriceRange!
-        rangeWithTax: PriceRange!
-        buckets: [PriceRangeBucket!]!
-        bucketsWithTax: [PriceRangeBucket!]!
-    }
-
-    type PriceRangeBucket {
-        to: Int!
-        count: Int!
-    }
-
-    extend input SearchInput {
-        priceRange: PriceRangeInput
-        priceRangeWithTax: PriceRangeInput
-    }
-
-    input PriceRangeInput {
-        min: Int!
-        max: Int!
-    }
-`;
-
 /**
  * @description
  * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
@@ -222,7 +195,18 @@ const schemaExtension = gql`
         { provide: ELASTIC_SEARCH_CLIENT, useFactory: () => ElasticsearchPlugin.client },
     ],
     adminApiExtensions: { resolvers: [AdminElasticSearchResolver] },
-    shopApiExtensions: { resolvers: [ShopElasticSearchResolver], schema: schemaExtension },
+    shopApiExtensions: {
+        resolvers: () => {
+            const { options } = ElasticsearchPlugin;
+            const requiresUnionResolver =
+                0 < Object.keys(options.customProductMappings || {}).length &&
+                0 < Object.keys(options.customProductVariantMappings || {}).length;
+            return requiresUnionResolver
+                ? [ShopElasticSearchResolver, CustomMappingsResolver]
+                : [ShopElasticSearchResolver];
+        },
+        schema: () => generateSchemaExtensions(ElasticsearchPlugin.options),
+    },
     workers: [ElasticsearchIndexerController],
 })
 export class ElasticsearchPlugin implements OnVendureBootstrap, OnVendureClose {

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

@@ -32,6 +32,7 @@ export type PriceRangeBucket = {
 export type VariantIndexItem = Omit<SearchResult, 'score' | 'price' | 'priceWithTax'> & {
     price: number;
     priceWithTax: number;
+    [customMapping: string]: any;
 };
 export type ProductIndexItem = {
     sku: string[];
@@ -52,6 +53,7 @@ export type ProductIndexItem = {
     priceMax: number;
     priceWithTaxMin: number;
     priceWithTaxMax: number;
+    [customMapping: string]: any;
 };
 
 export type SearchHit<T> = {
@@ -153,3 +155,32 @@ export class UpdateVariantsByIdMessage extends WorkerMessage<
 > {
     static readonly pattern = 'UpdateVariantsById';
 }
+
+type Maybe<T> = T | null | undefined;
+type CustomMappingDefinition<Args extends any[], T extends string, R> = {
+    graphQlType: T;
+    valueFn: (...args: Args) => R;
+};
+
+type CustomStringMapping<Args extends any[]> = CustomMappingDefinition<Args, 'String!', string>;
+type CustomStringMappingNullable<Args extends any[]> = CustomMappingDefinition<Args, 'String', Maybe<string>>;
+type CustomIntMapping<Args extends any[]> = CustomMappingDefinition<Args, 'Int!', number>;
+type CustomIntMappingNullable<Args extends any[]> = CustomMappingDefinition<Args, 'Int', Maybe<number>>;
+type CustomFloatMapping<Args extends any[]> = CustomMappingDefinition<Args, 'Float!', number>;
+type CustomFloatMappingNullable<Args extends any[]> = CustomMappingDefinition<Args, 'Float', Maybe<number>>;
+type CustomBooleanMapping<Args extends any[]> = CustomMappingDefinition<Args, 'Boolean!', boolean>;
+type CustomBooleanMappingNullable<Args extends any[]> = CustomMappingDefinition<
+    Args,
+    'Boolean',
+    Maybe<boolean>
+>;
+
+export type CustomMapping<Args extends any[]> =
+    | CustomStringMapping<Args>
+    | CustomStringMappingNullable<Args>
+    | CustomIntMapping<Args>
+    | CustomIntMappingNullable<Args>
+    | CustomFloatMapping<Args>
+    | CustomFloatMappingNullable<Args>
+    | CustomBooleanMapping<Args>
+    | CustomBooleanMappingNullable<Args>;