From c05f9a50bcfee6216704a07be039ae421d60f076 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 2 Apr 2018 20:07:25 -0700 Subject: [PATCH] [savedObjects] add experimental filter definition syntax --- src/dev/jest/config.js | 1 + .../client/__tests__/saved_objects_client.js | 3 +- .../lib/search_dsl/__tests__/search_dsl.js | 6 +- .../__snapshots__/convert.test.js.snap | 278 ++++++++++++++++++ .../parse_and_validate_from_api.test.js.snap | 172 +++++++++++ .../search_dsl/experimental_filter/convert.js | 70 +++++ .../experimental_filter/convert.test.js | 43 +++ .../search_dsl/experimental_filter/index.js | 3 + .../parse_and_validate_from_api.js | 93 ++++++ .../parse_and_validate_from_api.test.js | 189 ++++++++++++ .../search_dsl/experimental_filter/schemas.js | 36 +++ .../client/lib/search_dsl/query_params.js | 15 +- .../client/lib/search_dsl/search_dsl.js | 5 +- .../client/saved_objects_client.js | 5 +- src/server/saved_objects/routes/find.js | 47 ++- src/server/saved_objects/routes/index.js | 2 +- .../saved_objects/saved_objects_mixin.js | 2 + .../saved_objects/saved_objects_client.js | 10 +- 18 files changed, 960 insertions(+), 20 deletions(-) create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/convert.test.js.snap create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/parse_and_validate_from_api.test.js.snap create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.js create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.test.js create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/index.js create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.js create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.test.js create mode 100644 src/server/saved_objects/client/lib/search_dsl/experimental_filter/schemas.js diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index f25ca01539060ed..ed61e1a712cba42 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -3,6 +3,7 @@ export default { roots: [ '/src/ui', '/src/core_plugins', + '/src/server', '/packages', ], collectCoverageFrom: [ diff --git a/src/server/saved_objects/client/__tests__/saved_objects_client.js b/src/server/saved_objects/client/__tests__/saved_objects_client.js index 88cd9e983d43b1d..968f84a33638937 100644 --- a/src/server/saved_objects/client/__tests__/saved_objects_client.js +++ b/src/server/saved_objects/client/__tests__/saved_objects_client.js @@ -373,7 +373,8 @@ describe('SavedObjectsClient', () => { searchFields: ['foo'], type: 'bar', sortField: 'name', - sortOrder: 'desc' + sortOrder: 'desc', + experimentalFilter: null }; await savedObjectsClient.find(relevantOpts); diff --git a/src/server/saved_objects/client/lib/search_dsl/__tests__/search_dsl.js b/src/server/saved_objects/client/lib/search_dsl/__tests__/search_dsl.js index b65e4350ec174f5..f8791c43840a8bd 100644 --- a/src/server/saved_objects/client/lib/search_dsl/__tests__/search_dsl.js +++ b/src/server/saved_objects/client/lib/search_dsl/__tests__/search_dsl.js @@ -28,13 +28,14 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, type, search, searchFields) to getQueryParams', () => { + it('passes (mappings, type, search, searchFields, experimentalFilter) to getQueryParams', () => { const spy = sandbox.spy(queryParamsNS, 'getQueryParams'); const mappings = { type: { properties: {} } }; const opts = { type: 'foo', search: 'bar', - searchFields: ['baz'] + searchFields: ['baz'], + experimentalFilter: null }; getSearchDsl(mappings, opts); @@ -45,6 +46,7 @@ describe('getSearchDsl', () => { opts.type, opts.search, opts.searchFields, + opts.experimentalFilter ); }); diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/convert.test.js.snap b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/convert.test.js.snap new file mode 100644 index 000000000000000..e88f2f58ba9d3e4 --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/convert.test.js.snap @@ -0,0 +1,278 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SavedObjectsClient/experimentalFilter properly converts complex example 1`] = ` +Object { + "bool": Object { + "must": Array [ + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.stars": Object { + "gt": 3, + "gte": undefined, + "lt": undefined, + "lte": undefined, + }, + }, + }, + Object { + "range": Object { + "dashboard.stars": Object { + "gt": 3, + "gte": undefined, + "lt": undefined, + "lte": undefined, + }, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.followers": Object { + "gt": undefined, + "gte": undefined, + "lt": undefined, + "lte": 5, + }, + }, + }, + Object { + "range": Object { + "dashboard.followers": Object { + "gt": undefined, + "gte": undefined, + "lt": undefined, + "lte": 5, + }, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.created": Object { + "gt": undefined, + "gte": 2018-03-02T07:00:00.000Z, + "lt": 2018-03-03T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + Object { + "range": Object { + "dashboard.created": Object { + "gt": undefined, + "gte": 2018-03-02T07:00:00.000Z, + "lt": 2018-03-03T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.created": Object { + "gt": undefined, + "gte": 2018-02-25T07:00:00.000Z, + "lt": 2018-03-01T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + Object { + "range": Object { + "dashboard.created": Object { + "gt": undefined, + "gte": 2018-02-25T07:00:00.000Z, + "lt": 2018-03-01T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.created": Object { + "gt": undefined, + "gte": undefined, + "lt": 2012-01-01T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + Object { + "range": Object { + "dashboard.created": Object { + "gt": undefined, + "gte": undefined, + "lt": 2012-01-01T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + ], + }, + }, + Object { + "multi_match": Object { + "fields": undefined, + "query": "dashboard", + "type": "phrase", + }, + }, + Object { + "multi_match": Object { + "fields": Array [ + "visualize.active", + "dashboard.active", + ], + "query": true, + "type": "phrase", + }, + }, + Object { + "bool": Object { + "must": Array [], + "must_not": Array [], + "should": Array [ + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.created": Object { + "gt": undefined, + "gte": 2018-03-02T07:00:00.000Z, + "lt": 2018-03-03T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + Object { + "range": Object { + "dashboard.created": Object { + "gt": undefined, + "gte": 2018-03-02T07:00:00.000Z, + "lt": 2018-03-03T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "range": Object { + "visualize.created": Object { + "gt": undefined, + "gte": 2018-03-01T07:00:00.000Z, + "lt": 2018-03-02T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + Object { + "range": Object { + "dashboard.created": Object { + "gt": undefined, + "gte": 2018-03-01T07:00:00.000Z, + "lt": 2018-03-02T07:00:00.000Z, + "lte": undefined, + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + "must_not": Array [ + Object { + "multi_match": Object { + "fields": Array [ + "visualize.status", + "dashboard.status", + ], + "query": "open", + "type": "phrase", + }, + }, + Object { + "multi_match": Object { + "fields": Array [ + "visualize.owner", + "dashboard.owner", + ], + "query": "dewey", + "type": "phrase", + }, + }, + Object { + "bool": Object { + "must": Array [], + "must_not": Array [], + "should": Array [ + Object { + "multi_match": Object { + "fields": Array [ + "visualize.tag", + "dashboard.tag", + ], + "query": "finance", + "type": "phrase", + }, + }, + Object { + "multi_match": Object { + "fields": Array [ + "visualize.tag", + "dashboard.tag", + ], + "query": "eng", + "type": "phrase", + }, + }, + Object { + "multi_match": Object { + "fields": Array [ + "visualize.tag", + "dashboard.tag", + ], + "query": "ga", + "type": "phrase", + }, + }, + ], + }, + }, + ], + "should": Array [], + }, +} +`; diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/parse_and_validate_from_api.test.js.snap b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/parse_and_validate_from_api.test.js.snap new file mode 100644 index 000000000000000..d59d20f8d610c18 --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/__snapshots__/parse_and_validate_from_api.test.js.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects nested filter types 1`] = ` +Object { + "must": Array [ + Object { + "field": "foo", + "type": "value", + "value": "bar", + }, + Object { + "must": Array [], + "must_not": Array [], + "some": Array [ + Object { + "field": "foo2", + "type": "value", + "value": "bar2", + }, + Object { + "field": "foo2", + "type": "value", + "value": "bar3", + }, + ], + "type": "bool", + }, + ], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects with must and must_not 1`] = ` +Object { + "must": Array [], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects with must and some 1`] = ` +Object { + "must": Array [], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects with must_not and some 1`] = ` +Object { + "must": Array [], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects with single must 1`] = ` +Object { + "must": Array [], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects with single must_not 1`] = ` +Object { + "must": Array [], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types bool detects with single some 1`] = ` +Object { + "must": Array [], + "must_not": Array [], + "some": Array [], + "type": "bool", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors if gt and gte combined 1`] = `"Invalid range filter: \\"gt\\" must not exist simultaneously with [gte]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors if gt and must combined 1`] = `"Filter property \\"must\\" can't be used with properties \\"field\\" and \\"gt\\""`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors if gt and value combined 1`] = `"Filter property \\"value\\" can't be used with properties \\"field\\" and \\"gt\\""`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors if only field 1`] = `"Unable to determine filter type when only specifying property \\"field\\""`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors with gt and lt 1`] = `"Invalid range filter: child \\"field\\" fails because [\\"field\\" is required]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors with just gt 1`] = `"Invalid range filter: child \\"field\\" fails because [\\"field\\" is required]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors with just gte 1`] = `"Invalid range filter: child \\"field\\" fails because [\\"field\\" is required]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors with just lt 1`] = `"Invalid range filter: child \\"field\\" fails because [\\"field\\" is required]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors with just lte 1`] = `"Invalid range filter: child \\"field\\" fails because [\\"field\\" is required]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types errors errors with lte and gt 1`] = `"Invalid range filter: child \\"field\\" fails because [\\"field\\" is required]"`; + +exports[`SavedObjectsClient experimental_filter/detect_types range detects with gt and field 1`] = ` +Object { + "field": "foo", + "gt": 100, + "type": "range", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types range detects with gt, lt and field 1`] = ` +Object { + "field": "foo", + "gt": 100, + "lt": 100, + "type": "range", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types range detects with gte and field 1`] = ` +Object { + "field": "foo", + "gte": 100, + "type": "range", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types range detects with lt and field 1`] = ` +Object { + "field": "foo", + "lt": 10, + "type": "range", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types range detects with lte and field 1`] = ` +Object { + "field": "foo", + "lte": 100, + "type": "range", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types range detects with lte, gt and field 1`] = ` +Object { + "field": "foo", + "gt": 100, + "lte": 100, + "type": "range", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types value detects with just value 1`] = ` +Object { + "type": "value", + "value": "foo", +} +`; + +exports[`SavedObjectsClient experimental_filter/detect_types value detects with value and field 1`] = ` +Object { + "field": "foo", + "type": "value", + "value": "bar", +} +`; diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.js b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.js new file mode 100644 index 000000000000000..c8d9183ab4cfd74 --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.js @@ -0,0 +1,70 @@ +import { parseAndValidateFromApi } from './parse_and_validate_from_api'; + +export function convertFilterToEsDsl(types, apiParam) { + return convertFilter(types, parseAndValidateFromApi(apiParam)); +} + +function getFilterFields(types, field) { + switch (field) { + case 'type': + case 'updated_at': + return [field]; + + default: + return types.reduce((acc, t) => [ + ...acc, + `${t}.${field}` + ], []); + } +} + +function convertFilter(types, filter) { + switch (filter.type) { + case 'value': { + return { + multi_match: { + type: 'phrase', + query: filter.value, + fields: filter.field + ? getFilterFields(types, filter.field) + : undefined, + } + }; + } + + case 'range': { + const filters = getFilterFields(types, filter.field).map(field => ({ + range: { + [field]: { + gt: filter.gt, + gte: filter.gte, + lt: filter.lt, + lte: filter.lte, + } + } + })); + + if (filters.length > 1) { + return { + bool: { + should: filters + } + }; + } + + return filters[0]; + } + + case 'bool': + return { + bool: { + must: filter.must.map(filter => convertFilter(types, filter)), + must_not: filter.must_not.map(filter => convertFilter(types, filter)), + should: filter.some.map(filter => convertFilter(types, filter)), + } + }; + + default: + throw new Error(`unexpected filter.type "${filter.type}"`); + } +} diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.test.js b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.test.js new file mode 100644 index 000000000000000..a1a9e93712d939b --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/convert.test.js @@ -0,0 +1,43 @@ +import { convertFilterToEsDsl } from './convert'; + +describe('SavedObjectsClient/experimentalFilter', () => { + it('properly converts complex example', () => { + expect(convertFilterToEsDsl(['visualize', 'dashboard'], { + must: [ + // numeric ranges + { field: 'stars', gt: 3 }, + { field: 'followers', lte: 5 }, + + // date ranges + { field: 'created', gte: '2018-03-02T00:00:00-07:00', lt: '2018-03-03T00:00:00-07:00' }, + { field: 'created', gte: '2018-02-25T00:00:00-07:00', lt: '2018-03-01T00:00:00-07:00' }, + { field: 'created', lt: '2012-01-01T00:00:00-07:00' }, + + // match, field not required + { value: 'dashboard' }, + { field: 'active', value: true }, + + // bools can be nested + { + some: [ + // today + { field: 'created', gte: '2018-03-02T00:00:00-07:00', lt: '2018-03-03T00:00:00-07:00' }, + // yesterday + { field: 'created', gte: '2018-03-01T00:00:00-07:00', lt: '2018-03-02T00:00:00-07:00' } + ] + } + ], + must_not: [ + { field: 'status', value: 'open' }, + { field: 'owner', value: 'dewey' }, + { + some: [ + { field: 'tag', value: 'finance' }, + { field: 'tag', value: 'eng' }, + { field: 'tag', value: 'ga' } + ] + } + ] + })).toMatchSnapshot(); + }); +}); diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/index.js b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/index.js new file mode 100644 index 000000000000000..f048bf5a150f19d --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/index.js @@ -0,0 +1,3 @@ +export { + convertFilterToEsDsl as convertExperimentalFilterToEsDsl +} from './convert'; diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.js b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.js new file mode 100644 index 000000000000000..0cdc693703fe0bb --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.js @@ -0,0 +1,93 @@ +import Boom from 'boom'; + +import { formatListAsProse } from '../../../../../../utils'; +import { SCHEMAS } from './schemas'; + +const TYPES_BY_KEY = { + field: ['value', 'range'], + value: ['value'], + lt: ['range'], + lte: ['range'], + gt: ['range'], + gte: ['range'], + must: ['bool'], + must_not: ['bool'], + some: ['bool'], +}; + +const VALID_FILTER_KEYS = formatListAsProse(Object.keys(TYPES_BY_KEY)); + +function keysAsProse(keys) { + return `${keys.length > 1 ? 'properties' : 'property'} ${formatListAsProse(keys.map(k => `"${k}"`))}`; +} + +export function parseAndValidateFromApi(apiParam) { + const type = getType(apiParam); + const { error, value: filter } = SCHEMAS[type].validate(apiParam); + + if (error) { + throw Boom.boomify(error, { + message: `Invalid ${type} filter`, + statusCode: 400 + }); + } + + if (type !== 'bool') { + return { + type, + ...filter + }; + } + + return { + type: 'bool', + ...filter, + must: (filter.must || []).map(parseAndValidateFromApi), + must_not: (filter.must_not || []).map(parseAndValidateFromApi), + some: (filter.some || []).map(parseAndValidateFromApi), + }; +} + +function getType(filter) { + const keys = Object.keys(filter); + let possibleTypes; + + // map each of the keys in the filter to a list of possible + // filter types and narrow the list down for each key, verifying + // that the key is both known and that it is a valid key to + // be combined with the others + for (const [i, key] of keys.entries()) { + // unknown key + if (!TYPES_BY_KEY.hasOwnProperty(key)) { + throw Boom.badRequest(`Unknown filter property "${key}", valid options are ${VALID_FILTER_KEYS}`); + } + + // shortcut for first key + if (!possibleTypes) { + possibleTypes = TYPES_BY_KEY[key]; + continue; + } + + // filter the list of possible types to the intersection + // of the existing possible types and the types possible + // for this key + possibleTypes = possibleTypes + .filter(t => TYPES_BY_KEY[key].includes(t)); + + // attempt to be helpful if a key causes the list of + // possible types to empty + if (possibleTypes.length === 0) { + throw Boom.badRequest( + `Filter property "${key}" can't be used with ${keysAsProse(keys.slice(0, i))}` + ); + } + } + + if (possibleTypes.length > 1) { + throw Boom.badRequest( + `Unable to determine filter type when only specifying ${keysAsProse(keys)}` + ); + } + + return possibleTypes[0]; +} diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.test.js b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.test.js new file mode 100644 index 000000000000000..39b0b324396bea2 --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/parse_and_validate_from_api.test.js @@ -0,0 +1,189 @@ +import { parseAndValidateFromApi } from './parse_and_validate_from_api'; + +describe('SavedObjectsClient experimental_filter/detect_types', () => { + describe('value', () => { + it('detects with just value', () => { + expect(parseAndValidateFromApi({ + value: 'foo' + })).toMatchSnapshot(); + }); + + it('detects with value and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + value: 'bar' + })).toMatchSnapshot(); + }); + }); + + describe('range', () => { + it('detects with lt and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + lt: 10 + })).toMatchSnapshot(); + }); + + it('detects with lte and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + lte: 100 + })).toMatchSnapshot(); + }); + + it('detects with gt and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + gt: 100, + })).toMatchSnapshot(); + }); + + it('detects with gte and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + gte: 100 + })).toMatchSnapshot(); + }); + + it('detects with lte, gt and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + lte: 100, + gt: 100 + })).toMatchSnapshot(); + }); + + it('detects with gt, lt and field', () => { + expect(parseAndValidateFromApi({ + field: 'foo', + gt: 100, + lt: 100, + })).toMatchSnapshot(); + }); + }); + + describe('bool', () => { + it('detects with single must', () => { + expect(parseAndValidateFromApi({ + must: [], + })).toMatchSnapshot(); + }); + + it('detects with single must_not', () => { + expect(parseAndValidateFromApi({ + must_not: [], + })).toMatchSnapshot(); + }); + + it('detects with single some', () => { + expect(parseAndValidateFromApi({ + some: [], + })).toMatchSnapshot(); + }); + + it('detects with must and must_not', () => { + expect(parseAndValidateFromApi({ + must: [], + must_not: [], + })).toMatchSnapshot(); + }); + + it('detects with must_not and some', () => { + expect(parseAndValidateFromApi({ + must_not: [], + some: [], + })).toMatchSnapshot(); + }); + + it('detects with must and some', () => { + expect(parseAndValidateFromApi({ + must: [], + some: [], + })).toMatchSnapshot(); + }); + + it('detects nested filter types', () => { + expect(parseAndValidateFromApi({ + must: [ + { field: 'foo', value: 'bar' }, + { + some: [ + { field: 'foo2', value: 'bar2' }, + { field: 'foo2', value: 'bar3' }, + ] + } + ], + })).toMatchSnapshot(); + }); + }); + + describe('errors', () => { + it('errors with just lt', () => { + expect(() => parseAndValidateFromApi({ + lt: 10 + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors with just lte', () => { + expect(() => parseAndValidateFromApi({ + lte: 100 + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors with just gt', () => { + expect(() => parseAndValidateFromApi({ + gt: 100, + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors with just gte', () => { + expect(() => parseAndValidateFromApi({ + gte: 100 + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors with lte and gt', () => { + expect(() => parseAndValidateFromApi({ + lte: 100, + gt: 100 + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors with gt and lt', () => { + expect(() => parseAndValidateFromApi({ + gt: 100, + lt: 100, + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors if gt and value combined', () => { + expect(() => parseAndValidateFromApi({ + field: 'foo', + gt: 100, + value: 'bar' + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors if gt and gte combined', () => { + expect(() => parseAndValidateFromApi({ + field: 'foo', + gt: 100, + gte: 200 + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors if gt and must combined', () => { + expect(() => parseAndValidateFromApi({ + field: 'foo', + gt: 100, + must: [] + })).toThrowErrorMatchingSnapshot(); + }); + + it('errors if only field', () => { + expect(() => parseAndValidateFromApi({ + field: 'foo', + })).toThrowErrorMatchingSnapshot(); + }); + }); +}); diff --git a/src/server/saved_objects/client/lib/search_dsl/experimental_filter/schemas.js b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/schemas.js new file mode 100644 index 000000000000000..5f54edb51c5b0e4 --- /dev/null +++ b/src/server/saved_objects/client/lib/search_dsl/experimental_filter/schemas.js @@ -0,0 +1,36 @@ +import Joi from 'joi'; + +const dateOrNumber = Joi.alternatives().try( + Joi.number(), + Joi.date().iso() +); + +const valueFilterSchema = Joi.object().keys({ + field: Joi.string().optional(), + value: Joi.alternatives() + .try(Joi.string(), Joi.boolean(), Joi.number()) + .required(), +}).required(); + +const rangeFilterSchema = Joi.object().keys({ + field: Joi.string().required(), + gt: dateOrNumber, + gte: dateOrNumber, + lt: dateOrNumber, + lte: dateOrNumber, +}) + .nand('gt', 'gte') + .nand('lt', 'lte') + .required(); + +const boolFilterSchema = Joi.object().keys({ + must: Joi.array().optional(), + must_not: Joi.array().optional(), + some: Joi.array().optional(), +}).required(); + +export const SCHEMAS = { + bool: boolFilterSchema, + value: valueFilterSchema, + range: rangeFilterSchema, +}; diff --git a/src/server/saved_objects/client/lib/search_dsl/query_params.js b/src/server/saved_objects/client/lib/search_dsl/query_params.js index 3b29aa41b6d22b5..e83256afbb788f6 100644 --- a/src/server/saved_objects/client/lib/search_dsl/query_params.js +++ b/src/server/saved_objects/client/lib/search_dsl/query_params.js @@ -1,4 +1,5 @@ import { getRootProperties } from '../../../../mappings'; +import { convertExperimentalFilterToEsDsl } from './experimental_filter'; /** * Get the field params based on the types and searchFields @@ -27,14 +28,16 @@ function getFieldsForTypes(searchFields, types) { * @param {Object} type * @param {String} search * @param {Array} searchFields + * @param {Object} experimentalFilter * @return {Object} */ -export function getQueryParams(mappings, type, search, searchFields) { - if (!type && !search) { +export function getQueryParams(mappings, type, search, searchFields, experimentalFilter) { + if (!type && !search && !experimentalFilter) { return {}; } const bool = {}; + const savedObjectTypes = type ? [type] : Object.keys(getRootProperties(mappings)); if (type) { bool.filter = [ @@ -49,12 +52,18 @@ export function getQueryParams(mappings, type, search, searchFields) { query: search, ...getFieldsForTypes( searchFields, - type ? [type] : Object.keys(getRootProperties(mappings)) + savedObjectTypes ) } } ]; } + if (experimentalFilter) { + bool.must = (bool.must || []).concat( + convertExperimentalFilterToEsDsl(savedObjectTypes, experimentalFilter) + ); + } + return { query: { bool } }; } diff --git a/src/server/saved_objects/client/lib/search_dsl/search_dsl.js b/src/server/saved_objects/client/lib/search_dsl/search_dsl.js index a76f91eb11b0b14..64ae0a02be7c744 100644 --- a/src/server/saved_objects/client/lib/search_dsl/search_dsl.js +++ b/src/server/saved_objects/client/lib/search_dsl/search_dsl.js @@ -9,7 +9,8 @@ export function getSearchDsl(mappings, options = {}) { search, searchFields, sortField, - sortOrder + sortOrder, + experimentalFilter, } = options; if (!type && sortField) { @@ -21,7 +22,7 @@ export function getSearchDsl(mappings, options = {}) { } return { - ...getQueryParams(mappings, type, search, searchFields), + ...getQueryParams(mappings, type, search, searchFields, experimentalFilter), ...getSortingParams(mappings, type, sortField, sortOrder), }; } diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 79dc55540e039d5..5a2ed79a8a88c32 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -260,6 +260,7 @@ export class SavedObjectsClient { * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] + * @property {Object} [options.experimentalFilter] - A filter object that the Saved Objects API should apply. More info to come... * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ async find(options = {}) { @@ -272,6 +273,7 @@ export class SavedObjectsClient { sortField, sortOrder, fields, + experimentalFilter, } = options; if (searchFields && !Array.isArray(searchFields)) { @@ -295,7 +297,8 @@ export class SavedObjectsClient { searchFields, type, sortField, - sortOrder + sortOrder, + experimentalFilter }) } }; diff --git a/src/server/saved_objects/routes/find.js b/src/server/saved_objects/routes/find.js index 0e81d621d017b1c..e4dafdf4abd47ce 100644 --- a/src/server/saved_objects/routes/find.js +++ b/src/server/saved_objects/routes/find.js @@ -1,20 +1,22 @@ import Joi from 'joi'; import { keysToCamelCaseShallow } from '../../../utils/case_conversion'; +const querySchema = Joi.object().keys({ + per_page: Joi.number().min(0).default(20), + page: Joi.number().min(0).default(1), + type: Joi.string(), + search: Joi.string().allow('').optional(), + search_fields: Joi.array().items(Joi.string()).single(), + fields: Joi.array().items(Joi.string()).single() +}).default(); + export const createFindRoute = (prereqs) => ({ path: '/api/saved_objects/_find', method: 'GET', config: { pre: [prereqs.getSavedObjectsClient], validate: { - query: Joi.object().keys({ - per_page: Joi.number().min(0).default(20), - page: Joi.number().min(0).default(1), - type: Joi.string(), - search: Joi.string().allow('').optional(), - search_fields: Joi.array().items(Joi.string()).single(), - fields: Joi.array().items(Joi.string()).single() - }).default() + query: querySchema }, handler(request, reply) { const options = keysToCamelCaseShallow(request.query); @@ -22,3 +24,32 @@ export const createFindRoute = (prereqs) => ({ } } }); + +export const createFindPostRoute = (prereqs) => ({ + path: '/api/saved_objects/_find', + method: 'POST', + config: { + pre: [prereqs.getSavedObjectsClient], + payload: { + output: 'data', + parse: true, + allow: 'application/json' + }, + validate: { + query: querySchema, + payload: Joi.object().keys({ + // experimentalFilter is validated in SavedObjectClient because + // it is pretty complicated and we can produce better, more + // detailed error messages than Joi can + experimental_filter: Joi.object() + }).default() + }, + handler(request, reply) { + const options = keysToCamelCaseShallow(request.query); + reply(request.pre.savedObjectsClient.find({ + ...options, + experimentalFilter: request.payload.experimental_filter + })); + } + } +}); diff --git a/src/server/saved_objects/routes/index.js b/src/server/saved_objects/routes/index.js index 865962c0bb1110c..fbcb11d6d839be7 100644 --- a/src/server/saved_objects/routes/index.js +++ b/src/server/saved_objects/routes/index.js @@ -1,6 +1,6 @@ export { createBulkGetRoute } from './bulk_get'; export { createCreateRoute } from './create'; export { createDeleteRoute } from './delete'; -export { createFindRoute } from './find'; +export { createFindRoute, createFindPostRoute } from './find'; export { createGetRoute } from './get'; export { createUpdateRoute } from './update'; diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index 9a8ceb97704ee1e..46262060a3750d0 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -5,6 +5,7 @@ import { createCreateRoute, createDeleteRoute, createFindRoute, + createFindPostRoute, createGetRoute, createUpdateRoute, } from './routes'; @@ -23,6 +24,7 @@ export function savedObjectsMixin(kbnServer, server) { server.route(createCreateRoute(prereqs)); server.route(createDeleteRoute(prereqs)); server.route(createFindRoute(prereqs)); + server.route(createFindPostRoute(prereqs)); server.route(createGetRoute(prereqs)); server.route(createUpdateRoute(prereqs)); diff --git a/src/ui/public/saved_objects/saved_objects_client.js b/src/ui/public/saved_objects/saved_objects_client.js index f8e61add981f427..8e9519299f2c64d 100644 --- a/src/ui/public/saved_objects/saved_objects_client.js +++ b/src/ui/public/saved_objects/saved_objects_client.js @@ -79,12 +79,18 @@ export class SavedObjectsClient { * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] * @property {array} options.fields + * @property {object} options.experimentalFilter * @returns {promise} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ]} */ find(options = {}) { - const url = this._getUrl(['_find'], keysToSnakeCaseShallow(options)); + const { experimentalFilter, ...queryParams } = options; + const url = this._getUrl(['_find'], keysToSnakeCaseShallow(queryParams)); - return this._request('GET', url).then(resp => { + const reqPromise = experimentalFilter + ? this._request('POST', url, { experimental_filter: experimentalFilter }) + : this._request('GET', url); + + return reqPromise.then(resp => { resp.saved_objects = resp.saved_objects.map(d => this.createSavedObject(d)); return keysToCamelCaseShallow(resp); });