[Lens] Enable new context constants in formula (#158452)

## Summary

Fixes #151827,  Fixes #117548

This PR contains constant function features in Lens formula:
* direct exposure of the `time_range()` - as suggested in #117548)

<img width="297" alt="Screenshot 2023-05-29 at 12 05 43"
src="8dbd63a6-c5ec-4177-a4a2-cc0770a495bc">

* `interval()` (as intended in #151827) 

<img width="359" alt="Screenshot 2023-05-29 at 12 04 18"
src="8c3a4637-bd23-4b4d-8a5f-36853ccbfee3">

* and `now()` (as suggested in
[here](https://github.com/elastic/kibana/issues/112851#issuecomment-1183175053))
functions in the formula input

<img width="364" alt="Screenshot 2023-05-25 at 14 30 14"
src="2137717b-44a0-4531-bcc2-1fbda40368ba">

A visual explanation of what each constant represent:
<img width="677" alt="Screenshot 2023-05-29 at 11 56 16"
src="b7b7e92c-b061-4cce-a77e-e7baeab323a8">


Documentation:

<img width="546" alt="Screenshot 2023-05-31 at 12 59 40"
src="1fea7e88-6e4f-4958-949f-6b71a770cd44">



Some example usage
<img width="683" alt="Screenshot 2023-05-25 at 13 33 26"
src="e03442ab-0062-4999-8a38-c00a66b3b13b">

TSVB => Lens with `params._interval` support:

![tsvb_to_lens_with_interval](6ea86bd1-8e71-4928-b2e2-7ad27537b33d)

**Notes**: 

* context values work like static values, with the same limits,
therefore it is not possible to build a date histogram with a context
value alone (i.e. a formula with only `interval()` or `now()`). It works
ok without the `Date Histogram` dimension.
* The `interval()` function will report an error if used without a
configured `Date Histogram` dimension:

<img width="671" alt="Screenshot 2023-05-29 at 12 14 13"
src="ca67f102-35bb-4e4d-ab16-15d1be4cadcc">

* The `interval()` function does not take into account different bucket
interval size (i.e. DST changes, leap years, etc...), rather return the
same value to all the buckets. This is the same behaviour as in TSVB,
but in Lens it can be a problem due to the usage of `calendar_interval`.

* I had to duplicate a couple of function from the helpers to avoid
issues with tests. I've tried a different organization of the helpers
(between pure vs impure fns) but that took longer than expected, so I
would defer this task later in another PR.

<details>
  <summary>General approach with `constant(...)` removed</summary>
* a more general approach using `constants(value="...")`
<img width="301" alt="Screenshot 2023-05-29 at 12 07 23"
src="0a800aa4-8db5-4055-996d-344d37adfc01">
</details>


A cloud deployment has been created to test both approaches here. Let me
know which one do you prefer cc @elastic/kibana-visualizations

### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Marco Liberati 2023-06-06 14:36:38 +02:00 committed by GitHub
parent e2a060d557
commit 3600f975de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 933 additions and 192 deletions

View file

@ -297,6 +297,34 @@ describe('convertMathToFormulaColumn', () => {
expect(convertMathToFormulaColumn(...input)).toEqual(expect.objectContaining(expected));
}
});
it.each`
expression | expected
${'params._interval'} | ${'interval()'}
${'params._interval + params._interval'} | ${'interval() + interval()'}
${'params._all'} | ${null}
${'params._all + params.interval'} | ${null}
${'params._timestamp'} | ${null}
${'params._timestamp + params.interval'} | ${null}
${'params._index'} | ${null}
${'params._index + params.interval'} | ${null}
`(`handle special params cases: $expression`, ({ expression, expected }) => {
expect(
convertMathToFormulaColumn({
series,
metrics: [{ ...mathMetric, script: expression }],
dataView,
})
).toEqual(
expected
? expect.objectContaining({
meta: { metricId: 'some-id-1' },
operationType: 'formula',
params: { formula: expected },
})
: expected
);
});
});
describe('convertOtherAggsToFormulaColumn', () => {

View file

@ -141,6 +141,11 @@ export const convertMathToFormulaColumn = (
return null;
}
// now replace the _interval with the new interval() formula
if (script.includes('params._interval')) {
script = script.replaceAll('params._interval', 'interval()');
}
const scripthasNoStaticNumber = isNaN(Number(script));
if (script.includes('params') || !scripthasNoStaticNumber) {
return null;

View file

@ -59,6 +59,8 @@ jest.mock('./dimension_panel/reference_editor', () => ({
ReferenceEditor: () => null,
}));
const nowInstant = new Date();
const fieldsOne = [
{
name: 'timestamp',
@ -365,7 +367,14 @@ describe('IndexPattern Data Source', () => {
it('should generate an empty expression when no columns are selected', async () => {
const state = FormBasedDatasource.initialize();
expect(
FormBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange, 'testing-seed')
FormBasedDatasource.toExpression(
state,
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
)
).toEqual(null);
});
@ -395,6 +404,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
)
).toEqual({
@ -450,6 +460,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
)
).toMatchInlineSnapshot(`
@ -637,6 +648,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
@ -678,6 +690,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
expect((ast.chain[1].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']);
@ -891,6 +904,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
const count = (ast.chain[1].arguments.aggs[1] as Ast).chain[0];
@ -961,6 +975,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.aggs[0]).toMatchInlineSnapshot(`
@ -1091,6 +1106,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
const timeScaleCalls = ast.chain.filter((fn) => fn.function === 'lens_time_scale');
@ -1162,6 +1178,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
const filteredMetricAgg = (ast.chain[1].arguments.aggs[0] as Ast).chain[0].arguments;
@ -1219,6 +1236,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
const formatIndex = ast.chain.findIndex((fn) => fn.function === 'lens_format_column');
@ -1273,6 +1291,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.metricsAtAllLevels).toEqual([false]);
@ -1318,6 +1337,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp']);
@ -1381,6 +1401,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
);
@ -1455,6 +1476,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
@ -1525,6 +1547,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
@ -1636,6 +1659,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
// @ts-expect-error we can't isolate just the reference type
@ -1675,6 +1699,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
@ -1768,6 +1793,7 @@ describe('IndexPattern Data Source', () => {
'first',
indexPatterns,
dateRange,
nowInstant,
'testing-seed'
) as Ast;
const chainLength = ast.chain.length;

View file

@ -17,7 +17,7 @@ import { flatten, isEqual } from 'lodash';
import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { DataPublicPluginStart, ES_FIELD_TYPES } from '@kbn/data-plugin/public';
import { DataPublicPluginStart, ES_FIELD_TYPES, UI_SETTINGS } from '@kbn/data-plugin/public';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
@ -186,7 +186,7 @@ export function getFormBasedDatasource({
return loadInitialState({
persistedState,
references,
defaultIndexPatternId: core.uiSettings.get('defaultIndex'),
defaultIndexPatternId: uiSettings.get('defaultIndex'),
storage,
initialContext,
indexPatternRefs,
@ -425,8 +425,16 @@ export function getFormBasedDatasource({
return fields;
},
toExpression: (state, layerId, indexPatterns, dateRange, searchSessionId) =>
toExpression(state, layerId, indexPatterns, uiSettings, dateRange, searchSessionId),
toExpression: (state, layerId, indexPatterns, dateRange, nowInstant, searchSessionId) =>
toExpression(
state,
layerId,
indexPatterns,
uiSettings,
dateRange,
nowInstant,
searchSessionId
),
renderLayerSettings(domElement, props) {
render(
@ -853,7 +861,8 @@ export function getFormBasedDatasource({
layer,
columnId,
frameDatasourceAPI.dataViews.indexPatterns[layer.indexPatternId],
frameDatasourceAPI.dateRange
frameDatasourceAPI.dateRange,
uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET)
);
}
);

View file

@ -0,0 +1,255 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FormBasedLayer } from '../../../../..';
import { createMockedIndexPattern } from '../../../mocks';
import { DateHistogramIndexPatternColumn } from '../date_histogram';
import {
ConstantsIndexPatternColumn,
nowOperation,
intervalOperation,
timeRangeOperation,
} from './context_variables';
function createLayer<T extends ConstantsIndexPatternColumn>(
type: 'interval' | 'now' | 'time_range'
): FormBasedLayer {
return {
indexPatternId: '1',
columnOrder: ['col1'],
columns: {
col1: {
label: `Constant: ${type}`,
dataType: 'number',
operationType: type,
isBucketed: false,
scale: 'ratio',
references: [],
},
},
};
}
function createExpression(type: 'interval' | 'now' | 'time_range', value: number) {
return [
{
type: 'function',
function: 'mathColumn',
arguments: {
id: ['col1'],
name: [`Constant: ${type}`],
expression: [String(value)],
},
},
];
}
describe('context variables', () => {
describe('interval', () => {
describe('getErrorMessages', () => {
it('should return error if no date_histogram is configured', () => {
expect(
intervalOperation.getErrorMessage!(
createLayer('interval'),
'col1',
createMockedIndexPattern(),
{ fromDate: new Date().toISOString(), toDate: new Date().toISOString() },
{},
100
)
).toEqual(
expect.arrayContaining([
'Cannot compute an interval without a date histogram column configured',
])
);
});
it('should return error if no dateRange is passed over', () => {
expect(
intervalOperation.getErrorMessage!(
createLayer('interval'),
'col1',
createMockedIndexPattern(),
undefined,
{},
100
)
).toEqual(expect.arrayContaining(['The current time range interval is not available']));
});
it('should return error if no targetBar is passed over', () => {
expect(
intervalOperation.getErrorMessage!(
createLayer('interval'),
'col1',
createMockedIndexPattern(),
{ fromDate: new Date().toISOString(), toDate: new Date().toISOString() },
{}
)
).toEqual(expect.arrayContaining(['Missing "histogram:barTarget" value']));
});
it('should not return errors if all context is provided', () => {
const layer = createLayer('interval');
layer.columns = {
col2: {
label: 'Date histogram',
dataType: 'date',
operationType: 'date_histogram',
sourceField: '@timestamp',
isBucketed: true,
scale: 'interval',
params: {
interval: 'auto',
includeEmptyRows: true,
dropPartials: false,
},
} as DateHistogramIndexPatternColumn,
...layer.columns,
};
layer.columnOrder = ['col2', 'col1'];
expect(
intervalOperation.getErrorMessage!(
layer,
'col1',
createMockedIndexPattern(),
{ fromDate: new Date().toISOString(), toDate: new Date().toISOString() },
{},
100
)
).toBeUndefined();
});
});
describe('toExpression', () => {
it('should return 0 if no dateRange is passed', () => {
expect(
intervalOperation.toExpression(
createLayer('interval'),
'col1',
createMockedIndexPattern(),
{ now: new Date(), targetBars: 100 }
)
).toEqual(expect.arrayContaining(createExpression('interval', 0)));
});
it('should return 0 if no targetBars is passed', () => {
expect(
intervalOperation.toExpression(
createLayer('interval'),
'col1',
createMockedIndexPattern(),
{
dateRange: {
fromDate: new Date(2022, 0, 1).toISOString(),
toDate: new Date(2023, 0, 1).toISOString(),
},
now: new Date(),
}
)
).toEqual(expect.arrayContaining(createExpression('interval', 0)));
});
it('should return a valid value > 0 if both dateRange and targetBars is passed', () => {
expect(
intervalOperation.toExpression(
createLayer('interval'),
'col1',
createMockedIndexPattern(),
{
dateRange: {
fromDate: new Date(2022, 0, 1).toISOString(),
toDate: new Date(2023, 0, 1).toISOString(),
},
now: new Date(),
targetBars: 100,
}
)
).toEqual(expect.arrayContaining(createExpression('interval', 86400000)));
});
});
});
describe('time_range', () => {
describe('getErrorMessages', () => {
it('should return error if no dateRange is passed over', () => {
expect(
timeRangeOperation.getErrorMessage!(
createLayer('time_range'),
'col1',
createMockedIndexPattern(),
undefined,
{},
100
)
).toEqual(expect.arrayContaining(['The current time range interval is not available']));
});
it('should return error if dataView is not time-based', () => {
const dataView = createMockedIndexPattern();
dataView.timeFieldName = undefined;
expect(
timeRangeOperation.getErrorMessage!(
createLayer('time_range'),
'col1',
dataView,
undefined,
{},
100
)
).toEqual(expect.arrayContaining(['The current time range interval is not available']));
});
});
describe('toExpression', () => {
it('should return 0 if no dateRange is passed', () => {
expect(
timeRangeOperation.toExpression(
createLayer('time_range'),
'col1',
createMockedIndexPattern(),
{ now: new Date(), targetBars: 100 }
)
).toEqual(expect.arrayContaining(createExpression('time_range', 0)));
});
it('should return a valid value > 0 if dateRange is passed', () => {
expect(
timeRangeOperation.toExpression(
createLayer('time_range'),
'col1',
createMockedIndexPattern(),
{
dateRange: {
fromDate: new Date(2022, 0, 1).toISOString(),
toDate: new Date(2023, 0, 1).toISOString(),
},
}
)
).toEqual(expect.arrayContaining(createExpression('time_range', 31536000000)));
});
});
});
describe('now', () => {
describe('getErrorMessages', () => {
it('should return no error even without context', () => {
expect(
nowOperation.getErrorMessage!(createLayer('now'), 'col1', createMockedIndexPattern())
).toBeUndefined();
});
});
describe('toExpression', () => {
it('should return the now value when passed', () => {
const now = new Date();
expect(
nowOperation.toExpression(createLayer('now'), 'col1', createMockedIndexPattern(), {
now,
})
).toEqual(expect.arrayContaining(createExpression('now', +now)));
});
});
});
});

View file

@ -0,0 +1,269 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { calcAutoIntervalNear, UI_SETTINGS } from '@kbn/data-plugin/common';
import { partition } from 'lodash';
import type {
DateHistogramIndexPatternColumn,
FormBasedLayer,
GenericIndexPatternColumn,
} from '../../../../..';
import type { DateRange } from '../../../../../../common/types';
import type { GenericOperationDefinition, OperationDefinition } from '..';
import type { ReferenceBasedIndexPatternColumn } from '../column_types';
import { IndexPattern } from '../../../../../types';
// copied over from layer_helpers
// TODO: split layer_helpers util into pure/non-pure functions to avoid issues with tests
export function getColumnOrder(layer: FormBasedLayer): string[] {
const entries = Object.entries(layer.columns);
entries.sort(([idA], [idB]) => {
const indexA = layer.columnOrder.indexOf(idA);
const indexB = layer.columnOrder.indexOf(idB);
if (indexA > -1 && indexB > -1) {
return indexA - indexB;
} else if (indexA > -1) {
return -1;
} else {
return 1;
}
});
const [aggregations, metrics] = partition(entries, ([, col]) => col.isBucketed);
return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id));
}
// Copied over from helpers
export function isColumnOfType<C extends GenericIndexPatternColumn>(
type: C['operationType'],
column: GenericIndexPatternColumn
): column is C {
return column.operationType === type;
}
export interface ContextValues {
dateRange?: DateRange;
now?: Date;
targetBars?: number;
}
export interface TimeRangeIndexPatternColumn extends ReferenceBasedIndexPatternColumn {
operationType: 'time_range';
}
function getTimeRangeFromContext({ dateRange }: ContextValues) {
return dateRange ? moment(dateRange.toDate).diff(moment(dateRange.fromDate)) : 0;
}
function getTimeRangeErrorMessages(
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern,
dateRange?: DateRange | undefined
) {
const errors = [];
if (!indexPattern.timeFieldName) {
errors.push(
i18n.translate('xpack.lens.indexPattern.dateRange.dataViewNoTimeBased', {
defaultMessage: 'The current dataView is not time based',
})
);
}
if (!dateRange) {
errors.push(
i18n.translate('xpack.lens.indexPattern.dateRange.noTimeRange', {
defaultMessage: 'The current time range interval is not available',
})
);
}
return errors.length ? errors : undefined;
}
export const timeRangeOperation = createContextValueBasedOperation<TimeRangeIndexPatternColumn>({
type: 'time_range',
label: 'Time range',
description: i18n.translate('xpack.lens.indexPattern.timeRange.documentation.markdown', {
defaultMessage: `
The specified time range, in milliseconds (ms).
`,
}),
getContextValue: getTimeRangeFromContext,
getErrorMessage: getTimeRangeErrorMessages,
});
export interface NowIndexPatternColumn extends ReferenceBasedIndexPatternColumn {
operationType: 'now';
}
function getNowFromContext({ now }: ContextValues) {
return now == null ? Date.now() : +now;
}
function getNowErrorMessage() {
return undefined;
}
export const nowOperation = createContextValueBasedOperation<NowIndexPatternColumn>({
type: 'now',
label: 'Current now',
description: i18n.translate('xpack.lens.indexPattern.now.documentation.markdown', {
defaultMessage: `
The current now moment used in Kibana expressed in milliseconds (ms).
`,
}),
getContextValue: getNowFromContext,
getErrorMessage: getNowErrorMessage,
});
export interface IntervalIndexPatternColumn extends ReferenceBasedIndexPatternColumn {
operationType: 'interval';
}
function getIntervalFromContext(context: ContextValues) {
return context.dateRange && context.targetBars
? calcAutoIntervalNear(context.targetBars, getTimeRangeFromContext(context)).asMilliseconds()
: 0;
}
function getIntervalErrorMessages(
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern,
dateRange?: DateRange | undefined,
operationDefinitionMap?: Record<string, GenericOperationDefinition> | undefined,
targetBars?: number
) {
const errors = [];
if (!targetBars) {
errors.push(
i18n.translate('xpack.lens.indexPattern.interval.noTargetBars', {
defaultMessage: `Missing "{uiSettingVar}" value`,
values: {
uiSettingVar: UI_SETTINGS.HISTOGRAM_BAR_TARGET,
},
})
);
}
if (!dateRange) {
errors.push(
i18n.translate('xpack.lens.indexPattern.interval.noTimeRange', {
defaultMessage: 'The current time range interval is not available',
})
);
}
if (
!Object.values(layer.columns).some((column) =>
isColumnOfType<DateHistogramIndexPatternColumn>('date_histogram', column)
)
) {
errors.push(
i18n.translate('xpack.lens.indexPattern.interval.noDateHistogramColumn', {
defaultMessage: 'Cannot compute an interval without a date histogram column configured',
})
);
}
return errors.length ? errors : undefined;
}
export const intervalOperation = createContextValueBasedOperation<IntervalIndexPatternColumn>({
type: 'interval',
label: 'Date histogram interval',
description: i18n.translate('xpack.lens.indexPattern.interval.documentation.markdown', {
defaultMessage: `
The specified minimum interval for the date histogram, in milliseconds (ms).
`,
}),
getContextValue: getIntervalFromContext,
getErrorMessage: getIntervalErrorMessages,
});
export type ConstantsIndexPatternColumn =
| IntervalIndexPatternColumn
| TimeRangeIndexPatternColumn
| NowIndexPatternColumn;
function createContextValueBasedOperation<ColumnType extends ConstantsIndexPatternColumn>({
label,
type,
getContextValue,
getErrorMessage,
description,
}: {
label: string;
type: ColumnType['operationType'];
description: string;
getContextValue: (context: ContextValues) => number;
getErrorMessage: OperationDefinition<ColumnType, 'managedReference'>['getErrorMessage'];
}): OperationDefinition<ColumnType, 'managedReference'> {
return {
type,
displayName: label,
input: 'managedReference',
selectionStyle: 'hidden',
usedInMath: true,
getDefaultLabel: () => label,
isTransferable: () => true,
getDisabledStatus() {
return undefined;
},
getErrorMessage,
getPossibleOperation() {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
},
buildColumn: () => {
return {
label,
dataType: 'number',
operationType: type,
isBucketed: false,
scale: 'ratio',
references: [],
} as unknown as ColumnType;
},
toExpression: (layer, columnId, _, context = {}) => {
const column = layer.columns[columnId] as ColumnType;
return [
{
type: 'function',
function: 'mathColumn',
arguments: {
id: [columnId],
name: [column.label],
expression: [String(getContextValue(context))],
},
},
];
},
createCopy(layers, source, target) {
const currentColumn = layers[source.layerId].columns[source.columnId] as ColumnType;
const targetLayer = layers[target.layerId];
const columns = {
...targetLayer.columns,
[target.columnId]: { ...currentColumn },
};
return {
...layers,
[target.layerId]: {
...targetLayer,
columns,
columnOrder: getColumnOrder({ ...targetLayer, columns }),
},
};
},
documentation: {
section: 'constants',
signature: '',
description,
},
};
}

View file

@ -31,6 +31,7 @@ import { monaco } from '@kbn/monaco';
import classNames from 'classnames';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import type { CodeEditorProps } from '@kbn/kibana-react-plugin/public';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { useDebounceWithOptions } from '../../../../../../shared_components';
import { ParamEditorProps } from '../..';
import { getManagedColumnsFrom } from '../../../layer_helpers';
@ -109,6 +110,7 @@ export function FormulaEditor({
dateHistogramInterval,
hasData,
dateRange,
uiSettings,
}: Omit<ParamEditorProps<FormulaIndexPatternColumn>, 'activeData'> & {
dateHistogramInterval: ReturnType<typeof getDateHistogramInterval>;
hasData: boolean;
@ -356,7 +358,8 @@ export function FormulaEditor({
id,
indexPattern,
dateRange,
visibleOperationsMap
visibleOperationsMap,
uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET)
);
if (messages) {
const startPosition = offsetToRowColumn(text, locations[id].min);

View file

@ -22,6 +22,42 @@ import type {
} from '../..';
import type { FormulaIndexPatternColumn } from '../formula';
function createNewSection(
label: string,
description: string,
functionsToDocument: string[],
operationDefinitionMap: Record<string, GenericOperationDefinition>
) {
return {
label,
description,
items: functionsToDocument.sort().map((key) => {
const fnDescription = getFunctionDescriptionAndExamples(key, operationDefinitionMap);
return {
label: key,
description: (
<>
<h3>{getFunctionSignatureLabel(key, operationDefinitionMap, false)}</h3>
{fnDescription ? <Markdown markdown={fnDescription} /> : null}
</>
),
};
}),
};
}
function getFunctionDescriptionAndExamples(
label: string,
operationDefinitionMap: Record<string, GenericOperationDefinition>
) {
if (tinymathFunctions[label]) {
const [description, examples] = tinymathFunctions[label].help.split(`\`\`\``);
return `${description.replace(/\n/g, '\n\n')}${examples ? `\`\`\`${examples}\`\`\`` : ''}`;
}
return operationDefinitionMap[label].documentation?.description;
}
export function getDocumentationSections({
indexPattern,
operationDefinitionMap,
@ -159,22 +195,12 @@ max(system.network.in.bytes, reducedTimeRange="30m")
],
});
helpGroups.push({
label: i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSection', {
defaultMessage: 'Elasticsearch',
}),
description: i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSectionDescription', {
defaultMessage:
'These functions will be executed on the raw documents for each row of the resulting table, aggregating all documents matching the break down dimensions into a single value.',
}),
items: [],
});
const {
elasticsearch: esFunction,
calculation: calculationFunction,
elasticsearch: esFunctions,
calculation: calculationFunctions,
math: mathOperations,
comparison: comparisonOperations,
constants: constantsOperations,
} = groupBy(getPossibleFunctions(indexPattern), (key) => {
if (key in operationDefinitionMap) {
return operationDefinitionMap[key].documentation?.section;
@ -185,122 +211,78 @@ max(system.network.in.bytes, reducedTimeRange="30m")
});
// Es aggs
helpGroups[2].items.push(
...esFunction.sort().map((key) => ({
label: key,
description: (
<>
<h3>
{key}({operationDefinitionMap[key].documentation?.signature})
</h3>
{operationDefinitionMap[key].documentation?.description ? (
<Markdown markdown={operationDefinitionMap[key].documentation!.description} />
) : null}
</>
),
}))
);
helpGroups.push({
label: i18n.translate('xpack.lens.formulaDocumentation.columnCalculationSection', {
defaultMessage: 'Column calculations',
}),
description: i18n.translate(
'xpack.lens.formulaDocumentation.columnCalculationSectionDescription',
{
helpGroups.push(
createNewSection(
i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSection', {
defaultMessage: 'Elasticsearch',
}),
i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSectionDescription', {
defaultMessage:
'These functions are executed for each row, but are provided with the whole column as context. This is also known as a window function.',
}
),
items: [],
});
'These functions will be executed on the raw documents for each row of the resulting table, aggregating all documents matching the break down dimensions into a single value.',
}),
esFunctions,
operationDefinitionMap
)
);
// Calculations aggs
helpGroups[3].items.push(
...calculationFunction.sort().map((key) => ({
label: key,
description: (
<>
<h3>
{key}({operationDefinitionMap[key].documentation?.signature})
</h3>
helpGroups.push(
createNewSection(
i18n.translate('xpack.lens.formulaDocumentation.columnCalculationSection', {
defaultMessage: 'Column calculations',
}),
i18n.translate('xpack.lens.formulaDocumentation.columnCalculationSectionDescription', {
defaultMessage:
'These functions are executed for each row, but are provided with the whole column as context. This is also known as a window function.',
}),
{operationDefinitionMap[key].documentation?.description ? (
<Markdown markdown={operationDefinitionMap[key].documentation!.description} />
) : null}
</>
),
}))
calculationFunctions,
operationDefinitionMap
)
);
helpGroups.push({
label: i18n.translate('xpack.lens.formulaDocumentation.mathSection', {
defaultMessage: 'Math',
}),
description: i18n.translate('xpack.lens.formulaDocumentation.mathSectionDescription', {
defaultMessage:
'These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.',
}),
items: [],
});
helpGroups.push(
createNewSection(
i18n.translate('xpack.lens.formulaDocumentation.mathSection', {
defaultMessage: 'Math',
}),
i18n.translate('xpack.lens.formulaDocumentation.mathSectionDescription', {
defaultMessage:
'These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.',
}),
const mathFns = mathOperations.sort().map((key) => {
const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``);
return {
label: key,
description: description.replace(/\n/g, '\n\n'),
examples: examples ? `\`\`\`${examples}\`\`\`` : '',
};
});
helpGroups[4].items.push(
...mathFns.map(({ label, description, examples }) => {
return {
label,
description: (
<>
<h3>{getFunctionSignatureLabel(label, operationDefinitionMap)}</h3>
<Markdown markdown={`${description}${examples}`} />
</>
),
};
})
mathOperations,
operationDefinitionMap
)
);
helpGroups.push({
label: i18n.translate('xpack.lens.formulaDocumentation.comparisonSection', {
defaultMessage: 'Comparison',
}),
description: i18n.translate('xpack.lens.formulaDocumentation.comparisonSectionDescription', {
defaultMessage: 'These functions are used to perform value comparison.',
}),
items: [],
});
helpGroups.push(
createNewSection(
i18n.translate('xpack.lens.formulaDocumentation.comparisonSection', {
defaultMessage: 'Comparison',
}),
i18n.translate('xpack.lens.formulaDocumentation.comparisonSectionDescription', {
defaultMessage: 'These functions are used to perform value comparison.',
}),
const comparisonFns = comparisonOperations.sort().map((key) => {
const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``);
return {
label: key,
description: description.replace(/\n/g, '\n\n'),
examples: examples ? `\`\`\`${examples}\`\`\`` : '',
};
});
comparisonOperations,
operationDefinitionMap
)
);
helpGroups[5].items.push(
...comparisonFns.map(({ label, description, examples }) => {
return {
label,
description: (
<>
<h3>{getFunctionSignatureLabel(label, operationDefinitionMap)}</h3>
<Markdown markdown={`${description}${examples}`} />
</>
),
};
})
helpGroups.push(
createNewSection(
i18n.translate('xpack.lens.formulaDocumentation.constantsSection', {
defaultMessage: 'Kibana context',
}),
i18n.translate('xpack.lens.formulaDocumentation.constantsSectionDescription', {
defaultMessage:
'These functions are used to retrieve Kibana context variables, which are the date histogram `interval`, the current `now` and the selected `time_range` and help you to compute date math operations.',
}),
constantsOperations,
operationDefinitionMap
)
);
const sections = {
@ -353,7 +335,7 @@ Use the symbols +, -, /, and * to perform basic math.
export function getFunctionSignatureLabel(
name: string,
operationDefinitionMap: ParamEditorProps<FormulaIndexPatternColumn>['operationDefinitionMap'],
firstParam?: { label: string | [number, number] } | null
getFullSignature: boolean = true
): string {
if (tinymathFunctions[name]) {
return `${name}(${tinymathFunctions[name].positionalArguments
@ -363,26 +345,28 @@ export function getFunctionSignatureLabel(
if (operationDefinitionMap[name]) {
const def = operationDefinitionMap[name];
const extraArgs: string[] = [];
if (def.filterable) {
extraArgs.push(
i18n.translate('xpack.lens.formula.kqlExtraArguments', {
defaultMessage: '[kql]?: string, [lucene]?: string',
})
);
}
if (def.shiftable) {
extraArgs.push(
i18n.translate('xpack.lens.formula.shiftExtraArguments', {
defaultMessage: '[shift]?: string',
})
);
}
if (def.canReduceTimeRange) {
extraArgs.push(
i18n.translate('xpack.lens.formula.reducedTimeRangeExtraArguments', {
defaultMessage: '[reducedTimeRange]?: string',
})
);
if (getFullSignature) {
if (def.filterable) {
extraArgs.push(
i18n.translate('xpack.lens.formula.kqlExtraArguments', {
defaultMessage: '[kql]?: string, [lucene]?: string',
})
);
}
if (def.shiftable) {
extraArgs.push(
i18n.translate('xpack.lens.formula.shiftExtraArguments', {
defaultMessage: '[shift]?: string',
})
);
}
if (def.canReduceTimeRange) {
extraArgs.push(
i18n.translate('xpack.lens.formula.reducedTimeRangeExtraArguments', {
defaultMessage: '[reducedTimeRange]?: string',
})
);
}
}
const extraComma = extraArgs.length ? ', ' : '';
return `${name}(${def.documentation?.signature}${extraComma}${extraArgs.join(', ')})`;

View file

@ -219,7 +219,9 @@ export function getPossibleFunctions(
available.forEach((a) => {
if (a.operationMetaData.dataType === 'number' && !a.operationMetaData.isBucketed) {
possibleOperationNames.push(
...a.operations.filter((o) => o.type !== 'managedReference').map((o) => o.operationType)
...a.operations
.filter((o) => o.type !== 'managedReference' || o.usedInMath)
.map((o) => o.operationType)
);
}
});
@ -607,7 +609,7 @@ function getSignaturesForFunction(
}
: null;
const functionLabel = getFunctionSignatureLabel(name, operationDefinitionMap, firstParam);
const functionLabel = getFunctionSignatureLabel(name, operationDefinitionMap);
const documentation = getOperationTypeHelp(name, operationDefinitionMap);
if ('operationParams' in def && def.operationParams) {
return [

View file

@ -73,6 +73,13 @@ const operationDefinitionMap: Record<string, GenericOperationDefinition> = {
}),
}),
cumulative_sum: createOperationDefinitionMock('cumulative_sum', { input: 'fullReference' }),
interval: createOperationDefinitionMock('interval', {
input: 'managedReference',
usedInMath: true,
}),
opertion_not_available: createOperationDefinitionMock('operation_not_available', {
input: 'managedReference',
}),
};
describe('formula', () => {
@ -1871,5 +1878,27 @@ invalid: "
)
).toHaveLength(1);
});
it('should work with managed reference operations only when "usedInMath" flag is enabled', () => {
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula('interval()', false),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula('operation_not_available()', false),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Operation operation_not_available not found']);
});
});
});

View file

@ -68,7 +68,7 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
getDisabledStatus(indexPattern: IndexPattern) {
return undefined;
},
getErrorMessage(layer, columnId, indexPattern, dateRange, operationDefinitionMap) {
getErrorMessage(layer, columnId, indexPattern, dateRange, operationDefinitionMap, targetBars) {
const column = layer.columns[columnId] as FormulaIndexPatternColumn;
if (!column.params.formula || !operationDefinitionMap) {
return;
@ -110,7 +110,8 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
id,
indexPattern,
dateRange,
visibleOperationsMap
visibleOperationsMap,
targetBars
);
return messages || [];
}

View file

@ -12,3 +12,10 @@ export { insertOrReplaceFormulaColumn } from './parse';
export type { MathIndexPatternColumn } from './math';
export { mathOperation } from './math';
export type {
TimeRangeIndexPatternColumn,
NowIndexPatternColumn,
IntervalIndexPatternColumn,
} from './context_variables';
export { timeRangeOperation, nowOperation, intervalOperation } from './context_variables';

View file

@ -73,7 +73,8 @@ function parseAndExtract(
i18n.translate('xpack.lens.indexPattern.formulaPartLabel', {
defaultMessage: 'Part of {label}',
values: { label: label || text },
})
}),
dateRange
);
return { extracted, isValid: true };
}
@ -84,7 +85,8 @@ function extractColumns(
ast: TinymathAST,
layer: FormBasedLayer,
indexPattern: IndexPattern,
label: string
label: string,
dateRange: DateRange | undefined
): Array<{ column: GenericIndexPatternColumn; location?: TinymathLocation }> {
const columns: Array<{ column: GenericIndexPatternColumn; location?: TinymathLocation }> = [];
const { filter: globalFilter, reducedTimeRange: globalReducedTimeRange } =
@ -194,6 +196,21 @@ function extractColumns(
// replace by new column id
return newColId;
}
if (nodeOperation.input === 'managedReference' && nodeOperation.usedInMath) {
const newCol = (
nodeOperation as OperationDefinition<GenericIndexPatternColumn, 'managedReference'>
).buildColumn({
layer,
indexPattern,
});
const newColId = getManagedId(idPrefix, columns.length);
newCol.customLabel = true;
newCol.label = label;
columns.push({ column: newCol, location: node.location });
// replace by new column id
return newColId;
}
}
const root = parseNode(ast);

View file

@ -41,7 +41,13 @@ import {
timeScaleOperation,
} from './calculations';
import { countOperation } from './count';
import { mathOperation, formulaOperation } from './formula';
import {
mathOperation,
formulaOperation,
timeRangeOperation,
nowOperation,
intervalOperation,
} from './formula';
import { staticValueOperation } from './static_value';
import { lastValueOperation } from './last_value';
import type {
@ -100,7 +106,13 @@ export type {
export type { CountIndexPatternColumn } from './count';
export type { LastValueIndexPatternColumn } from './last_value';
export type { RangeIndexPatternColumn } from './ranges';
export type { FormulaIndexPatternColumn, MathIndexPatternColumn } from './formula';
export type {
FormulaIndexPatternColumn,
MathIndexPatternColumn,
TimeRangeIndexPatternColumn,
NowIndexPatternColumn,
IntervalIndexPatternColumn,
} from './formula';
export type { StaticValueIndexPatternColumn } from './static_value';
// List of all operation definitions registered to this data source.
@ -134,6 +146,9 @@ const internalOperationDefinitions = [
overallAverageOperation,
staticValueOperation,
timeScaleOperation,
timeRangeOperation,
nowOperation,
intervalOperation,
];
export { termsOperation } from './terms';
@ -308,7 +323,8 @@ interface BaseOperationDefinitionProps<
columnId: string,
indexPattern: IndexPattern,
dateRange?: DateRange,
operationDefinitionMap?: Record<string, GenericOperationDefinition>
operationDefinitionMap?: Record<string, GenericOperationDefinition>,
targetBars?: number
) => FieldBasedOperationErrorMessage[] | undefined;
/*
@ -338,7 +354,7 @@ interface BaseOperationDefinitionProps<
documentation?: {
signature: string;
description: string;
section: 'elasticsearch' | 'calculation';
section: 'elasticsearch' | 'calculation' | 'constants';
};
quickFunctionDocumentation?: string;
/**
@ -665,7 +681,8 @@ interface ManagedReferenceOperationDefinition<C extends BaseIndexPatternColumn>
toExpression: (
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern
indexPattern: IndexPattern,
context?: { dateRange?: DateRange; now?: Date; targetBars?: number }
) => ExpressionAstFunction[];
/**
* Managed references control the IDs of their inner columns, so we need to be able to copy from the
@ -677,6 +694,18 @@ interface ManagedReferenceOperationDefinition<C extends BaseIndexPatternColumn>
target: DataViewDragDropOperation,
operationDefinitionMap: Record<string, GenericOperationDefinition>
) => Record<string, FormBasedLayer>;
/**
* Special managed columns can be used in a formula
*/
usedInMath?: boolean;
/**
* The specification of the arguments used by the operations used for both validation,
* and use from external managed operations
*/
operationParams?: OperationParam[];
selectionStyle?: 'hidden';
}
interface OperationDefinitionMap<C extends BaseIndexPatternColumn, P = {}> {

View file

@ -39,9 +39,9 @@ import {
OperationDefinition,
} from './definitions';
import { TinymathAST } from '@kbn/tinymath';
import { CoreStart } from '@kbn/core/public';
import { IndexPattern } from '../../../types';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { createCoreStartMock } from '@kbn/core-lifecycle-browser-mocks/src/core_start.mock';
const dataMock = dataPluginMock.createStartContract();
dataMock.query.timefilter.timefilter.getAbsoluteTime = jest
@ -53,6 +53,10 @@ jest.mock('../../../id_generator');
jest.mock('../dimension_panel/reference_editor', () => ({
ReferenceEditor: () => null,
}));
const TARGET_BAR_COUNT = 100;
const CoreStartMock = createCoreStartMock();
CoreStartMock.uiSettings.get.mockReturnValue(TARGET_BAR_COUNT);
const indexPatternFields = [
{
@ -3128,7 +3132,7 @@ describe('state_helpers', () => {
indexPattern,
{},
'1',
{},
CoreStartMock,
dataMock
);
expect(mock).toHaveBeenCalled();
@ -3155,7 +3159,7 @@ describe('state_helpers', () => {
indexPattern,
{} as FormBasedPrivateState,
'1',
{} as CoreStart,
CoreStartMock,
dataMock
);
expect(mock).toHaveBeenCalled();
@ -3191,7 +3195,7 @@ describe('state_helpers', () => {
indexPattern,
{} as FormBasedPrivateState,
'1',
{} as CoreStart,
CoreStartMock,
dataMock
);
expect(notCalledMock).not.toHaveBeenCalled();
@ -3228,7 +3232,7 @@ describe('state_helpers', () => {
indexPattern,
{} as FormBasedPrivateState,
'1',
{} as CoreStart,
CoreStartMock,
dataMock
);
expect(savedRef).toHaveBeenCalled();
@ -3258,7 +3262,7 @@ describe('state_helpers', () => {
indexPattern,
{} as FormBasedPrivateState,
'1',
{} as CoreStart,
CoreStartMock,
dataMock
);
expect(mock).toHaveBeenCalledWith(
@ -3281,7 +3285,8 @@ describe('state_helpers', () => {
fromDate: '2022-11-01T00:00:00.000Z',
toDate: '2022-11-03T00:00:00.000Z',
},
operationDefinitionMap
operationDefinitionMap,
TARGET_BAR_COUNT
);
});
});

View file

@ -9,7 +9,7 @@ import { partition, mapValues, pickBy } from 'lodash';
import { CoreStart } from '@kbn/core/public';
import type { Query } from '@kbn/es-query';
import memoizeOne from 'memoize-one';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataPublicPluginStart, UI_SETTINGS } from '@kbn/data-plugin/public';
import type { DateRange } from '../../../../common/types';
import type {
DatasourceFixAction,
@ -1580,7 +1580,8 @@ export function getErrorMessages(
columnId,
indexPattern,
{ fromDate: currentTimeRange.from, toDate: currentTimeRange.to },
operationDefinitionMap
operationDefinitionMap,
core.uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET)
);
}
})

View file

@ -442,10 +442,27 @@ describe('getOperationTypesForField', () => {
Object {
"operationType": "math",
"type": "managedReference",
"usedInMath": undefined,
},
Object {
"operationType": "formula",
"type": "managedReference",
"usedInMath": undefined,
},
Object {
"operationType": "time_range",
"type": "managedReference",
"usedInMath": true,
},
Object {
"operationType": "now",
"type": "managedReference",
"usedInMath": true,
},
Object {
"operationType": "interval",
"type": "managedReference",
"usedInMath": true,
},
],
},
@ -498,6 +515,7 @@ describe('getOperationTypesForField', () => {
Object {
"operationType": "static_value",
"type": "managedReference",
"usedInMath": undefined,
},
],
},

View file

@ -164,6 +164,7 @@ export type OperationFieldTuple =
| {
type: 'managedReference';
operationType: OperationType;
usedInMath?: boolean;
};
/**
@ -203,7 +204,11 @@ export function getAvailableOperationsByMetadata(
) {
const operationByMetadata: Record<
string,
{ operationMetaData: OperationMetadata; operations: OperationFieldTuple[] }
{
operationMetaData: OperationMetadata;
operations: OperationFieldTuple[];
usedInMath?: boolean;
}
> = {};
const addToMap = (
@ -255,7 +260,10 @@ export function getAvailableOperationsByMetadata(
const validOperation = operationDefinition.getPossibleOperation(indexPattern);
if (validOperation) {
addToMap(
{ type: 'fullReference', operationType: operationDefinition.type },
{
type: 'fullReference',
operationType: operationDefinition.type,
},
validOperation
);
}
@ -263,7 +271,11 @@ export function getAvailableOperationsByMetadata(
const validOperation = operationDefinition.getPossibleOperation();
if (validOperation) {
addToMap(
{ type: 'managedReference', operationType: operationDefinition.type },
{
type: 'managedReference',
operationType: operationDefinition.type,
usedInMath: operationDefinition.usedInMath,
},
validOperation
);
}

View file

@ -8,10 +8,11 @@
import type { IUiSettingsClient } from '@kbn/core/public';
import { partition, uniq } from 'lodash';
import seedrandom from 'seedrandom';
import type {
import {
AggFunctionsMapping,
EsaggsExpressionFunctionDefinition,
IndexPatternLoadExpressionFunctionDefinition,
UI_SETTINGS,
} from '@kbn/data-plugin/public';
import { queryToAst } from '@kbn/data-plugin/common';
import {
@ -58,6 +59,7 @@ function getExpressionForLayer(
indexPattern: IndexPattern,
uiSettings: IUiSettingsClient,
dateRange: DateRange,
nowInstant: Date,
searchSessionId?: string
): ExpressionAstExpression | null {
const { columnOrder } = layer;
@ -132,19 +134,25 @@ function getExpressionForLayer(
if (referenceEntries.length || esAggEntries.length) {
let aggs: ExpressionAstExpressionBuilder[] = [];
const expressions: ExpressionAstFunction[] = [];
const histogramBarsTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET);
sortedReferences(referenceEntries).forEach((colId) => {
const col = columns[colId];
const def = operationDefinitionMap[col.operationType];
if (def.input === 'fullReference' || def.input === 'managedReference') {
expressions.push(...def.toExpression(layer, colId, indexPattern));
expressions.push(
...def.toExpression(layer, colId, indexPattern, {
dateRange,
now: nowInstant,
targetBars: histogramBarsTarget,
})
);
}
});
const orderedColumnIds = esAggEntries.map(([colId]) => colId);
let esAggsIdMap: Record<string, OriginalColumn[]> = {};
const aggExpressionToEsAggsIdMap: Map<ExpressionAstExpressionBuilder, string> = new Map();
const histogramBarsTarget = uiSettings.get('histogram:barTarget');
esAggEntries.forEach(([colId, col], index) => {
const def = operationDefinitionMap[col.operationType];
if (def.input !== 'fullReference' && def.input !== 'managedReference') {
@ -469,6 +477,7 @@ export function toExpression(
indexPatterns: IndexPatternMap,
uiSettings: IUiSettingsClient,
dateRange: DateRange,
nowInstant: Date,
searchSessionId?: string
) {
if (state.layers[layerId]) {
@ -477,6 +486,7 @@ export function toExpression(
indexPatterns[state.layers[layerId].indexPatternId],
uiSettings,
dateRange,
nowInstant,
searchSessionId
);
}

View file

@ -101,7 +101,8 @@ export function isColumnInvalid(
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern,
dateRange: DateRange | undefined
dateRange: DateRange | undefined,
targetBars: number
) {
const column: GenericIndexPatternColumn | undefined = layer.columns[columnId];
if (!column || !indexPattern) return;
@ -111,7 +112,9 @@ export function isColumnInvalid(
const referencesHaveErrors =
true &&
'references' in column &&
Boolean(getReferencesErrors(layer, column, indexPattern, dateRange).filter(Boolean).length);
Boolean(
getReferencesErrors(layer, column, indexPattern, dateRange, targetBars).filter(Boolean).length
);
const operationErrorMessages =
operationDefinition &&
@ -120,7 +123,8 @@ export function isColumnInvalid(
columnId,
indexPattern,
dateRange,
operationDefinitionMap
operationDefinitionMap,
targetBars
);
// it looks like this is just a back-stop since we prevent
@ -138,7 +142,8 @@ function getReferencesErrors(
layer: FormBasedLayer,
column: ReferenceBasedIndexPatternColumn,
indexPattern: IndexPattern,
dateRange: DateRange | undefined
dateRange: DateRange | undefined,
targetBars: number
) {
return column.references?.map((referenceId: string) => {
const referencedOperation = layer.columns[referenceId]?.operationType;
@ -148,7 +153,8 @@ function getReferencesErrors(
referenceId,
indexPattern,
dateRange,
operationDefinitionMap
operationDefinitionMap,
targetBars
);
});
}

View file

@ -680,9 +680,9 @@ describe('Textbased Data Source', () => {
describe('#toExpression', () => {
it('should generate an empty expression when no columns are selected', async () => {
const state = TextBasedDatasource.initialize();
expect(TextBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange)).toEqual(
null
);
expect(
TextBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange, new Date())
).toEqual(null);
});
it('should generate an expression for an SQL query', async () => {
@ -732,8 +732,9 @@ describe('Textbased Data Source', () => {
],
} as unknown as TextBasedPrivateState;
expect(TextBasedDatasource.toExpression(queryBaseState, 'a', indexPatterns, dateRange))
.toMatchInlineSnapshot(`
expect(
TextBasedDatasource.toExpression(queryBaseState, 'a', indexPatterns, dateRange, new Date())
).toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {

View file

@ -178,6 +178,7 @@ export function EditorFrame(props: EditorFrameProps) {
visualizationMap={visualizationMap}
frame={framePublicAPI}
getUserMessages={props.getUserMessages}
nowProvider={props.plugins.data.nowProvider}
/>
</ErrorBoundary>
)

View file

@ -14,6 +14,7 @@ export function getDatasourceExpressionsByLayers(
datasourceStates: DatasourceStates,
indexPatterns: IndexPatternMap,
dateRange: DateRange,
nowInstant: Date,
searchSessionId?: string
): null | Record<string, Ast> {
const datasourceExpressions: Array<[string, Ast | string]> = [];
@ -32,6 +33,7 @@ export function getDatasourceExpressionsByLayers(
layerId,
indexPatterns,
dateRange,
nowInstant,
searchSessionId
);
if (result) {
@ -63,6 +65,7 @@ export function buildExpression({
description,
indexPatterns,
dateRange,
nowInstant,
searchSessionId,
}: {
title?: string;
@ -75,6 +78,7 @@ export function buildExpression({
indexPatterns: IndexPatternMap;
searchSessionId?: string;
dateRange: DateRange;
nowInstant: Date;
}): Ast | null {
if (visualization === null) {
return null;
@ -85,6 +89,7 @@ export function buildExpression({
datasourceStates,
indexPatterns,
dateRange,
nowInstant,
searchSessionId
);

View file

@ -12,7 +12,7 @@ import { difference } from 'lodash';
import type { DataViewsContract, DataViewSpec } from '@kbn/data-views-plugin/public';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { DataPublicPluginStart, TimefilterContract } from '@kbn/data-plugin/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import {
EventAnnotationGroupConfig,
@ -346,6 +346,7 @@ export async function persistedStateToExpression(
storage: IStorageWrapper;
dataViews: DataViewsContract;
timefilter: TimefilterContract;
nowProvider: DataPublicPluginStart['nowProvider'];
eventAnnotationService: EventAnnotationServiceType;
}
): Promise<DocumentToExpressionReturnType> {
@ -432,6 +433,7 @@ export async function persistedStateToExpression(
datasourceLayers,
indexPatterns,
dateRange: { fromDate: currentTimeRange.from, toDate: currentTimeRange.to },
nowInstant: services.nowProvider.get(),
}),
activeVisualizationState,
indexPatterns,

View file

@ -105,6 +105,7 @@ describe('suggestion_panel', () => {
ExpressionRenderer: expressionRendererMock,
frame: createMockFramePublicAPI(),
getUserMessages: () => [],
nowProvider: { get: jest.fn(() => new Date()) },
};
});

View file

@ -25,7 +25,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon';
import { Ast, fromExpression, toExpression } from '@kbn/interpreter';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import { ExecutionContextSearch } from '@kbn/data-plugin/public';
import { DataPublicPluginStart, ExecutionContextSearch } from '@kbn/data-plugin/public';
import {
ReactExpressionRendererProps,
ReactExpressionRendererType,
@ -100,6 +100,7 @@ export interface SuggestionPanelProps {
ExpressionRenderer: ReactExpressionRendererType;
frame: FramePublicAPI;
getUserMessages: UserMessagesGetter;
nowProvider: DataPublicPluginStart['nowProvider'];
}
const PreviewRenderer = ({
@ -219,6 +220,7 @@ export function SuggestionPanel({
frame,
ExpressionRenderer: ExpressionRendererComponent,
getUserMessages,
nowProvider,
}: SuggestionPanelProps) {
const dispatchLens = useLensDispatch();
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
@ -289,7 +291,8 @@ export function SuggestionPanel({
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
frame,
nowProvider
),
}));
@ -303,7 +306,8 @@ export function SuggestionPanel({
visualizationMap[currentVisualization.activeId],
datasourceMap,
currentDatasourceStates,
frame
frame,
nowProvider
)
: undefined;
@ -508,7 +512,8 @@ function getPreviewExpression(
visualization: Visualization,
datasources: Record<string, Datasource>,
datasourceStates: DatasourceStates,
frame: FramePublicAPI
frame: FramePublicAPI,
nowProvider: DataPublicPluginStart['nowProvider']
) {
if (!visualization.toPreviewExpression) {
return null;
@ -546,7 +551,8 @@ function getPreviewExpression(
datasources,
datasourceStates,
frame.dataViews.indexPatterns,
frame.dateRange
frame.dateRange,
nowProvider.get()
);
return visualization.toPreviewExpression(
@ -565,7 +571,8 @@ function preparePreviewExpression(
visualization: Visualization,
datasourceMap: DatasourceMap,
datasourceStates: DatasourceStates,
framePublicAPI: FramePublicAPI
framePublicAPI: FramePublicAPI,
nowProvider: DataPublicPluginStart['nowProvider']
) {
const suggestionDatasourceId = visualizableState.datasourceId;
const suggestionDatasourceState = visualizableState.datasourceState;
@ -585,7 +592,8 @@ function preparePreviewExpression(
visualization,
datasourceMap,
datasourceStatesWithSuggestions,
framePublicAPI
framePublicAPI,
nowProvider
);
if (!expression) {

View file

@ -301,6 +301,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceLayers,
indexPatterns: dataViews.indexPatterns,
dateRange: framePublicAPI.dateRange,
nowInstant: plugins.data.nowProvider.get(),
searchSessionId,
});
@ -347,6 +348,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceLayers,
dataViews.indexPatterns,
framePublicAPI.dateRange,
plugins.data.nowProvider,
searchSessionId,
addUserMessages,
]);

View file

@ -59,6 +59,7 @@ export interface EditorFramePlugins {
uiSettings: IUiSettingsClient;
storage: IStorageWrapper;
timefilter: TimefilterContract;
nowProvider: DataPublicPluginStart['nowProvider'];
eventAnnotationService: EventAnnotationServiceType;
}

View file

@ -46,7 +46,7 @@ export function createMockDatasource(
initialize: jest.fn((_state?) => {}),
renderDataPanel: jest.fn(),
renderLayerPanel: jest.fn(),
toExpression: jest.fn((_frame, _state, _indexPatterns, dateRange) => null),
toExpression: jest.fn((_frame, _state, _indexPatterns, dateRange, nowInstant) => null),
insertLayer: jest.fn((_state, _newLayerId) => ({})),
removeLayer: jest.fn((state, layerId) => ({ newState: state, removedLayerIds: [layerId] })),
cloneLayer: jest.fn((_state, _layerId, _newLayerId, getNewId) => {}),

View file

@ -332,6 +332,7 @@ export class LensPlugin {
storage: new Storage(localStorage),
uiSettings: core.uiSettings,
timefilter: plugins.data.query.timefilter.timefilter,
nowProvider: plugins.data.nowProvider,
eventAnnotationService,
}),
injectFilterReferences: data.query.filterManager.inject.bind(data.query.filterManager),

View file

@ -437,6 +437,7 @@ export interface Datasource<T = unknown, P = unknown> {
layerId: string,
indexPatterns: IndexPatternMap,
dateRange: DateRange,
nowInstant: Date,
searchSessionId?: string
) => ExpressionAstExpression | string | null;

View file

@ -61,7 +61,8 @@ describe('#toExpression', () => {
frame.datasourceLayers.first,
'first',
frame.dataViews.indexPatterns,
frame.dateRange
frame.dateRange,
new Date()
) ?? {
type: 'expression',
chain: [],

View file

@ -77,6 +77,7 @@
"@kbn/core-lifecycle-browser",
"@kbn/core-notifications-browser-mocks",
"@kbn/core-saved-objects-utils-server",
"@kbn/core-lifecycle-browser-mocks",
],
"exclude": [
"target/**/*",