Browse Source

feat(server): Create initial types and resolver for product search

Michael Bromley 7 years ago
parent
commit
81defefe5d

+ 20 - 1
server/dev-config.ts

@@ -7,6 +7,8 @@ import { gripePaymentHandler } from './src/config/payment-method/gripe-payment-m
 import { OrderProcessOptions, VendureConfig } from './src/config/vendure-config';
 import { defaultEmailTypes } from './src/email/default-email-types';
 import { HandlebarsMjmlGenerator } from './src/email/handlebars-mjml-generator';
+import { DefaultAssetServerPlugin } from './src/plugin';
+import { DefaultSearchPlugin } from './src/plugin/default-search-engine/default-search-plugin';
 
 /**
  * Config settings used during development
@@ -46,5 +48,22 @@ export const devConfig: VendureConfig = {
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
-    plugins: [],
+    plugins: [
+        new DefaultAssetServerPlugin({
+            route: 'assets',
+            assetUploadDir: path.join(__dirname, 'assets'),
+            port: 4000,
+            hostname: 'http://localhost',
+            previewMaxHeight: 1600,
+            previewMaxWidth: 1600,
+            presets: [
+                { name: 'tiny', width: 50, height: 50, mode: 'crop' },
+                { name: 'thumb', width: 150, height: 150, mode: 'crop' },
+                { name: 'small', width: 300, height: 300, mode: 'resize' },
+                { name: 'medium', width: 500, height: 500, mode: 'resize' },
+                { name: 'large', width: 800, height: 800, mode: 'resize' },
+            ],
+        }),
+        new DefaultSearchPlugin(),
+    ],
 };

+ 16 - 0
server/src/api/resolvers/search.resolver.ts

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

+ 20 - 0
server/src/api/types/search.api.graphql

@@ -0,0 +1,20 @@
+type Query {
+    search(input: SearchInput!): [SearchResult!]!
+    reindex: Boolean!
+}
+
+input SearchInput {
+    term: String
+}
+
+type SearchResult {
+    productVariantName: String!
+    productName: String!
+    productVariantId: ID!
+    productId: ID!
+    description: String!
+    productPreview: String!
+    productVariantPreview: String!
+    facetIds: [String!]!
+    facetValueIds: [String!]!
+}

+ 1 - 20
server/src/config/default-config.ts

@@ -1,10 +1,7 @@
-import * as path from 'path';
-
 import { LanguageCode } from '../../../shared/generated-types';
 import { API_PATH, API_PORT } from '../../../shared/shared-constants';
 import { CustomFields } from '../../../shared/shared-types';
 import { ReadOnlyRequired } from '../common/types/common-types';
-import { DefaultAssetServerPlugin } from '../plugin';
 
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
@@ -91,21 +88,5 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         User: [],
     } as ReadOnlyRequired<CustomFields>,
     middleware: [],
-    plugins: [
-        new DefaultAssetServerPlugin({
-            route: 'assets',
-            assetUploadDir: path.join(__dirname, 'assets'),
-            port: 4000,
-            hostname: 'http://localhost',
-            previewMaxHeight: 1600,
-            previewMaxWidth: 1600,
-            presets: [
-                { name: 'tiny', width: 50, height: 50, mode: 'crop' },
-                { name: 'thumb', width: 150, height: 150, mode: 'crop' },
-                { name: 'small', width: 300, height: 300, mode: 'resize' },
-                { name: 'medium', width: 500, height: 500, mode: 'resize' },
-                { name: 'large', width: 800, height: 800, mode: 'resize' },
-            ],
-        }),
-    ],
+    plugins: [],
 };

+ 3 - 2
server/src/entity/custom-entity-fields.ts

@@ -5,7 +5,6 @@ import { assertNever } from '../../../shared/shared-utils';
 import { VendureConfig } from '../config/vendure-config';
 
 import { VendureEntity } from './base/base.entity';
-import { coreEntitiesMap } from './entities';
 
 @Entity()
 export class CustomAddressFields {}
@@ -175,8 +174,10 @@ export function registerCustomEntityFields(config: VendureConfig) {
  * Validates the custom fields config, e.g. by ensuring that there are no naming conflicts with the built-in fields
  * of each entity.
  */
-export function validateCustomFieldsConfig(customFieldConfig: CustomFields) {
+export async function validateCustomFieldsConfig(customFieldConfig: CustomFields) {
     const connection = getConnection();
+    // dynamic import to avoid bootstrap-time order of loading issues
+    const { coreEntitiesMap } = await import('./entities');
 
     for (const key of Object.keys(customFieldConfig)) {
         const entityName = key as keyof CustomFields;

+ 1 - 1
server/src/entity/product/product-translation.entity.ts

@@ -20,7 +20,7 @@ export class ProductTranslation extends VendureEntity implements Translation<Pro
 
     @Column() slug: string;
 
-    @Column() description: string;
+    @Column('text') description: string;
 
     @ManyToOne(type => Product, base => base.translations)
     base: Product;

+ 9 - 0
server/src/event-bus/events/catalog-modification-event.ts

@@ -0,0 +1,9 @@
+import { RequestContext } from '../../api/common/request-context';
+import { VendureEntity } from '../../entity';
+import { VendureEvent } from '../vendure-event';
+
+export class CatalogModificationEvent extends VendureEvent {
+    constructor(public ctx: RequestContext, public entity: VendureEntity) {
+        super();
+    }
+}

+ 91 - 0
server/src/plugin/default-search-engine/default-search-plugin.ts

@@ -0,0 +1,91 @@
+import { Connection } from 'typeorm';
+
+import { LanguageCode } from '../../../../shared/generated-types';
+import { Type } from '../../../../shared/shared-types';
+import { unique } from '../../../../shared/unique';
+import { VendureConfig, VendurePlugin } from '../../config';
+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 { FulltextSearchResolver } from './fulltext-search-resolver';
+import { SearchIndexItem } from './search-index-item.entity';
+
+export class DefaultSearchPlugin implements VendurePlugin {
+    private connection: Connection;
+
+    async init(config: Required<VendureConfig>): Promise<Required<VendureConfig>> {
+        return config;
+    }
+
+    onBootstrap(inject: <T>(type: Type<T>) => T): void | Promise<void> {
+        this.connection = inject(Connection);
+        const eventBus = inject(EventBus);
+
+        eventBus.subscribe(CatalogModificationEvent, event => this.buildSearchIndex(event.ctx.languageCode));
+    }
+
+    defineEntities(): Array<Type<any>> {
+        return [SearchIndexItem];
+    }
+
+    defineResolvers(): Array<Type<any>> {
+        return [FulltextSearchResolver];
+    }
+
+    /**
+     * Clears the search index for the given language and rebuilds from scratch.
+     */
+    private async buildSearchIndex(languageCode: LanguageCode) {
+        const variants = await this.connection.getRepository(ProductVariant).find({
+            relations: [
+                'product',
+                'product.featuredAsset',
+                'product.facetValues',
+                'product.facetValues.facet',
+                'featuredAsset',
+                'facetValues',
+                'facetValues.facet',
+            ],
+        });
+
+        await this.connection.getRepository(SearchIndexItem).delete({ languageCode });
+        const items = variants
+            .map(v => translateDeep(v, languageCode, ['product']))
+            .map(
+                v =>
+                    new SearchIndexItem({
+                        languageCode,
+                        productVariantId: v.id,
+                        productId: v.product.id,
+                        productName: v.product.name,
+                        description: v.product.description,
+                        productVariantName: v.name,
+                        productPreview: v.product.featuredAsset ? v.product.featuredAsset.preview : '',
+                        productVariantPreview: v.featuredAsset ? v.featuredAsset.preview : '',
+                        facetIds: this.getFacetIds(v),
+                        facetValueIds: this.getFacetValueIds(v),
+                    }),
+            );
+        await this.connection.getRepository(SearchIndexItem).save(items);
+    }
+
+    private async updateSearchIndex(languageCode: LanguageCode, entity: Product | ProductVariant) {
+        //
+    }
+
+    private getFacetIds(variant: ProductVariant): string[] {
+        const facetIds = (fv: FacetValue) => fv.facet.id.toString();
+        const variantFacetIds = variant.facetValues.map(facetIds);
+        const productFacetIds = variant.product.facetValues.map(facetIds);
+        return unique([...variantFacetIds, ...productFacetIds]);
+    }
+
+    private getFacetValueIds(variant: ProductVariant): string[] {
+        const facetValueIds = (fv: FacetValue) => fv.id.toString();
+        const variantFacetValueIds = variant.facetValues.map(facetValueIds);
+        const productFacetValueIds = variant.product.facetValues.map(facetValueIds);
+        return unique([...variantFacetValueIds, ...productFacetValueIds]);
+    }
+}

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

@@ -0,0 +1,30 @@
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+
+import { Permission } from '../../../../shared/generated-types';
+import { Allow } from '../../api/decorators/allow.decorator';
+import { SearchResolver as BaseSearchResolver } from '../../api/resolvers/search.resolver';
+
+import { SearchIndexItem } from './search-index-item.entity';
+
+@Resolver()
+export class FulltextSearchResolver extends BaseSearchResolver {
+    constructor(@InjectConnection() private connection: Connection) {
+        super();
+    }
+
+    @Query()
+    @Allow(Permission.Public)
+    async search(@Args() args: any) {
+        return this.connection
+            .getRepository(SearchIndexItem)
+            .createQueryBuilder('si')
+            .addSelect(`MATCH (productName) AGAINST ('${args.input.term}')`, 'score')
+            .orderBy('score', 'DESC')
+            .getMany()
+            .then(res => {
+                return res;
+            });
+    }
+}

+ 51 - 0
server/src/plugin/default-search-engine/search-index-item.entity.ts

@@ -0,0 +1,51 @@
+import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
+
+import { LanguageCode } from '../../../../shared/generated-types';
+import { ID } from '../../../../shared/shared-types';
+import { idType } from '../../config/config-helpers';
+
+@Entity()
+export class SearchIndexItem {
+    constructor(input?: Partial<SearchIndexItem>) {
+        if (input) {
+            for (const [key, value] of Object.entries(input)) {
+                this[key] = value;
+            }
+        }
+    }
+
+    @PrimaryColumn({ type: idType() })
+    productVariantId: ID;
+
+    @Column('varchar')
+    languageCode: LanguageCode;
+
+    @Column({ type: idType() })
+    productId: ID;
+
+    @Index({ fulltext: true })
+    @Column()
+    productName: string;
+
+    @Index({ fulltext: true })
+    @Column()
+    productVariantName: string;
+
+    @Index({ fulltext: true })
+    @Column('text')
+    description: string;
+
+    @Column('simple-array')
+    facetIds: string[];
+
+    @Column('simple-array')
+    facetValueIds: string[];
+
+    @Column()
+    productPreview: string;
+
+    @Column()
+    productVariantPreview: string;
+
+    score: number;
+}

+ 4 - 0
server/src/service/services/product.service.ts

@@ -12,6 +12,8 @@ import { assertFound } from '../../common/utils';
 import { ProductOptionGroup } from '../../entity/product-option-group/product-option-group.entity';
 import { ProductTranslation } from '../../entity/product/product-translation.entity';
 import { Product } from '../../entity/product/product.entity';
+import { EventBus } from '../../event-bus/event-bus';
+import { CatalogModificationEvent } from '../../event-bus/events/catalog-modification-event';
 import { AssetUpdater } from '../helpers/asset-updater/asset-updater';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
@@ -33,6 +35,7 @@ export class ProductService {
         private taxRateService: TaxRateService,
         private listQueryBuilder: ListQueryBuilder,
         private translatableSaver: TranslatableSaver,
+        private eventBus: EventBus,
     ) {}
 
     findAll(
@@ -107,6 +110,7 @@ export class ProductService {
                 await this.assetUpdater.updateEntityAssets(p, input);
             },
         });
+        this.eventBus.publish(new CatalogModificationEvent(ctx, product));
         return assertFound(this.findOne(ctx, product.id));
     }