From ecd88714fe93470eb4863432b8f3223fd710b39f Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 24 Mar 2021 14:43:17 +0100 Subject: [PATCH] adding kibana filter expression functions (#94069) (#95287) --- .../common/es_query/filters/build_filters.ts | 6 +- .../search/expressions/exists_filter.test.ts | 33 +++++++ .../search/expressions/exists_filter.ts | 65 ++++++++++++++ .../common/search/expressions/field.test.ts | 42 +++++++++ .../data/common/search/expressions/field.ts | 67 ++++++++++++++ .../search/expressions/filters_to_ast.test.ts | 47 ++++++++++ .../search/expressions/filters_to_ast.ts | 23 +++++ .../data/common/search/expressions/index.ts | 7 ++ .../search/expressions/kibana_context.ts | 11 +-- .../search/expressions/kibana_context_type.ts | 3 + .../search/expressions/kibana_filter.test.ts | 30 +++++++ .../search/expressions/kibana_filter.ts | 61 +++++++++++++ .../search/expressions/phrase_filter.test.ts | 58 ++++++++++++ .../search/expressions/phrase_filter.ts | 89 +++++++++++++++++++ .../common/search/expressions/range.test.ts | 40 +++++++++ .../data/common/search/expressions/range.ts | 76 ++++++++++++++++ .../search/expressions/range_filter.test.ts | 41 +++++++++ .../common/search/expressions/range_filter.ts | 74 +++++++++++++++ .../data/public/search/search_service.ts | 12 +++ .../data/server/search/search_service.ts | 12 +++ .../common/expression_types/index.ts | 1 + .../unbox_expression_value.test.ts | 17 ++++ .../unbox_expression_value.ts | 16 ++++ src/plugins/expressions/common/util/index.ts | 1 + .../expressions/common/util/test_utils.ts | 20 +++++ .../public/embeddable/to_ast.ts | 9 +- 26 files changed, 851 insertions(+), 10 deletions(-) create mode 100644 src/plugins/data/common/search/expressions/exists_filter.test.ts create mode 100644 src/plugins/data/common/search/expressions/exists_filter.ts create mode 100644 src/plugins/data/common/search/expressions/field.test.ts create mode 100644 src/plugins/data/common/search/expressions/field.ts create mode 100644 src/plugins/data/common/search/expressions/filters_to_ast.test.ts create mode 100644 src/plugins/data/common/search/expressions/filters_to_ast.ts create mode 100644 src/plugins/data/common/search/expressions/kibana_filter.test.ts create mode 100644 src/plugins/data/common/search/expressions/kibana_filter.ts create mode 100644 src/plugins/data/common/search/expressions/phrase_filter.test.ts create mode 100644 src/plugins/data/common/search/expressions/phrase_filter.ts create mode 100644 src/plugins/data/common/search/expressions/range.test.ts create mode 100644 src/plugins/data/common/search/expressions/range.ts create mode 100644 src/plugins/data/common/search/expressions/range_filter.test.ts create mode 100644 src/plugins/data/common/search/expressions/range_filter.ts create mode 100644 src/plugins/expressions/common/expression_types/unbox_expression_value.test.ts create mode 100644 src/plugins/expressions/common/expression_types/unbox_expression_value.ts create mode 100644 src/plugins/expressions/common/util/test_utils.ts diff --git a/src/plugins/data/common/es_query/filters/build_filters.ts b/src/plugins/data/common/es_query/filters/build_filters.ts index 42a4d66359346..ba1bd0a615493 100644 --- a/src/plugins/data/common/es_query/filters/build_filters.ts +++ b/src/plugins/data/common/es_query/filters/build_filters.ts @@ -26,13 +26,15 @@ export function buildFilter( disabled: boolean, params: any, alias: string | null, - store: FilterStateStore + store?: FilterStateStore ): Filter { const filter = buildBaseFilter(indexPattern, field, type, params); filter.meta.alias = alias; filter.meta.negate = negate; filter.meta.disabled = disabled; - filter.$state = { store }; + if (store) { + filter.$state = { store }; + } return filter; } diff --git a/src/plugins/data/common/search/expressions/exists_filter.test.ts b/src/plugins/data/common/search/expressions/exists_filter.test.ts new file mode 100644 index 0000000000000..e3b53b2281398 --- /dev/null +++ b/src/plugins/data/common/search/expressions/exists_filter.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { createMockContext } from '../../../../expressions/common'; +import { functionWrapper } from './utils'; +import { existsFilterFunction } from './exists_filter'; + +describe('interpreter/functions#existsFilter', () => { + const fn = functionWrapper(existsFilterFunction); + + it('returns an object with the correct structure', () => { + const actual = fn(null, { field: { spec: { name: 'test' } } }, createMockContext()); + expect(actual).toMatchInlineSnapshot(` + Object { + "exists": Object { + "field": "test", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": undefined, + "negate": false, + }, + "type": "kibana_filter", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/exists_filter.ts b/src/plugins/data/common/search/expressions/exists_filter.ts new file mode 100644 index 0000000000000..0979328860b4c --- /dev/null +++ b/src/plugins/data/common/search/expressions/exists_filter.ts @@ -0,0 +1,65 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaField, KibanaFilter } from './kibana_context_type'; +import { buildFilter, FILTERS } from '../../es_query/filters'; +import { IndexPattern } from '../../index_patterns/index_patterns'; + +interface Arguments { + field: KibanaField; + negate?: boolean; +} + +export type ExpressionFunctionExistsFilter = ExpressionFunctionDefinition< + 'existsFilter', + null, + Arguments, + KibanaFilter +>; + +export const existsFilterFunction: ExpressionFunctionExistsFilter = { + name: 'existsFilter', + type: 'kibana_filter', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.existsFilter.help', { + defaultMessage: 'Create kibana exists filter', + }), + args: { + field: { + types: ['kibana_field'], + required: true, + help: i18n.translate('data.search.functions.existsFilter.field.help', { + defaultMessage: 'Specify the field you want to filter on. Use `field` function.', + }), + }, + negate: { + types: ['boolean'], + default: false, + help: i18n.translate('data.search.functions.existsFilter.negate.help', { + defaultMessage: 'Should the filter be negated.', + }), + }, + }, + + fn(input, args) { + return { + type: 'kibana_filter', + ...buildFilter( + ({} as any) as IndexPattern, + args.field.spec, + FILTERS.EXISTS, + args.negate || false, + false, + {}, + null + ), + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/field.test.ts b/src/plugins/data/common/search/expressions/field.test.ts new file mode 100644 index 0000000000000..2ad139e2bd0a7 --- /dev/null +++ b/src/plugins/data/common/search/expressions/field.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { ExecutionContext } from 'src/plugins/expressions/common'; +import { functionWrapper } from './utils'; +import { fieldFunction } from './field'; + +describe('interpreter/functions#field', () => { + const fn = functionWrapper(fieldFunction); + let context: ExecutionContext; + + beforeEach(() => { + context = { + getSearchContext: () => ({}), + getSearchSessionId: () => undefined, + types: {}, + variables: {}, + abortSignal: {} as any, + inspectorAdapters: {} as any, + }; + }); + + it('returns an object with the correct structure', () => { + const actual = fn(null, { name: 'test', type: 'number' }, context); + expect(actual).toMatchInlineSnapshot(` + Object { + "spec": Object { + "name": "test", + "script": undefined, + "scripted": false, + "type": "number", + }, + "type": "kibana_field", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/field.ts b/src/plugins/data/common/search/expressions/field.ts new file mode 100644 index 0000000000000..8c13069f50ad5 --- /dev/null +++ b/src/plugins/data/common/search/expressions/field.ts @@ -0,0 +1,67 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaField } from './kibana_context_type'; + +interface Arguments { + name: string; + type: string; + script?: string; +} + +export type ExpressionFunctionField = ExpressionFunctionDefinition< + 'field', + null, + Arguments, + KibanaField +>; + +export const fieldFunction: ExpressionFunctionField = { + name: 'field', + type: 'kibana_field', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.field.help', { + defaultMessage: 'Create a Kibana field.', + }), + args: { + name: { + types: ['string'], + required: true, + help: i18n.translate('data.search.functions.field.name.help', { + defaultMessage: 'Name of the field', + }), + }, + type: { + types: ['string'], + required: true, + help: i18n.translate('data.search.functions.field.type.help', { + defaultMessage: 'Type of the field', + }), + }, + script: { + types: ['string'], + help: i18n.translate('data.search.functions.field.script.help', { + defaultMessage: 'A field script, in case the field is scripted.', + }), + }, + }, + + fn(input, args) { + return { + type: 'kibana_field', + spec: { + name: args.name, + type: args.type, + scripted: args.script ? true : false, + script: args.script, + }, + } as KibanaField; + }, +}; diff --git a/src/plugins/data/common/search/expressions/filters_to_ast.test.ts b/src/plugins/data/common/search/expressions/filters_to_ast.test.ts new file mode 100644 index 0000000000000..108b48f9ea77e --- /dev/null +++ b/src/plugins/data/common/search/expressions/filters_to_ast.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { filtersToAst } from './filters_to_ast'; + +describe('interpreter/functions#filtersToAst', () => { + const normalFilter = { + meta: { negate: false, alias: '', disabled: false }, + query: { test: 'something' }, + }; + const negatedFilter = { + meta: { negate: true, alias: '', disabled: false }, + query: { test: 'something' }, + }; + + it('converts a list of filters to an expression AST node', () => { + const actual = filtersToAst([normalFilter, negatedFilter]); + expect(actual).toHaveLength(2); + expect(actual[0].functions[0]).toHaveProperty('name', 'kibanaFilter'); + expect(actual[0].functions[0].arguments).toMatchInlineSnapshot(` + Object { + "negate": Array [ + false, + ], + "query": Array [ + "{\\"query\\":{\\"test\\":\\"something\\"}}", + ], + } + `); + expect(actual[1].functions[0]).toHaveProperty('name', 'kibanaFilter'); + expect(actual[1].functions[0].arguments).toMatchInlineSnapshot(` + Object { + "negate": Array [ + true, + ], + "query": Array [ + "{\\"query\\":{\\"test\\":\\"something\\"}}", + ], + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/filters_to_ast.ts b/src/plugins/data/common/search/expressions/filters_to_ast.ts new file mode 100644 index 0000000000000..a4dd959caecf6 --- /dev/null +++ b/src/plugins/data/common/search/expressions/filters_to_ast.ts @@ -0,0 +1,23 @@ +/* + * 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 { buildExpression, buildExpressionFunction } from '../../../../expressions/common'; +import { Filter } from '../../es_query/filters'; +import { ExpressionFunctionKibanaFilter } from './kibana_filter'; + +export const filtersToAst = (filters: Filter[] | Filter) => { + return (Array.isArray(filters) ? filters : [filters]).map((filter) => { + const { meta, $state, ...restOfFilter } = filter; + return buildExpression([ + buildExpressionFunction('kibanaFilter', { + query: JSON.stringify(restOfFilter), + negate: filter.meta.negate, + }), + ]); + }); +}; diff --git a/src/plugins/data/common/search/expressions/index.ts b/src/plugins/data/common/search/expressions/index.ts index b38dce247261c..b80cbad778a11 100644 --- a/src/plugins/data/common/search/expressions/index.ts +++ b/src/plugins/data/common/search/expressions/index.ts @@ -15,4 +15,11 @@ export * from './timerange_to_ast'; export * from './kibana_context_type'; export * from './esaggs'; export * from './utils'; +export * from './range'; +export * from './field'; +export * from './phrase_filter'; +export * from './exists_filter'; +export * from './range_filter'; +export * from './kibana_filter'; +export * from './filters_to_ast'; export * from './timerange'; diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 5c2e2f418e69c..98d7a2c45b4fc 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -10,14 +10,15 @@ import { uniqBy } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition, ExecutionContext } from 'src/plugins/expressions/common'; import { Adapters } from 'src/plugins/inspector/common'; +import { unboxExpressionValue } from '../../../../expressions/common'; import { Query, uniqFilters } from '../../query'; -import { ExecutionContextSearch, KibanaContext } from './kibana_context_type'; +import { ExecutionContextSearch, KibanaContext, KibanaFilter } from './kibana_context_type'; import { KibanaQueryOutput } from './kibana_context_type'; import { KibanaTimerangeOutput } from './timerange'; interface Arguments { q?: KibanaQueryOutput | null; - filters?: string | null; + filters?: KibanaFilter[] | null; timeRange?: KibanaTimerangeOutput | null; savedSearchId?: string | null; } @@ -56,8 +57,8 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { }), }, filters: { - types: ['string', 'null'], - default: '"[]"', + types: ['kibana_filter', 'null'], + multi: true, help: i18n.translate('data.search.functions.kibana_context.filters.help', { defaultMessage: 'Specify Kibana generic filters', }), @@ -81,7 +82,7 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { async fn(input, args, { getSavedObject }) { const timeRange = args.timeRange || input?.timeRange; let queries = mergeQueries(input?.query, args?.q || []); - let filters = [...(input?.filters || []), ...getParsedValue(args?.filters, [])]; + let filters = [...(input?.filters || []), ...(args?.filters?.map(unboxExpressionValue) || [])]; if (args.savedSearchId) { if (typeof getSavedObject !== 'function') { diff --git a/src/plugins/data/common/search/expressions/kibana_context_type.ts b/src/plugins/data/common/search/expressions/kibana_context_type.ts index 090f09f7004ca..0a7c365bb2914 100644 --- a/src/plugins/data/common/search/expressions/kibana_context_type.ts +++ b/src/plugins/data/common/search/expressions/kibana_context_type.ts @@ -9,6 +9,7 @@ import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; import { Filter } from '../../es_query'; import { Query, TimeRange } from '../../query'; +import { IndexPatternField } from '../../index_patterns/fields'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type ExecutionContextSearch = { @@ -23,6 +24,8 @@ export type ExpressionValueSearchContext = ExpressionValueBoxed< >; export type KibanaQueryOutput = ExpressionValueBoxed<'kibana_query', Query>; +export type KibanaFilter = ExpressionValueBoxed<'kibana_filter', Filter>; +export type KibanaField = ExpressionValueBoxed<'kibana_field', IndexPatternField>; // TODO: These two are exported for legacy reasons - remove them eventually. export type KIBANA_CONTEXT_NAME = 'kibana_context'; diff --git a/src/plugins/data/common/search/expressions/kibana_filter.test.ts b/src/plugins/data/common/search/expressions/kibana_filter.test.ts new file mode 100644 index 0000000000000..ac8ae55492cc0 --- /dev/null +++ b/src/plugins/data/common/search/expressions/kibana_filter.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { createMockContext } from '../../../../expressions/common'; +import { functionWrapper } from './utils'; +import { kibanaFilterFunction } from './kibana_filter'; + +describe('interpreter/functions#kibanaFilter', () => { + const fn = functionWrapper(kibanaFilterFunction); + + it('returns an object with the correct structure', () => { + const actual = fn(null, { query: '{ "name": "test" }' }, createMockContext()); + expect(actual).toMatchInlineSnapshot(` + Object { + "meta": Object { + "alias": "", + "disabled": false, + "negate": false, + }, + "name": "test", + "type": "kibana_filter", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/kibana_filter.ts b/src/plugins/data/common/search/expressions/kibana_filter.ts new file mode 100644 index 0000000000000..6d6f70fa8d1d6 --- /dev/null +++ b/src/plugins/data/common/search/expressions/kibana_filter.ts @@ -0,0 +1,61 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaFilter } from './kibana_context_type'; + +interface Arguments { + query: string; + negate?: boolean; +} + +export type ExpressionFunctionKibanaFilter = ExpressionFunctionDefinition< + 'kibanaFilter', + null, + Arguments, + KibanaFilter +>; + +export const kibanaFilterFunction: ExpressionFunctionKibanaFilter = { + name: 'kibanaFilter', + type: 'kibana_filter', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.kibanaFilter.help', { + defaultMessage: 'Create kibana filter', + }), + args: { + query: { + types: ['string'], + aliases: ['q', '_'], + required: true, + help: i18n.translate('data.search.functions.kibanaFilter.field.help', { + defaultMessage: 'Specify free form esdsl query', + }), + }, + negate: { + types: ['boolean'], + default: false, + help: i18n.translate('data.search.functions.kibanaFilter.negate.help', { + defaultMessage: 'Should the filter be negated', + }), + }, + }, + + fn(input, args) { + return { + type: 'kibana_filter', + meta: { + negate: args.negate || false, + alias: '', + disabled: false, + }, + ...JSON.parse(args.query), + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/phrase_filter.test.ts b/src/plugins/data/common/search/expressions/phrase_filter.test.ts new file mode 100644 index 0000000000000..39bd907513a0d --- /dev/null +++ b/src/plugins/data/common/search/expressions/phrase_filter.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { createMockContext } from '../../../../expressions/common'; +import { functionWrapper } from './utils'; +import { phraseFilterFunction } from './phrase_filter'; + +describe('interpreter/functions#phraseFilter', () => { + const fn = functionWrapper(phraseFilterFunction); + + it('returns an object with the correct structure', () => { + const actual = fn( + null, + { field: { spec: { name: 'test' } }, phrase: ['test', 'something'] }, + createMockContext() + ); + expect(actual).toMatchInlineSnapshot(` + Object { + "meta": Object { + "alias": null, + "disabled": false, + "index": undefined, + "key": "test", + "negate": false, + "params": Array [ + "test", + "something", + ], + "type": "phrases", + "value": "test, something", + }, + "query": Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "test": "test", + }, + }, + Object { + "match_phrase": Object { + "test": "something", + }, + }, + ], + }, + }, + "type": "kibana_filter", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/phrase_filter.ts b/src/plugins/data/common/search/expressions/phrase_filter.ts new file mode 100644 index 0000000000000..0b19e8a1e416d --- /dev/null +++ b/src/plugins/data/common/search/expressions/phrase_filter.ts @@ -0,0 +1,89 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaField, KibanaFilter } from './kibana_context_type'; +import { buildFilter, FILTERS } from '../../es_query/filters'; +import { IndexPattern } from '../../index_patterns/index_patterns'; + +interface Arguments { + field: KibanaField; + phrase: string[]; + negate?: boolean; +} + +export type ExpressionFunctionPhraseFilter = ExpressionFunctionDefinition< + 'rangeFilter', + null, + Arguments, + KibanaFilter +>; + +export const phraseFilterFunction: ExpressionFunctionPhraseFilter = { + name: 'rangeFilter', + type: 'kibana_filter', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.phraseFilter.help', { + defaultMessage: 'Create kibana phrase or phrases filter', + }), + args: { + field: { + types: ['kibana_field'], + required: true, + help: i18n.translate('data.search.functions.phraseFilter.field.help', { + defaultMessage: 'Specify the field you want to filter on. Use `field` function.', + }), + }, + phrase: { + types: ['string'], + multi: true, + required: true, + help: i18n.translate('data.search.functions.phraseFilter.phrase.help', { + defaultMessage: 'Specify the phrases', + }), + }, + negate: { + types: ['boolean'], + default: false, + help: i18n.translate('data.search.functions.phraseFilter.negate.help', { + defaultMessage: 'Should the filter be negated', + }), + }, + }, + + fn(input, args) { + if (args.phrase.length === 1) { + return { + type: 'kibana_filter', + ...buildFilter( + ({} as any) as IndexPattern, + args.field.spec, + FILTERS.PHRASE, + args.negate || false, + false, + args.phrase[0], + null + ), + }; + } + + return { + type: 'kibana_filter', + ...buildFilter( + ({} as any) as IndexPattern, + args.field.spec, + FILTERS.PHRASES, + args.negate || false, + false, + args.phrase, + null + ), + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/range.test.ts b/src/plugins/data/common/search/expressions/range.test.ts new file mode 100644 index 0000000000000..fbb4781c33a04 --- /dev/null +++ b/src/plugins/data/common/search/expressions/range.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { ExecutionContext } from 'src/plugins/expressions/common'; +import { functionWrapper } from './utils'; +import { rangeFunction } from './range'; + +describe('interpreter/functions#range', () => { + const fn = functionWrapper(rangeFunction); + let context: ExecutionContext; + + beforeEach(() => { + context = { + getSearchContext: () => ({}), + getSearchSessionId: () => undefined, + types: {}, + variables: {}, + abortSignal: {} as any, + inspectorAdapters: {} as any, + }; + }); + + it('returns an object with the correct structure', () => { + const actual = fn(null, { lt: 20, gt: 10 }, context); + expect(actual).toMatchInlineSnapshot(` + Object { + "gt": 10, + "gte": undefined, + "lt": 20, + "lte": undefined, + "type": "kibana_range", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/range.ts b/src/plugins/data/common/search/expressions/range.ts new file mode 100644 index 0000000000000..c7649a6e0669c --- /dev/null +++ b/src/plugins/data/common/search/expressions/range.ts @@ -0,0 +1,76 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, ExpressionValueBoxed } from 'src/plugins/expressions/common'; + +interface Arguments { + gt?: number | string; + lt?: number | string; + gte?: number | string; + lte?: number | string; +} + +export type KibanaRange = ExpressionValueBoxed<'kibana_range', Arguments>; + +export type ExpressionFunctionRange = ExpressionFunctionDefinition< + 'range', + null, + Arguments, + KibanaRange +>; + +export const rangeFunction: ExpressionFunctionRange = { + name: 'range', + type: 'kibana_range', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.range.help', { + defaultMessage: 'Create kibana range filter', + }), + args: { + gt: { + types: ['string', 'number'], + help: i18n.translate('data.search.functions.range.gt.help', { + defaultMessage: 'Greater than', + }), + }, + lt: { + types: ['string', 'number'], + help: i18n.translate('data.search.functions.range.lt.help', { + defaultMessage: 'Less than', + }), + }, + gte: { + types: ['string', 'number'], + help: i18n.translate('data.search.functions.range.gte.help', { + defaultMessage: 'Greater or equal than', + }), + }, + lte: { + types: ['string', 'number'], + help: i18n.translate('data.search.functions.range.lte.help', { + defaultMessage: 'Less or equal than', + }), + }, + }, + + fn(input, args) { + if (args.lt === undefined && args.lte === undefined) { + throw new Error('lt or lte must be provided'); + } + + if (args.gt === undefined && args.gte === undefined) { + throw new Error('gt or gte must be provided'); + } + + return { + type: 'kibana_range', + ...args, + }; + }, +}; diff --git a/src/plugins/data/common/search/expressions/range_filter.test.ts b/src/plugins/data/common/search/expressions/range_filter.test.ts new file mode 100644 index 0000000000000..92670f8a044ba --- /dev/null +++ b/src/plugins/data/common/search/expressions/range_filter.test.ts @@ -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 { createMockContext } from '../../../../expressions/common'; +import { functionWrapper } from './utils'; +import { rangeFilterFunction } from './range_filter'; + +describe('interpreter/functions#rangeFilter', () => { + const fn = functionWrapper(rangeFilterFunction); + + it('returns an object with the correct structure', () => { + const actual = fn( + null, + { field: { spec: { name: 'test' } }, range: { gt: 10, lt: 20 } }, + createMockContext() + ); + expect(actual).toMatchInlineSnapshot(` + Object { + "meta": Object { + "alias": null, + "disabled": false, + "index": undefined, + "negate": false, + "params": Object {}, + }, + "range": Object { + "test": Object { + "gte": 10, + "lt": 20, + }, + }, + "type": "kibana_filter", + } + `); + }); +}); diff --git a/src/plugins/data/common/search/expressions/range_filter.ts b/src/plugins/data/common/search/expressions/range_filter.ts new file mode 100644 index 0000000000000..ed71f5362fe85 --- /dev/null +++ b/src/plugins/data/common/search/expressions/range_filter.ts @@ -0,0 +1,74 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { KibanaField, KibanaFilter } from './kibana_context_type'; +import { buildFilter, FILTERS } from '../../es_query/filters'; +import { IndexPattern } from '../../index_patterns/index_patterns'; +import { KibanaRange } from './range'; + +interface Arguments { + field: KibanaField; + range: KibanaRange; + negate?: boolean; +} + +export type ExpressionFunctionRangeFilter = ExpressionFunctionDefinition< + 'rangeFilter', + null, + Arguments, + KibanaFilter +>; + +export const rangeFilterFunction: ExpressionFunctionRangeFilter = { + name: 'rangeFilter', + type: 'kibana_filter', + inputTypes: ['null'], + help: i18n.translate('data.search.functions.rangeFilter.help', { + defaultMessage: 'Create kibana range filter', + }), + args: { + field: { + types: ['kibana_field'], + required: true, + help: i18n.translate('data.search.functions.rangeFilter.field.help', { + defaultMessage: 'Specify the field you want to filter on. Use `field` function.', + }), + }, + range: { + types: ['kibana_range'], + required: true, + help: i18n.translate('data.search.functions.rangeFilter.range.help', { + defaultMessage: 'Specify the range, use `range` function.', + }), + }, + negate: { + types: ['boolean'], + default: false, + help: i18n.translate('data.search.functions.rangeFilter.negate.help', { + defaultMessage: 'Should the filter be negated', + }), + }, + }, + + fn(input, args) { + return { + type: 'kibana_filter', + ...buildFilter( + ({} as any) as IndexPattern, + args.field.spec, + FILTERS.RANGE, + args.negate || false, + false, + { from: args.range.gt || args.range.gte, to: args.range.lt || args.range.lte }, + null + ), + }; + }, +}; diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 8eb73ba62244f..94fa5b7230f69 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -28,6 +28,12 @@ import { kibanaTimerangeFunction, luceneFunction, kqlFunction, + fieldFunction, + rangeFunction, + existsFilterFunction, + rangeFilterFunction, + kibanaFilterFunction, + phraseFilterFunction, } from '../../common/search'; import { getCallMsearch } from './legacy'; import { AggsService, AggsStartDependencies } from './aggs'; @@ -108,6 +114,12 @@ export class SearchService implements Plugin { expressions.registerFunction(luceneFunction); expressions.registerFunction(kqlFunction); expressions.registerFunction(kibanaTimerangeFunction); + expressions.registerFunction(fieldFunction); + expressions.registerFunction(rangeFunction); + expressions.registerFunction(kibanaFilterFunction); + expressions.registerFunction(existsFilterFunction); + expressions.registerFunction(rangeFilterFunction); + expressions.registerFunction(phraseFilterFunction); expressions.registerType(kibanaContext); expressions.registerFunction(esdsl); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index ab9fc84d51187..69710e82b73b4 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -43,6 +43,8 @@ import { registerUsageCollector } from './collectors/register'; import { usageProvider } from './collectors/usage'; import { searchTelemetry } from '../saved_objects'; import { + existsFilterFunction, + fieldFunction, IEsSearchRequest, IEsSearchResponse, IKibanaSearchRequest, @@ -52,11 +54,15 @@ import { kibanaContext, kibanaContextFunction, kibanaTimerangeFunction, + kibanaFilterFunction, kqlFunction, luceneFunction, + rangeFilterFunction, + rangeFunction, SearchSourceDependencies, searchSourceRequiredUiSettings, SearchSourceService, + phraseFilterFunction, } from '../../common/search'; import { getEsaggs } from './expressions'; import { @@ -149,6 +155,12 @@ export class SearchService implements Plugin { expressions.registerFunction(kqlFunction); expressions.registerFunction(kibanaTimerangeFunction); expressions.registerFunction(kibanaContextFunction); + expressions.registerFunction(fieldFunction); + expressions.registerFunction(rangeFunction); + expressions.registerFunction(kibanaFilterFunction); + expressions.registerFunction(existsFilterFunction); + expressions.registerFunction(rangeFilterFunction); + expressions.registerFunction(phraseFilterFunction); expressions.registerType(kibanaContext); const aggs = this.aggsService.setup({ registerFunction: expressions.registerFunction }); diff --git a/src/plugins/expressions/common/expression_types/index.ts b/src/plugins/expressions/common/expression_types/index.ts index 169dd434cf430..c7f0340a52fad 100644 --- a/src/plugins/expressions/common/expression_types/index.ts +++ b/src/plugins/expressions/common/expression_types/index.ts @@ -11,3 +11,4 @@ export * from './get_type'; export * from './serialize_provider'; export * from './expression_type'; export * from './specs'; +export * from './unbox_expression_value'; diff --git a/src/plugins/expressions/common/expression_types/unbox_expression_value.test.ts b/src/plugins/expressions/common/expression_types/unbox_expression_value.test.ts new file mode 100644 index 0000000000000..ace99d4925e9f --- /dev/null +++ b/src/plugins/expressions/common/expression_types/unbox_expression_value.test.ts @@ -0,0 +1,17 @@ +/* + * 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 { unboxExpressionValue } from './unbox_expression_value'; + +describe('unboxExpressionValue()', () => { + it('should remove type property from a boxed value', () => { + const expressionValue = { type: 'something', value: 'something' }; + + expect(unboxExpressionValue(expressionValue)).toEqual({ value: 'something' }); + }); +}); diff --git a/src/plugins/expressions/common/expression_types/unbox_expression_value.ts b/src/plugins/expressions/common/expression_types/unbox_expression_value.ts new file mode 100644 index 0000000000000..bb41efdf15609 --- /dev/null +++ b/src/plugins/expressions/common/expression_types/unbox_expression_value.ts @@ -0,0 +1,16 @@ +/* + * 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 { ExpressionValueBoxed } from './types'; + +export function unboxExpressionValue({ + type, + ...value +}: ExpressionValueBoxed): T { + return value as T; +} diff --git a/src/plugins/expressions/common/util/index.ts b/src/plugins/expressions/common/util/index.ts index 5f83d962d5aea..470dfc3c2d436 100644 --- a/src/plugins/expressions/common/util/index.ts +++ b/src/plugins/expressions/common/util/index.ts @@ -10,3 +10,4 @@ export * from './create_error'; export * from './get_by_alias'; export * from './tables_adapter'; export * from './expressions_inspector_adapter'; +export * from './test_utils'; diff --git a/src/plugins/expressions/common/util/test_utils.ts b/src/plugins/expressions/common/util/test_utils.ts new file mode 100644 index 0000000000000..59bd0a4235d9b --- /dev/null +++ b/src/plugins/expressions/common/util/test_utils.ts @@ -0,0 +1,20 @@ +/* + * 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 { ExecutionContext } from '../execution'; + +export const createMockContext = () => { + return { + getSearchContext: () => ({}), + getSearchSessionId: () => undefined, + types: {}, + variables: {}, + abortSignal: {} as any, + inspectorAdapters: {} as any, + } as ExecutionContext; +}; diff --git a/src/plugins/visualizations/public/embeddable/to_ast.ts b/src/plugins/visualizations/public/embeddable/to_ast.ts index 7ccff9394943a..95b64fe4e6e17 100644 --- a/src/plugins/visualizations/public/embeddable/to_ast.ts +++ b/src/plugins/visualizations/public/embeddable/to_ast.ts @@ -10,7 +10,7 @@ import { ExpressionFunctionKibana, ExpressionFunctionKibanaContext } from '../.. import { buildExpression, buildExpressionFunction } from '../../../expressions/public'; import { VisToExpressionAst } from '../types'; -import { queryToAst } from '../../../data/common'; +import { queryToAst, filtersToAst } from '../../../data/common'; /** * Creates an ast expression for a visualization based on kibana context (query, filters, timerange) @@ -22,12 +22,15 @@ import { queryToAst } from '../../../data/common'; export const toExpressionAst: VisToExpressionAst = async (vis, params) => { const { savedSearchId, searchSource } = vis.data; const query = searchSource?.getField('query'); - const filters = searchSource?.getField('filter'); + let filters = searchSource?.getField('filter'); + if (typeof filters === 'function') { + filters = filters(); + } const kibana = buildExpressionFunction('kibana', {}); const kibanaContext = buildExpressionFunction('kibana_context', { q: query && queryToAst(query), - filters: filters && JSON.stringify(filters), + filters: filters && filtersToAst(filters), savedSearchId, });