diff --git a/src/index.js b/src/index.js index 92e27bcf..2d1bf63f 100644 --- a/src/index.js +++ b/src/index.js @@ -27,7 +27,8 @@ const { MultiMatchQuery, CommonTermsQuery, QueryStringQuery, - SimpleQueryStringQuery + SimpleQueryStringQuery, + CombinedFieldsQuery }, termLevelQueries: { TermQuery, @@ -203,6 +204,9 @@ exports.queryStringQuery = constructorWrapper(QueryStringQuery); exports.SimpleQueryStringQuery = SimpleQueryStringQuery; exports.simpleQueryStringQuery = constructorWrapper(SimpleQueryStringQuery); +exports.CombinedFieldsQuery = CombinedFieldsQuery; +exports.combinedFieldsQuery = constructorWrapper(CombinedFieldsQuery); + /* ============ ============ ============ */ /* ========= Term Level Queries ========= */ /* ============ ============ ============ */ diff --git a/src/queries/full-text-queries/combined-fields-query.js b/src/queries/full-text-queries/combined-fields-query.js new file mode 100644 index 00000000..9669d121 --- /dev/null +++ b/src/queries/full-text-queries/combined-fields-query.js @@ -0,0 +1,147 @@ +'use strict'; + +const isNil = require('lodash.isnil'); + +const { + util: { checkType, invalidParam } +} = require('../../core'); +const FullTextQueryBase = require('./full-text-query-base'); + +const ES_REF_URL = + 'https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-combined-fields-query.html'; + +const invalidOperatorParam = invalidParam( + ES_REF_URL, + 'operator', + "'and' or 'or'" +); +const invalidZeroTermsQueryParam = invalidParam( + ES_REF_URL, + 'zero_terms_query', + "'all' or 'none'" +); + +/** + * [Elasticsearch reference](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-combined-fields-query.html) + * + * @example + * const qry = esb.combinedFieldsQuery(['subject', 'message'], 'this is a test'); + * + * NOTE: This query was added in elasticsearch v7.13. + * + * @param {Array|string=} fields The fields to be queried + * @param {string=} queryString The query string + * + * @extends FullTextQueryBase + */ +class CombinedFieldsQuery extends FullTextQueryBase { + // eslint-disable-next-line require-jsdoc + constructor(fields, queryString) { + super('combined_fields', queryString); + + // This field is required + // Avoid checking for key in `this.field` + this._queryOpts.fields = []; + + if (!isNil(fields)) { + if (Array.isArray(fields)) this.fields(fields); + else this.field(fields); + } + } + + /** + * Appends given field to the list of fields to search against. + * Fields can be specified with wildcards. + * Individual fields can be boosted with the caret (^) notation. + * Example - `"subject^3"` + * + * @param {string} field One of the fields to be queried + * @returns {CombinedFieldsQuery} returns `this` so that calls can be chained. + */ + field(field) { + this._queryOpts.fields.push(field); + return this; + } + + /** + * Appends given fields to the list of fields to search against. + * Fields can be specified with wildcards. + * Individual fields can be boosted with the caret (^) notation. + * + * @example + * // Boost individual fields with caret `^` notation + * const qry = esb.combinedFieldsQuery(['subject^3', 'message'], 'this is a test'); + * + * @example + * // Specify fields with wildcards + * const qry = esb.combinedFieldsQuery(['title', '*_name'], 'Will Smith'); + * + * @param {Array} fields The fields to be queried + * @returns {CombinedFieldsQuery} returns `this` so that calls can be chained. + */ + fields(fields) { + checkType(fields, Array); + + this._queryOpts.fields = this._queryOpts.fields.concat(fields); + return this; + } + + /** + * If true, match phrase queries are automatically created for multi-term synonyms. + * + * @param {boolean} enable Defaults to `true` + * @returns {CombinedFieldsQuery} returns `this` so that calls can be chained. + */ + autoGenerateSynonymsPhraseQuery(enable) { + this._queryOpts.auto_generate_synonyms_phrase_query = enable; + return this; + } + + /** + * The operator to be used in the boolean query which is constructed + * by analyzing the text provided. The `operator` flag can be set to `or` or + * `and` to control the boolean clauses (defaults to `or`). + * + * @param {string} operator Can be `and`/`or`. Default is `or`. + * @returns {CombinedFieldsQuery} returns `this` so that calls can be chained. + */ + operator(operator) { + if (isNil(operator)) invalidOperatorParam(operator); + + const operatorLower = operator.toLowerCase(); + if (operatorLower !== 'and' && operatorLower !== 'or') { + invalidOperatorParam(operator); + } + + this._queryOpts.operator = operatorLower; + return this; + } + + /** + * If the analyzer used removes all tokens in a query like a `stop` filter does, + * the default behavior is to match no documents at all. In order to change that + * the `zero_terms_query` option can be used, which accepts `none` (default) and `all` + * which corresponds to a `match_all` query. + * + * @example + * const qry = esb.combinedFieldsQuery('message', 'to be or not to be') + * .operator('and') + * .zeroTermsQuery('all'); + * + * @param {string} behavior A no match action, `all` or `none`. Default is `none`. + * @returns {CombinedFieldsQuery} returns `this` so that calls can be chained. + */ + zeroTermsQuery(behavior) { + if (isNil(behavior)) invalidZeroTermsQueryParam(behavior); + + const behaviorLower = behavior.toLowerCase(); + if (behaviorLower !== 'all' && behaviorLower !== 'none') { + invalidZeroTermsQueryParam(behavior); + } + + this._queryOpts.zero_terms_query = behaviorLower; + return this; + } +} + +module.exports = CombinedFieldsQuery; diff --git a/src/queries/full-text-queries/index.js b/src/queries/full-text-queries/index.js index 53b1a4d0..08356a94 100644 --- a/src/queries/full-text-queries/index.js +++ b/src/queries/full-text-queries/index.js @@ -12,3 +12,4 @@ exports.MultiMatchQuery = require('./multi-match-query'); exports.CommonTermsQuery = require('./common-terms-query'); exports.QueryStringQuery = require('./query-string-query'); exports.SimpleQueryStringQuery = require('./simple-query-string-query'); +exports.CombinedFieldsQuery = require('./combined-fields-query'); diff --git a/test/queries-test/combined-fields-query.test.js b/test/queries-test/combined-fields-query.test.js new file mode 100644 index 00000000..182f4f29 --- /dev/null +++ b/test/queries-test/combined-fields-query.test.js @@ -0,0 +1,68 @@ +import test from 'ava'; +import { CombinedFieldsQuery } from '../../src'; +import { + validatedCorrectly, + nameExpectStrategy, + makeSetsOptionMacro +} from '../_macros'; + +const getInstance = (fields, queryStr) => + new CombinedFieldsQuery(fields, queryStr); + +const setsOption = makeSetsOptionMacro( + getInstance, + nameExpectStrategy('combined_fields', { fields: [] }) +); + +test(validatedCorrectly, getInstance, 'operator', ['and', 'or']); +test(validatedCorrectly, getInstance, 'zeroTermsQuery', ['all', 'none']); +test(setsOption, 'field', { + param: 'my_field', + propValue: ['my_field'], + keyName: 'fields' +}); +test(setsOption, 'fields', { + param: ['my_field_a', 'my_field_b'], + spread: false +}); +test(setsOption, 'autoGenerateSynonymsPhraseQuery', { param: true }); + +// constructor, fields can be str or arr +test('constructor sets arguments', t => { + let valueA = getInstance('my_field', 'query str').toJSON(); + let valueB = getInstance() + .field('my_field') + .query('query str') + .toJSON(); + t.deepEqual(valueA, valueB); + + let expected = { + combined_fields: { + fields: ['my_field'], + query: 'query str' + } + }; + t.deepEqual(valueA, expected); + + valueA = getInstance(['my_field_a', 'my_field_b'], 'query str').toJSON(); + valueB = getInstance() + .fields(['my_field_a', 'my_field_b']) + .query('query str') + .toJSON(); + t.deepEqual(valueA, valueB); + + const valueC = getInstance() + .field('my_field_a') + .field('my_field_b') + .query('query str') + .toJSON(); + t.deepEqual(valueA, valueC); + + expected = { + combined_fields: { + fields: ['my_field_a', 'my_field_b'], + query: 'query str' + } + }; + t.deepEqual(valueA, valueB); +});