-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Expressions] [Lens] Add id and copyMetaFrom arg to mapColumn fn + add configurable onError argument to math fn #90481
Changes from 19 commits
2fc8e67
2f1e324
744f39a
b00b9dc
96c3aae
241aa03
b89934f
4c8fd8c
75fecc8
abbdbbc
7eb7ebd
804507a
88a7d36
0ba2ce9
0ac1f27
eb841bc
2579d0e
a5c8bdf
6bfa823
a6cb2d0
7cdf96f
0f601c0
b8aff19
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
}); | ||
}, | ||
}; |
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).", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should specify valid values instead of just being of type string (to allow auto-complete) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried that as first option, but could not find a way to map a string union to a specific type for expressions. All the examples I check in the documentation have the |
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
throw errors.emptyDatatable(); | ||
} else { | ||
throw e; | ||
} | ||
} | ||
}, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It needs to extends the return type to cover the
onError
returning values