options.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. import { ClientOptions } from '@elastic/elasticsearch';
  2. import { DeepRequired, EntityRelationPaths, ID, LanguageCode, Product, ProductVariant } from '@vendure/core';
  3. import deepmerge from 'deepmerge';
  4. import {
  5. CustomMapping,
  6. CustomScriptMapping,
  7. ElasticSearchInput,
  8. ElasticSearchSortInput,
  9. ElasticSearchSortParameter,
  10. GraphQlPrimitive,
  11. PrimitiveTypeVariations,
  12. } from './types';
  13. /**
  14. * @description
  15. * Configuration options for the {@link ElasticsearchPlugin}.
  16. *
  17. * @docsCategory ElasticsearchPlugin
  18. * @docsPage ElasticsearchOptions
  19. */
  20. export interface ElasticsearchOptions {
  21. /**
  22. * @description
  23. * The host of the Elasticsearch server. May also be specified in `clientOptions.node`.
  24. *
  25. * @default 'http://localhost'
  26. */
  27. host?: string;
  28. /**
  29. * @description
  30. * The port of the Elasticsearch server. May also be specified in `clientOptions.node`.
  31. *
  32. * @default 9200
  33. */
  34. port?: number;
  35. /**
  36. * @description
  37. * Maximum amount of attempts made to connect to the ElasticSearch server on startup.
  38. *
  39. * @default 10
  40. */
  41. connectionAttempts?: number;
  42. /**
  43. * @description
  44. * Interval in milliseconds between attempts to connect to the ElasticSearch server on startup.
  45. *
  46. * @default 5000
  47. */
  48. connectionAttemptInterval?: number;
  49. /**
  50. * @description
  51. * Options to pass directly to the
  52. * [Elasticsearch Node.js client](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html). For example, to
  53. * set authentication or other more advanced options.
  54. * Note that if the `node` or `nodes` option is specified, it will override the values provided in the `host` and `port` options.
  55. */
  56. clientOptions?: ClientOptions;
  57. /**
  58. * @description
  59. * Prefix for the indices created by the plugin.
  60. *
  61. * @default
  62. * 'vendure-'
  63. */
  64. indexPrefix?: string;
  65. /**
  66. * @description
  67. * [These options](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/index-modules.html#index-modules-settings)
  68. * are directly passed to index settings. To apply some settings indices will be recreated.
  69. *
  70. * @example
  71. * ```TypeScript
  72. * // Configuring an English stemmer
  73. * indexSettings: {
  74. * analysis: {
  75. * analyzer: {
  76. * custom_analyzer: {
  77. * tokenizer: 'standard',
  78. * filter: [
  79. * 'lowercase',
  80. * 'english_stemmer'
  81. * ]
  82. * }
  83. * },
  84. * filter : {
  85. * english_stemmer : {
  86. * type : 'stemmer',
  87. * name : 'english'
  88. * }
  89. * }
  90. * }
  91. * },
  92. * ```
  93. *
  94. * @since 1.2.0
  95. * @default
  96. * {}
  97. */
  98. indexSettings?: object;
  99. /**
  100. * @description
  101. * This option allow to redefine or define new properties in mapping. More about elastic
  102. * [mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)
  103. * After changing this option indices will be recreated.
  104. *
  105. * @example
  106. * ```TypeScript
  107. * // Configuring custom analyzer for the `productName` field.
  108. * indexMappingProperties: {
  109. * productName: {
  110. * type: 'text',
  111. * analyzer:'custom_analyzer',
  112. * fields: {
  113. * keyword: {
  114. * type: 'keyword',
  115. * ignore_above: 256,
  116. * }
  117. * }
  118. * }
  119. * }
  120. * ```
  121. *
  122. * To reference a field defined by `customProductMappings` or `customProductVariantMappings`, you will
  123. * need to prefix the name with `'product-<name>'` or `'variant-<name>'` respectively, e.g.:
  124. *
  125. * @example
  126. * ```TypeScript
  127. * customProductMappings: {
  128. * variantCount: {
  129. * graphQlType: 'Int!',
  130. * valueFn: (product, variants) => variants.length,
  131. * },
  132. * },
  133. * indexMappingProperties: {
  134. * 'product-variantCount': {
  135. * type: 'integer',
  136. * }
  137. * }
  138. * ```
  139. *
  140. * @since 1.2.0
  141. * @default
  142. * {}
  143. */
  144. indexMappingProperties?: {
  145. [indexName: string]: object;
  146. };
  147. /**
  148. * @description
  149. * Batch size for bulk operations (e.g. when rebuilding the indices).
  150. *
  151. * @default
  152. * 2000
  153. */
  154. batchSize?: number;
  155. /**
  156. * @description
  157. * Configuration of the internal Elasticsearch query.
  158. */
  159. searchConfig?: SearchConfig;
  160. /**
  161. * @description
  162. * Custom mappings may be defined which will add the defined data to the
  163. * Elasticsearch index and expose that data via the SearchResult GraphQL type,
  164. * adding a new `customMappings` field.
  165. *
  166. * The `graphQlType` property may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
  167. * versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
  168. *
  169. * The `public` (default = `true`) property is used to reveal or hide the property in the GraphQL API schema.
  170. * If this property is set to `false` it's not accessible in the `customMappings` field but it's still getting
  171. * parsed to the elasticsearch index.
  172. *
  173. * This config option defines custom mappings which are accessible when the "groupByProduct"
  174. * input options is set to `true`.
  175. *
  176. * @example
  177. * ```TypeScript
  178. * customProductMappings: {
  179. * variantCount: {
  180. * graphQlType: 'Int!',
  181. * valueFn: (product, variants) => variants.length,
  182. * },
  183. * reviewRating: {
  184. * graphQlType: 'Float',
  185. * public: true,
  186. * valueFn: product => (product.customFields as any).reviewRating,
  187. * },
  188. * priority: {
  189. * graphQlType: 'Int!',
  190. * public: false,
  191. * valueFn: product => (product.customFields as any).priority,
  192. * },
  193. * }
  194. * ```
  195. *
  196. * @example
  197. * ```SDL
  198. * query SearchProducts($input: SearchInput!) {
  199. * search(input: $input) {
  200. * totalItems
  201. * items {
  202. * productId
  203. * productName
  204. * customMappings {
  205. * ...on CustomProductMappings {
  206. * variantCount
  207. * reviewRating
  208. * }
  209. * }
  210. * }
  211. * }
  212. * }
  213. * ```
  214. */
  215. customProductMappings?: {
  216. [fieldName: string]: CustomMapping<[Product, ProductVariant[], LanguageCode]>;
  217. };
  218. /**
  219. * @description
  220. * This config option defines custom mappings which are accessible when the "groupByProduct"
  221. * input options is set to `false`.
  222. *
  223. * @example
  224. * ```SDL
  225. * query SearchProducts($input: SearchInput!) {
  226. * search(input: $input) {
  227. * totalItems
  228. * items {
  229. * productId
  230. * productName
  231. * customMappings {
  232. * ...on CustomProductVariantMappings {
  233. * weight
  234. * }
  235. * }
  236. * }
  237. * }
  238. * }
  239. * ```
  240. */
  241. customProductVariantMappings?: {
  242. [fieldName: string]: CustomMapping<[ProductVariant, LanguageCode]>;
  243. };
  244. /**
  245. * @description
  246. * If set to `true`, updates to Products, ProductVariants and Collections will not immediately
  247. * trigger an update to the search index. Instead, all these changes will be buffered and will
  248. * only be run via a call to the `runPendingSearchIndexUpdates` mutation in the Admin API.
  249. *
  250. * This is very useful for installations with a large number of ProductVariants and/or
  251. * Collections, as the buffering allows better control over when these expensive jobs are run,
  252. * and also performs optimizations to minimize the amount of work that needs to be performed by
  253. * the worker.
  254. *
  255. * @since 1.3.0
  256. * @default false
  257. */
  258. bufferUpdates?: boolean;
  259. /**
  260. * @description
  261. * Additional product relations that will be fetched from DB while reindexing. This can be used
  262. * in combination with `customProductMappings` to ensure that the required relations are joined
  263. * before the `product` object is passed to the `valueFn`.
  264. *
  265. * @example
  266. * ```TypeScript
  267. * {
  268. * hydrateProductRelations: ['assets.asset'],
  269. * customProductMappings: {
  270. * assetPreviews: {
  271. * graphQlType: '[String!]',
  272. * // Here we can be sure that the `product.assets` array is populated
  273. * // with an Asset object
  274. * valueFn: (product) => product.assets.map(asset => asset.preview),
  275. * }
  276. * }
  277. * }
  278. * ```
  279. *
  280. * @default []
  281. * @since 1.3.0
  282. */
  283. hydrateProductRelations?: Array<EntityRelationPaths<Product>>;
  284. /**
  285. * @description
  286. * Additional variant relations that will be fetched from DB while reindexing. See
  287. * `hydrateProductRelations` for more explanation and a usage example.
  288. *
  289. * @default []
  290. * @since 1.3.0
  291. */
  292. hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
  293. /**
  294. * @description
  295. * Allows the `SearchInput` type to be extended with new input fields. This allows arbitrary
  296. * data to be passed in, which can then be used e.g. in the `mapQuery()` function or
  297. * custom `scriptFields` functions.
  298. *
  299. * @example
  300. * ```TypeScript
  301. * extendSearchInputType: {
  302. * longitude: 'Float',
  303. * latitude: 'Float',
  304. * radius: 'Float',
  305. * }
  306. * ```
  307. *
  308. * This allows the search query to include these new fields:
  309. *
  310. * @example
  311. * ```GraphQl
  312. * query {
  313. * search(input: {
  314. * longitude: 101.7117,
  315. * latitude: 3.1584,
  316. * radius: 50.00
  317. * }) {
  318. * items {
  319. * productName
  320. * }
  321. * }
  322. * }
  323. * ```
  324. *
  325. * @default {}
  326. * @since 1.3.0
  327. */
  328. extendSearchInputType?: {
  329. [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
  330. };
  331. /**
  332. * @description
  333. * Adds a list of sort parameters. This is mostly important to make the
  334. * correct sort order values available inside `input` parameter of the `mapSort` option.
  335. *
  336. * @example
  337. * ```TypeScript
  338. * extendSearchSortType: ["distance"]
  339. * ```
  340. *
  341. * will extend the `SearchResultSortParameter` input type like this:
  342. *
  343. * @example
  344. * ```GraphQl
  345. * extend input SearchResultSortParameter {
  346. * distance: SortOrder
  347. * }
  348. * ```
  349. *
  350. * @default []
  351. * @since 1.4.0
  352. */
  353. extendSearchSortType?: string[];
  354. }
  355. /**
  356. * @description
  357. * Configuration options for the internal Elasticsearch query which is generated when performing a search.
  358. *
  359. * @docsCategory ElasticsearchPlugin
  360. * @docsPage ElasticsearchOptions
  361. */
  362. export interface SearchConfig {
  363. /**
  364. * @description
  365. * The maximum number of FacetValues to return from the search query. Internally, this
  366. * value sets the "size" property of an Elasticsearch aggregation.
  367. *
  368. * @default
  369. * 50
  370. */
  371. facetValueMaxSize?: number;
  372. /**
  373. * @description
  374. * The maximum number of Collections to return from the search query. Internally, this
  375. * value sets the "size" property of an Elasticsearch aggregation.
  376. *
  377. * @since 1.1.0
  378. * @default
  379. * 50
  380. */
  381. collectionMaxSize?: number;
  382. /**
  383. * @description
  384. * The maximum number of totalItems to return from the search query. Internally, this
  385. * value sets the "track_total_hits" property of an Elasticsearch query.
  386. * If this parameter is set to "True", accurate count of totalItems will be returned.
  387. * If this parameter is set to "False", totalItems will be returned as 0.
  388. * If this parameter is set to integer, accurate count of totalItems will be returned not bigger than integer.
  389. *
  390. * @since 1.2.0
  391. * @default
  392. * 10000
  393. */
  394. totalItemsMaxSize?: number | boolean;
  395. // prettier-ignore
  396. /**
  397. * @description
  398. * Defines the
  399. * [multi match type](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types)
  400. * used when matching against a search term.
  401. *
  402. * @default
  403. * 'best_fields'
  404. */
  405. multiMatchType?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix';
  406. /**
  407. * @description
  408. * Set custom boost values for particular fields when matching against a search term.
  409. */
  410. boostFields?: BoostFieldsConfig;
  411. /**
  412. * @description
  413. * The interval used to group search results into buckets according to price range. For example, setting this to
  414. * `2000` will group into buckets every $20.00:
  415. *
  416. * ```JSON
  417. * {
  418. * "data": {
  419. * "search": {
  420. * "totalItems": 32,
  421. * "priceRange": {
  422. * "buckets": [
  423. * {
  424. * "to": 2000,
  425. * "count": 21
  426. * },
  427. * {
  428. * "to": 4000,
  429. * "count": 7
  430. * },
  431. * {
  432. * "to": 6000,
  433. * "count": 3
  434. * },
  435. * {
  436. * "to": 12000,
  437. * "count": 1
  438. * }
  439. * ]
  440. * }
  441. * }
  442. * }
  443. * }
  444. * ```
  445. */
  446. priceRangeBucketInterval?: number;
  447. /**
  448. * @description
  449. * This config option allows the the modification of the whole (already built) search query. This allows
  450. * for e.g. wildcard / fuzzy searches on the index.
  451. *
  452. * @example
  453. * ```TypeScript
  454. * mapQuery: (query, input, searchConfig, channelId, enabledOnly){
  455. * if(query.bool.must){
  456. * delete query.bool.must;
  457. * }
  458. * query.bool.should = [
  459. * {
  460. * query_string: {
  461. * query: "*" + term + "*",
  462. * fields: [
  463. * `productName^${searchConfig.boostFields.productName}`,
  464. * `productVariantName^${searchConfig.boostFields.productVariantName}`,
  465. * ]
  466. * }
  467. * },
  468. * {
  469. * multi_match: {
  470. * query: term,
  471. * type: searchConfig.multiMatchType,
  472. * fields: [
  473. * `description^${searchConfig.boostFields.description}`,
  474. * `sku^${searchConfig.boostFields.sku}`,
  475. * ],
  476. * },
  477. * },
  478. * ];
  479. *
  480. * return query;
  481. * }
  482. * ```
  483. */
  484. mapQuery?: (
  485. query: any,
  486. input: ElasticSearchInput,
  487. searchConfig: DeepRequired<SearchConfig>,
  488. channelId: ID,
  489. enabledOnly: boolean,
  490. ) => any;
  491. /**
  492. * @description
  493. * Sets `script_fields` inside the elasticsearch body which allows returning a script evaluation for each hit.
  494. *
  495. * The script field definition consists of three properties:
  496. *
  497. * * `graphQlType`: This is the type that will be returned when this script field is queried
  498. * via the GraphQL API. It may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
  499. * versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
  500. * * `context`: determines whether this script field is available when grouping by product. Can be
  501. * `product`, `variant` or `both`.
  502. * * `scriptFn`: This is the function to run on each hit. Should return an object with a `script` property,
  503. * as covered in the
  504. * [Elasticsearch script fields docs](https://www.elastic.co/guide/en/elasticsearch/reference/7.15/search-fields.html#script-fields)
  505. *
  506. * @example
  507. * ```TypeScript
  508. * extendSearchInputType: {
  509. * latitude: 'Float',
  510. * longitude: 'Float',
  511. * },
  512. * indexMappingProperties: {
  513. * // The `product-location` field corresponds to the `location` customProductMapping
  514. * // defined below. Here we specify that it would be index as a `geo_point` type,
  515. * // which will allow us to perform geo-spacial calculations on it in our script field.
  516. * 'product-location': {
  517. * type: 'geo_point', // contains function arcDistance
  518. * },
  519. * },
  520. * customProductMappings: {
  521. * location: {
  522. * graphQlType: 'String',
  523. * valueFn: (product: Product) => {
  524. * // Assume that the Product entity has this customField defined
  525. * const custom = product.customFields.location;
  526. * return `${custom.latitude},${custom.longitude}`;
  527. * },
  528. * }
  529. * },
  530. * searchConfig: {
  531. * scriptFields: {
  532. * distance: {
  533. * graphQlType: 'Float!',
  534. * // Run this script only when grouping results by product
  535. * context: 'product',
  536. * scriptFn: (input) => {
  537. * // The SearchInput was extended with latitude and longitude
  538. * // via the `extendSearchInputType` option above.
  539. * const lat = input.latitude;
  540. * const lon = input.longitude;
  541. * return {
  542. * script: `doc['product-location'].arcDistance(${lat}, ${lon})`,
  543. * }
  544. * }
  545. * }
  546. * }
  547. * }
  548. * ```
  549. *
  550. * @since 1.3.0
  551. */
  552. scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> };
  553. /**
  554. * @description
  555. * Allows extending the `sort` input of the elasticsearch body as covered in
  556. * [Elasticsearch sort docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
  557. *
  558. * @example
  559. * ```TS
  560. * mapSort: (sort, input) => {
  561. * // Assuming `extendSearchSortType: ["priority"]`
  562. * // Assuming priority is never undefined
  563. * const { priority } = input.sort;
  564. * return [
  565. * ...sort,
  566. * {
  567. * // The `product-priority` field corresponds to the `priority` customProductMapping
  568. * // Depending on the index type, this field might require a
  569. * // more detailed input (example: 'productName.keyword')
  570. * ["product-priority"]: {
  571. * order: priority === SortOrder.ASC ? 'asc' : 'desc'
  572. * }
  573. * }
  574. * ];
  575. * }
  576. * ```
  577. *
  578. * A more generic example would be a sort function based on a product location like this:
  579. * @example
  580. * ```TS
  581. * extendSearchInputType: {
  582. * latitude: 'Float',
  583. * longitude: 'Float',
  584. * },
  585. * extendSearchSortType: ["distance"],
  586. * indexMappingProperties: {
  587. * // The `product-location` field corresponds to the `location` customProductMapping
  588. * // defined below. Here we specify that it would be index as a `geo_point` type,
  589. * // which will allow us to perform geo-spacial calculations on it in our script field.
  590. * 'product-location': {
  591. * type: 'geo_point',
  592. * },
  593. * },
  594. * customProductMappings: {
  595. * location: {
  596. * graphQlType: 'String',
  597. * valueFn: (product: Product) => {
  598. * // Assume that the Product entity has this customField defined
  599. * const custom = product.customFields.location;
  600. * return `${custom.latitude},${custom.longitude}`;
  601. * },
  602. * }
  603. * },
  604. * searchConfig: {
  605. * mapSort: (sort, input) => {
  606. * // Assuming distance is never undefined
  607. * const { distance } = input.sort;
  608. * return [
  609. * ...sort,
  610. * {
  611. * ["_geo_distance"]: {
  612. * "product-location": [
  613. * input.longitude,
  614. * input.latitude
  615. * ],
  616. * order: distance === SortOrder.ASC ? 'asc' : 'desc',
  617. * unit: "km"
  618. * }
  619. * }
  620. * ];
  621. * }
  622. * }
  623. * ```
  624. *
  625. * @default {}
  626. * @since 1.4.0
  627. */
  628. mapSort?: (sort: ElasticSearchSortInput, input: ElasticSearchInput) => ElasticSearchSortInput;
  629. }
  630. /**
  631. * @description
  632. * Configuration for [boosting](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#field-boost)
  633. * the scores of given fields when performing a search against a term.
  634. *
  635. * Boosting a field acts as a score multiplier for matches against that field.
  636. *
  637. * @docsCategory ElasticsearchPlugin
  638. * @docsPage ElasticsearchOptions
  639. */
  640. export interface BoostFieldsConfig {
  641. /**
  642. * @description
  643. * Defines the boost factor for the productName field.
  644. *
  645. * @default 1
  646. */
  647. productName?: number;
  648. /**
  649. * @description
  650. * Defines the boost factor for the productVariantName field.
  651. *
  652. * @default 1
  653. */
  654. productVariantName?: number;
  655. /**
  656. * @description
  657. * Defines the boost factor for the description field.
  658. *
  659. * @default 1
  660. */
  661. description?: number;
  662. /**
  663. * @description
  664. * Defines the boost factor for the sku field.
  665. *
  666. * @default 1
  667. */
  668. sku?: number;
  669. }
  670. export type ElasticsearchRuntimeOptions = DeepRequired<Omit<ElasticsearchOptions, 'clientOptions'>> & {
  671. clientOptions?: ClientOptions;
  672. };
  673. export const defaultOptions: ElasticsearchRuntimeOptions = {
  674. host: 'http://localhost',
  675. port: 9200,
  676. connectionAttempts: 10,
  677. connectionAttemptInterval: 5000,
  678. indexPrefix: 'vendure-',
  679. indexSettings: {},
  680. indexMappingProperties: {},
  681. batchSize: 2000,
  682. searchConfig: {
  683. facetValueMaxSize: 50,
  684. collectionMaxSize: 50,
  685. totalItemsMaxSize: 10000,
  686. multiMatchType: 'best_fields',
  687. boostFields: {
  688. productName: 1,
  689. productVariantName: 1,
  690. description: 1,
  691. sku: 1,
  692. },
  693. priceRangeBucketInterval: 1000,
  694. mapQuery: query => query,
  695. mapSort: sort => sort,
  696. scriptFields: {},
  697. },
  698. customProductMappings: {},
  699. customProductVariantMappings: {},
  700. bufferUpdates: false,
  701. hydrateProductRelations: [],
  702. hydrateProductVariantRelations: [],
  703. extendSearchInputType: {},
  704. extendSearchSortType: [],
  705. };
  706. export function mergeWithDefaults(userOptions: ElasticsearchOptions): ElasticsearchRuntimeOptions {
  707. const { clientOptions, ...pluginOptions } = userOptions;
  708. const merged = deepmerge(defaultOptions, pluginOptions) as ElasticsearchRuntimeOptions;
  709. return { ...merged, clientOptions };
  710. }