Skip to content

Commit

Permalink
[Expressions] [Lens] Add id and copyMetaFrom arg to mapColumn fn + ad…
Browse files Browse the repository at this point in the history
…d configurable onError argument to math fn (#90481) (#92810)

Co-authored-by: Kibana Machine <[email protected]>

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
dej611 and kibanamachine authored Feb 25, 2021
1 parent 31dc410 commit 7592e8c
Show file tree
Hide file tree
Showing 17 changed files with 706 additions and 365 deletions.
19 changes: 18 additions & 1 deletion docs/canvas/canvas-function-reference.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +27,8 @@ export const functionSpecs: AnyExpressionFunctionDefinition[] = [
cumulativeSum,
derivative,
movingAverage,
mapColumn,
math,
];

export * from './clog';
Expand All @@ -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';
Original file line number Diff line number Diff line change
@@ -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;
});
},
};
167 changes: 167 additions & 0 deletions src/plugins/expressions/common/expression_functions/specs/math.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
},
};
Loading

0 comments on commit 7592e8c

Please sign in to comment.