diff --git a/dsl/index.d.ts b/dsl/index.d.ts index 12ede70f5..c976d3b53 100644 --- a/dsl/index.d.ts +++ b/dsl/index.d.ts @@ -17,7 +17,8 @@ * under the License. */ -import Q from './lib/query-helpers' -import A from './lib/aggregation-helpers' +import Q from './lib/query' +import A from './lib/aggregation' +import F from './lib/fluent' -export { Q, A } +export { Q, A, F } diff --git a/dsl/index.js b/dsl/index.js index 1dd6d389c..8731afcf3 100644 --- a/dsl/index.js +++ b/dsl/index.js @@ -19,8 +19,8 @@ 'use strict' -const Q = require('./lib/query-helpers').default -const A = require('./lib/aggregation-helpers').default +const Q = require('./lib/query').default +const A = require('./lib/aggregation').default const F = require('./lib/fluent').default module.exports = { Q, A, F } diff --git a/dsl/src/aggregation-helpers.ts b/dsl/src/aggregation.ts similarity index 99% rename from dsl/src/aggregation-helpers.ts rename to dsl/src/aggregation.ts index 981969ba5..5bbfdbc06 100644 --- a/dsl/src/aggregation-helpers.ts +++ b/dsl/src/aggregation.ts @@ -32,7 +32,8 @@ type aggsOptions = anyObject | string function _A (...aggregations: any[]): any { return { - aggs: Object.assign.apply(null, aggregations.filter(falsy)) + // @ts-ignore + aggs: Object.assign.apply(null, aggregations) } } @@ -356,7 +357,8 @@ function generateAggsObject (type: string, name: string, defaultField: string | return { [name]: { [type]: opts, - aggs: Object.assign.apply(null, aggregations.filter(falsy)) + // @ts-ignore + aggs: Object.assign.apply(null, aggregations) } } } else { diff --git a/dsl/src/query-helpers.ts b/dsl/src/query-helpers.ts deleted file mode 100644 index 18371cee4..000000000 --- a/dsl/src/query-helpers.ts +++ /dev/null @@ -1,713 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/* eslint camelcase: 0 */ -/* eslint no-undef: 0 */ -/* eslint no-use-before-define: 0 */ -/* eslint no-redeclare: 0 */ - -import deepMerge from 'deepmerge' -import * as t from './types' - -function Q (...blocks: t.AnyQuery[]): Record { - const topLevelKeys = [ - 'aggs', - 'collapse', - 'explain', - 'from', - 'highlight', - 'indices_boost', - 'min_score', - 'post_filter', - 'profile', - 'rescore', - 'script_fields', - 'search_after', - 'size', - 'slice', - 'sort', - '_source', - 'suggest', - 'terminate_after', - 'timeout', - 'track_scores', - 'version' - ] - - const queries = blocks.flat().filter(block => { - return !topLevelKeys.includes(Object.keys(block)[0]) - }) - const body: Record = queries.length > 0 ? Q.bool(...queries) : {} - for (const block of blocks) { - const key = Object.keys(block)[0] - if (topLevelKeys.includes(key)) { - body[key] = block[key] - } - } - - return body -} - -Object.defineProperty(Q, 'name', { writable: true }) - -Q.param = function param (key: string) { - return `###${key}###` -} - -Q.compile = function compile (query: Record): t.compiledFunction { - let stringified = JSON.stringify(query) - const keys: string[] = [] - const matches = stringified.match(/"###\w+###"/g) - if (matches === null) { - throw new Error('The query does not contain any use of `Q.params`') - } - for (const match of matches) { - const key = match.slice(4, -4) - keys.push(key) - stringified = stringified.replace(new RegExp(match), `input[${JSON.stringify(key)}]`) - } - const code = ` - if (input == null) { - throw new Error('Input must not be empty') - } - const keys = ${JSON.stringify(keys)} - for (const key of keys) { - if (input[key] === undefined) { - throw new Error('Missing key: ' + key) - } - } - return ${stringified} - ` - // @ts-ignore - return new Function('input', code) // eslint-disable-line -} - -function match (key: string, val: string, opts?: Record): t.Condition -function match (key: string, val: string[], opts?: Record): t.Condition[] -function match (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - return generateQueryObject('match', key, val, opts) -} -Q.match = match - -Q.matchPhrase = function matchPhrase (key: string, val: string, opts?: Record): t.Condition { - return generateQueryObject('match_phrase', key, val, opts) -} - -Q.matchPhrasePrefix = function matchPhrasePrefix (key: string, val: string, opts?: Record): t.Condition { - return generateQueryObject('match_phrase_prefix', key, val, opts) -} - -Q.multiMatch = function multiMatch (keys: string[], val: string, opts?: Record): t.Condition { - return { - multi_match: { - query: val, - fields: keys, - ...opts - } - } -} - -Q.matchAll = function matchAll (opts?: Record): t.Condition { - return { match_all: { ...opts } } -} - -Q.matchNone = function matchNone (): t.Condition { - return { match_none: {} } -} - -Q.common = function common (key: string, val: string, opts: Record): t.Condition { - return generateQueryObject('common', key, val, opts) -} - -Q.queryString = function queryString (val: string, opts: Record): t.Condition { - return { - query_string: { - query: val, - ...opts - } - } -} - -Q.simpleQueryString = function simpleQueryString (val: string, opts: Record): t.Condition { - return { - simple_query_string: { - query: val, - ...opts - } - } -} - -Q.term = function term (key: string, val: string | string[], opts?: Record): t.Condition { - if (Array.isArray(val)) { - return Q.terms(key, val, opts) - } - return generateValueObject('term', key, val, opts) -} - -Q.terms = function terms (key: string, val: string[], opts?: Record): t.Condition { - return { - terms: { - [key]: val, - ...opts - } - } -} - -Q.termsSet = function termsSet (key: string, val: string[], opts: Record): t.Condition { - return { - terms_set: { - [key]: { - terms: val, - ...opts - } - } - } -} - -Q.range = function range (key: string, val: any): t.Condition { - return { range: { [key]: val } } -} - -function exists (key: string): t.Condition -function exists (key: string[]): t.Condition[] -function exists (key: string | string[]): t.Condition | t.Condition[] { - if (Array.isArray(key)) { - return key.map(k => exists(k)) - } - return { exists: { field: key } } -} -Q.exists = exists - -function prefix (key: string, val: string, opts?: Record): t.Condition -function prefix (key: string, val: string[], opts?: Record): t.Condition[] -function prefix (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - return generateValueObject('prefix', key, val, opts) -} -Q.prefix = prefix - -function wildcard (key: string, val: string, opts?: Record): t.Condition -function wildcard (key: string, val: string[], opts?: Record): t.Condition[] -function wildcard (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - return generateValueObject('wildcard', key, val, opts) -} -Q.wildcard = wildcard - -function regexp (key: string, val: string, opts?: Record): t.Condition -function regexp (key: string, val: string[], opts?: Record): t.Condition[] -function regexp (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - return generateValueObject('regexp', key, val, opts) -} -Q.regexp = regexp - -function fuzzy (key: string, val: string, opts?: Record): t.Condition -function fuzzy (key: string, val: string[], opts?: Record): t.Condition[] -function fuzzy (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - return generateValueObject('fuzzy', key, val, opts) -} -Q.fuzzy = fuzzy - -Q.ids = function ids (key: string, val: string[], opts: Record): t.Condition { - return { - ids: { - [key]: { - values: val, - ...opts - } - } - } -} - -Q.must = function must (...queries: t.AnyQuery[]): t.MustClause { - return { must: queries.flatMap(mergeableMust) } -} - -Q.should = function should (...queries: t.AnyQuery[]): t.ShouldClause { - return { should: queries.flatMap(mergeableShould) } -} - -Q.mustNot = function mustNot (...queries: t.AnyQuery[]): t.MustNotClause { - return { must_not: queries.flatMap(mergeableMustNot) } -} - -Q.filter = function filter (...queries: t.AnyQuery[]): t.FilterClause { - return { filter: queries.flatMap(mergeableFilter) } -} - -Q.bool = function bool (...queries: t.AnyQuery[]): t.BoolQuery { - if (queries.length === 0) { - return { query: { bool: {} } } - } - - const normalizedQueries: t.BoolQueryOptions[] = queries - .flat() - .filter(val => { - // filters empty objects/arrays as well - if (typeof val === 'object' && val != null) { - return Object.keys(val).length > 0 - } - return !!val - }) - .map(q => { - if (isBool(q)) { - if (q.query.bool._name) { - return { must: [q.query] } - } - return q.query.bool - } - - if (isClause(q)) { - return q - } - - return { must: [q] } - }) - - const clauseCount = { - must: 0, - should: 0, - must_not: 0, - filter: 0 - } - for (let i = 0; i < normalizedQueries.length; i++) { - const q = normalizedQueries[i] - if (q.must !== undefined) { clauseCount.must++ } - if (q.should !== undefined) { clauseCount.should++ } - if (q.must_not !== undefined) { clauseCount.must_not++ } - if (q.filter !== undefined) { clauseCount.filter++ } - } - - // if there is at least one should, we cannot deep merge - // multiple clauses, so we check how many clauses we have per type - // and we throw an error if there is more than one per type - if (clauseCount.should > 0) { - if (clauseCount.must > 1 || clauseCount.must_not > 1 || clauseCount.filter > 1) { - throw new Error('Cannot merge this query') - } - } - - const bool: t.BoolQueryOptions = deepMerge.all(normalizedQueries) - - // if there are not should clauses, - // we can safely deepmerge queries - return { - query: { - bool: optimize(bool) - } - } -} - -// Tries to flat the query based on the content -function optimize (q: t.BoolQueryOptions): t.BoolQueryOptions { - const clauses: t.BoolQueryOptions = {} - - if (q.minimum_should_match !== undefined || - q.should !== undefined || q._name !== undefined) { - return q - } - - if (q.must) { - for (const c of q.must) { - if (isBoolBlock(c)) { - if (c.bool.should || c.bool._name) { - clauses.must = clauses.must || [] - clauses.must.push(c) - } else { - // if we are in a BoolBlock and there is not a should clause - // then we can "merge up" the other clauses safely - if (c.bool.must) { - clauses.must = clauses.must || [] - clauses.must.push.apply(clauses.must, c.bool.must) - } - - if (c.bool.must_not) { - clauses.must_not = clauses.must_not || [] - clauses.must_not.push.apply(clauses.must_not, c.bool.must_not) - } - - if (c.bool.filter) { - clauses.filter = clauses.filter || [] - clauses.filter.push.apply(clauses.filter, c.bool.filter) - } - } - } else { - clauses.must = clauses.must || [] - clauses.must.push(c) - } - } - } - - if (q.filter) { - for (const c of q.filter) { - if (isBoolBlock(c)) { - if (c.bool.should || c.bool.must_not || c.bool._name) { - clauses.filter = clauses.filter || [] - clauses.filter.push(c) - } else { - // if there are must clauses and we are inside - // a filter clause, we can safely move them to the upper - // filter clause, since the score is not influenced - if (c.bool.must) { - clauses.filter = clauses.filter || [] - clauses.filter.push.apply(clauses.filter, c.bool.must) - } - - if (c.bool.filter) { - clauses.filter = clauses.filter || [] - clauses.filter.push.apply(clauses.filter, c.bool.filter) - } - } - } else { - clauses.filter = clauses.filter || [] - clauses.filter.push(c) - } - } - } - - if (q.must_not) { - for (const c of q.must_not) { - if (isBoolBlock(c)) { - if (c.bool.should || c.bool.filter || c.bool._name) { - clauses.must_not = clauses.must_not || [] - clauses.must_not.push(c) - } else { - // if 'c' is a BoolBlock and there are only must and must_not, - // then we can swap them safely - if (c.bool.must) { - clauses.must_not = clauses.must_not || [] - clauses.must_not.push.apply(clauses.must_not, c.bool.must) - } - - if (c.bool.must_not) { - clauses.must = clauses.must || [] - clauses.must.push.apply(clauses.must, c.bool.must_not) - } - } - } else { - clauses.must_not = clauses.must_not || [] - clauses.must_not.push(c) - } - } - } - - return clauses -} - -Q.and = function and (...queries: t.AnyQuery[]): t.BoolQuery { - let query = queries[0] - for (let i = 1; i < queries.length; i++) { - query = andOp(query, queries[i]) - } - return query as t.BoolQuery - - function andOp (q1: t.AnyQuery, q2: t.AnyQuery): t.BoolQuery { - const b1: t.BoolQuery = toMustQuery(q1) - const b2: t.BoolQuery = toMustQuery(q2) - if (!onlyShould(b1.query.bool) && !onlyShould(b2.query.bool)) { - return deepMerge(b1, b2) - } else { - const { must, ...clauses } = b1.query.bool - return Q.bool( - must == null ? Q.must(b2) : Q.must(must, b2), - clauses - ) - } - } -} - -Q.or = function or (...queries: t.AnyQuery[]): t.BoolQuery { - return Q.bool(Q.should(...queries)) -} - -Q.not = function not (q: t.AnyQuery): t.BoolQuery { - if (!isBool(q) && !isClause(q)) { - return Q.bool(Q.mustNot(q)) - } - - const b: t.BoolQuery = isClause(q) - ? Q.bool(q as t.BoolQueryOptions) - : q as t.BoolQuery - - if (onlyMust(b.query.bool)) { - return Q.bool(Q.mustNot(...b.query.bool.must)) - } else if (onlyMustNot(b.query.bool)) { - return Q.bool(Q.must(...b.query.bool.must_not)) - } else { - return Q.bool(Q.mustNot(b)) - } -} - -Q.minShouldMatch = function minShouldMatch (min: number): t.BoolQueryOptions { - return { minimum_should_match: min } -} - -Q.name = function name (queryName: string): t.BoolQueryOptions { - return { _name: queryName } -} - -Q.nested = function nested (path: string, query: any, opts: Record): t.QueryBlock { - return { - query: { - nested: { - path, - ...opts, - ...query - } - } - } -} - -Q.constantScore = function constantScore (query: any, boost: number): t.QueryBlock { - return { - query: { - constant_score: { - ...query, - boost - } - } - } -} - -Q.disMax = function disMax (queries: t.AnyQuery[], opts?: Record): t.QueryBlock { - return { - query: { - dis_max: { - ...opts, - queries: queries.flat() - } - } - } -} - -Q.functionScore = function functionScore (function_score: any): t.QueryBlock { - return { query: { function_score } } -} - -Q.boosting = function boosting (boostOpts: Record): t.QueryBlock { - return { query: { boosting: boostOpts } } -} - -Q.sort = function sort (key: string | any[], opts?: Record): t.Condition { - if (Array.isArray(key) === true) { - return { sort: key } - } - return { - // @ts-ignore - sort: [{ [key]: opts }] - } -} - -Q.size = function size (s: number): t.Condition { - return { size: s } -} - -function generateQueryObject (queryType: string, key: string, val: string, opts?: Record): t.Condition -function generateQueryObject (queryType: string, key: string, val: string[], opts?: Record): t.Condition[] -function generateQueryObject (queryType: string, key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - if (Array.isArray(val)) { - return val.map(v => generateQueryObject(queryType, key, v, opts)) - } - if (opts === undefined) { - return { [queryType]: { [key]: val } } - } - return { - [queryType]: { - [key]: { - query: val, - ...opts - } - } - } -} - -function generateValueObject (queryType: string, key: string, val: string, opts?: Record): t.Condition -function generateValueObject (queryType: string, key: string, val: string[], opts?: Record): t.Condition[] -function generateValueObject (queryType: string, key: string, val: any, opts?: Record): t.Condition | t.Condition[] { - if (Array.isArray(val)) { - return val.map(v => generateValueObject(queryType, key, v, opts)) - } - if (opts === undefined) { - return { [queryType]: { [key]: val } } - } - return { - [queryType]: { - [key]: { - value: val, - ...opts - } - } - } -} - -function isBool (q: any): q is t.BoolQuery { - return q.query && q.query.bool -} - -function isBoolBlock (q: any): q is t.BoolBlock { - return !!q.bool -} - -function isClause (q: any): q is t.BoolQueryOptions { - if (q.must !== undefined) return true - if (q.should !== undefined) return true - if (q.must_not !== undefined) return true - if (q.filter !== undefined) return true - if (q.minimum_should_match !== undefined) return true - if (q._name !== undefined) return true - return false -} - -function onlyShould (bool: t.BoolQueryOptions): bool is t.ShouldClause { - if (bool.must !== undefined) return false - if (bool.must_not !== undefined) return false - if (bool.filter !== undefined) return false - if (bool.minimum_should_match !== undefined) return false - if (bool._name !== undefined) return false - return true -} - -function onlyMust (bool: t.BoolQueryOptions): bool is t.MustClause { - if (bool.should !== undefined) return false - if (bool.must_not !== undefined) return false - if (bool.filter !== undefined) return false - if (bool.minimum_should_match !== undefined) return false - if (bool._name !== undefined) return false - return true -} - -function onlyMustNot (bool: t.BoolQueryOptions): bool is t.MustNotClause { - if (bool.should !== undefined) return false - if (bool.must !== undefined) return false - if (bool.filter !== undefined) return false - if (bool.minimum_should_match !== undefined) return false - if (bool._name !== undefined) return false - return true -} - -function onlyFilter (bool: t.BoolQueryOptions): bool is t.FilterClause { - if (bool.should !== undefined) return false - if (bool.must !== undefined) return false - if (bool.must_not !== undefined) return false - if (bool.minimum_should_match !== undefined) return false - if (bool._name !== undefined) return false - return true -} - -// for a given query it always return a bool query: -// - if is a bool query returns the query -// - if is a clause, wraps the query in a bool block -// - if is condition, wraps the query into a must clause and then in a bool block -function toMustQuery (query: t.AnyQuery): t.BoolQuery { - if (isBool(query)) { - return query - } - - if (isClause(query)) { - return { query: { bool: query } } - } - - return { query: { bool: { must: [query] } } } -} - -// the aim of this mergeable functions -// is to reduce the depth of the query objects -function mergeableMust (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { - if (Array.isArray(q)) { - return q.map(mergeableMust) - } - if (isBool(q)) { - if (onlyMust(q.query.bool)) { - return q.query.bool.must - } else { - return q.query - } - } else if (isClause(q)) { - if (onlyMust(q)) { - return q.must - } else { - return { bool: q } - } - } else { - return q - } -} - -function mergeableShould (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { - if (Array.isArray(q)) { - return q.map(mergeableShould) - } - if (isBool(q)) { - if (onlyShould(q.query.bool)) { - return q.query.bool.should - } else { - return q.query - } - } else if (isClause(q)) { - if (onlyShould(q)) { - return q.should - } else { - return { bool: q } - } - } else { - return q - } -} - -function mergeableMustNot (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { - if (Array.isArray(q)) { - return q.map(mergeableMustNot) - } - if (isBool(q)) { - if (onlyMustNot(q.query.bool)) { - return q.query.bool.must_not - } else { - return q.query - } - } else if (isClause(q)) { - if (onlyMustNot(q)) { - return q.must_not - } else { - return { bool: q } - } - } else { - return q - } -} - -function mergeableFilter (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { - if (Array.isArray(q)) { - return q.map(mergeableFilter) - } - if (isBool(q)) { - if (onlyFilter(q.query.bool)) { - return q.query.bool.filter - } else { - return q.query - } - } else if (isClause(q)) { - if (onlyFilter(q)) { - return q.filter - } else { - return { bool: q } - } - } else { - return q - } -} - -export default Q diff --git a/dsl/src/query.ts b/dsl/src/query.ts new file mode 100644 index 000000000..f85a3b58f --- /dev/null +++ b/dsl/src/query.ts @@ -0,0 +1,764 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint camelcase: 0 */ +/* eslint no-undef: 0 */ +/* eslint no-use-before-define: 0 */ +/* eslint no-redeclare: 0 */ +/* eslint no-inner-declarations: 0 */ + +import deepMerge from 'deepmerge' +import * as t from './types' + +function Q (...blocks: t.AnyQuery[]): Record { + const topLevelKeys = [ + 'aggs', + 'collapse', + 'explain', + 'from', + 'highlight', + 'indices_boost', + 'min_score', + 'post_filter', + 'profile', + 'rescore', + 'script_fields', + 'search_after', + 'size', + 'slice', + 'sort', + '_source', + 'suggest', + 'terminate_after', + 'timeout', + 'track_scores', + 'version' + ] + + const queries = blocks.flat().filter(block => { + return !topLevelKeys.includes(Object.keys(block)[0]) + }) + const body: Record = queries.length > 0 ? Q.bool(...queries) : {} + for (const block of blocks) { + const key = Object.keys(block)[0] + if (topLevelKeys.includes(key)) { + body[key] = (block as Record)[key] + } + } + + return body +} + +Object.defineProperty(Q, 'name', { writable: true }) + +namespace Q { + export function param (key: string): Symbol { + return Symbol(key) + } + + export function compileUnsafe = Record> (query: Record): t.compiledFunction { + let stringified = JSON.stringify(query, (key, value) => typeof value === 'symbol' ? `###${value.description!}###` : value) + const keys: string[] = [] + const matches = stringified.match(/"###\w+###"/g) + if (matches === null) { + throw new Error('The query does not contain any use of `Q.params`') + } + for (const match of matches) { + const key = match.slice(4, -4) + keys.push(key) + stringified = stringified.replace(new RegExp(match), `input[${JSON.stringify(key)}]`) + } + const code = ` + if (input == null) { + throw new Error('Input must not be empty') + } + const keys = ${JSON.stringify(keys)} + for (const key of keys) { + if (input[key] === undefined) { + throw new Error('Missing key: ' + key) + } + } + return ${stringified} + ` + // @ts-ignore + return new Function('input', code) // eslint-disable-line + } + + export function compile = Record> (query: Record): t.compiledFunction { + const params: Array<{ path: string[], key: string }> = [] + traverse(query, []) + + return function (input: TInput): Record { + let q = query + for (const param of params) { + q = setParam(q, param.path, input[param.key]) + } + return q + } + + function traverse (obj: Record, path: string[]) { + for (const key in obj) { + const value = obj[key] + if (typeof value === 'symbol') { + params.push({ path: path.concat(key), key: value.description! }) + } else if (Array.isArray(value)) { + for (var i = 0; i < value.length; i++) { + traverse(value[i], path.concat(key, '' + i)) + } + } else if (typeof value === 'object' && value !== null) { + traverse(value, path.concat(key)) + } else { + // do nothing + } + } + } + } + + export function match (key: string, val: string | Symbol, opts?: Record): t.Condition + export function match (key: string, val: string[], opts?: Record): t.Condition[] + export function match (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + return generateQueryObject('match', key, val, opts) + } + + export function matchPhrase (key: string, val: string | Symbol, opts?: Record): t.Condition { + return generateQueryObject('match_phrase', key, val, opts) + } + + export function matchPhrasePrefix (key: string, val: string | Symbol, opts?: Record): t.Condition { + return generateQueryObject('match_phrase_prefix', key, val, opts) + } + + export function multiMatch (keys: string[], val: string | Symbol, opts?: Record): t.Condition { + return { + multi_match: { + query: val, + fields: keys, + ...opts + } + } + } + + export function matchAll (opts?: Record): t.Condition { + return { match_all: { ...opts } } + } + + export function matchNone (): t.Condition { + return { match_none: {} } + } + + export function common (key: string, val: string | Symbol, opts: Record): t.Condition { + return generateQueryObject('common', key, val, opts) + } + + export function queryString (val: string | Symbol, opts: Record): t.Condition { + return { + query_string: { + query: val, + ...opts + } + } + } + + export function simpleQueryString (val: string | Symbol, opts: Record): t.Condition { + return { + simple_query_string: { + query: val, + ...opts + } + } + } + + export function term (key: string, val: string | Symbol, opts?: Record): t.Condition + export function term (key: string, val: string[], opts?: Record): t.Condition + export function term (key: string, val: any, opts?: Record): t.Condition { + if (Array.isArray(val)) { + return Q.terms(key, val, opts) + } + return generateValueObject('term', key, val, opts) + } + + export function terms (key: string, val: string[] | Symbol, opts?: Record): t.Condition { + return { + terms: { + [key]: val, + ...opts + } + } + } + + export function termsSet (key: string, val: string[] | Symbol, opts: Record): t.Condition { + return { + terms_set: { + [key]: { + terms: val, + ...opts + } + } + } + } + + export function range (key: string, val: any): t.Condition { + return { range: { [key]: val } } + } + + export function exists (key: string): t.Condition + export function exists (key: string[]): t.Condition[] + export function exists (key: any): t.Condition | t.Condition[] { + if (Array.isArray(key)) { + return key.map(k => exists(k)) + } + return { exists: { field: key } } + } + + export function prefix (key: string, val: string | Symbol, opts?: Record): t.Condition + export function prefix (key: string, val: string[], opts?: Record): t.Condition[] + export function prefix (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + return generateValueObject('prefix', key, val, opts) + } + + export function wildcard (key: string, val: string | Symbol, opts?: Record): t.Condition + export function wildcard (key: string, val: string[], opts?: Record): t.Condition[] + export function wildcard (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + return generateValueObject('wildcard', key, val, opts) + } + + export function regexp (key: string, val: string | Symbol, opts?: Record): t.Condition + export function regexp (key: string, val: string[], opts?: Record): t.Condition[] + export function regexp (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + return generateValueObject('regexp', key, val, opts) + } + + export function fuzzy (key: string, val: string | Symbol, opts?: Record): t.Condition + export function fuzzy (key: string, val: string[], opts?: Record): t.Condition[] + export function fuzzy (key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + return generateValueObject('fuzzy', key, val, opts) + } + + export function ids (key: string, val: string[] | Symbol, opts: Record): t.Condition { + return { + ids: { + [key]: { + values: val, + ...opts + } + } + } + } + + export function must (...queries: t.AnyQuery[]): t.MustClause { + return { must: queries.flatMap(mergeableMust) } + } + + export function should (...queries: t.AnyQuery[]): t.ShouldClause { + return { should: queries.flatMap(mergeableShould) } + } + + export function mustNot (...queries: t.AnyQuery[]): t.MustNotClause { + return { must_not: queries.flatMap(mergeableMustNot) } + } + + export function filter (...queries: t.AnyQuery[]): t.FilterClause { + return { filter: queries.flatMap(mergeableFilter) } + } + + export function bool (...queries: t.AnyQuery[]): t.BoolQuery { + if (queries.length === 0) { + return { query: { bool: {} } } + } + + const normalizedQueries: t.BoolQueryOptions[] = queries + .flat() + .filter(val => { + // filters empty objects/arrays as well + if (typeof val === 'object' && val != null) { + return Object.keys(val).length > 0 + } + return !!val + }) + .map(q => { + if (isBool(q)) { + if (q.query.bool._name) { + return { must: [q.query] } + } + return q.query.bool + } + + if (isClause(q)) { + return q + } + + return { must: [q] } + }) + + const clauseCount = { + must: 0, + should: 0, + must_not: 0, + filter: 0 + } + for (let i = 0; i < normalizedQueries.length; i++) { + const q = normalizedQueries[i] + if (q.must !== undefined) { clauseCount.must++ } + if (q.should !== undefined) { clauseCount.should++ } + if (q.must_not !== undefined) { clauseCount.must_not++ } + if (q.filter !== undefined) { clauseCount.filter++ } + } + + // if there is at least one should, we cannot deep merge + // multiple clauses, so we check how many clauses we have per type + // and we throw an error if there is more than one per type + if (clauseCount.should > 0) { + if (clauseCount.must > 1 || clauseCount.must_not > 1 || clauseCount.filter > 1) { + throw new Error('Cannot merge this query') + } + } + + const bool: t.BoolQueryOptions = deepMerge.all(normalizedQueries) + + // if there are not should clauses, + // we can safely deepmerge queries + return { + query: { + bool: optimize(bool) + } + } + } + + export function and (...queries: t.AnyQuery[]): t.BoolQuery { + let query = queries[0] + for (let i = 1; i < queries.length; i++) { + query = andOp(query, queries[i]) + } + return query as t.BoolQuery + + function andOp (q1: t.AnyQuery, q2: t.AnyQuery): t.BoolQuery { + const b1: t.BoolQuery = toMustQuery(q1) + const b2: t.BoolQuery = toMustQuery(q2) + if (!onlyShould(b1.query.bool) && !onlyShould(b2.query.bool)) { + return deepMerge(b1, b2) + } else { + const { must, ...clauses } = b1.query.bool + return Q.bool( + must == null ? Q.must(b2) : Q.must(must, b2), + clauses + ) + } + } + } + + export function or (...queries: t.AnyQuery[]): t.BoolQuery { + return Q.bool(Q.should(...queries)) + } + + export function not (q: t.AnyQuery): t.BoolQuery { + if (!isBool(q) && !isClause(q)) { + return Q.bool(Q.mustNot(q)) + } + + const b: t.BoolQuery = isClause(q) + ? Q.bool(q as t.BoolQueryOptions) + : q as t.BoolQuery + + if (onlyMust(b.query.bool)) { + return Q.bool(Q.mustNot(...b.query.bool.must)) + } else if (onlyMustNot(b.query.bool)) { + return Q.bool(Q.must(...b.query.bool.must_not)) + } else { + return Q.bool(Q.mustNot(b)) + } + } + + export function minShouldMatch (int: number): t.BoolQueryOptions { + return { minimum_should_match: int } + } + + export function name (queryName: string): t.BoolQueryOptions { + return { _name: queryName } + } + + export function nested (path: string, query: any, opts: Record): t.QueryBlock { + return { + query: { + nested: { + path, + ...opts, + ...query + } + } + } + } + + export function constantScore (query: any, boost: number): t.QueryBlock { + return { + query: { + constant_score: { + ...query, + boost + } + } + } + } + + export function disMax (queries: t.AnyQuery[], opts?: Record): t.QueryBlock { + return { + query: { + dis_max: { + ...opts, + queries: queries.flat() + } + } + } + } + + export function functionScore (function_score: any): t.QueryBlock { + return { query: { function_score } } + } + + export function boosting (boostOpts: Record): t.QueryBlock { + return { query: { boosting: boostOpts } } + } + + export function sort (key: string | any[], opts?: Record): t.Condition { + if (Array.isArray(key) === true) { + return { sort: key } + } + return { + // @ts-ignore + sort: [{ [key]: opts }] + } + } + + export function size (s: number | Symbol): t.Condition { + return { size: s } + } +} + +// Tries to flat the query based on the content +function optimize (q: t.BoolQueryOptions): t.BoolQueryOptions { + const clauses: t.BoolQueryOptions = {} + + if (q.minimum_should_match !== undefined || + q.should !== undefined || q._name !== undefined) { + return q + } + + if (q.must) { + for (const c of q.must) { + if (isBoolBlock(c)) { + if (c.bool.should || c.bool._name) { + clauses.must = clauses.must || [] + clauses.must.push(c) + } else { + // if we are in a BoolBlock and there is not a should clause + // then we can "merge up" the other clauses safely + if (c.bool.must) { + clauses.must = clauses.must || [] + clauses.must.push.apply(clauses.must, c.bool.must) + } + + if (c.bool.must_not) { + clauses.must_not = clauses.must_not || [] + clauses.must_not.push.apply(clauses.must_not, c.bool.must_not) + } + + if (c.bool.filter) { + clauses.filter = clauses.filter || [] + clauses.filter.push.apply(clauses.filter, c.bool.filter) + } + } + } else { + clauses.must = clauses.must || [] + clauses.must.push(c) + } + } + } + + if (q.filter) { + for (const c of q.filter) { + if (isBoolBlock(c)) { + if (c.bool.should || c.bool.must_not || c.bool._name) { + clauses.filter = clauses.filter || [] + clauses.filter.push(c) + } else { + // if there are must clauses and we are inside + // a filter clause, we can safely move them to the upper + // filter clause, since the score is not influenced + if (c.bool.must) { + clauses.filter = clauses.filter || [] + clauses.filter.push.apply(clauses.filter, c.bool.must) + } + + if (c.bool.filter) { + clauses.filter = clauses.filter || [] + clauses.filter.push.apply(clauses.filter, c.bool.filter) + } + } + } else { + clauses.filter = clauses.filter || [] + clauses.filter.push(c) + } + } + } + + if (q.must_not) { + for (const c of q.must_not) { + if (isBoolBlock(c)) { + if (c.bool.should || c.bool.filter || c.bool._name) { + clauses.must_not = clauses.must_not || [] + clauses.must_not.push(c) + } else { + // if 'c' is a BoolBlock and there are only must and must_not, + // then we can swap them safely + if (c.bool.must) { + clauses.must_not = clauses.must_not || [] + clauses.must_not.push.apply(clauses.must_not, c.bool.must) + } + + if (c.bool.must_not) { + clauses.must = clauses.must || [] + clauses.must.push.apply(clauses.must, c.bool.must_not) + } + } + } else { + clauses.must_not = clauses.must_not || [] + clauses.must_not.push(c) + } + } + } + + return clauses +} + +function generateQueryObject (queryType: string, key: string, val: string | Symbol, opts?: Record): t.Condition +function generateQueryObject (queryType: string, key: string, val: string[], opts?: Record): t.Condition[] +function generateQueryObject (queryType: string, key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + if (Array.isArray(val)) { + return val.map(v => generateQueryObject(queryType, key, v, opts)) + } + if (opts === undefined) { + return { [queryType]: { [key]: val } } + } + return { + [queryType]: { + [key]: { + query: val, + ...opts + } + } + } +} + +function generateValueObject (queryType: string, key: string, val: string | Symbol, opts?: Record): t.Condition +function generateValueObject (queryType: string, key: string, val: string[], opts?: Record): t.Condition[] +function generateValueObject (queryType: string, key: string, val: any, opts?: Record): t.Condition | t.Condition[] { + if (Array.isArray(val)) { + return val.map(v => generateValueObject(queryType, key, v, opts)) + } + if (opts === undefined) { + return { [queryType]: { [key]: val } } + } + return { + [queryType]: { + [key]: { + value: val, + ...opts + } + } + } +} + +function isBool (q: any): q is t.BoolQuery { + return q.query && q.query.bool +} + +function isBoolBlock (q: any): q is t.BoolBlock { + return !!q.bool +} + +function isClause (q: any): q is t.BoolQueryOptions { + if (q.must !== undefined) return true + if (q.should !== undefined) return true + if (q.must_not !== undefined) return true + if (q.filter !== undefined) return true + if (q.minimum_should_match !== undefined) return true + if (q._name !== undefined) return true + return false +} + +function onlyShould (bool: t.BoolQueryOptions): bool is t.ShouldClause { + if (bool.must !== undefined) return false + if (bool.must_not !== undefined) return false + if (bool.filter !== undefined) return false + if (bool.minimum_should_match !== undefined) return false + if (bool._name !== undefined) return false + return true +} + +function onlyMust (bool: t.BoolQueryOptions): bool is t.MustClause { + if (bool.should !== undefined) return false + if (bool.must_not !== undefined) return false + if (bool.filter !== undefined) return false + if (bool.minimum_should_match !== undefined) return false + if (bool._name !== undefined) return false + return true +} + +function onlyMustNot (bool: t.BoolQueryOptions): bool is t.MustNotClause { + if (bool.should !== undefined) return false + if (bool.must !== undefined) return false + if (bool.filter !== undefined) return false + if (bool.minimum_should_match !== undefined) return false + if (bool._name !== undefined) return false + return true +} + +function onlyFilter (bool: t.BoolQueryOptions): bool is t.FilterClause { + if (bool.should !== undefined) return false + if (bool.must !== undefined) return false + if (bool.must_not !== undefined) return false + if (bool.minimum_should_match !== undefined) return false + if (bool._name !== undefined) return false + return true +} + +// for a given query it always return a bool query: +// - if is a bool query returns the query +// - if is a clause, wraps the query in a bool block +// - if is condition, wraps the query into a must clause and then in a bool block +function toMustQuery (query: t.AnyQuery): t.BoolQuery { + if (isBool(query)) { + return query + } + + if (isClause(query)) { + return { query: { bool: query } } + } + + return { query: { bool: { must: [query] } } } +} + +// the aim of this mergeable functions +// is to reduce the depth of the query objects +function mergeableMust (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { + if (Array.isArray(q)) { + return q.map(mergeableMust) + } + if (isBool(q)) { + if (onlyMust(q.query.bool)) { + return q.query.bool.must + } else { + return q.query + } + } else if (isClause(q)) { + if (onlyMust(q)) { + return q.must + } else { + return { bool: q } + } + } else { + return q + } +} + +function mergeableShould (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { + if (Array.isArray(q)) { + return q.map(mergeableShould) + } + if (isBool(q)) { + if (onlyShould(q.query.bool)) { + return q.query.bool.should + } else { + return q.query + } + } else if (isClause(q)) { + if (onlyShould(q)) { + return q.should + } else { + return { bool: q } + } + } else { + return q + } +} + +function mergeableMustNot (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { + if (Array.isArray(q)) { + return q.map(mergeableMustNot) + } + if (isBool(q)) { + if (onlyMustNot(q.query.bool)) { + return q.query.bool.must_not + } else { + return q.query + } + } else if (isClause(q)) { + if (onlyMustNot(q)) { + return q.must_not + } else { + return { bool: q } + } + } else { + return q + } +} + +function mergeableFilter (q: t.AnyQuery): t.AnyQuery | t.AnyQuery[] { + if (Array.isArray(q)) { + return q.map(mergeableFilter) + } + if (isBool(q)) { + if (onlyFilter(q.query.bool)) { + return q.query.bool.filter + } else { + return q.query + } + } else if (isClause(q)) { + if (onlyFilter(q)) { + return q.filter + } else { + return { bool: q } + } + } else { + return q + } +} + +// code from https://github.com/fwilkerson/clean-set +function setParam (source: Record, keys: string[], update: any) { + const next = copy(source) + let last = next + + for (let i = 0, len = keys.length; i < len; i++) { + // @ts-ignore + last = last[keys[i]] = i === len - 1 ? update : copy(last[keys[i]]) + } + + return next + + function copy (source: Record | any[]): Record | any[] { + const to = source && !!source.pop ? [] : {} + for (const i in source) { + // @ts-ignore + to[i] = source[i] + } + return to + } +} + +export default Q