[Expressions] [Lens] Add id and copyMetaFrom arg to mapColumn fn + add configurable onError argument to math fn (#90481)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2021-02-25 09:43:15 +01:00 committed by GitHub
parent 27f6a3b3e7
commit 019473948f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 706 additions and 365 deletions

View file

@ -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]

View file

@ -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';

View file

@ -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<boolean | number | string | null>;
copyMetaFrom?: string | null;
}
export const mapColumn: ExpressionFunctionDefinition<
'mapColumn',
Datatable,
MapColumnArguments,
Promise<Datatable>
> = {
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;
});
},
};

View file

@ -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<string, ReturnColumns[]> {
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;
}
}
},
};

View file

@ -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<Datatable>;
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);
});
});
});
});

View file

@ -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<unknown>(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')
);
});
});
});

View file

@ -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 = <ContextType = object | null>(
spec: AnyExpressionFunctionDefinition
) => {
const defaultArgs = mapValues(spec.args, (argSpec) => argSpec.default);
return (
context: object | null,
context: ContextType,
args: Record<string, any> = {},
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 };

View file

@ -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,

View file

@ -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);
});
});
});
});

View file

@ -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<boolean | number | string | null>;
}
export function mapColumn(): ExpressionFunctionDefinition<
'mapColumn',
Datatable,
Arguments,
Promise<Datatable>
> {
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;
});
},
};
}

View file

@ -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;
}
}
},
};
}

View file

@ -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<FunctionFactory<typeof mapColumn>> = {
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,
},
}),
},
};

View file

@ -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<FunctionFactory<typeof math>> = {
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',
})
),
};

View file

@ -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,

View file

@ -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,

View file

@ -5944,19 +5944,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}。",

View file

@ -5955,19 +5955,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}。",