From 13093983f68f751d5e28478e5267a69188c5719f Mon Sep 17 00:00:00 2001 From: uboness Date: Fri, 30 Mar 2018 02:15:20 +0200 Subject: [PATCH] [SearchBar] Dates, Booleans, Numbers and Range Clauses (#485) Added date, boolean and number support to the Query language. With that, also added support for the following operations on field clauses: - greater than (`gt`) - greater than or equals (`gte`) - less than (`lt`) - less than or equals (`lte`) When it comes to date values, we try to cover a large range of user friendly formats, including terms such as: `today`, `yesterday`, `last week`, `next week`, `this week`, `this month` and `last/next month/year`. Date operation also respect the granularity of the date.. for example, `today` refers to the full day, therefore the value has a `DAY` granularity. Therefore, the expression `date:'today'` will match any value that falls in the full day. With this change, the search bar also supports a "schema" - this enables defining a more granular set of rules of how the query should be interpreted. For example, the schema may state that field `count` is of type 'number' - if the user tries to assign a string to this field, a parsing error will be triggered. Aside from the data types, the schema enables defining finer validation logic for the assigned field values. The `EuiInMemoryTable` was updated such that it is now possible to set `schema: true` on its search box. This will deduce a schema from the configured columns. (this can further be enhanced in the future, by supporting `searchable` and `validate` properties on the table columns). --- CHANGELOG.md | 1 + package.json | 2 +- src-docs/src/views/search_bar/props_info.js | 54 ++ src-docs/src/views/search_bar/search_bar.js | 227 ++++--- .../views/search_bar/search_bar_example.js | 5 +- .../tables/in_memory/in_memory_search.js | 19 +- src/components/basic_table/in_memory_table.js | 44 +- src/components/search_bar/index.js | 1 - .../__snapshots__/ast_to_es.test.js.snap | 119 ++++ src/components/search_bar/query/ast.js | 101 ++- src/components/search_bar/query/ast_to_es.js | 203 ++++-- .../search_bar/query/ast_to_es.test.js | 67 +- .../search_bar/query/date_format.js | 334 ++++++++++ .../search_bar/query/date_format.test.js | 351 +++++++++++ src/components/search_bar/query/date_value.js | 57 ++ .../search_bar/query/date_value.test.js | 22 + .../search_bar/query/default_syntax.js | 271 ++++++-- .../search_bar/query/default_syntax.test.js | 381 +++++++++++- .../search_bar/query/execute_ast.js | 37 +- .../search_bar/query/execute_ast.test.js | 130 +++- src/components/search_bar/query/index.js | 3 + src/components/search_bar/query/must.js | 32 - src/components/search_bar/query/must_not.js | 32 - src/components/search_bar/query/operators.js | 141 +++++ .../search_bar/query/operators.test.js | 587 ++++++++++++++++++ src/components/search_bar/query/query.js | 9 +- src/components/search_bar/search_bar.js | 20 +- src/components/search_bar/search_box.js | 9 +- src/services/format/index.js | 2 +- src/services/predicate/common_predicates.js | 14 + src/services/predicate/lodash_predicates.js | 1 - src/services/random.js | 12 +- yarn.lock | 6 +- 33 files changed, 2917 insertions(+), 377 deletions(-) create mode 100644 src/components/search_bar/query/date_format.js create mode 100644 src/components/search_bar/query/date_format.test.js create mode 100644 src/components/search_bar/query/date_value.js create mode 100644 src/components/search_bar/query/date_value.test.js delete mode 100644 src/components/search_bar/query/must.js delete mode 100644 src/components/search_bar/query/must_not.js create mode 100644 src/components/search_bar/query/operators.js create mode 100644 src/components/search_bar/query/operators.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e50ff09e8f..45c0b57e2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # [`master`](https://github.com/elastic/eui/tree/master) - Relaxed query syntax of `EuiSearchBar` to allow usage of hyphens without escaping ([#581](https://github.com/elastic/eui/pull/581)) +- Added support for range queries in `EuiSearchBar` (works for numeric and date values) ([#485](https://github.com/elastic/eui/pull/485)) - Add support for expandable rows to `EuiBasicTable` ([#585](https://github.com/elastic/eui/pull/585)) # [`0.0.35`](https://github.com/elastic/eui/tree/v0.0.35) diff --git a/package.json b/package.json index 52466f48d02..5b39acd3425 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "html-webpack-plugin": "^2.30.1", "jest": "^22.0.6", "jest-cli": "^22.0.6", - "moment": "2.19.3", + "moment": "^2.20.1", "node-sass": "^4.5.3", "npm-run": "^4.1.2", "pegjs": "^0.10.0", diff --git a/src-docs/src/views/search_bar/props_info.js b/src-docs/src/views/search_bar/props_info.js index 5581958bfde..a6abf55c7a6 100644 --- a/src-docs/src/views/search_bar/props_info.js +++ b/src-docs/src/views/search_bar/props_info.js @@ -75,6 +75,60 @@ export const propsInfo = { required: false, defaultValue: { value: 'false' }, type: { name: 'boolean' } + }, + schema: { + description: 'A schema describing the supported fields and flags', + required: false, + type: { name: '#Schema' } + } + } + } + }, + + Schema: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + strict: { + description: 'Indicates whether the query parsing should be strictly compliant with the schema', + required: false, + defaultValue: { value: 'false' }, + type: { name: 'boolean' } + }, + flags: { + description: 'A list of supported flags', + required: false, + type: { name: 'string[]' } + }, + fields: { + description: 'A dictionary of supported fields', + required: false, + type: { name: '{ [fieldName]: #SchemaField }' } + } + } + } + }, + + SchemaField: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + type: { + description: 'The data type of the field', + required: true, + type: { name: 'boolean | string | date | number' } + }, + valueDescription: { + description: 'A description of the values accepted by this field', + required: false, + defaultValue: { value: 'the data type' }, + type: { name: 'string' } + }, + validate: { + description: 'A function to validate a possible value for the field. An error should be thrown when ' + + 'validation fails (with appropriate error message of course)', + required: false, + type: { name: '(value) => void' } } } } diff --git a/src-docs/src/views/search_bar/search_bar.js b/src-docs/src/views/search_bar/search_bar.js index be442cd2a72..3313019a248 100644 --- a/src-docs/src/views/search_bar/search_bar.js +++ b/src-docs/src/views/search_bar/search_bar.js @@ -1,6 +1,6 @@ import React, { Component, Fragment } from 'react'; import { times } from 'lodash'; - +import { Random } from '../../../../src/services/random'; import { EuiHealth, EuiCallOut, @@ -14,24 +14,15 @@ import { EuiSearchBar, } from '../../../../src/components'; -import { - Query, - Random, -} from '../../../../src/services'; - const random = new Random(); -const tags = [{ - name: 'marketing', status: 'off', -}, { - name: 'finance', status: 'on', -}, { - name: 'eng', status: 'on', -}, { - name: 'sales', status: 'processing', -}, { - name: 'ga', status: 'on', -}]; +const tags = [ + { name: 'marketing', color: 'danger' }, + { name: 'finance', color: 'success' }, + { name: 'eng', color: 'success' }, + { name: 'sales', color: 'warning' }, + { name: 'ga', color: 'success' } +]; const types = [ 'dashboard', @@ -54,30 +45,28 @@ const items = times(10, (id) => { type: random.oneOf(types), tag: random.setOf(tags.map(tag => tag.name), { min: 0, max: 3 }), active: random.boolean(), - owner: random.oneOf(users) + owner: random.oneOf(users), + followers: random.integer({ min: 0, max: 20 }), + comments: random.integer({ min: 0, max: 10 }), + stars: random.integer({ min: 0, max: 5 }) }; }); const loadTags = () => { - const statusToColorMap = { - 'on': 'success', - 'off': 'danger', - 'processing': 'warning', - }; - return new Promise((resolve) => { setTimeout(() => { resolve(tags.map(tag => ({ value: tag.name, - view: {tag.name} + view: {tag.name} }))); }, 2000); }); }; -const initialQuery = Query.MATCH_ALL; +const initialQuery = EuiSearchBar.Query.MATCH_ALL; export class SearchBar extends Component { + constructor(props) { super(props); this.state = { @@ -95,7 +84,8 @@ export class SearchBar extends Component { onChange = (query) => { this.setState({ error: null, - query, + result: EuiSearchBar.Query.execute(query, items, { defaultFields: ['owner', 'tag', 'type'] }), + query }); }; @@ -104,45 +94,81 @@ export class SearchBar extends Component { }; renderSearch() { - const { - incremental, - } = this.state; - - const filters = [{ - type: 'field_value_toggle_group', - field: 'status', - items: [{ - value: 'open', - name: 'Open' - }, { - value: 'closed', - name: 'Closed' - }] - }, { - type: 'is', - field: 'active', - name: 'Active', - negatedName: 'Inactive' - }, { - type: 'field_value_toggle', - name: 'Mine', - field: 'owner', - value: 'dewey' - }, { - type: 'field_value_selection', - field: 'tag', - name: 'Tag', - multiSelect: 'or', - cache: 10000, // will cache the loaded tags for 10 sec - options: () => loadTags() - }]; + const { incremental } = this.state; + + const filters = [ + { + type: 'field_value_toggle_group', + field: 'status', + items: [ + { + value: 'open', + name: 'Open' + }, + { + value: 'closed', + name: 'Closed' + } + ] + }, + { + type: 'is', + field: 'active', + name: 'Active', + negatedName: 'Inactive' + }, + { + type: 'field_value_toggle', + name: 'Mine', + field: 'owner', + value: 'dewey' + }, + { + type: 'field_value_selection', + field: 'tag', + name: 'Tag', + multiSelect: 'or', + cache: 10000, // will cache the loaded tags for 10 sec + options: () => loadTags() + } + ]; + + const schema = { + strict: true, + fields: { + active: { + type: 'boolean' + }, + status: { + type: 'string' + }, + followers: { + type: 'number' + }, + comments: { + type: 'number' + }, + stars: { + type: 'number' + }, + tag: { + type: 'string', + validate: (value) => { + if (!tags.some(tag => tag.name === value)) { + throw new Error(`unknown tag (possible values: ${tags.map(tag => tag.name).join(',')})`); + } + } + } + } + }; return ( - + ); } renderTable() { - const columns = [{ - name: 'Type', - field: 'type' - }, { - name: 'Open', - field: 'status', - render: (status) => status === 'open' ? 'Yes' : 'No' - }, { - name: 'Active', - field: 'active', - dataType: 'boolean' - }, { - name: 'Tags', - field: 'tag' - }, { - name: 'Owner', - field: 'owner', - }]; - - const queriedItems = Query.execute(this.state.query, items, { + const columns = [ + { + name: 'Type', + field: 'type' + }, + { + name: 'Open', + field: 'status', + render: (status) => status === 'open' ? 'Yes' : 'No' + }, + { + name: 'Active', + field: 'active', + dataType: 'boolean' + }, + { + name: 'Tags', + field: 'tag' + }, + { + name: 'Owner', + field: 'owner' + }, + { + name: 'Stats', + width: '150px', + render: (item) => { + return ( +
+
{`${item.stars} Stars`}
+
{`${item.followers} Followers`}
+
{`${item.comments} Comments`}
+
+ ); + } + } + ]; + + const queriedItems = EuiSearchBar.Query.execute(this.state.query, items, { defaultFields: ['owner', 'tag', 'type'] }); @@ -210,28 +251,28 @@ export class SearchBar extends Component { query, } = this.state; - const esQuery = Query.toESQuery(query); + const esQuery = EuiSearchBar.Query.toESQuery(query); const content = this.renderError() || ( - +

Elasticsearch query

- + {esQuery ? JSON.stringify(esQuery, null, 2) : ''}
- +

JS execution

- + {this.renderTable()}
@@ -253,9 +294,7 @@ export class SearchBar extends Component { />
- - - + {content} ); diff --git a/src-docs/src/views/search_bar/search_bar_example.js b/src-docs/src/views/search_bar/search_bar_example.js index bba3849819b..89a91c79dcc 100644 --- a/src-docs/src/views/search_bar/search_bar_example.js +++ b/src-docs/src/views/search_bar/search_bar_example.js @@ -47,7 +47,10 @@ export const SearchBarExample = { Field/value search - one can search for terms within specific fields - Example, tag:bug -severity:high. In this example the intention is to find all items that has "bug" in their tag field but do not have "high" in their - severity field + severity field. It is also possible to define range queries on numeric and date fields. + For example, followers>=10 will only match items that have 10 follower or above. And + created>'12 Jan 2018' will only match items that were created after 12th + January 2018.
  • is clauses - a simple boolean filter over a flag - Example, diff --git a/src-docs/src/views/tables/in_memory/in_memory_search.js b/src-docs/src/views/tables/in_memory/in_memory_search.js index 3c5889cc32b..3f37418ed9b 100644 --- a/src-docs/src/views/tables/in_memory/in_memory_search.js +++ b/src-docs/src/views/tables/in_memory/in_memory_search.js @@ -74,6 +74,22 @@ export class Table extends Component { const country = store.getCountry(countryCode); return `${country.flag} ${country.name}`; } + }, { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + } + }, { + field: 'nationality', + name: 'Nationality', + render: (countryCode) => { + const country = store.getCountry(countryCode); + return `${country.flag} ${country.name}`; + } }, { field: 'online', name: 'Online', @@ -88,7 +104,8 @@ export class Table extends Component { const search = { box: { - incremental: this.state.incremental + incremental: this.state.incremental, + schema: true }, filters: !this.state.filters ? undefined : [ { diff --git a/src/components/basic_table/in_memory_table.js b/src/components/basic_table/in_memory_table.js index 8c40489a057..64c572ebe44 100644 --- a/src/components/basic_table/in_memory_table.js +++ b/src/components/basic_table/in_memory_table.js @@ -11,7 +11,6 @@ import { import { isBoolean, isString } from '../../services/predicate'; import { Comparators, PropertySortType } from '../../services/sort'; import { - Query, QueryType, SearchFiltersFiltersType, SearchBoxConfigPropTypes, EuiSearchBar @@ -26,7 +25,15 @@ const InMemoryTablePropTypes = { error: PropTypes.string, search: PropTypes.oneOfType([PropTypes.bool, PropTypes.shape({ defaultQuery: QueryType, - box: PropTypes.shape(SearchBoxConfigPropTypes), + box: PropTypes.shape({ + ...SearchBoxConfigPropTypes, + schema: PropTypes.oneOfType([ + // here we enable the user to just assign 'true' to the schema, in which case + // we will auto-generate it out of the columns configuration + PropTypes.bool, + SearchBoxConfigPropTypes.schema + ]) + }), filters: SearchFiltersFiltersType, onChange: PropTypes.func, })]), @@ -55,7 +62,7 @@ const getInitialQuery = (search) => { } const query = search.defaultQuery || ''; - return isString(query) ? Query.parse(query) : query; + return isString(query) ? EuiSearchBar.Query.parse(query) : query; }; const getInitialPagination = (pagination) => { @@ -132,31 +139,31 @@ export class EuiInMemoryTable extends Component { onTableChange = ({ page = {}, sort = {} }) => { const { index: pageIndex, - size: pageSize, + size: pageSize } = page; const { field: sortField, - direction: sortDirection, + direction: sortDirection } = sort; this.setState({ pageIndex, pageSize, sortField, - sortDirection, + sortDirection }); }; onQueryChange(query) { if (this.props.search.onChange) { const shouldQueryInMemory = this.props.search.onChange(query); - - if (!shouldQueryInMemory) return; + if (!shouldQueryInMemory) { + return; + } } - this.setState({ - query, + query }); } @@ -168,6 +175,10 @@ export class EuiInMemoryTable extends Component { ...searchBarProps } = isBoolean(search) ? {} : search; + if (searchBarProps.box && searchBarProps.box.schema === true) { + searchBarProps.box.schema = this.resolveSearchSchema(); + } + return ( { + if (column.field) { + const type = column.dataType || 'string'; + schema.fields[column.field] = { type }; + } + return schema; + }, { strict: true, fields: {} }); + } + getItems() { const { items } = this.props; @@ -195,7 +217,7 @@ export class EuiInMemoryTable extends Component { pageSize, } = this.state; - const matchingItems = query ? Query.execute(query, items) : items; + const matchingItems = query ? EuiSearchBar.Query.execute(query, items) : items; const sortedItems = sortField ? matchingItems.sort(Comparators.property(sortField, Comparators.default(sortDirection))) : matchingItems; diff --git a/src/components/search_bar/index.js b/src/components/search_bar/index.js index 89174986313..a203e6fc36f 100644 --- a/src/components/search_bar/index.js +++ b/src/components/search_bar/index.js @@ -1,5 +1,4 @@ export { EuiSearchBar } from './search_bar'; -export { Query } from './query'; export { QueryType } from './search_bar'; export { SearchBoxConfigPropTypes } from './search_box'; export { SearchFiltersFiltersType } from './search_filters'; diff --git a/src/components/search_bar/query/__snapshots__/ast_to_es.test.js.snap b/src/components/search_bar/query/__snapshots__/ast_to_es.test.js.snap index 71f38298994..f59884cf1a3 100644 --- a/src/components/search_bar/query/__snapshots__/ast_to_es.test.js.snap +++ b/src/components/search_bar/query/__snapshots__/ast_to_es.test.js.snap @@ -199,3 +199,122 @@ Object { }, } `; + +exports[`astToEs ast - -count<=4 size<5 age>=3 -number>9 1`] = ` +Object { + "bool": Object { + "must": Array [ + Object { + "range": Object { + "size": Object { + "lt": 5, + }, + }, + }, + Object { + "range": Object { + "age": Object { + "gte": 3, + }, + }, + }, + ], + "must_not": Array [ + Object { + "range": Object { + "count": Object { + "lte": 4, + }, + }, + }, + Object { + "range": Object { + "number": Object { + "gt": 9, + }, + }, + }, + ], + }, +} +`; + +exports[`astToEs ast - count>3 1`] = ` +Object { + "bool": Object { + "must": Array [ + Object { + "range": Object { + "count": Object { + "gt": 3, + }, + }, + }, + ], + }, +} +`; + +exports[`astToEs ast - date:'2004-03' -date<'2004-03-10' 1`] = ` +Object { + "bool": Object { + "must": Array [ + Object { + "match": Object { + "date": "2004-03-01T00:00:00Z||/M", + }, + }, + ], + "must_not": Array [ + Object { + "range": Object { + "date": Object { + "lt": "2004-03-10T00:00:00Z||/d", + }, + }, + }, + ], + }, +} +`; + +exports[`astToEs ast - date>'2004-02' -otherDate>='2004-03-10' 1`] = ` +Object { + "bool": Object { + "must": Array [ + Object { + "range": Object { + "date": Object { + "gte": "2004-02-01T00:00:00Z||+1M/M", + }, + }, + }, + ], + "must_not": Array [ + Object { + "range": Object { + "date": Object { + "gte": "2004-03-10T00:00:00Z||/d", + }, + }, + }, + ], + }, +} +`; + +exports[`astToEs ast - date>='2004-03-22' 1`] = ` +Object { + "bool": Object { + "must": Array [ + Object { + "range": Object { + "date": Object { + "gte": "2004-03-22T00:00:00Z||/d", + }, + }, + }, + ], + }, +} +`; diff --git a/src/components/search_bar/query/ast.js b/src/components/search_bar/query/ast.js index 89fbe1d5248..cf7c3cc5abe 100644 --- a/src/components/search_bar/query/ast.js +++ b/src/components/search_bar/query/ast.js @@ -1,16 +1,61 @@ import { isArray, isNil } from '../../../services/predicate'; +import { isDateValue, dateValuesEqual } from './date_value'; export const Match = Object.freeze({ MUST: 'must', MUST_NOT: 'must_not', isMust(match) { - return match === this.MUST; + return match === Match.MUST; }, isMustClause(clause) { return Match.isMust(clause.match); } }); +export const Operator = Object.freeze({ + EQ: 'eq', + GT: 'gt', + GTE: 'gte', + LT: 'lt', + LTE: 'lte', + isEQ(match) { + return match === Operator.EQ; + }, + isEQClause(clause) { + return Operator.isEQ(clause.operator); + }, + isRange(match) { + return Operator.isGT(match) || Operator.isGTE(match) || Operator.isLT(match) || Operator.isLTE(match); + }, + isRangeClause(clause) { + return Operator.isRange(clause.operator); + }, + isGT(match) { + return match === Operator.GT; + }, + isGTClause(clause) { + return Operator.isGT(clause.operator); + }, + isGTE(match) { + return match === Operator.GTE; + }, + isGTEClause(clause) { + return Operator.isGTE(clause.operator); + }, + isLT(match) { + return match === Operator.LT; + }, + isLTClause(clause) { + return Operator.isLT(clause.operator); + }, + isLTE(match) { + return match === Operator.LTE; + }, + isLTEClause(clause) { + return Operator.isLTE(clause.operator); + } +}); + const Term = Object.freeze({ TYPE: 'term', isInstance: (clause) => { @@ -29,11 +74,19 @@ const Field = Object.freeze({ isInstance: (clause) => { return clause.type === Field.TYPE; }, - must: (field, value) => { - return { type: Field.TYPE, field, value, match: Match.MUST }; + must: { + eq: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST, operator: Operator.EQ }), + gt: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST, operator: Operator.GT }), + gte: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST, operator: Operator.GTE }), + lt: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST, operator: Operator.LT }), + lte: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST, operator: Operator.LTE }) }, - mustNot: (field, value) => { - return { type: Field.TYPE, field, value, match: Match.MUST_NOT }; + mustNot: { + eq: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST_NOT, operator: Operator.EQ }), + gt: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST_NOT, operator: Operator.GT }), + gte: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST_NOT, operator: Operator.GTE }), + lt: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST_NOT, operator: Operator.LT }), + lte: (field, value) => ({ type: Field.TYPE, field, value, match: Match.MUST_NOT, operator: Operator.LTE }) } }); @@ -50,6 +103,17 @@ const Is = Object.freeze({ } }); +const valuesEqual = (v1, v2) => { + if (isDateValue(v1)) { + return dateValuesEqual(v1, v2); + } + return v1 === v2; +}; + +const arrayIncludesValue = (array, value) => { + return array.some(item => valuesEqual(item, value)); +}; + /** * The AST structure is an array of clauses. There are 3 types of clauses that are supported: * @@ -106,7 +170,7 @@ export class _AST { getTermClause(value) { const clauses = this.getTermClauses(); - return clauses.find(clause => clause.value === value); + return clauses.find(clause => valuesEqual(clause.value, value)); } getFieldNames() { @@ -131,18 +195,18 @@ export class _AST { if (!clauses) { return false; } - return isNil(value) || clauses.some(clause => clause.value.includes(value)); + return isNil(value) || clauses.some(clause => arrayIncludesValue(clause.value, value)); } getOrFieldClause(field, value = undefined) { - return this.getFieldClause(field, clause => isArray(clause.value) && (!value || clause.value.includes(value))); + return this.getFieldClause(field, clause => isArray(clause.value) && (isNil(value) || arrayIncludesValue(clause.value, value))); } - addOrFieldValue(field, value, must = true) { + addOrFieldValue(field, value, must = true, operator = Operator.EQ) { const existingClause = this.getOrFieldClause(field); if (!existingClause) { - const newClause = must ? Field.must(field, [ value ]) : Field.mustNot(field, [ value ]); - return new _AST([ ...this._clauses, newClause ]); + const newClause = must ? Field.must[operator](field, [value]) : Field.mustNot[operator](field, [value]); + return new _AST([...this._clauses, newClause]); } const clauses = this._clauses.map(clause => { if (clause === existingClause) { @@ -156,14 +220,14 @@ export class _AST { removeOrFieldValue(field, value) { const existingClause = this.getOrFieldClause(field, value); if (!existingClause) { - return new _AST([ ...this._clauses ]); + return new _AST([...this._clauses]); } const clauses = this._clauses.reduce((clauses, clause) => { if (clause !== existingClause) { clauses.push(clause); return clauses; } - const filteredValue = clause.value.filter(val => val !== value); + const filteredValue = clause.value.filter(val => !valuesEqual(val, value)); if (filteredValue.length === 0) { return clauses; } @@ -185,22 +249,22 @@ export class _AST { if (!clauses) { return false; } - return isNil(value) || clauses.some(clause => clause.value === value); + return isNil(value) || clauses.some(clause => valuesEqual(clause.value, value)); } getSimpleFieldClause(field, value = undefined) { - return this.getFieldClause(field, clause => !isArray(clause.value) && (!value || clause.value === value)); + return this.getFieldClause(field, clause => !isArray(clause.value) && (isNil(value) || valuesEqual(clause.value, value))); } - addSimpleFieldValue(field, value, must = true) { - const clause = must ? Field.must(field, value) : Field.mustNot(field, value); + addSimpleFieldValue(field, value, must = true, operator = Operator.EQ) { + const clause = must ? Field.must[operator](field, value) : Field.mustNot[operator](field, value); return this.addClause(clause); } removeSimpleFieldValue(field, value) { const existingClause = this.getSimpleFieldClause(field, value); if (!existingClause) { - return new _AST([ ...this._clauses ]); + return new _AST([...this._clauses]); } const clauses = this._clauses.filter(clause => clause !== existingClause); return new _AST(clauses); @@ -286,6 +350,7 @@ export class _AST { export const AST = Object.freeze({ Match, + Operator, Term, Field, Is, diff --git a/src/components/search_bar/query/ast_to_es.js b/src/components/search_bar/query/ast_to_es.js index 2f2815f10bd..ad263b4ff4e 100644 --- a/src/components/search_bar/query/ast_to_es.js +++ b/src/components/search_bar/query/ast_to_es.js @@ -1,5 +1,32 @@ +import { printIso8601 } from './date_format'; +import { isDateValue, dateValue } from './date_value'; import { AST } from './ast'; -import { isArray } from '../../../services/predicate'; +import { isArray, isDateLike, isString } from '../../../services/predicate'; + +const processDateOperation = (value, operator) => { + const { granularity, resolve } = value; + let expression = printIso8601(resolve()); + if (!granularity) { + return { operator, expression }; + } + switch (operator) { + case AST.Operator.GT: + expression = `${expression}||+1${granularity.es}/${granularity.es}`; + return { operator: AST.Operator.GTE, expression }; + case AST.Operator.GTE: + expression = `${expression}||/${granularity.es}`; + return { operator, expression }; + case AST.Operator.LT: + expression = `${expression}||/${granularity.es}`; + return { operator, expression }; + case AST.Operator.LTE: + expression = `${expression}||+1${granularity.es}/${granularity.es}`; + return { operator: AST.Operator.LT, expression }; + default: + expression = `${expression}||/${granularity.es}`; + return { expression }; + } +}; export const _termValuesToQuery = (values, options) => { const body = { @@ -16,54 +43,91 @@ export const _termValuesToQuery = (values, options) => { }; }; -export const _fieldValuesToQuery = (field, values, operator) => { - - const { terms, phrases } = values.reduce((split, value) => { - if (value.match(/\s/)) { - split.phrases.push(value); - } else { - split.terms.push(value); +export const _fieldValuesToQuery = (field, operations, andOr) => { + const queries = []; + + Object.keys(operations).forEach(operator => { + const values = operations[operator]; + switch (operator) { + + case AST.Operator.EQ: + const { terms, phrases, dates } = values.reduce((tokenTypes, value) => { + if (isDateValue(value)) { + tokenTypes.dates.push(value); + } else if (isDateLike(value)) { + tokenTypes.dates.push(dateValue(value)); + } else if (isString(value) && value.match(/\s/)) { + tokenTypes.phrases.push(value); + } else { + tokenTypes.terms.push(value); + } + return tokenTypes; + }, { terms: [], phrases: [], dates: [] }); + + if (terms.length > 0) { + queries.push({ + match: { + [field]: { + query: terms.join(' '), + operator: andOr + } + } + }); + } + + if (phrases.length > 0) { + queries.push(...phrases.map(phrase => ({ + match_phrase: { + [field]: phrase + } + }))); + } + + if (dates.length > 0) { + queries.push(...dates.map(value => ({ + match: { + [field]: processDateOperation(value).expression + } + }))); + } + + break; + + default: + + values.forEach(value => { + if (isDateValue(value)) { + const operation = processDateOperation(value, operator); + queries.push({ + range: { + [field]: { + [operation.operator]: operation.expression + } + } + }); + } else { + queries.push({ + range: { + [field]: { + [operator]: value + } + } + }); + } + }); } - return split; - }, { terms: [], phrases: [] }); - - const termsQuery = terms.length === 0 ? undefined : { - match: { - [field]: { - query: terms.join(' '), - operator - } - } - }; - - const phraseQueries = phrases.length === 0 ? undefined : phrases.map(phrase => ({ - match_phrase: { - [field]: phrase - } - })); - - const key = operator === 'and' ? 'must' : 'should'; + }); - if (termsQuery && phraseQueries) { - return { - bool: { - [key]: [ termsQuery, ...phraseQueries ] - } - }; - } - if (termsQuery) { - return termsQuery; + if (queries.length === 1) { + return queries[0]; } - if (phraseQueries) { - if (phraseQueries.length === 1) { - return phraseQueries[0]; + + const key = andOr === 'and' ? 'must' : 'should'; + return { + bool: { + [key]: [...queries] } - return { - bool: { - [key]: phraseQueries - } - }; - } + }; }; export const _isFlagToQuery = (flag, on) => { @@ -85,30 +149,33 @@ const collectTerms = (ast) => { const collectFields = (ast) => { - const fieldArray = (obj, field) => { + const fieldArray = (obj, field, operator) => { if (!obj[field]) { - obj[field] = []; + obj[field] = {}; } - return obj[field]; + if (!obj[field][operator]) { + obj[field][operator] = []; + } + return obj[field][operator]; }; return ast.getFieldClauses().reduce((fields, clause) => { if (AST.Match.isMustClause(clause)) { if (isArray(clause.value)) { - fieldArray(fields.must.or, clause.field).push(...clause.value); + fieldArray(fields.must.or, clause.field, clause.operator).push(...clause.value); } else { - fieldArray(fields.must.and, clause.field).push(clause.value); + fieldArray(fields.must.and, clause.field, clause.operator).push(clause.value); } } else { if (isArray(clause.value)) { - fieldArray(fields.mustNot.or, clause.field).push(...clause.value); + fieldArray(fields.mustNot.or, clause.field, clause.operator).push(...clause.value); } else { - fieldArray(fields.mustNot.and, clause.field).push(clause.value); + fieldArray(fields.mustNot.and, clause.field, clause.operator).push(clause.value); } } return fields; }, { - must: { and: {}, or: {} }, + must: { and: {}, or: {}, }, mustNot: { and: {}, or: {} } }); }; @@ -134,15 +201,15 @@ export const astToEs = (ast, options = {}) => { if (termMustQuery) { must.push(termMustQuery); } - must.push(...Object.keys(fields.must.and).map(field => { - return fieldValuesToQuery(field, fields.must.and[field], 'and'); - })); - must.push(...Object.keys(fields.must.or).map(field => { - return fieldValuesToQuery(field, fields.must.or[field], 'or'); - })); - must.push(...ast.getIsClauses().map(clause => { - return isFlagToQuery(clause.flag, AST.Match.isMustClause(clause)); - })); + Object.keys(fields.must.and).forEach(field => { + must.push(fieldValuesToQuery(field, fields.must.and[field], 'and')); + }); + Object.keys(fields.must.or).forEach(field => { + must.push(fieldValuesToQuery(field, fields.must.or[field], 'or')); + }); + ast.getIsClauses().forEach(clause => { + must.push(isFlagToQuery(clause.flag, AST.Match.isMustClause(clause))); + }); const mustNot = []; mustNot.push(...extraMustNotQueries); @@ -150,12 +217,12 @@ export const astToEs = (ast, options = {}) => { if (termMustNotQuery) { mustNot.push(termMustNotQuery); } - mustNot.push(...Object.keys(fields.mustNot.and).map(field => { - return fieldValuesToQuery(field, fields.mustNot.and[field], 'and'); - })); - mustNot.push(...Object.keys(fields.mustNot.or).map(field => { - return fieldValuesToQuery(field, fields.mustNot.or[field], 'or'); - })); + Object.keys(fields.mustNot.and).forEach(field => { + mustNot.push(fieldValuesToQuery(field, fields.mustNot.and[field], 'and')); + }); + Object.keys(fields.mustNot.or).forEach(field => { + mustNot.push(fieldValuesToQuery(field, fields.mustNot.or[field], 'or')); + }); const bool = {}; if (must.length !== 0) { diff --git a/src/components/search_bar/query/ast_to_es.test.js b/src/components/search_bar/query/ast_to_es.test.js index a180dfc4a04..8c7eac409c9 100644 --- a/src/components/search_bar/query/ast_to_es.test.js +++ b/src/components/search_bar/query/ast_to_es.test.js @@ -1,5 +1,8 @@ import { astToEs } from './ast_to_es'; import { AST } from './ast'; +import moment from 'moment/moment'; +import { dateValue } from './date_value'; +import { Granularity } from './date_format'; describe('astToEs', () => { @@ -18,10 +21,10 @@ describe('astToEs', () => { test(`ast - '-group:es group:kibana -group:beats group:logstash'`, () => { const query = astToEs(AST.create([ - AST.Field.mustNot('group', 'es'), - AST.Field.must('group', 'kibana'), - AST.Field.mustNot('group', 'beats'), - AST.Field.must('group', 'logstash') + AST.Field.mustNot.eq('group', 'es'), + AST.Field.must.eq('group', 'kibana'), + AST.Field.mustNot.eq('group', 'beats'), + AST.Field.must.eq('group', 'logstash') ])); expect(query).toMatchSnapshot(); }); @@ -29,7 +32,7 @@ describe('astToEs', () => { test(`ast - 'is:online group:kibana john'`, () => { const query = astToEs(AST.create([ AST.Is.must('online'), - AST.Field.must('group', 'kibana'), + AST.Field.must.eq('group', 'kibana'), AST.Term.must('john') ])); expect(query).toMatchSnapshot(); @@ -40,9 +43,9 @@ describe('astToEs', () => { AST.Term.must('john'), AST.Term.mustNot('doe'), AST.Is.must('online'), - AST.Field.must('group', 'eng'), - AST.Field.must('group', 'es'), - AST.Field.mustNot('group', 'kibana'), + AST.Field.must.eq('group', 'eng'), + AST.Field.must.eq('group', 'es'), + AST.Field.mustNot.eq('group', 'kibana'), AST.Is.mustNot('active') ])); expect(query).toMatchSnapshot(); @@ -51,8 +54,8 @@ describe('astToEs', () => { test(`ast - 'john group:(eng or es) -group:kibana'`, () => { const query = astToEs(AST.create([ AST.Term.must('john'), - AST.Field.must('group', ['eng', 'es']), - AST.Field.mustNot('group', 'kibana') + AST.Field.must.eq('group', ['eng', 'es']), + AST.Field.mustNot.eq('group', 'kibana') ])); expect(query).toMatchSnapshot(); }); @@ -60,8 +63,48 @@ describe('astToEs', () => { test(`ast - 'john group:(eng or "marketing org") -group:"kibana team"`, () => { const query = astToEs(AST.create([ AST.Term.must('john'), - AST.Field.must('group', ['eng', 'marketing org']), - AST.Field.mustNot('group', 'kibana team') + AST.Field.must.eq('group', ['eng', 'marketing org']), + AST.Field.mustNot.eq('group', 'kibana team') + ])); + expect(query).toMatchSnapshot(); + }); + + test(`ast - count>3`, () => { + const query = astToEs(AST.create([ + AST.Field.must.gt('count', 3) + ])); + expect(query).toMatchSnapshot(); + }); + + test(`ast - -count<=4 size<5 age>=3 -number>9`, () => { + const query = astToEs(AST.create([ + AST.Field.mustNot.lte('count', 4), + AST.Field.must.lt('size', 5), + AST.Field.must.gte('age', 3), + AST.Field.mustNot.gt('number', 9), + ])); + expect(query).toMatchSnapshot(); + }); + + test(`ast - date>='2004-03-22'`, () => { + const query = astToEs(AST.create([ + AST.Field.must.gte('date', dateValue(moment.utc('2004-03-22'), Granularity.DAY)) + ])); + expect(query).toMatchSnapshot(); + }); + + test(`ast - date:'2004-03' -date<'2004-03-10'`, () => { + const query = astToEs(AST.create([ + AST.Field.must.eq('date', dateValue(moment.utc('2004-03'), Granularity.MONTH)), + AST.Field.mustNot.lt('date', dateValue(moment.utc('2004-03-10'), Granularity.DAY)) + ])); + expect(query).toMatchSnapshot(); + }); + + test(`ast - date>'2004-02' -otherDate>='2004-03-10'`, () => { + const query = astToEs(AST.create([ + AST.Field.must.gt('date', dateValue(moment.utc('2004-02'), Granularity.MONTH)), + AST.Field.mustNot.gte('date', dateValue(moment.utc('2004-03-10'), Granularity.DAY)) ])); expect(query).toMatchSnapshot(); }); diff --git a/src/components/search_bar/query/date_format.js b/src/components/search_bar/query/date_format.js new file mode 100644 index 00000000000..c013b9d2157 --- /dev/null +++ b/src/components/search_bar/query/date_format.js @@ -0,0 +1,334 @@ +import { dateFormatAliases } from '../../../services/format'; +import moment from 'moment'; + +const utc = moment.utc; + +const GRANULARITY_KEY = '__eui_granularity'; +const FORMAT_KEY = '__eui_format'; + +export const Granularity = Object.freeze({ + DAY: { + es: 'd', + js: 'day', + isSame: (d1, d2) => d1.isSame(d2, 'day'), + start: (date) => date.startOf('day'), + startOfNext: (date) => date.add(1, 'days').startOf('day') + }, + WEEK: { + es: 'w', + js: 'week', + isSame: (d1, d2) => d1.isSame(d2, 'week'), + start: (date) => date.startOf('week'), + startOfNext: (date) => date.add(1, 'weeks').startOf('week') + }, + MONTH: { + es: 'M', + js: 'month', + isSame: (d1, d2) => d1.isSame(d2, 'month'), + start: (date) => date.startOf('month'), + startOfNext: (date) => date.add(1, 'months').startOf('month') + }, + YEAR: { + es: 'y', + js: 'year', + isSame: (d1, d2) => d1.isSame(d2, 'year'), + start: (date) => date.startOf('year'), + startOfNext: (date) => date.add(1, 'years').startOf('year') + } +}); + +const parseTime = (value) => { + const parsed = utc(value, [ + 'HH:mm', + 'H:mm', + 'H:mm', + 'h:mm a', + 'h:mm A', + 'hh:mm a', + 'hh:mm A' + ], true); + if (parsed.isValid()) { + parsed[FORMAT_KEY] = parsed.creationData().format; + return parsed; + } +}; + +const parseDay = (value) => { + let parsed = null; + switch (value.toLowerCase()) { + case 'today': + parsed = utc().startOf('day'); + parsed[GRANULARITY_KEY] = Granularity.DAY; + parsed[FORMAT_KEY] = value; + return parsed; + case 'yesterday': + parsed = utc().subtract(1, 'days').startOf('day'); + parsed[GRANULARITY_KEY] = Granularity.DAY; + parsed[FORMAT_KEY] = value; + return parsed; + case 'tomorrow': + parsed = utc().add(1, 'days').startOf('day'); + parsed[GRANULARITY_KEY] = Granularity.DAY; + parsed[FORMAT_KEY] = value; + return parsed; + default: + parsed = utc(value, [ + 'ddd', + 'dddd', + 'D MMM YY', + 'Do MMM YY', + 'D MMM YYYY', + 'Do MMM YYYY', + 'DD MMM YY', + 'DD MMM YYYY', + 'D MMMM YY', + 'Do MMMM YY', + 'D MMMM YYYY', + 'Do MMMM YYYY', + 'DD MMMM YY', + 'DD MMMM YYYY', + 'YYYY-MM-DD' + ], true); + if (parsed.isValid()) { + try { + parsed[GRANULARITY_KEY] = Granularity.DAY; + parsed[FORMAT_KEY] = parsed.creationData().format; + return parsed; + } catch (e) { + console.error(e); + } + } + } +}; + +const parseWeek = (value) => { + let parsed = null; + switch (value.toLowerCase()) { + case 'this week': + parsed = utc(); + break; + case 'last week': + parsed = utc().subtract(1, 'weeks'); + break; + case 'next week': + parsed = utc().add(1, 'weeks'); + break; + default: + const match = value.match(/week ([1-9][1-9]?)/i); + if (match) { + const weekNr = Number(match[1]); + parsed = utc().weeks(weekNr); + } + } + if (parsed && parsed.isValid()) { + parsed = parsed.startOf('week'); + parsed[GRANULARITY_KEY] = Granularity.WEEK; + parsed[FORMAT_KEY] = parsed.creationData().format; + return parsed; + } +}; + +const parseMonth = (value) => { + let parsed = null; + switch (value.toLowerCase()) { + case 'this month': + parsed = utc(); + break; + case 'next month': + parsed = utc().endOf('month').add(2, 'days'); + break; + case 'last month': + parsed = utc().startOf('month').subtract(2, 'days'); + break; + default: + parsed = utc(value, [ + 'MMM', + 'MMMM' + ], true); + if (parsed.isValid()) { + const now = utc(); + parsed.year(now.year); + } else { + parsed = utc(value, [ + 'MMM YY', + 'MMMM YY', + 'MMM YYYY', + 'MMMM YYYY', + 'YYYY MMM', + 'YYYY MMMM', + 'YYYY-MM' + ], true); + } + } + if (parsed.isValid()) { + parsed.startOf('month'); + parsed[GRANULARITY_KEY] = Granularity.MONTH; + parsed[FORMAT_KEY] = parsed.creationData().format; + return parsed; + } +}; + +const parseYear = (value) => { + let parsed = null; + switch (value.toLowerCase()) { + case 'this year': + parsed = utc().startOf('year'); + parsed[GRANULARITY_KEY] = Granularity.YEAR; + parsed[FORMAT_KEY] = value; + return parsed; + case 'next year': + parsed = utc().endOf('year').add(2, 'months').startOf('year'); + parsed[GRANULARITY_KEY] = Granularity.YEAR; + parsed[FORMAT_KEY] = value; + return parsed; + case 'last year': + parsed = utc().startOf('year').subtract(2, 'months').startOf('year'); + parsed[GRANULARITY_KEY] = Granularity.YEAR; + parsed[FORMAT_KEY] = value; + return parsed; + default: + parsed = utc(value, [ + 'YY', + 'YYYY' + ], true); + if (parsed.isValid()) { + parsed[GRANULARITY_KEY] = Granularity.YEAR; + parsed[FORMAT_KEY] = parsed.creationData().format; + return parsed; + } + } +}; + +const parseDefault = (value) => { + let parsed = utc(value, [ + moment.ISO_8601, + moment.RFC_2822, + 'DD MMM YY HH:mm', + 'DD MMM YY HH:mm:ss', + 'DD MMM YYYY HH:mm', + 'DD MMM YYYY HH:mm:ss', + 'DD MMMM YYYY HH:mm', + 'DD MMMM YYYY HH:mm:ss' + ], true); + if (!parsed.isValid()) { + const time = Date.parse(value); + const offset = moment(time).utcOffset(); + parsed = utc(time); + parsed.add(offset, 'minutes'); + } + if (parsed.isValid()) { + parsed[FORMAT_KEY] = parsed.creationData().format; + } + return parsed; +}; + + +const printDay = (now, date, format) => { + if (format.match(/yesterday|tomorrow|today/i)) { + if (now.isSame(date, 'day')) { + return 'today'; + } + if (now.subtract(1, 'day').isSame(date, 'day')) { + return 'yesterday'; + } + if (now.add(1, 'day').isSame(date, 'day')) { + return 'tomorrow'; + } + if (now.isSame(date, 'week')) { + return date.format('dddd'); + } + } + return date.format(format); +}; + +const printWeek = (now, date, format) => { + if (format.match(/(?:this|next|last) week/i)) { + if (now.isSame(date, 'week')) { + return 'This Week'; + } + if (now.startOf('week').subtract(2, 'days').isSame(date, 'week')) { + return 'Last Week'; + } + if (now.endOf('week').add(2, 'days').isSame(date, 'week')) { + return 'Next Week'; + } + } + return date.format(format); +}; + +const printMonth = (now, date, format) => { + if (format.match(/(?:this|next|last) month/i)) { + if (now.isSame(date, 'month')) { + return 'This Month'; + } + if (now.startOf('month').subtract(2, 'days').isSame(date, 'month')) { + return 'Last Month'; + } + if (now.endOf('month').add(2, 'days').isSame(date, 'month')) { + return 'Next Month'; + } + } + return date.format(format); +}; + +const printYear = (now, date, format) => { + if (format.match(/(?:this|next|last) year/i)) { + if (now.isSame(date, 'year')) { + return 'This Year'; + } + if (now.startOf('year').subtract(2, 'months').isSame(date, 'year')) { + return 'Last Year'; + } + if (now.endOf('year').add(2, 'months').isSame(date, 'year')) { + return 'Next Year'; + } + } + return date.format(format); +}; + +export const printIso8601 = (value) => { + return utc(value).format(moment.defaultFormatUtc); +}; + +export const dateGranularity = (parsedDate) => { + return parsedDate[GRANULARITY_KEY]; +}; + +export const dateFormat = Object.freeze({ + + parse(value) { + const parsed = parseDay(value) || + parseMonth(value) || + parseYear(value) || + parseWeek(value) || + parseTime(value) || + parseDefault(value); + if (!parsed) { + throw new Error(`could not parse [${value}] as date`); + } + return parsed; + }, + + print(date, defaultGranularity = undefined) { + date = moment.isMoment(date) ? date : utc(date); + const now = utc(); + const format = date[FORMAT_KEY]; + if (!format) { + return date.format(dateFormatAliases.iso8601); + } + const granularity = date[GRANULARITY_KEY] || defaultGranularity; + switch (granularity) { + case Granularity.DAY: + return printDay(now, date, format); + case Granularity.WEEK: + return printWeek(now, date, format); + case Granularity.MONTH: + return printMonth(now, date, format); + case Granularity.YEAR: + return printYear(now, date, format); + default: + return date.format(format); + } + } + +}); diff --git a/src/components/search_bar/query/date_format.test.js b/src/components/search_bar/query/date_format.test.js new file mode 100644 index 00000000000..bd98a2a725e --- /dev/null +++ b/src/components/search_bar/query/date_format.test.js @@ -0,0 +1,351 @@ +import { dateFormat, dateGranularity, Granularity } from './date_format'; +import { Random } from '../../../services/random'; +import moment from 'moment'; + +const random = new Random(); +const originalMomentNow = moment.now; + +const now = random.moment().utc(); + +beforeEach(() => { + moment.now = () => +now; +}); + +afterEach(() => { + moment.now = originalMomentNow; +}); + + +describe('date format', () => { + + test('parse - explicit date', () => { + const parsed = dateFormat.parse('2018-01-02T22:33:44.555Z'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(2018); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(2); + expect(parsed.hours()).toBe(22); + expect(parsed.minutes()).toBe(33); + expect(parsed.seconds()).toBe(44); + expect(parsed.milliseconds()).toBe(555); + expect(dateGranularity(parsed)).toBeUndefined(); + }); + + test('parse - explicit date 2', () => { + [ + '12 January 2018 22:33:44', + '12 January 18 22:33:44', + '12 Jan 18 22:33:44', + '12 Jan 2018 22:33:44', + ].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(2018); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(12); + expect(parsed.hours()).toBe(22); + expect(parsed.minutes()).toBe(33); + expect(parsed.seconds()).toBe(44); + expect(dateGranularity(parsed)).toBeUndefined(); + }); + }); + + test('parse - explicit date 3', () => { + [ + '12 January 2018 22:33', + '12 January 18 22:33', + '12 Jan 18 22:33', + '12 Jan 2018 22:33', + ].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(2018); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(12); + expect(parsed.hours()).toBe(22); + expect(parsed.minutes()).toBe(33); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBeUndefined(); + }); + }); + + test('parse - time', () => { + ['22:33', '10:33 PM', '10:33 pm'].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(now.year()); + expect(parsed.month()).toBe(now.month()); + expect(parsed.date()).toBe(now.date()); + expect(parsed.hours()).toBe(22); + expect(parsed.minutes()).toBe(33); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBeUndefined(); + }); + + ['04:33', '4:33', '4:33 AM', '4:33 am'].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(now.year()); + expect(parsed.month()).toBe(now.month()); + expect(parsed.date()).toBe(now.date()); + expect(parsed.hours()).toBe(4); + expect(parsed.minutes()).toBe(33); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBeUndefined(); + }); + }); + + test('parse - day granularity', () => { + + [ + '2 Jan 18', + '2nd Jan 18', + '2 Jan 2018', + '2nd Jan 2018', + '02 Jan 18', + '02 jan 2018', + '2 January 18', + '2nd January 18', + '2 January 2018', + '2nd January 2018', + '02 January 18', + '02 January 2018', + ].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(2018); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(2); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.DAY); + }); + + const sunday = moment(now).subtract(now.day(), 'days'); + ['Sun', 'Sunday'].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(sunday.year()); + expect(parsed.month()).toBe(sunday.month()); + expect(parsed.date()).toBe(sunday.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.DAY); + }); + + const today = now; + let parsed = dateFormat.parse('today'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(today.year()); + expect(parsed.month()).toBe(today.month()); + expect(parsed.date()).toBe(today.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.DAY); + + const tomorrow = moment(today).add(1, 'days'); + parsed = dateFormat.parse('tomorrow'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(tomorrow.year()); + expect(parsed.month()).toBe(tomorrow.month()); + expect(parsed.date()).toBe(tomorrow.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.DAY); + + const yesterday = moment(today).subtract(1, 'days'); + parsed = dateFormat.parse('yesterday'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(yesterday.year()); + expect(parsed.month()).toBe(yesterday.month()); + expect(parsed.date()).toBe(yesterday.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.DAY); + }); + + test('parse - week granularity', () => { + const weekNumber = random.integer({ min: 0, max: 50 }); + const week = moment(now).week(weekNumber).startOf('week'); + let parsed = dateFormat.parse(`Week ${weekNumber}`); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(week.year()); + expect(parsed.month()).toBe(week.month()); + expect(parsed.date()).toBe(week.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.WEEK); + + const thisWeek = moment(now).startOf('week'); + parsed = dateFormat.parse('this week'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(thisWeek.year()); + expect(parsed.month()).toBe(thisWeek.month()); + expect(parsed.date()).toBe(thisWeek.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.WEEK); + + const nextWeek = moment(now).add(1, 'week').startOf('week'); + parsed = dateFormat.parse('next week'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(nextWeek.year()); + expect(parsed.month()).toBe(nextWeek.month()); + expect(parsed.date()).toBe(nextWeek.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.WEEK); + + const lastWeek = moment(now).subtract(1, 'week').startOf('week'); + parsed = dateFormat.parse('last week'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(lastWeek.year()); + expect(parsed.month()).toBe(lastWeek.month()); + expect(parsed.date()).toBe(lastWeek.date()); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.WEEK); + }); + + test('parse - month granularity', () => { + + [ + 'Feb', + 'February' + ].forEach(date => { + const parsed = dateFormat.parse(date); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(now.year()); + expect(parsed.month()).toBe(1); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.MONTH); + }); + + [ + 'Feb 17', + 'February 17', + 'Feb 2017', + 'February 2017', + ].forEach(date => { + const parsed = dateFormat.parse(date); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(2017); + expect(parsed.month()).toBe(1); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.MONTH); + }); + + const january = moment(now).subtract(now.month(), 'month'); + ['Jan', 'January'].forEach(time => { + const parsed = dateFormat.parse(time); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(january.year()); + expect(parsed.month()).toBe(january.month()); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.MONTH); + }); + + const thisMonth = now.startOf('month'); + let parsed = dateFormat.parse('this month'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(thisMonth.year()); + expect(parsed.month()).toBe(thisMonth.month()); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.MONTH); + + const nextMonth = moment(thisMonth).add(1, 'months'); + parsed = dateFormat.parse('next month'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(nextMonth.year()); + expect(parsed.month()).toBe(nextMonth.month()); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.MONTH); + + const lastMonth = moment(thisMonth).subtract(1, 'month'); + parsed = dateFormat.parse('last month'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(lastMonth.year()); + expect(parsed.month()).toBe(lastMonth.month()); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.MONTH); + }); + + test('parse - year granularity', () => { + const year = random.integer({ min: 1970, max: new Date().getFullYear() }); + [ + year.toString(), + year % 100 < 10 ? `0${year % 100}` : (year % 100).toString() // YY format (padding with 0s) + ].forEach(date => { + const parsed = dateFormat.parse(date); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(year); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.YEAR); + }); + + const thisYear = now.startOf('year'); + let parsed = dateFormat.parse('this year'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(thisYear.year()); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.YEAR); + + const nextYear = moment(thisYear).add(1, 'years'); + parsed = dateFormat.parse('next year'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(nextYear.year()); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.YEAR); + + const lastYear = moment(thisYear).subtract(1, 'years'); + parsed = dateFormat.parse('last year'); + expect(parsed.utcOffset()).toBe(0); + expect(parsed.year()).toBe(lastYear.year()); + expect(parsed.month()).toBe(0); + expect(parsed.date()).toBe(1); + expect(parsed.hours()).toBe(0); + expect(parsed.minutes()).toBe(0); + expect(parsed.seconds()).toBe(0); + expect(dateGranularity(parsed)).toBe(Granularity.YEAR); + }); + +}); diff --git a/src/components/search_bar/query/date_value.js b/src/components/search_bar/query/date_value.js new file mode 100644 index 00000000000..04076d57830 --- /dev/null +++ b/src/components/search_bar/query/date_value.js @@ -0,0 +1,57 @@ +import { isDateLike, isNumber } from '../../../services/predicate'; +import { + dateFormat as defaultDateFormat, + dateGranularity +} from './date_format'; +import moment from 'moment'; + +export const DATE_TYPE = 'date'; + +export const dateValuesEqual = (v1, v2) => { + return v1.raw === v2.raw && + v1.granularity === v2.granularity && + v1.text === v2.text; +}; + +export const isDateValue = (value) => { + return !!value && value.type === DATE_TYPE && !!value.raw && !!value.text && !!value.resolve; +}; + +export const dateValue = (raw, granularity, dateFormat = defaultDateFormat) => { + if (!raw) { + return undefined; + } + if (isDateLike(raw)) { + return { + type: DATE_TYPE, + raw, + granularity, + text: dateFormat.print(raw), + resolve: () => moment(raw) + }; + } + if (isNumber(raw)) { + return { + type: DATE_TYPE, + raw, + granularity, + text: raw.toString(), + resolve: () => moment(raw) + }; + } + const text = raw.toString(); + return { + type: DATE_TYPE, + raw, + granularity, + text, + resolve: () => dateFormat.parse(text) + }; +}; + +export const dateValueParser = (format = defaultDateFormat) => { + return (text) => { + const parsed = format.parse(text); + return dateValue(text, dateGranularity(parsed), format); + }; +}; diff --git a/src/components/search_bar/query/date_value.test.js b/src/components/search_bar/query/date_value.test.js new file mode 100644 index 00000000000..236222c0d1d --- /dev/null +++ b/src/components/search_bar/query/date_value.test.js @@ -0,0 +1,22 @@ +import { dateValueParser, isDateValue } from './date_value'; +import { Random } from '../../../services/random'; + +const random = new Random(); + +describe('date value', () => { + + test('dateValueParser', () => { + const date = random.moment().utc(); + const parse = jest.fn(); + parse.mockReturnValue(date); + const format = { parse, print: jest.fn() }; + const parser = dateValueParser(format); + const value = parser('dateString'); + expect(parse.mock.calls.length).toBe(1); + expect(parse.mock.calls[0][0]).toBe('dateString'); + expect(isDateValue(value)).toBe(true); + expect(value.resolve().isSame(date)).toBe(true); + }); + + +}); diff --git a/src/components/search_bar/query/default_syntax.js b/src/components/search_bar/query/default_syntax.js index c2f0baf5538..7438b3ab41d 100644 --- a/src/components/search_bar/query/default_syntax.js +++ b/src/components/search_bar/query/default_syntax.js @@ -1,18 +1,13 @@ import { AST } from './ast'; -import { isArray } from '../../../services/predicate'; +import { isArray, isString, isDateLike } from '../../../services/predicate'; +import { dateFormat as defaultDateFormat } from './date_format'; +import { dateValueParser, isDateValue } from './date_value'; import peg from 'pegjs-inline-precompile'; // eslint-disable-line import/no-unresolved -const unescapeValue = (value) => { - return value.replace(/\\([:\-\\])/, '$1'); -}; - -const escapeValue = (value) => { - return value.replace(/([:\-\\])/, '\\$1'); -}; - const parser = peg` { - const { AST, unescapeValue } = options; + const { AST, Exp, unescapeValue, resolveFieldValue } = options; + const ctx = Object.assign({ error }, options ); } Query @@ -36,50 +31,101 @@ TermClause / space? value:termValue { return AST.Term.must(value); } IsClause - = space? "-" value:IsValue { return AST.Is.mustNot(value); } - / space? value:IsValue { return AST.Is.must(value); } + = space? "-" flag:IsFlag { return AST.Is.mustNot(flag); } + / space? flag:IsFlag { return AST.Is.must(flag); } -IsValue - = "is:" value:value { return value; } +IsFlag + = "is:" flag:flagName { + validateFlag(flag, location(), ctx); + return flag; + } FieldClause - = space? "-" fv:FieldAndValue { return AST.Field.mustNot(fv.field, fv.value); } - / space? fv:FieldAndValue { return AST.Field.must(fv.field, fv.value); } + = space? "-" fv:FieldEQValue { return AST.Field.mustNot.eq(fv.field, fv.value); } + / space? "-" fv:FieldGTValue { return AST.Field.mustNot.gt(fv.field, fv.value); } + / space? "-" fv:FieldGTEValue { return AST.Field.mustNot.gte(fv.field, fv.value); } + / space? "-" fv:FieldLTValue { return AST.Field.mustNot.lt(fv.field, fv.value); } + / space? "-" fv:FieldLTEValue { return AST.Field.mustNot.lte(fv.field, fv.value); } + / space? fv:FieldEQValue { return AST.Field.must.eq(fv.field, fv.value); } + / space? fv:FieldGTValue { return AST.Field.must.gt(fv.field, fv.value); } + / space? fv:FieldGTEValue { return AST.Field.must.gte(fv.field, fv.value); } + / space? fv:FieldLTValue { return AST.Field.must.lt(fv.field, fv.value); } + / space? fv:FieldLTEValue { return AST.Field.must.lte(fv.field, fv.value); } + +FieldEQValue + = field:fieldName ":" valueExpression:fieldContainsValue { + return {field, value: resolveFieldValue(field, valueExpression, ctx) }; + } + +FieldGTValue + = field:fieldName ">" valueExpression:fieldRangeValue { + return {field, value: resolveFieldValue(field, valueExpression, ctx)}; + } + +FieldGTEValue + = field:fieldName ">=" valueExpression:fieldRangeValue { + return {field, value: resolveFieldValue(field, valueExpression, ctx)}; + } -FieldAndValue - = field:fieldName ":" value:fieldValue { return {field, value}; } +FieldLTValue + = field:fieldName "<" valueExpression:fieldRangeValue { + return {field, value: resolveFieldValue(field, valueExpression, ctx)}; + } + +FieldLTEValue + = field:fieldName "<=" valueExpression:fieldRangeValue { + return {field, value: resolveFieldValue(field, valueExpression, ctx)}; + } + +flagName "flag name" + = identifier fieldName "field name" - = fieldChar+ { return unescapeValue(text()); } + = identifier + +identifier + = identifierChar+ { return unescapeValue(text()); } -fieldChar +identifierChar = alnum / [-] / escapedChar + +fieldRangeValue + = rangeValue -fieldValue "field value" - = fieldValues - / value - -fieldValues - = "(" space? head:value tail:( - space ([oO][rR]) space value:value { return value; } - )* space? ")" { return [ head, ...tail ] } +fieldContainsValue "field value" + = containsOrValues + / containsValue termValue "term" - = value + = value:containsValue { return value.expression; } + +containsOrValues + = "(" space? head:containsValue tail:( + space ([oO][rR]) space value:containsValue { return value; } + )* space? ")" { return [ head, ...tail ]; } + +rangeValue + = number + / date -value - = word - / '"' space? phrase:phrase space?'"' { return phrase; } +containsValue + = number + / date + / boolean + / word + / phrase phrase - = word (space word)* { return unescapeValue(text()); } + = '"' space? phrase:( + word (space word)* { return unescapeValue(text()); } + ) space? '"' { return Exp.string(phrase, location()); } word - = valueChar+ { return unescapeValue(text()); } + = wordChar+ { return Exp.string(unescapeValue(text()), location()); } -valueChar +wordChar = alnum / [-] / escapedChar @@ -90,40 +136,177 @@ escapedChar reservedChar = [\-:\\\\] +boolean + = [tT][rR][uU][eE] { return Exp.boolean(text(), location()); } + / [fF][aA][lL][sS][eE] { return Exp.boolean(text(), location()); } + / [yY][eE][sS] { return Exp.boolean(text(), location()); } + / [nN][oO] { return Exp.boolean(text(), location()); } + / [oO][nN] { return Exp.boolean(text(), location()); } + / [oO][fF][fF] { return Exp.boolean(text(), location()); } + +number + = [\\-]?[0-9]+("."[0-9]+)* { return Exp.number(text(), location()); } + +date + = "'" expression:((!"'" .)+ { return text(); }) "'" { + return Exp.date(expression, location()); + } + alnum "alpha numeric" - = [a-zA-Z0-9] + = [a-zA-Z0-9\\.] space "whitespace" = [ \\t\\n\\r]+ `; -const printValue = (value) => { +const unescapeValue = (value) => { + return value.replace(/\\([:\-\\])/, '$1'); +}; + +const escapeValue = (value) => { + return value.replace(/([:\-\\])/, '\\$1'); +}; + +const Exp = { + date: (expression, location) => ({ type: 'date', expression, location }), + number: (expression, location) => ({ type: 'number', expression, location }), + string: (expression, location) => ({ type: 'string', expression, location }), + boolean: (expression, location) => ({ type: 'boolean', expression, location }) +}; + +const validateFlag = (flag, location, ctx) => { + if (ctx.schema && ctx.schema.strict) { + if (ctx.schema.flags && ctx.schema.flags.includes(flag)) { + return; + } + if (ctx.schema.fields && ctx.schema.fields[flag] && ctx.schema.fields[flag].type === 'boolean') { + return; + } + ctx.error(`Unknown flag \`${flag}\``); + } +}; + +const validateFieldValue = (field, schemaField, expression, value, location, error) => { + if (schemaField && schemaField.validate) { + try { + schemaField.validate(value); + } catch (e) { + error(`Invalid value \`${expression}\` set for field \`${field}\` - ${e.message}`, location); + } + } +}; + +const resolveFieldValue = (field, valueExpression, ctx) => { + const { schema, error, parseDate } = ctx; + if (isArray(valueExpression)) { + return valueExpression.map(exp => resolveFieldValue(field, exp, ctx)); + } + const { type, expression, location } = valueExpression; + if (schema && !schema.fields[field] && schema.strict) { + error(`Unknown field \`${field}\``, location); + } + const schemaField = schema && schema.fields[field]; + if (schemaField && schemaField.type !== type && schema.strict) { + const valueDesc = schemaField.valueDescription || `a ${schemaField.type} value`; + error(`Expected ${valueDesc} for field \`${field}\`, but found \`${expression}\``, location); + } + switch(type) { + + case 'date': + let date = null; + try { + date = parseDate(expression); + } catch (e) { + error(`Invalid data \`${expression}\` set for field \`${field}\``, location); + } + validateFieldValue(field, schemaField, expression, date, location, error); + return date; + + case 'number': + const number = Number(expression); + if (Number.isNaN(number)) { + error(`Invalid number \`${expression}\` set for field \`${field}\``, location); + } + validateFieldValue(field, schemaField, expression, number, location, error); + return number; + + case 'boolean': + const boolean = !!expression.match(/true|yes|on/i); + validateFieldValue(field, schemaField, expression, boolean, location, error); + return boolean; + + default: + validateFieldValue(field, schemaField, expression, expression, location, error); + return expression; + } +}; + +const printValue = (value, options) => { + if (isDateValue(value)) { + return `'${value.text}'`; + } + if (isDateLike(value)) { + const dateFormat = options.dateFormat || defaultDateFormat; + return `'${dateFormat.print(value)}'`; + } + if (!isString(value)) { + return value.toString(); + } if (value.match(/\s/)) { return `"${escapeValue(value)}"`; } return escapeValue(value); }; +const resolveOperator = (operator) => { + switch (operator) { + case AST.Operator.EQ: + return ':'; + case AST.Operator.GT: + return '>'; + case AST.Operator.GTE: + return '>='; + case AST.Operator.LT: + return '<'; + case AST.Operator.LTE: + return '<='; + default: + throw new Error(`unknown field/value operator [${operator}]`); + } +}; + export const defaultSyntax = Object.freeze({ - parse: (query) => { - const clauses = parser.parse(query, { AST, unescapeValue }); + parse: (query, options = {}) => { + const dateFormat = options.dateFormat || defaultDateFormat; + const parseDate = dateValueParser(dateFormat); + const schema = options.schema || {}; + const clauses = parser.parse(query, { + AST, + Exp, + unescapeValue, + parseDate, + resolveFieldValue, + validateFlag, + schema: { strict: false, flags: [], fields: {}, ...schema } + }); return AST.create(clauses); }, - print: (ast) => { + print: (ast, options = {}) => { return ast.clauses.reduce((text, clause) => { const prefix = AST.Match.isMustClause(clause) ? '' : '-'; switch (clause.type) { case AST.Field.TYPE: + const op = resolveOperator(clause.operator); if (isArray(clause.value)) { - return `${text} ${prefix}${escapeValue(clause.field)}:(${clause.value.map(val => printValue(val)).join(' or ')})`; + return `${text} ${prefix}${escapeValue(clause.field)}${op}(${clause.value.map(val => printValue(val, options)).join(' or ')})`; } - return `${text} ${prefix}${escapeValue(clause.field)}:${printValue(clause.value)}`; + return `${text} ${prefix}${escapeValue(clause.field)}${op}${printValue(clause.value, options)}`; case AST.Is.TYPE: return `${text} ${prefix}is:${escapeValue(clause.flag)}`; case AST.Term.TYPE: - return `${text} ${prefix}${printValue(clause.value)}`; + return `${text} ${prefix}${printValue(clause.value, options)}`; default: return text; } @@ -131,5 +314,3 @@ export const defaultSyntax = Object.freeze({ } }); - - diff --git a/src/components/search_bar/query/default_syntax.test.js b/src/components/search_bar/query/default_syntax.test.js index 12cbb33a12e..e5495c35b33 100644 --- a/src/components/search_bar/query/default_syntax.test.js +++ b/src/components/search_bar/query/default_syntax.test.js @@ -1,5 +1,10 @@ import { defaultSyntax } from './default_syntax'; import { AST } from './ast'; +import { Granularity } from './date_format'; +import { isDateValue } from './date_value'; +import { Random } from '../../../services/random'; + +const random = new Random(); describe('defaultSyntax', () => { @@ -140,12 +145,12 @@ describe('defaultSyntax', () => { expect(clause.field).toBe('name'); expect(clause.value).toBe('john'); - clause = ast.getSimpleFieldClause('age', '6'); + clause = ast.getSimpleFieldClause('age', 6); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('age'); - expect(clause.value).toBe('6'); + expect(clause.value).toBe(6); const printedQuery = defaultSyntax.print(ast); expect(printedQuery).toBe(query); @@ -166,19 +171,19 @@ describe('defaultSyntax', () => { expect(clause.field).toBe('name'); expect(clause.value).toBe('john'); - clause = ast.getSimpleFieldClause('age', '6'); + clause = ast.getSimpleFieldClause('age', 6); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('age'); - expect(clause.value).toBe('6'); + expect(clause.value).toBe(6); - clause = ast.getSimpleFieldClause('age', '5'); + clause = ast.getSimpleFieldClause('age', 5); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('age'); - expect(clause.value).toBe('5'); + expect(clause.value).toBe(5); const printedQuery = defaultSyntax.print(ast); expect(printedQuery).toBe(query); @@ -199,19 +204,19 @@ describe('defaultSyntax', () => { expect(clause.field).toBe('name'); expect(clause.value).toBe('john'); - clause = ast.getSimpleFieldClause('age', '6'); + clause = ast.getSimpleFieldClause('age', 6); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('age'); - expect(clause.value).toBe('6'); + expect(clause.value).toBe(6); - clause = ast.getSimpleFieldClause('age', '5'); + clause = ast.getSimpleFieldClause('age', 5); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(false); expect(clause.field).toBe('age'); - expect(clause.value).toBe('5'); + expect(clause.value).toBe(5); const printedQuery = defaultSyntax.print(ast); expect(printedQuery).toBe(query); @@ -292,12 +297,12 @@ describe('defaultSyntax', () => { expect(AST.Match.isMustClause(clause)).toBe(false); expect(clause.value).toBe('bar'); - clause = ast.getSimpleFieldClause('age', '5'); + clause = ast.getSimpleFieldClause('age', 5); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('age'); - expect(clause.value).toBe('5'); + expect(clause.value).toBe(5); clause = ast.getSimpleFieldClause('name', 'joe'); expect(clause).toBeDefined(); @@ -337,12 +342,12 @@ describe('defaultSyntax', () => { expect(AST.Match.isMustClause(clause)).toBe(false); expect(clause.value).toBe('bar'); - clause = ast.getSimpleFieldClause('age', '5'); + clause = ast.getSimpleFieldClause('age', 5); expect(clause).toBeDefined(); expect(AST.Field.isInstance(clause)).toBe(true); expect(AST.Match.isMustClause(clause)).toBe(true); expect(clause.field).toBe('age'); - expect(clause.value).toBe('5'); + expect(clause.value).toBe(5); clause = ast.getSimpleFieldClause('name', 'joe'); expect(clause).toBeDefined(); @@ -492,4 +497,352 @@ describe('defaultSyntax', () => { expect(printedQuery).toBe(query); }); + test('date eq expression', () => { + + const query = `created:'12 Jan 2010'`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getSimpleFieldClause('created'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isEQClause(clause)).toBe(true); + expect(clause.field).toBe('created'); + expect(clause.value).toBeDefined(); + expect(isDateValue(clause.value)).toBe(true); + expect(clause.value.raw).toBe('12 Jan 2010'); + expect(clause.value.text).toBe('12 Jan 2010'); + expect(clause.value.granularity).toBe(Granularity.DAY); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('date > expression', () => { + + const query = `expires>'last week'`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getSimpleFieldClause('expires'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isGTClause(clause)).toBe(true); + expect(clause.field).toBe('expires'); + expect(clause.value).toBeDefined(); + expect(isDateValue(clause.value)).toBe(true); + expect(clause.value.raw).toBe('last week'); + expect(clause.value.text).toBe('last week'); + expect(clause.value.granularity).toBe(Granularity.WEEK); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('date >= expression', () => { + + const query = `expires>='next year'`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getSimpleFieldClause('expires'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isGTEClause(clause)).toBe(true); + expect(clause.field).toBe('expires'); + expect(clause.value).toBeDefined(); + expect(isDateValue(clause.value)).toBe(true); + expect(clause.value.raw).toBe('next year'); + expect(clause.value.text).toBe('next year'); + expect(clause.value.granularity).toBe(Granularity.YEAR); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('date < expression', () => { + + const query = `created<'last month'`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getSimpleFieldClause('created'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isLTClause(clause)).toBe(true); + expect(clause.field).toBe('created'); + expect(clause.value).toBeDefined(); + expect(isDateValue(clause.value)).toBe(true); + expect(clause.value.raw).toBe('last month'); + expect(clause.value.text).toBe('last month'); + expect(clause.value.granularity).toBe(Granularity.MONTH); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('date <= expression', () => { + + const query = `created<='Sunday'`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getSimpleFieldClause('created'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isLTEClause(clause)).toBe(true); + expect(clause.field).toBe('created'); + expect(clause.value).toBeDefined(); + expect(isDateValue(clause.value)).toBe(true); + expect(clause.value.raw).toBe('Sunday'); + expect(clause.value.text).toBe('Sunday'); + expect(clause.value.granularity).toBe(Granularity.DAY); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('boolean : expression', () => { + + const query = `active:true -closed:false`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(2); + + let clause = ast.getSimpleFieldClause('active'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isEQClause(clause)).toBe(true); + expect(clause.field).toBe('active'); + expect(clause.value).toBe(true); + + clause = ast.getSimpleFieldClause('closed'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMust(clause)).toBe(false); + expect(AST.Operator.isEQClause(clause)).toBe(true); + expect(clause.field).toBe('closed'); + expect(clause.value).toBe(false); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('number range expressions', () => { + + const query = `num1>6 -num2>=8 num3<4 -num4<=2`; + const ast = defaultSyntax.parse(query); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(4); + + let clause = ast.getSimpleFieldClause('num1'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isGTClause(clause)).toBe(true); + expect(clause.field).toBe('num1'); + expect(clause.value).toBe(6); + + clause = ast.getSimpleFieldClause('num2'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(false); + expect(AST.Operator.isGTEClause(clause)).toBe(true); + expect(clause.field).toBe('num2'); + expect(clause.value).toBe(8); + + clause = ast.getSimpleFieldClause('num3'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(AST.Operator.isLTClause(clause)).toBe(true); + expect(clause.field).toBe('num3'); + expect(clause.value).toBe(4); + + clause = ast.getSimpleFieldClause('num4'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(false); + expect(AST.Operator.isLTEClause(clause)).toBe(true); + expect(clause.field).toBe('num4'); + expect(clause.value).toBe(2); + + const printedQuery = defaultSyntax.print(ast); + expect(printedQuery).toBe(query); + }); + + test('strict schema - flags - listed', () => { + + const query = `is:active`; + const schema = { + strict: true, + flags: [ 'active' ] + }; + const ast = defaultSyntax.parse(query, { schema }); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getIsClause('active'); + expect(clause).toBeDefined(); + expect(AST.Is.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(clause.flag).toBe('active'); + }); + + test('strict schema - flags - listed as boolean field', () => { + + const query = `is:active`; + const schema = { + strict: true, + fields: { + active: { + type: 'boolean' + } + } + }; + const ast = defaultSyntax.parse(query, { schema }); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getIsClause('active'); + expect(clause).toBeDefined(); + expect(AST.Is.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(clause.flag).toBe('active'); + }); + + test('strict schema - flags - listed as non-boolean field', () => { + + const query = `is:active`; + const schema = { + strict: true, + fields: { + active: { + type: random.oneOf('number', 'string', 'date') + } + } + }; + expect(() => { + defaultSyntax.parse(query, { schema }); + }).toThrow('Unknown flag `active`'); + }); + + test('strict schema - flags - not listed', () => { + + const query = `is:active`; + const schema = { + strict: true + }; + expect(() => { + defaultSyntax.parse(query, { schema }); + }).toThrow('Unknown flag `active`'); + }); + + test('strict schema - fields - listed', () => { + + const query = `name:foo`; + const schema = { + strict: true, + fields: { + name: { + type: 'string' + } + } + }; + const ast = defaultSyntax.parse(query, { schema }); + + expect(ast).toBeDefined(); + expect(ast.clauses).toHaveLength(1); + + const clause = ast.getSimpleFieldClause('name'); + expect(clause).toBeDefined(); + expect(AST.Field.isInstance(clause)).toBe(true); + expect(AST.Match.isMustClause(clause)).toBe(true); + expect(clause.field).toBe('name'); + expect(clause.value).toBe('foo'); + }); + + test('strict schema - fields - listed - with data type mismatch', () => { + + const query = `name:foo`; + const schema = { + strict: true, + fields: { + name: { + type: 'boolean' + } + } + }; + expect(() => { + defaultSyntax.parse(query, { schema }); + }).toThrow('Expected a boolean value for field `name`, but found `foo`'); + }); + + test('strict schema - fields - listed - with data type mismatch - with value description', () => { + + const query = `name:foo`; + const schema = { + strict: true, + fields: { + name: { + type: 'boolean', + valueDescription: '`true` or `false`' + } + } + }; + expect(() => { + defaultSyntax.parse(query, { schema }); + }).toThrow('Expected `true` or `false` for field `name`, but found `foo`'); + }); + + test('strict schema - fields - listed - with validate', () => { + + const query = `name:foo`; + const schema = { + strict: true, + fields: { + name: { + type: 'string', + validate: () => { + throw new Error('invalid name!!!'); + } + } + } + }; + expect(() => { + defaultSyntax.parse(query, { schema }); + }).toThrow(/invalid name!!!/); + }); + + test('strict schema - fields - not listed', () => { + + const query = `name:foo`; + const schema = { + strict: true + }; + expect(() => { + defaultSyntax.parse(query, { schema }); + }).toThrow('Unknown field `name`'); + }); + }); diff --git a/src/components/search_bar/query/execute_ast.js b/src/components/search_bar/query/execute_ast.js index f825f40c73c..fa976deec99 100644 --- a/src/components/search_bar/query/execute_ast.js +++ b/src/components/search_bar/query/execute_ast.js @@ -1,14 +1,16 @@ import { get } from 'lodash'; import { isString, isArray } from '../../../services/predicate'; -import { must } from './must'; -import { mustNot } from './must_not'; +import { eq, gt, gte, lt, lte } from './operators'; import { AST } from './ast'; const EXPLAIN_FIELD = '__explain'; -const matchers = { - [AST.Match.MUST]: must, - [AST.Match.MUST_NOT]: mustNot +const operators = { + [AST.Operator.EQ]: eq, + [AST.Operator.GT]: gt, + [AST.Operator.GTE]: gte, + [AST.Operator.LT]: lt, + [AST.Operator.LTE]: lte }; const defaultIsClauseMatcher = (record, clause, explain) => { @@ -25,16 +27,19 @@ const defaultIsClauseMatcher = (record, clause, explain) => { const fieldClauseMatcher = (record, field, clauses = [], explain) => { return clauses.every(clause => { const { type, value, match } = clause; - const matcher = matchers[match]; - if (!matcher) { // unknown matcher + let operator = operators[clause.operator]; + if (!operator) { // unknown matcher return true; } + if (!AST.Match.isMust(match)) { + operator = (value, token) => !operators[clause.operator](value, token); + } const recordValue = get(record, field); const hit = isArray(value) ? - value.some(v => matcher(recordValue, v)) : - matcher(recordValue, value); + value.some(v => operator(recordValue, v)) : + operator(recordValue, value); if (explain && hit) { - explain.push({ hit, type, field, value, match }); + explain.push({ hit, type, field, value, match, operator }); } return hit; }); @@ -53,25 +58,23 @@ const termClauseMatcher = (record, fields, clauses = [], explain) => { fields = fields || resolveStringFields(record); return clauses.every(clause => { const { type, value, match } = clause; - const matcher = matchers[match]; - if (!matcher) { // unknown matcher - return true; - } + const operator = operators[AST.Operator.EQ]; if (AST.Match.isMustClause(clause)) { return fields.some(field => { const recordValue = get(record, field); - const hit = matcher(recordValue, value); + const hit = operator(recordValue, value); if (explain && hit) { explain.push({ hit, type, field, match, value }); } return hit; }); } else { + const notMatcher = (value, token) => !operator(value, token); return fields.every(field => { const recordValue = get(record, field); - const hit = matcher(recordValue, value); + const hit = notMatcher(recordValue, value); if (explain && hit) { - explain.push({ hit, type, field, match, value }); + explain.push({ hit, type, field, value, match }); } return hit; }); diff --git a/src/components/search_bar/query/execute_ast.test.js b/src/components/search_bar/query/execute_ast.test.js index 58af1721059..df1b589ff05 100644 --- a/src/components/search_bar/query/execute_ast.test.js +++ b/src/components/search_bar/query/execute_ast.test.js @@ -12,7 +12,7 @@ describe('execute ast', () => { { name: 'joe' } ]; const result = executeAst(AST.create([ - AST.Field.must('name', 'john') + AST.Field.must.eq('name', 'john') ]), items); expect(result).toHaveLength(1); expect(result[0].name).toBe('john doe'); @@ -24,7 +24,7 @@ describe('execute ast', () => { { name: 'joe' } ]; const result = executeAst(AST.create([ - AST.Field.mustNot('name', 'john') + AST.Field.mustNot.eq('name', 'john') ]), items); expect(result).toHaveLength(1); expect(result[0].name).toBe('joe'); @@ -36,19 +36,19 @@ describe('execute ast', () => { { name: 'joe' } ]; let result = executeAst(AST.create([ - AST.Field.must('name', ['john', 'doe']) + AST.Field.must.eq('name', ['john', 'doe']) ]), items); expect(result).toHaveLength(1); expect(result[0].name).toBe('john'); result = executeAst(AST.create([ - AST.Field.must('name', ['joe', 'doe']) + AST.Field.must.eq('name', ['joe', 'doe']) ]), items); expect(result).toHaveLength(1); expect(result[0].name).toBe('joe'); result = executeAst(AST.create([ - AST.Field.must('name', ['foo', 'bar']) + AST.Field.must.eq('name', ['foo', 'bar']) ]), items); expect(result).toHaveLength(0); }); @@ -59,7 +59,7 @@ describe('execute ast', () => { { name: 'joe' } ]; const result = executeAst(AST.create([ - AST.Field.must('name', 'foo') + AST.Field.must.eq('name', 'foo') ]), items); expect(result).toHaveLength(0); }); @@ -70,8 +70,8 @@ describe('execute ast', () => { { name: 'joe' } ]; const result = executeAst(AST.create([ - AST.Field.must('name', 'john'), - AST.Field.must('name', 'joe') + AST.Field.must.eq('name', 'john'), + AST.Field.must.eq('name', 'joe') ]), items); expect(result).toHaveLength(0); }); @@ -82,8 +82,8 @@ describe('execute ast', () => { { name: 'joe' } ]; const result = executeAst(AST.create([ - AST.Field.must('name', 'foo'), - AST.Field.must('age', '7') + AST.Field.must.eq('name', 'foo'), + AST.Field.must.eq('age', '7') ]), items); expect(result).toHaveLength(0); }); @@ -94,8 +94,8 @@ describe('execute ast', () => { { name: 'joe' } ]; const result = executeAst(AST.create([ - AST.Field.must('name', 'john'), - AST.Field.must('age', '5') + AST.Field.must.eq('name', 'john'), + AST.Field.must.eq('age', '5') ]), items); expect(result).toHaveLength(1); }); @@ -214,7 +214,7 @@ describe('execute ast', () => { ]; const result = executeAst(AST.create([ AST.Is.mustNot('open'), - AST.Field.must('age', '7'), + AST.Field.must.eq('age', '7'), AST.Term.must('bar'), AST.Term.mustNot('foo') ]), items); @@ -231,7 +231,7 @@ describe('execute ast', () => { { text: 'bar', age: 7 }, ]; const result = executeAst(AST.create([ - AST.Field.must('name', 'John Doe'), + AST.Field.must.eq('name', 'John Doe'), ]), items); expect(result).toHaveLength(1); expect(result[0].name).toBe('john doe'); @@ -245,7 +245,7 @@ describe('execute ast', () => { { name: 'bar', age: 7 }, ]; const result = executeAst(AST.create([ - AST.Field.must('name', [ 'john', 'bar' ]), + AST.Field.must.eq('name', [ 'john', 'bar' ]), ]), items); expect(result).toHaveLength(3); const names = result.map(item => item.name); @@ -254,4 +254,104 @@ describe('execute ast', () => { expect(names).toContain('foo bar'); }); + test('gt fields', () => { + const items = [ + { name: 'john doe', age: 5, open: true }, + { name: 'foo', age: 6, open: false }, + { name: 'foo bar', age: 7 }, + { name: 'bar', age: 7 }, + ]; + const result = executeAst(AST.create([ + AST.Field.must.gt('age', 5), + ]), items); + expect(result).toHaveLength(3); + const names = result.map(item => item.name); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + expect(names).toContain('foo bar'); + }); + + test('gte fields', () => { + const items = [ + { name: 'john doe', age: 5, open: true }, + { name: 'foo', age: 6, open: false }, + { name: 'foo bar', age: 7 }, + { name: 'bar', age: 7 }, + ]; + const result = executeAst(AST.create([ + AST.Field.must.gte('age', 5), + ]), items); + expect(result).toHaveLength(4); + const names = result.map(item => item.name); + expect(names).toContain('john doe'); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + expect(names).toContain('foo bar'); + }); + + test('lt fields', () => { + const items = [ + { name: 'john doe', age: 5, open: true }, + { name: 'foo', age: 6, open: false }, + { name: 'foo bar', age: 7 }, + { name: 'bar', age: 7 }, + ]; + const result = executeAst(AST.create([ + AST.Field.must.lt('age', 7), + ]), items); + expect(result).toHaveLength(2); + const names = result.map(item => item.name); + expect(names).toContain('john doe'); + expect(names).toContain('foo'); + }); + + test('lte fields', () => { + const items = [ + { name: 'john doe', age: 5, open: true }, + { name: 'foo', age: 6, open: false }, + { name: 'foo bar', age: 7 }, + { name: 'bar', age: 7 }, + ]; + const result = executeAst(AST.create([ + AST.Field.must.lte('age', 6), + ]), items); + expect(result).toHaveLength(2); + const names = result.map(item => item.name); + expect(names).toContain('john doe'); + expect(names).toContain('foo'); + }); + + test('gt and lte fields', () => { + const items = [ + { name: 'john doe', age: 5, open: true }, + { name: 'foo', age: 6, open: false }, + { name: 'foo bar', age: 7 }, + { name: 'bar', age: 8 }, + ]; + const result = executeAst(AST.create([ + AST.Field.must.gt('age', 5), + AST.Field.must.lte('age', 7), + ]), items); + expect(result).toHaveLength(2); + const names = result.map(item => item.name); + expect(names).toContain('foo'); + expect(names).toContain('foo bar'); + }); + + test('negated range queries', () => { + const items = [ + { name: 'john doe', age: 5, open: true }, + { name: 'foo', age: 6, open: false }, + { name: 'foo bar', age: 7 }, + { name: 'bar', age: 8 }, + ]; + const result = executeAst(AST.create([ + AST.Field.mustNot.lt('age', 6), + AST.Field.mustNot.gte('age', 7), + ]), items); + expect(result).toHaveLength(1); + const names = result.map(item => item.name); + expect(names).toContain('foo'); + }); + }); diff --git a/src/components/search_bar/query/index.js b/src/components/search_bar/query/index.js index 60e75e781d8..09bd2300bf9 100644 --- a/src/components/search_bar/query/index.js +++ b/src/components/search_bar/query/index.js @@ -1,2 +1,5 @@ export { Query } from './query'; export { AST } from './ast'; +export { + dateValueParser as parseDateValue +} from './date_value'; diff --git a/src/components/search_bar/query/must.js b/src/components/search_bar/query/must.js deleted file mode 100644 index 7c02950cc90..00000000000 --- a/src/components/search_bar/query/must.js +++ /dev/null @@ -1,32 +0,0 @@ -import { - isArray, isBoolean, - isNumber, isString -} from '../../../services/predicate'; -import moment from 'moment/moment'; - -const defaultOptions = { - ignoreCase: true -}; - -export const must = (value, token, options = {}) => { - options = { ...defaultOptions, ...options }; - if (isString(value)) { - return options.ignoreCase ? - value.toLowerCase().includes(token.toLowerCase()) : - value.includes(token); - } - if (isNumber(value)) { - token = Number(token); - return value === token; - } - if (isBoolean(value)) { - return token === value.toString(); - } - if (moment.isDate(value) || moment.isMoment(value)) { - return moment(value).isSame(token); - } - if (isArray(value)) { - return value.some(item => must(item, token, options)); - } - return false; // unknown value type -}; diff --git a/src/components/search_bar/query/must_not.js b/src/components/search_bar/query/must_not.js deleted file mode 100644 index eadd7e410b5..00000000000 --- a/src/components/search_bar/query/must_not.js +++ /dev/null @@ -1,32 +0,0 @@ -import { - isArray, isBoolean, - isNumber, isString -} from '../../../services/predicate'; -import moment from 'moment/moment'; - -const defaultOptions = { - ignoreCase: true -}; - -export const mustNot = (value, token, options = {}) => { - options = { ...defaultOptions, ...options }; - if (isString(value)) { - return options.ignoreCase ? - !value.toLowerCase().includes(token.toLowerCase()) : - !value.includes(token); - } - if (isNumber(value)) { - token = Number(token); - return value !== token; - } - if (isBoolean(value)) { - return token !== value.toString(); - } - if (moment.isDate(value) || moment.isMoment(value)) { - return !moment(value).isSame(token); - } - if (isArray(value)) { - return value.every(item => mustNot(item, token, options)); - } - return false; // unknown value type -}; diff --git a/src/components/search_bar/query/operators.js b/src/components/search_bar/query/operators.js new file mode 100644 index 00000000000..640aead310e --- /dev/null +++ b/src/components/search_bar/query/operators.js @@ -0,0 +1,141 @@ +import { dateFormat, dateGranularity } from './date_format'; +import { isDateValue } from './date_value'; +import { + isArray, isBoolean, + isNumber, isString, + isDateLike, isNil +} from '../../../services/predicate'; +import moment from 'moment'; +const utc = moment.utc; + +const resolveValueAsDate = (value) => { + if (moment.isMoment(value)) { + return value; + } + if (moment.isDate(value) || isNumber(value)) { + return moment(value); + } + return dateFormat.parse(value.toString()); +}; + +const defaultEqOptions = { + ignoreCase: true +}; + +export const eq = (fieldValue, clauseValue, options = {}) => { + options = { ...defaultEqOptions, ...options }; + + if (isNil(fieldValue) || isNil(clauseValue)) { + return fieldValue === clauseValue; + } + + if (isDateValue(clauseValue)) { + const dateFieldValue = resolveValueAsDate(fieldValue); + if (clauseValue.granularity) { + return clauseValue.granularity.isSame(dateFieldValue, clauseValue.resolve()); + } + return dateFieldValue.isSame(clauseValue.resolve()); + } + + if (isString(fieldValue)) { + return options.ignoreCase ? + fieldValue.toLowerCase().includes(clauseValue.toString().toLowerCase()) : + fieldValue.includes(clauseValue.toString()); + } + + if (isNumber(fieldValue)) { + clauseValue = Number(clauseValue); + return fieldValue === clauseValue; + } + + if (isBoolean(fieldValue)) { + return clauseValue === fieldValue; + } + + if (isDateLike(fieldValue)) { + const date = resolveValueAsDate(clauseValue); + if (!date.isValid()) { + return false; + } + const granularity = dateGranularity(date); + if (!granularity) { + return utc(fieldValue).isSame(date); + } + return granularity.isSame(fieldValue, date); + } + + if (isArray(fieldValue)) { + return fieldValue.some(item => eq(item, clauseValue, options)); + } + + return false; // unknown value type +}; + +const greaterThen = (fieldValue, clauseValue, inclusive = false) => { + if (isDateValue(clauseValue)) { + const clauseDateValue = clauseValue.resolve(); + if (!clauseValue.granularity) { + return inclusive ? utc(fieldValue).isSameOrAfter(clauseDateValue) : utc(fieldValue).isAfter(clauseDateValue); + } + if (inclusive) { + return utc(fieldValue).isSameOrAfter(clauseValue.granularity.start(clauseDateValue)); + } + return utc(fieldValue).isSameOrAfter(clauseValue.granularity.startOfNext(clauseDateValue)); + } + + if (isString(fieldValue)) { + const str = clauseValue.toString(); + return inclusive ? fieldValue >= str : fieldValue > str; + } + + if (isNumber(fieldValue)) { + const number = Number(clauseValue); + return inclusive ? fieldValue >= number : fieldValue > number; + } + + if (isDateLike(fieldValue)) { + const date = resolveValueAsDate(clauseValue); + const granularity = dateGranularity(date); + if (!granularity) { + return inclusive ? utc(fieldValue).isSameOrAfter(date) : utc(fieldValue).isAfter(date); + } + if (inclusive) { + return utc(fieldValue).isSameOrAfter(granularity.start(date)); + } + return utc(fieldValue).isSameOrAfter(granularity.startOfNext(date)); + } + + if (isArray(fieldValue)) { + return fieldValue.all(item => greaterThen(item, clauseValue, inclusive)); + } + + return false; // unsupported value type +}; + +export const gt = (fieldValue, clauseValue) => { + if (isNil(fieldValue) || isNil(clauseValue)) { + return false; + } + return greaterThen(fieldValue, clauseValue); +}; + +export const gte = (fieldValue, clauseValue) => { + if (isNil(fieldValue) || isNil(clauseValue)) { + return fieldValue === clauseValue; + } + return greaterThen(fieldValue, clauseValue, true); +}; + +export const lt = (fieldValue, clauseValue) => { + if (isNil(fieldValue) || isNil(clauseValue)) { + return false; + } + return !greaterThen(fieldValue, clauseValue, true); +}; + +export const lte = (fieldValue, clauseValue) => { + if (isNil(fieldValue) || isNil(clauseValue)) { + return fieldValue === clauseValue; + } + return !greaterThen(fieldValue, clauseValue); +}; diff --git a/src/components/search_bar/query/operators.test.js b/src/components/search_bar/query/operators.test.js new file mode 100644 index 00000000000..db741698fe6 --- /dev/null +++ b/src/components/search_bar/query/operators.test.js @@ -0,0 +1,587 @@ +import moment from 'moment'; +import { + eq, gt, gte, + lt, lte +} from './operators'; +import { dateValue } from './date_value'; +import { Random } from '../../../services/random'; +import { Granularity } from './date_format'; + +const random = new Random(); + +const laterMoment = (date, count, units) => { + const later = moment(date); + later.add(count, units); + return later; +}; + +const earlierMoment = (date, count, units) => { + const later = moment(date); + later.subtract(count, units); + return later; +}; + +describe('operators', () => { + + test('eq - string', () => { + expect(eq('val', 'val')).toBe(true); + expect(eq('val', 'Val', { ignoreCase: true })).toBe(true); + expect(eq('val', 'Val', { ignoreCase: false })).toBe(false); + expect(eq('foo', 'bar')).toBe(false); + expect(eq('foo', null)).toBe(false); + expect(eq('foo', undefined)).toBe(false); + expect(eq(null, null)).toBe(true); + expect(eq(undefined, undefined)).toBe(true); + }); + + test('eq - number', () => { + const num = random.number({ min: -60, max: 60 }); + expect(eq(num, num)).toBe(true); + expect(eq(num, num - 1)).toBe(false); + expect(eq(num, null)).toBe(false); + expect(eq(num, undefined)).toBe(false); + expect(eq(null, null)).toBe(true); + expect(eq(undefined, undefined)).toBe(true); + }); + + test('eq - boolean', () => { + expect(eq(true, true)).toBe(true); + expect(eq(false, false)).toBe(true); + expect(eq(true, false)).toBe(false); + expect(eq(false, true)).toBe(false); + expect(eq(true, null)).toBe(false); + expect(eq(false, null)).toBe(false); + expect(eq(null, true)).toBe(false); + expect(eq(null, false)).toBe(false); + expect(eq(null, null)).toBe(true); + expect(eq(undefined, undefined)).toBe(true); + }); + + test('eq - date', () => { + const date = random.date(); + const momnt = moment(date); + expect(eq(date, date)).toBe(true); + expect(eq(date, momnt)).toBe(true); + expect(eq(momnt, date)).toBe(true); + expect(eq(momnt, momnt)).toBe(true); + expect(eq(momnt, moment())).toBe(false); + expect(eq(date, 'string')).toBe(false); + expect(eq(momnt, 'string')).toBe(false); + expect(eq(date, 2)).toBe(false); + expect(eq(momnt, 3)).toBe(false); + expect(eq(date, true)).toBe(false); + expect(eq(momnt, false)).toBe(false); + expect(eq(momnt, false)).toBe(false); + expect(eq(null, null)).toBe(true); + expect(eq(undefined, undefined)).toBe(true); + }); + + test('eq - date value', () => { + const date = random.moment(); + const granularity = random.oneOf(Object.values(Granularity)); + const parse = jest.fn(); + const print = jest.fn(); + print.mockReturnValue(date.format()); + const format = { parse, print }; + const value = dateValue(date, granularity, format); + expect(eq(date, value)).toBe(true); + expect(eq(moment(date), value)).toBe(true); + expect(eq(date.valueOf(), value)).toBe(true); + expect(eq(date.format(), value)).toBe(true); + expect(eq(null, null)).toBe(true); + expect(eq(undefined, undefined)).toBe(true); + }); + + test('gt - number', () => { + const num = random.number({ min: -60, max: 60 }); + expect(gt(num + 1, num)).toBe(true); + expect(gt(num, num + 1)).toBe(false); + expect(gt(num, num)).toBe(false); + expect(gt(num, null)).toBe(false); + expect(gt(num, undefined)).toBe(false); + expect(gt(null, null)).toBe(false); + expect(gt(undefined, undefined)).toBe(false); + }); + + test('gt - date', () => { + const date = random.moment(); + const laterDate = laterMoment(date, 1, 'days'); + expect(gt(laterDate, date)).toBe(true); + expect(gt(date, date)).toBe(false); + }); + + test('gt - date value - day granularity', () => { + const date = random.moment(); + date.hours(12); + + const granularity = Granularity.DAY; + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gt(value, hourBeforeValue)).toBe(false); + expect(gt(value, dayBeforeValue)).toBe(true); + }); + }); + + test('gt - date value - week granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + + const granularity = Granularity.WEEK; + + const weekBefore = earlierMoment(date, 1, 'weeks'); + const weekBeforeValue = dateValue(weekBefore, granularity); + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gt(value, hourBeforeValue)).toBe(false); + expect(gt(value, dayBeforeValue)).toBe(false); + expect(gt(value, weekBeforeValue)).toBe(true); + }); + }); + + test('gt - date value - month granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); // july - middle of the year + + const granularity = Granularity.MONTH; + + const monthBefore = earlierMoment(date, 1, 'months'); + const monthBeforeValue = dateValue(monthBefore, granularity); + + const weekBefore = earlierMoment(date, 1, 'weeks'); + const weekBeforeValue = dateValue(weekBefore, granularity); + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gt(value, hourBeforeValue)).toBe(false); + expect(gt(value, dayBeforeValue)).toBe(false); + expect(gt(value, weekBeforeValue)).toBe(false); + expect(gt(value, monthBeforeValue)).toBe(true); + }); + }); + + test('gt - date value - year granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); // july - middle of the year + date.year(1980); + + const granularity = Granularity.YEAR; + + const yearBefore = earlierMoment(date, 1, 'years'); + const yearBeforeValue = dateValue(yearBefore, granularity); + + const monthBefore = earlierMoment(date, 1, 'months'); + const monthBeforeValue = dateValue(monthBefore, granularity); + + const weekBefore = earlierMoment(date, 1, 'weeks'); + const weekBeforeValue = dateValue(weekBefore, granularity); + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gt(value, hourBeforeValue)).toBe(false); + expect(gt(value, dayBeforeValue)).toBe(false); + expect(gt(value, weekBeforeValue)).toBe(false); + expect(gt(value, monthBeforeValue)).toBe(false); + expect(gt(value, yearBeforeValue)).toBe(true); + }); + }); + + test('gte - number', () => { + const num = random.number({ min: -60, max: 60 }); + expect(gte(num + 1, num)).toBe(true); + expect(gte(num, num + 1)).toBe(false); + expect(gte(num, num)).toBe(true); + expect(gte(num, null)).toBe(false); + expect(gte(num, undefined)).toBe(false); + expect(gte(null, null)).toBe(true); + expect(gte(undefined, undefined)).toBe(true); + }); + + test('gte - date and date value', () => { + const date = random.moment(); + const laterDate = laterMoment(date, 1, 'days'); + expect(gte(laterDate, date)).toBe(true); + expect(gte(date, date)).toBe(true); + }); + + test('gte - date value - day granularity', () => { + const date = random.moment(); + date.hours(12); + + const granularity = Granularity.DAY; + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gte(value, hourBeforeValue)).toBe(true); + expect(gte(value, dayBeforeValue)).toBe(true); + }); + }); + + test('gte - date value - week granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + + const granularity = Granularity.WEEK; + + const weekBefore = earlierMoment(date, 1, 'weeks'); + const weekBeforeValue = dateValue(weekBefore, granularity); + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gte(value, hourBeforeValue)).toBe(true); + expect(gte(value, dayBeforeValue)).toBe(true); + expect(gte(value, weekBeforeValue)).toBe(true); + }); + }); + + test('gte - date value - month granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); + + const granularity = Granularity.MONTH; + + const monthBefore = earlierMoment(date, 1, 'months'); + const monthBeforeValue = dateValue(monthBefore, granularity); + + const weekBefore = earlierMoment(date, 1, 'weeks'); + const weekBeforeValue = dateValue(weekBefore, granularity); + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gte(value, hourBeforeValue)).toBe(true); + expect(gte(value, dayBeforeValue)).toBe(true); + expect(gte(value, weekBeforeValue)).toBe(true); + expect(gte(value, monthBeforeValue)).toBe(true); + }); + }); + + test('gte - date value - year granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); + date.year(1980); + + const granularity = Granularity.YEAR; + + const yearBefore = earlierMoment(date, 1, 'years'); + const yearBeforeValue = dateValue(yearBefore, granularity); + + const monthBefore = earlierMoment(date, 1, 'months'); + const monthBeforeValue = dateValue(monthBefore, granularity); + + const weekBefore = earlierMoment(date, 1, 'weeks'); + const weekBeforeValue = dateValue(weekBefore, granularity); + + const dayBefore = earlierMoment(date, 1, 'days'); + const dayBeforeValue = dateValue(dayBefore, granularity); + + const hourBefore = earlierMoment(date, 1, 'hours'); + const hourBeforeValue = dateValue(hourBefore, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(gte(value, hourBeforeValue)).toBe(true); + expect(gte(value, dayBeforeValue)).toBe(true); + expect(gte(value, weekBeforeValue)).toBe(true); + expect(gte(value, monthBeforeValue)).toBe(true); + expect(gte(value, yearBeforeValue)).toBe(true); + }); + }); + + test('lt - number', () => { + const num = random.number({ min: -60, max: 60 }); + expect(lt(num, num + 1)).toBe(true); + expect(lt(num + 1, num)).toBe(false); + expect(lt(num, num)).toBe(false); + expect(lt(num, null)).toBe(false); + expect(lt(num, undefined)).toBe(false); + expect(lt(null, null)).toBe(false); + expect(lt(undefined, undefined)).toBe(false); + }); + + test('lt - date', () => { + const date = random.moment(); + const laterDate = laterMoment(date, 1, 'days'); + expect(lt(date, laterDate)).toBe(true); + expect(lt(date, date)).toBe(false); + }); + + test('lt - date value - day granularity', () => { + const date = random.moment(); + date.hours(12); + + const granularity = Granularity.DAY; + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lt(value, hourLaterValue)).toBe(false); + expect(lt(value, dayLaterValue)).toBe(true); + }); + }); + + test('lt - date value - week granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + + const granularity = Granularity.WEEK; + + const weekLater = laterMoment(date, 1, 'weeks'); + const weekLaterValue = dateValue(weekLater, granularity); + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lt(value, hourLaterValue)).toBe(false); + expect(lt(value, dayLaterValue)).toBe(false); + expect(lt(value, weekLaterValue)).toBe(true); + }); + }); + + test('lt - date value - month granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); + + const granularity = Granularity.MONTH; + + const monthLater = laterMoment(date, 1, 'months'); + const monthLaterValue = dateValue(monthLater, granularity); + + const weekLater = laterMoment(date, 1, 'weeks'); + const weekLaterValue = dateValue(weekLater, granularity); + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lt(value, hourLaterValue)).toBe(false); + expect(lt(value, dayLaterValue)).toBe(false); + expect(lt(value, weekLaterValue)).toBe(false); + expect(lt(value, monthLaterValue)).toBe(true); + }); + }); + + test('lt - date value - year granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); + date.year(1980); + + const granularity = Granularity.YEAR; + + const yearLater = laterMoment(date, 1, 'years'); + const yearLaterValue = dateValue(yearLater, granularity); + + const monthLater = laterMoment(date, 1, 'months'); + const monthLaterValue = dateValue(monthLater, granularity); + + const weekLater = laterMoment(date, 1, 'weeks'); + const weekLaterValue = dateValue(weekLater, granularity); + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lt(value, hourLaterValue)).toBe(false); + expect(lt(value, dayLaterValue)).toBe(false); + expect(lt(value, weekLaterValue)).toBe(false); + expect(lt(value, monthLaterValue)).toBe(false); + expect(lt(value, yearLaterValue)).toBe(true); + }); + }); + + test('lte - number', () => { + const num = random.number({ min: -60, max: 60 }); + expect(lte(num, num + 1)).toBe(true); + expect(lte(num + 1, num)).toBe(false); + expect(lte(num, num)).toBe(true); + expect(lte(num, null)).toBe(false); + expect(lte(num, undefined)).toBe(false); + expect(lte(null, null)).toBe(true); + expect(lte(undefined, undefined)).toBe(true); + }); + + test('lte - date', () => { + const date = random.moment(); + const laterDate = laterMoment(date, 1, 'days'); + expect(lte(date, laterDate)).toBe(true); + expect(lte(date, date)).toBe(true); + }); + + test('lte - date value - day granularity', () => { + const date = random.moment(); + date.hours(12); + + const granularity = Granularity.DAY; + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lte(value, hourLaterValue)).toBe(true); + expect(lte(value, dayLaterValue)).toBe(true); + }); + }); + + test('lte - date value - week granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + + const granularity = Granularity.WEEK; + + const weekLater = laterMoment(date, 1, 'weeks'); + const weekLaterValue = dateValue(weekLater, granularity); + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lte(value, hourLaterValue)).toBe(true); + expect(lte(value, dayLaterValue)).toBe(true); + expect(lte(value, weekLaterValue)).toBe(true); + }); + }); + + test('lte - date value - month granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); + + const granularity = Granularity.MONTH; + + const monthLater = laterMoment(date, 1, 'months'); + const monthLaterValue = dateValue(monthLater, granularity); + + const weekLater = laterMoment(date, 1, 'weeks'); + const weekLaterValue = dateValue(weekLater, granularity); + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lte(value, hourLaterValue)).toBe(true); + expect(lte(value, dayLaterValue)).toBe(true); + expect(lte(value, weekLaterValue)).toBe(true); + expect(lte(value, monthLaterValue)).toBe(true); + }); + }); + + test('lte - date value - year granularity', () => { + const date = random.moment(); + date.hours(12); // noon + date.date(15); // middle of the month + date.day(3); // wed - middle of the week + date.month(6); + date.year(1980); + + const granularity = Granularity.YEAR; + + const yearLater = laterMoment(date, 1, 'years'); + const yearLaterValue = dateValue(yearLater, granularity); + + const monthLater = laterMoment(date, 1, 'months'); + const monthLaterValue = dateValue(monthLater, granularity); + + const weekLater = laterMoment(date, 1, 'weeks'); + const weekLaterValue = dateValue(weekLater, granularity); + + const dayLater = laterMoment(date, 1, 'days'); + const dayLaterValue = dateValue(dayLater, granularity); + + const hourLater = laterMoment(date, 1, 'hours'); + const hourLaterValue = dateValue(hourLater, granularity); + + [date, date.valueOf(), date.format()].forEach(value => { + expect(lte(value, hourLaterValue)).toBe(true); + expect(lte(value, dayLaterValue)).toBe(true); + expect(lte(value, weekLaterValue)).toBe(true); + expect(lte(value, monthLaterValue)).toBe(true); + expect(lte(value, yearLaterValue)).toBe(true); + }); + }); + +}); diff --git a/src/components/search_bar/query/query.js b/src/components/search_bar/query/query.js index 8d1d40b4589..d83357ef030 100644 --- a/src/components/search_bar/query/query.js +++ b/src/components/search_bar/query/query.js @@ -2,6 +2,7 @@ import { defaultSyntax } from './default_syntax'; import { executeAst } from './execute_ast'; import { isNil, isString } from '../../../services/predicate'; import { astToEs } from './ast_to_es'; +import { dateValueParser } from './date_value'; import { AST } from './ast'; /** @@ -11,8 +12,12 @@ import { AST } from './ast'; */ export class Query { - static parse(text, syntax = defaultSyntax) { - return new Query(syntax.parse(text), syntax, text); + static parse(text, options, syntax = defaultSyntax) { + return new Query(syntax.parse(text, options), syntax, text); + } + + static parseDateValue(value, format = undefined) { + return dateValueParser(format)(value); } static isMust(clause) { diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js index 3225c3a44dc..fb7623f2a56 100644 --- a/src/components/search_bar/search_bar.js +++ b/src/components/search_bar/search_bar.js @@ -59,20 +59,28 @@ export const SearchBarPropTypes = { toolsRight: PropTypes.node, }; -const resolveQuery = (query) => { +const parseQuery = (query, props) => { + const parseDate = props.box ? props.box.parseDate : undefined; + const schema = props.box ? props.box.schema : undefined; + const parseOptions = { + parseDate, + schema + }; if (!query) { - return Query.parse(''); + return Query.parse('', parseOptions); } - return isString(query) ? Query.parse(query) : query; + return isString(query) ? Query.parse(query, parseOptions) : query; }; export class EuiSearchBar extends Component { static propTypes = SearchBoxConfigPropTypes; + static Query = Query; + constructor(props) { super(props); - const query = resolveQuery(props.defaultQuery || props.query); + const query = parseQuery(props.defaultQuery || props.query, props); this.state = { query, queryText: query.text, @@ -82,7 +90,7 @@ export class EuiSearchBar extends Component { componentWillReceiveProps(nextProps) { if (nextProps.query) { - const query = resolveQuery(nextProps.query); + const query = parseQuery(nextProps.query, this.props); this.setState({ query, queryText: query.text, @@ -93,7 +101,7 @@ export class EuiSearchBar extends Component { onSearch = (queryText) => { try { - const query = Query.parse(queryText); + const query = parseQuery(queryText, this.props); if (this.props.onParse) { this.props.onParse({ query, queryText }); } diff --git a/src/components/search_bar/search_box.js b/src/components/search_bar/search_box.js index cd46631e088..c64e9369f6f 100644 --- a/src/components/search_bar/search_box.js +++ b/src/components/search_bar/search_box.js @@ -2,9 +2,16 @@ import React, { Component } from 'react'; import { EuiFieldSearch } from '../form/field_search/field_search'; import PropTypes from 'prop-types'; +export const SchemaType = PropTypes.shape({ + strict: PropTypes.bool, + fields: PropTypes.object, + flags: PropTypes.arrayOf(PropTypes.string) +}); + export const SearchBoxConfigPropTypes = { placeholder: PropTypes.string, - incremental: PropTypes.bool + incremental: PropTypes.bool, + schema: SchemaType }; export class EuiSearchBox extends Component { diff --git a/src/services/format/index.js b/src/services/format/index.js index 833568ea9c0..23eeda21d19 100644 --- a/src/services/format/index.js +++ b/src/services/format/index.js @@ -1,5 +1,5 @@ export { formatAuto } from './format_auto'; export { formatBoolean } from './format_boolean'; -export { formatDate } from './format_date'; +export { formatDate, dateFormatAliases } from './format_date'; export { formatNumber } from './format_number'; export { formatText } from './format_text'; diff --git a/src/services/predicate/common_predicates.js b/src/services/predicate/common_predicates.js index 25b83c285d6..22196fdc010 100644 --- a/src/services/predicate/common_predicates.js +++ b/src/services/predicate/common_predicates.js @@ -1,3 +1,5 @@ +import moment from 'moment'; + export const always = () => true; export const never = () => false; @@ -13,3 +15,15 @@ export const isNull = (value) => { export const isNil = (value) => { return isUndefined(value) || isNull(value); }; + +export const isMoment = (value) => { + return moment.isMoment(value); +}; + +export const isDate = (value) => { + return moment.isDate(value); +}; + +export const isDateLike = (value) => { + return isMoment(value) || isDate(value); +}; diff --git a/src/services/predicate/lodash_predicates.js b/src/services/predicate/lodash_predicates.js index 432d90837dc..b3925795e4b 100644 --- a/src/services/predicate/lodash_predicates.js +++ b/src/services/predicate/lodash_predicates.js @@ -3,7 +3,6 @@ export { isArray, isString, isBoolean, - isDate, isNumber, isNaN, isPromise diff --git a/src/services/random.js b/src/services/random.js index 8e85384a09b..1c65948b31c 100644 --- a/src/services/random.js +++ b/src/services/random.js @@ -1,3 +1,4 @@ +import moment from 'moment'; import { isNil } from './predicate'; import { times } from './utils'; @@ -43,11 +44,20 @@ export class Random { date(options = {}) { const min = isNil(options.min) ? new Date(0) : options.min; - const max = isNil(options.max) ? Date.now() : options.max; + const max = isNil(options.max) ? new Date(Date.now()) : options.max; const minMls = min.getTime(); const maxMls = max.getTime(); const time = this.integer({ min: minMls, max: maxMls }); return new Date(time); } + moment(options = {}) { + const min = isNil(options.min) ? moment(0) : options.min; + const max = isNil(options.max) ? moment() : options.max; + const minMls = +min; + const maxMls = +max; + const time = this.integer({ min: minMls, max: maxMls }); + return moment(time); + } + } diff --git a/yarn.lock b/yarn.lock index 0717e4d4645..22f86566a9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5701,9 +5701,9 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd dependencies: minimist "0.0.8" -moment@2.19.3: - version "2.19.3" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.3.tgz#bdb99d270d6d7fda78cc0fbace855e27fe7da69f" +moment@^2.20.1: + version "2.20.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" mri@1.1.0: version "1.1.0"