postgres-search-strategy.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { Brackets, Connection, SelectQueryBuilder } from 'typeorm';
  2. import { SearchInput, SearchResult } from '../../../../../shared/generated-types';
  3. import { ID } from '../../../../../shared/shared-types';
  4. import { unique } from '../../../../../shared/unique';
  5. import { RequestContext } from '../../../api/common/request-context';
  6. import { SearchIndexItem } from '../search-index-item.entity';
  7. import { SearchStrategy } from './search-strategy';
  8. import { createFacetIdCountMap, mapToSearchResult } from './search-strategy-utils';
  9. /**
  10. * A weighted fulltext search for PostgeSQL.
  11. */
  12. export class PostgresSearchStrategy implements SearchStrategy {
  13. private readonly minTermLength = 2;
  14. constructor(private connection: Connection) {}
  15. async getFacetValueIds(ctx: RequestContext, input: SearchInput): Promise<Map<ID, number>> {
  16. const facetValuesQb = this.connection
  17. .getRepository(SearchIndexItem)
  18. .createQueryBuilder('si')
  19. .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
  20. .addSelect(`string_agg("si"."facetValueIds",',')`, 'facetValues');
  21. this.applyTermAndFilters(facetValuesQb, input, true);
  22. if (!input.groupByProduct) {
  23. facetValuesQb.groupBy('"si"."productVariantId", "si"."productId"');
  24. }
  25. const facetValuesResult = await facetValuesQb.getRawMany();
  26. return createFacetIdCountMap(facetValuesResult);
  27. }
  28. async getSearchResults(ctx: RequestContext, input: SearchInput): Promise<SearchResult[]> {
  29. const take = input.take || 25;
  30. const skip = input.skip || 0;
  31. const sort = input.sort;
  32. const qb = this.connection
  33. .getRepository(SearchIndexItem)
  34. .createQueryBuilder('si')
  35. .select(this.createPostgresSelect(!!input.groupByProduct));
  36. if (input.groupByProduct) {
  37. qb.addSelect('MIN(price)', 'minPrice')
  38. .addSelect('MAX(price)', 'maxPrice')
  39. .addSelect('MIN("priceWithTax")', 'minPriceWithTax')
  40. .addSelect('MAX("priceWithTax")', 'maxPriceWithTax');
  41. }
  42. this.applyTermAndFilters(qb, input);
  43. if (input.term && input.term.length > this.minTermLength) {
  44. qb.orderBy('score', 'DESC');
  45. }
  46. if (sort) {
  47. if (sort.name) {
  48. qb.addOrderBy('"si_productName"', sort.name);
  49. }
  50. if (sort.price) {
  51. qb.addOrderBy('"si_price"', sort.price);
  52. }
  53. }
  54. return qb
  55. .take(take)
  56. .skip(skip)
  57. .getRawMany()
  58. .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
  59. }
  60. async getTotalCount(ctx: RequestContext, input: SearchInput): Promise<number> {
  61. const innerQb = this.applyTermAndFilters(
  62. this.connection
  63. .getRepository(SearchIndexItem)
  64. .createQueryBuilder('si')
  65. .select(this.createPostgresSelect(!!input.groupByProduct)),
  66. input,
  67. );
  68. const totalItemsQb = this.connection
  69. .createQueryBuilder()
  70. .select('COUNT(*) as total')
  71. .from(`(${innerQb.getQuery()})`, 'inner')
  72. .setParameters(innerQb.getParameters());
  73. return totalItemsQb.getRawOne().then(res => res.total);
  74. }
  75. private applyTermAndFilters(
  76. qb: SelectQueryBuilder<SearchIndexItem>,
  77. input: SearchInput,
  78. forceGroup: boolean = false,
  79. ): SelectQueryBuilder<SearchIndexItem> {
  80. const { term, facetIds, collectionId } = input;
  81. // join multiple words with the logical AND operator
  82. const termLogicalAnd = term ? term.trim().replace(/\s+/, ' & ') : '';
  83. qb.where('1 = 1');
  84. if (term && term.length > this.minTermLength) {
  85. const minIfGrouped = (colName: string) =>
  86. input.groupByProduct || forceGroup ? `MIN(${colName})` : colName;
  87. qb.addSelect(
  88. `
  89. (ts_rank_cd(to_tsvector(${minIfGrouped('si.sku')}), to_tsquery(:term)) * 10 +
  90. ts_rank_cd(to_tsvector(${minIfGrouped('si.productName')}), to_tsquery(:term)) * 2 +
  91. ts_rank_cd(to_tsvector(${minIfGrouped(
  92. 'si.productVariantName',
  93. )}), to_tsquery(:term)) * 1.5 +
  94. ts_rank_cd(to_tsvector(${minIfGrouped('si.description')}), to_tsquery(:term)) * 1)
  95. `,
  96. 'score',
  97. )
  98. .andWhere(
  99. new Brackets(qb1 => {
  100. qb1.where('to_tsvector(si.sku) @@ to_tsquery(:term)')
  101. .orWhere('to_tsvector(si.productName) @@ to_tsquery(:term)')
  102. .orWhere('to_tsvector(si.productVariantName) @@ to_tsquery(:term)')
  103. .orWhere('to_tsvector(si.description) @@ to_tsquery(:term)');
  104. }),
  105. )
  106. .setParameters({ term: termLogicalAnd });
  107. }
  108. if (facetIds) {
  109. for (const id of facetIds) {
  110. const placeholder = '_' + id;
  111. qb.andWhere(`:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`, {
  112. [placeholder]: id,
  113. });
  114. }
  115. }
  116. if (collectionId) {
  117. qb.andWhere(`:collectionId = ANY (string_to_array(si.collectionIds, ','))`, { collectionId });
  118. }
  119. if (input.groupByProduct === true) {
  120. qb.groupBy('si.productId');
  121. }
  122. return qb;
  123. }
  124. /**
  125. * When a select statement includes a GROUP BY clause,
  126. * then all selected columns must be aggregated. So we just apply the
  127. * "MIN" function in this case to all other columns than the productId.
  128. */
  129. private createPostgresSelect(groupByProduct: boolean): string {
  130. return [
  131. 'sku',
  132. 'slug',
  133. 'price',
  134. 'priceWithTax',
  135. 'productVariantId',
  136. 'languageCode',
  137. 'productId',
  138. 'productName',
  139. 'productVariantName',
  140. 'description',
  141. 'facetIds',
  142. 'facetValueIds',
  143. 'collectionIds',
  144. 'productPreview',
  145. 'productVariantPreview',
  146. ]
  147. .map(col => {
  148. const qualifiedName = `si.${col}`;
  149. const alias = `si_${col}`;
  150. if (groupByProduct && col !== 'productId') {
  151. if (col === 'facetIds' || col === 'facetValueIds' || col === 'collectionIds') {
  152. return `string_agg(${qualifiedName}, ',') as "${alias}"`;
  153. } else {
  154. return `MIN(${qualifiedName}) as "${alias}"`;
  155. }
  156. } else {
  157. return `${qualifiedName} as "${alias}"`;
  158. }
  159. })
  160. .join(', ');
  161. }
  162. }