diff --git a/.circleci/config.yml b/.circleci/config.yml index 619292432..4a9199b90 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2 jobs: - "test": + "server-test": docker: - image: circleci/python:3.6.7-node-browsers - image: cypress/base:10 @@ -44,7 +44,73 @@ jobs: name: Run tests command: | . venv/bin/activate - npm run test + npm run test.server + + + "standalone-test": + docker: + - image: circleci/python:3.6.7-node-browsers + - image: cypress/base:10 + + steps: + - checkout + - restore_cache: + key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} + - run: + name: Install npm packages + command: npm install + - run: + name: Cypress Install + command: | + $(npm bin)/cypress install + + - save_cache: + key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} + paths: + - node_modules + - /home/circleci/.cache/Cypress + + - run: + name: Run tests + command: npm run test.standalone + + + "unit-test": + docker: + - image: circleci/python:3.6.7-node-browsers + - image: cypress/base:10 + + steps: + - checkout + - restore_cache: + key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} + - run: + name: Install npm packages + command: npm install + - run: + name: Cypress Install + command: | + $(npm bin)/cypress install + + - save_cache: + key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} + paths: + - node_modules + - /home/circleci/.cache/Cypress + + - run: + name: Install requirements + command: | + sudo pip install --upgrade virtualenv + python -m venv venv || virtualenv venv + . venv/bin/activate + pip install -r requirements.txt --quiet + + - run: + name: Run tests + command: | + . venv/bin/activate + npm run test.unit "visual-test": @@ -170,5 +236,7 @@ workflows: jobs: - "python-3.6" - "node" - - "test" + - "server-test" + - "standalone-test" + - "unit-test" - "visual-test" diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4b5b81f..572d99c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,18 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added -[#397](https://github.com/plotly/dash-table/pull/397) +[#397](https://github.com/plotly/dash-table/pull/397), [#410](https://github.com/plotly/dash-table/pull/410) - Improve filtering syntax and capabilities - new field syntax `{myField}` - short form by-column filter - - implicit column and `eq` operator (e.g `"value"`) - - implicit column (e.g `ne "value"`) - - explicit form (e.g `{field} ne "value"`) + - implicit column and default operator based on column type + - Text and Any columns default to `contains` + - Numeric columns default to `eq` + - Date columns default to `datestartswith` + - implicit column (e.g `ne "value"` becomes `{my-column} ne "value"`) - new `contains` relational operator for strings + - new `datestartswith` relational operator for dates + - new `eq` behavior (will attempt to convert and compare numeric values if possible) - new readonly `derived_filter_structure` prop exposing the query structure in a programmatically friendlier way ### Changed diff --git a/package.json b/package.json index 93c1f639b..e2f278cb7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "dash_table/bundle.js", "scripts": { "preprivate::opentests": "run-s private::wait*", - "preprivate::runtests": "run-s private::wait*", + "preprivate::test.server": "run-s private::wait_dash*", + "preprivate::test.standalone": "run-s private::wait_js", + "pretest.standalone": "run-s private::build:js-test", "private::build": "node --max_old_space_size=4096 node_modules/webpack/bin/webpack --display-reasons --bail", "private::build:js": "run-s \"private::build -- --mode production\"", "private::build:js-dev": "run-s \"private::build -- --mode development\"", @@ -26,15 +28,16 @@ "private::wait_dash8083": "wait-on http://localhost:8083", "private::wait_js": "wait-on http://localhost:8080", "private::opentests": "cypress open", - "private::runtests:python": "python -m unittest tests/unit/format_test.py", - "private::runtests:unit": "cypress run --browser chrome --spec 'tests/cypress/tests/unit/**/*'", - "private::runtests:standalone": "cypress run --browser chrome --spec 'tests/cypress/tests/standalone/**/*'", - "private::runtests:server": "cypress run --browser chrome --spec 'tests/cypress/tests/server/**/*'", - "private::runtests": "run-s private::runtests:python private::runtests:unit private::runtests:standalone private::runtests:server", + "private::test.python": "python -m unittest tests/unit/format_test.py", + "private::test.unit": "cypress run --browser chrome --spec 'tests/cypress/tests/unit/**/*'", + "private::test.server": "cypress run --browser chrome --spec 'tests/cypress/tests/server/**/*'", + "private::test.standalone": "cypress run --browser chrome --spec 'tests/cypress/tests/standalone/**/*'", "build.watch": "webpack-dev-server --content-base dash_table --mode development", "build": "run-s private::build:js private::build:py", "lint": "run-s private::lint:*", - "test": "run-p --race private::host* private::runtests", + "test.server": "run-p --race private::host* private::test.server", + "test.standalone": "run-p --race private::host_js private::test.standalone", + "test.unit": "run-s private::test.python private::test.unit", "test.visual": "build-storybook && percy-storybook", "test.visual-local": "build-storybook", "test.watch": "run-p --race \"private::build:js-test-watch\" --race private::host* private::opentests" @@ -54,7 +57,7 @@ "@storybook/react": "^5.0.5", "@types/d3-format": "^1.3.1", "@types/papaparse": "^4.5.9", - "@types/ramda": "^0.26.5", + "@types/ramda": "^0.26.6", "@types/react": "^16.8.8", "@types/react-dom": "^16.8.3", "@types/react-select": "^1.3.4", @@ -82,7 +85,7 @@ "style-loader": "^0.23.1", "ts-loader": "^5.3.3", "tslint": "^5.14.0", - "typescript": "^3.3.4000", + "typescript": "^3.4.3", "wait-on": "^3.2.0", "webpack": "^4.29.6", "webpack-cli": "^3.3.0", diff --git a/src/core/syntax-tree/lexer.ts b/src/core/syntax-tree/lexer.ts index c0fa303f5..170ca9350 100644 --- a/src/core/syntax-tree/lexer.ts +++ b/src/core/syntax-tree/lexer.ts @@ -42,14 +42,20 @@ export default function lexer(lexicon: Lexicon, query: string): ILexerResult { query = query.substring(value.length); } - const last = result.slice(-1)[0]; - - const terminal: boolean = last && (typeof last.lexeme.terminal === 'function' ? - last.lexeme.terminal(result, last) : - last.lexeme.terminal); + const [terminalPrevious, last] = [ + undefined, + undefined, + ...result + ].slice(-2); + + const terminal: boolean = !last || + (typeof last.lexeme.terminal === 'function' ? + last.lexeme.terminal(result, terminalPrevious) : + last.lexeme.terminal + ); return { lexemes: result, - valid: !last || terminal + valid: terminal }; } \ No newline at end of file diff --git a/src/core/syntax-tree/lexicon.ts b/src/core/syntax-tree/lexicon.ts index 6a26e1b77..f1bb0907c 100644 --- a/src/core/syntax-tree/lexicon.ts +++ b/src/core/syntax-tree/lexicon.ts @@ -7,8 +7,7 @@ export enum LexemeType { LogicalOperator = 'logical-operator', RelationalOperator = 'relational-operator', UnaryOperator = 'unary-operator', - Expression = 'expression', - Operand = 'operand' + Expression = 'expression' } export interface IUnboundedLexeme { @@ -25,8 +24,8 @@ export interface IUnboundedLexeme { } export interface ILexeme extends IUnboundedLexeme { - terminal: boolean | ((lexemes: ILexemeResult[], previous: ILexemeResult) => boolean); - if: (string | undefined)[] | ((lexemes: ILexemeResult[], previous: ILexemeResult) => boolean); + terminal: boolean | ((lexemes: ILexemeResult[], previous: ILexemeResult | undefined) => boolean); + if: (string | undefined)[] | ((lexemes: ILexemeResult[], previous: ILexemeResult | undefined) => boolean); } export function boundLexeme(lexeme: IUnboundedLexeme) { diff --git a/src/core/type/index.ts b/src/core/type/index.ts new file mode 100644 index 000000000..f509ef023 --- /dev/null +++ b/src/core/type/index.ts @@ -0,0 +1,2 @@ +export type RequiredPluck = { [r in R]: T[r] }; +export type OptionalPluck = { [r in R]?: T[r] }; diff --git a/src/dash-table/components/FilterFactory.tsx b/src/dash-table/components/FilterFactory.tsx index 1aa02ecf5..6ebbdf80b 100644 --- a/src/dash-table/components/FilterFactory.tsx +++ b/src/dash-table/components/FilterFactory.tsx @@ -48,14 +48,14 @@ export default class FilterFactory { } - private onChange = (columnId: ColumnId, setFilter: SetFilter, ev: any) => { - Logger.debug('Filter -- onChange', columnId, ev.target.value && ev.target.value.trim()); + private onChange = (column: IVisibleColumn, setFilter: SetFilter, ev: any) => { + Logger.debug('Filter -- onChange', column.id, ev.target.value && ev.target.value.trim()); const value = ev.target.value.trim(); - const safeColumnId = columnId.toString(); + const safeColumnId = column.id.toString(); if (value && value.length) { - this.ops.set(safeColumnId, new SingleColumnSyntaxTree(safeColumnId, value)); + this.ops.set(safeColumnId, new SingleColumnSyntaxTree(value, column)); } else { this.ops.delete(safeColumnId); } @@ -65,26 +65,26 @@ export default class FilterFactory { const rawGlobalFilter = R.map( ast => ast.query || '', - R.filter(ast => Boolean(ast), asts) + R.filter(ast => Boolean(ast), asts) ).join(' && '); setFilter(globalFilter, rawGlobalFilter); } - private getEventHandler = (fn: Function, columnId: ColumnId, setFilter: SetFilter): any => { + private getEventHandler = (fn: Function, column: IVisibleColumn, setFilter: SetFilter): any => { const fnHandler = (this.handlers.get(fn) || this.handlers.set(fn, new Map()).get(fn)); - const columnIdHandler = (fnHandler.get(columnId) || fnHandler.set(columnId, new Map()).get(columnId)); + const columnIdHandler = (fnHandler.get(column.id) || fnHandler.set(column.id, new Map()).get(column.id)); return ( columnIdHandler.get(setFilter) || - (columnIdHandler.set(setFilter, fn.bind(this, columnId, setFilter)).get(setFilter)) + (columnIdHandler.set(setFilter, fn.bind(this, column, setFilter)).get(setFilter)) ); } - private updateOps = memoizeOne((query: string) => { + private updateOps = memoizeOne((query: string, columns: IVisibleColumn[]) => { const multiQuery = new MultiColumnsSyntaxTree(query); - const newOps = getSingleColumnMap(multiQuery); + const newOps = getSingleColumnMap(multiQuery, columns); if (!newOps) { return; } @@ -109,7 +109,7 @@ export default class FilterFactory { }); private filter = memoizerCache<[ColumnId, number]>()(( - column: ColumnId, + column: IVisibleColumn, index: number, ast: SingleColumnSyntaxTree | undefined, setFilter: SetFilter @@ -117,7 +117,7 @@ export default class FilterFactory { return ((R.map)((column, index) => { return this.filter.get(column.id, index)( - column.id, + column, index, this.ops.get(column.id.toString()), setFilter diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 52119b881..e140777da 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -992,18 +992,15 @@ export const propTypes = { * - 'relational-operator' * - 'unary-operator' * - 'expression' - * - 'operand' * - subType (string; optional) * - 'open-block': '()' * - 'logical-operator': '&&', '||' * - 'relational-operator': '=', '>=', '>', '<=', '<', '!=', 'contains' * - 'unary-operator': '!', 'is bool', 'is even', 'is nil', 'is num', 'is object', 'is odd', 'is prime', 'is str' * - 'expression': 'value', 'field' - * - 'operand': 'field' * - value (any) * - 'expression, value': passed value * - 'expression, field': the field/prop name - * - 'operand, field': the field/prop name * * - block (nested query structure; optional) * - left (nested query structure; optional) diff --git a/src/dash-table/derived/cell/dropdowns.ts b/src/dash-table/derived/cell/dropdowns.ts index 2f8e81ef1..64961c99f 100644 --- a/src/dash-table/derived/cell/dropdowns.ts +++ b/src/dash-table/derived/cell/dropdowns.ts @@ -96,7 +96,7 @@ class Dropdowns { ...(staticDropdown ? [staticDropdown] : []), ...R.map( ([cd]) => cd.dropdown, - R.filter( + R.filter<[IConditionalDropdown, number]>( ([cd, i]) => this.evaluation.get(column.id, i)( this.ast.get(column.id, i)(cd.condition), datum diff --git a/src/dash-table/derived/cell/wrapperStyles.ts b/src/dash-table/derived/cell/wrapperStyles.ts index d7ead3d7d..f53d78d98 100644 --- a/src/dash-table/derived/cell/wrapperStyles.ts +++ b/src/dash-table/derived/cell/wrapperStyles.ts @@ -16,7 +16,7 @@ function getter( return R.addIndex(R.map)((datum, index) => R.map(column => { const relevantStyles = R.map( s => s.style, - R.filter( + R.filter( style => style.matchesColumn(column) && style.matchesRow(index + offset.rows) && diff --git a/src/dash-table/derived/filter/wrapperStyles.ts b/src/dash-table/derived/filter/wrapperStyles.ts index 5cba4a033..81fa97ce2 100644 --- a/src/dash-table/derived/filter/wrapperStyles.ts +++ b/src/dash-table/derived/filter/wrapperStyles.ts @@ -16,7 +16,7 @@ function getter( return R.map(column => { const relevantStyles = R.map( s => s.style, - R.filter( + R.filter( style => style.matchesColumn(column), filterStyles ) diff --git a/src/dash-table/derived/header/wrapperStyles.ts b/src/dash-table/derived/header/wrapperStyles.ts index e90a58f03..d887da1f6 100644 --- a/src/dash-table/derived/header/wrapperStyles.ts +++ b/src/dash-table/derived/header/wrapperStyles.ts @@ -17,7 +17,7 @@ function getter( return R.map(idx => R.map(column => { const relevantStyles = R.map( s => s.style, - R.filter( + R.filter( style => style.matchesColumn(column) && style.matchesRow(idx), diff --git a/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts b/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts index e492e1576..15c044023 100644 --- a/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts +++ b/src/dash-table/syntax-tree/MultiColumnsSyntaxTree.ts @@ -5,6 +5,7 @@ import { LexemeType } from 'core/syntax-tree/lexicon'; import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; import columnMultiLexicon from './lexicon/columnMulti'; +import { ILexemeResult } from 'core/syntax-tree/lexer'; export default class MultiColumnsSyntaxTree extends SyntaxTree { constructor(query: string) { @@ -39,7 +40,13 @@ export default class MultiColumnsSyntaxTree extends SyntaxTree { return statements; } private respectsBasicSyntax() { - const fields = R.map(item => item.value, R.filter(i => i.lexeme.type === LexemeType.Operand, this.lexerResult.lexemes)); + const fields = R.map( + (item: ILexemeResult) => item.value, + R.filter( + i => i.lexeme.type === LexemeType.Expression && i.lexeme.subType === 'field', + this.lexerResult.lexemes + ) + ); const uniqueFields = R.uniq(fields); return fields.length === uniqueFields.length; } diff --git a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts index a2d2b7076..6651f3bc0 100644 --- a/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts +++ b/src/dash-table/syntax-tree/SingleColumnSyntaxTree.ts @@ -1,14 +1,27 @@ +import { RequiredPluck, OptionalPluck } from 'core/type'; import SyntaxTree from 'core/syntax-tree'; import { ILexemeResult, ILexerResult } from 'core/syntax-tree/lexer'; import { LexemeType, boundLexeme } from 'core/syntax-tree/lexicon'; -import { ColumnId } from 'dash-table/components/Table/props'; +import { ColumnType, IVisibleColumn } from 'dash-table/components/Table/props'; -import operand from './lexeme/operand'; -import { equal } from './lexeme/relational'; +import { fieldExpression } from './lexeme/expression'; +import { equal, RelationalOperator } from './lexeme/relational'; import columnLexicon from './lexicon/column'; +function getDefaultRelationalOperator(type: ColumnType = ColumnType.Any): RelationalOperator { + switch (type) { + case ColumnType.Any: + case ColumnType.Text: + return RelationalOperator.Contains; + case ColumnType.Datetime: + return RelationalOperator.DateStartsWith; + case ColumnType.Numeric: + return RelationalOperator.Equal; + } +} + function isBinary(lexemes: ILexemeResult[]) { return lexemes.length === 2; } @@ -23,20 +36,23 @@ function isUnary(lexemes: ILexemeResult[]) { lexemes[0].lexeme.type === LexemeType.UnaryOperator; } -export function modifyLex(key: ColumnId, res: ILexerResult) { +export function modifyLex(config: SingleColumnConfig, res: ILexerResult) { if (!res.valid) { return res; } if (isBinary(res.lexemes) || isUnary(res.lexemes)) { res.lexemes = [ - { lexeme: boundLexeme(operand), value: `{${key}}` }, + { lexeme: boundLexeme(fieldExpression), value: `{${config.id}}` }, ...res.lexemes ]; } else if (isExpression(res.lexemes)) { res.lexemes = [ - { lexeme: boundLexeme(operand), value: `{${key}}` }, - { lexeme: boundLexeme(equal), value: 'eq' }, + { lexeme: boundLexeme(fieldExpression), value: `{${config.id}}` }, + { + lexeme: boundLexeme(equal), + value: getDefaultRelationalOperator(config.type) + }, ...res.lexemes ]; } @@ -44,8 +60,14 @@ export function modifyLex(key: ColumnId, res: ILexerResult) { return res; } +export type SingleColumnConfig = RequiredPluck & OptionalPluck; + export default class SingleColumnSyntaxTree extends SyntaxTree { - constructor(key: ColumnId, query: string) { - super(columnLexicon, query, modifyLex.bind(undefined, key)); + constructor(query: string, config: SingleColumnConfig) { + super( + columnLexicon, + query, + modifyLex.bind(undefined, config) + ); } } \ No newline at end of file diff --git a/src/dash-table/syntax-tree/index.ts b/src/dash-table/syntax-tree/index.ts index a651fbef0..46d307751 100644 --- a/src/dash-table/syntax-tree/index.ts +++ b/src/dash-table/syntax-tree/index.ts @@ -6,15 +6,19 @@ import MultiColumnsSyntaxTree from './MultiColumnsSyntaxTree'; import QuerySyntaxTree from './QuerySyntaxTree'; import SingleColumnSyntaxTree from './SingleColumnSyntaxTree'; import { RelationalOperator } from './lexeme/relational'; +import { IVisibleColumn } from 'dash-table/components/Table/props'; export const getMultiColumnQueryString = ( asts: SingleColumnSyntaxTree[] ) => R.map( ast => ast.toQueryString(), - R.filter(ast => ast && ast.isValid, asts) + R.filter(ast => ast && ast.isValid && ast.query !== '', asts) ).join(' && '); -export const getSingleColumnMap = (ast: MultiColumnsSyntaxTree) => { +export const getSingleColumnMap = ( + ast: MultiColumnsSyntaxTree, + columns: IVisibleColumn[] +) => { if (!ast.isValid) { return; } @@ -29,14 +33,25 @@ export const getSingleColumnMap = (ast: MultiColumnsSyntaxTree) => { R.forEach(s => { if (s.lexeme.type === LexemeType.UnaryOperator && s.left) { const sanitizedColumnId = s.left.lexeme.present ? s.left.lexeme.present(s.left) : s.left.value; - map.set(sanitizedColumnId, new SingleColumnSyntaxTree(sanitizedColumnId, s.value)); + + const column = R.find(c => c.id.toString() === sanitizedColumnId, columns); + if (!column) { + throw new Error(`column ${sanitizedColumnId} not found`); + } + + map.set(sanitizedColumnId, new SingleColumnSyntaxTree(s.value, column)); } else if (s.lexeme.type === LexemeType.RelationalOperator && s.left && s.right) { const sanitizedColumnId = s.left.lexeme.present ? s.left.lexeme.present(s.left) : s.left.value; + const column = R.find(c => c.id.toString() === sanitizedColumnId, columns); + if (!column) { + throw new Error(`column ${sanitizedColumnId} not found`); + } + if (s.lexeme.present && s.lexeme.present(s) === RelationalOperator.Equal) { - map.set(sanitizedColumnId, new SingleColumnSyntaxTree(sanitizedColumnId, `${s.right.value}`)); + map.set(sanitizedColumnId, new SingleColumnSyntaxTree(`${s.right.value}`, column)); } else { - map.set(sanitizedColumnId, new SingleColumnSyntaxTree(sanitizedColumnId, `${s.value} ${s.right.value}`)); + map.set(sanitizedColumnId, new SingleColumnSyntaxTree(`${s.value} ${s.right.value}`, column)); } } }, statements); diff --git a/src/dash-table/syntax-tree/lexeme/expression.ts b/src/dash-table/syntax-tree/lexeme/expression.ts index 8dad633ee..730586a6a 100644 --- a/src/dash-table/syntax-tree/lexeme/expression.ts +++ b/src/dash-table/syntax-tree/lexeme/expression.ts @@ -1,19 +1,31 @@ import isNumeric from 'fast-isnumeric'; -import * as R from 'ramda'; import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; -import operand from './operand'; +const FIELD_REGEX = /^{(([^{}\\]|\\.)+)}/; const STRING_REGEX = /^(('([^'\\]|\\.)+')|("([^"\\]|\\.)+")|(`([^`\\]|\\.)+`))/; const VALUE_REGEX = /^(([^\s'"`{}()\\]|\\.)+)(?:[\s)]|$)/; -export const fieldExpression: IUnboundedLexeme = R.merge( - operand, { - subType: 'field', - type: LexemeType.Expression - } -); +const getField = ( + value: string +) => value + .slice(1, value.length - 1) + .replace(/\\(.)/g, '$1'); + +export const fieldExpression: IUnboundedLexeme = { + present: (tree: ISyntaxTree) => getField(tree.value), + resolve: (target: any, tree: ISyntaxTree) => { + if (FIELD_REGEX.test(tree.value)) { + return target[getField(tree.value)]; + } else { + throw new Error(); + } + }, + regexp: FIELD_REGEX, + subType: 'field', + type: LexemeType.Expression +}; const getString = ( value: string diff --git a/src/dash-table/syntax-tree/lexeme/operand.ts b/src/dash-table/syntax-tree/lexeme/operand.ts deleted file mode 100644 index 5fe72feea..000000000 --- a/src/dash-table/syntax-tree/lexeme/operand.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; -import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; - -const FIELD_REGEX = /^{(([^{}\\]|\\.)+)}/; - -const getField = ( - value: string -) => value - .slice(1, value.length - 1) - .replace(/\\(.)/g, '$1'); - -const operand: IUnboundedLexeme = { - present: (tree: ISyntaxTree) => getField(tree.value), - resolve: (target: any, tree: ISyntaxTree) => { - if (FIELD_REGEX.test(tree.value)) { - return target[getField(tree.value)]; - } else { - throw new Error(); - } - }, - regexp: FIELD_REGEX, - subType: 'field', - type: LexemeType.Operand -}; - -export default operand; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexeme/relational.ts b/src/dash-table/syntax-tree/lexeme/relational.ts index df6fb9a78..1c9a92ea9 100644 --- a/src/dash-table/syntax-tree/lexeme/relational.ts +++ b/src/dash-table/syntax-tree/lexeme/relational.ts @@ -1,8 +1,11 @@ +import isNumeric from 'fast-isnumeric'; import * as R from 'ramda'; import Logger from 'core/Logger'; import { LexemeType, IUnboundedLexeme } from 'core/syntax-tree/lexicon'; import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; +import { normalizeDate } from 'dash-table/type/date'; +import { IDateValidation } from 'dash-table/components/Table/props'; function evaluator( target: any, @@ -31,6 +34,7 @@ function relationalEvaluator( export enum RelationalOperator { Contains = 'contains', + DateStartsWith = 'datestartswith', Equal = '=', GreaterOrEqual = '>=', GreaterThan = '>', @@ -47,16 +51,21 @@ const LEXEME_BASE = { export const contains: IUnboundedLexeme = R.merge({ evaluate: relationalEvaluator(([op, exp]) => - typeof op === 'string' && - typeof exp === 'string' && - op.indexOf(exp) !== -1 + !R.isNil(exp) && + !R.isNil(op) && + (R.type(exp) === 'String' || R.type(op) === 'String') && + op.toString().indexOf(exp.toString()) !== -1 ), subType: RelationalOperator.Contains, regexp: /^(contains)/i }, LEXEME_BASE); export const equal: IUnboundedLexeme = R.merge({ - evaluate: relationalEvaluator(([op, exp]) => op === exp), + evaluate: relationalEvaluator(([op, exp]) => + (isNumeric(op) && isNumeric(exp)) ? + +op === +exp : + op === exp + ), subType: RelationalOperator.Equal, regexp: /^(=|eq)/i }, LEXEME_BASE); @@ -73,6 +82,24 @@ export const greaterThan: IUnboundedLexeme = R.merge({ regexp: /^(>|gt)/i }, LEXEME_BASE); +const DATE_OPTIONS: IDateValidation = { + allow_YY: true +}; + +export const dateStartsWith: IUnboundedLexeme = R.merge({ + evaluate: relationalEvaluator(([op, exp]) => { + const normalizedOp = normalizeDate(op, DATE_OPTIONS); + const normalizedExp = normalizeDate(exp, DATE_OPTIONS); + + return !R.isNil(normalizedOp) && + !R.isNil(normalizedExp) && + // IE11 does not support `startsWith` + normalizedOp.indexOf(normalizedExp) === 0; + }), + subType: RelationalOperator.DateStartsWith, + regexp: /^(datestartswith)/i +}, LEXEME_BASE); + export const lessOrEqual: IUnboundedLexeme = R.merge({ evaluate: relationalEvaluator(([op, exp]) => op <= exp), subType: RelationalOperator.LessOrEqual, diff --git a/src/dash-table/syntax-tree/lexicon/column.ts b/src/dash-table/syntax-tree/lexicon/column.ts index b6e42ec6d..b73c85231 100644 --- a/src/dash-table/syntax-tree/lexicon/column.ts +++ b/src/dash-table/syntax-tree/lexicon/column.ts @@ -1,5 +1,3 @@ -import * as R from 'ramda'; - import { fieldExpression, stringExpression, @@ -7,6 +5,7 @@ import { } from '../lexeme/expression'; import { contains, + dateStartsWith, equal, greaterOrEqual, greaterThan, @@ -25,11 +24,16 @@ import { isStr } from '../lexeme/unary'; -import { LexemeType, ILexeme } from 'core/syntax-tree/lexicon'; -import { ILexemeResult } from 'core/syntax-tree/lexer'; +import { + ifExpression, + ifLeading +} from '.'; + +import { ILexeme } from 'core/syntax-tree/lexicon'; const lexicon: ILexeme[] = [ ...[contains, + dateStartsWith, equal, greaterOrEqual, greaterThan, @@ -38,8 +42,8 @@ const lexicon: ILexeme[] = [ notEqual ].map(op => ({ ...op, - terminal: false, - if: (_lexs: ILexemeResult[], previous: ILexemeResult) => !previous + if: ifLeading, + terminal: false })), ...[isBool, isEven, @@ -51,7 +55,7 @@ const lexicon: ILexeme[] = [ isStr ].map(op => ({ ...op, - if: (_lexs: ILexemeResult[], previous: ILexemeResult) => !previous, + if: ifLeading, terminal: true })), ...[ @@ -60,11 +64,7 @@ const lexicon: ILexeme[] = [ valueExpression ].map(exp => ({ ...exp, - if: (_lexs: ILexemeResult[], previous: ILexemeResult) => - !previous || R.contains( - previous.lexeme.type, - [LexemeType.RelationalOperator] - ), + if: ifExpression, terminal: true })) ]; diff --git a/src/dash-table/syntax-tree/lexicon/columnMulti.ts b/src/dash-table/syntax-tree/lexicon/columnMulti.ts index a8542b5bc..43406f7df 100644 --- a/src/dash-table/syntax-tree/lexicon/columnMulti.ts +++ b/src/dash-table/syntax-tree/lexicon/columnMulti.ts @@ -1,4 +1,4 @@ -import * as R from 'ramda'; +import { ILexeme } from 'core/syntax-tree/lexicon'; import { fieldExpression, @@ -8,9 +8,9 @@ import { import { and } from '../lexeme/logical'; -import operand from '../lexeme/operand'; import { contains, + dateStartsWith, equal, greaterOrEqual, greaterThan, @@ -29,32 +29,22 @@ import { isStr } from '../lexeme/unary'; -import { ILexeme, LexemeType } from 'core/syntax-tree/lexicon'; -import { ILexemeResult } from 'core/syntax-tree/lexer'; +import { + ifExpression, + ifLogicalOperator, + ifRelationalOperator, + ifUnaryOperator, + isTerminalExpression +} from '.'; const lexicon: ILexeme[] = [ { ...and, - if: (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [ - LexemeType.Expression, - LexemeType.UnaryOperator - ] - ), - terminal: false - }, - { - ...operand, - if: (_: ILexemeResult[], previous: ILexemeResult) => - !previous || R.contains( - previous.lexeme.type, - [LexemeType.LogicalOperator] - ), + if: ifLogicalOperator, terminal: false }, ...[contains, + dateStartsWith, equal, greaterOrEqual, greaterThan, @@ -63,11 +53,7 @@ const lexicon: ILexeme[] = [ notEqual ].map(op => ({ ...op, - if: (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [LexemeType.Operand] - ), + if: ifRelationalOperator, terminal: false })), ...[isBool, @@ -80,11 +66,7 @@ const lexicon: ILexeme[] = [ isStr ].map(op => ({ ...op, - if: (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [LexemeType.Operand] - ), + if: ifUnaryOperator, terminal: true })), ...[ @@ -93,12 +75,8 @@ const lexicon: ILexeme[] = [ valueExpression ].map(exp => ({ ...exp, - if: (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [LexemeType.RelationalOperator] - ), - terminal: true + if: ifExpression, + terminal: isTerminalExpression })) ]; diff --git a/src/dash-table/syntax-tree/lexicon/index.ts b/src/dash-table/syntax-tree/lexicon/index.ts new file mode 100644 index 000000000..af4e92fa4 --- /dev/null +++ b/src/dash-table/syntax-tree/lexicon/index.ts @@ -0,0 +1,71 @@ +import * as R from 'ramda'; + +import { ILexemeResult } from 'core/syntax-tree/lexer'; +import { LexemeType } from 'core/syntax-tree/lexicon'; + +const nestingReducer = R.reduce( + (nesting, l) => nesting + (l.lexeme.nesting || 0) +); + +export const isTerminal = (lexemes: ILexemeResult[], _: ILexemeResult | undefined) => + nestingReducer(0, lexemes) === 0; + +export const isTerminalExpression = (lexemes: ILexemeResult[], previous: ILexemeResult | undefined) => + isTerminal(lexemes, previous) && + !!previous && + R.contains(previous.lexeme.type, [ + LexemeType.RelationalOperator + ]); + +export const ifBlockClose = (lexemes: ILexemeResult[], previous: ILexemeResult | undefined) => + !!previous && R.contains( + previous.lexeme.type, + [ + LexemeType.BlockClose, + LexemeType.BlockOpen, + LexemeType.Expression, + LexemeType.UnaryOperator + ] + ) && nestingReducer(0, lexemes) > 0; + +export const ifBlockOpen = (_: ILexemeResult[], previous: ILexemeResult | undefined) => + !previous || R.contains( + previous.lexeme.type, + [ + LexemeType.BlockOpen, + LexemeType.LogicalOperator, + LexemeType.UnaryOperator + ] + ); + +export const ifExpression = (_: ILexemeResult[], previous: ILexemeResult | undefined) => { + return !previous || R.contains( + previous.lexeme.type, + [ + LexemeType.BlockOpen, + LexemeType.LogicalOperator, + LexemeType.RelationalOperator + ] + ); +}; + +export const ifLeading = (_lexs: ILexemeResult[], previous: ILexemeResult | undefined) => + !previous; + +export const ifLogicalOperator = (_: ILexemeResult[], previous: ILexemeResult | undefined) => + !!previous && R.contains( + previous.lexeme.type, + [ + LexemeType.BlockClose, + LexemeType.Expression, + LexemeType.UnaryOperator + ] + ); + +export const ifRelationalOperator = (_: ILexemeResult[], previous: ILexemeResult | undefined) => + !!previous && R.contains( + previous.lexeme.type, + [LexemeType.Expression] + ); + +export const ifUnaryOperator = ifRelationalOperator; \ No newline at end of file diff --git a/src/dash-table/syntax-tree/lexicon/query.ts b/src/dash-table/syntax-tree/lexicon/query.ts index 8b23c1b63..66b34be2d 100644 --- a/src/dash-table/syntax-tree/lexicon/query.ts +++ b/src/dash-table/syntax-tree/lexicon/query.ts @@ -1,5 +1,8 @@ import * as R from 'ramda'; +import { ILexemeResult } from 'core/syntax-tree/lexer'; +import { LexemeType, ILexeme } from 'core/syntax-tree/lexicon'; + import { blockClose, blockOpen @@ -13,9 +16,9 @@ import { and, or } from '../lexeme/logical'; -import operand from '../lexeme/operand'; import { contains, + dateStartsWith, equal, greaterOrEqual, greaterThan, @@ -35,89 +38,46 @@ import { not } from '../lexeme/unary'; -import { ILexemeResult } from 'core/syntax-tree/lexer'; -import { LexemeType, ILexeme } from 'core/syntax-tree/lexicon'; - -const nestingReducer = R.reduce( - (nesting, l) => nesting + (l.lexeme.nesting || 0) -); - -const isTerminal = (lexemes: ILexemeResult[], previous: ILexemeResult) => - previous && nestingReducer(0, lexemes) === 0; - -const ifExpression = (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [LexemeType.RelationalOperator] - ); +import { + ifBlockClose, + ifBlockOpen, + ifExpression, + ifLogicalOperator, + ifRelationalOperator, + ifUnaryOperator, + isTerminal, + isTerminalExpression +} from '.'; -const ifLogicalOperator = (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( +const ifNotUnaryOperator = (_: ILexemeResult[], previous: ILexemeResult | undefined) => + !previous || R.contains( previous.lexeme.type, [ - LexemeType.BlockClose, - LexemeType.Expression, + LexemeType.LogicalOperator, LexemeType.UnaryOperator ] ); -const ifOperator = (_: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [LexemeType.Operand] - ); - const lexicon: ILexeme[] = [ - { - ...and, - if: ifLogicalOperator, - terminal: false - }, - { - ...or, + ...[and, + or + ].map(op => ({ + ...op, if: ifLogicalOperator, terminal: false - }, + })), { ...blockClose, - if: (lexemes: ILexemeResult[], previous: ILexemeResult) => - previous && R.contains( - previous.lexeme.type, - [ - LexemeType.BlockClose, - LexemeType.BlockOpen, - LexemeType.Expression, - LexemeType.UnaryOperator - ] - ) && nestingReducer(0, lexemes) > 0, + if: ifBlockClose, terminal: isTerminal }, { ...blockOpen, - if: (_: ILexemeResult[], previous: ILexemeResult) => - !previous || R.contains( - previous.lexeme.type, - [ - LexemeType.BlockOpen, - LexemeType.LogicalOperator, - LexemeType.UnaryOperator - ] - ), - terminal: false - }, - { - ...operand, - if: (_: ILexemeResult[], previous: ILexemeResult) => - !previous || R.contains( - previous.lexeme.type, - [ - LexemeType.BlockOpen, - LexemeType.LogicalOperator - ] - ), + if: ifBlockOpen, terminal: false }, ...[contains, + dateStartsWith, equal, greaterOrEqual, greaterThan, @@ -126,7 +86,7 @@ const lexicon: ILexeme[] = [ notEqual ].map(op => ({ ...op, - if: ifOperator, + if: ifRelationalOperator, terminal: false })), ...[isBool, @@ -139,19 +99,12 @@ const lexicon: ILexeme[] = [ isStr ].map(op => ({ ...op, - if: ifOperator, + if: ifUnaryOperator, terminal: isTerminal })), { ...not, - if: (_: ILexemeResult[], previous: ILexemeResult) => - !previous || R.contains( - previous.lexeme.type, - [ - LexemeType.LogicalOperator, - LexemeType.UnaryOperator - ] - ), + if: ifNotUnaryOperator, terminal: false }, ...[ @@ -161,7 +114,7 @@ const lexicon: ILexeme[] = [ ].map(exp => ({ ...exp, if: ifExpression, - terminal: isTerminal + terminal: isTerminalExpression })) ]; diff --git a/src/dash-table/type/date.ts b/src/dash-table/type/date.ts index 45a820efe..2f71e5ed0 100644 --- a/src/dash-table/type/date.ts +++ b/src/dash-table/type/date.ts @@ -1,4 +1,4 @@ -import { IDatetimeColumn } from 'dash-table/components/Table/props'; +import { IDatetimeColumn, IDateValidation } from 'dash-table/components/Table/props'; import { reconcileNull } from './null'; import { IReconciliation } from './reconcile'; @@ -13,7 +13,7 @@ const DATETIME_REGEXP = /^\s*(-?\d{4}|\d{2})(-(\d{1,2})(-(\d{1,2})([ Tt]([01]?\d // Please don't use 2-digit years! const YFIRST = new Date().getFullYear() - 70; -export function normalizeDate(value: any, options: IDatetimeColumn | undefined): string | null { +export function normalizeDate(value: any, options?: IDateValidation): string | null { // unlike plotly.js, do not accept year as a number - only strings. if (typeof value !== 'string') { return null; @@ -27,7 +27,7 @@ export function normalizeDate(value: any, options: IDatetimeColumn | undefined): const yearMatch = match[1]; const YY = yearMatch.length === 2; - if (YY && !(options && options.validation && options.validation.allow_YY)) { + if (YY && !(options && options.allow_YY)) { return null; } @@ -81,7 +81,7 @@ export function normalizeDate(value: any, options: IDatetimeColumn | undefined): } export function coerce(value: any, options: IDatetimeColumn | undefined): IReconciliation { - const normalizedDate = normalizeDate(value, options); + const normalizedDate = normalizeDate(value, options && options.validation); return normalizedDate !== null ? { success: true, @@ -91,7 +91,7 @@ export function coerce(value: any, options: IDatetimeColumn | undefined): IRecon } export function validate(value: any, options: IDatetimeColumn | undefined): IReconciliation { - return (typeof value === 'string') && (normalizeDate(value, options) !== null) ? + return (typeof value === 'string') && (normalizeDate(value, options && options.validation) !== null) ? { success: true, value: value.trim() } : reconcileNull(value, options); } diff --git a/tests/cypress/tests/standalone/filtering_test.ts b/tests/cypress/tests/standalone/filtering_test.ts index fe2b2c856..7417860da 100644 --- a/tests/cypress/tests/standalone/filtering_test.ts +++ b/tests/cypress/tests/standalone/filtering_test.ts @@ -95,6 +95,24 @@ describe('filter', () => { DashTable.getFilterById('eee').should('have.class', 'invalid'); }); + it('filters `Text` columns with `contains` without operator', () => { + DashTable.getFilterById('bbb').click(); + DOM.focused.type('Tr'); + DashTable.getFilterById('ccc').click(); + + DashTable.getFilterById('bbb').within(() => cy.get('input').should('have.value', 'Tr')); + DashTable.getCellById(0, 'bbb-readonly').within(() => cy.get('.dash-cell-value').should('have.html', 'Tropical Beaches')); + }); + + it('filters `Numeric` columns with `equal` without operator', () => { + DashTable.getFilterById('ccc').click(); + DOM.focused.type('100'); + DashTable.getFilterById('bbb').click(); + + DashTable.getFilterById('ccc').within(() => cy.get('input').should('have.value', '100')); + DashTable.getCellById(0, 'ccc').within(() => cy.get('.dash-cell-value').should('have.html', '100')); + }); + it('reset updates results and filter fields', () => { let cell_0; let cell_1; diff --git a/tests/cypress/tests/unit/dash_table_queries_test.ts b/tests/cypress/tests/unit/dash_table_queries_test.ts new file mode 100644 index 000000000..d811e5731 --- /dev/null +++ b/tests/cypress/tests/unit/dash_table_queries_test.ts @@ -0,0 +1,144 @@ +import * as R from 'ramda'; + +import SyntaxTree from 'core/syntax-tree'; + +import { QuerySyntaxTree, MultiColumnsSyntaxTree, SingleColumnSyntaxTree } from 'dash-table/syntax-tree'; + +export interface ICase { + name: string; + query: string; + target: object; + valid: boolean; + evaluate?: boolean; +} + +export function processCases(getSyntaxer: (query: string) => SyntaxTree, cases: ICase[]) { + R.forEach(c => it(c.name, () => { + const tree = getSyntaxer(c.query); + + expect(tree.isValid).to.equal(c.valid); + if (!c.valid) { + return; + } + + expect(tree.evaluate(c.target)).to.equal(c.evaluate); + }), cases); +} +const getQuerySyntaxTree = (query: string): any => new QuerySyntaxTree(query); +const getMultiColumnSyntaxTree = (query: string): any => new MultiColumnsSyntaxTree(query); +const getSingleColumnSyntaxTree = (query: string): any => new SingleColumnSyntaxTree(query, { + id: 'a' +}); + +describe('Dash Table Queries', () => { + R.forEach(c => { + describe(c.name, () => { + describe('relational operator', () => { + describe('eq', () => { + processCases(c.syntaxer, [ + { name: 'compares "1" to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1" to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1.0" to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: '1.0' }, valid: true, evaluate: true }, + { name: 'compares "1.0" to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: '1.0' }, valid: true, evaluate: true }, + { name: 'compares "1" to 1.0', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1" to "1.0"', query: `${c.hideOperand ? '' : '{a} '}eq "1.0"`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1.1" to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1.0`, target: { a: '1.1' }, valid: true, evaluate: false }, + { name: 'compares "1.1" to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: '1.1' }, valid: true, evaluate: false }, + { name: 'compares "1" to 0x1', query: `${c.hideOperand ? '' : '{a} '}eq 0x1`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1" to "0x1"', query: `${c.hideOperand ? '' : '{a} '}eq "0x1"`, target: { a: '1' }, valid: true, evaluate: true }, + + { name: 'compares 1 to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: 1 }, valid: true, evaluate: true }, + { name: 'compares 1 to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: 1 }, valid: true, evaluate: true }, + { name: 'compares 1.1 to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: 1.1 }, valid: true, evaluate: false }, + { name: 'compares 1.1 to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: 1.1 }, valid: true, evaluate: false }, + { name: 'compares 1 to 0x1', query: `${c.hideOperand ? '' : '{a} '}eq 0x1`, target: { a: 1 }, valid: true, evaluate: true }, + { name: 'compares 1 to "0x1"', query: `${c.hideOperand ? '' : '{a} '}eq "0x1"`, target: { a: 1 }, valid: true, evaluate: true }, + + { name: 'compares "x1" to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: 'x1' }, valid: true, evaluate: false }, + { name: 'compares "x1" to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: 'x1' }, valid: true, evaluate: false }, + + { name: 'compares "1x" to 1', query: `${c.hideOperand ? '' : '{a} '}eq 1`, target: { a: '1x' }, valid: true, evaluate: false }, + { name: 'compares "1x" to "1"', query: `${c.hideOperand ? '' : '{a} '}eq "1"`, target: { a: '1x' }, valid: true, evaluate: false }, + { name: 'compares 1 to 1x', query: `${c.hideOperand ? '' : '{a} '}eq 1x`, target: { a: 1 }, valid: true, evaluate: false }, + { name: 'compares 1 to "1x"', query: `${c.hideOperand ? '' : '{a} '}eq "1x"`, target: { a: 1 }, valid: true, evaluate: false }, + { name: 'compares "1" to 1x', query: `${c.hideOperand ? '' : '{a} '}eq 1x`, target: { a: '1' }, valid: true, evaluate: false }, + { name: 'compares "1" to "1x"', query: `${c.hideOperand ? '' : '{a} '}eq "1x"`, target: { a: '1' }, valid: true, evaluate: false }, + + { name: 'compares "1" to " 1 "', query: `${c.hideOperand ? '' : '{a} '}eq " 1 "`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1" to "\t1\t"', query: `${c.hideOperand ? '' : '{a} '}eq "\t1\t"`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "1" to "\r\n1\r\n"', query: `${c.hideOperand ? '' : '{a} '}eq "\r\n1\r\n"`, target: { a: '1' }, valid: true, evaluate: true }, + { name: 'compares "\t1\t" to "\r\n1\r\n"', query: `${c.hideOperand ? '' : '{a} '}eq "\r\n1\r\n"`, target: { a: '\t1\t' }, valid: true, evaluate: true }, + { name: 'compares "\r\n1\r\n" to "\t1\t"', query: `${c.hideOperand ? '' : '{a} '}eq "\t1\t"`, target: { a: '\r\n1\r\n' }, valid: true, evaluate: true } + ]); + }); + + describe('contains', () => { + processCases(c.syntaxer, [ + { name: 'cannot compare "11" to 1', query: `${c.hideOperand ? '' : '{a} '}contains 1`, target: { a: '11' }, valid: true, evaluate: true }, + { name: 'cannot compare 11 to 1', query: `${c.hideOperand ? '' : '{a} '}contains 1`, target: { a: 11 }, valid: true, evaluate: false }, + { name: 'compares "11" to "1"', query: `${c.hideOperand ? '' : '{a} '}contains "1"`, target: { a: '11' }, valid: true, evaluate: true }, + { name: 'compares 11 to "1"', query: `${c.hideOperand ? '' : '{a} '}contains "1"`, target: { a: 11 }, valid: true, evaluate: true }, + { name: 'compares "1" to "1.0"', query: `${c.hideOperand ? '' : '{a} '}contains "1.0"`, target: { a: '1' }, valid: true, evaluate: false }, + { name: 'compares 1 to "1.0"', query: `${c.hideOperand ? '' : '{a} '}contains "1.0"`, target: { a: 1 }, valid: true, evaluate: false }, + + { name: 'compares "abc" to "b"', query: `${c.hideOperand ? '' : '{a} '}contains "b"`, target: { a: 'abc' }, valid: true, evaluate: true }, + { name: 'compares "abc" to " b"', query: `${c.hideOperand ? '' : '{a} '}contains " b"`, target: { a: 'abc' }, valid: true, evaluate: false }, + { name: 'compares "abc" to "b "', query: `${c.hideOperand ? '' : '{a} '}contains "b "`, target: { a: 'abc' }, valid: true, evaluate: false }, + { name: 'compares "abc" to " b"', query: `${c.hideOperand ? '' : '{a} '}contains " b"`, target: { a: 'a bc' }, valid: true, evaluate: true }, + { name: 'compares "abc" to "b "', query: `${c.hideOperand ? '' : '{a} '}contains "b "`, target: { a: 'ab c' }, valid: true, evaluate: true } + ]); + }); + + describe('datestartswith', () => { + processCases(c.syntaxer, [ + { name: '0yyy in "0"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "0"`, target: { a: '0987' }, valid: true, evaluate: false }, + { name: '0 in "0yyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "0987"`, target: { a: '0' }, valid: true, evaluate: false }, + { name: '0yyy in "0yyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "0987"`, target: { a: '0987' }, valid: true, evaluate: true }, + { name: 'yyyy in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2006"`, target: { a: '2005' }, valid: true, evaluate: false }, + { name: 'yyyy in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005' }, valid: true, evaluate: true }, + { name: 'yyyy in yyyy', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005`, target: { a: '2005' }, valid: true, evaluate: false }, + { name: 'yyyy-mm in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01-01' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01-01T10' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01-01T10:00' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01-01 10:00:00' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss.xxx in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01-01 10:00:00.000' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss.xxxxxxxxx in "yyyy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005"`, target: { a: '2005-01-01 10:00:00.000000000' }, valid: true, evaluate: true }, + { name: 'yyyy in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005' }, valid: true, evaluate: false }, + { name: 'yyyy-mm in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01' }, valid: true, evaluate: true }, + { name: 'yyyy-mm in "yyyy-mm"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2005-01"`, target: { a: '2005-01' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01-01' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01-01T10' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01-01T10:00' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01-01 10:00:00' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss.xxx in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01-01 10:00:00.000' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss.xxxxxxxxx in yyyy-mm', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01`, target: { a: '2005-01-01 10:00:00.000000000' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss.xxx in yyyy-mm-ddThh:mm:ss.xxx', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T10:00:00.000`, target: { a: '2005-01-01 10:00:00.000' }, valid: true, evaluate: true }, + { name: 'yyyy-mm-dd hh:mm:ss.xxx in yyyy-mm-ddThh:mm:ss.xxx000', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T10:00:00.000000`, target: { a: '2005-01-01 10:00:00.000' }, valid: true, evaluate: false }, + { name: 'yyyy-mm-dd hh:mm:ss.xxx in yyyy-mm-ddThh:mm:ss.xxx111', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T10:00:00.000111`, target: { a: '2005-01-01 10:00:00.000' }, valid: true, evaluate: false }, + + { name: 'yyyy-01 in yyyy-02', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-02`, target: { a: '2005-01' }, valid: true, evaluate: false }, + { name: 'yyyy-mm-01 in yyyy-mm-02', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-02`, target: { a: '2005-01-01' }, valid: true, evaluate: false }, + { name: 'yyyy-mm-dd 00 in yyyy-mm-dd 01', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T01`, target: { a: '2005-01-01 00' }, valid: true, evaluate: false }, + { name: 'yyyy-mm-dd hh:00 in yyyy-mm-dd hh:01', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T00:01`, target: { a: '2005-01-01 00:00' }, valid: true, evaluate: false }, + { name: 'yyyy-mm-dd hh:mm:00 in yyyy-mm-dd hh:mm:01', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T00:00:01`, target: { a: '2005-01-01 00:00:00' }, valid: true, evaluate: false }, + { name: 'yyyy-mm-dd hh:mm:ss.000 in yyyy-mm-dd hh:mm:ss.001', query: `${c.hideOperand ? '' : '{a} '}datestartswith 2005-01-01T00:00:00.001`, target: { a: '2005-01-01 00:00:00.000' }, valid: true, evaluate: false }, + + { name: '20yy in "yy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "19"`, target: { a: '2019' }, valid: true, evaluate: true }, + { name: '19yy in "yy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "19"`, target: { a: '1919' }, valid: true, evaluate: false }, + { name: 'yy in "19yy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "1999"`, target: { a: '99' }, valid: true, evaluate: true }, + { name: 'yy in "20yy"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "2099"`, target: { a: '99' }, valid: true, evaluate: false }, + { name: 'yy in yy', query: `${c.hideOperand ? '' : '{a} '}datestartswith "79"`, target: { a: '79' }, valid: true, evaluate: true }, + { name: 'yy in YY"', query: `${c.hideOperand ? '' : '{a} '}datestartswith "79"`, target: { a: '78' }, valid: true, evaluate: false } + + ]); + }); + }); + }); + }, [ + { name: 'Query Syntax Tree', syntaxer: getQuerySyntaxTree }, + { name: 'Multi Columns Syntax Tree', syntaxer: getMultiColumnSyntaxTree }, + { name: 'Single Column Syntax Tree', syntaxer: getSingleColumnSyntaxTree, hideOperand: true } + ] + ); +}); \ No newline at end of file diff --git a/tests/cypress/tests/unit/lexeme_test.ts b/tests/cypress/tests/unit/lexeme_test.ts index 5c878e82c..d924c4e9a 100644 --- a/tests/cypress/tests/unit/lexeme_test.ts +++ b/tests/cypress/tests/unit/lexeme_test.ts @@ -3,7 +3,6 @@ import { stringExpression, valueExpression } from 'dash-table/syntax-tree/lexeme/expression'; -import operand from 'dash-table/syntax-tree/lexeme/operand'; import { ISyntaxTree } from 'core/syntax-tree/syntaxer'; describe('expression', () => { @@ -94,24 +93,4 @@ describe('expression', () => { expect(valueExpression.resolve.bind(undefined, {}, { value: '}' } as ISyntaxTree)).to.throw(Error); } }); -}); - -describe('operand', () => { - it('resolves values', () => { - expect(!!operand.resolve).to.equal(true); - expect(typeof operand.resolve).to.equal('function'); - - if (operand.resolve) { - expect(operand.resolve.bind(undefined, {}, { value: 'abc' } as ISyntaxTree)).to.throw(Error); - expect(operand.resolve.bind(undefined, {}, { value: '123' } as ISyntaxTree)).to.throw(Error); - expect(operand.resolve.bind(undefined, {}, { value: '{abc' } as ISyntaxTree)).to.throw(Error); - expect(operand.resolve.bind(undefined, {}, { value: 'abc}' } as ISyntaxTree)).to.throw(Error); - expect(operand.resolve.bind(undefined, {}, { value: '{{abc}}' } as ISyntaxTree)).to.throw(Error); - - expect(operand.resolve({ abc: 3 }, { value: '{abc}' } as ISyntaxTree)).to.equal(3); - expect(operand.resolve({ ['a bc']: 3 }, { value: '{a bc}' } as ISyntaxTree)).to.equal(3); - expect(operand.resolve({ ['{abc}']: 3 }, { value: '{\\{abc\\}}' } as ISyntaxTree)).to.equal(3); - expect(operand.resolve({ ['"abc"']: 3 }, { value: '{"abc"}' } as ISyntaxTree)).to.equal(3); - } - }); }); \ No newline at end of file diff --git a/tests/cypress/tests/unit/query_syntactic_tree_test.ts b/tests/cypress/tests/unit/query_syntactic_tree_test.ts index 7fb011b0e..5c2adba98 100644 --- a/tests/cypress/tests/unit/query_syntactic_tree_test.ts +++ b/tests/cypress/tests/unit/query_syntactic_tree_test.ts @@ -1,7 +1,7 @@ -import * as R from 'ramda'; - import { QuerySyntaxTree } from 'dash-table/syntax-tree'; +import { processCases } from './dash_table_queries_test'; + describe('Query Syntax Tree', () => { const data0 = { a: '0', b: '0', c: 0, d: null, '\\{': 0, 'a.dot': '0.dot', 'a-dot': '0-dot', a_dot: '0_dot', 'a+dot': '0+dot', 'a dot': '0 dot', 'a:dot': '0:dot', '_-6.:+** *@$': '0*dot', '{a:dot}': '0*dot*', '\'""\'': '0\'"dot' }; const data1 = { a: '1', b: '0', c: 1, d: 0, '\\{': 1, 'a.dot': '1.dot', 'a-dot': '1-dot', a_dot: '1_dot', 'a+dot': '1+dot', 'a dot': '1 dot', 'a:dot': '1:dot', '_-6.:+** *@$': '1*dot', '{a:dot}': '1*dot*', '\'""\'': '1\'"dot' }; @@ -9,41 +9,32 @@ describe('Query Syntax Tree', () => { const data3 = { a: '3', b: '1', c: 3, d: false, '\\{': 3, 'a.dot': '3.dot', 'a-dot': '3-dot', a_dot: '3_dot', 'a+dot': '3+dot', 'a dot': '3 dot', 'a:dot': '3:dot', '_-6.:+** *@$': '3*dot', '{a:dot}': '3*dot*', '\'""\'': '3\'"dot' }; describe('special whitespace characters are valid', () => { - const cases = [ - { name: 'suports new line', query: '{a}\neq\n"0"' }, - { name: 'suports carriage return', query: '{a}\req\r"0"' }, - { name: 'suports new line ad carriage return combination', query: '{a}\r\neq\r\n"0"' }, - { name: 'supports tab', query: '{a}\teq\t"0"' }, + processCases((query: string) => new QuerySyntaxTree(query), [ + { name: 'suports new line', query: '{a}\neq\n"0"', target: data0, valid: true, evaluate: true }, + { name: 'suports carriage return', query: '{a}\req\r"0"', target: data0, valid: true, evaluate: true }, + { name: 'suports new line ad carriage return combination', target: data0, query: '{a}\r\neq\r\n"0"', valid: true, evaluate: true }, + { name: 'supports tab', query: '{a}\teq\t"0"', target: data0, valid: true, evaluate: true }, // some random non-standard whitespace character from https://en.wikipedia.org/wiki/Whitespace_character - { name: 'supports ogham space mark', query: '{a}\u1680eq\u1680"0"' }, - { name: 'supports all', query: '{a}\r\n\t\u1680eq\r\n\t\u1680"0"' } - ]; - - R.forEach(c => { - it(c.name, () => { - const tree = new QuerySyntaxTree(c.query); - - expect(tree.isValid).to.equal(true); - expect(tree.evaluate(data0)).to.equal(true); - }); - }, cases); + { name: 'supports ogham space mark', query: '{a}\u1680eq\u1680"0"', target: data0, valid: true, evaluate: true }, + { name: 'supports all', query: '{a}\r\n\t\u1680eq\r\n\t\u1680"0"', target: data0, valid: true, evaluate: true } + ]); }); - describe('operands', () => { - it('does not support badly formed operands', () => { + describe('expressions', () => { + it('does not support badly formed lhs', () => { expect(new QuerySyntaxTree(`{myField} eq 0`).isValid).to.equal(true); expect(new QuerySyntaxTree(`{'myField'} eq 0`).isValid).to.equal(true); expect(new QuerySyntaxTree(`{"myField"} eq 0`).isValid).to.equal(true); expect(new QuerySyntaxTree('{`myField`} eq 0').isValid).to.equal(true); expect(new QuerySyntaxTree('{\\{myField\\}} eq 0').isValid).to.equal(true); + expect(new QuerySyntaxTree(`myField eq 0`).isValid).to.equal(true); expect(new QuerySyntaxTree('{{myField}} eq 0').isValid).to.equal(false); expect(new QuerySyntaxTree(`{myField eq 0`).isValid).to.equal(false); expect(new QuerySyntaxTree(`myField} eq 0`).isValid).to.equal(false); - expect(new QuerySyntaxTree(`myField eq 0`).isValid).to.equal(false); expect(new QuerySyntaxTree('{\\\\{myField\\\\}} eq 0').isValid).to.equal(false); }); - it('does not support badly formed expression', () => { + it('does not support badly formed rhs', () => { expect(new QuerySyntaxTree(`{myField} eq 'value'`).isValid).to.equal(true); expect(new QuerySyntaxTree(`{myField} eq "value"`).isValid).to.equal(true); expect(new QuerySyntaxTree('{myField} eq `value`').isValid).to.equal(true); @@ -244,12 +235,12 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate({ field: 2 })).to.equal(true); }); - it('can compare string to number and return false', () => { + it('can compare string to number', () => { const tree = new QuerySyntaxTree('{a} eq 1'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); - expect(tree.evaluate(data1)).to.equal(false); + expect(tree.evaluate(data1)).to.equal(true); expect(tree.evaluate(data2)).to.equal(false); expect(tree.evaluate(data3)).to.equal(false); }); @@ -264,12 +255,12 @@ describe('Query Syntax Tree', () => { expect(tree.evaluate(data3)).to.equal(false); }); - it('can compare string to number and return false', () => { + it('can compare string to number', () => { const tree = new QuerySyntaxTree('{c} eq "1"'); expect(tree.isValid).to.equal(true); expect(tree.evaluate(data0)).to.equal(false); - expect(tree.evaluate(data1)).to.equal(false); + expect(tree.evaluate(data1)).to.equal(true); expect(tree.evaluate(data2)).to.equal(false); expect(tree.evaluate(data3)).to.equal(false); }); diff --git a/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts b/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts index dacdcc140..d98108f00 100644 --- a/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts +++ b/tests/cypress/tests/unit/single_column_syntactic_tree_test.ts @@ -1,33 +1,55 @@ import { SingleColumnSyntaxTree } from 'dash-table/syntax-tree'; +import { ColumnType } from 'dash-table/components/Table/props'; +import { SingleColumnConfig } from 'dash-table/syntax-tree/SingleColumnSyntaxTree'; + +const COLUMN_ANY: SingleColumnConfig = { + id: 'a', + type: ColumnType.Any +}; + +const COLUMN_NUMERIC: SingleColumnConfig = { + id: 'a', + type: ColumnType.Numeric +}; + +const COLUMN_TEXT: SingleColumnConfig = { + id: 'a', + type: ColumnType.Text +}; + +const COLUMN_UNDEFINED: SingleColumnConfig = { + id: 'a', + type: undefined +}; describe('Single Column Syntax Tree', () => { it('cannot have operand', () => { - const tree = new SingleColumnSyntaxTree('a', '{a} <= 1'); + const tree = new SingleColumnSyntaxTree('{a} <= 1', COLUMN_UNDEFINED); expect(tree.isValid).to.equal(false); }); it('cannot have binary dangle', () => { - const tree = new SingleColumnSyntaxTree('a', '<='); + const tree = new SingleColumnSyntaxTree('<=', COLUMN_UNDEFINED); expect(tree.isValid).to.equal(false); }); it('cannot be unary + expression', () => { - const tree = new SingleColumnSyntaxTree('a', 'is prime "a"'); + const tree = new SingleColumnSyntaxTree('is prime "a"', COLUMN_UNDEFINED); expect(tree.isValid).to.equal(false); }); it('can be empty', () => { - const tree = new SingleColumnSyntaxTree('a', ''); + const tree = new SingleColumnSyntaxTree('', COLUMN_UNDEFINED); expect(tree.isValid).to.equal(true); expect(tree.evaluate({ a: 0 })).to.equal(true); }); it('can be binary + expression', () => { - const tree = new SingleColumnSyntaxTree('a', '<= 1'); + const tree = new SingleColumnSyntaxTree('<= 1', COLUMN_UNDEFINED); expect(tree.isValid).to.equal(true); expect(tree.evaluate({ a: 0 })).to.equal(true); @@ -37,7 +59,7 @@ describe('Single Column Syntax Tree', () => { }); it('can be unary', () => { - const tree = new SingleColumnSyntaxTree('a', 'is prime'); + const tree = new SingleColumnSyntaxTree('is prime', COLUMN_UNDEFINED); expect(tree.isValid).to.equal(true); expect(tree.evaluate({ a: 5 })).to.equal(true); @@ -46,13 +68,51 @@ describe('Single Column Syntax Tree', () => { expect(tree.toQueryString()).to.equal('{a} is prime'); }); - it('can be expression', () => { - const tree = new SingleColumnSyntaxTree('a', '1'); + it('can be expression with undefined column type', () => { + const tree = new SingleColumnSyntaxTree('1', COLUMN_UNDEFINED); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: 1 })).to.equal(true); + expect(tree.evaluate({ a: 2 })).to.equal(false); + + expect(tree.toQueryString()).to.equal('{a} contains 1'); + }); + + it('can be expression with numeric column type', () => { + const tree = new SingleColumnSyntaxTree('1', COLUMN_NUMERIC); expect(tree.isValid).to.equal(true); expect(tree.evaluate({ a: 1 })).to.equal(true); expect(tree.evaluate({ a: 2 })).to.equal(false); - expect(tree.toQueryString()).to.equal('{a} eq 1'); + expect(tree.toQueryString()).to.equal('{a} = 1'); + }); + + it('`undefined` column type can use `contains`', () => { + const tree = new SingleColumnSyntaxTree('contains 1', COLUMN_UNDEFINED); + + expect(tree.isValid).to.equal(true); + }); + + it('`any` column type can use `contains`', () => { + const tree = new SingleColumnSyntaxTree('contains 1', COLUMN_ANY); + + expect(tree.isValid).to.equal(true); + }); + + it('`numeric` column type can use `contains`', () => { + const tree = new SingleColumnSyntaxTree('contains 1', COLUMN_NUMERIC); + + expect(tree.isValid).to.equal(true); + }); + + it('can be expression with text column type', () => { + const tree = new SingleColumnSyntaxTree('"1"', COLUMN_TEXT); + + expect(tree.isValid).to.equal(true); + expect(tree.evaluate({ a: '1' })).to.equal(true); + expect(tree.evaluate({ a: '2' })).to.equal(false); + + expect(tree.toQueryString()).to.equal('{a} contains "1"'); }); }); \ No newline at end of file diff --git a/webpack.test.config.js b/webpack.test.config.js index b1f954194..1928de87b 100644 --- a/webpack.test.config.js +++ b/webpack.test.config.js @@ -1,6 +1,14 @@ -module.exports = require('./.config/webpack/base.js')( +const config = require('./.config/webpack/base.js')( { definitions: ['TEST_COPY_PASTE'] }, 'development' -); \ No newline at end of file +); + +config.module.rules.forEach(rule => { + if (rule.loader) { + rule.loader = rule.loader.replace('ts-loader', `ts-loader?${JSON.stringify({ transpileOnly: true })}`); + } +}); + +module.exports = config; \ No newline at end of file