Procházet zdrojové kódy

feat(core): Make search strategy configurable via plugin options (#1504)

Sebastian Geschke před 3 roky
rodič
revize
b31694f896

+ 0 - 1
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -2,7 +2,6 @@
 import { pick } from '@vendure/common/lib/pick';
 import {
     DefaultJobQueuePlugin,
-    DefaultLogger,
     DefaultSearchPlugin,
     facetValueCollectionFilter,
     mergeConfig,

+ 28 - 2
packages/core/src/plugin/default-search-plugin/default-search-plugin.ts

@@ -1,9 +1,11 @@
-import { OnApplicationBootstrap } from '@nestjs/common';
+import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
 import { SearchReindexResponse } from '@vendure/common/lib/generated-types';
 import { ID, Type } from '@vendure/common/lib/shared-types';
 import { buffer, debounceTime, delay, filter, map } from 'rxjs/operators';
 import { Column } from 'typeorm';
 
+import { Injector } from '../../common';
 import { idsAreEqual } from '../../common/utils';
 import { EventBus } from '../../event-bus/event-bus';
 import { AssetEvent } from '../../event-bus/events/asset-event';
@@ -89,7 +91,7 @@ export interface DefaultSearchReindexResponse extends SearchReindexResponse {
     },
     entities: [SearchIndexItem],
 })
-export class DefaultSearchPlugin implements OnApplicationBootstrap {
+export class DefaultSearchPlugin implements OnApplicationBootstrap, OnApplicationShutdown {
     static options: DefaultSearchPluginInitOptions = {};
 
     /** @internal */
@@ -97,6 +99,7 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap {
         private eventBus: EventBus,
         private searchIndexService: SearchIndexService,
         private jobQueueService: JobQueueService,
+        private moduleRef: ModuleRef,
     ) {}
 
     static init(options: DefaultSearchPluginInitOptions): Type<DefaultSearchPlugin> {
@@ -192,6 +195,29 @@ export class DefaultSearchPlugin implements OnApplicationBootstrap {
                     return this.searchIndexService.reindex(event.ctx);
                 }
             });
+
+        await this.initSearchStrategy();
+    }
+
+    /** @internal */
+    async onApplicationShutdown(signal?: string) {
+        await this.destroySearchStrategy();
+    }
+
+    private async initSearchStrategy(): Promise<void> {
+        const injector = new Injector(this.moduleRef);
+        const searchService = injector.get(FulltextSearchService);
+        if (typeof searchService.searchStrategy.init === 'function') {
+            await searchService.searchStrategy.init(injector);
+        }
+    }
+
+    private async destroySearchStrategy(): Promise<void> {
+        const injector = new Injector(this.moduleRef);
+        const searchService = injector.get(FulltextSearchService);
+        if (typeof searchService.searchStrategy.destroy === 'function') {
+            await searchService.searchStrategy.destroy();
+        }
     }
 
     /**

+ 28 - 20
packages/core/src/plugin/default-search-plugin/fulltext-search.service.ts

@@ -27,7 +27,7 @@ import { DefaultSearchPluginInitOptions } from './types';
  */
 @Injectable()
 export class FulltextSearchService {
-    private searchStrategy: SearchStrategy;
+    private _searchStrategy: SearchStrategy;
     private readonly minTermLength = 2;
 
     constructor(
@@ -52,8 +52,8 @@ export class FulltextSearchService {
         input: SearchInput,
         enabledOnly: boolean = false,
     ): Promise<Omit<Omit<SearchResponse, 'facetValues'>, 'collections'>> {
-        const items = await this.searchStrategy.getSearchResults(ctx, input, enabledOnly);
-        const totalItems = await this.searchStrategy.getTotalCount(ctx, input, enabledOnly);
+        const items = await this._searchStrategy.getSearchResults(ctx, input, enabledOnly);
+        const totalItems = await this._searchStrategy.getTotalCount(ctx, input, enabledOnly);
         return {
             items,
             totalItems,
@@ -68,7 +68,7 @@ export class FulltextSearchService {
         input: SearchInput,
         enabledOnly: boolean = false,
     ): Promise<Array<{ facetValue: FacetValue; count: number }>> {
-        const facetValueIdsMap = await this.searchStrategy.getFacetValueIds(ctx, input, enabledOnly);
+        const facetValueIdsMap = await this._searchStrategy.getFacetValueIds(ctx, input, enabledOnly);
         const facetValues = await this.facetValueService.findByIds(ctx, Array.from(facetValueIdsMap.keys()));
         return facetValues.map((facetValue, index) => {
             return {
@@ -86,7 +86,7 @@ export class FulltextSearchService {
         input: SearchInput,
         enabledOnly: boolean = false,
     ): Promise<Array<{ collection: Collection; count: number }>> {
-        const collectionIdsMap = await this.searchStrategy.getCollectionIds(ctx, input, enabledOnly);
+        const collectionIdsMap = await this._searchStrategy.getCollectionIds(ctx, input, enabledOnly);
         const collections = await this.collectionService.findByIds(ctx, Array.from(collectionIdsMap.keys()));
         return collections.map((collection, index) => {
             return {
@@ -108,21 +108,29 @@ export class FulltextSearchService {
      * Sets the SearchStrategy appropriate to th configured database type.
      */
     private setSearchStrategy() {
-        switch (this.connection.rawConnection.options.type) {
-            case 'mysql':
-            case 'mariadb':
-                this.searchStrategy = new MysqlSearchStrategy(this.connection, this.options);
-                break;
-            case 'sqlite':
-            case 'sqljs':
-            case 'better-sqlite3':
-                this.searchStrategy = new SqliteSearchStrategy(this.connection, this.options);
-                break;
-            case 'postgres':
-                this.searchStrategy = new PostgresSearchStrategy(this.connection, this.options);
-                break;
-            default:
-                throw new InternalServerError(`error.database-not-supported-by-default-search-plugin`);
+        if (this.options.searchStategy) {
+            this._searchStrategy = this.options.searchStategy;
+        } else {
+            switch (this.connection.rawConnection.options.type) {
+                case 'mysql':
+                case 'mariadb':
+                    this._searchStrategy = new MysqlSearchStrategy();
+                    break;
+                case 'sqlite':
+                case 'sqljs':
+                case 'better-sqlite3':
+                    this._searchStrategy = new SqliteSearchStrategy();
+                    break;
+                case 'postgres':
+                    this._searchStrategy = new PostgresSearchStrategy();
+                    break;
+                default:
+                    throw new InternalServerError(`error.database-not-supported-by-default-search-plugin`);
+            }
         }
     }
+
+    public get searchStrategy(): SearchStrategy {
+        return this._searchStrategy;
+    }
 }

+ 8 - 4
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -3,8 +3,10 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, SelectQueryBuilder } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
+import { Injector } from '../../../common';
 import { UserInputError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { PLUGIN_INIT_OPTIONS } from '../constants';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
 import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 
@@ -22,11 +24,13 @@ import {
  */
 export class MysqlSearchStrategy implements SearchStrategy {
     private readonly minTermLength = 2;
+    private connection: TransactionalConnection;
+    private options: DefaultSearchPluginInitOptions;
 
-    constructor(
-        private connection: TransactionalConnection,
-        private options: DefaultSearchPluginInitOptions,
-    ) {}
+    async init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        this.options = injector.get(PLUGIN_INIT_OPTIONS);
+    }
 
     async getFacetValueIds(
         ctx: RequestContext,

+ 8 - 4
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -3,8 +3,10 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, SelectQueryBuilder } from 'typeorm';
 
 import { RequestContext } from '../../../api/common/request-context';
+import { Injector } from '../../../common';
 import { UserInputError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { PLUGIN_INIT_OPTIONS } from '../constants';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
 import { DefaultSearchPluginInitOptions, SearchInput } from '../types';
 
@@ -22,11 +24,13 @@ import {
  */
 export class PostgresSearchStrategy implements SearchStrategy {
     private readonly minTermLength = 2;
+    private connection: TransactionalConnection;
+    private options: DefaultSearchPluginInitOptions;
 
-    constructor(
-        private connection: TransactionalConnection,
-        private options: DefaultSearchPluginInitOptions,
-    ) {}
+    async init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        this.options = injector.get(PLUGIN_INIT_OPTIONS);
+    }
 
     async getFacetValueIds(
         ctx: RequestContext,

+ 2 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/search-strategy.ts

@@ -2,12 +2,13 @@ import { SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../../api';
+import { InjectableStrategy } from '../../../common';
 
 /**
  * This interface defines the contract that any database-specific search implementations
  * should follow.
  */
-export interface SearchStrategy {
+export interface SearchStrategy extends InjectableStrategy {
     getSearchResults(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<SearchResult[]>;
     getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number>;
     /**

+ 8 - 4
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -2,7 +2,9 @@ import { LogicalOperator, SearchResult } from '@vendure/common/lib/generated-typ
 import { ID } from '@vendure/common/lib/shared-types';
 import { Brackets, SelectQueryBuilder } from 'typeorm';
 
+import { PLUGIN_INIT_OPTIONS } from '../../..';
 import { RequestContext } from '../../../api/common/request-context';
+import { Injector } from '../../../common';
 import { UserInputError } from '../../../common/error/errors';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 import { SearchIndexItem } from '../entities/search-index-item.entity';
@@ -22,11 +24,13 @@ import {
  */
 export class SqliteSearchStrategy implements SearchStrategy {
     private readonly minTermLength = 2;
+    private connection: TransactionalConnection;
+    private options: DefaultSearchPluginInitOptions;
 
-    constructor(
-        private connection: TransactionalConnection,
-        private options: DefaultSearchPluginInitOptions,
-    ) {}
+    async init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        this.options = injector.get(PLUGIN_INIT_OPTIONS);
+    }
 
     async getFacetValueIds(
         ctx: RequestContext,

+ 86 - 0
packages/core/src/plugin/default-search-plugin/types.ts

@@ -4,6 +4,8 @@ import { ID, JsonCompatible } from '@vendure/common/lib/shared-types';
 import { SerializedRequestContext } from '../../api/common/request-context';
 import { Asset } from '../../entity/asset/asset.entity';
 
+import { SearchStrategy } from './search-strategy/search-strategy';
+
 /**
  * @description
  * Options which configure the behaviour of the DefaultSearchPlugin
@@ -35,6 +37,90 @@ export interface DefaultSearchPluginInitOptions {
      * @default false
      */
     bufferUpdates?: boolean;
+
+    /**
+     * @description
+     * Set a custom search strategy that implements {@link SearchStrategy} or extends an existing search strategy
+     * such as {@link MysqlSearchStrategy}, {@link PostgresSearchStrategy} or {@link SqliteSearchStrategy}.
+     *
+     * @example
+     * ```Typescript
+     * export class MySearchStrategy implements SearchStrategy {
+     *     private readonly minTermLength = 2;
+     *     private connection: TransactionalConnection;
+     *     private options: DefaultSearchPluginInitOptions;
+     *
+     *     async init(injector: Injector) {
+     *         this.connection = injector.get(TransactionalConnection);
+     *         this.options = injector.get(PLUGIN_INIT_OPTIONS);
+     *     }
+     *
+     *     async getFacetValueIds(
+     *         ctx: RequestContext,
+     *         input: SearchInput,
+     *         enabledOnly: boolean,
+     *     ): Promise<Map<ID, number>> {
+     *         // ...
+     *         return createFacetIdCountMap(facetValuesResult);
+     *     }
+     *
+     *     async getCollectionIds(
+     *         ctx: RequestContext,
+     *         input: SearchInput,
+     *         enabledOnly: boolean,
+     *     ): Promise<Map<ID, number>> {
+     *         // ...
+     *         return createCollectionIdCountMap(collectionsResult);
+     *     }
+     *
+     *     async getSearchResults(
+     *         ctx: RequestContext,
+     *         input: SearchInput,
+     *         enabledOnly: boolean,
+     *     ): Promise<SearchResult[]> {
+     *         const take = input.take || 25;
+     *         const skip = input.skip || 0;
+     *         const sort = input.sort;
+     *         const qb = this.connection
+     *             .getRepository(SearchIndexItem)
+     *             .createQueryBuilder('si')
+     *             .select(this.createMysqlSelect(!!input.groupByProduct));
+     *         // ...
+     *
+     *         return qb
+     *             .take(take)
+     *             .skip(skip)
+     *             .getRawMany()
+     *             .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+     *     }
+     *
+     *     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
+     *         const innerQb = this.applyTermAndFilters(
+     *             ctx,
+     *             this.connection
+     *                 .getRepository(SearchIndexItem)
+     *                 .createQueryBuilder('si')
+     *                 .select(this.createMysqlSelect(!!input.groupByProduct)),
+     *             input,
+     *         );
+     *         if (enabledOnly) {
+     *             innerQb.andWhere('si.enabled = :enabled', { enabled: true });
+     *         }
+     *
+     *         const totalItemsQb = this.connection.rawConnection
+     *             .createQueryBuilder()
+     *             .select('COUNT(*) as total')
+     *             .from(`(${innerQb.getQuery()})`, 'inner')
+     *             .setParameters(innerQb.getParameters());
+     *         return totalItemsQb.getRawOne().then(res => res.total);
+     *     }
+     * }
+     * ```
+     *
+     * @since 1.6.0
+     * @default undefined
+     */
+    searchStategy?: SearchStrategy;
 }
 
 /**