Skip to content

Commit

Permalink
feat: Add full-text combined fields query (#195)
Browse files Browse the repository at this point in the history
combined_fields query was added in Elasticsearch 7.13.
  • Loading branch information
kaufmo committed Mar 3, 2024
1 parent 8002c89 commit 70fcf8d
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 1 deletion.
6 changes: 5 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const {
MultiMatchQuery,
CommonTermsQuery,
QueryStringQuery,
SimpleQueryStringQuery
SimpleQueryStringQuery,
CombinedFieldsQuery
},
termLevelQueries: {
TermQuery,
Expand Down Expand Up @@ -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 ========= */
/* ============ ============ ============ */
Expand Down
147 changes: 147 additions & 0 deletions src/queries/full-text-queries/combined-fields-query.js
Original file line number Diff line number Diff line change
@@ -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>|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<string>} 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;
1 change: 1 addition & 0 deletions src/queries/full-text-queries/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
68 changes: 68 additions & 0 deletions test/queries-test/combined-fields-query.test.js
Original file line number Diff line number Diff line change
@@ -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);
});

0 comments on commit 70fcf8d

Please sign in to comment.