Bläddra i källkod

feat(elasticsearch-plugin): Create initial impl of Elasticsearch plugin

Michael Bromley 6 år sedan
förälder
incheckning
50209671aa

+ 0 - 1
.gitignore

@@ -7,7 +7,6 @@
 ../.idea/workspace.xml
 .idea/**/tasks.xml
 .idea/dictionaries
-docker-compose.yml
 .env
 lerna-debug.log
 dist

+ 2 - 1
package.json

@@ -21,10 +21,11 @@
     "version": "yarn build && yarn generate-changelog && git add CHANGELOG.md",
     "dev-server:start": "cd packages/dev-server && yarn start",
     "dev-server:populate": "cd packages/dev-server && yarn populate",
-    "test:all": "cd admin-ui && yarn test --watch=false --browsers=ChromeHeadlessCI --progress=false && cd ../ && yarn test:common && yarn test:core && yarn test:email-plugin && yarn test:e2e",
+    "test:all": "cd admin-ui && yarn test --watch=false --browsers=ChromeHeadlessCI --progress=false && cd ../ && yarn test:common && yarn test:core && yarn test:email-plugin && yarn test:elasticsearch-plugin && yarn test:e2e",
     "test:common": "jest --config packages/common/jest.config.js",
     "test:core": "jest --config packages/core/jest.config.js",
     "test:email-plugin": "jest --config packages/email-plugin/jest.config.js",
+    "test:elasticsearch-plugin": "jest --config packages/elasticsearch-plugin/jest.config.js",
     "test:e2e": "jest --config packages/core/e2e/config/jest-e2e.json --runInBand",
     "test:admin-ui": "cd admin-ui && yarn test --watch=false --browsers=ChromeHeadlessCI --progress=false",
     "build": "lerna run build",

+ 2 - 0
packages/core/src/api/index.ts

@@ -2,3 +2,5 @@ export * from './common/request-context';
 export * from './decorators/allow.decorator';
 export * from './decorators/decode.decorator';
 export * from './decorators/request-context.decorator';
+export * from './resolvers/admin/search.resolver';
+

+ 5 - 0
packages/core/src/service/index.ts

@@ -1,3 +1,5 @@
+export * from './helpers/job-manager/job';
+export * from './helpers/utils/translate-entity';
 export * from './services/administrator.service';
 export * from './services/asset.service';
 export * from './services/auth.service';
@@ -8,6 +10,8 @@ export * from './services/customer-group.service';
 export * from './services/facet.service';
 export * from './services/facet-value.service';
 export * from './services/global-settings.service';
+export * from './services/job.service';
+export * from './services/job.service';
 export * from './services/order.service';
 export * from './services/payment-method.service';
 export * from './services/product.service';
@@ -17,6 +21,7 @@ export * from './services/product-option-group.service';
 export * from './services/product-variant.service';
 export * from './services/promotion.service';
 export * from './services/role.service';
+export * from './services/search.service';
 export * from './services/shipping-method.service';
 export * from './services/tax-category.service';
 export * from './services/tax-rate.service';

+ 3 - 0
packages/elasticsearch-plugin/.gitignore

@@ -0,0 +1,3 @@
+preview/output
+yarn-error.log
+lib

+ 9 - 0
packages/elasticsearch-plugin/README.md

@@ -0,0 +1,9 @@
+# Vendure Elasticsearch Plugin
+
+The `ElasticsearchPlugin` uses Elasticsearch to power the the Vendure product search.
+
+`npm install @vendure/elasticsearch-plugin`
+
+~~For documentation, see [www.vendure.io/docs/plugins/elasticsearch-plugin](www.vendure.io/docs/plugins/elasticsearch-plugin)~~
+
+Status: work in progress

+ 24 - 0
packages/elasticsearch-plugin/docker-compose.yml

@@ -0,0 +1,24 @@
+version: "3"
+services:
+  elasticsearch:
+    image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1
+    container_name: elasticsearch
+    environment:
+      - discovery.type=single-node
+      - bootstrap.memory_lock=true
+      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+    volumes:
+      - esdata:/usr/share/elasticsearch/data
+    ports:
+      - 9200:9200
+    networks:
+      - esnet
+volumes:
+  esdata:
+    driver: local
+networks:
+  esnet:

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

@@ -0,0 +1 @@
+export * from './src/plugin';

+ 25 - 0
packages/elasticsearch-plugin/jest.config.js

@@ -0,0 +1,25 @@
+module.exports = {
+    coverageDirectory: 'coverage',
+    moduleFileExtensions: [
+        'js',
+        'json',
+        'ts',
+    ],
+    preset: 'ts-jest',
+    rootDir: __dirname,
+    roots: [
+        '<rootDir>/src',
+    ],
+    transform: {
+        '^.+\\.(t|j)s$': 'ts-jest',
+    },
+    globals: {
+        'ts-jest': {
+            tsConfig: {
+                allowJs: true,
+                skipLibCheck: true,
+            },
+        },
+    },
+    testEnvironment: 'node',
+};

+ 26 - 0
packages/elasticsearch-plugin/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "@vendure/elasticsearch-plugin",
+  "version": "0.1.2-beta.4",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "files": [
+    "lib/**/*"
+  ],
+  "scripts": {
+    "watch": "tsc -p ./tsconfig.build.json --watch",
+    "build": "rimraf lib && tsc -p ./tsconfig.build.json"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "dependencies": {
+    "@elastic/elasticsearch": "^7.1.0"
+  },
+  "devDependencies": {
+    "@vendure/common": "^0.1.2-beta.4",
+    "@vendure/core": "^0.1.2-beta.4",
+    "rimraf": "^2.6.3",
+    "typescript": "^3.4.1"
+  }
+}

+ 148 - 0
packages/elasticsearch-plugin/src/build-elastic-body.spec.ts

@@ -0,0 +1,148 @@
+import { SortOrder } from '@vendure/common/lib/generated-types';
+
+import { buildElasticBody } from './build-elastic-body';
+
+describe('buildElasticBody()', () => {
+
+    it('search term', () => {
+        const result = buildElasticBody({ term: 'test' });
+        expect(result.query).toEqual({
+            bool: {
+                must: [
+                    {
+                        multi_match: {
+                            query: 'test',
+                            type: 'best_fields',
+                            fields: [
+                                'productName',
+                                'productVariantName',
+                                'description',
+                                'sku',
+                            ],
+                        },
+                    },
+                ],
+            },
+        });
+    });
+
+    it('facetValueIds', () => {
+        const result = buildElasticBody({ facetValueIds: ['1', '2'] });
+        expect(result.query).toEqual({
+            bool: {
+                filter:  [
+                    { term: { facetValueIds: '1' } },
+                    { term: { facetValueIds: '2' } },
+                ],
+            },
+        });
+    });
+
+    it('collectionId', () => {
+        const result = buildElasticBody({ collectionId: '1' });
+        expect(result.query).toEqual({
+            bool: {
+                filter:  [
+                    { term: { collectionIds: '1' } },
+                ],
+            },
+        });
+    });
+
+    it('paging', () => {
+        const result = buildElasticBody({ skip: 20, take: 10 });
+        expect(result).toEqual({
+            from: 20,
+            size: 10,
+            query: { bool: {} },
+            sort: [],
+        });
+    });
+
+    describe('sorting', () => {
+        it('name', () => {
+            const result = buildElasticBody({ sort: { name: SortOrder.DESC } });
+            expect(result.sort).toEqual([
+                { productName: { order: 'desc' } },
+            ]);
+        });
+
+        it('price', () => {
+            const result = buildElasticBody({ sort: { price: SortOrder.ASC } });
+            expect(result.sort).toEqual([
+                { price: { order: 'asc' } },
+            ]);
+        });
+
+        it('grouped price', () => {
+            const result = buildElasticBody({ sort: { price: SortOrder.ASC }, groupByProduct: true });
+            expect(result.sort).toEqual([
+                { priceMin: { order: 'asc' } },
+            ]);
+        });
+    });
+
+    it('enabledOnly true', () => {
+        const result = buildElasticBody({}, true);
+        expect(result.query).toEqual({
+            bool: {
+                filter: [
+                    { term: { enabled: true } },
+                ],
+            },
+        });
+    });
+
+    it('enabledOnly false', () => {
+        const result = buildElasticBody({}, false);
+        expect(result.query).toEqual({
+            bool: {},
+        });
+    });
+
+    it('combined inputs', () => {
+        const result = buildElasticBody({
+            term: 'test',
+            take: 25,
+            skip: 0,
+            sort: {
+                name: SortOrder.DESC,
+            },
+            groupByProduct: true,
+            collectionId: '42',
+            facetValueIds: ['6', '7'],
+        }, true);
+
+        expect(result).toEqual({
+            from: 0,
+            size: 25,
+            query: {
+                bool: {
+                    must: [
+                        {
+                            multi_match: {
+                                query: 'test',
+                                type: 'best_fields',
+                                fields: [
+                                    'productName',
+                                    'productVariantName',
+                                    'description',
+                                    'sku',
+                                ],
+                            },
+                        },
+                    ],
+                    filter: [
+                        { term: { facetValueIds: '6' } },
+                        { term: { facetValueIds: '7' } },
+                        { term: { collectionIds: '42' } },
+                        { term: { enabled: true } },
+                    ],
+                },
+            },
+            sort: [
+                { productName: { order: 'desc' } },
+            ],
+        });
+    });
+});

+ 70 - 0
packages/elasticsearch-plugin/src/build-elastic-body.ts

@@ -0,0 +1,70 @@
+import { SearchInput, SortOrder } from '@vendure/common/lib/generated-types';
+
+import { SearchRequestBody } from './types';
+
+/**
+ * Given a SearchInput object, returns the corresponding Elasticsearch body.
+ */
+export function buildElasticBody(input: SearchInput, enabledOnly: boolean = false): SearchRequestBody {
+    const { term, facetValueIds, collectionId, groupByProduct, skip, take, sort } = input;
+    const query: any = {
+        bool: {},
+    };
+    if (term) {
+        query.bool.must = [
+            {
+                multi_match: {
+                    query: term,
+                    type: 'best_fields',
+                    fields: [
+                        'productName',
+                        'productVariantName',
+                        'description',
+                        'sku',
+                    ],
+                },
+            },
+        ];
+    }
+    if (facetValueIds && facetValueIds.length) {
+        ensureBoolFilterExists(query);
+        query.bool.filter = query.bool.filter.concat(
+            facetValueIds.map(id => ({ term: { facetValueIds: id }})),
+        );
+    }
+    if (collectionId) {
+        ensureBoolFilterExists(query);
+        query.bool.filter.push(
+            { term: { collectionIds: collectionId } },
+        );
+    }
+    if (enabledOnly) {
+        ensureBoolFilterExists(query);
+        query.bool.filter.push(
+            { term: { enabled: true } },
+        );
+    }
+
+    const sortArray = [];
+    if (sort) {
+        if (sort.name) {
+            sortArray.push({ productName: { order: (sort.name === SortOrder.ASC) ? 'asc' : 'desc' } });
+        }
+        if (sort.price) {
+            const priceField = groupByProduct ? 'priceMin' : 'price';
+            sortArray.push({ [priceField]: { order: (sort.price === SortOrder.ASC) ? 'asc' : 'desc' } });
+        }
+    }
+    return {
+        query,
+        sort: sortArray,
+        from: skip || 0,
+        size: take || 10,
+    };
+}
+
+function ensureBoolFilterExists(query: { bool: { filter?: any; } }) {
+    if (!query.bool.filter) {
+        query.bool.filter = [];
+    }
+}

+ 8 - 0
packages/elasticsearch-plugin/src/constants.ts

@@ -0,0 +1,8 @@
+export const ELASTIC_SEARCH_OPTIONS = Symbol('ELASTIC_SEARCH_OPTIONS');
+export const ELASTIC_SEARCH_CLIENT = Symbol('ELASTIC_SEARCH_CLIENT');
+export const BATCH_SIZE = 500;
+export const VARIANT_INDEX_NAME = 'variants';
+export const VARIANT_INDEX_TYPE = 'variant-index-item';
+export const PRODUCT_INDEX_NAME = 'products';
+export const PRODUCT_INDEX_TYPE = 'product-index-item';
+export const loggerCtx = 'ElasticsearchPlugin';

+ 324 - 0
packages/elasticsearch-plugin/src/elasticsearch-index.service.ts

@@ -0,0 +1,324 @@
+import { Client } from '@elastic/elasticsearch';
+import { Inject, Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { unique } from '@vendure/common/lib/unique';
+import {
+    FacetValue,
+    ID,
+    Job,
+    JobService,
+    Logger,
+    Product,
+    ProductVariant,
+    ProductVariantService,
+    RequestContext,
+    translateDeep,
+} from '@vendure/core';
+import { Connection, SelectQueryBuilder } from 'typeorm';
+import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
+
+import {
+    BATCH_SIZE,
+    ELASTIC_SEARCH_CLIENT,
+    ELASTIC_SEARCH_OPTIONS,
+    loggerCtx,
+    PRODUCT_INDEX_NAME,
+    PRODUCT_INDEX_TYPE,
+    VARIANT_INDEX_NAME,
+    VARIANT_INDEX_TYPE,
+} from './constants';
+import { ElasticsearchOptions } from './plugin';
+import { BulkOperation, BulkOperationDoc, BulkResponseBody, ProductIndexItem, VariantIndexItem } from './types';
+
+export const variantRelations = [
+    'product',
+    'product.featuredAsset',
+    'product.facetValues',
+    'product.facetValues.facet',
+    'featuredAsset',
+    'facetValues',
+    'facetValues.facet',
+    'collections',
+    'taxCategory',
+];
+
+@Injectable()
+export class ElasticsearchIndexService {
+
+    constructor(@InjectConnection() private connection: Connection,
+                @Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
+                @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
+                private productVariantService: ProductVariantService,
+                private jobService: JobService) {}
+
+    /**
+     * Updates the search index only for the affected entities.
+     */
+    async updateProductOrVariant(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
+        let updatedProductVariants: ProductVariant[] = [];
+        let removedProducts: Product[] = [];
+        let updatedVariants: ProductVariant[] = [];
+        let removedVariantIds: ID[] = [];
+        if (updatedEntity instanceof Product) {
+            const product = await this.connection.getRepository(Product).findOne(updatedEntity.id, {
+                relations: ['variants'],
+            });
+            if (product) {
+                if (product.deletedAt) {
+                    removedProducts = [product];
+                    removedVariantIds = product.variants.map(v => v.id);
+                } else {
+                    const variants = await this.connection
+                        .getRepository(ProductVariant)
+                        .findByIds(product.variants.map(v => v.id), {
+                            relations: variantRelations,
+                        });
+                    updatedVariants = this.hydrateVariants(ctx, variants);
+                    updatedProductVariants = updatedVariants;
+                }
+            }
+        } else {
+            const variant = await this.connection.getRepository(ProductVariant).findOne(updatedEntity.id, {
+                relations: variantRelations,
+            });
+            if (variant) {
+                updatedVariants = [variant];
+            }
+        }
+
+        if (updatedProductVariants.length) {
+            const updatedProductIndexItem = this.createProductIndexItem(updatedProductVariants);
+            const operations: [BulkOperation, BulkOperationDoc<ProductIndexItem>] = [
+                { update: { _id: updatedProductIndexItem.productId.toString() } },
+                { doc: updatedProductIndexItem },
+            ];
+            await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, operations);
+        }
+        if (updatedVariants.length) {
+            const operations = updatedVariants.reduce((ops, variant) => {
+                return [
+                    ...ops,
+                    { update: { _id: variant.id.toString() } },
+                    { doc: this.createVariantIndexItem(variant) },
+                ];
+            }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
+            await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
+        }
+        if (removedVariantIds.length) {
+            const operations = removedVariantIds.reduce((ops, id) => {
+                return [
+                    ...ops,
+                    { delete: { _id: id.toString() } },
+                ];
+            }, [] as BulkOperation[]);
+            await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
+        }
+    }
+
+    async updateVariantsById(ctx: RequestContext, ids: ID[]) {
+        if (ids.length) {
+            const batches = Math.ceil(ids.length / BATCH_SIZE);
+            Logger.verbose(`Updating ${ids.length} variants...`);
+
+            for (let i = 0; i < batches; i++) {
+                const begin = i * BATCH_SIZE;
+                const end = begin + BATCH_SIZE;
+                Logger.verbose(`Updating ids from index ${begin} to ${end}`);
+                const batchIds = ids.slice(begin, end);
+                const batch = await this.getBatchByIds(ctx, batchIds);
+                const variants = this.hydrateVariants(ctx, batch);
+                const operations = this.hydrateVariants(ctx, variants).reduce((ops, variant) => {
+                    return [
+                        ...ops,
+                        { update: { _id: variant.id.toString() } },
+                        { doc: this.createVariantIndexItem(variant) },
+                    ];
+                }, [] as Array<BulkOperation | BulkOperationDoc<VariantIndexItem>>);
+                await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, operations);
+            }
+        }
+    }
+
+    reindex(ctx: RequestContext): Job {
+        const job = this.jobService.createJob({
+            name: 'reindex',
+            singleInstance: true,
+            work: async reporter => {
+                const timeStart = Date.now();
+                const qb = this.getSearchIndexQueryBuilder();
+                const count = await qb.where('variants__product.deletedAt IS NULL').getCount();
+                Logger.verbose(`Reindexing ${count} variants`, loggerCtx);
+
+                const batches = Math.ceil(count / BATCH_SIZE);
+                let variantsInProduct: ProductVariant[] = [];
+
+                for (let i = 0; i < batches; i++) {
+                    Logger.verbose(`Processing batch ${i + 1} of ${batches}`, loggerCtx);
+
+                    const variants = await this.getBatch(ctx, qb, i);
+                    Logger.verbose(`variants count: ${variants.length}`);
+
+                    const variantsToIndex: Array<BulkOperation | VariantIndexItem> = [];
+                    const productsToIndex: Array<BulkOperation | ProductIndexItem> = [];
+
+                    // tslint:disable-next-line:prefer-for-of
+                    for (let j = 0; j < variants.length; j++) {
+                        const variant = variants[j];
+                        variantsInProduct.push(variant);
+                        variantsToIndex.push({ index: { _id: variant.id.toString() } });
+                        variantsToIndex.push(this.createVariantIndexItem(variant));
+
+                        const nextVariant = variants[j + 1];
+                        if (nextVariant && nextVariant.productId !== variant.productId) {
+                            productsToIndex.push({ index: { _id: variant.productId.toString() } });
+                            productsToIndex.push(this.createProductIndexItem(variantsInProduct) as any);
+                            variantsInProduct = [];
+                        }
+                    }
+                    await this.executeBulkOperations(VARIANT_INDEX_NAME, VARIANT_INDEX_TYPE, variantsToIndex);
+                    await this.executeBulkOperations(PRODUCT_INDEX_NAME, PRODUCT_INDEX_TYPE, productsToIndex);
+                    reporter.setProgress(Math.ceil(((i + 1) / batches) * 100));
+                }
+                return {
+                    success: true,
+                    indexedItemCount: count,
+                    timeTaken: Date.now() - timeStart,
+                };
+            },
+        });
+        return job;
+    }
+
+    private async executeBulkOperations(indexName: string,
+                                        indexType: string,
+                                        operations: Array<BulkOperation | BulkOperationDoc<VariantIndexItem | ProductIndexItem>>) {
+        try {
+            const {body}: { body: BulkResponseBody; } = await this.client.bulk({
+                refresh: 'true',
+                index: this.options.indexPrefix + indexName,
+                type: indexType,
+                body: operations,
+            });
+
+            if (body.errors) {
+                Logger.error(`Some errors occurred running bulk operations on ${indexType}! Set logger to "debug" to print all errors.`, loggerCtx);
+                body.items.forEach(item => {
+                    if (item.index) {
+                        Logger.debug(JSON.stringify(item.index.error, null, 2), loggerCtx);
+                    }
+                    if (item.update) {
+                        Logger.debug(JSON.stringify(item.update.error, null, 2), loggerCtx);
+                    }
+                    if (item.delete) {
+                        Logger.debug(JSON.stringify(item.delete.error, null, 2), loggerCtx);
+                    }
+                });
+            } else {
+                Logger.verbose(`Executed ${body.items.length} bulk operations on ${indexType}`);
+            }
+            return body;
+        } catch (e) {
+            Logger.error(`Error when attempting to run bulk operations [${e.toString()}]`, loggerCtx);
+            Logger.error('Error details: ' + JSON.stringify(e.body.error, null, 2), loggerCtx);
+        }
+    }
+
+    private getSearchIndexQueryBuilder() {
+        const qb = this.connection.getRepository(ProductVariant).createQueryBuilder('variants');
+        FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
+            relations: variantRelations,
+            order: {
+                productId: 'ASC',
+            },
+        });
+        FindOptionsUtils.joinEagerRelations(qb, qb.alias, this.connection.getMetadata(ProductVariant));
+        return qb;
+    }
+
+    private async getBatch(ctx: RequestContext, qb: SelectQueryBuilder<ProductVariant>, batchNumber: string | number): Promise<ProductVariant[]> {
+        const i = Number.parseInt(batchNumber.toString(), 10);
+        const variants = await qb
+            .where('variants__product.deletedAt IS NULL')
+            .take(BATCH_SIZE)
+            .skip(i * BATCH_SIZE)
+            .getMany();
+
+        return this.hydrateVariants(ctx, variants);
+    }
+
+    private async getBatchByIds(ctx: RequestContext, ids: ID[]) {
+        const variants = await this.connection.getRepository(ProductVariant).findByIds(ids, {
+            relations: variantRelations,
+        });
+        return this.hydrateVariants(ctx, variants);
+    }
+    /**
+     * Given an array of ProductVariants, this method applies the correct taxes and translations.
+     */
+    private hydrateVariants(ctx: RequestContext, variants: ProductVariant[]): ProductVariant[] {
+        return variants
+            .map(v => this.productVariantService.applyChannelPriceAndTax(v, ctx))
+            .map(v => translateDeep(v, ctx.languageCode, ['product']));
+    }
+
+    private createVariantIndexItem(v: ProductVariant): VariantIndexItem {
+        return {
+            sku: v.sku,
+            slug: v.product.slug,
+            productId: v.product.id as string,
+            productName: v.product.name,
+            productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+            productVariantId: v.id as string,
+            productVariantName: v.name,
+            productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+            price: v.price,
+            priceWithTax: v.priceWithTax,
+            currencyCode: v.currencyCode,
+            description: v.product.description,
+            facetIds: this.getFacetIds([v]),
+            facetValueIds: this.getFacetValueIds([v]),
+            collectionIds: v.collections.map(c => c.id.toString()),
+            enabled: v.enabled && v.product.enabled,
+        };
+    }
+
+    private createProductIndexItem(variants: ProductVariant[]): ProductIndexItem {
+        const first = variants[0];
+        const prices = variants.map(v => v.price);
+        const pricesWithTax = variants.map(v => v.priceWithTax);
+        return {
+            sku: variants.map(v => v.sku),
+            slug: variants.map(v => v.product.slug),
+            productId: first.product.id,
+            productName: variants.map(v => v.product.name),
+            productPreview: first.product.featuredAsset ? first.product.featuredAsset.preview : '',
+            productVariantId: variants.map(v => v.id),
+            productVariantName: variants.map(v => v.name),
+            productVariantPreview: variants.filter(v => v.featuredAsset).map(v => v.featuredAsset.preview),
+            priceMin: Math.min(...prices),
+            priceMax: Math.max(...prices),
+            priceWithTaxMin: Math.min(...pricesWithTax),
+            priceWithTaxMax: Math.max(...pricesWithTax),
+            currencyCode: first.currencyCode,
+            description: first.product.description,
+            facetIds: this.getFacetIds(variants),
+            facetValueIds: this.getFacetValueIds(variants),
+            collectionIds: variants.reduce((ids, v) => [ ...ids, ...v.collections.map(c => c.id)], [] as ID[]),
+            enabled: first.product.enabled,
+        };
+    }
+
+    private getFacetIds(variants: ProductVariant[]): string[] {
+        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
+        const variantFacetIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetIds)], [] as string[]);
+        const productFacetIds = variants[0].product.facetValues.map(facetIds);
+        return unique([...variantFacetIds, ...productFacetIds]);
+    }
+
+    private getFacetValueIds(variants: ProductVariant[]): string[] {
+        const facetValueIds = (fv: FacetValue) => fv.id.toString();
+        const variantFacetValueIds = variants.reduce((ids, v) => [ ...ids, ...v.facetValues.map(facetValueIds)], [] as string[]);
+        const productFacetValueIds = variants[0].product.facetValues.map(facetValueIds);
+        return unique([...variantFacetValueIds, ...productFacetValueIds]);
+    }
+}

+ 67 - 0
packages/elasticsearch-plugin/src/elasticsearch-resolver.ts

@@ -0,0 +1,67 @@
+import { Args, Mutation, Parent, Query, ResolveProperty, Resolver } from '@nestjs/graphql';
+import { JobInfo, Permission, QuerySearchArgs, SearchInput, SearchResponse } from '@vendure/common/lib/generated-types';
+import { Omit } from '@vendure/common/lib/omit';
+import { Allow, Ctx, Decode, FacetValue, RequestContext, SearchResolver } from '@vendure/core';
+
+import { ElasticsearchService } from './elasticsearch.service';
+
+@Resolver('SearchResponse')
+export class ShopElasticSearchResolver implements Omit<SearchResolver, 'reindex'> {
+
+    constructor(private elasticsearchService: ElasticsearchService) {}
+
+    @Query()
+    @Allow(Permission.Public)
+    @Decode('facetValueIds', 'collectionId')
+    async search(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QuerySearchArgs,
+    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+        const result = await this.elasticsearchService.search(ctx, args.input, true);
+        // ensure the facetValues property resolver has access to the input args
+        (result as any).input = args.input;
+        return result;
+    }
+
+    @ResolveProperty()
+    async facetValues(
+        @Ctx() ctx: RequestContext,
+        @Parent() parent: { input: SearchInput },
+    ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
+        return this.elasticsearchService.facetValues(ctx, parent.input, true);
+    }
+}
+
+@Resolver('SearchResponse')
+export class AdminElasticSearchResolver implements SearchResolver {
+
+    constructor(private elasticsearchService: ElasticsearchService) {}
+
+    @Query()
+    @Allow(Permission.ReadCatalog)
+    @Decode('facetValueIds', 'collectionId')
+    async search(
+        @Ctx() ctx: RequestContext,
+        @Args() args: QuerySearchArgs,
+    ): Promise<Omit<SearchResponse, 'facetValues'>> {
+        const result = await this.elasticsearchService.search(ctx, args.input, false);
+        // ensure the facetValues property resolver has access to the input args
+        (result as any).input = args.input;
+        return result;
+    }
+
+    @ResolveProperty()
+    async facetValues(
+        @Ctx() ctx: RequestContext,
+        @Parent() parent: { input: SearchInput },
+    ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
+        return this.elasticsearchService.facetValues(ctx, parent.input, false);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateCatalog)
+    async reindex(@Ctx() ctx: RequestContext): Promise<JobInfo> {
+        return this.elasticsearchService.reindex(ctx);
+
+    }
+}

+ 203 - 0
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -0,0 +1,203 @@
+import { Client } from '@elastic/elasticsearch';
+import { Inject, Injectable } from '@nestjs/common';
+import { JobInfo, SearchInput, SearchResponse, SearchResult } from '@vendure/common/lib/generated-types';
+import { FacetValue, FacetValueService, Logger, RequestContext, SearchService } from '@vendure/core';
+
+import { buildElasticBody } from './build-elastic-body';
+import {
+    ELASTIC_SEARCH_CLIENT,
+    ELASTIC_SEARCH_OPTIONS,
+    loggerCtx,
+    PRODUCT_INDEX_NAME,
+    PRODUCT_INDEX_TYPE,
+    VARIANT_INDEX_NAME,
+    VARIANT_INDEX_TYPE,
+} from './constants';
+import { ElasticsearchIndexService } from './elasticsearch-index.service';
+import { ElasticsearchOptions } from './plugin';
+import { ProductIndexItem, SearchHit, SearchResponseBody, VariantIndexItem } from './types';
+
+@Injectable()
+export class ElasticsearchService implements SearchService {
+
+    constructor(@Inject(ELASTIC_SEARCH_OPTIONS) private options: Required<ElasticsearchOptions>,
+                @Inject(ELASTIC_SEARCH_CLIENT) private client: Client,
+                private elasticsearchIndexService: ElasticsearchIndexService,
+                private facetValueService: FacetValueService) {}
+
+    checkConnection() {
+        return this.client.ping({}, { requestTimeout: 1000 });
+    }
+
+    async createIndicesIfNotExists() {
+        const { indexPrefix } = this.options;
+
+        const createIndex = async (indexName: string) => {
+            const index = indexPrefix + indexName;
+            const result = await this.client.indices.exists({ index });
+
+            if (result.body === false) {
+                Logger.verbose(`Index "${index}" does not exist. Creating...`, loggerCtx);
+                await this.createIndices(indexPrefix);
+            } else {
+                Logger.verbose(`Index "${index}" exists`, loggerCtx);
+            }
+        };
+
+        await createIndex(VARIANT_INDEX_NAME);
+        await createIndex(PRODUCT_INDEX_NAME);
+    }
+
+    /**
+     * Perform a fulltext search according to the provided input arguments.
+     */
+    async search(ctx: RequestContext, input: SearchInput, enabledOnly: boolean = false): Promise<Omit<SearchResponse, 'facetValues'>> {
+        const { indexPrefix } = this.options;
+        const { groupByProduct } = input;
+        const elasticSearchBody = buildElasticBody(input, enabledOnly);
+        if (groupByProduct) {
+            const { body }: { body: SearchResponseBody<ProductIndexItem>; } = await this.client.search({
+                index: indexPrefix + PRODUCT_INDEX_NAME,
+                type: PRODUCT_INDEX_TYPE,
+                body: elasticSearchBody,
+            });
+            return {
+                items: body.hits.hits.map(this.mapProductToSearchResult),
+                totalItems: body.hits.total.value,
+            };
+        } else {
+            const {body}: { body: SearchResponseBody<VariantIndexItem>; } = await this.client.search({
+                index: indexPrefix + VARIANT_INDEX_NAME,
+                type: VARIANT_INDEX_TYPE,
+                body: elasticSearchBody,
+            });
+            return {
+                items: body.hits.hits.map(this.mapVariantToSearchResult),
+                totalItems: body.hits.total.value,
+            };
+        }
+    }
+
+    /**
+     * Return a list of all FacetValues which appear in the result set.
+     */
+    async facetValues(
+        ctx: RequestContext,
+        input: SearchInput,
+        enabledOnly: boolean = false,
+    ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
+        const { indexPrefix } = this.options;
+        const elasticSearchBody = buildElasticBody(input, enabledOnly);
+        elasticSearchBody.from = 0;
+        elasticSearchBody.size = 0;
+        elasticSearchBody.aggs = {
+            facetValue: {
+                terms: { field: 'facetValueIds.keyword' },
+            },
+        };
+        const { body }: { body: SearchResponseBody<VariantIndexItem>; } = await this.client.search({
+            index: indexPrefix + VARIANT_INDEX_NAME,
+            type: VARIANT_INDEX_TYPE,
+            body: elasticSearchBody,
+        });
+
+        const buckets = body.aggregations ? body.aggregations.facetValue.buckets : [];
+
+        const facetValues = await this.facetValueService.findByIds(
+            buckets.map(b => b.key),
+            ctx.languageCode,
+        );
+        return facetValues.map((facetValue, index) => {
+            return {
+                facetValue,
+                count: buckets[index].doc_count,
+            };
+        });
+    }
+
+    /**
+     * Rebuilds the full search index.
+     */
+    async reindex(ctx: RequestContext): Promise<JobInfo> {
+        const { indexPrefix } = this.options;
+        await this.deleteIndices(indexPrefix);
+        await this.createIndices(indexPrefix);
+        const job = this.elasticsearchIndexService.reindex(ctx);
+        job.start();
+        return job;
+    }
+
+    private async createIndices(prefix: string) {
+        try {
+            const index = prefix + VARIANT_INDEX_NAME;
+            await this.client.indices.create({ index });
+            Logger.verbose(`Created index "${index}"`, loggerCtx);
+        } catch (e) {
+            Logger.error(JSON.stringify(e, null, 2), loggerCtx);
+        }
+        try {
+            const index = prefix + PRODUCT_INDEX_NAME;
+            await this.client.indices.create({ index });
+            Logger.verbose(`Created index "${index}"`, loggerCtx);
+        } catch (e) {
+            Logger.error(JSON.stringify(e, null, 2), loggerCtx);
+        }
+    }
+
+    private async deleteIndices(prefix: string) {
+        try {
+            const index = prefix + VARIANT_INDEX_NAME;
+            await this.client.indices.delete({ index });
+            Logger.verbose(`Deleted index "${index}"`, loggerCtx);
+        } catch (e) {
+            Logger.error(e, loggerCtx);
+        }
+        try {
+            const index = prefix + PRODUCT_INDEX_NAME;
+            await this.client.indices.delete({ index });
+            Logger.verbose(`Deleted index "${index}"`, loggerCtx);
+        } catch (e) {
+            Logger.error(e, loggerCtx);
+        }
+    }
+
+    private mapVariantToSearchResult(hit: SearchHit<VariantIndexItem>): SearchResult {
+        const source = hit._source;
+        return {
+            ...source,
+            price: {
+                value: source.price,
+            },
+            priceWithTax: {
+                value: source.priceWithTax,
+            },
+            score: hit._score,
+        };
+    }
+
+    private mapProductToSearchResult(hit: SearchHit<ProductIndexItem>): SearchResult {
+        const source = hit._source;
+        return {
+            ...source,
+            productId: source.productId.toString(),
+            productName: source.productName[0],
+            productVariantId: source.productVariantId[0].toString(),
+            productVariantName: source.productVariantName[0],
+            productVariantPreview: source.productVariantPreview[0],
+            facetIds: source.facetIds as string[],
+            facetValueIds: source.facetValueIds as string[],
+            collectionIds: source.collectionIds as string[],
+            sku: source.sku[0],
+            slug: source.slug[0],
+            price: {
+                min: source.priceMin,
+                max: source.priceMax,
+            },
+            priceWithTax: {
+                min: source.priceWithTaxMin,
+                max: source.priceWithTaxMax,
+            },
+            score: hit._score,
+        };
+    }
+}

+ 111 - 0
packages/elasticsearch-plugin/src/plugin.ts

@@ -0,0 +1,111 @@
+import { Client } from '@elastic/elasticsearch';
+import { Provider } from '@nestjs/common';
+import {
+    APIExtensionDefinition,
+    CatalogModificationEvent,
+    CollectionModificationEvent,
+    EventBus,
+    idsAreEqual,
+    Logger,
+    Product,
+    ProductVariant,
+    SearchService,
+    TaxRateModificationEvent,
+    Type,
+    VendurePlugin,
+} from '@vendure/core';
+import { gql } from 'apollo-server-core';
+
+import { ELASTIC_SEARCH_CLIENT, ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
+import { ElasticsearchIndexService } from './elasticsearch-index.service';
+import { AdminElasticSearchResolver, ShopElasticSearchResolver } from './elasticsearch-resolver';
+import { ElasticsearchService } from './elasticsearch.service';
+
+export interface ElasticsearchOptions {
+    host: string;
+    port: number;
+    indexPrefix?: string;
+}
+
+export class ElasticsearchPlugin implements VendurePlugin {
+    private readonly options: Required<ElasticsearchOptions>;
+    private readonly client: Client;
+
+    constructor(options: ElasticsearchOptions) {
+        this.options = { indexPrefix: 'vendure-', ...options };
+        const { host, port } = options;
+        this.client = new Client({
+            node: `${host}:${port}`,
+        });
+    }
+
+    async onBootstrap(inject: <T>(type: Type<T>) => T): Promise<void> {
+        const elasticsearchService = inject(ElasticsearchService);
+        const elasticsearchIndexService = inject(ElasticsearchIndexService);
+        const { host, port } = this.options;
+        try {
+            const pingResult = await elasticsearchService.checkConnection();
+        } catch (e) {
+            Logger.error(`Could not connect to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
+            Logger.error(JSON.stringify(e), loggerCtx);
+            return;
+        }
+        Logger.info(`Sucessfully connected to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
+
+        await elasticsearchService.createIndicesIfNotExists();
+
+        const eventBus = inject(EventBus);
+        eventBus.subscribe(CatalogModificationEvent, event => {
+            if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
+                return elasticsearchIndexService.updateProductOrVariant(event.ctx, event.entity);
+            }
+        });
+        eventBus.subscribe(CollectionModificationEvent, event => {
+            return elasticsearchIndexService.updateVariantsById(event.ctx, event.productVariantIds);
+        });
+        eventBus.subscribe(TaxRateModificationEvent, event => {
+            const defaultTaxZone = event.ctx.channel.defaultTaxZone;
+            if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
+                return elasticsearchService.reindex(event.ctx);
+            }
+        });
+    }
+
+    /** @internal */
+    extendAdminAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [AdminElasticSearchResolver],
+            schema: gql`
+                extend type SearchReindexResponse {
+                    timeTaken: Int!
+                    indexedItemCount: Int!
+                }
+            `,
+        };
+    }
+
+    /** @internal */
+    extendShopAPI(): APIExtensionDefinition {
+        return {
+            resolvers: [ShopElasticSearchResolver],
+            schema: gql`
+                extend type SearchReindexResponse {
+                    timeTaken: Int!
+                    indexedItemCount: Int!
+                }
+            `,
+        };
+    }
+
+    defineProviders(): Provider[] {
+        return [
+            { provide: ElasticsearchIndexService, useClass: ElasticsearchIndexService },
+            AdminElasticSearchResolver,
+            ShopElasticSearchResolver,
+            ElasticsearchService,
+            { provide: SearchService, useClass: ElasticsearchService },
+            { provide: ELASTIC_SEARCH_OPTIONS, useFactory: () => this.options },
+            { provide: ELASTIC_SEARCH_CLIENT, useFactory: () => this.client },
+        ];
+    }
+}

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

@@ -0,0 +1,96 @@
+import { CurrencyCode, SearchResult } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+
+export type VariantIndexItem = Omit<SearchResult, 'score' | 'price' | 'priceWithTax'> & {
+    price: number;
+    priceWithTax: number;
+};
+export type ProductIndexItem = {
+    sku: string[],
+    slug: string[],
+    productId: ID,
+    productName: string[],
+    productPreview: string,
+    productVariantId: ID[],
+    productVariantName: string[],
+    productVariantPreview: string[],
+    currencyCode: CurrencyCode,
+    description: string,
+    facetIds: ID[],
+    facetValueIds: ID[],
+    collectionIds: ID[],
+    enabled: boolean,
+    priceMin: number;
+    priceMax: number;
+    priceWithTaxMin: number;
+    priceWithTaxMax: number;
+};
+
+export type SearchHit<T> = {
+    _id: string;
+    _index: string;
+    _score: number;
+    _source: T;
+    _type: string;
+};
+
+export type SearchRequestBody = {
+    query?: any;
+    sort?: any[];
+    from?: number;
+    size?: number;
+    aggs?: any;
+};
+
+export type SearchResponseBody<T = any> = {
+    hits: {
+        hits: Array<SearchHit<T>>;
+        total: {
+            relation: string;
+            value: number;
+        };
+        max_score: number;
+    };
+    timed_out: boolean;
+    took: number;
+    _shards: {
+        failed: number;
+        skipped: number;
+        successful: number;
+        total: number;
+    };
+    aggregations?: {
+        [key: string]: {
+            doc_count_error_upper_bound: 0,
+            sum_other_doc_count: 89,
+            buckets: Array<{ key: string; doc_count: number; }>;
+        },
+    }
+};
+
+export type BulkOperationType = 'index' | 'update' | 'delete';
+export type BulkOperation = { [operation in BulkOperationType]?: { _id: string; }; };
+export type BulkOperationDoc<T> = T | { doc: T; };
+export type BulkResponseResult = {
+    [operation in BulkOperationType]?: {
+        _index: string;
+        _type: string;
+        _id: string;
+        _version?: number;
+        result?: string;
+        _shards: {
+            total: number;
+            successful: number;
+            failed: number;
+        };
+        status: number;
+        _seq_no?: number;
+        _primary_term?: number;
+        error?: any;
+    };
+};
+export type BulkResponseBody = {
+    took: number;
+    errors: boolean;
+    items: BulkResponseResult[]
+};

+ 9 - 0
packages/elasticsearch-plugin/tsconfig.build.json

@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./lib"
+  },
+  "files": [
+    "./index.ts"
+  ]
+}

+ 11 - 0
packages/elasticsearch-plugin/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "removeComments": true,
+    "noLib": false,
+    "skipLibCheck": true,
+    "sourceMap": true,
+    "newLine": "LF"
+  }
+}

+ 33 - 1
yarn.lock

@@ -297,6 +297,18 @@
     is-absolute "^1.0.0"
     is-negated-glob "^1.0.0"
 
+"@elastic/elasticsearch@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.1.0.tgz#e6f940e634d034cc0613050a1b4232cd0b4d95cc"
+  integrity sha512-evfs+W6hrC0wu11w/0xL1ZXADHiCbWnVBUbAhZe3xjaFKDfOGioHkPULaEE8uALe7PPPNAMuFErWzutAwwcU9g==
+  dependencies:
+    debug "^4.1.1"
+    decompress-response "^4.2.0"
+    into-stream "^5.1.0"
+    ms "^2.1.1"
+    once "^1.4.0"
+    pump "^3.0.0"
+
 "@graphql-codegen/add@^1.1.3":
   version "1.2.0"
   resolved "https://registry.npmjs.org/@graphql-codegen/add/-/add-1.2.0.tgz#c3df09752ac11a45428f8645a39785ba9ae802c3"
@@ -3879,6 +3891,13 @@ decompress-response@^3.3.0:
   dependencies:
     mimic-response "^1.0.0"
 
+decompress-response@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.0.tgz#805ca9d1d3cdf17a03951475ad6cdc93115cec3f"
+  integrity sha512-MHebOkORCgLW1ramLri5vzfR4r7HgXXrVkVr/eaPVRCtYWFUp9hNAuqsBxhpABbpqd7zY2IrjxXfTuaVrW0Z2A==
+  dependencies:
+    mimic-response "^2.0.0"
+
 dedent@^0.7.0:
   version "0.7.0"
   resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
@@ -4829,7 +4848,7 @@ fresh@0.5.2:
   resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
 
-from2@^2.1.0:
+from2@^2.1.0, from2@^2.3.0:
   version "2.3.0"
   resolved "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
   integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=
@@ -5823,6 +5842,14 @@ intl-messageformat@2.2.0:
   dependencies:
     intl-messageformat-parser "1.4.0"
 
+into-stream@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.npmjs.org/into-stream/-/into-stream-5.1.0.tgz#b05f37d8fed05c06a0b43b556d74e53e5af23878"
+  integrity sha512-cbDhb8qlxKMxPBk/QxTtYg1DQ4CwXmadu7quG3B7nrJsgSncEreF2kwWKZFdnjc/lSNNIkFPsjI7SM0Cx/QXPw==
+  dependencies:
+    from2 "^2.3.0"
+    p-is-promise "^2.0.0"
+
 invariant@^2.2.4:
   version "2.2.4"
   resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -7680,6 +7707,11 @@ mimic-response@^1.0.0:
   resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
+mimic-response@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-2.0.0.tgz#996a51c60adf12cb8a87d7fb8ef24c2f3d5ebb46"
+  integrity sha512-8ilDoEapqA4uQ3TwS0jakGONKXVJqpy+RpM+3b7pLdOjghCrEiGp9SRkFbUHAmZW9vdnrENWHjaweIoTIJExSQ==
+
 minimatch@^3.0.0, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"