[Lens] Calculation operations (#83789) (#84390)

This commit is contained in:
Joe Reuter 2020-11-26 11:02:32 +01:00 committed by GitHub
parent b02858b20d
commit 86bb03d6f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 552 additions and 17 deletions

View file

@ -161,7 +161,7 @@ export function WorkspacePanel({
const expression = useMemo(
() => {
if (!configurationValidationError) {
if (!configurationValidationError || configurationValidationError.length === 0) {
try {
return buildExpression({
visualization: activeVisualization,

View file

@ -37,12 +37,14 @@ export class IndexPatternDatasource {
getIndexPatternDatasource,
renameColumns,
formatColumn,
counterRate,
getTimeScaleFunction,
getSuffixFormatter,
} = await import('../async_services');
return core.getStartServices().then(([coreStart, { data }]) => {
data.fieldFormats.register([getSuffixFormatter(data.fieldFormats.deserialize)]);
expressions.registerFunction(getTimeScaleFunction(data));
expressions.registerFunction(counterRate);
expressions.registerFunction(renameColumns);
expressions.registerFunction(formatColumn);
return getIndexPatternDatasource({

View file

@ -661,19 +661,30 @@ describe('IndexPattern Data Source', () => {
it('should skip columns that are being referenced', () => {
publicAPI = indexPatternDatasource.getPublicAPI({
state: {
...enrichBaseState(baseState),
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
// @ts-ignore this is too little information for a real column
col1: {
label: 'Sum',
dataType: 'number',
},
isBucketed: false,
operationType: 'sum',
sourceField: 'test',
params: {},
} as IndexPatternColumn,
col2: {
// @ts-expect-error update once we have a reference operation outside tests
label: 'Cumulative sum',
dataType: 'number',
isBucketed: false,
operationType: 'cumulative_sum',
references: ['col1'],
},
params: {},
} as IndexPatternColumn,
},
},
},

View file

@ -78,6 +78,7 @@ export function columnToOperation(column: IndexPatternColumn, uniqueLabel?: stri
export * from './rename_columns';
export * from './format_column';
export * from './time_scale';
export * from './counter_rate';
export * from './suffix_formatter';
export function getIndexPatternDatasource({

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
import { IndexPatternLayer } from '../../../types';
import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils';
import { OperationDefinition } from '..';
const ofName = (name?: string) => {
return i18n.translate('xpack.lens.indexPattern.CounterRateOf', {
defaultMessage: 'Counter rate of {name}',
values: {
name:
name ??
i18n.translate('xpack.lens.indexPattern.incompleteOperation', {
defaultMessage: '(incomplete)',
}),
},
});
};
export type CounterRateIndexPatternColumn = FormattedIndexPatternColumn &
ReferenceBasedIndexPatternColumn & {
operationType: 'counter_rate';
};
export const counterRateOperation: OperationDefinition<
CounterRateIndexPatternColumn,
'fullReference'
> = {
type: 'counter_rate',
priority: 1,
displayName: i18n.translate('xpack.lens.indexPattern.counterRate', {
defaultMessage: 'Counter rate',
}),
input: 'fullReference',
selectionStyle: 'field',
requiredReferences: [
{
input: ['field'],
specificOperations: ['max'],
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate');
},
buildColumn: ({ referenceIds, previousColumn, layer }) => {
const metric = layer.columns[referenceIds[0]];
return {
label: ofName(metric?.label),
dataType: 'number',
operationType: 'counter_rate',
isBucketed: false,
scale: 'ratio',
references: referenceIds,
params:
previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params &&
previousColumn.params.format
? { format: previousColumn.params.format }
: undefined,
};
},
isTransferable: (column, newIndexPattern) => {
return hasDateField(newIndexPattern);
},
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.counterRate', {
defaultMessage: 'Counter rate',
})
);
},
};

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
import { IndexPatternLayer } from '../../../types';
import { checkForDateHistogram, dateBasedOperationToExpression } from './utils';
import { OperationDefinition } from '..';
const ofName = (name?: string) => {
return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', {
defaultMessage: 'Cumulative sum rate of {name}',
values: {
name:
name ??
i18n.translate('xpack.lens.indexPattern.incompleteOperation', {
defaultMessage: '(incomplete)',
}),
},
});
};
export type CumulativeSumIndexPatternColumn = FormattedIndexPatternColumn &
ReferenceBasedIndexPatternColumn & {
operationType: 'cumulative_sum';
};
export const cumulativeSumOperation: OperationDefinition<
CumulativeSumIndexPatternColumn,
'fullReference'
> = {
type: 'cumulative_sum',
priority: 1,
displayName: i18n.translate('xpack.lens.indexPattern.cumulativeSum', {
defaultMessage: 'Cumulative sum',
}),
input: 'fullReference',
selectionStyle: 'field',
requiredReferences: [
{
input: ['field'],
specificOperations: ['count', 'sum'],
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum');
},
buildColumn: ({ referenceIds, previousColumn, layer }) => {
const metric = layer.columns[referenceIds[0]];
return {
label: ofName(metric?.label),
dataType: 'number',
operationType: 'cumulative_sum',
isBucketed: false,
scale: 'ratio',
references: referenceIds,
params:
previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params &&
previousColumn.params.format
? { format: previousColumn.params.format }
: undefined,
};
},
isTransferable: () => {
return true;
},
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.cumulativeSum', {
defaultMessage: 'Cumulative sum',
})
);
},
};

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
import { IndexPatternLayer } from '../../../types';
import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils';
import { OperationDefinition } from '..';
const ofName = (name?: string) => {
return i18n.translate('xpack.lens.indexPattern.derivativeOf', {
defaultMessage: 'Differences of {name}',
values: {
name:
name ??
i18n.translate('xpack.lens.indexPattern.incompleteOperation', {
defaultMessage: '(incomplete)',
}),
},
});
};
export type DerivativeIndexPatternColumn = FormattedIndexPatternColumn &
ReferenceBasedIndexPatternColumn & {
operationType: 'derivative';
};
export const derivativeOperation: OperationDefinition<
DerivativeIndexPatternColumn,
'fullReference'
> = {
type: 'derivative',
priority: 1,
displayName: i18n.translate('xpack.lens.indexPattern.derivative', {
defaultMessage: 'Differences',
}),
input: 'fullReference',
selectionStyle: 'full',
requiredReferences: [
{
input: ['field'],
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'derivative');
},
buildColumn: ({ referenceIds, previousColumn, layer }) => {
const metric = layer.columns[referenceIds[0]];
return {
label: ofName(metric?.label),
dataType: 'number',
operationType: 'derivative',
isBucketed: false,
scale: 'ratio',
references: referenceIds,
params:
previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params &&
previousColumn.params.format
? { format: previousColumn.params.format }
: undefined,
};
},
isTransferable: (column, newIndexPattern) => {
return hasDateField(newIndexPattern);
},
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.derivative', {
defaultMessage: 'Differences',
})
);
},
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { counterRateOperation, CounterRateIndexPatternColumn } from './counter_rate';
export { cumulativeSumOperation, CumulativeSumIndexPatternColumn } from './cumulative_sum';
export { derivativeOperation, DerivativeIndexPatternColumn } from './derivative';
export { movingAverageOperation, MovingAverageIndexPatternColumn } from './moving_average';

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { useState } from 'react';
import React from 'react';
import { EuiFormRow } from '@elastic/eui';
import { EuiFieldNumber } from '@elastic/eui';
import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types';
import { IndexPatternLayer } from '../../../types';
import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils';
import { updateColumnParam } from '../../layer_helpers';
import { useDebounceWithOptions } from '../helpers';
import type { OperationDefinition, ParamEditorProps } from '..';
const ofName = (name?: string) => {
return i18n.translate('xpack.lens.indexPattern.movingAverageOf', {
defaultMessage: 'Moving average of {name}',
values: {
name:
name ??
i18n.translate('xpack.lens.indexPattern.incompleteOperation', {
defaultMessage: '(incomplete)',
}),
},
});
};
export type MovingAverageIndexPatternColumn = FormattedIndexPatternColumn &
ReferenceBasedIndexPatternColumn & {
operationType: 'moving_average';
params: {
window: number;
};
};
export const movingAverageOperation: OperationDefinition<
MovingAverageIndexPatternColumn,
'fullReference'
> = {
type: 'moving_average',
priority: 1,
displayName: i18n.translate('xpack.lens.indexPattern.movingAverage', {
defaultMessage: 'Moving Average',
}),
input: 'fullReference',
selectionStyle: 'full',
requiredReferences: [
{
input: ['field'],
validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed,
},
],
getPossibleOperation: () => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
},
getDefaultLabel: (column, indexPattern, columns) => {
return ofName(columns[column.references[0]]?.label);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'moving_average', {
window: [(layer.columns[columnId] as MovingAverageIndexPatternColumn).params.window],
});
},
buildColumn: ({ referenceIds, previousColumn, layer }) => {
const metric = layer.columns[referenceIds[0]];
return {
label: ofName(metric?.label),
dataType: 'number',
operationType: 'moving_average',
isBucketed: false,
scale: 'ratio',
references: referenceIds,
params:
previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params &&
previousColumn.params.format
? { format: previousColumn.params.format, window: 5 }
: { window: 5 },
};
},
paramEditor: MovingAverageParamEditor,
isTransferable: (column, newIndexPattern) => {
return hasDateField(newIndexPattern);
},
getErrorMessage: (layer: IndexPatternLayer) => {
return checkForDateHistogram(
layer,
i18n.translate('xpack.lens.indexPattern.movingAverage', {
defaultMessage: 'Moving Average',
})
);
},
};
function MovingAverageParamEditor({
state,
setState,
currentColumn,
layerId,
}: ParamEditorProps<MovingAverageIndexPatternColumn>) {
const [inputValue, setInputValue] = useState(String(currentColumn.params.window));
useDebounceWithOptions(
() => {
if (inputValue === '') {
return;
}
const inputNumber = Number(inputValue);
setState(
updateColumnParam({
state,
layerId,
currentColumn,
paramName: 'window',
value: inputNumber,
})
);
},
{ skipFirstRender: true },
256,
[inputValue]
);
return (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.movingAverage.window', {
defaultMessage: 'Window size',
})}
display="columnCompressed"
fullWidth
>
<EuiFieldNumber
compressed
value={inputValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value)}
/>
</EuiFormRow>
);
}

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionAST } from '@kbn/interpreter/common';
import { IndexPattern, IndexPatternLayer } from '../../../types';
import { ReferenceBasedIndexPatternColumn } from '../column_types';
/**
* Checks whether the current layer includes a date histogram and returns an error otherwise
*/
export function checkForDateHistogram(layer: IndexPatternLayer, name: string) {
const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed);
const hasDateHistogram = buckets.some(
(colId) => layer.columns[colId].operationType === 'date_histogram'
);
if (hasDateHistogram) {
return undefined;
}
return [
i18n.translate('xpack.lens.indexPattern.calculations.dateHistogramErrorMessage', {
defaultMessage:
'{name} requires a date histogram to work. Choose a different function or add a date histogram.',
values: {
name,
},
}),
];
}
export function hasDateField(indexPattern: IndexPattern) {
return indexPattern.fields.some((field) => field.type === 'date');
}
/**
* Creates an expression ast for a date based operation (cumulative sum, derivative, moving average, counter rate)
*/
export function dateBasedOperationToExpression(
layer: IndexPatternLayer,
columnId: string,
functionName: string,
additionalArgs: Record<string, unknown[]> = {}
): ExpressionFunctionAST[] {
const currentColumn = (layer.columns[columnId] as unknown) as ReferenceBasedIndexPatternColumn;
const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed);
const dateColumnIndex = buckets.findIndex(
(colId) => layer.columns[colId].operationType === 'date_histogram'
)!;
buckets.splice(dateColumnIndex, 1);
return [
{
type: 'function',
function: functionName,
arguments: {
by: buckets,
inputColumnId: [currentColumn.references[0]],
outputColumnId: [columnId],
outputColumnName: [currentColumn.label],
...additionalArgs,
},
},
];
}

View file

@ -16,7 +16,7 @@ export interface BaseIndexPatternColumn extends Operation {
// export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
export type FormattedIndexPatternColumn = BaseIndexPatternColumn & {
params?: {
format: {
format?: {
id: string;
params?: {
decimals: number;

View file

@ -23,6 +23,16 @@ import {
MedianIndexPatternColumn,
} from './metrics';
import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram';
import {
cumulativeSumOperation,
CumulativeSumIndexPatternColumn,
counterRateOperation,
CounterRateIndexPatternColumn,
derivativeOperation,
DerivativeIndexPatternColumn,
movingAverageOperation,
MovingAverageIndexPatternColumn,
} from './calculations';
import { countOperation, CountIndexPatternColumn } from './count';
import { StateSetter, OperationMetadata } from '../../../types';
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
@ -52,7 +62,11 @@ export type IndexPatternColumn =
| CardinalityIndexPatternColumn
| SumIndexPatternColumn
| MedianIndexPatternColumn
| CountIndexPatternColumn;
| CountIndexPatternColumn
| CumulativeSumIndexPatternColumn
| CounterRateIndexPatternColumn
| DerivativeIndexPatternColumn
| MovingAverageIndexPatternColumn;
export type FieldBasedIndexPatternColumn = Extract<IndexPatternColumn, { sourceField: string }>;
@ -73,6 +87,10 @@ const internalOperationDefinitions = [
medianOperation,
countOperation,
rangeOperation,
cumulativeSumOperation,
counterRateOperation,
derivativeOperation,
movingAverageOperation,
];
export { termsOperation } from './terms';

View file

@ -424,7 +424,6 @@ export function deleteColumn({
};
}
// @ts-expect-error this fails statically because there are no references added
const extraDeletions: string[] = 'references' in column ? column.references : [];
const hypotheticalColumns = { ...layer.columns };
@ -452,11 +451,9 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] {
);
// If a reference has another reference as input, put it last in sort order
referenceBased.sort(([idA, a], [idB, b]) => {
// @ts-expect-error not statically analyzed
if ('references' in a && a.references.includes(idB)) {
return 1;
}
// @ts-expect-error not statically analyzed
if ('references' in b && b.references.includes(idA)) {
return -1;
}
@ -517,14 +514,12 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined
}
if ('references' in column) {
// @ts-expect-error references are not statically analyzed yet
column.references.forEach((referenceId, index) => {
if (!layer.columns[referenceId]) {
errors.push(
i18n.translate('xpack.lens.indexPattern.missingReferenceError', {
defaultMessage: 'Dimension {dimensionLabel} is incomplete',
values: {
// @ts-expect-error references are not statically analyzed yet
dimensionLabel: column.label,
},
})
@ -544,7 +539,6 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined
i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', {
defaultMessage: 'Dimension {dimensionLabel} does not have a valid configuration',
values: {
// @ts-expect-error references are not statically analyzed yet
dimensionLabel: column.label,
},
})
@ -560,10 +554,7 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined
export function isReferenced(layer: IndexPatternLayer, columnId: string): boolean {
const allReferences = Object.values(layer.columns).flatMap((col) =>
'references' in col
? // @ts-expect-error not statically analyzed
col.references
: []
'references' in col ? col.references : []
);
return allReferences.includes(columnId);
}

View file

@ -247,6 +247,22 @@ describe('getOperationTypesForField', () => {
"operationType": "sum",
"type": "field",
},
Object {
"operationType": "cumulative_sum",
"type": "fullReference",
},
Object {
"operationType": "counter_rate",
"type": "fullReference",
},
Object {
"operationType": "derivative",
"type": "fullReference",
},
Object {
"operationType": "moving_average",
"type": "fullReference",
},
Object {
"field": "bytes",
"operationType": "min",