Browse Source

feat(server): Simple weighted fulltext search

Relates to #47
Michael Bromley 7 years ago
parent
commit
8ca0b0ff28

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 2 - 2
server/src/api/resolvers/search.resolver.ts

@@ -1,6 +1,6 @@
 import { Mutation, Query, Resolver } from '@nestjs/graphql';
 
-import { Permission } from '../../../../shared/generated-types';
+import { Permission, SearchResponse } from '../../../../shared/generated-types';
 import { Allow } from '../../api/decorators/allow.decorator';
 import { InternalServerError } from '../../common/error/errors';
 
@@ -8,7 +8,7 @@ import { InternalServerError } from '../../common/error/errors';
 export class SearchResolver {
     @Query()
     @Allow(Permission.Public)
-    async search(...args: any): Promise<any[]> {
+    async search(...args: any): Promise<SearchResponse> {
         throw new InternalServerError(`error.no-search-plugin-configured`);
     }
 

+ 12 - 1
server/src/api/types/search.api.graphql

@@ -1,5 +1,5 @@
 type Query {
-    search(input: SearchInput!): [SearchResult!]!
+    search(input: SearchInput!): SearchResponse!
 }
 
 type Mutation {
@@ -8,9 +8,19 @@ type Mutation {
 
 input SearchInput {
     term: String
+    facetIds: [String!]
+    take: Int
+    skip: Int
+}
+
+type SearchResponse {
+    items: [SearchResult!]!
+    totalItems: Int!
+    facetValueIds: [String!]!
 }
 
 type SearchResult {
+    sku: String!
     productVariantName: String!
     productName: String!
     productVariantId: ID!
@@ -20,4 +30,5 @@ type SearchResult {
     productVariantPreview: String!
     facetIds: [String!]!
     facetValueIds: [String!]!
+    score: Float!
 }

+ 3 - 3
server/src/plugin/default-search-engine/fulltext-search.resolver.ts

@@ -1,6 +1,6 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 
-import { Permission } from '../../../../shared/generated-types';
+import { Permission, SearchQueryArgs, SearchResponse } from '../../../../shared/generated-types';
 import { RequestContext } from '../../api/common/request-context';
 import { Allow } from '../../api/decorators/allow.decorator';
 import { Ctx } from '../../api/decorators/request-context.decorator';
@@ -16,8 +16,8 @@ export class FulltextSearchResolver extends BaseSearchResolver {
 
     @Query()
     @Allow(Permission.Public)
-    async search(@Ctx() ctx: RequestContext, @Args() args: any) {
-        return this.fulltextSearchService.search(ctx, args.input.term);
+    async search(@Ctx() ctx: RequestContext, @Args() args: SearchQueryArgs): Promise<SearchResponse> {
+        return this.fulltextSearchService.search(ctx, args.input);
     }
 
     @Mutation()

+ 141 - 21
server/src/plugin/default-search-engine/fulltext-search.service.ts

@@ -1,54 +1,175 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { Connection } from 'typeorm';
+import { Connection, SelectQueryBuilder } from 'typeorm';
 
+import { LanguageCode, SearchInput, SearchResponse } from '../../../../shared/generated-types';
 import { unique } from '../../../../shared/unique';
 import { RequestContext } from '../../api/common/request-context';
-import { FacetValue, ProductVariant } from '../../entity';
+import { FacetValue, Product, ProductVariant } from '../../entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { translateDeep } from '../../service/helpers/utils/translate-entity';
 
 import { SearchIndexItem } from './search-index-item.entity';
 
+/**
+ * MySQL / MariaDB Fulltext-based product search implementation.
+ * TODO: Needs implementing for postgres, sql server etc.
+ */
 @Injectable()
 export class FulltextSearchService {
+    private readonly minTermLength = 2;
+    private readonly variantRelations = [
+        'product',
+        'product.featuredAsset',
+        'product.facetValues',
+        'product.facetValues.facet',
+        'featuredAsset',
+        'facetValues',
+        'facetValues.facet',
+    ];
+
     constructor(@InjectConnection() private connection: Connection, private eventBus: EventBus) {
-        eventBus.subscribe(CatalogModificationEvent, event => this.reindex(event.ctx));
+        eventBus.subscribe(CatalogModificationEvent, event => {
+            if (event.entity instanceof Product || event.entity instanceof ProductVariant) {
+                return this.update(event.ctx, event.entity);
+            }
+        });
     }
 
-    async search(ctx: RequestContext, term: string) {
-        return this.connection
+    /**
+     * Perform a fulltext search according to the provided input arguments.
+     */
+    async search(ctx: RequestContext, input: SearchInput): Promise<SearchResponse> {
+        const take = input.take || 25;
+        const skip = input.skip || 0;
+        const qb = this.connection.getRepository(SearchIndexItem).createQueryBuilder('si');
+        this.applyTermAndFilters(qb, input);
+        if (input.term && input.term.length > this.minTermLength) {
+            qb.orderBy('score', 'DESC');
+        }
+
+        const items = await qb
+            .take(take)
+            .skip(skip)
+            .getRawMany()
+            .then(res =>
+                res.map(r => {
+                    return {
+                        sku: r.si_sku,
+                        productVariantId: r.si_productVariantId,
+                        languageCode: r.si_languageCode,
+                        productId: r.si_productId,
+                        productName: r.si_productName,
+                        productVariantName: r.si_productVariantName,
+                        description: r.si_description,
+                        facetIds: r.si_facetIds.split(',').map(x => x.trim()),
+                        facetValueIds: r.si_facetValueIds.split(',').map(x => x.trim()),
+                        productPreview: r.si_productPreview,
+                        productVariantPreview: r.si_productVariantPreview,
+                        score: r.score || 0,
+                    };
+                }),
+            );
+
+        const innerQb = this.applyTermAndFilters(
+            this.connection.getRepository(SearchIndexItem).createQueryBuilder('si'),
+            input,
+        );
+
+        const totalItemsQb = this.connection
+            .createQueryBuilder()
+            .select('COUNT(*) as total')
+            .from(`(${innerQb.getQuery()})`, 'inner')
+            .setParameters(innerQb.getParameters());
+        const totalResult = await totalItemsQb.getRawOne();
+
+        const facetValuesQb = this.connection
             .getRepository(SearchIndexItem)
             .createQueryBuilder('si')
-            .addSelect(`MATCH (productName) AGAINST ('${term}')`, 'score')
-            .orderBy('score', 'DESC')
-            .getMany()
-            .then(res => {
-                return res;
-            });
+            .select('GROUP_CONCAT(facetValueIds)', 'allFacetValues');
+
+        const facetValuesResult = await this.applyTermAndFilters(facetValuesQb, input).getRawOne();
+        const allFacetValues = facetValuesResult ? facetValuesResult.allFacetValues || '' : '';
+        const facetValueIds = unique(allFacetValues.split(',').filter(x => x !== '') as string[]);
+
+        return {
+            items,
+            totalItems: totalResult.total,
+            facetValueIds,
+        };
     }
 
+    /**
+     * Rebuilds the full search index.
+     */
     async reindex(ctx: RequestContext): Promise<boolean> {
         const { languageCode } = ctx;
         const variants = await this.connection.getRepository(ProductVariant).find({
-            relations: [
-                'product',
-                'product.featuredAsset',
-                'product.facetValues',
-                'product.facetValues.facet',
-                'featuredAsset',
-                'facetValues',
-                'facetValues.facet',
-            ],
+            relations: this.variantRelations,
         });
 
         await this.connection.getRepository(SearchIndexItem).delete({ languageCode });
+        await this.saveSearchIndexItems(languageCode, variants);
+        return true;
+    }
+
+    /**
+     * Updates the search index only for the affected entities.
+     */
+    async update(ctx: RequestContext, updatedEntity: Product | ProductVariant) {
+        let variants: ProductVariant[] = [];
+        if (updatedEntity instanceof Product) {
+            variants = await this.connection.getRepository(ProductVariant).find({
+                relations: this.variantRelations,
+                where: { product: { id: updatedEntity.id } },
+            });
+        } else {
+            const variant = await this.connection.getRepository(ProductVariant).findOne(updatedEntity.id, {
+                relations: this.variantRelations,
+            });
+            if (variant) {
+                variants = [variant];
+            }
+        }
+        await this.saveSearchIndexItems(ctx.languageCode, variants);
+    }
+
+    private applyTermAndFilters(
+        qb: SelectQueryBuilder<SearchIndexItem>,
+        input: SearchInput,
+    ): SelectQueryBuilder<SearchIndexItem> {
+        const { term, facetIds } = input;
+        if (term && term.length > this.minTermLength) {
+            qb.addSelect(`IF (sku LIKE :like_term, 10, 0)`, 'sku_score')
+                .addSelect(
+                    `
+                        (SELECT sku_score) +
+                        MATCH (productName) AGAINST (:term) * 2 +
+                        MATCH (productVariantName) AGAINST (:term) * 1.5 +
+                        MATCH (description) AGAINST (:term)* 1`,
+                    'score',
+                )
+                .having(`score > 0`)
+                .setParameters({ term, like_term: `%${term}%` });
+        }
+        if (facetIds) {
+            qb.where('true');
+            for (const id of facetIds) {
+                const placeholder = '_' + id;
+                qb.andWhere(`FIND_IN_SET(:${placeholder}, facetValueIds)`, { [placeholder]: id });
+            }
+        }
+        return qb;
+    }
+
+    private async saveSearchIndexItems(languageCode: LanguageCode, variants: ProductVariant[]) {
         const items = variants
             .map(v => translateDeep(v, languageCode, ['product']))
             .map(
                 v =>
                     new SearchIndexItem({
+                        sku: v.sku,
                         languageCode,
                         productVariantId: v.id,
                         productId: v.product.id,
@@ -62,7 +183,6 @@ export class FulltextSearchService {
                     }),
             );
         await this.connection.getRepository(SearchIndexItem).save(items);
-        return true;
     }
 
     private getFacetIds(variant: ProductVariant): string[] {

+ 4 - 3
server/src/plugin/default-search-engine/search-index-item.entity.ts

@@ -17,7 +17,7 @@ export class SearchIndexItem {
     @PrimaryColumn({ type: idType() })
     productVariantId: ID;
 
-    @Column('varchar')
+    @PrimaryColumn('varchar')
     languageCode: LanguageCode;
 
     @Column({ type: idType() })
@@ -35,6 +35,9 @@ export class SearchIndexItem {
     @Column('text')
     description: string;
 
+    @Column()
+    sku: string;
+
     @Column('simple-array')
     facetIds: string[];
 
@@ -46,6 +49,4 @@ export class SearchIndexItem {
 
     @Column()
     productVariantPreview: string;
-
-    score: number;
 }

+ 109 - 0
shared/generated-types.ts

@@ -77,6 +77,7 @@ export interface Query {
     adjustmentOperations: AdjustmentOperations;
     roles: RoleList;
     role?: Role | null;
+    search: SearchResponse;
     shippingMethods: ShippingMethodList;
     shippingMethod?: ShippingMethod | null;
     shippingEligibilityCheckers: AdjustmentOperation[];
@@ -584,6 +585,26 @@ export interface RoleList extends PaginatedList {
     totalItems: number;
 }
 
+export interface SearchResponse {
+    items: SearchResult[];
+    totalItems: number;
+    facetValueIds: string[];
+}
+
+export interface SearchResult {
+    sku: string;
+    productVariantName: string;
+    productName: string;
+    productVariantId: string;
+    productId: string;
+    description: string;
+    productPreview: string;
+    productVariantPreview: string;
+    facetIds: string[];
+    facetValueIds: string[];
+    score: number;
+}
+
 export interface ShippingMethodList extends PaginatedList {
     items: ShippingMethod[];
     totalItems: number;
@@ -659,6 +680,7 @@ export interface Mutation {
     updatePromotion: Promotion;
     createRole: Role;
     updateRole: Role;
+    reindex: boolean;
     createShippingMethod: ShippingMethod;
     updateShippingMethod: ShippingMethod;
     createTaxCategory: TaxCategory;
@@ -956,6 +978,13 @@ export interface RoleFilterParameter {
     updatedAt?: DateOperators | null;
 }
 
+export interface SearchInput {
+    term?: string | null;
+    facetIds?: string[] | null;
+    take?: number | null;
+    skip?: number | null;
+}
+
 export interface ShippingMethodListOptions {
     take?: number | null;
     skip?: number | null;
@@ -1503,6 +1532,9 @@ export interface RolesQueryArgs {
 export interface RoleQueryArgs {
     id: string;
 }
+export interface SearchQueryArgs {
+    input: SearchInput;
+}
 export interface ShippingMethodsQueryArgs {
     options?: ShippingMethodListOptions | null;
 }
@@ -1998,6 +2030,7 @@ export namespace QueryResolvers {
         adjustmentOperations?: AdjustmentOperationsResolver<AdjustmentOperations, any, Context>;
         roles?: RolesResolver<RoleList, any, Context>;
         role?: RoleResolver<Role | null, any, Context>;
+        search?: SearchResolver<SearchResponse, any, Context>;
         shippingMethods?: ShippingMethodsResolver<ShippingMethodList, any, Context>;
         shippingMethod?: ShippingMethodResolver<ShippingMethod | null, any, Context>;
         shippingEligibilityCheckers?: ShippingEligibilityCheckersResolver<
@@ -2342,6 +2375,16 @@ export namespace QueryResolvers {
         id: string;
     }
 
+    export type SearchResolver<R = SearchResponse, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context,
+        SearchArgs
+    >;
+    export interface SearchArgs {
+        input: SearchInput;
+    }
+
     export type ShippingMethodsResolver<R = ShippingMethodList, Parent = any, Context = any> = Resolver<
         R,
         Parent,
@@ -3834,6 +3877,70 @@ export namespace RoleListResolvers {
     export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
 
+export namespace SearchResponseResolvers {
+    export interface Resolvers<Context = any> {
+        items?: ItemsResolver<SearchResult[], any, Context>;
+        totalItems?: TotalItemsResolver<number, any, Context>;
+        facetValueIds?: FacetValueIdsResolver<string[], any, Context>;
+    }
+
+    export type ItemsResolver<R = SearchResult[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type TotalItemsResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type FacetValueIdsResolver<R = string[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+}
+
+export namespace SearchResultResolvers {
+    export interface Resolvers<Context = any> {
+        sku?: SkuResolver<string, any, Context>;
+        productVariantName?: ProductVariantNameResolver<string, any, Context>;
+        productName?: ProductNameResolver<string, any, Context>;
+        productVariantId?: ProductVariantIdResolver<string, any, Context>;
+        productId?: ProductIdResolver<string, any, Context>;
+        description?: DescriptionResolver<string, any, Context>;
+        productPreview?: ProductPreviewResolver<string, any, Context>;
+        productVariantPreview?: ProductVariantPreviewResolver<string, any, Context>;
+        facetIds?: FacetIdsResolver<string[], any, Context>;
+        facetValueIds?: FacetValueIdsResolver<string[], any, Context>;
+        score?: ScoreResolver<number, any, Context>;
+    }
+
+    export type SkuResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ProductVariantNameResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ProductNameResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ProductVariantIdResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ProductIdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type DescriptionResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type ProductPreviewResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ProductVariantPreviewResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type FacetIdsResolver<R = string[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type FacetValueIdsResolver<R = string[], Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type ScoreResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+}
+
 export namespace ShippingMethodListResolvers {
     export interface Resolvers<Context = any> {
         items?: ItemsResolver<ShippingMethod[], any, Context>;
@@ -3946,6 +4053,7 @@ export namespace MutationResolvers {
         updatePromotion?: UpdatePromotionResolver<Promotion, any, Context>;
         createRole?: CreateRoleResolver<Role, any, Context>;
         updateRole?: UpdateRoleResolver<Role, any, Context>;
+        reindex?: ReindexResolver<boolean, any, Context>;
         createShippingMethod?: CreateShippingMethodResolver<ShippingMethod, any, Context>;
         updateShippingMethod?: UpdateShippingMethodResolver<ShippingMethod, any, Context>;
         createTaxCategory?: CreateTaxCategoryResolver<TaxCategory, any, Context>;
@@ -4466,6 +4574,7 @@ export namespace MutationResolvers {
         input: UpdateRoleInput;
     }
 
+    export type ReindexResolver<R = boolean, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CreateShippingMethodResolver<R = ShippingMethod, Parent = any, Context = any> = Resolver<
         R,
         Parent,

Some files were not shown because too many files changed in this diff