[expressions] decrease bundle size (#114229)

This commit is contained in:
Peter Pisljar 2021-10-20 13:56:37 +02:00 committed by GitHub
parent 109e966a7a
commit 73a0fc0948
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 877 additions and 675 deletions

View file

@ -19,11 +19,7 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import {
ExpressionsStart,
ReactExpressionRenderer,
ExpressionsInspectorAdapter,
} from '../../../src/plugins/expressions/public';
import { ExpressionsStart } from '../../../src/plugins/expressions/public';
import { ExpressionEditor } from './editor/expression_editor';
import { UiActionsStart } from '../../../src/plugins/ui_actions/public';
import { NAVIGATE_TRIGGER_ID } from './actions/navigate_trigger';
@ -42,10 +38,6 @@ export function ActionsExpressionsExample({ expressions, actions }: Props) {
updateExpression(value);
};
const inspectorAdapters = {
expression: new ExpressionsInspectorAdapter(),
};
const handleEvents = (event: any) => {
if (event.name !== 'NAVIGATE') return;
// enrich event context with some extra data
@ -83,10 +75,9 @@ export function ActionsExpressionsExample({ expressions, actions }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel paddingSize="none" role="figure">
<ReactExpressionRenderer
<expressions.ReactExpressionRenderer
expression={expression}
debug={true}
inspectorAdapters={inspectorAdapters}
onEvent={handleEvents}
renderError={(message: any) => {
return <div>{message}</div>;

View file

@ -19,11 +19,7 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import {
ExpressionsStart,
ReactExpressionRenderer,
ExpressionsInspectorAdapter,
} from '../../../src/plugins/expressions/public';
import { ExpressionsStart } from '../../../src/plugins/expressions/public';
import { ExpressionEditor } from './editor/expression_editor';
import { UiActionsStart } from '../../../src/plugins/ui_actions/public';
@ -45,10 +41,6 @@ export function ActionsExpressionsExample2({ expressions, actions }: Props) {
updateExpression(value);
};
const inspectorAdapters = {
expression: new ExpressionsInspectorAdapter(),
};
const handleEvents = (event: any) => {
updateVariables({ color: event.data.href === 'http://www.google.com' ? 'red' : 'blue' });
};
@ -81,11 +73,10 @@ export function ActionsExpressionsExample2({ expressions, actions }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel paddingSize="none" role="figure">
<ReactExpressionRenderer
<expressions.ReactExpressionRenderer
data-test-subj="expressionsVariablesTestRenderer"
expression={expression}
debug={true}
inspectorAdapters={inspectorAdapters}
variables={variables}
onEvent={handleEvents}
renderError={(message: any) => {

View file

@ -20,11 +20,7 @@ import {
EuiTitle,
EuiButton,
} from '@elastic/eui';
import {
ExpressionsStart,
ReactExpressionRenderer,
ExpressionsInspectorAdapter,
} from '../../../src/plugins/expressions/public';
import { ExpressionsStart } from '../../../src/plugins/expressions/public';
import { ExpressionEditor } from './editor/expression_editor';
import { Start as InspectorStart } from '../../../src/plugins/inspector/public';
@ -42,9 +38,7 @@ export function RenderExpressionsExample({ expressions, inspector }: Props) {
updateExpression(value);
};
const inspectorAdapters = {
expression: new ExpressionsInspectorAdapter(),
};
const inspectorAdapters = {};
return (
<EuiPageBody>
@ -83,10 +77,12 @@ export function RenderExpressionsExample({ expressions, inspector }: Props) {
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel data-test-subj="expressionRender" paddingSize="none" role="figure">
<ReactExpressionRenderer
<expressions.ReactExpressionRenderer
expression={expression}
debug={true}
inspectorAdapters={inspectorAdapters}
onData$={(result, panels) => {
Object.assign(inspectorAdapters, panels);
}}
renderError={(message: any) => {
return <div>{message}</div>;
}}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { pluck } from 'rxjs/operators';
import {
EuiCodeBlock,
@ -22,12 +22,9 @@ import {
EuiTitle,
EuiButton,
} from '@elastic/eui';
import {
ExpressionsStart,
ExpressionsInspectorAdapter,
} from '../../../src/plugins/expressions/public';
import { ExpressionsStart } from '../../../src/plugins/expressions/public';
import { ExpressionEditor } from './editor/expression_editor';
import { Start as InspectorStart } from '../../../src/plugins/inspector/public';
import { Adapters, Start as InspectorStart } from '../../../src/plugins/inspector/public';
interface Props {
expressions: ExpressionsStart;
@ -37,25 +34,24 @@ interface Props {
export function RunExpressionsExample({ expressions, inspector }: Props) {
const [expression, updateExpression] = useState('markdownVis "## expressions explorer"');
const [result, updateResult] = useState<unknown>({});
const [inspectorAdapters, updateInspectorAdapters] = useState<Adapters>({});
const expressionChanged = (value: string) => {
updateExpression(value);
};
const inspectorAdapters = useMemo(
() => ({
expression: new ExpressionsInspectorAdapter(),
}),
[]
);
useEffect(() => {
const execution = expressions.execute(expression, null, {
debug: true,
inspectorAdapters,
});
const subscription = execution.getData().pipe(pluck('result')).subscribe(updateResult);
const subscription = execution
.getData()
.pipe(pluck('result'))
.subscribe((data) => {
updateResult(data);
updateInspectorAdapters(execution.inspect());
});
execution.inspect();
return () => subscription.unsubscribe();
}, [expression, expressions, inspectorAdapters]);

View file

@ -14,7 +14,6 @@ import {
EsaggsExpressionFunctionDefinition,
EsaggsStartDependencies,
getEsaggsMeta,
handleEsaggsRequest,
} from '../../../common/search/expressions';
import { DataPublicPluginStart, DataStartDependencies } from '../../types';
@ -48,10 +47,12 @@ export function getFunctionDefinition({
);
aggConfigs.hierarchical = args.metricsAtAllLevels;
return { aggConfigs, indexPattern, searchSource, getNow };
const { handleEsaggsRequest } = await import('../../../common/search/expressions');
return { aggConfigs, indexPattern, searchSource, getNow, handleEsaggsRequest };
}).pipe(
switchMap(({ aggConfigs, indexPattern, searchSource, getNow }) =>
handleEsaggsRequest({
switchMap(({ aggConfigs, indexPattern, searchSource, getNow, handleEsaggsRequest }) => {
return handleEsaggsRequest({
abortSignal,
aggs: aggConfigs,
filters: get(input, 'filters', undefined),
@ -65,8 +66,8 @@ export function getFunctionDefinition({
timeRange: get(input, 'timeRange', undefined),
getNow,
executionContext: getExecutionContext(),
})
)
});
})
);
},
});

View file

@ -11,6 +11,7 @@ import { catchError } from 'rxjs/operators';
import { Execution, ExecutionResult } from './execution';
import { ExpressionValueError } from '../expression_types/specs';
import { ExpressionAstExpression } from '../ast';
import { Adapters } from '../../../inspector/common/adapters';
/**
* `ExecutionContract` is a wrapper around `Execution` class. It provides the
@ -75,5 +76,5 @@ export class ExecutionContract<Input = unknown, Output = unknown, InspectorAdapt
* Get Inspector adapters provided to all functions of expression through
* execution context.
*/
inspect = () => this.execution.inspectorAdapters;
inspect = (): Adapters => this.execution.inspectorAdapters;
}

View file

@ -14,6 +14,7 @@ import type { KibanaExecutionContext } from 'src/core/public';
import { Datatable, ExpressionType } from '../expression_types';
import { Adapters, RequestAdapter } from '../../../inspector/common';
import { TablesAdapter } from '../util/tables_adapter';
import { ExpressionsInspectorAdapter } from '../util';
/**
* `ExecutionContext` is an object available to all functions during a single execution;
@ -79,7 +80,8 @@ export interface ExecutionContext<
/**
* Default inspector adapters created if inspector adapters are not set explicitly.
*/
export interface DefaultInspectorAdapters extends Adapters {
export interface DefaultInspectorAdapters {
requests: RequestAdapter;
tables: TablesAdapter;
expression: ExpressionsInspectorAdapter;
}

View file

@ -9,7 +9,6 @@
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition } from '../types';
import { Datatable } from '../../expression_types';
import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
export interface CumulativeSumArgs {
by?: string[];
@ -22,7 +21,7 @@ export type ExpressionFunctionCumulativeSum = ExpressionFunctionDefinition<
'cumulative_sum',
Datatable,
CumulativeSumArgs,
Datatable
Promise<Datatable>
>;
/**
@ -94,37 +93,8 @@ export const cumulativeSum: ExpressionFunctionCumulativeSum = {
},
},
fn(input, { by, inputColumnId, outputColumnId, outputColumnName }) {
const resultColumns = buildResultColumns(
input,
outputColumnId,
inputColumnId,
outputColumnName
);
if (!resultColumns) {
return input;
}
const accumulators: Partial<Record<string, number>> = {};
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
const accumulatorValue = accumulators[bucketIdentifier] ?? 0;
const currentValue = newRow[inputColumnId];
if (currentValue != null) {
newRow[outputColumnId] = Number(currentValue) + accumulatorValue;
accumulators[bucketIdentifier] = newRow[outputColumnId];
} else {
newRow[outputColumnId] = accumulatorValue;
}
return newRow;
}),
};
async fn(input, args) {
const { cumulativeSumFn } = await import('./cumulative_sum_fn');
return cumulativeSumFn(input, args);
},
};

View file

@ -0,0 +1,43 @@
/*
* 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 { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
import { CumulativeSumArgs } from './cumulative_sum';
export const cumulativeSumFn = (
input: Datatable,
{ by, inputColumnId, outputColumnId, outputColumnName }: CumulativeSumArgs
): Datatable => {
const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName);
if (!resultColumns) {
return input;
}
const accumulators: Partial<Record<string, number>> = {};
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
const accumulatorValue = accumulators[bucketIdentifier] ?? 0;
const currentValue = newRow[inputColumnId];
if (currentValue != null) {
newRow[outputColumnId] = Number(currentValue) + accumulatorValue;
accumulators[bucketIdentifier] = newRow[outputColumnId];
} else {
newRow[outputColumnId] = accumulatorValue;
}
return newRow;
}),
};
};

View file

@ -9,7 +9,6 @@
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition } from '../types';
import { Datatable } from '../../expression_types';
import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
export interface DerivativeArgs {
by?: string[];
@ -22,7 +21,7 @@ export type ExpressionFunctionDerivative = ExpressionFunctionDefinition<
'derivative',
Datatable,
DerivativeArgs,
Datatable
Promise<Datatable>
>;
/**
@ -95,43 +94,8 @@ export const derivative: ExpressionFunctionDerivative = {
},
},
fn(input, { by, inputColumnId, outputColumnId, outputColumnName }) {
const resultColumns = buildResultColumns(
input,
outputColumnId,
inputColumnId,
outputColumnName
);
if (!resultColumns) {
return input;
}
const previousValues: Partial<Record<string, number>> = {};
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
const previousValue = previousValues[bucketIdentifier];
const currentValue = newRow[inputColumnId];
if (currentValue != null && previousValue != null) {
newRow[outputColumnId] = Number(currentValue) - previousValue;
} else {
newRow[outputColumnId] = undefined;
}
if (currentValue != null) {
previousValues[bucketIdentifier] = Number(currentValue);
} else {
previousValues[bucketIdentifier] = undefined;
}
return newRow;
}),
};
async fn(input, args) {
const { derivativeFn } = await import('./derivative_fn');
return derivativeFn(input, args);
},
};

View file

@ -0,0 +1,77 @@
/*
* 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 { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
import { DerivativeArgs } from './derivative';
/**
* Calculates the derivative of a specified column in the data table.
*
* Also supports multiple series in a single data table - use the `by` argument
* to specify the columns to split the calculation by.
* For each unique combination of all `by` columns a separate derivative will be calculated.
* The order of rows won't be changed - this function is not modifying any existing columns, it's only
* adding the specified `outputColumnId` column to every row of the table without adding or removing rows.
*
* Behavior:
* * Will write the derivative of `inputColumnId` into `outputColumnId`
* * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId`
* * Derivative always start with an undefined value for the first row of a series, a cell will contain its own value minus the
* value of the previous cell of the same series.
*
* Edge cases:
* * Will return the input table if `inputColumnId` does not exist
* * Will throw an error if `outputColumnId` exists already in provided data table
* * If there is no previous row of the current series with a non `null` or `undefined` value, the output cell of the current row
* will be set to `undefined`.
* * If the row value contains `null` or `undefined`, it will be ignored and the output cell will be set to `undefined`
* * If the value of the previous row of the same series contains `null` or `undefined`, the output cell of the current row will be set to `undefined` as well
* * For all values besides `null` and `undefined`, the value will be cast to a number before it's used in the
* calculation of the current series even if this results in `NaN` (like in case of objects).
* * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings
* before comparison. If the values are objects, the return value of their `toString` method will be used for comparison.
* Missing values (`null` and `undefined`) will be treated as empty strings.
*/
export const derivativeFn = (
input: Datatable,
{ by, inputColumnId, outputColumnId, outputColumnName }: DerivativeArgs
): Datatable => {
const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName);
if (!resultColumns) {
return input;
}
const previousValues: Partial<Record<string, number>> = {};
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
const previousValue = previousValues[bucketIdentifier];
const currentValue = newRow[inputColumnId];
if (currentValue != null && previousValue != null) {
newRow[outputColumnId] = Number(currentValue) - previousValue;
} else {
newRow[outputColumnId] = undefined;
}
if (currentValue != null) {
previousValues[bucketIdentifier] = Number(currentValue);
} else {
previousValues[bucketIdentifier] = undefined;
}
return newRow;
}),
};
};

View file

@ -6,11 +6,9 @@
* Side Public License, v 1.
*/
import { map, zipObject, isString } from 'lodash';
import { i18n } from '@kbn/i18n';
import { evaluate } from '@kbn/tinymath';
import { ExpressionFunctionDefinition } from '../types';
import { Datatable, isDatatable } from '../../expression_types';
import { Datatable } from '../../expression_types';
export type MathArguments = {
expression: string;
@ -23,63 +21,11 @@ const TINYMATH = '`TinyMath`';
const TINYMATH_URL =
'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html';
function pivotObjectArray<
RowType extends { [key: string]: unknown },
ReturnColumns extends keyof RowType & string
>(rows: RowType[], columns?: 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) as { [K in ReturnColumns]: Array<RowType[K]> };
}
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
Promise<boolean | number | null>
> = {
name: 'math',
type: undefined,
@ -121,47 +67,8 @@ export const math: ExpressionFunctionDefinition<
}),
},
},
fn: (input, args) => {
const { expression, onError } = args;
const onErrorValue = onError ?? 'throw';
if (!expression || expression.trim() === '') {
throw errors.emptyExpression();
}
// Use unique ID if available, otherwise fall back to names
const mathContext = isDatatable(input)
? pivotObjectArray(
input.rows,
input.columns.map((col) => col.id)
)
: { 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;
}
}
fn: async (input, args) => {
const { mathFn } = await import('./math_fn');
return mathFn(input, args);
},
};

View file

@ -21,7 +21,7 @@ export const mathColumn: ExpressionFunctionDefinition<
'mathColumn',
Datatable,
MathColumnArguments,
Datatable
Promise<Datatable>
> = {
name: 'mathColumn',
type: 'datatable',
@ -63,7 +63,7 @@ export const mathColumn: ExpressionFunctionDefinition<
default: null,
},
},
fn: (input, args, context) => {
fn: async (input, args, context) => {
const columns = [...input.columns];
const existingColumnIndex = columns.findIndex(({ id }) => {
return id === args.id;
@ -76,34 +76,36 @@ export const mathColumn: ExpressionFunctionDefinition<
);
}
const newRows = input.rows.map((row) => {
const result = math.fn(
{
type: 'datatable',
columns: input.columns,
rows: [row],
},
{
expression: args.expression,
onError: args.onError,
},
context
);
if (Array.isArray(result)) {
if (result.length === 1) {
return { ...row, [args.id]: result[0] };
}
throw new Error(
i18n.translate('expressions.functions.mathColumn.arrayValueError', {
defaultMessage: 'Cannot perform math on array values at {name}',
values: { name: args.name },
})
const newRows = await Promise.all(
input.rows.map(async (row) => {
const result = await math.fn(
{
type: 'datatable',
columns: input.columns,
rows: [row],
},
{
expression: args.expression,
onError: args.onError,
},
context
);
}
return { ...row, [args.id]: result };
});
if (Array.isArray(result)) {
if (result.length === 1) {
return { ...row, [args.id]: result[0] };
}
throw new Error(
i18n.translate('expressions.functions.mathColumn.arrayValueError', {
defaultMessage: 'Cannot perform math on array values at {name}',
values: { name: args.name },
})
);
}
return { ...row, [args.id]: result };
})
);
let type: DatatableColumnType = 'null';
if (newRows.length) {
for (const row of newRows) {

View file

@ -0,0 +1,109 @@
/*
* 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, isString } from 'lodash';
import { i18n } from '@kbn/i18n';
import { evaluate } from '@kbn/tinymath';
import { isDatatable } from '../../expression_types';
import { MathArguments, MathInput } from './math';
function pivotObjectArray<
RowType extends { [key: string]: unknown },
ReturnColumns extends keyof RowType & string
>(rows: RowType[], columns?: 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) as { [K in ReturnColumns]: Array<RowType[K]> };
}
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 mathFn = (input: MathInput, args: MathArguments): boolean | number | null => {
const { expression, onError } = args;
const onErrorValue = onError ?? 'throw';
if (!expression || expression.trim() === '') {
throw errors.emptyExpression();
}
// Use unique ID if available, otherwise fall back to names
const mathContext = isDatatable(input)
? pivotObjectArray(
input.rows,
input.columns.map((col) => col.id)
)
: { 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

@ -9,7 +9,6 @@
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition } from '../types';
import { Datatable } from '../../expression_types';
import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
export interface MovingAverageArgs {
by?: string[];
@ -23,7 +22,7 @@ export type ExpressionFunctionMovingAverage = ExpressionFunctionDefinition<
'moving_average',
Datatable,
MovingAverageArgs,
Datatable
Promise<Datatable>
>;
/**
@ -100,43 +99,8 @@ export const movingAverage: ExpressionFunctionMovingAverage = {
},
},
fn(input, { by, inputColumnId, outputColumnId, outputColumnName, window }) {
const resultColumns = buildResultColumns(
input,
outputColumnId,
inputColumnId,
outputColumnName
);
if (!resultColumns) {
return input;
}
const lastNValuesByBucket: Partial<Record<string, number[]>> = {};
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
const lastNValues = lastNValuesByBucket[bucketIdentifier];
const currentValue = newRow[inputColumnId];
if (lastNValues != null && currentValue != null) {
const sum = lastNValues.reduce((acc, current) => acc + current, 0);
newRow[outputColumnId] = sum / lastNValues.length;
} else {
newRow[outputColumnId] = undefined;
}
if (currentValue != null) {
lastNValuesByBucket[bucketIdentifier] = [
...(lastNValues || []),
Number(currentValue),
].slice(-window);
}
return newRow;
}),
};
async fn(input, args) {
const { movingAverageFn } = await import('./moving_average_fn');
return movingAverageFn(input, args);
},
};

View file

@ -0,0 +1,74 @@
/*
* 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 { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
import { MovingAverageArgs } from './moving_average';
/**
* Calculates the moving average of a specified column in the data table.
*
* Also supports multiple series in a single data table - use the `by` argument
* to specify the columns to split the calculation by.
* For each unique combination of all `by` columns a separate moving average will be calculated.
* The order of rows won't be changed - this function is not modifying any existing columns, it's only
* adding the specified `outputColumnId` column to every row of the table without adding or removing rows.
*
* Behavior:
* * Will write the moving average of `inputColumnId` into `outputColumnId`
* * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId`
* * Moving average always starts with an undefined value for the first row of a series. Each next cell will contain sum of the last
* * [window] of values divided by [window] excluding the current bucket.
* If either of window edges moves outside the borders of data series, the window shrinks to include available values only.
*
* Edge cases:
* * Will return the input table if `inputColumnId` does not exist
* * Will throw an error if `outputColumnId` exists already in provided data table
* * If null or undefined value is encountered, skip the current row and do not change the window
* * For all values besides `null` and `undefined`, the value will be cast to a number before it's used in the
* calculation of the current series even if this results in `NaN` (like in case of objects).
* * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings
* before comparison. If the values are objects, the return value of their `toString` method will be used for comparison.
*/
export const movingAverageFn = (
input: Datatable,
{ by, inputColumnId, outputColumnId, outputColumnName, window }: MovingAverageArgs
): Datatable => {
const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName);
if (!resultColumns) {
return input;
}
const lastNValuesByBucket: Partial<Record<string, number[]>> = {};
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
const lastNValues = lastNValuesByBucket[bucketIdentifier];
const currentValue = newRow[inputColumnId];
if (lastNValues != null && currentValue != null) {
const sum = lastNValues.reduce((acc, current) => acc + current, 0);
newRow[outputColumnId] = sum / lastNValues.length;
} else {
newRow[outputColumnId] = undefined;
}
if (currentValue != null) {
lastNValuesByBucket[bucketIdentifier] = [
...(lastNValues || []),
Number(currentValue),
].slice(-window);
}
return newRow;
}),
};
};

View file

@ -9,7 +9,6 @@
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition } from '../types';
import { Datatable } from '../../expression_types';
import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
export interface OverallMetricArgs {
by?: string[];
@ -23,17 +22,9 @@ export type ExpressionFunctionOverallMetric = ExpressionFunctionDefinition<
'overall_metric',
Datatable,
OverallMetricArgs,
Datatable
Promise<Datatable>
>;
function getValueAsNumberArray(value: unknown) {
if (Array.isArray(value)) {
return value.map((innerVal) => Number(innerVal));
} else {
return [Number(value)];
}
}
/**
* Calculates the overall metric of a specified column in the data table.
*
@ -109,73 +100,8 @@ export const overallMetric: ExpressionFunctionOverallMetric = {
},
},
fn(input, { by, inputColumnId, outputColumnId, outputColumnName, metric }) {
const resultColumns = buildResultColumns(
input,
outputColumnId,
inputColumnId,
outputColumnName
);
if (!resultColumns) {
return input;
}
const accumulators: Partial<Record<string, number>> = {};
const valueCounter: Partial<Record<string, number>> = {};
input.rows.forEach((row) => {
const bucketIdentifier = getBucketIdentifier(row, by);
const accumulatorValue = accumulators[bucketIdentifier];
const currentValue = row[inputColumnId];
if (currentValue != null) {
const currentNumberValues = getValueAsNumberArray(currentValue);
switch (metric) {
case 'average':
valueCounter[bucketIdentifier] =
(valueCounter[bucketIdentifier] ?? 0) + currentNumberValues.length;
case 'sum':
accumulators[bucketIdentifier] = currentNumberValues.reduce(
(a, b) => a + b,
accumulatorValue || 0
);
break;
case 'min':
if (typeof accumulatorValue !== 'undefined') {
accumulators[bucketIdentifier] = Math.min(accumulatorValue, ...currentNumberValues);
} else {
accumulators[bucketIdentifier] = Math.min(...currentNumberValues);
}
break;
case 'max':
if (typeof accumulatorValue !== 'undefined') {
accumulators[bucketIdentifier] = Math.max(accumulatorValue, ...currentNumberValues);
} else {
accumulators[bucketIdentifier] = Math.max(...currentNumberValues);
}
break;
}
}
});
if (metric === 'average') {
Object.keys(accumulators).forEach((bucketIdentifier) => {
const accumulatorValue = accumulators[bucketIdentifier];
const valueCount = valueCounter[bucketIdentifier];
if (typeof accumulatorValue !== 'undefined' && typeof valueCount !== 'undefined') {
accumulators[bucketIdentifier] = accumulatorValue / valueCount;
}
});
}
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
newRow[outputColumnId] = accumulators[bucketIdentifier];
return newRow;
}),
};
async fn(input, args) {
const { overallMetricFn } = await import('./overall_metric_fn');
return overallMetricFn(input, args);
},
};

View file

@ -0,0 +1,113 @@
/*
* 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 { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers';
import { OverallMetricArgs } from './overall_metric';
function getValueAsNumberArray(value: unknown) {
if (Array.isArray(value)) {
return value.map((innerVal) => Number(innerVal));
} else {
return [Number(value)];
}
}
/**
* Calculates the overall metric of a specified column in the data table.
*
* Also supports multiple series in a single data table - use the `by` argument
* to specify the columns to split the calculation by.
* For each unique combination of all `by` columns a separate overall metric will be calculated.
* The order of rows won't be changed - this function is not modifying any existing columns, it's only
* adding the specified `outputColumnId` column to every row of the table without adding or removing rows.
*
* Behavior:
* * Will write the overall metric of `inputColumnId` into `outputColumnId`
* * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId`
* * Each cell will contain the calculated metric based on the values of all cells belonging to the current series.
*
* Edge cases:
* * Will return the input table if `inputColumnId` does not exist
* * Will throw an error if `outputColumnId` exists already in provided data table
* * If the row value contains `null` or `undefined`, it will be ignored and overwritten with the overall metric of
* all cells of the same series.
* * For all values besides `null` and `undefined`, the value will be cast to a number before it's added to the
* overall metric of the current series - if this results in `NaN` (like in case of objects), all cells of the
* current series will be set to `NaN`.
* * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings
* before comparison. If the values are objects, the return value of their `toString` method will be used for comparison.
* Missing values (`null` and `undefined`) will be treated as empty strings.
*/
export const overallMetricFn = (
input: Datatable,
{ by, inputColumnId, outputColumnId, outputColumnName, metric }: OverallMetricArgs
): Datatable => {
const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName);
if (!resultColumns) {
return input;
}
const accumulators: Partial<Record<string, number>> = {};
const valueCounter: Partial<Record<string, number>> = {};
input.rows.forEach((row) => {
const bucketIdentifier = getBucketIdentifier(row, by);
const accumulatorValue = accumulators[bucketIdentifier];
const currentValue = row[inputColumnId];
if (currentValue != null) {
const currentNumberValues = getValueAsNumberArray(currentValue);
switch (metric) {
case 'average':
valueCounter[bucketIdentifier] =
(valueCounter[bucketIdentifier] ?? 0) + currentNumberValues.length;
case 'sum':
accumulators[bucketIdentifier] = currentNumberValues.reduce(
(a, b) => a + b,
accumulatorValue || 0
);
break;
case 'min':
if (typeof accumulatorValue !== 'undefined') {
accumulators[bucketIdentifier] = Math.min(accumulatorValue, ...currentNumberValues);
} else {
accumulators[bucketIdentifier] = Math.min(...currentNumberValues);
}
break;
case 'max':
if (typeof accumulatorValue !== 'undefined') {
accumulators[bucketIdentifier] = Math.max(accumulatorValue, ...currentNumberValues);
} else {
accumulators[bucketIdentifier] = Math.max(...currentNumberValues);
}
break;
}
}
});
if (metric === 'average') {
Object.keys(accumulators).forEach((bucketIdentifier) => {
const accumulatorValue = accumulators[bucketIdentifier];
const valueCount = valueCounter[bucketIdentifier];
if (typeof accumulatorValue !== 'undefined' && typeof valueCount !== 'undefined') {
accumulators[bucketIdentifier] = accumulatorValue / valueCount;
}
});
}
return {
...input,
columns: resultColumns,
rows: input.rows.map((row) => {
const newRow = { ...row };
const bucketIdentifier = getBucketIdentifier(row, by);
newRow[outputColumnId] = accumulators[bucketIdentifier];
return newRow;
}),
};
};

View file

@ -14,10 +14,10 @@ import { Datatable } from '../../../expression_types/specs/datatable';
describe('interpreter/functions#cumulative_sum', () => {
const fn = functionWrapper(cumulativeSum);
const runFn = (input: Datatable, args: CumulativeSumArgs) =>
fn(input, args, {} as ExecutionContext) as Datatable;
fn(input, args, {} as ExecutionContext) as Promise<Datatable>;
it('calculates cumulative sum', () => {
const result = runFn(
it('calculates cumulative sum', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -33,8 +33,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([5, 12, 15, 17]);
});
it('replaces null or undefined data with zeroes until there is real data', () => {
const result = runFn(
it('replaces null or undefined data with zeroes until there is real data', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -50,8 +50,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([0, 0, 0, 1]);
});
it('calculates cumulative sum for multiple series', () => {
const result = runFn(
it('calculates cumulative sum for multiple series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -84,8 +84,8 @@ describe('interpreter/functions#cumulative_sum', () => {
]);
});
it('treats missing split column as separate series', () => {
const result = runFn(
it('treats missing split column as separate series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -117,8 +117,8 @@ describe('interpreter/functions#cumulative_sum', () => {
]);
});
it('treats null like undefined and empty string for split columns', () => {
const result = runFn(
it('treats null like undefined and empty string for split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -152,8 +152,8 @@ describe('interpreter/functions#cumulative_sum', () => {
]);
});
it('calculates cumulative sum for multiple series by multiple split columns', () => {
const result = runFn(
it('calculates cumulative sum for multiple series by multiple split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -177,8 +177,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([1, 2, 3, 1 + 4, 5, 6, 7, 7 + 8]);
});
it('splits separate series by the string representation of the cell values', () => {
const result = runFn(
it('splits separate series by the string representation of the cell values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -198,8 +198,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([1, 1 + 2, 10, 21]);
});
it('casts values to number before calculating cumulative sum', () => {
const result = runFn(
it('casts values to number before calculating cumulative sum', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -210,8 +210,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([5, 12, 15, 17]);
});
it('casts values to number before calculating cumulative sum for NaN like values', () => {
const result = runFn(
it('casts values to number before calculating cumulative sum for NaN like values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -222,8 +222,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([5, 12, NaN, NaN]);
});
it('skips undefined and null values', () => {
const result = runFn(
it('skips undefined and null values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -244,8 +244,8 @@ describe('interpreter/functions#cumulative_sum', () => {
expect(result.rows.map((row) => row.output)).toEqual([0, 7, 7, 7, 7, 7, 10, 12, 12]);
});
it('copies over meta information from the source column', () => {
const result = runFn(
it('copies over meta information from the source column', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -286,8 +286,8 @@ describe('interpreter/functions#cumulative_sum', () => {
});
});
it('sets output name on output column if specified', () => {
const result = runFn(
it('sets output name on output column if specified', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -310,7 +310,7 @@ describe('interpreter/functions#cumulative_sum', () => {
});
});
it('returns source table if input column does not exist', () => {
it('returns source table if input column does not exist', async () => {
const input: Datatable = {
type: 'datatable',
columns: [
@ -324,11 +324,13 @@ describe('interpreter/functions#cumulative_sum', () => {
],
rows: [{ val: 5 }],
};
expect(runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe(input);
expect(await runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe(
input
);
});
it('throws an error if output column exists already', () => {
expect(() =>
it('throws an error if output column exists already', async () => {
await expect(
runFn(
{
type: 'datatable',
@ -345,6 +347,6 @@ describe('interpreter/functions#cumulative_sum', () => {
},
{ inputColumnId: 'val', outputColumnId: 'val' }
)
).toThrow();
).rejects.toBeDefined();
});
});

View file

@ -14,10 +14,10 @@ import { Datatable } from '../../../expression_types/specs/datatable';
describe('interpreter/functions#derivative', () => {
const fn = functionWrapper(derivative);
const runFn = (input: Datatable, args: DerivativeArgs) =>
fn(input, args, {} as ExecutionContext) as Datatable;
fn(input, args, {} as ExecutionContext) as Promise<Datatable>;
it('calculates derivative', () => {
const result = runFn(
it('calculates derivative', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -33,8 +33,8 @@ describe('interpreter/functions#derivative', () => {
expect(result.rows.map((row) => row.output)).toEqual([undefined, 2, -4, -1]);
});
it('skips null or undefined values until there is real data', () => {
const result = runFn(
it('skips null or undefined values until there is real data', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -70,8 +70,8 @@ describe('interpreter/functions#derivative', () => {
]);
});
it('treats 0 as real data', () => {
const result = runFn(
it('treats 0 as real data', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -108,8 +108,8 @@ describe('interpreter/functions#derivative', () => {
]);
});
it('calculates derivative for multiple series', () => {
const result = runFn(
it('calculates derivative for multiple series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -142,8 +142,8 @@ describe('interpreter/functions#derivative', () => {
]);
});
it('treats missing split column as separate series', () => {
const result = runFn(
it('treats missing split column as separate series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -175,8 +175,8 @@ describe('interpreter/functions#derivative', () => {
]);
});
it('treats null like undefined and empty string for split columns', () => {
const result = runFn(
it('treats null like undefined and empty string for split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -210,8 +210,8 @@ describe('interpreter/functions#derivative', () => {
]);
});
it('calculates derivative for multiple series by multiple split columns', () => {
const result = runFn(
it('calculates derivative for multiple series by multiple split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -244,8 +244,8 @@ describe('interpreter/functions#derivative', () => {
]);
});
it('splits separate series by the string representation of the cell values', () => {
const result = runFn(
it('splits separate series by the string representation of the cell values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -265,8 +265,8 @@ describe('interpreter/functions#derivative', () => {
expect(result.rows.map((row) => row.output)).toEqual([undefined, 2 - 1, undefined, 11 - 10]);
});
it('casts values to number before calculating derivative', () => {
const result = runFn(
it('casts values to number before calculating derivative', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -277,8 +277,8 @@ describe('interpreter/functions#derivative', () => {
expect(result.rows.map((row) => row.output)).toEqual([undefined, 7 - 5, 3 - 7, 2 - 3]);
});
it('casts values to number before calculating derivative for NaN like values', () => {
const result = runFn(
it('casts values to number before calculating derivative for NaN like values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -289,8 +289,8 @@ describe('interpreter/functions#derivative', () => {
expect(result.rows.map((row) => row.output)).toEqual([undefined, 7 - 5, NaN, NaN, 5 - 2]);
});
it('copies over meta information from the source column', () => {
const result = runFn(
it('copies over meta information from the source column', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -331,8 +331,8 @@ describe('interpreter/functions#derivative', () => {
});
});
it('sets output name on output column if specified', () => {
const result = runFn(
it('sets output name on output column if specified', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -355,7 +355,7 @@ describe('interpreter/functions#derivative', () => {
});
});
it('returns source table if input column does not exist', () => {
it('returns source table if input column does not exist', async () => {
const input: Datatable = {
type: 'datatable',
columns: [
@ -369,11 +369,13 @@ describe('interpreter/functions#derivative', () => {
],
rows: [{ val: 5 }],
};
expect(runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe(input);
expect(await runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe(
input
);
});
it('throws an error if output column exists already', () => {
expect(() =>
it('throws an error if output column exists already', async () => {
await expect(
runFn(
{
type: 'datatable',
@ -390,6 +392,6 @@ describe('interpreter/functions#derivative', () => {
},
{ inputColumnId: 'val', outputColumnId: 'val' }
)
).toThrow();
).rejects.toBeDefined();
});
});

View file

@ -6,129 +6,163 @@
* Side Public License, v 1.
*/
import { errors, math, MathArguments, MathInput } from '../math';
import { math, MathArguments, MathInput } from '../math';
import { errors } from '../math_fn';
import { emptyTable, functionWrapper, testTable } from './utils';
describe('math', () => {
const fn = functionWrapper(math);
it('evaluates math expressions without reference to context', () => {
expect(fn(null as unknown as MathInput, { expression: '10.5345' })).toBe(10.5345);
expect(fn(null as unknown as MathInput, { expression: '123 + 456' })).toBe(579);
expect(fn(null as unknown as MathInput, { expression: '100 - 46' })).toBe(54);
expect(fn(1, { expression: '100 / 5' })).toBe(20);
expect(fn('foo' as unknown as MathInput, { expression: '100 / 5' })).toBe(20);
expect(fn(true as unknown as MathInput, { expression: '100 / 5' })).toBe(20);
expect(fn(testTable, { expression: '100 * 5' })).toBe(500);
expect(fn(emptyTable, { expression: '100 * 5' })).toBe(500);
it('evaluates math expressions without reference to context', async () => {
expect(await fn(null as unknown as MathInput, { expression: '10.5345' })).toBe(10.5345);
expect(await fn(null as unknown as MathInput, { expression: '123 + 456' })).toBe(579);
expect(await fn(null as unknown as MathInput, { expression: '100 - 46' })).toBe(54);
expect(await fn(1, { expression: '100 / 5' })).toBe(20);
expect(await fn('foo' as unknown as MathInput, { expression: '100 / 5' })).toBe(20);
expect(await fn(true as unknown as MathInput, { expression: '100 / 5' })).toBe(20);
expect(await fn(testTable, { expression: '100 * 5' })).toBe(500);
expect(await fn(emptyTable, { expression: '100 * 5' })).toBe(500);
});
it('evaluates math expressions with reference to the value of the context, must be a number', () => {
expect(fn(-103, { expression: 'abs(value)' })).toBe(103);
it('evaluates math expressions with reference to the value of the context, must be a number', async () => {
expect(await fn(-103, { expression: 'abs(value)' })).toBe(103);
});
it('evaluates math expressions with references to columns by id in a datatable', () => {
expect(fn(testTable, { expression: 'unique(in_stock)' })).toBe(2);
expect(fn(testTable, { expression: 'sum(quantity)' })).toBe(2508);
expect(fn(testTable, { expression: 'mean(price)' })).toBe(320);
expect(fn(testTable, { expression: 'min(price)' })).toBe(67);
expect(fn(testTable, { expression: 'median(quantity)' })).toBe(256);
expect(fn(testTable, { expression: 'max(price)' })).toBe(605);
it('evaluates math expressions with references to columns by id in a datatable', async () => {
expect(await fn(testTable, { expression: 'unique(in_stock)' })).toBe(2);
expect(await fn(testTable, { expression: 'sum(quantity)' })).toBe(2508);
expect(await fn(testTable, { expression: 'mean(price)' })).toBe(320);
expect(await fn(testTable, { expression: 'min(price)' })).toBe(67);
expect(await fn(testTable, { expression: 'median(quantity)' })).toBe(256);
expect(await fn(testTable, { expression: 'max(price)' })).toBe(605);
});
it('does not use the name for math', () => {
expect(() => fn(testTable, { expression: 'unique("in_stock label")' })).toThrow(
'Unknown variable'
it('does not use the name for math', async () => {
await expect(fn(testTable, { expression: 'unique("in_stock label")' })).rejects.toHaveProperty(
'message',
'Unknown variable: in_stock label'
);
expect(() => fn(testTable, { expression: 'sum("quantity label")' })).toThrow(
'Unknown variable'
await expect(fn(testTable, { expression: 'sum("quantity label")' })).rejects.toHaveProperty(
'message',
'Unknown variable: quantity label'
);
expect(() => fn(testTable, { expression: 'mean("price label")' })).toThrow('Unknown variable');
expect(() => fn(testTable, { expression: 'min("price label")' })).toThrow('Unknown variable');
expect(() => fn(testTable, { expression: 'median("quantity label")' })).toThrow(
'Unknown variable'
await expect(fn(testTable, { expression: 'mean("price label")' })).rejects.toHaveProperty(
'message',
'Unknown variable: price label'
);
await expect(fn(testTable, { expression: 'min("price label")' })).rejects.toHaveProperty(
'message',
'Unknown variable: price label'
);
await expect(fn(testTable, { expression: 'median("quantity label")' })).rejects.toHaveProperty(
'message',
'Unknown variable: quantity label'
);
await expect(fn(testTable, { expression: 'max("price label")' })).rejects.toHaveProperty(
'message',
'Unknown variable: price label'
);
expect(() => fn(testTable, { expression: 'max("price label")' })).toThrow('Unknown variable');
});
describe('args', () => {
describe('expression', () => {
it('sets the math expression to be evaluted', () => {
expect(fn(null as unknown as MathInput, { expression: '10' })).toBe(10);
expect(fn(23.23, { expression: 'floor(value)' })).toBe(23);
expect(fn(testTable, { expression: 'count(price)' })).toBe(9);
expect(fn(testTable, { expression: 'count(name)' })).toBe(9);
it('sets the math expression to be evaluted', async () => {
expect(await fn(null as unknown as MathInput, { expression: '10' })).toBe(10);
expect(await fn(23.23, { expression: 'floor(value)' })).toBe(23);
expect(await fn(testTable, { expression: 'count(price)' })).toBe(9);
expect(await 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 invalid expressions', async () => {
expect(await fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0);
expect(await fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null);
expect(await 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);
it('should return the desired fallback value, for division by zero', async () => {
expect(await fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0);
expect(await fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null);
expect(await fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false);
});
});
});
describe('invalid expressions', () => {
it('throws when expression evaluates to an array', () => {
expect(() => fn(testTable, { expression: 'multiply(price, 2)' })).toThrow(
new RegExp(errors.tooManyResults().message.replace(/[()]/g, '\\$&'))
it('throws when expression evaluates to an array', async () => {
await expect(fn(testTable, { expression: 'multiply(price, 2)' })).rejects.toHaveProperty(
'message',
errors.tooManyResults().message
);
});
it('throws when using an unknown context variable', () => {
expect(() => fn(testTable, { expression: 'sum(foo)' })).toThrow('Unknown variable: foo');
});
it('throws when using non-numeric data', () => {
expect(() => fn(testTable, { expression: 'mean(name)' })).toThrow(
new RegExp(errors.executionFailed().message)
);
expect(() => fn(testTable, { expression: 'mean(in_stock)' })).toThrow(
new RegExp(errors.executionFailed().message)
it('throws when using an unknown context variable', async () => {
await expect(fn(testTable, { expression: 'sum(foo)' })).rejects.toHaveProperty(
'message',
'Unknown variable: foo'
);
});
it('throws when missing expression', () => {
expect(() => fn(testTable)).toThrow(new RegExp(errors.emptyExpression().message));
expect(() => fn(testTable, { expession: '' } as unknown as MathArguments)).toThrow(
new RegExp(errors.emptyExpression().message)
it('throws when using non-numeric data', async () => {
await expect(fn(testTable, { expression: 'mean(name)' })).rejects.toHaveProperty(
'message',
errors.executionFailed().message
);
expect(() => fn(testTable, { expession: ' ' } as unknown as MathArguments)).toThrow(
new RegExp(errors.emptyExpression().message)
await expect(fn(testTable, { expression: 'mean(in_stock)' })).rejects.toHaveProperty(
'message',
errors.executionFailed().message
);
});
it('throws when passing a context variable from an empty datatable', () => {
expect(() => fn(emptyTable, { expression: 'mean(foo)' })).toThrow(
new RegExp(errors.emptyDatatable().message)
it('throws when missing expression', async () => {
await expect(fn(testTable)).rejects.toHaveProperty(
'message',
errors.emptyExpression().message
);
await expect(
fn(testTable, { expession: '' } as unknown as MathArguments)
).rejects.toHaveProperty('message', errors.emptyExpression().message);
await expect(
fn(testTable, { expession: ' ' } as unknown as MathArguments)
).rejects.toHaveProperty('message', errors.emptyExpression().message);
});
it('throws when passing a context variable from an empty datatable', async () => {
await expect(() => fn(emptyTable, { expression: 'mean(foo)' })).rejects.toHaveProperty(
'message',
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 not throw when requesting fallback values for invalid expression', async () => {
await expect(
fn(testTable, { expression: 'mean(name)', onError: 'zero' })
).resolves.toBeDefined();
await expect(
fn(testTable, { expression: 'mean(name)', onError: 'false' })
).resolves.toBeDefined();
await expect(
fn(testTable, { expression: 'mean(name)', onError: 'null' })
).resolves.toBeDefined();
});
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 declared in the onError argument', async () => {
await expect(
fn(testTable, { expression: 'mean(name)', onError: 'throw' })
).rejects.toHaveProperty('message', 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')
it('should throw when dividing by zero', async () => {
await expect(fn(testTable, { expression: '1/0', onError: 'throw' })).rejects.toHaveProperty(
'message',
'Cannot divide by 0'
);
});
});

View file

@ -12,14 +12,18 @@ import { functionWrapper, testTable, tableWithNulls } from './utils';
describe('mathColumn', () => {
const fn = functionWrapper(mathColumn);
it('throws if the id is used', () => {
expect(() => fn(testTable, { id: 'price', name: 'price', expression: 'price * 2' })).toThrow(
`ID must be unique`
);
it('throws if the id is used', async () => {
await expect(
fn(testTable, { id: 'price', name: 'price', expression: 'price * 2' })
).rejects.toHaveProperty('message', `ID must be unique`);
});
it('applies math to each row by id', () => {
const result = fn(testTable, { id: 'output', name: 'output', expression: 'quantity * price' });
it('applies math to each row by id', async () => {
const result = await fn(testTable, {
id: 'output',
name: 'output',
expression: 'quantity * price',
});
expect(result.columns).toEqual([
...testTable.columns,
{ id: 'output', name: 'output', meta: { params: { id: 'number' }, type: 'number' } },
@ -34,7 +38,7 @@ describe('mathColumn', () => {
});
});
it('extracts a single array value, but not a multi-value array', () => {
it('extracts a single array value, but not a multi-value array', async () => {
const arrayTable = {
...testTable,
rows: [
@ -52,23 +56,24 @@ describe('mathColumn', () => {
name: 'output',
expression: 'quantity',
};
expect(fn(arrayTable, args).rows[0].output).toEqual(100);
expect(() => fn(arrayTable, { ...args, expression: 'price' })).toThrowError(
`Cannot perform math on array values`
expect((await fn(arrayTable, args)).rows[0].output).toEqual(100);
await expect(fn(arrayTable, { ...args, expression: 'price' })).rejects.toHaveProperty(
'message',
`Cannot perform math on array values at output`
);
});
it('handles onError', () => {
it('handles onError', async () => {
const args = {
id: 'output',
name: 'output',
expression: 'quantity / 0',
};
expect(() => fn(testTable, args)).toThrowError(`Cannot divide by 0`);
expect(() => fn(testTable, { ...args, onError: 'throw' })).toThrow();
expect(fn(testTable, { ...args, onError: 'zero' }).rows[0].output).toEqual(0);
expect(fn(testTable, { ...args, onError: 'false' }).rows[0].output).toEqual(false);
expect(fn(testTable, { ...args, onError: 'null' }).rows[0].output).toEqual(null);
await expect(fn(testTable, args)).rejects.toHaveProperty('message', `Cannot divide by 0`);
await expect(fn(testTable, { ...args, onError: 'throw' })).rejects.toBeDefined();
expect((await fn(testTable, { ...args, onError: 'zero' })).rows[0].output).toEqual(0);
expect((await fn(testTable, { ...args, onError: 'false' })).rows[0].output).toEqual(false);
expect((await fn(testTable, { ...args, onError: 'null' })).rows[0].output).toEqual(null);
});
it('should copy over the meta information from the specified column', async () => {
@ -96,9 +101,14 @@ describe('mathColumn', () => {
});
});
it('should correctly infer the type from the first non-null row', () => {
it('should correctly infer the type from the first non-null row', async () => {
expect(
fn(tableWithNulls, { id: 'value', name: 'value', expression: 'price + 2', onError: 'null' })
await fn(tableWithNulls, {
id: 'value',
name: 'value',
expression: 'price + 2',
onError: 'null',
})
).toEqual(
expect.objectContaining({
type: 'datatable',

View file

@ -16,10 +16,10 @@ const defaultArgs = { window: 5, inputColumnId: 'val', outputColumnId: 'output'
describe('interpreter/functions#movingAverage', () => {
const fn = functionWrapper(movingAverage);
const runFn = (input: Datatable, args: MovingAverageArgs) =>
fn(input, args, {} as ExecutionContext) as Datatable;
fn(input, args, {} as ExecutionContext) as Promise<Datatable>;
it('calculates movingAverage', () => {
const result = runFn(
it('calculates movingAverage', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -40,8 +40,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('skips null or undefined values until there is real data', () => {
const result = runFn(
it('skips null or undefined values until there is real data', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -77,8 +77,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('treats 0 as real data', () => {
const result = runFn(
it('treats 0 as real data', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -115,8 +115,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('calculates movingAverage for multiple series', () => {
const result = runFn(
it('calculates movingAverage for multiple series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -149,8 +149,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('treats missing split column as separate series', () => {
const result = runFn(
it('treats missing split column as separate series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -182,8 +182,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('treats null like undefined and empty string for split columns', () => {
const result = runFn(
it('treats null like undefined and empty string for split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -217,8 +217,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('calculates movingAverage for multiple series by multiple split columns', () => {
const result = runFn(
it('calculates movingAverage for multiple series by multiple split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -251,8 +251,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('splits separate series by the string representation of the cell values', () => {
const result = runFn(
it('splits separate series by the string representation of the cell values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -272,8 +272,8 @@ describe('interpreter/functions#movingAverage', () => {
expect(result.rows.map((row) => row.output)).toEqual([undefined, 1, undefined, 10]);
});
it('casts values to number before calculating movingAverage', () => {
const result = runFn(
it('casts values to number before calculating movingAverage', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -289,8 +289,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('skips NaN like values', () => {
const result = runFn(
it('skips NaN like values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -301,8 +301,8 @@ describe('interpreter/functions#movingAverage', () => {
expect(result.rows.map((row) => row.output)).toEqual([undefined, 5, (5 + 7) / 2, NaN, NaN]);
});
it('copies over meta information from the source column', () => {
const result = runFn(
it('copies over meta information from the source column', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -343,8 +343,8 @@ describe('interpreter/functions#movingAverage', () => {
});
});
it('sets output name on output column if specified', () => {
const result = runFn(
it('sets output name on output column if specified', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -367,7 +367,7 @@ describe('interpreter/functions#movingAverage', () => {
});
});
it('returns source table if input column does not exist', () => {
it('returns source table if input column does not exist', async () => {
const input: Datatable = {
type: 'datatable',
columns: [
@ -382,12 +382,12 @@ describe('interpreter/functions#movingAverage', () => {
rows: [{ val: 5 }],
};
expect(
runFn(input, { ...defaultArgs, inputColumnId: 'nonexisting', outputColumnId: 'output' })
await runFn(input, { ...defaultArgs, inputColumnId: 'nonexisting', outputColumnId: 'output' })
).toBe(input);
});
it('throws an error if output column exists already', () => {
expect(() =>
it('throws an error if output column exists already', async () => {
await expect(
runFn(
{
type: 'datatable',
@ -404,11 +404,11 @@ describe('interpreter/functions#movingAverage', () => {
},
{ ...defaultArgs, inputColumnId: 'val', outputColumnId: 'val' }
)
).toThrow();
).rejects.toBeDefined();
});
it('calculates moving average for window equal to 1', () => {
const result = runFn(
it('calculates moving average for window equal to 1', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -441,8 +441,8 @@ describe('interpreter/functions#movingAverage', () => {
]);
});
it('calculates moving average for window bigger than array', () => {
const result = runFn(
it('calculates moving average for window bigger than array', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],

View file

@ -14,10 +14,10 @@ import { overallMetric, OverallMetricArgs } from '../overall_metric';
describe('interpreter/functions#overall_metric', () => {
const fn = functionWrapper(overallMetric);
const runFn = (input: Datatable, args: OverallMetricArgs) =>
fn(input, args, {} as ExecutionContext) as Datatable;
fn(input, args, {} as ExecutionContext) as Promise<Datatable>;
it('ignores null or undefined with sum', () => {
const result = runFn(
it('ignores null or undefined with sum', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -33,8 +33,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([12, 12, 12, 12]);
});
it('ignores null or undefined with average', () => {
const result = runFn(
it('ignores null or undefined with average', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -50,8 +50,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([3, 3, 3, 3, 3]);
});
it('ignores null or undefined with min', () => {
const result = runFn(
it('ignores null or undefined with min', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -67,8 +67,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([1, 1, 1, 1, 1]);
});
it('ignores null or undefined with max', () => {
const result = runFn(
it('ignores null or undefined with max', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -84,8 +84,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([-1, -1, -1, -1, -1]);
});
it('calculates overall sum for multiple series', () => {
const result = runFn(
it('calculates overall sum for multiple series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -118,8 +118,8 @@ describe('interpreter/functions#overall_metric', () => {
]);
});
it('treats missing split column as separate series', () => {
const result = runFn(
it('treats missing split column as separate series', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -142,7 +142,7 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([1, 2, 3, 1, 3, 1, 2, 2]);
});
it('treats null like undefined and empty string for split columns', () => {
it('treats null like undefined and empty string for split columns', async () => {
const table: Datatable = {
type: 'datatable',
columns: [
@ -162,7 +162,7 @@ describe('interpreter/functions#overall_metric', () => {
],
};
const result = runFn(table, {
const result = await runFn(table, {
inputColumnId: 'val',
outputColumnId: 'output',
by: ['split'],
@ -180,7 +180,7 @@ describe('interpreter/functions#overall_metric', () => {
3 + 5 + 7 + 9,
]);
const result2 = runFn(table, {
const result2 = await runFn(table, {
inputColumnId: 'val',
outputColumnId: 'output',
by: ['split'],
@ -188,7 +188,7 @@ describe('interpreter/functions#overall_metric', () => {
});
expect(result2.rows.map((row) => row.output)).toEqual([6, 8, 9, 6, 9, 6, 9, 8, 9]);
const result3 = runFn(table, {
const result3 = await runFn(table, {
inputColumnId: 'val',
outputColumnId: 'output',
by: ['split'],
@ -207,8 +207,8 @@ describe('interpreter/functions#overall_metric', () => {
]);
});
it('handles array values', () => {
const result = runFn(
it('handles array values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -224,8 +224,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([28, 28, 28, 28]);
});
it('takes array values into account for average calculation', () => {
const result = runFn(
it('takes array values into account for average calculation', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -241,7 +241,7 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([3, 3]);
});
it('handles array values for split columns', () => {
it('handles array values for split columns', async () => {
const table: Datatable = {
type: 'datatable',
columns: [
@ -261,7 +261,7 @@ describe('interpreter/functions#overall_metric', () => {
],
};
const result = runFn(table, {
const result = await runFn(table, {
inputColumnId: 'val',
outputColumnId: 'output',
by: ['split'],
@ -279,7 +279,7 @@ describe('interpreter/functions#overall_metric', () => {
3 + 5 + 7 + 9 + 99,
]);
const result2 = runFn(table, {
const result2 = await runFn(table, {
inputColumnId: 'val',
outputColumnId: 'output',
by: ['split'],
@ -288,8 +288,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result2.rows.map((row) => row.output)).toEqual([6, 11, 99, 6, 99, 6, 99, 11, 99]);
});
it('calculates cumulative sum for multiple series by multiple split columns', () => {
const result = runFn(
it('calculates cumulative sum for multiple series by multiple split columns', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -313,8 +313,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([1 + 4, 2, 3, 1 + 4, 5, 6, 7 + 8, 7 + 8]);
});
it('splits separate series by the string representation of the cell values', () => {
const result = runFn(
it('splits separate series by the string representation of the cell values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -334,8 +334,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([1 + 2, 1 + 2, 10 + 11, 10 + 11]);
});
it('casts values to number before calculating cumulative sum', () => {
const result = runFn(
it('casts values to number before calculating cumulative sum', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -346,8 +346,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([7, 7, 7, 7]);
});
it('casts values to number before calculating metric for NaN like values', () => {
const result = runFn(
it('casts values to number before calculating metric for NaN like values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -358,8 +358,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([NaN, NaN, NaN, NaN]);
});
it('skips undefined and null values', () => {
const result = runFn(
it('skips undefined and null values', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }],
@ -380,8 +380,8 @@ describe('interpreter/functions#overall_metric', () => {
expect(result.rows.map((row) => row.output)).toEqual([4, 4, 4, 4, 4, 4, 4, 4, 4]);
});
it('copies over meta information from the source column', () => {
const result = runFn(
it('copies over meta information from the source column', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -422,8 +422,8 @@ describe('interpreter/functions#overall_metric', () => {
});
});
it('sets output name on output column if specified', () => {
const result = runFn(
it('sets output name on output column if specified', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
@ -451,7 +451,7 @@ describe('interpreter/functions#overall_metric', () => {
});
});
it('returns source table if input column does not exist', () => {
it('returns source table if input column does not exist', async () => {
const input: Datatable = {
type: 'datatable',
columns: [
@ -466,12 +466,12 @@ describe('interpreter/functions#overall_metric', () => {
rows: [{ val: 5 }],
};
expect(
runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output', metric: 'sum' })
await runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output', metric: 'sum' })
).toBe(input);
});
it('throws an error if output column exists already', () => {
expect(() =>
it('throws an error if output column exists already', async () => {
await expect(
runFn(
{
type: 'datatable',
@ -488,6 +488,6 @@ describe('interpreter/functions#overall_metric', () => {
},
{ inputColumnId: 'val', outputColumnId: 'val', metric: 'max' }
)
).toThrow();
).rejects.toBeDefined();
});
});

View file

@ -22,20 +22,23 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
// Static exports.
export { ExpressionExecutor, IExpressionLoaderParams, ExpressionRenderError } from './types';
export {
export type {
ExpressionExecutor,
IExpressionLoaderParams,
ExpressionRenderError,
ExpressionRendererEvent,
} from './types';
export type { ExpressionLoader } from './loader';
export type { ExpressionRenderHandler } from './render';
export type {
ExpressionRendererComponent,
ReactExpressionRenderer,
ReactExpressionRendererProps,
ReactExpressionRendererType,
} from './react_expression_renderer';
export { ExpressionRenderHandler, ExpressionRendererEvent } from './render';
export {
export type {
AnyExpressionFunctionDefinition,
AnyExpressionTypeDefinition,
ArgumentType,
buildExpression,
buildExpressionFunction,
Datatable,
DatatableColumn,
DatatableColumnType,
@ -79,17 +82,12 @@ export {
FontStyle,
FontValue,
FontWeight,
format,
formatExpression,
FunctionsRegistry,
IInterpreterRenderHandlers,
InterpreterErrorType,
IRegistry,
isExpressionAstBuilder,
KnownTypeToString,
Overflow,
parse,
parseExpression,
PointSeries,
PointSeriesColumn,
PointSeriesColumnName,
@ -109,6 +107,13 @@ export {
ExpressionsServiceSetup,
ExpressionsServiceStart,
TablesAdapter,
ExpressionsInspectorAdapter,
} from '../common';
export {
buildExpression,
buildExpressionFunction,
formatExpression,
isExpressionAstBuilder,
parseExpression,
createDefaultInspectorAdapters,
} from '../common';

View file

@ -88,8 +88,8 @@ jest.mock('./services', () => {
});
describe('execute helper function', () => {
it('returns ExpressionLoader instance', () => {
const response = loader(element, '', {});
it('returns ExpressionLoader instance', async () => {
const response = await loader(element, '', {});
expect(response).toBeInstanceOf(ExpressionLoader);
});
});

View file

@ -194,10 +194,10 @@ export class ExpressionLoader {
export type IExpressionLoader = (
element: HTMLElement,
expression: string | ExpressionAstExpression,
params: IExpressionLoaderParams
) => ExpressionLoader;
expression?: string | ExpressionAstExpression,
params?: IExpressionLoaderParams
) => Promise<ExpressionLoader>;
export const loader: IExpressionLoader = (element, expression, params) => {
export const loader: IExpressionLoader = async (element, expression?, params?) => {
return new ExpressionLoader(element, expression, params);
};

View file

@ -30,8 +30,6 @@ const createSetupContract = (): Setup => {
const createStartContract = (): Start => {
return {
execute: jest.fn(),
ExpressionLoader: jest.fn(),
ExpressionRenderHandler: jest.fn(),
getFunction: jest.fn(),
getFunctions: jest.fn(),
getRenderer: jest.fn(),
@ -39,8 +37,8 @@ const createStartContract = (): Start => {
getType: jest.fn(),
getTypes: jest.fn(),
loader: jest.fn(),
ReactExpressionRenderer: jest.fn((props) => <></>),
render: jest.fn(),
ReactExpressionRenderer: jest.fn((props) => <></>),
run: jest.fn(),
};
};

View file

@ -8,16 +8,17 @@
import { pick } from 'lodash';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public';
import { ExpressionsServiceSetup, ExpressionsServiceStart } from '../common';
import { SerializableRecord } from '@kbn/utility-types';
import type { ExpressionsServiceSetup, ExpressionsServiceStart } from '../common';
import {
ExpressionsService,
setRenderersRegistry,
setNotifications,
setExpressionsService,
} from './services';
import { ReactExpressionRenderer } from './react_expression_renderer';
import { ExpressionLoader, IExpressionLoader, loader } from './loader';
import { render, ExpressionRenderHandler } from './render';
import { ReactExpressionRenderer } from './react_expression_renderer_wrapper';
import type { IExpressionLoader } from './loader';
import type { IExpressionRenderer } from './render';
/**
* Expressions public setup contract, extends {@link ExpressionsServiceSetup}
@ -28,11 +29,9 @@ export type ExpressionsSetup = ExpressionsServiceSetup;
* Expressions public start contrect, extends {@link ExpressionServiceStart}
*/
export interface ExpressionsStart extends ExpressionsServiceStart {
ExpressionLoader: typeof ExpressionLoader;
ExpressionRenderHandler: typeof ExpressionRenderHandler;
loader: IExpressionLoader;
render: IExpressionRenderer;
ReactExpressionRenderer: typeof ReactExpressionRenderer;
render: typeof render;
}
export class ExpressionsPublicPlugin implements Plugin<ExpressionsSetup, ExpressionsStart> {
@ -66,13 +65,24 @@ export class ExpressionsPublicPlugin implements Plugin<ExpressionsSetup, Express
setNotifications(core.notifications);
const { expressions } = this;
const loader: IExpressionLoader = async (element, expression, params) => {
const { ExpressionLoader } = await import('./loader');
return new ExpressionLoader(element, expression, params);
};
const render: IExpressionRenderer = async (element, data, options) => {
const { ExpressionRenderHandler } = await import('./render');
const handler = new ExpressionRenderHandler(element, options);
handler.render(data as SerializableRecord);
return handler;
};
const start = {
...expressions.start(),
ExpressionLoader,
ExpressionRenderHandler,
loader,
ReactExpressionRenderer,
render,
ReactExpressionRenderer,
};
return Object.freeze(start);

View file

@ -10,13 +10,12 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { Subject } from 'rxjs';
import { share } from 'rxjs/operators';
import { ReactExpressionRenderer } from './react_expression_renderer';
import { default as ReactExpressionRenderer } from './react_expression_renderer';
import { ExpressionLoader } from './loader';
import { mount } from 'enzyme';
import { EuiProgress } from '@elastic/eui';
import { IInterpreterRenderHandlers } from '../common';
import { RenderErrorHandlerFnType } from './types';
import { ExpressionRendererEvent } from './render';
import { RenderErrorHandlerFnType, ExpressionRendererEvent } from './types';
jest.mock('./loader', () => {
return {

View file

@ -13,10 +13,9 @@ import { filter } from 'rxjs/operators';
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
import { EuiLoadingChart, EuiProgress } from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { IExpressionLoaderParams, ExpressionRenderError } from './types';
import { IExpressionLoaderParams, ExpressionRenderError, ExpressionRendererEvent } from './types';
import { ExpressionAstExpression, IInterpreterRenderHandlers } from '../common';
import { ExpressionLoader } from './loader';
import { ExpressionRendererEvent } from './render';
// Accept all options of the runner as props except for the
// dom element which is provided by the component itself
@ -58,7 +57,8 @@ const defaultState: State = {
error: null,
};
export const ReactExpressionRenderer = ({
// eslint-disable-next-line import/no-default-export
export default function ReactExpressionRenderer({
className,
dataAttrs,
padding,
@ -69,7 +69,7 @@ export const ReactExpressionRenderer = ({
reload$,
debounce,
...expressionLoaderOptions
}: ReactExpressionRendererProps) => {
}: ReactExpressionRendererProps) {
const mountpoint: React.MutableRefObject<null | HTMLDivElement> = useRef(null);
const [state, setState] = useState<State>({ ...defaultState });
const hasCustomRenderErrorHandler = !!renderError;
@ -237,4 +237,4 @@ export const ReactExpressionRenderer = ({
/>
</div>
);
};
}

View file

@ -0,0 +1,19 @@
/*
* 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 React, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import type { ReactExpressionRendererProps } from './react_expression_renderer';
const ReactExpressionRendererComponent = lazy(() => import('./react_expression_renderer'));
export const ReactExpressionRenderer = (props: ReactExpressionRendererProps) => (
<Suspense fallback={<EuiLoadingSpinner />}>
<ReactExpressionRendererComponent {...props} />
</Suspense>
);

View file

@ -53,8 +53,8 @@ const getHandledError = () => {
};
describe('render helper function', () => {
it('returns ExpressionRenderHandler instance', () => {
const response = render(element, {});
it('returns ExpressionRenderHandler instance', async () => {
const response = await render(element, {});
expect(response).toBeInstanceOf(ExpressionRenderHandler);
});
});

View file

@ -11,14 +11,14 @@ import { Observable } from 'rxjs';
import { filter } from 'rxjs/operators';
import { isNumber } from 'lodash';
import { SerializableRecord } from '@kbn/utility-types';
import { ExpressionRenderError, RenderErrorHandlerFnType, IExpressionLoaderParams } from './types';
import { renderErrorHandler as defaultRenderErrorHandler } from './render_error_handler';
import {
IInterpreterRenderHandlers,
IInterpreterRenderEvent,
IInterpreterRenderUpdateParams,
RenderMode,
} from '../common';
ExpressionRenderError,
RenderErrorHandlerFnType,
IExpressionLoaderParams,
ExpressionRendererEvent,
} from './types';
import { renderErrorHandler as defaultRenderErrorHandler } from './render_error_handler';
import { IInterpreterRenderHandlers, IInterpreterRenderUpdateParams, RenderMode } from '../common';
import { getRenderersRegistry } from './services';
@ -32,9 +32,6 @@ export interface ExpressionRenderHandlerParams {
hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise<boolean>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ExpressionRendererEvent = IInterpreterRenderEvent<any>;
type UpdateValue = IInterpreterRenderUpdateParams<IExpressionLoaderParams>;
export class ExpressionRenderHandler {
@ -154,12 +151,14 @@ export class ExpressionRenderHandler {
};
}
export function render(
export type IExpressionRenderer = (
element: HTMLElement,
data: unknown,
options?: ExpressionRenderHandlerParams
): ExpressionRenderHandler {
) => Promise<ExpressionRenderHandler>;
export const render: IExpressionRenderer = async (element, data, options) => {
const handler = new ExpressionRenderHandler(element, options);
handler.render(data as SerializableRecord);
return handler;
}
};

View file

@ -14,6 +14,7 @@ import {
ExpressionValue,
ExpressionsService,
RenderMode,
IInterpreterRenderEvent,
} from '../../common';
import { ExpressionRenderHandlerParams } from '../render';
@ -75,3 +76,6 @@ export type RenderErrorHandlerFnType = (
error: ExpressionRenderError,
handlers: IInterpreterRenderHandlers
) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ExpressionRendererEvent = IInterpreterRenderEvent<any>;

View file

@ -30,7 +30,7 @@ import {
} from '../../../../plugins/embeddable/public';
import {
IExpressionLoaderParams,
ExpressionsStart,
ExpressionLoader,
ExpressionRenderError,
ExpressionAstExpression,
} from '../../../../plugins/expressions/public';
@ -81,8 +81,6 @@ export type VisualizeSavedObjectAttributes = SavedObjectAttributes & {
export type VisualizeByValueInput = { attributes: VisualizeSavedObjectAttributes } & VisualizeInput;
export type VisualizeByReferenceInput = SavedObjectEmbeddableInput & VisualizeInput;
type ExpressionLoader = InstanceType<ExpressionsStart['ExpressionLoader']>;
export class VisualizeEmbeddable
extends Embeddable<VisualizeInput, VisualizeOutput>
implements ReferenceOrValueEmbeddable<VisualizeByValueInput, VisualizeByReferenceInput>
@ -302,7 +300,7 @@ export class VisualizeEmbeddable
super.render(this.domNode);
const expressions = getExpressions();
this.handler = new expressions.ExpressionLoader(this.domNode, undefined, {
this.handler = await expressions.loader(this.domNode, undefined, {
onRenderError: (element: HTMLElement, error: ExpressionRenderError) => {
this.onContainerError(error);
},

View file

@ -9,5 +9,5 @@
"requiredPlugins": ["data", "savedObjects", "kibanaUtils", "expressions"],
"server": true,
"ui": true,
"requiredBundles": ["inspector"]
"requiredBundles": []
}

View file

@ -13,10 +13,8 @@ import { first, pluck } from 'rxjs/operators';
import {
IInterpreterRenderHandlers,
ExpressionValue,
TablesAdapter,
} from '../../../../../../../src/plugins/expressions/public';
import { RequestAdapter } from '../../../../../../../src/plugins/inspector/public';
import { Adapters, ExpressionRenderHandler } from '../../types';
import { ExpressionRenderHandler } from '../../types';
import { getExpressions } from '../../services';
declare global {
@ -50,13 +48,9 @@ class Main extends React.Component<{}, State> {
initialContext: ExpressionValue = {}
) => {
this.setState({ expression });
const adapters: Adapters = {
requests: new RequestAdapter(),
tables: new TablesAdapter(),
};
return getExpressions()
.execute(expression, context || { type: 'null' }, {
inspectorAdapters: adapters,
searchContext: initialContext as any,
})
.getData()
@ -70,7 +64,7 @@ class Main extends React.Component<{}, State> {
lastRenderHandler.destroy();
}
lastRenderHandler = getExpressions().render(this.chartRef.current!, context, {
lastRenderHandler = await getExpressions().render(this.chartRef.current!, context, {
onRenderError: (el: HTMLElement, error: unknown, handler: IInterpreterRenderHandlers) => {
this.setState({
expression: 'Render error!\n\n' + JSON.stringify(error),

View file

@ -58,6 +58,7 @@ describe('lens_merge_tables', () => {
const adapters: DefaultInspectorAdapters = {
tables: new TablesAdapter(),
requests: {} as never,
expression: {} as never,
};
mergeTables.fn(null, { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, {
inspectorAdapters: adapters,