[kbn-es-query] Add support for OR filters (#142417)

Co-authored-by: Peter Pisljar <peter.pisljar@elastic.co>
This commit is contained in:
Lukas Olson 2022-10-05 01:18:26 -07:00 committed by GitHub
parent 32b029ebbb
commit 98f365eadf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 727 additions and 35 deletions

View file

@ -67,6 +67,7 @@ export {
buildEmptyFilter,
buildExistsFilter,
buildFilter,
buildOrFilter,
buildPhraseFilter,
buildPhrasesFilter,
buildQueryFilter,
@ -89,6 +90,7 @@ export {
isFilterPinned,
isFilters,
isMatchAllFilter,
isOrFilter,
isPhraseFilter,
isPhrasesFilter,
isQueryStringFilter,

View file

@ -13,6 +13,7 @@ import { filterMatchesIndex } from './filter_matches_index';
import { Filter, cleanFilter, isFilterDisabled } from '../filters';
import { BoolQuery, DataViewBase } from './types';
import { handleNestedFilter } from './handle_nested_filter';
import { handleOrFilter } from './handle_or_filter';
/**
* Create a filter that can be reversed for filters with negate set
@ -66,10 +67,11 @@ export interface EsQueryFiltersConfig {
export const buildQueryFromFilters = (
inputFilters: Filter[] = [],
inputDataViews: DataViewBase | DataViewBase[] | undefined,
{ ignoreFilterIfFieldNotInIndex = false, nestedIgnoreUnmapped }: EsQueryFiltersConfig = {
options: EsQueryFiltersConfig = {
ignoreFilterIfFieldNotInIndex: false,
}
): BoolQuery => {
const { ignoreFilterIfFieldNotInIndex = false, nestedIgnoreUnmapped } = options;
const filters = inputFilters.filter((filter) => filter && !isFilterDisabled(filter));
const indexPatterns = Array.isArray(inputDataViews) ? inputDataViews : [inputDataViews];
@ -92,6 +94,7 @@ export const buildQueryFromFilters = (
ignoreUnmapped: nestedIgnoreUnmapped,
});
})
.map((filter) => handleOrFilter(filter, inputDataViews, options))
.map(cleanFilter)
.map(translateToQuery);
};

View file

@ -0,0 +1,610 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fields } from '../filters/stubs';
import { DataViewBase } from './types';
import { handleOrFilter } from './handle_or_filter';
import {
buildExistsFilter,
buildOrFilter,
buildPhraseFilter,
buildPhrasesFilter,
buildRangeFilter,
} from '../filters';
describe('#handleOrFilter', function () {
const indexPattern: DataViewBase = {
id: 'logstash-*',
fields,
title: 'dataView',
};
const getField = (fieldName: string) => {
const field = fields.find(({ name }) => fieldName === name);
if (!field) throw new Error(`field ${name} does not exist`);
return field;
};
it('Handles an empty list of filters', () => {
const filter = buildOrFilter([]);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [],
},
}
`);
});
it('Handles a simple list of filters', () => {
const filters = [
buildPhraseFilter(getField('extension'), 'value', indexPattern),
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
buildExistsFilter(getField('machine.os'), indexPattern),
];
const filter = buildOrFilter(filters);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"exists": Object {
"field": "machine.os",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
}
`);
});
it('Handles a combination of filters and filter arrays', () => {
const filters = [
buildPhraseFilter(getField('extension'), 'value', indexPattern),
[
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
buildExistsFilter(getField('machine.os'), indexPattern),
],
];
const filter = buildOrFilter(filters);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
Object {
"exists": Object {
"field": "machine.os",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
}
`);
});
it('Handles nested OR filters', () => {
const nestedOrFilter = buildOrFilter([
buildPhraseFilter(getField('machine.os'), 'value', indexPattern),
buildPhraseFilter(getField('extension'), 'value', indexPattern),
]);
const filters = [
buildPhraseFilter(getField('extension'), 'value2', indexPattern),
nestedOrFilter,
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
buildExistsFilter(getField('machine.os.raw'), indexPattern),
];
const filter = buildOrFilter(filters);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value2",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"machine.os": "value",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"exists": Object {
"field": "machine.os.raw",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
}
`);
});
it('Handles negated sub-filters', () => {
const negatedFilter = buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern);
negatedFilter.meta.negate = true;
const filters = [
[negatedFilter, buildPhraseFilter(getField('extension'), 'value', indexPattern)],
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
buildExistsFilter(getField('machine.os'), indexPattern),
];
const filter = buildOrFilter(filters);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value",
},
},
],
"must": Array [],
"must_not": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"extension": "tar",
},
},
Object {
"match_phrase": Object {
"extension": "gz",
},
},
],
},
},
],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"exists": Object {
"field": "machine.os",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
}
`);
});
it('Handles disabled filters within a filter array', () => {
const disabledFilter = buildPhraseFilter(getField('ssl'), false, indexPattern);
disabledFilter.meta.disabled = true;
const filters = [
buildPhraseFilter(getField('extension'), 'value', indexPattern),
[disabledFilter, buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern)],
buildExistsFilter(getField('machine.os'), indexPattern),
];
const filter = buildOrFilter(filters);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"exists": Object {
"field": "machine.os",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
}
`);
});
it('Handles complex-nested filters with ANDs and ORs', () => {
const filters = [
[
buildPhrasesFilter(getField('extension'), ['tar', 'gz'], indexPattern),
buildPhraseFilter(getField('ssl'), false, indexPattern),
buildOrFilter([
buildPhraseFilter(getField('extension'), 'value', indexPattern),
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
]),
buildExistsFilter(getField('machine.os'), indexPattern),
],
buildPhrasesFilter(getField('machine.os.keyword'), ['foo', 'bar'], indexPattern),
];
const filter = buildOrFilter(filters);
const result = handleOrFilter(filter);
expect(result.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"extension": "tar",
},
},
Object {
"match_phrase": Object {
"extension": "gz",
},
},
],
},
},
Object {
"match_phrase": Object {
"ssl": false,
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_phrase": Object {
"extension": "value",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
},
Object {
"exists": Object {
"field": "machine.os",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"machine.os.keyword": "foo",
},
},
Object {
"match_phrase": Object {
"machine.os.keyword": "bar",
},
},
],
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
],
},
}
`);
});
it('Preserves filter properties', () => {
const filters = [
buildPhraseFilter(getField('extension'), 'value', indexPattern),
buildRangeFilter(getField('bytes'), { gte: 10 }, indexPattern),
buildExistsFilter(getField('machine.os'), indexPattern),
];
const filter = buildOrFilter(filters);
const { query, ...rest } = handleOrFilter(filter);
expect(rest).toMatchInlineSnapshot(`
Object {
"$state": Object {
"store": "appState",
},
"meta": Object {
"alias": null,
"disabled": false,
"index": undefined,
"negate": false,
"params": Array [
Object {
"meta": Object {
"index": "logstash-*",
},
"query": Object {
"match_phrase": Object {
"extension": "value",
},
},
},
Object {
"meta": Object {
"field": "bytes",
"index": "logstash-*",
"params": Object {},
},
"query": Object {
"range": Object {
"bytes": Object {
"gte": 10,
},
},
},
},
Object {
"meta": Object {
"index": "logstash-*",
},
"query": Object {
"exists": Object {
"field": "machine.os",
},
},
},
],
"type": "OR",
},
}
`);
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Filter, FilterItem, isOrFilter } from '../filters';
import { DataViewBase } from './types';
import { buildQueryFromFilters, EsQueryFiltersConfig } from './from_filters';
/** @internal */
export const handleOrFilter = (
filter: Filter,
inputDataViews?: DataViewBase | DataViewBase[],
options: EsQueryFiltersConfig = {}
): Filter => {
if (!isOrFilter(filter)) return filter;
const { params } = filter.meta;
const should = params.map((subFilter) => {
const subFilters = Array.isArray(subFilter) ? subFilter : [subFilter];
return { bool: buildQueryFromFilters(flattenFilters(subFilters), inputDataViews, options) };
});
return {
...filter,
query: {
bool: {
should,
minimum_should_match: 1,
},
},
};
};
function flattenFilters(filters: FilterItem[]): Filter[] {
return filters.reduce<Filter[]>((result, filter) => {
if (Array.isArray(filter)) return [...result, ...flattenFilters(filter)];
return [...result, filter];
}, []);
}

View file

@ -14,6 +14,7 @@ export * from './exists_filter';
export * from './get_filter_field';
export * from './get_filter_params';
export * from './match_all_filter';
export * from './or_filter';
export * from './phrase_filter';
export * from './phrases_filter';
export * from './query_string_filter';

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Filter, FilterMeta, FILTERS } from './types';
import { buildEmptyFilter } from './build_empty_filter';
/**
* Each item in an OR filter may represent either one filter (to be ORed) or an array of filters (ANDed together before
* becoming part of the OR clause).
* @public
*/
export type FilterItem = Filter | FilterItem[];
/**
* @public
*/
export interface OrFilterMeta extends FilterMeta {
type: typeof FILTERS.OR;
params: FilterItem[];
}
/**
* @public
*/
export interface OrFilter extends Filter {
meta: OrFilterMeta;
}
/**
* @public
*/
export function isOrFilter(filter: Filter): filter is OrFilter {
return filter?.meta?.type === FILTERS.OR;
}
/**
* Builds an OR filter. An OR filter is a filter with multiple sub-filters. Each sub-filter (FilterItem) represents a
* condition.
* @param filters An array of OrFilterItem
* @public
*/
export function buildOrFilter(filters: FilterItem[]): OrFilter {
const filter = buildEmptyFilter(false);
return {
...filter,
meta: {
...filter.meta,
type: FILTERS.OR,
params: filters,
},
};
}

View file

@ -37,6 +37,7 @@ export enum FILTERS {
RANGE = 'range',
RANGE_FROM_VALUE = 'range_from_value',
SPATIAL_FILTER = 'spatial_filter',
OR = 'OR',
}
/**

View file

@ -34,6 +34,8 @@ export {
export {
isExistsFilter,
isMatchAllFilter,
buildOrFilter,
isOrFilter,
isPhraseFilter,
isPhrasesFilter,
isRangeFilter,
@ -75,6 +77,9 @@ export type {
CustomFilter,
RangeFilterParams,
QueryStringFilter,
OrFilter,
OrFilterMeta,
FilterItem,
} from './build_filters';
export { FilterStateStore, FILTERS } from './build_filters/types';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { buildEmptyFilter, Filter } from '@kbn/es-query';
import { buildEmptyFilter, Filter, FilterItem } from '@kbn/es-query';
import { ConditionTypes } from '../utils';
import {
getFilterByPath,
@ -16,7 +16,6 @@ import {
moveFilter,
normalizeFilters,
} from './filters_builder_utils';
import type { FilterItem } from '../utils';
import { getConditionalOperationType } from '../utils';
import {

View file

@ -7,10 +7,10 @@
*/
import { DataViewField } from '@kbn/data-views-plugin/common';
import type { Filter } from '@kbn/es-query';
import type { Filter, FilterItem } from '@kbn/es-query';
import { cloneDeep } from 'lodash';
import { ConditionTypes, getConditionalOperationType, isOrFilter, buildOrFilter } from '../utils';
import type { FilterItem } from '../utils';
import { buildOrFilter, isOrFilter } from '@kbn/es-query';
import { ConditionTypes, getConditionalOperationType } from '../utils';
import type { Operator } from '../filter_bar/filter_editor';
const PATH_SEPARATOR = '.';

View file

@ -9,10 +9,4 @@
export { onRaf } from './on_raf';
export { shallowEqual } from './shallow_equal';
export type { FilterItem } from './or_filter';
export {
ConditionTypes,
isOrFilter,
getConditionalOperationType,
buildOrFilter,
} from './or_filter';
export { ConditionTypes, getConditionalOperationType } from './or_filter';

View file

@ -6,20 +6,13 @@
* Side Public License, v 1.
*/
// Methods from this file will be removed after they are moved to the package
import { buildEmptyFilter, Filter } from '@kbn/es-query';
import { isOrFilter, FilterItem } from '@kbn/es-query';
export enum ConditionTypes {
OR = 'OR',
AND = 'AND',
}
/** @internal **/
export type FilterItem = Filter | FilterItem[];
/** to: @kbn/es-query **/
export const isOrFilter = (filter: Filter) => Boolean(filter?.meta?.type === 'OR');
/**
* Defines a conditional operation type (AND/OR) from the filter otherwise returns undefined.
* @param {FilterItem} filter
@ -31,17 +24,3 @@ export const getConditionalOperationType = (filter: FilterItem) => {
return ConditionTypes.OR;
}
};
/** to: @kbn/es-query **/
export const buildOrFilter = (filters: FilterItem) => {
const filter = buildEmptyFilter(false);
return {
...filter,
meta: {
...filter.meta,
type: 'OR',
params: filters,
},
};
};