diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 6a6c840074f02..eaaf68eb06195 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -1697,6 +1697,16 @@ Aliases: `column`, `name` Aliases: `exp`, `fn`, `function` |`boolean`, `number`, `string`, `null` |A Canvas expression that is passed to each row as a single row `datatable`. + +|`id` + +|`string`, `null` +|An optional id of the resulting column. When not specified or `null` the name argument is used as id. + +|`copyMetaFrom` + +|`string`, `null` +|If set, the meta object from the specified column id is copied over to the specified target column. Throws an exception if the column doesn't exist |=== *Returns:* `datatable` @@ -1755,9 +1765,16 @@ Interprets a `TinyMath` math expression using a `number` or `datatable` as _cont Alias: `expression` |`string` |An evaluated `TinyMath` expression. See https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html. + +|`onError` + +|`string` +|In case the `TinyMath` evaluation fails or returns NaN, the return value is specified by onError. For example, `"null"`, `"zero"`, `"false"`, `"throw"`. When `"throw"`, it will throw an exception, terminating expression execution. + +Default: `"throw"` |=== -*Returns:* `number` +*Returns:* `number` | `boolean` | `null` [float] diff --git a/src/plugins/expressions/common/expression_functions/specs/index.ts b/src/plugins/expressions/common/expression_functions/specs/index.ts index 938fce026027c..9408b3a433712 100644 --- a/src/plugins/expressions/common/expression_functions/specs/index.ts +++ b/src/plugins/expressions/common/expression_functions/specs/index.ts @@ -15,6 +15,8 @@ import { theme } from './theme'; import { cumulativeSum } from './cumulative_sum'; import { derivative } from './derivative'; import { movingAverage } from './moving_average'; +import { mapColumn } from './map_column'; +import { math } from './math'; export const functionSpecs: AnyExpressionFunctionDefinition[] = [ clog, @@ -25,6 +27,8 @@ export const functionSpecs: AnyExpressionFunctionDefinition[] = [ cumulativeSum, derivative, movingAverage, + mapColumn, + math, ]; export * from './clog'; @@ -35,3 +39,5 @@ export * from './theme'; export * from './cumulative_sum'; export * from './derivative'; export * from './moving_average'; +export { mapColumn, MapColumnArguments } from './map_column'; +export { math, MathArguments, MathInput } from './math'; diff --git a/src/plugins/expressions/common/expression_functions/specs/map_column.ts b/src/plugins/expressions/common/expression_functions/specs/map_column.ts new file mode 100644 index 0000000000000..e2605e5ddf38d --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/map_column.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, getType } from '../../expression_types'; + +export interface MapColumnArguments { + id?: string | null; + name: string; + expression?: (datatable: Datatable) => Promise; + copyMetaFrom?: string | null; +} + +export const mapColumn: ExpressionFunctionDefinition< + 'mapColumn', + Datatable, + MapColumnArguments, + Promise +> = { + name: 'mapColumn', + aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. + type: 'datatable', + inputTypes: ['datatable'], + help: i18n.translate('expressions.functions.mapColumnHelpText', { + defaultMessage: + 'Adds a column calculated as the result of other columns. ' + + 'Changes are made only when you provide arguments.' + + 'See also {alterColumnFn} and {staticColumnFn}.', + values: { + alterColumnFn: '`alterColumn`', + staticColumnFn: '`staticColumn`', + }, + }), + args: { + id: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.idHelpText', { + defaultMessage: + 'An optional id of the resulting column. When `null` the name/column argument is used as id.', + }), + required: false, + default: null, + }, + name: { + types: ['string'], + aliases: ['_', 'column'], + help: i18n.translate('expressions.functions.mapColumn.args.nameHelpText', { + defaultMessage: 'The name of the resulting column.', + }), + required: true, + }, + expression: { + types: ['boolean', 'number', 'string', 'null'], + resolve: false, + aliases: ['exp', 'fn', 'function'], + help: i18n.translate('expressions.functions.mapColumn.args.expressionHelpText', { + defaultMessage: + 'An expression that is executed on every row, provided with a single-row {DATATABLE} context and returning the cell value.', + values: { + DATATABLE: '`datatable`', + }, + }), + required: true, + }, + copyMetaFrom: { + types: ['string', 'null'], + help: i18n.translate('expressions.functions.mapColumn.args.copyMetaFromHelpText', { + defaultMessage: + "If set, the meta object from the specified column id is copied over to the specified target column. If the column doesn't exist it silently fails.", + }), + required: false, + default: null, + }, + }, + fn: (input, args) => { + const expression = args.expression || (() => Promise.resolve(null)); + const columnId = args.id != null ? args.id : args.name; + + const columns = [...input.columns]; + const rowPromises = input.rows.map((row) => { + return expression({ + type: 'datatable', + columns, + rows: [row], + }).then((val) => ({ + ...row, + [columnId]: val, + })); + }); + + return Promise.all(rowPromises).then((rows) => { + const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); + const type = rows.length ? getType(rows[0][columnId]) : 'null'; + const newColumn = { + id: columnId, + name: args.name, + meta: { type }, + }; + if (args.copyMetaFrom) { + const metaSourceFrom = columns.find(({ id }) => id === args.copyMetaFrom); + newColumn.meta = { ...newColumn.meta, ...(metaSourceFrom?.meta || {}) }; + } + + if (existingColumnIndex === -1) { + columns.push(newColumn); + } else { + columns[existingColumnIndex] = newColumn; + } + + return { + type: 'datatable', + columns, + rows, + } as Datatable; + }); + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/math.ts b/src/plugins/expressions/common/expression_functions/specs/math.ts new file mode 100644 index 0000000000000..a70c032769b57 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/math.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { map, zipObject } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { evaluate } from '@kbn/tinymath'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, isDatatable } from '../../expression_types'; + +export type MathArguments = { + expression: string; + onError?: 'null' | 'zero' | 'false' | 'throw'; +}; + +export type MathInput = number | Datatable; + +const TINYMATH = '`TinyMath`'; +const TINYMATH_URL = + 'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html'; + +const isString = (val: any): boolean => typeof val === 'string'; + +function pivotObjectArray< + RowType extends { [key: string]: any }, + ReturnColumns extends string | number | symbol = keyof RowType +>(rows: RowType[], columns?: string[]): Record { + const columnNames = columns || Object.keys(rows[0]); + if (!columnNames.every(isString)) { + throw new Error('Columns should be an array of strings'); + } + + const columnValues = map(columnNames, (name) => map(rows, name)); + return zipObject(columnNames, columnValues); +} + +export const errors = { + emptyExpression: () => + new Error( + i18n.translate('expressions.functions.math.emptyExpressionErrorMessage', { + defaultMessage: 'Empty expression', + }) + ), + tooManyResults: () => + new Error( + i18n.translate('expressions.functions.math.tooManyResultsErrorMessage', { + defaultMessage: + 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', + values: { + mean: 'mean()', + sum: 'sum()', + }, + }) + ), + executionFailed: () => + new Error( + i18n.translate('expressions.functions.math.executionFailedErrorMessage', { + defaultMessage: 'Failed to execute math expression. Check your column names', + }) + ), + emptyDatatable: () => + new Error( + i18n.translate('expressions.functions.math.emptyDatatableErrorMessage', { + defaultMessage: 'Empty datatable', + }) + ), +}; + +const fallbackValue = { + null: null, + zero: 0, + false: false, +} as const; + +export const math: ExpressionFunctionDefinition< + 'math', + MathInput, + MathArguments, + boolean | number | null +> = { + name: 'math', + type: undefined, + inputTypes: ['number', 'datatable'], + help: i18n.translate('expressions.functions.mathHelpText', { + defaultMessage: + 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + + 'The {DATATABLE} columns are available by their column name. ' + + 'If the {CONTEXT} is a number it is available as {value}.', + values: { + TINYMATH, + CONTEXT: '_context_', + DATATABLE: '`datatable`', + value: '`value`', + TYPE_NUMBER: '`number`', + }, + }), + args: { + expression: { + aliases: ['_'], + types: ['string'], + help: i18n.translate('expressions.functions.math.args.expressionHelpText', { + defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', + values: { + TINYMATH, + TINYMATH_URL, + }, + }), + }, + onError: { + types: ['string'], + options: ['throw', 'false', 'zero', 'null'], + help: i18n.translate('expressions.functions.math.args.onErrorHelpText', { + defaultMessage: + "In case the {TINYMATH} evaluation fails or returns NaN, the return value is specified by onError. When `'throw'`, it will throw an exception, terminating expression execution (default).", + values: { + TINYMATH, + }, + }), + }, + }, + fn: (input, args) => { + const { expression, onError } = args; + const onErrorValue = onError ?? 'throw'; + + if (!expression || expression.trim() === '') { + throw errors.emptyExpression(); + } + + const mathContext = isDatatable(input) + ? pivotObjectArray( + input.rows, + input.columns.map((col) => col.name) + ) + : { value: input }; + + try { + const result = evaluate(expression, mathContext); + if (Array.isArray(result)) { + if (result.length === 1) { + return result[0]; + } + throw errors.tooManyResults(); + } + if (isNaN(result)) { + // make TS happy + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + throw errors.executionFailed(); + } + return result; + } catch (e) { + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + if (isDatatable(input) && input.rows.length === 0) { + throw errors.emptyDatatable(); + } else { + throw e; + } + } + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts new file mode 100644 index 0000000000000..6b0dce4ff9a2a --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/tests/map_column.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../../expression_types'; +import { mapColumn, MapColumnArguments } from '../map_column'; +import { emptyTable, functionWrapper, testTable } from './utils'; + +const pricePlusTwo = (datatable: Datatable) => Promise.resolve(datatable.rows[0].price + 2); + +describe('mapColumn', () => { + const fn = functionWrapper(mapColumn); + const runFn = (input: Datatable, args: MapColumnArguments) => + fn(input, args) as Promise; + + it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { + return runFn(testTable, { + id: 'pricePlusTwo', + name: 'pricePlusTwo', + expression: pricePlusTwo, + }).then((result) => { + const arbitraryRowIndex = 2; + + expect(result.type).toBe('datatable'); + expect(result.columns).toEqual([ + ...testTable.columns, + { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, + ]); + expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); + }); + }); + + it('overwrites existing column with the new column if an existing column name is provided', () => { + return runFn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + const arbitraryRowIndex = 4; + + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(testTable.columns.length); + expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); + expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); + }); + }); + + it('adds a column to empty tables', () => { + return runFn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + }); + }); + + it('should assign specific id, different from name, when id arg is passed for new columns', () => { + return runFn(emptyTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'myid'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should assign specific id, different from name, when id arg is passed for copied column', () => { + return runFn(testTable, { name: 'name', id: 'myid', expression: pricePlusTwo }).then( + (result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'myid', + name: 'name', + meta: { type: 'number' }, + }); + } + ); + }); + + it('should copy over the meta information from the specified column', () => { + return runFn( + { + ...testTable, + columns: [ + ...testTable.columns, + // add a new entry + { + id: 'myId', + name: 'myName', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }, + ], + rows: testTable.rows.map((row) => ({ ...row, myId: Date.now() })), + }, + { name: 'name', copyMetaFrom: 'myId', expression: pricePlusTwo } + ).then((result) => { + const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); + expect(result.type).toBe('datatable'); + expect(result.columns[nameColumnIndex]).toEqual({ + id: 'name', + name: 'name', + meta: { type: 'date', params: { id: 'number', params: { digits: 2 } } }, + }); + }); + }); + + it('should be resilient if the references column for meta information does not exists', () => { + return runFn(emptyTable, { name: 'name', copyMetaFrom: 'time', expression: pricePlusTwo }).then( + (result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'name'); + expect(result.columns[0]).toHaveProperty('id', 'name'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); + } + ); + }); + + it('should correctly infer the type fromt he first row if the references column for meta information does not exists', () => { + return runFn( + { ...emptyTable, rows: [...emptyTable.rows, { value: 5 }] }, + { name: 'value', copyMetaFrom: 'time', expression: pricePlusTwo } + ).then((result) => { + expect(result.type).toBe('datatable'); + expect(result.columns).toHaveLength(1); + expect(result.columns[0]).toHaveProperty('name', 'value'); + expect(result.columns[0]).toHaveProperty('id', 'value'); + expect(result.columns[0].meta).toHaveProperty('type', 'number'); + }); + }); + + describe('expression', () => { + it('maps null values to the new column', () => { + return runFn(testTable, { name: 'empty' }).then((result) => { + const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); + const arbitraryRowIndex = 8; + + expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); + expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); + expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); + }); + }); + }); +}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts similarity index 63% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js rename to src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts index f5b8123ab8568..7541852cdbdaf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.test.js +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts @@ -1,19 +1,16 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { getFunctionErrors } from '../../../i18n'; -import { emptyTable, testTable } from './__fixtures__/test_tables'; -import { math } from './math'; - -const errors = getFunctionErrors().math; +import { errors, math } from '../math'; +import { emptyTable, functionWrapper, testTable } from './utils'; describe('math', () => { - const fn = functionWrapper(math); + const fn = functionWrapper(math); it('evaluates math expressions without reference to context', () => { expect(fn(null, { expression: '10.5345' })).toBe(10.5345); @@ -48,6 +45,19 @@ describe('math', () => { expect(fn(testTable, { expression: 'count(name)' })).toBe(9); }); }); + + describe('onError', () => { + it('should return the desired fallback value, for invalid expressions', () => { + expect(fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: 'mean(name)', onError: 'false' })).toBe(false); + }); + it('should return the desired fallback value, for division by zero', () => { + expect(fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0); + expect(fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null); + expect(fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false); + }); + }); }); describe('invalid expressions', () => { @@ -88,5 +98,23 @@ describe('math', () => { new RegExp(errors.emptyDatatable().message) ); }); + + it('should not throw when requesting fallback values for invalid expression', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'zero' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'false' })).not.toThrow(); + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'null' })).not.toThrow(); + }); + + it('should throw when declared in the onError argument', () => { + expect(() => fn(testTable, { expression: 'mean(name)', onError: 'throw' })).toThrow( + new RegExp(errors.executionFailed().message) + ); + }); + + it('should throw when dividing by zero', () => { + expect(() => fn(testTable, { expression: '1/0', onError: 'throw' })).toThrow( + new RegExp('Cannot divide by 0') + ); + }); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts index 9006de9067616..7369570cf2c4b 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/utils.ts @@ -9,16 +9,219 @@ import { mapValues } from 'lodash'; import { AnyExpressionFunctionDefinition } from '../../types'; import { ExecutionContext } from '../../../execution/types'; +import { Datatable } from '../../../expression_types'; /** * Takes a function spec and passes in default args, * overriding with any provided args. */ -export const functionWrapper = (spec: AnyExpressionFunctionDefinition) => { +export const functionWrapper = ( + spec: AnyExpressionFunctionDefinition +) => { const defaultArgs = mapValues(spec.args, (argSpec) => argSpec.default); return ( - context: object | null, + context: ContextType, args: Record = {}, handlers: ExecutionContext = {} as ExecutionContext ) => spec.fn(context, { ...defaultArgs, ...args }, handlers); }; + +const emptyTable: Datatable = { + type: 'datatable', + columns: [], + rows: [], +}; + +const testTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'date' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'number' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'number' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'boolean' }, + }, + ], + rows: [ + { + name: 'product1', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 605, + quantity: 100, + in_stock: true, + }, + { + name: 'product1', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 583, + quantity: 200, + in_stock: true, + }, + { + name: 'product1', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 420, + quantity: 300, + in_stock: true, + }, + { + name: 'product2', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 216, + quantity: 350, + in_stock: false, + }, + { + name: 'product2', + time: 1517929200950, // 06 Feb 2018 15:00:00 GMT + price: 200, + quantity: 256, + in_stock: false, + }, + { + name: 'product2', + time: 1518015600950, // 07 Feb 2018 15:00:00 GMT + price: 190, + quantity: 231, + in_stock: false, + }, + { + name: 'product3', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 67, + quantity: 240, + in_stock: true, + }, + { + name: 'product4', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 311, + quantity: 447, + in_stock: false, + }, + { + name: 'product5', + time: 1517842800950, // 05 Feb 2018 15:00:00 GMT + price: 288, + quantity: 384, + in_stock: true, + }, + ], +}; + +const stringTable: Datatable = { + type: 'datatable', + columns: [ + { + id: 'name', + name: 'name', + meta: { type: 'string' }, + }, + { + id: 'time', + name: 'time', + meta: { type: 'string' }, + }, + { + id: 'price', + name: 'price', + meta: { type: 'string' }, + }, + { + id: 'quantity', + name: 'quantity', + meta: { type: 'string' }, + }, + { + id: 'in_stock', + name: 'in_stock', + meta: { type: 'string' }, + }, + ], + rows: [ + { + name: 'product1', + time: '2018-02-05T15:00:00.950Z', + price: '605', + quantity: '100', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-06T15:00:00.950Z', + price: '583', + quantity: '200', + in_stock: 'true', + }, + { + name: 'product1', + time: '2018-02-07T15:00:00.950Z', + price: '420', + quantity: '300', + in_stock: 'true', + }, + { + name: 'product2', + time: '2018-02-05T15:00:00.950Z', + price: '216', + quantity: '350', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-06T15:00:00.950Z', + price: '200', + quantity: '256', + in_stock: 'false', + }, + { + name: 'product2', + time: '2018-02-07T15:00:00.950Z', + price: '190', + quantity: '231', + in_stock: 'false', + }, + { + name: 'product3', + time: '2018-02-05T15:00:00.950Z', + price: '67', + quantity: '240', + in_stock: 'true', + }, + { + name: 'product4', + time: '2018-02-05T15:00:00.950Z', + price: '311', + quantity: '447', + in_stock: 'false', + }, + { + name: 'product5', + time: '2018-02-05T15:00:00.950Z', + price: '288', + quantity: '384', + in_stock: 'true', + }, + ], +}; + +export { emptyTable, testTable, stringTable }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 048bc3468f149..5c4d1d55cff04 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -34,8 +34,6 @@ import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; import { mapCenter } from './map_center'; -import { mapColumn } from './mapColumn'; -import { math } from './math'; import { metric } from './metric'; import { neq } from './neq'; import { ply } from './ply'; @@ -89,8 +87,6 @@ export const functions = [ lte, joinRows, mapCenter, - mapColumn, - math, metric, neq, ply, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js deleted file mode 100644 index d511c7774122d..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { functionWrapper } from '../../../test_helpers/function_wrapper'; -import { testTable, emptyTable } from './__fixtures__/test_tables'; -import { mapColumn } from './mapColumn'; - -const pricePlusTwo = (datatable) => Promise.resolve(datatable.rows[0].price + 2); - -describe('mapColumn', () => { - const fn = functionWrapper(mapColumn); - - it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { - return fn(testTable, { - id: 'pricePlusTwo', - name: 'pricePlusTwo', - expression: pricePlusTwo, - }).then((result) => { - const arbitraryRowIndex = 2; - - expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([ - ...testTable.columns, - { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, - ]); - expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); - }); - }); - - it('overwrites existing column with the new column if an existing column name is provided', () => { - return fn(testTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - const nameColumnIndex = result.columns.findIndex(({ name }) => name === 'name'); - const arbitraryRowIndex = 4; - - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(testTable.columns.length); - expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); - expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); - }); - }); - - it('adds a column to empty tables', () => { - return fn(emptyTable, { name: 'name', expression: pricePlusTwo }).then((result) => { - expect(result.type).toBe('datatable'); - expect(result.columns).toHaveLength(1); - expect(result.columns[0]).toHaveProperty('name', 'name'); - expect(result.columns[0].meta).toHaveProperty('type', 'null'); - }); - }); - - describe('expression', () => { - it('maps null values to the new column', () => { - return fn(testTable, { name: 'empty' }).then((result) => { - const emptyColumnIndex = result.columns.findIndex(({ name }) => name === 'empty'); - const arbitraryRowIndex = 8; - - expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); - expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); - expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); - }); - }); - }); -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts deleted file mode 100644 index 63cc0d6cbc687..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Datatable, ExpressionFunctionDefinition, getType } from '../../../types'; -import { getFunctionHelp } from '../../../i18n'; - -interface Arguments { - name: string; - expression: (datatable: Datatable) => Promise; -} - -export function mapColumn(): ExpressionFunctionDefinition< - 'mapColumn', - Datatable, - Arguments, - Promise -> { - const { help, args: argHelp } = getFunctionHelp().mapColumn; - - return { - name: 'mapColumn', - aliases: ['mc'], // midnight commander. So many times I've launched midnight commander instead of moving a file. - type: 'datatable', - inputTypes: ['datatable'], - help, - args: { - name: { - types: ['string'], - aliases: ['_', 'column'], - help: argHelp.name, - required: true, - }, - expression: { - types: ['boolean', 'number', 'string', 'null'], - resolve: false, - aliases: ['exp', 'fn', 'function'], - help: argHelp.expression, - required: true, - }, - }, - fn: (input, args) => { - const expression = args.expression || (() => Promise.resolve(null)); - - const columns = [...input.columns]; - const rowPromises = input.rows.map((row) => { - return expression({ - type: 'datatable', - columns, - rows: [row], - }).then((val) => ({ - ...row, - [args.name]: val, - })); - }); - - return Promise.all(rowPromises).then((rows) => { - const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); - const type = rows.length ? getType(rows[0][args.name]) : 'null'; - const newColumn = { id: args.name, name: args.name, meta: { type } }; - - if (existingColumnIndex === -1) { - columns.push(newColumn); - } else { - columns[existingColumnIndex] = newColumn; - } - - return { - type: 'datatable', - columns, - rows, - } as Datatable; - }); - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts deleted file mode 100644 index af70fa729b7da..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { evaluate } from '@kbn/tinymath'; -import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; -import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; -import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; - -interface Arguments { - expression: string; -} - -type Input = number | Datatable; - -export function math(): ExpressionFunctionDefinition<'math', Input, Arguments, number> { - const { help, args: argHelp } = getFunctionHelp().math; - const errors = getFunctionErrors().math; - - return { - name: 'math', - type: 'number', - inputTypes: ['number', 'datatable'], - help, - args: { - expression: { - aliases: ['_'], - types: ['string'], - help: argHelp.expression, - }, - }, - fn: (input, args) => { - const { expression } = args; - - if (!expression || expression.trim() === '') { - throw errors.emptyExpression(); - } - - const mathContext = isDatatable(input) - ? pivotObjectArray( - input.rows, - input.columns.map((col) => col.name) - ) - : { value: input }; - - try { - const result = evaluate(expression, mathContext); - if (Array.isArray(result)) { - if (result.length === 1) { - return result[0]; - } - throw errors.tooManyResults(); - } - if (isNaN(result)) { - throw errors.executionFailed(); - } - return result; - } catch (e) { - if (isDatatable(input) && input.rows.length === 0) { - throw errors.emptyDatatable(); - } else { - throw e; - } - } - }, - }; -} diff --git a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts deleted file mode 100644 index f8d0311d08961..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/map_column.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { mapColumn } from '../../../canvas_plugin_src/functions/common/mapColumn'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { CANVAS, DATATABLE } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { - defaultMessage: - 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments.' + - 'See also {alterColumnFn} and {staticColumnFn}.', - values: { - alterColumnFn: '`alterColumn`', - staticColumnFn: '`staticColumn`', - }, - }), - args: { - name: i18n.translate('xpack.canvas.functions.mapColumn.args.nameHelpText', { - defaultMessage: 'The name of the resulting column.', - }), - expression: i18n.translate('xpack.canvas.functions.mapColumn.args.expressionHelpText', { - defaultMessage: - 'A {CANVAS} expression that is passed to each row as a single row {DATATABLE}.', - values: { - CANVAS, - DATATABLE, - }, - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/math.ts b/x-pack/plugins/canvas/i18n/functions/dict/math.ts deleted file mode 100644 index 110136872e1ff..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/math.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { math } from '../../../canvas_plugin_src/functions/common/math'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { DATATABLE, CONTEXT, TINYMATH, TINYMATH_URL, TYPE_NUMBER } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.mathHelpText', { - defaultMessage: - 'Interprets a {TINYMATH} math expression using a {TYPE_NUMBER} or {DATATABLE} as {CONTEXT}. ' + - 'The {DATATABLE} columns are available by their column name. ' + - 'If the {CONTEXT} is a number it is available as {value}.', - values: { - TINYMATH, - CONTEXT, - DATATABLE, - value: '`value`', - TYPE_NUMBER, - }, - }), - args: { - expression: i18n.translate('xpack.canvas.functions.math.args.expressionHelpText', { - defaultMessage: 'An evaluated {TINYMATH} expression. See {TINYMATH_URL}.', - values: { - TINYMATH, - TINYMATH_URL, - }, - }), - }, -}; - -export const errors = { - emptyExpression: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyExpressionErrorMessage', { - defaultMessage: 'Empty expression', - }) - ), - tooManyResults: () => - new Error( - i18n.translate('xpack.canvas.functions.math.tooManyResultsErrorMessage', { - defaultMessage: - 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', - values: { - mean: 'mean()', - sum: 'sum()', - }, - }) - ), - executionFailed: () => - new Error( - i18n.translate('xpack.canvas.functions.math.executionFailedErrorMessage', { - defaultMessage: 'Failed to execute math expression. Check your column names', - }) - ), - emptyDatatable: () => - new Error( - i18n.translate('xpack.canvas.functions.math.emptyDatatableErrorMessage', { - defaultMessage: 'Empty datatable', - }) - ), -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_errors.ts b/x-pack/plugins/canvas/i18n/functions/function_errors.ts index ac86eb4c4d0e9..4a85018c1b4ac 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_errors.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_errors.ts @@ -16,7 +16,6 @@ import { errors as demodata } from './dict/demodata'; import { errors as getCell } from './dict/get_cell'; import { errors as image } from './dict/image'; import { errors as joinRows } from './dict/join_rows'; -import { errors as math } from './dict/math'; import { errors as ply } from './dict/ply'; import { errors as pointseries } from './dict/pointseries'; import { errors as progress } from './dict/progress'; @@ -36,7 +35,6 @@ export const getFunctionErrors = () => ({ getCell, image, joinRows, - math, ply, pointseries, progress, diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index fd0d3ed952b2f..f0585b226d33b 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -46,9 +46,7 @@ import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; import { help as mapCenter } from './dict/map_center'; -import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; -import { help as math } from './dict/math'; import { help as metric } from './dict/metric'; import { help as neq } from './dict/neq'; import { help as pie } from './dict/pie'; @@ -209,9 +207,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ lt, lte, mapCenter, - mapColumn, markdown, - math, metric, neq, pie, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7f2a8e986b5d4..29bcf1791d456 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5949,19 +5949,10 @@ "xpack.canvas.functions.ltHelpText": "{CONTEXT} が引数よりも小さいかを戻します。", "xpack.canvas.functions.mapCenter.args.latHelpText": "マップの中央の緯度", "xpack.canvas.functions.mapCenterHelpText": "マップの中央座標とズームレベルのオブジェクトに戻ります。", - "xpack.canvas.functions.mapColumn.args.expressionHelpText": "単一行 {DATATABLE} として各行に渡される {CANVAS} 表現です。", - "xpack.canvas.functions.mapColumn.args.nameHelpText": "結果の列の名前です。", - "xpack.canvas.functions.mapColumnHelpText": "他の列の結果として計算された列を追加します。引数が指定された場合のみ変更が加えられます。{alterColumnFn}と{staticColumnFn}もご参照ください。", "xpack.canvas.functions.markdown.args.contentHelpText": "{MARKDOWN} を含むテキストの文字列です。連結させるには、{stringFn} 関数を複数回渡します。", "xpack.canvas.functions.markdown.args.fontHelpText": "コンテンツの {CSS} フォントプロパティです。たとえば、{fontFamily} または {fontWeight} です。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "新しいタブでリンクを開くためのtrue/false値。デフォルト値は「false」です。「true」に設定するとすべてのリンクが新しいタブで開くようになります。", "xpack.canvas.functions.markdownHelpText": "{MARKDOWN} テキストをレンダリングするエレメントを追加します。ヒント:単一の数字、メトリック、テキストの段落には {markdownFn} 関数を使います。", - "xpack.canvas.functions.math.args.expressionHelpText": "評価された {TINYMATH} 表現です。{TINYMATH_URL} をご覧ください。", - "xpack.canvas.functions.math.emptyDatatableErrorMessage": "空のデータベース", - "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空の表現", - "xpack.canvas.functions.math.executionFailedErrorMessage": "数式の実行に失敗しました。列名を確認してください", - "xpack.canvas.functions.math.tooManyResultsErrorMessage": "式は 1 つの数字を返す必要があります。表現を {mean} または {sum} で囲んでみてください", - "xpack.canvas.functions.mathHelpText": "{TYPE_NUMBER}または{DATATABLE}を{CONTEXT}として使用して、{TINYMATH}数式を解釈します。{DATATABLE}列は列名で表示されます。{CONTEXT}が数字の場合は、{value}と表示されます。", "xpack.canvas.functions.metric.args.labelFontHelpText": "ラベルの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "メトリックを説明するテキストです。", "xpack.canvas.functions.metric.args.metricFontHelpText": "メトリックの {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1eee5f0d2f4dc..1986f67909742 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5960,19 +5960,10 @@ "xpack.canvas.functions.ltHelpText": "返回 {CONTEXT} 是否小于参数。", "xpack.canvas.functions.mapCenter.args.latHelpText": "地图中心的纬度", "xpack.canvas.functions.mapCenterHelpText": "返回包含地图中心坐标和缩放级别的对象。", - "xpack.canvas.functions.mapColumn.args.expressionHelpText": "作为单行 {DATATABLE} 传递到每一行的 {CANVAS} 表达式。", - "xpack.canvas.functions.mapColumn.args.nameHelpText": "结果列的名称。", - "xpack.canvas.functions.mapColumnHelpText": "添加计算为其他列的结果的列。只有提供参数时,才会执行更改。另请参见 {alterColumnFn} 和 {staticColumnFn}。", "xpack.canvas.functions.markdown.args.contentHelpText": "包含 {MARKDOWN} 的文本字符串。要进行串联,请多次传递 {stringFn} 函数。", "xpack.canvas.functions.markdown.args.fontHelpText": "内容的 {CSS} 字体属性。例如 {fontFamily} 或 {fontWeight}。", "xpack.canvas.functions.markdown.args.openLinkHelpText": "用于在新标签页中打开链接的 true 或 false 值。默认值为 `false`。设置为 `true` 时将在新标签页中打开所有链接。", "xpack.canvas.functions.markdownHelpText": "添加呈现 {MARKDOWN} 文本的元素。提示:将 {markdownFn} 函数用于单个数字、指标和文本段落。", - "xpack.canvas.functions.math.args.expressionHelpText": "已计算的 {TINYMATH} 表达式。请参阅 {TINYMATH_URL}。", - "xpack.canvas.functions.math.emptyDatatableErrorMessage": "空数据表", - "xpack.canvas.functions.math.emptyExpressionErrorMessage": "空表达式", - "xpack.canvas.functions.math.executionFailedErrorMessage": "无法执行数学表达式。检查您的列名称", - "xpack.canvas.functions.math.tooManyResultsErrorMessage": "表达式必须返回单个数字。尝试将您的表达式包装在 {mean} 或 {sum} 中", - "xpack.canvas.functions.mathHelpText": "使用 {TYPE_NUMBER} 或 {DATATABLE} 作为 {CONTEXT} 来解释 {TINYMATH} 数学表达式。{DATATABLE} 列按列名使用。如果 {CONTEXT} 是数字,则作为 {value} 使用。", "xpack.canvas.functions.metric.args.labelFontHelpText": "标签的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", "xpack.canvas.functions.metric.args.labelHelpText": "描述指标的文本。", "xpack.canvas.functions.metric.args.metricFontHelpText": "指标的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。",