cockroachdb-search-strategy.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { LogicalOperator, SearchInput, SearchResult } from '@vendure/common/lib/generated-types';
  2. import { ID } from '@vendure/common/lib/shared-types';
  3. import {
  4. Injector,
  5. PLUGIN_INIT_OPTIONS,
  6. RequestContext,
  7. TransactionalConnection,
  8. UserInputError,
  9. } from '@vendure/core';
  10. import { SearchIndexItem } from '@vendure/core/dist/plugin/default-search-plugin/entities/search-index-item.entity';
  11. import { SearchStrategy } from '@vendure/core/dist/plugin/default-search-plugin/search-strategy/search-strategy';
  12. import { getFieldsToSelect } from '@vendure/core/dist/plugin/default-search-plugin/search-strategy/search-strategy-common';
  13. import {
  14. applyLanguageConstraints,
  15. createCollectionIdCountMap,
  16. createFacetIdCountMap,
  17. createPlaceholderFromId,
  18. mapToSearchResult,
  19. } from '@vendure/core/dist/plugin/default-search-plugin/search-strategy/search-strategy-utils';
  20. import { DefaultSearchPluginInitOptions } from '@vendure/core/dist/plugin/default-search-plugin/types';
  21. import { Brackets, SelectQueryBuilder } from 'typeorm';
  22. /**
  23. * A weighted fulltext search for PostgeSQL.
  24. */
  25. export class CockroachdbSearchStrategy implements SearchStrategy {
  26. protected readonly minTermLength = 2;
  27. protected connection: TransactionalConnection;
  28. protected options: DefaultSearchPluginInitOptions;
  29. async init(injector: Injector) {
  30. this.connection = injector.get(TransactionalConnection);
  31. this.options = injector.get(PLUGIN_INIT_OPTIONS);
  32. }
  33. async getFacetValueIds(
  34. ctx: RequestContext,
  35. input: SearchInput,
  36. enabledOnly: boolean,
  37. ): Promise<Map<ID, number>> {
  38. const facetValuesQb = this.connection
  39. .getRepository(ctx, SearchIndexItem)
  40. .createQueryBuilder('si')
  41. .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
  42. .addSelect('string_agg("si"."facetValueIds",\',\')', 'facetValues');
  43. this.applyTermAndFilters(ctx, facetValuesQb, input, true);
  44. if (!input.groupByProduct) {
  45. facetValuesQb.groupBy('"si"."productVariantId", "si"."productId"');
  46. }
  47. if (enabledOnly) {
  48. facetValuesQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
  49. }
  50. const facetValuesResult = await facetValuesQb.getRawMany();
  51. return createFacetIdCountMap(facetValuesResult);
  52. }
  53. async getCollectionIds(
  54. ctx: RequestContext,
  55. input: SearchInput,
  56. enabledOnly: boolean,
  57. ): Promise<Map<ID, number>> {
  58. const collectionsQb = this.connection
  59. .getRepository(ctx, SearchIndexItem)
  60. .createQueryBuilder('si')
  61. .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
  62. .addSelect('string_agg("si"."collectionIds",\',\')', 'collections');
  63. this.applyTermAndFilters(ctx, collectionsQb, input, true);
  64. if (!input.groupByProduct) {
  65. collectionsQb.groupBy('"si"."productVariantId", "si"."productId"');
  66. }
  67. if (enabledOnly) {
  68. collectionsQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
  69. }
  70. const collectionsResult = await collectionsQb.getRawMany();
  71. return createCollectionIdCountMap(collectionsResult);
  72. }
  73. async getSearchResults(
  74. ctx: RequestContext,
  75. input: SearchInput,
  76. enabledOnly: boolean,
  77. ): Promise<SearchResult[]> {
  78. const take = input.take || 25;
  79. const skip = input.skip || 0;
  80. const sort = input.sort;
  81. const qb = this.connection
  82. .getRepository(ctx, SearchIndexItem)
  83. .createQueryBuilder('si')
  84. .select(this.createPostgresSelect(!!input.groupByProduct));
  85. if (input.groupByProduct) {
  86. qb.addSelect('MIN(si.price)', 'minPrice')
  87. .addSelect('MAX(si.price)', 'maxPrice')
  88. .addSelect('MIN(si.priceWithTax)', 'minPriceWithTax')
  89. .addSelect('MAX(si.priceWithTax)', 'maxPriceWithTax');
  90. }
  91. this.applyTermAndFilters(ctx, qb, input);
  92. if (sort) {
  93. if (sort.name) {
  94. qb.addOrderBy('"si_productName"', sort.name);
  95. }
  96. if (sort.price) {
  97. qb.addOrderBy('"si_price"', sort.price);
  98. }
  99. } else {
  100. if (input.term && input.term.length > this.minTermLength) {
  101. qb.addOrderBy('score', 'DESC');
  102. } else {
  103. qb.addOrderBy('"si_productVariantId"', 'ASC');
  104. }
  105. }
  106. if (enabledOnly) {
  107. qb.andWhere('"si"."enabled" = :enabled', { enabled: true });
  108. }
  109. return qb
  110. .limit(take)
  111. .offset(skip)
  112. .getRawMany()
  113. .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
  114. }
  115. async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
  116. const innerQb = this.applyTermAndFilters(
  117. ctx,
  118. this.connection
  119. .getRepository(ctx, SearchIndexItem)
  120. .createQueryBuilder('si')
  121. .select(this.createPostgresSelect(!!input.groupByProduct)),
  122. input,
  123. );
  124. if (enabledOnly) {
  125. innerQb.andWhere('"si"."enabled" = :enabled', { enabled: true });
  126. }
  127. const totalItemsQb = this.connection.rawConnection
  128. .createQueryBuilder()
  129. .select('COUNT(*) as total')
  130. .from(`(${innerQb.getQuery()})`, 'inner')
  131. .setParameters(innerQb.getParameters());
  132. return totalItemsQb.getRawOne().then(res => res.total);
  133. }
  134. protected applyTermAndFilters(
  135. ctx: RequestContext,
  136. qb: SelectQueryBuilder<SearchIndexItem>,
  137. input: SearchInput & { inStock?: boolean },
  138. forceGroup: boolean = false,
  139. ): SelectQueryBuilder<SearchIndexItem> {
  140. const { term, facetValueFilters, facetValueIds, facetValueOperator, collectionId, collectionSlug } =
  141. input;
  142. // join multiple words with the logical AND operator
  143. const termLogicalAnd = term
  144. ? term
  145. .trim()
  146. .split(/\s+/g)
  147. .map(t => `'${t}':*`)
  148. .join(' & ')
  149. : '';
  150. qb.where('1 = 1');
  151. if (term && term.length > this.minTermLength) {
  152. const minIfGrouped = (colName: string) =>
  153. input.groupByProduct || forceGroup ? `MIN(${colName})` : colName;
  154. qb.addSelect(
  155. `
  156. (ts_rank_cd(to_tsvector(${minIfGrouped('si.sku')}), to_tsquery(:term)) * 10 +
  157. ts_rank_cd(to_tsvector(${minIfGrouped('si.productName')}), to_tsquery(:term)) * 2 +
  158. ts_rank_cd(to_tsvector(${minIfGrouped(
  159. 'si.productVariantName',
  160. )}), to_tsquery(:term)) * 1.5 +
  161. ts_rank_cd(to_tsvector(${minIfGrouped('si.description')}), to_tsquery(:term)) * 1)
  162. `,
  163. 'score',
  164. )
  165. .andWhere(
  166. new Brackets(qb1 => {
  167. qb1.where('to_tsvector(si.sku) @@ to_tsquery(:term)')
  168. .orWhere('to_tsvector(si.productName) @@ to_tsquery(:term)')
  169. .orWhere('to_tsvector(si.productVariantName) @@ to_tsquery(:term)')
  170. .orWhere('to_tsvector(si.description) @@ to_tsquery(:term)');
  171. }),
  172. )
  173. .setParameters({ term: termLogicalAnd });
  174. }
  175. if (input.inStock != null) {
  176. if (input.groupByProduct) {
  177. qb.andWhere('si.productInStock = :inStock', { inStock: input.inStock });
  178. } else {
  179. qb.andWhere('si.inStock = :inStock', { inStock: input.inStock });
  180. }
  181. }
  182. if (facetValueIds?.length) {
  183. qb.andWhere(
  184. new Brackets(qb1 => {
  185. for (const id of facetValueIds) {
  186. const placeholder = createPlaceholderFromId(id);
  187. const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
  188. const params = { [placeholder]: id };
  189. if (facetValueOperator === LogicalOperator.AND) {
  190. qb1.andWhere(clause, params);
  191. } else {
  192. qb1.orWhere(clause, params);
  193. }
  194. }
  195. }),
  196. );
  197. }
  198. if (facetValueFilters?.length) {
  199. qb.andWhere(
  200. new Brackets(qb1 => {
  201. for (const facetValueFilter of facetValueFilters) {
  202. qb1.andWhere(
  203. new Brackets(qb2 => {
  204. if (facetValueFilter.and && facetValueFilter.or?.length) {
  205. throw new UserInputError('error.facetfilterinput-invalid-input');
  206. }
  207. if (facetValueFilter.and) {
  208. const placeholder = createPlaceholderFromId(facetValueFilter.and);
  209. const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
  210. const params = { [placeholder]: facetValueFilter.and };
  211. qb2.where(clause, params);
  212. }
  213. if (facetValueFilter.or?.length) {
  214. for (const id of facetValueFilter.or) {
  215. const placeholder = createPlaceholderFromId(id);
  216. const clause = `:${placeholder} = ANY (string_to_array(si.facetValueIds, ','))`;
  217. const params = { [placeholder]: id };
  218. qb2.orWhere(clause, params);
  219. }
  220. }
  221. }),
  222. );
  223. }
  224. }),
  225. );
  226. }
  227. if (collectionId) {
  228. qb.andWhere(":collectionId::varchar = ANY (string_to_array(si.collectionIds, ','))", {
  229. collectionId,
  230. });
  231. }
  232. if (collectionSlug) {
  233. qb.andWhere(":collectionSlug::varchar = ANY (string_to_array(si.collectionSlugs, ','))", {
  234. collectionSlug,
  235. });
  236. }
  237. applyLanguageConstraints(qb, ctx.languageCode, ctx.channel.defaultLanguageCode);
  238. qb.andWhere('si.channelId = :channelId', { channelId: ctx.channelId });
  239. if (input.groupByProduct === true) {
  240. qb.groupBy('si.productId');
  241. }
  242. return qb;
  243. }
  244. /**
  245. * When a select statement includes a GROUP BY clause,
  246. * then all selected columns must be aggregated. So we just apply the
  247. * "MIN" function in this case to all other columns than the productId.
  248. */
  249. private createPostgresSelect(groupByProduct: boolean): string {
  250. return getFieldsToSelect(this.options.indexStockStatus)
  251. .map(col => {
  252. const qualifiedName = `si.${col}`;
  253. const alias = `si_${col}`;
  254. if (groupByProduct && col !== 'productId') {
  255. if (
  256. col === 'facetIds' ||
  257. col === 'facetValueIds' ||
  258. col === 'collectionIds' ||
  259. col === 'channelIds'
  260. ) {
  261. return `string_agg(${qualifiedName}, ',') as "${alias}"`;
  262. } else if (col === 'enabled' || col === 'inStock' || col === 'productInStock') {
  263. return `bool_or(${qualifiedName}) as "${alias}"`;
  264. } else {
  265. return `MIN(${qualifiedName}) as "${alias}"`;
  266. }
  267. } else {
  268. return `${qualifiedName} as "${alias}"`;
  269. }
  270. })
  271. .join(', ');
  272. }
  273. }