| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
- import { ID } from '@vendure/common/lib/shared-types';
- import {
- Injector,
- PLUGIN_INIT_OPTIONS,
- RequestContext,
- TransactionalConnection,
- UserInputError,
- } from '@vendure/core';
- import { SearchIndexItem } from '@vendure/core/dist/plugin/default-search-plugin/entities/search-index-item.entity';
- import { SearchStrategy } from '@vendure/core/dist/plugin/default-search-plugin/search-strategy/search-strategy';
- import { getFieldsToSelect } from '@vendure/core/dist/plugin/default-search-plugin/search-strategy/search-strategy-common';
- import {
- applyLanguageConstraints,
- createCollectionIdCountMap,
- createFacetIdCountMap,
- createPlaceholderFromId,
- mapToSearchResult,
- } from '@vendure/core/dist/plugin/default-search-plugin/search-strategy/search-strategy-utils';
- import { DefaultSearchPluginInitOptions } from '@vendure/core/dist/plugin/default-search-plugin/types';
- import { Brackets, SelectQueryBuilder } from 'typeorm';
- /**
- * A weighted fulltext search for PostgeSQL.
- */
- export class CockroachdbSearchStrategy implements SearchStrategy {
- protected readonly minTermLength = 2;
- protected connection: TransactionalConnection;
- protected 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>> {
- const facetValuesQb = this.connection
- .getRepository(ctx, SearchIndexItem)
- .createQueryBuilder('si')
- .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
- .addSelect('string_agg("si"."facetValueIds",\',\')', 'facetValues');
- this.applyTermAndFilters(ctx, facetValuesQb, input, true);
- if (!input.groupByProduct) {
- facetValuesQb.groupBy('"si"."productVariantId", "si"."productId"');
- }
- if (enabledOnly) {
- facetValuesQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
- }
- const facetValuesResult = await facetValuesQb.getRawMany();
- return createFacetIdCountMap(facetValuesResult);
- }
- async getCollectionIds(
- ctx: RequestContext,
- input: SearchInput,
- enabledOnly: boolean,
- ): Promise<Map<ID, number>> {
- const collectionsQb = this.connection
- .getRepository(ctx, SearchIndexItem)
- .createQueryBuilder('si')
- .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
- .addSelect('string_agg("si"."collectionIds",\',\')', 'collections');
- this.applyTermAndFilters(ctx, collectionsQb, input, true);
- if (!input.groupByProduct) {
- collectionsQb.groupBy('"si"."productVariantId", "si"."productId"');
- }
- if (enabledOnly) {
- collectionsQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
- }
- const collectionsResult = await collectionsQb.getRawMany();
- 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(ctx, SearchIndexItem)
- .createQueryBuilder('si')
- .select(this.createPostgresSelect(!!input.groupByProduct));
- if (input.groupByProduct) {
- qb.addSelect('MIN(si.price)', 'minPrice')
- .addSelect('MAX(si.price)', 'maxPrice')
- .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
- .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
- }
- this.applyTermAndFilters(ctx, qb, input);
- if (sort) {
- if (sort.name) {
- qb.addOrderBy('"si_productName"', sort.name);
- }
- if (sort.price) {
- qb.addOrderBy('"si_price"', sort.price);
- }
- } else {
- if (input.term && input.term.length > this.minTermLength) {
- qb.addOrderBy('score', 'DESC');
- } else {
- qb.addOrderBy('"si_productVariantId"', 'ASC');
- }
- }
- if (enabledOnly) {
- qb.andWhere('"si"."enabled" = :enabled', { enabled: true });
- }
- return qb
- .limit(take)
- .offset(skip)
- .getRawMany()
- .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
- }
- async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
- const innerQb = this.applyTermAndFilters(
- ctx,
- this.connection
- .getRepository(ctx, SearchIndexItem)
- .createQueryBuilder('si')
- .select(this.createPostgresSelect(!!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);
- }
- protected applyTermAndFilters(
- ctx: RequestContext,
- qb: SelectQueryBuilder<SearchIndexItem>,
- input: SearchInput & { inStock?: boolean },
- forceGroup: boolean = false,
- ): SelectQueryBuilder<SearchIndexItem> {
- const { term, facetValueFilters, facetValueIds, facetValueOperator, collectionId, collectionSlug } =
- input;
- // join multiple words with the logical AND operator
- const termLogicalAnd = term
- ? term
- .trim()
- .split(/\s+/g)
- .map(t => `'${t}':*`)
- .join(' & ')
- : '';
- qb.where('1 = 1');
- if (term && term.length > this.minTermLength) {
- const minIfGrouped = (colName: string) =>
- input.groupByProduct || forceGroup ? `MIN(${colName})` : colName;
- qb.addSelect(
- `
- (ts_rank_cd(to_tsvector(${minIfGrouped('si.sku')}), to_tsquery(:term)) * 10 +
- ts_rank_cd(to_tsvector(${minIfGrouped('si.productName')}), to_tsquery(:term)) * 2 +
- ts_rank_cd(to_tsvector(${minIfGrouped(
- 'si.productVariantName',
- )}), to_tsquery(:term)) * 1.5 +
- ts_rank_cd(to_tsvector(${minIfGrouped('si.description')}), to_tsquery(:term)) * 1)
- `,
- 'score',
- )
- .andWhere(
- new Brackets(qb1 => {
- qb1.where('to_tsvector(si.sku) @@ to_tsquery(:term)')
- .orWhere('to_tsvector(si.productName) @@ to_tsquery(:term)')
- .orWhere('to_tsvector(si.productVariantName) @@ to_tsquery(:term)')
- .orWhere('to_tsvector(si.description) @@ to_tsquery(:term)');
- }),
- )
- .setParameters({ term: termLogicalAnd });
- }
- if (input.inStock != null) {
- if (input.groupByProduct) {
- qb.andWhere('si.productInStock = :inStock', { inStock: input.inStock });
- } else {
- qb.andWhere('si.inStock = :inStock', { inStock: input.inStock });
- }
- }
- if (facetValueIds?.length) {
- qb.andWhere(
- new Brackets(qb1 => {
- for (const id of facetValueIds) {
- const placeholder = createPlaceholderFromId(id);
- const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
- const params = { [placeholder]: id };
- if (facetValueOperator === LogicalOperator.AND) {
- qb1.andWhere(clause, params);
- } else {
- qb1.orWhere(clause, params);
- }
- }
- }),
- );
- }
- if (facetValueFilters?.length) {
- qb.andWhere(
- new Brackets(qb1 => {
- for (const facetValueFilter of facetValueFilters) {
- qb1.andWhere(
- new Brackets(qb2 => {
- if (facetValueFilter.and && facetValueFilter.or?.length) {
- throw new UserInputError('error.facetfilterinput-invalid-input');
- }
- if (facetValueFilter.and) {
- const placeholder = createPlaceholderFromId(facetValueFilter.and);
- const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
- const params = { [placeholder]: facetValueFilter.and };
- qb2.where(clause, params);
- }
- if (facetValueFilter.or?.length) {
- for (const id of facetValueFilter.or) {
- const placeholder = createPlaceholderFromId(id);
- const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
- const params = { [placeholder]: id };
- qb2.orWhere(clause, params);
- }
- }
- }),
- );
- }
- }),
- );
- }
- if (collectionId) {
- qb.andWhere(":collectionId::varchar = ANY (string_to_array(si.collectionIds, ','))", {
- collectionId,
- });
- }
- if (collectionSlug) {
- qb.andWhere(":collectionSlug::varchar = ANY (string_to_array(si.collectionSlugs, ','))", {
- collectionSlug,
- });
- }
- applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
- qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
- if (input.groupByProduct === true) {
- qb.groupBy('si.productId');
- }
- return qb;
- }
- /**
- * When a select statement includes a GROUP BY clause,
- * then all selected columns must be aggregated. So we just apply the
- * "MIN" function in this case to all other columns than the productId.
- */
- private createPostgresSelect(groupByProduct: boolean): string {
- return getFieldsToSelect(this.options.indexStockStatus)
- .map(col => {
- const qualifiedName = `si.${col}`;
- const alias = `si_${col}`;
- if (groupByProduct && col !== 'productId') {
- if (
- col === 'facetIds' ||
- col === 'facetValueIds' ||
- col === 'collectionIds' ||
- col === 'channelIds'
- ) {
- return `string_agg(${qualifiedName}, ',') as "${alias}"`;
- } else if (col === 'enabled' || col === 'inStock' || col === 'productInStock') {
- return `bool_or(${qualifiedName}) as "${alias}"`;
- } else {
- return `MIN(${qualifiedName}) as "${alias}"`;
- }
- } else {
- return `${qualifiedName} as "${alias}"`;
- }
- })
- .join(', ');
- }
- }
|