[Lens] Absolute time shift support in formula (#144564)

## Summary

Closes #102493

This PR introduces the ability to define an absolute time shift as a new
type of time shift in Lens.
The idea is to avoid to push down the absolute logic to the agg level,
rather translate as soon as possible the absolute shift into a relative
one (in seconds, the minimal time unit allowed) and perform all checks
in this format.

**Note**:
The feature is currently enabled as formula-only and using it in a Quick
function context will validate it as an invalid shift.

**Details**:
The used format for anchoring right now is:

* `startAt(2022-11-03T18:30:27.278Z)` for start anchoring (from the
given date used as `start` anchor forward to a dynamic end date), and

<img width="1172" alt="Screenshot 2022-11-15 at 15 13 36"
src="https://user-images.githubusercontent.com/924948/201942522-8515f845-b065-448b-bc3b-da72ea8f9de3.png">

* `endAt(2022-11-03T18:30:27.278Z)` for end anchoring (from the given
date backward to a start date dynamically computed)

<img width="1168" alt="Screenshot 2022-11-15 at 15 13 21"
src="https://user-images.githubusercontent.com/924948/201942569-91175316-1cb6-45af-8d32-83eb91d5ad9a.png">

* when an absolute time shift is detected in formula then the shifts
suggestions are translated into absolute shifts

<img width="304" alt="Screenshot 2022-11-15 at 15 18 08"
src="https://user-images.githubusercontent.com/924948/201942631-b4906a11-9de0-4ce0-94f8-16d9576247bf.png">
<img width="281" alt="Screenshot 2022-11-15 at 15 18 17"
src="https://user-images.githubusercontent.com/924948/201942633-20c52277-450e-4191-94a7-74f21a6255af.png">

### Basics

 * [x] Add a Absolute time shift validation function
 * [x] Add a strict shift parser to duration (in seconds)
 * [x] Add a function to extract tokens from raw string
* Enable the feature in Lens
   * [x] formula
     * [x] parsing
     * [x] formula validation
     * [x] suggestion adapted to work with the Absolute shift
   * [x] errors
   * [x] warnings
* interval-based validations are skipped for absolute shifts as issues
are resolved at expression translation time anyway
   * [x] via API (formula helper)

### Implementation details

While the range is computed correctly based on the current interval, the
actual shift is rounded to prevent issues on data fetching. The interval
is computed with an algorithm similar to the one used by ES and the
shift is offset to the closest multiple of that interval (i.e. if the
current shift is `1.78` times the interval, then the shift is rounded to
`2` intervals in a way to include the given date).

### 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: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Marco Liberati 2022-12-07 18:04:07 +01:00 committed by GitHub
parent cf2bbcd957
commit 528f3bd3a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1032 additions and 227 deletions

View file

@ -0,0 +1,166 @@
/*
* 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 moment from 'moment';
import {
isAbsoluteTimeShift,
parseAbsoluteTimeShift,
parseTimeShift,
REASON_IDS,
validateAbsoluteTimeShift,
} from './parse_time_shift';
describe('parse time shifts', () => {
describe('relative time shifts', () => {
it('should return valid duration for valid units', () => {
for (const unit of ['s', 'm', 'h', 'd', 'w', 'M', 'y']) {
expect(moment.isDuration(parseTimeShift(`1${unit}`))).toBeTruthy();
}
});
it('should return previous for the previous string', () => {
expect(parseTimeShift('previous')).toBe('previous');
});
it('should return "invalid" for anything else', () => {
for (const value of ['1a', 's', 'non-valid-string']) {
expect(parseTimeShift(value)).toBe('invalid');
}
});
});
describe('absolute time shifts', () => {
const dateString = '2022-11-02T00:00:00.000Z';
const futureDateString = '3022-11-02T00:00:00.000Z';
function applyTimeZone(zone: string = 'Z') {
return dateString.replace('Z', zone);
}
describe('isAbsoluteTimeShift', () => {
it('should return true for a valid absoluteTimeShift string', () => {
for (const anchor of ['startAt', 'endAt']) {
expect(isAbsoluteTimeShift(`${anchor}(${dateString})`)).toBeTruthy();
}
});
it('should return false for no string passed', () => {
expect(isAbsoluteTimeShift()).toBeFalsy();
});
// that's ok, the function is used to distinguish from the relative shifts
it('should perform only a shallow check on the string', () => {
expect(isAbsoluteTimeShift('startAt(aaaaa)')).toBeTruthy();
});
});
describe('validateAbsoluteTimeShift', () => {
it('should return no error for valid time shifts', () => {
for (const anchor of ['startAt', 'endAt']) {
expect(
validateAbsoluteTimeShift(`${anchor}(${dateString})`, {
from: moment(dateString).add('5', 'd').toISOString(),
to: moment(dateString).add('6', 'd').toISOString(),
})
).toBeUndefined();
}
});
it('should return no error for valid time shifts if no time range is passed', () => {
for (const anchor of ['startAt', 'endAt']) {
expect(validateAbsoluteTimeShift(`${anchor}(${dateString})`)).toBeUndefined();
// This will pass as the range checks are relaxed without the second argument passed
expect(validateAbsoluteTimeShift(`${anchor}(${futureDateString})`)).toBeUndefined();
}
});
it('should return an error if the string value is not an absolute time shift', () => {
for (const val of ['startAt()', 'endAt()', '1d', 'aaa']) {
expect(validateAbsoluteTimeShift(val)).toBe(REASON_IDS.notAbsoluteTimeShift);
}
});
it('should return an error if the passed date is invalid', () => {
for (const val of [
'startAt(a)',
'endAt(a)',
'startAt(2022)',
'startAt(2022-11-02T00:00:00.000)',
'endAt(2022-11-02)',
]) {
expect(validateAbsoluteTimeShift(val)).toBe(REASON_IDS.invalidDate);
}
});
it('should return an error if dateRange is passed and the shift is after that', () => {
for (const anchor of ['startAt', 'endAt']) {
expect(
validateAbsoluteTimeShift(`${anchor}(${futureDateString})`, {
from: moment(dateString).subtract('1', 'd').toISOString(),
to: moment(dateString).add('1', 'd').toISOString(),
})
).toBe(REASON_IDS.shiftAfterTimeRange);
}
});
it('should return no error for dates with non-UTC offset', () => {
for (const anchor of ['startAt', 'endAt']) {
for (const offset of ['Z', '+01:00', '-12:00', '-05']) {
expect(
validateAbsoluteTimeShift(`${anchor}(${applyTimeZone(offset)})`, {
from: moment(dateString).add('5', 'd').toISOString(),
to: moment(dateString).add('6', 'd').toISOString(),
})
).toBeUndefined();
}
}
});
});
describe('parseAbsoluteTimeShift', () => {
it('should return an error if no time range is passed', () => {
for (const anchor of ['startAt', 'endAt']) {
expect(parseAbsoluteTimeShift(`${anchor}(${dateString})`, undefined)).toEqual({
value: 'invalid',
reason: REASON_IDS.missingTimerange,
});
}
});
it('should return an error for invalid dates', () => {
for (const invalidDates of [
'startAt(a)',
'endAt(a)',
'startAt(2022)',
'startAt(2022-11-02T00:00:00.000)',
'endAt(2022-11-02)',
'1d',
'aaa',
`startAt(${futureDateString})`,
]) {
expect(
parseAbsoluteTimeShift(invalidDates, {
from: moment(dateString).add('5', 'd').toISOString(),
to: moment(dateString).add('6', 'd').toISOString(),
}).value
).toBe('invalid');
}
});
it('should return no reason for a valid absolute time shift', () => {
for (const anchor of ['startAt', 'endAt']) {
expect(
parseAbsoluteTimeShift(`${anchor}(${dateString})`, {
from: moment(dateString).add('5', 'd').toISOString(),
to: moment(dateString).add('6', 'd').toISOString(),
}).reason
).toBe(null);
}
});
});
});
});

View file

@ -6,24 +6,140 @@
* Side Public License, v 1.
*/
import moment from 'moment';
import { TimeRange } from '../../../types';
const allowedUnits = ['s', 'm', 'h', 'd', 'w', 'M', 'y'] as const;
type AllowedUnit = typeof allowedUnits[number];
const ALLOWED_UNITS = ['s', 'm', 'h', 'd', 'w', 'M', 'y'] as const;
const ANCHORED_TIME_SHIFT_REGEXP = /^(startAt|endAt)\((.+)\)$/;
const DURATION_REGEXP = /^(\d+)\s*(\w)$/;
const INVALID_DATE = 'invalid';
const PREVIOUS_DATE = 'previous';
const START_AT_ANCHOR = 'startAt';
type AllowedUnit = typeof ALLOWED_UNITS[number];
type PreviousDateType = typeof PREVIOUS_DATE;
type InvalidDateType = typeof INVALID_DATE;
// The ISO8601 format supports also partial date strings as described here:
// https://momentjs.com/docs/#/parsing/string/
// But in this specific case we want to enforce the full version of the ISO8601
// which is build in this case with moment special HTML5 format + the timezone support
const LONG_ISO8601_LIKE_FORMAT = moment.HTML5_FMT.DATETIME_LOCAL_MS + 'Z';
/**
* This method parses a string into a time shift duration.
* If parsing fails, 'invalid' is returned.
* Allowed values are the string 'previous' and an integer followed by the units s,m,h,d,w,M,y
* */
export const parseTimeShift = (val: string): moment.Duration | 'previous' | 'invalid' => {
export const parseTimeShift = (
val: string
): moment.Duration | PreviousDateType | InvalidDateType => {
const trimmedVal = val.trim();
if (trimmedVal === 'previous') {
return 'previous';
if (trimmedVal === PREVIOUS_DATE) {
return PREVIOUS_DATE;
}
const [, amount, unit] = trimmedVal.match(/^(\d+)\s*(\w)$/) || [];
const [, amount, unit] = trimmedVal.match(DURATION_REGEXP) || [];
const parsedAmount = Number(amount);
if (Number.isNaN(parsedAmount) || !allowedUnits.includes(unit as AllowedUnit)) {
return 'invalid';
if (Number.isNaN(parsedAmount) || !ALLOWED_UNITS.includes(unit as AllowedUnit)) {
return INVALID_DATE;
}
return moment.duration(Number(amount), unit as AllowedUnit);
};
/**
* Check function to detect an absolute time shift.
* The check is performed only on the string format and the timestamp is not validated:
* use the validateAbsoluteTimeShift fucntion to perform more in depth checks
* @param val the string to parse (it assumes it has been trimmed already)
* @returns true if an absolute time shift
*/
export const isAbsoluteTimeShift = (val?: string) => {
return val != null && ANCHORED_TIME_SHIFT_REGEXP.test(val);
};
export const REASON_IDS = {
missingTimerange: 'missingTimerange',
notAbsoluteTimeShift: 'notAbsoluteTimeShift',
invalidDate: 'invalidDate',
shiftAfterTimeRange: 'shiftAfterTimeRange',
} as const;
export type REASON_ID_TYPES = keyof typeof REASON_IDS;
/**
* Parses an absolute time shift string and returns its equivalent duration
* @param val the string to parse
* @param timeRange the current date histogram interval
* @returns
*/
export const parseAbsoluteTimeShift = (
val: string,
timeRange: TimeRange | undefined
):
| { value: moment.Duration; reason: null }
| { value: InvalidDateType; reason: REASON_ID_TYPES } => {
const trimmedVal = val.trim();
if (timeRange == null) {
return {
value: INVALID_DATE,
reason: REASON_IDS.missingTimerange,
};
}
const error = validateAbsoluteTimeShift(trimmedVal, timeRange);
if (error) {
return {
value: INVALID_DATE,
reason: error,
};
}
const { anchor, timestamp } = extractTokensFromAbsTimeShift(trimmedVal);
// the regexp test above will make sure anchor and timestamp are both strings
// now be very strict on the format
const tsMoment = moment(timestamp, LONG_ISO8601_LIKE_FORMAT, true);
// workout how long is the ref time range
const duration = moment(timeRange.to).diff(moment(timeRange.from));
// pick the end of the absolute range now
const absRangeEnd = anchor === START_AT_ANCHOR ? tsMoment.add(duration) : tsMoment;
// return (ref end date - shift end date)
return { value: moment.duration(moment(timeRange.to).diff(absRangeEnd)), reason: null };
};
/**
* Fucntion to extract the anchor and timestamp tokens from an absolute time shift
* @param val absolute time shift string
* @returns the anchor and timestamp strings
*/
function extractTokensFromAbsTimeShift(val: string) {
const [, anchor, timestamp] = val.match(ANCHORED_TIME_SHIFT_REGEXP) || [];
return { anchor, timestamp };
}
/**
* Relaxed version of the parsing validation
* This version of the validation applies the timeRange validation only when passed
* @param val
* @param timeRange
* @returns the reason id if the absolute shift is not valid, undefined otherwise
*/
export function validateAbsoluteTimeShift(
val: string,
timeRange?: TimeRange
): REASON_ID_TYPES | undefined {
const trimmedVal = val.trim();
if (!isAbsoluteTimeShift(trimmedVal)) {
return REASON_IDS.notAbsoluteTimeShift;
}
const { anchor, timestamp } = extractTokensFromAbsTimeShift(trimmedVal);
// the regexp test above will make sure anchor and timestamp are both strings
// now be very strict on the format
const tsMoment = moment(timestamp, LONG_ISO8601_LIKE_FORMAT, true);
if (!tsMoment.isValid()) {
return REASON_IDS.invalidDate;
}
if (timeRange) {
const duration = moment(timeRange.to).diff(moment(timeRange.from));
if (
(anchor === START_AT_ANCHOR && tsMoment.isAfter(timeRange.from)) ||
tsMoment.subtract(duration).isAfter(timeRange.from)
)
return REASON_IDS.shiftAfterTimeRange;
}
}

View file

@ -328,6 +328,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
field: currentField || undefined,
filterOperations: props.filterOperations,
visualizationGroups: dimensionGroups,
dateRange,
}),
disabledStatus:
definition.getDisabledStatus &&

View file

@ -1571,6 +1571,23 @@ describe('FormBasedDimensionEditor', () => {
.prop('error')
).toBe('Time shift value is not valid.');
});
it('should mark absolute time shift as invalid', () => {
const props = getProps({
timeShift: 'startAt(2022-11-02T00:00:00.000Z)',
});
wrapper = mount(<FormBasedDimensionEditorComponent {...props} />);
expect(wrapper.find(TimeShift).find(EuiComboBox).prop('isInvalid')).toBeTruthy();
expect(
wrapper
.find(TimeShift)
.find('[data-test-subj="indexPattern-dimension-time-shift-row"]')
.first()
.prop('error')
).toBe('Time shift value is not valid.');
});
});
describe('filtering', () => {

View file

@ -52,14 +52,13 @@ function wrapOnDot(str?: string) {
export const FormBasedDimensionTriggerComponent = function FormBasedDimensionTrigger(
props: FormBasedDimensionTriggerProps
) {
const layerId = props.layerId;
const { columnId, uniqueLabel, invalid, invalidMessage, hideTooltip, layerId, dateRange } = props;
const layer = props.state.layers[layerId];
const currentIndexPattern = props.indexPatterns[layer.indexPatternId];
const { columnId, uniqueLabel, invalid, invalidMessage, hideTooltip } = props;
const currentColumnHasErrors = useMemo(
() => invalid || isColumnInvalid(layer, columnId, currentIndexPattern),
[layer, columnId, currentIndexPattern, invalid]
() => invalid || isColumnInvalid(layer, columnId, currentIndexPattern, dateRange),
[layer, columnId, currentIndexPattern, invalid, dateRange]
);
const selectedColumn: GenericIndexPatternColumn | null = layer.columns[props.columnId] ?? null;

View file

@ -9,7 +9,7 @@ import { EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { EuiComboBox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import { DatatableUtilitiesService, parseTimeShift } from '@kbn/data-plugin/common';
import { type DatatableUtilitiesService, parseTimeShift } from '@kbn/data-plugin/common';
import {
adjustTimeScaleLabelSuffix,
GenericIndexPatternColumn,
@ -21,6 +21,7 @@ import {
getDateHistogramInterval,
getLayerTimeShiftChecks,
timeShiftOptions,
getColumnTimeShiftWarnings,
} from '../time_shift_utils';
import type { IndexPattern } from '../../../types';
@ -90,7 +91,7 @@ export function TimeShift({
activeData,
layerId
);
const { isValueTooSmall, isValueNotMultiple, isInvalid, canShift } =
const { canShift, isValueTooSmall, isValueNotMultiple, isInvalid } =
getLayerTimeShiftChecks(dateHistogramInterval);
if (!canShift) {
@ -99,8 +100,7 @@ export function TimeShift({
const parsedLocalValue = localValue && parseTimeShift(localValue);
const isLocalValueInvalid = Boolean(parsedLocalValue && isInvalid(parsedLocalValue));
const localValueTooSmall = parsedLocalValue && isValueTooSmall(parsedLocalValue);
const localValueNotMultiple = parsedLocalValue && isValueNotMultiple(parsedLocalValue);
const warnings = getColumnTimeShiftWarnings(dateHistogramInterval, localValue);
function getSelectedOption() {
const goodPick = timeShiftOptions.filter(({ value }) => value === localValue);
@ -130,22 +130,13 @@ export function TimeShift({
defaultMessage: 'Enter the time shift number and unit',
})}
error={
(localValueTooSmall &&
i18n.translate('xpack.lens.indexPattern.timeShift.tooSmallHelp', {
defaultMessage:
'Time shift should to be larger than the date histogram interval. Either increase time shift or specify smaller interval in date histogram',
})) ||
(localValueNotMultiple &&
i18n.translate('xpack.lens.indexPattern.timeShift.noMultipleHelp', {
defaultMessage:
'Time shift should be a multiple of the date histogram interval. Either adjust time shift or date histogram interval',
})) ||
warnings[0] ||
(isLocalValueInvalid &&
i18n.translate('xpack.lens.indexPattern.timeShift.genericInvalidHelp', {
defaultMessage: 'Time shift value is not valid.',
}))
}
isInvalid={Boolean(isLocalValueInvalid || localValueTooSmall || localValueNotMultiple)}
isInvalid={Boolean(isLocalValueInvalid || warnings.length)}
>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem>

View file

@ -176,6 +176,10 @@ const expectedIndexPatterns = {
};
const indexPatterns = expectedIndexPatterns;
const dateRange = {
fromDate: '2022-03-17T08:25:00.000Z',
toDate: '2022-04-17T08:25:00.000Z',
};
describe('IndexPattern Data Source', () => {
let baseState: FormBasedPrivateState;
@ -316,7 +320,7 @@ 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, 'testing-seed')
FormBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange, 'testing-seed')
).toEqual(null);
});
@ -341,7 +345,13 @@ describe('IndexPattern Data Source', () => {
},
};
expect(
FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns, 'testing-seed')
FormBasedDatasource.toExpression(
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
)
).toEqual({
chain: [
{
@ -390,7 +400,13 @@ describe('IndexPattern Data Source', () => {
};
expect(
FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns, 'testing-seed')
FormBasedDatasource.toExpression(
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
)
).toMatchInlineSnapshot(`
Object {
"chain": Array [
@ -575,6 +591,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
@ -615,6 +632,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
expect((ast.chain[1].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']);
@ -827,6 +845,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
const count = (ast.chain[1].arguments.aggs[1] as Ast).chain[0];
@ -896,6 +915,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.aggs[0]).toMatchInlineSnapshot(`
@ -1025,6 +1045,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
const timeScaleCalls = ast.chain.filter((fn) => fn.function === 'lens_time_scale');
@ -1095,6 +1116,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
const filteredMetricAgg = (ast.chain[1].arguments.aggs[0] as Ast).chain[0].arguments;
@ -1151,6 +1173,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
const formatIndex = ast.chain.findIndex((fn) => fn.function === 'lens_format_column');
@ -1204,6 +1227,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.metricsAtAllLevels).toEqual([false]);
@ -1248,6 +1272,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp']);
@ -1306,7 +1331,13 @@ describe('IndexPattern Data Source', () => {
const optimizeMock = jest.spyOn(operationDefinitionMap.percentile, 'optimizeEsAggs');
FormBasedDatasource.toExpression(queryBaseState, 'first', indexPatterns, 'testing-seed');
FormBasedDatasource.toExpression(
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
);
expect(operationDefinitionMap.percentile.optimizeEsAggs).toHaveBeenCalledTimes(1);
@ -1378,6 +1409,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
@ -1447,6 +1479,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
@ -1557,6 +1590,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
// @ts-expect-error we can't isolate just the reference type
@ -1595,6 +1629,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
@ -1687,6 +1722,7 @@ describe('IndexPattern Data Source', () => {
queryBaseState,
'first',
indexPatterns,
dateRange,
'testing-seed'
) as Ast;
const chainLength = ast.chain.length;

View file

@ -416,8 +416,8 @@ export function getFormBasedDatasource({
return fields;
},
toExpression: (state, layerId, indexPatterns, searchSessionId) =>
toExpression(state, layerId, indexPatterns, uiSettings, searchSessionId),
toExpression: (state, layerId, indexPatterns, dateRange, searchSessionId) =>
toExpression(state, layerId, indexPatterns, uiSettings, dateRange, searchSessionId),
renderLayerSettings(
domElement: Element,
@ -513,10 +513,10 @@ export function getFormBasedDatasource({
return columnLabelMap;
},
isValidColumn: (state, indexPatterns, layerId, columnId) => {
isValidColumn: (state, indexPatterns, layerId, columnId, dateRange) => {
const layer = state.layers[layerId];
return !isColumnInvalid(layer, columnId, indexPatterns[layer.indexPatternId]);
return !isColumnInvalid(layer, columnId, indexPatterns[layer.indexPatternId], dateRange);
},
renderDimensionTrigger: (

View file

@ -107,6 +107,7 @@ export function FormulaEditor({
setIsCloseable,
dateHistogramInterval,
hasData,
dateRange,
}: Omit<ParamEditorProps<FormulaIndexPatternColumn>, 'activeData'> & {
dateHistogramInterval: ReturnType<typeof getDateHistogramInterval>;
hasData: boolean;
@ -189,6 +190,7 @@ export function FormulaEditor({
{
indexPattern,
operations: operationDefinitionMap,
dateRange,
}
).layer
);
@ -218,6 +220,7 @@ export function FormulaEditor({
{
indexPattern,
operations: operationDefinitionMap,
dateRange,
}
).layer
);
@ -237,7 +240,8 @@ export function FormulaEditor({
layer,
indexPattern,
visibleOperationsMap,
currentColumn
currentColumn,
dateRange
);
if (validationErrors.length) {
errors = validationErrors;
@ -267,6 +271,7 @@ export function FormulaEditor({
{
indexPattern,
operations: operationDefinitionMap,
dateRange,
}
).layer
);
@ -332,6 +337,7 @@ export function FormulaEditor({
{
indexPattern,
operations: operationDefinitionMap,
dateRange,
}
);
@ -348,6 +354,7 @@ export function FormulaEditor({
newLayer,
id,
indexPattern,
dateRange,
visibleOperationsMap
);
if (messages) {
@ -367,14 +374,16 @@ export function FormulaEditor({
const startPosition = offsetToRowColumn(text, locations[id].min);
const endPosition = offsetToRowColumn(text, locations[id].max);
newWarnings.push(
...getColumnTimeShiftWarnings(dateHistogramInterval, column).map((message) => ({
message,
startColumn: startPosition.column + 1,
startLineNumber: startPosition.lineNumber,
endColumn: endPosition.column + 1,
endLineNumber: endPosition.lineNumber,
severity: monaco.MarkerSeverity.Warning,
}))
...getColumnTimeShiftWarnings(dateHistogramInterval, column.timeShift).map(
(message) => ({
message,
startColumn: startPosition.column + 1,
startLineNumber: startPosition.lineNumber,
endColumn: endPosition.column + 1,
endLineNumber: endPosition.lineNumber,
severity: monaco.MarkerSeverity.Warning,
})
)
);
}
}
@ -442,6 +451,7 @@ export function FormulaEditor({
unifiedSearch,
dataViews,
dateHistogramInterval: baseIntervalRef.current,
dateRange,
});
}
} else {
@ -454,6 +464,7 @@ export function FormulaEditor({
unifiedSearch,
dataViews,
dateHistogramInterval: baseIntervalRef.current,
dateRange,
});
}
@ -469,7 +480,7 @@ export function FormulaEditor({
),
};
},
[indexPattern, visibleOperationsMap, unifiedSearch, dataViews, baseIntervalRef]
[indexPattern, visibleOperationsMap, unifiedSearch, dataViews, baseIntervalRef, dateRange]
);
const provideSignatureHelp = useCallback(

View file

@ -202,21 +202,41 @@ describe('math completion', () => {
});
describe('autocomplete', () => {
it('should list all valid functions at the top level (fake test)', async () => {
// This test forces an invalid scenario, since the autocomplete actually requires
// some typing
const results = await suggest({
expression: '',
zeroIndexedOffset: 1,
const dateRange = { fromDate: '2022-11-01T00:00:00.000Z', toDate: '2022-11-03T00:00:00.000Z' };
function getSuggestionArgs({
expression,
zeroIndexedOffset,
triggerCharacter,
}: {
expression: string;
zeroIndexedOffset: number;
triggerCharacter: string;
}) {
return {
expression,
zeroIndexedOffset,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
triggerCharacter: '',
triggerCharacter,
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
dateRange,
};
}
it('should list all valid functions at the top level (fake test)', async () => {
// This test forces an invalid scenario, since the autocomplete actually requires
// some typing
const results = await suggest(
getSuggestionArgs({
expression: '',
zeroIndexedOffset: 1,
triggerCharacter: '',
})
);
expect(results.list).toHaveLength(4 + Object.keys(tinymathFunctions).length);
['sum', 'moving_average', 'cumulative_sum', 'last_value'].forEach((key) => {
expect(results.list).toEqual(expect.arrayContaining([{ label: key, type: 'operation' }]));
@ -227,18 +247,13 @@ describe('math completion', () => {
});
it('should list all valid sub-functions for a fullReference', async () => {
const results = await suggest({
expression: 'moving_average()',
zeroIndexedOffset: 15,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'moving_average()',
zeroIndexedOffset: 15,
triggerCharacter: '(',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toHaveLength(2);
['sum', 'last_value'].forEach((key) => {
expect(results.list).toEqual(expect.arrayContaining([{ label: key, type: 'operation' }]));
@ -246,50 +261,35 @@ describe('math completion', () => {
});
it('should list all valid named arguments for a fullReference', async () => {
const results = await suggest({
expression: 'moving_average(count(),)',
zeroIndexedOffset: 23,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'moving_average(count(),)',
zeroIndexedOffset: 23,
triggerCharacter: ',',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toEqual(['window']);
});
it('should not list named arguments when they are already in use', async () => {
const results = await suggest({
expression: 'moving_average(count(), window=5, )',
zeroIndexedOffset: 34,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'moving_average(count(), window=5, )',
zeroIndexedOffset: 34,
triggerCharacter: ',',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toEqual([]);
});
it('should list all valid positional arguments for a tinymath function used by name', async () => {
const results = await suggest({
expression: 'divide(count(), )',
zeroIndexedOffset: 16,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'divide(count(), )',
zeroIndexedOffset: 16,
triggerCharacter: ',',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toHaveLength(4 + Object.keys(tinymathFunctions).length);
['sum', 'moving_average', 'cumulative_sum', 'last_value'].forEach((key) => {
expect(results.list).toEqual(expect.arrayContaining([{ label: key, type: 'math' }]));
@ -300,18 +300,13 @@ describe('math completion', () => {
});
it('should list all valid positional arguments for a tinymath function used with alias', async () => {
const results = await suggest({
expression: 'count() / ',
zeroIndexedOffset: 10,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'count() / ',
zeroIndexedOffset: 10,
triggerCharacter: ',',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toHaveLength(4 + Object.keys(tinymathFunctions).length);
['sum', 'moving_average', 'cumulative_sum', 'last_value'].forEach((key) => {
expect(results.list).toEqual(expect.arrayContaining([{ label: key, type: 'math' }]));
@ -322,52 +317,87 @@ describe('math completion', () => {
});
it('should not autocomplete any fields for the count function', async () => {
const results = await suggest({
expression: 'count()',
zeroIndexedOffset: 6,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'count()',
zeroIndexedOffset: 6,
triggerCharacter: '(',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toHaveLength(0);
});
it('should autocomplete and validate the right type of field', async () => {
const results = await suggest({
expression: 'sum()',
zeroIndexedOffset: 4,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'sum()',
zeroIndexedOffset: 4,
triggerCharacter: '(',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toEqual(['bytes', 'memory']);
});
it('should autocomplete only operations that provide numeric or date output', async () => {
const results = await suggest({
expression: 'last_value()',
zeroIndexedOffset: 11,
context: {
triggerKind: monaco.languages.CompletionTriggerKind.TriggerCharacter,
const results = await suggest(
getSuggestionArgs({
expression: 'last_value()',
zeroIndexedOffset: 11,
triggerCharacter: '(',
},
indexPattern: createMockedIndexPattern(),
operationDefinitionMap,
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
});
})
);
expect(results.list).toEqual(['bytes', 'memory', 'timestamp', 'start_date']);
});
it('should autocomplete shift parameter with relative suggestions and a couple of abs ones', async () => {
const results = await suggest(
getSuggestionArgs({
expression: `count(shift='')`,
zeroIndexedOffset: 13,
triggerCharacter: '=',
})
);
expect(results.list).toEqual([
'',
'1h',
'3h',
'6h',
'12h',
'1d',
'1w',
'1M',
'3M',
'6M',
'1y',
'previous',
'startAt(2022-11-01T00:00:00.000Z)',
'endAt(2022-11-03T00:00:00.000Z)',
]);
});
it('should autocomplete shift parameter with absolute suggestions once detected', async () => {
const results = await suggest(
getSuggestionArgs({
expression: `count(shift='endAt(')`,
zeroIndexedOffset: 19,
triggerCharacter: '=',
})
);
expect(results.list).toEqual([
'2022-11-03T00:00:00.000Z)',
'2022-11-02T23:00:00.000Z)',
'2022-11-02T21:00:00.000Z)',
'2022-11-02T18:00:00.000Z)',
'2022-11-02T12:00:00.000Z)',
'2022-11-02T00:00:00.000Z)',
'2022-10-27T00:00:00.000Z)',
'2022-10-03T00:00:00.000Z)',
'2022-08-03T00:00:00.000Z)',
'2022-05-03T00:00:00.000Z)',
'2021-11-03T00:00:00.000Z)',
'2022-11-03T00:00:00.000Z)',
]);
});
});
describe('offsetToRowColumn', () => {

View file

@ -22,6 +22,8 @@ import type {
} from '@kbn/unified-search-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { parseTimeShift } from '@kbn/data-plugin/common';
import moment from 'moment';
import { DateRange } from '../../../../../../../common/types';
import type { IndexPattern } from '../../../../../../types';
import { memoizedGetAvailableOperationsByMetadata } from '../../../operations';
import { tinymathFunctions, groupArgsByType, unquotedStringRegex, nonNullable } from '../util';
@ -146,6 +148,7 @@ export async function suggest({
dataViews,
unifiedSearch,
dateHistogramInterval,
dateRange,
}: {
expression: string;
zeroIndexedOffset: number;
@ -155,6 +158,7 @@ export async function suggest({
unifiedSearch: UnifiedSearchPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
dateHistogramInterval?: number;
dateRange: DateRange;
}): Promise<LensMathSuggestions> {
const text =
expression.substr(0, zeroIndexedOffset) + MARKER + expression.substr(zeroIndexedOffset);
@ -177,6 +181,7 @@ export async function suggest({
dataViews,
indexPattern,
dateHistogramInterval,
dateRange,
});
} else if (tokenInfo?.parent) {
return getArgumentSuggestions(
@ -362,32 +367,55 @@ function getArgumentSuggestions(
return { list: [], type: SUGGESTION_TYPE.FIELD };
}
const anchoredAbsoluteTimeShiftRegexp = /^(start|end)At\(/;
function computeAbsSuggestion(dateRange: DateRange, prefix: string, value: string) {
const refDate = prefix.startsWith('s') ? dateRange.fromDate : dateRange.toDate;
return moment(refDate).subtract(parseTimeShift(value), 'ms').toISOString();
}
export async function getNamedArgumentSuggestions({
ast,
unifiedSearch,
dataViews,
indexPattern,
dateHistogramInterval,
dateRange,
}: {
ast: TinymathNamedArgument;
indexPattern: IndexPattern;
unifiedSearch: UnifiedSearchPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
dateHistogramInterval?: number;
dateRange: DateRange;
}) {
if (ast.name === 'shift') {
const validTimeShiftOptions = timeShiftOptions
.filter(({ value }) => {
if (dateHistogramInterval == null) return true;
const parsedValue = parseTimeShift(value);
return (
parsedValue !== 'previous' &&
(parsedValue === 'invalid' ||
Number.isInteger(parsedValue.asMilliseconds() / dateHistogramInterval))
);
})
.map(({ value }) => value);
const absShift = ast.value.split(MARKER)[0];
// Translate the relative time shifts into absolute ones
if (anchoredAbsoluteTimeShiftRegexp.test(absShift)) {
return {
list: validTimeShiftOptions.map(
(value) => `${computeAbsSuggestion(dateRange, absShift, value)})`
),
type: SUGGESTION_TYPE.SHIFTS,
};
}
const extraAbsSuggestions = ['startAt', 'endAt'].map(
(prefix) => `${prefix}(${computeAbsSuggestion(dateRange, prefix, validTimeShiftOptions[0])})`
);
return {
list: timeShiftOptions
.filter(({ value }) => {
if (typeof dateHistogramInterval === 'undefined') return true;
const parsedValue = parseTimeShift(value);
return (
parsedValue !== 'previous' &&
(parsedValue === 'invalid' ||
Number.isInteger(parsedValue.asMilliseconds() / dateHistogramInterval))
);
})
.map(({ value }) => value),
list: validTimeShiftOptions.concat(extraAbsSuggestions),
type: SUGGESTION_TYPE.SHIFTS,
};
}

View file

@ -27,7 +27,14 @@ jest.mock('../../layer_helpers', () => {
getColumnOrder: jest.fn(({ columns }: { columns: Record<string, GenericIndexPatternColumn> }) =>
Object.keys(columns)
),
getManagedColumnsFrom: jest.fn().mockReturnValue([]),
getManagedColumnsFrom: jest
.fn()
.mockImplementation(
(
id: string,
columns: Record<string, Extract<GenericIndexPatternColumn, { references: string[] }>>
) => columns[id].references?.map((colId) => [colId, {}]) || []
),
};
});
@ -893,7 +900,7 @@ describe('formula', () => {
function getNewLayerWithFormula(
formula: string,
isBroken = true,
columnParams: Partial<Pick<FormulaIndexPatternColumn, 'filter'>> = {}
columnParams: Partial<Pick<FormulaIndexPatternColumn, 'filter' | 'references'>> = {}
): FormBasedLayer {
return {
columns: {
@ -922,6 +929,7 @@ describe('formula', () => {
getNewLayerWithFormula('count()'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -933,6 +941,7 @@ describe('formula', () => {
getNewLayerWithFormula(`count(kql='*')`, false),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -944,6 +953,7 @@ describe('formula', () => {
getNewLayerWithFormula(`count(kql='invalid: "')`, false),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -959,6 +969,7 @@ invalid: "
getNewLayerWithFormula('average(bytes)'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -968,6 +979,7 @@ invalid: "
getNewLayerWithFormula('average("bytes")'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -979,6 +991,7 @@ invalid: "
getNewLayerWithFormula('derivative(average(bytes))'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -988,6 +1001,7 @@ invalid: "
getNewLayerWithFormula('derivative(average("bytes"))'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -999,6 +1013,7 @@ invalid: "
getNewLayerWithFormula('moving_average(average(bytes), window=7)'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1010,6 +1025,7 @@ invalid: "
getNewLayerWithFormula('bytes'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The field bytes cannot be used without operation`]);
@ -1019,6 +1035,7 @@ invalid: "
getNewLayerWithFormula('bytes + bytes'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The operation add does not accept any field as argument`]);
@ -1040,6 +1057,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The Formula ${formula} cannot be parsed`]);
@ -1060,6 +1078,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Field noField not found']);
@ -1075,6 +1094,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Fields noField, noField2 not found']);
@ -1090,6 +1110,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Operation noFn not found']);
@ -1103,6 +1124,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Operations noFn, noFnTwo not found']);
@ -1118,6 +1140,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Operation formula not found']);
@ -1131,6 +1154,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Operation math not found']);
@ -1154,6 +1178,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(
@ -1173,6 +1198,7 @@ invalid: "
getNewLayerWithFormula('count()'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1184,6 +1210,7 @@ invalid: "
getNewLayerWithFormula('moving_average(average(bytes))'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1195,6 +1222,7 @@ invalid: "
getNewLayerWithFormula('moving_average(average(bytes), myparam=7)'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1208,6 +1236,7 @@ invalid: "
getNewLayerWithFormula('average(bytes, myparam=7)'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['The operation average does not accept any parameter']);
@ -1228,6 +1257,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(
@ -1258,6 +1288,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(
@ -1276,6 +1307,7 @@ invalid: "
getNewLayerWithFormula('moving_average(average(bytes), window="m")'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1295,6 +1327,7 @@ invalid: "
`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1315,6 +1348,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1353,6 +1387,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(expect.arrayContaining([expect.stringMatching(`Single quotes are required`)]));
@ -1383,6 +1418,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The Formula ${formula} cannot be parsed`]);
@ -1401,6 +1437,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1413,6 +1450,7 @@ invalid: "
getNewLayerWithFormula(`count(kql='category.keyword: *', lucene='category.keyword: *')`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(['Use only one of kql= or lucene=, not both']);
@ -1425,6 +1463,7 @@ invalid: "
getNewLayerWithFormula(`${fn}()`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The first argument for ${fn} should be a field name. Found no field`]);
@ -1434,6 +1473,7 @@ invalid: "
getNewLayerWithFormula(`sum(kql='category.keyword: *')`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The first argument for sum should be a field name. Found category.keyword: *`]);
@ -1446,6 +1486,7 @@ invalid: "
getNewLayerWithFormula(`${fn}()`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([`The first argument for ${fn} should be a operation name. Found no operation`]);
@ -1453,9 +1494,11 @@ invalid: "
});
it('returns an error if the formula is fully static and there is at least one bucket dimension', () => {
const formulaLayer = getNewLayerWithFormula('5 + 3 * 7');
const formulaLayer = getNewLayerWithFormula('5 + 3 * 7', false, { references: ['col1X'] });
expect(
formulaOperation.getErrorMessage!(
// this became a little bit more tricker as now it takes into account the number of references
// for the error
{
...formulaLayer,
columns: {
@ -1473,6 +1516,7 @@ invalid: "
},
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1486,6 +1530,7 @@ invalid: "
getNewLayerWithFormula('5 + 3 * 7'),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1502,6 +1547,7 @@ invalid: "
getNewLayerWithFormula(`ifelse(${formula}, 1, 5)`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1520,6 +1566,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1538,6 +1585,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);
@ -1560,6 +1608,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1633,6 +1682,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toContain(errors[i](fn));
@ -1653,6 +1703,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1697,6 +1748,7 @@ invalid: "
getNewLayerWithFormula(`${fn}(${cond}, ${left}, ${right})`),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(
@ -1723,6 +1775,7 @@ invalid: "
getNewLayerWithFormula(formula),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([errorsWithSuggestions[i]]);
@ -1751,6 +1804,7 @@ invalid: "
}),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1771,6 +1825,7 @@ invalid: "
),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual([
@ -1795,6 +1850,7 @@ invalid: "
}),
'col1',
indexPattern,
undefined,
operationDefinitionMap
)
).toEqual(undefined);

View file

@ -62,7 +62,7 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
getDisabledStatus(indexPattern: IndexPattern) {
return undefined;
},
getErrorMessage(layer, columnId, indexPattern, operationDefinitionMap) {
getErrorMessage(layer, columnId, indexPattern, dateRange, operationDefinitionMap) {
const column = layer.columns[columnId] as FormulaIndexPatternColumn;
if (!column.params.formula || !operationDefinitionMap) {
return;
@ -74,7 +74,14 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
return error?.message ? [error.message] : [];
}
const errors = runASTValidation(root, layer, indexPattern, visibleOperationsMap, column);
const errors = runASTValidation(
root,
layer,
indexPattern,
visibleOperationsMap,
column,
dateRange
);
if (errors.length) {
// remove duplicates
@ -86,7 +93,13 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
.flatMap(([id, col]) => {
const def = visibleOperationsMap[col.operationType];
if (def?.getErrorMessage) {
const messages = def.getErrorMessage(layer, id, indexPattern, visibleOperationsMap);
const messages = def.getErrorMessage(
layer,
id,
indexPattern,
dateRange,
visibleOperationsMap
);
return messages ? { message: messages.join(', ') } : [];
}
return [];
@ -102,8 +115,13 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
col.operationType !== 'formula'
);
});
// What happens when it transition from an error state to a new valid state?
// the "hasOtherMetrics" might be false as the formula hasn't had time to
// populate all the referenced columns yet. So check if there are managedColumns
// (if no error is present, there's at least one other math column present)
const hasBeenEvaluated = !errors.length && managedColumns.length;
if (hasBuckets && !hasOtherMetrics) {
if (hasBuckets && !hasOtherMetrics && hasBeenEvaluated) {
innerErrors.push({
message: i18n.translate('xpack.lens.indexPattern.noRealMetricError', {
defaultMessage:

View file

@ -11,10 +11,18 @@ import { createFormulaPublicApi, FormulaPublicApi } from './formula_public_api';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DateHistogramIndexPatternColumn, PersistedIndexPatternLayer } from '../../../types';
import { convertDataViewIntoLensIndexPattern } from '../../../../../data_views_service/loader';
import moment from 'moment';
import type { FormulaIndexPatternColumn } from './formula';
jest.mock('./parse', () => ({
insertOrReplaceFormulaColumn: jest.fn().mockReturnValue({}),
}));
jest.mock('./parse', () => {
const original = jest.requireActual('./parse');
return {
...original,
insertOrReplaceFormulaColumn: jest.fn((...args) =>
original.insertOrReplaceFormulaColumn(...args)
),
};
});
jest.mock('../../../../../data_views_service/loader', () => ({
convertDataViewIntoLensIndexPattern: jest.fn((v) => v),
@ -153,4 +161,80 @@ describe('createFormulaPublicApi', () => {
{ indexPattern: {} }
);
});
test('should accept an absolute time shift for shiftable operations', () => {
const baseLayer = getBaseLayer();
const dateString = '2022-11-02T00:00:00.000Z';
// shift by 2 days + 2500 s (to get a shift which is not a multiple of the given interval)
const shiftedDate = moment(dateString).subtract(175300, 's').toISOString();
const result = publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{
formula: `count(shift='startAt(${shiftedDate})') - count(shift='endAt(${shiftedDate})')`,
},
baseLayer,
dataView
);
expect((result?.columns.col as FormulaIndexPatternColumn).params.isFormulaBroken).toBe(false);
});
test('should perform more validations for absolute time shifts if dateRange is passed', () => {
const baseLayer = getBaseLayer();
const dateString = '2022-11-02T00:00:00.000Z';
// date in the future
const shiftedDate = '3022-11-02T00:00:00.000Z';
const dateRange = {
fromDate: moment(dateString).subtract('1', 'd').toISOString(),
toDate: moment(dateString).add('1', 'd').toISOString(),
};
const result = publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{
formula: `count(shift='startAt(${shiftedDate})') - count(shift='endAt(${shiftedDate})')`,
},
baseLayer,
dataView,
dateRange
);
expect((result?.columns.col as FormulaIndexPatternColumn).params.isFormulaBroken).toBe(true);
});
test('should perform format-only validation if no date range is passed', () => {
const baseLayer = getBaseLayer();
const result = publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{
formula: `count(shift='startAt(invalid)') - count(shift='endAt(3022)')`,
},
baseLayer,
dataView
);
expect((result?.columns.col as FormulaIndexPatternColumn).params.isFormulaBroken).toBe(true);
});
test('should not detect date in the future error if no date range is passed', () => {
const baseLayer = getBaseLayer();
// date in the future
const shiftedDate = '3022-11-02T00:00:00.000Z';
const result = publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{
formula: `count(shift='startAt(${shiftedDate})') - count(shift='endAt(${shiftedDate})')`,
},
baseLayer,
dataView
);
expect((result?.columns.col as FormulaIndexPatternColumn).params.isFormulaBroken).toBe(false);
});
});

View file

@ -7,6 +7,7 @@
import type { DataView } from '@kbn/data-views-plugin/public';
import { Query } from '@kbn/es-query';
import type { DateRange } from '../../../../../../common';
import { convertDataViewIntoLensIndexPattern } from '../../../../../data_views_service/loader';
import type { IndexPattern } from '../../../../../types';
import type { PersistedIndexPatternLayer } from '../../../types';
@ -44,7 +45,8 @@ export interface FormulaPublicApi {
};
},
layer: PersistedIndexPatternLayer,
dataView: DataView
dataView: DataView,
dateRange?: DateRange
) => PersistedIndexPatternLayer | undefined;
}
@ -67,7 +69,8 @@ export const createFormulaPublicApi = (): FormulaPublicApi => {
id,
{ formula, label, format, filter, reducedTimeRange, timeScale },
layer,
dataView
dataView,
dateRange
) => {
const indexPattern = getCachedLensIndexPattern(dataView);
@ -89,7 +92,7 @@ export const createFormulaPublicApi = (): FormulaPublicApi => {
},
},
{ ...layer, indexPatternId: indexPattern.id },
{ indexPattern }
{ indexPattern, dateRange }
).layer;
},
};

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import { isObject } from 'lodash';
import type { TinymathAST, TinymathVariable, TinymathLocation } from '@kbn/tinymath';
import type { DateRange } from '../../../../../../common/types';
import type { IndexPattern } from '../../../../../types';
import {
OperationDefinition,
@ -41,6 +42,7 @@ function parseAndExtract(
columnId: string,
indexPattern: IndexPattern,
operations: Record<string, GenericOperationDefinition>,
dateRange: DateRange | undefined,
label?: string
) {
const { root, error } = tryToParse(text, operations);
@ -48,7 +50,14 @@ function parseAndExtract(
return { extracted: [], isValid: false };
}
// before extracting the data run the validation task and throw if invalid
const errors = runASTValidation(root, layer, indexPattern, operations, layer.columns[columnId]);
const errors = runASTValidation(
root,
layer,
indexPattern,
operations,
layer.columns[columnId],
dateRange
);
if (errors.length) {
return { extracted: [], isValid: false };
}
@ -210,6 +219,8 @@ function extractColumns(
interface ExpandColumnProperties {
indexPattern: IndexPattern;
operations?: Record<string, GenericOperationDefinition>;
dateRange?: DateRange;
strictShiftValidation?: boolean;
}
const getEmptyColumnsWithFormulaMeta = (): {
@ -228,7 +239,7 @@ function generateFormulaColumns(
id: string,
column: FormulaIndexPatternColumn,
layer: FormBasedLayer,
{ indexPattern, operations = operationDefinitionMap }: ExpandColumnProperties
{ indexPattern, operations = operationDefinitionMap, dateRange }: ExpandColumnProperties
) {
const { columns, meta } = getEmptyColumnsWithFormulaMeta();
const formula = column.params.formula || '';
@ -239,6 +250,7 @@ function generateFormulaColumns(
id,
indexPattern,
filterByVisibleOperation(operations),
dateRange,
column.customLabel ? column.label : undefined
);

View file

@ -11,7 +11,14 @@ import { parse, TinymathLocation, TinymathVariable } from '@kbn/tinymath';
import type { TinymathAST, TinymathFunction, TinymathNamedArgument } from '@kbn/tinymath';
import { luceneStringToDsl, toElasticsearchQuery, fromKueryExpression } from '@kbn/es-query';
import type { Query } from '@kbn/es-query';
import { parseTimeShift } from '@kbn/data-plugin/common';
import {
isAbsoluteTimeShift,
parseTimeShift,
REASON_IDS,
REASON_ID_TYPES,
validateAbsoluteTimeShift,
} from '@kbn/data-plugin/common';
import { DateRange } from '../../../../../../common/types';
import {
findMathNodes,
findVariables,
@ -437,12 +444,13 @@ export function runASTValidation(
layer: FormBasedLayer,
indexPattern: IndexPattern,
operations: Record<string, GenericOperationDefinition>,
currentColumn: GenericIndexPatternColumn
currentColumn: GenericIndexPatternColumn,
dateRange?: DateRange
) {
return [
...checkMissingVariableOrFunctions(ast, layer, indexPattern, operations),
...checkTopNodeReturnType(ast),
...runFullASTValidation(ast, layer, indexPattern, operations, currentColumn),
...runFullASTValidation(ast, layer, indexPattern, operations, dateRange, currentColumn),
];
}
@ -508,9 +516,31 @@ function checkMissingVariableOrFunctions(
return [...missingErrors, ...invalidVariableErrors];
}
function getAbsoluteTimeShiftErrorMessage(reason: REASON_ID_TYPES) {
switch (reason) {
case REASON_IDS.missingTimerange:
return i18n.translate('xpack.lens.indexPattern.absoluteMissingTimeRange', {
defaultMessage: 'Invalid time shift. No time range found as reference',
});
case REASON_IDS.invalidDate:
return i18n.translate('xpack.lens.indexPattern.absoluteInvalidDate', {
defaultMessage: 'Invalid time shift. The date is not of the correct format',
});
case REASON_IDS.shiftAfterTimeRange:
return i18n.translate('xpack.lens.indexPattern.absoluteAfterTimeRange', {
defaultMessage: 'Invalid time shift. The provided date is after the current time range',
});
case REASON_IDS.notAbsoluteTimeShift:
return i18n.translate('xpack.lens.indexPattern.notAbsoluteTimeShift', {
defaultMessage: 'Invalid time shift.',
});
}
}
function getQueryValidationErrors(
namedArguments: TinymathNamedArgument[] | undefined,
indexPattern: IndexPattern
indexPattern: IndexPattern,
dateRange: DateRange | undefined
): ErrorWrapper[] {
const errors: ErrorWrapper[] = [];
(namedArguments ?? []).forEach((arg) => {
@ -530,16 +560,34 @@ function getQueryValidationErrors(
if (arg.name === 'shift') {
const parsedShift = parseTimeShift(arg.value);
if (parsedShift === 'invalid') {
errors.push({
message: i18n.translate('xpack.lens.indexPattern.invalidTimeShift', {
defaultMessage:
'Invalid time shift. Enter positive integer amount followed by one of the units s, m, h, d, w, M, y. For example 3h for 3 hours',
}),
locations: [arg.location],
});
if (isAbsoluteTimeShift(arg.value)) {
// try to parse as absolute time shift
const error = validateAbsoluteTimeShift(
arg.value,
dateRange
? {
from: dateRange.fromDate,
to: dateRange.toDate,
}
: undefined
);
if (error) {
errors.push({
message: getAbsoluteTimeShiftErrorMessage(error),
locations: [arg.location],
});
}
} else {
errors.push({
message: i18n.translate('xpack.lens.indexPattern.invalidTimeShift', {
defaultMessage:
'Invalid time shift. Enter positive integer amount followed by one of the units s, m, h, d, w, M, y. For example 3h for 3 hours',
}),
locations: [arg.location],
});
}
}
}
if (arg.name === 'reducedTimeRange') {
const parsedReducedTimeRange = parseTimeShift(arg.value || '');
if (parsedReducedTimeRange === 'invalid' || parsedReducedTimeRange === 'previous') {
@ -600,7 +648,8 @@ function validateNameArguments(
| OperationDefinition<GenericIndexPatternColumn, 'field'>
| OperationDefinition<GenericIndexPatternColumn, 'fullReference'>,
namedArguments: TinymathNamedArgument[] | undefined,
indexPattern: IndexPattern
indexPattern: IndexPattern,
dateRange: DateRange | undefined
) {
const errors = [];
const missingParams = getMissingParams(nodeOperation, namedArguments);
@ -642,7 +691,7 @@ function validateNameArguments(
})
);
}
const queryValidationErrors = getQueryValidationErrors(namedArguments, indexPattern);
const queryValidationErrors = getQueryValidationErrors(namedArguments, indexPattern, dateRange);
if (queryValidationErrors.length) {
errors.push(...queryValidationErrors);
}
@ -685,6 +734,7 @@ function runFullASTValidation(
layer: FormBasedLayer,
indexPattern: IndexPattern,
operations: Record<string, GenericOperationDefinition>,
dateRange?: DateRange,
currentColumn?: GenericIndexPatternColumn
): ErrorWrapper[] {
const missingVariables = findVariables(ast).filter(
@ -783,7 +833,8 @@ function runFullASTValidation(
node,
nodeOperation,
namedArguments,
indexPattern
indexPattern,
dateRange
);
const filtersErrors = validateFiltersArguments(
@ -860,7 +911,8 @@ function runFullASTValidation(
node,
nodeOperation,
namedArguments,
indexPattern
indexPattern,
dateRange
);
const filtersErrors = validateFiltersArguments(
node,

View file

@ -310,6 +310,7 @@ interface BaseOperationDefinitionProps<
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern,
dateRange?: DateRange,
operationDefinitionMap?: Record<string, GenericOperationDefinition>
) =>
| Array<

View file

@ -32,6 +32,10 @@ jest.mock('lodash', () => {
});
const uiSettingsMock = {} as IUiSettingsClient;
const dateRange = {
fromDate: '2022-03-17T08:25:00.000Z',
toDate: '2022-04-17T08:25:00.000Z',
};
const defaultProps = {
storage: {} as IStorageWrapper,
@ -152,7 +156,8 @@ describe('static_value', () => {
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue('23'),
'col2',
createMockedIndexPattern()
createMockedIndexPattern(),
dateRange
)
).toBeUndefined();
// test for potential falsy value
@ -160,7 +165,8 @@ describe('static_value', () => {
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue('0'),
'col2',
createMockedIndexPattern()
createMockedIndexPattern(),
dateRange
)
).toBeUndefined();
});
@ -172,7 +178,8 @@ describe('static_value', () => {
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue(value),
'col2',
createMockedIndexPattern()
createMockedIndexPattern(),
dateRange
)
).toEqual(expect.arrayContaining([expect.stringMatching('is not a valid number')]));
}
@ -183,7 +190,8 @@ describe('static_value', () => {
staticValueOperation.getErrorMessage!(
getLayerWithStaticValue(value),
'col2',
createMockedIndexPattern()
createMockedIndexPattern(),
dateRange
)
).toBe(undefined);
});

View file

@ -42,6 +42,9 @@ import { IndexPattern } from '../../../types';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
const dataMock = dataPluginMock.createStartContract();
dataMock.query.timefilter.timefilter.getAbsoluteTime = jest
.fn()
.mockReturnValue({ from: '2022-11-01T00:00:00.000Z', to: '2022-11-03T00:00:00.000Z' });
jest.mock('.');
jest.mock('../../../id_generator');
@ -3202,6 +3205,10 @@ describe('state_helpers', () => {
},
'col1',
indexPattern,
{
fromDate: '2022-11-01T00:00:00.000Z',
toDate: '2022-11-03T00:00:00.000Z',
},
operationDefinitionMap
);
});

View file

@ -10,6 +10,7 @@ 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 type { DateRange } from '../../../../common';
import type {
DatasourceFixAction,
FrameDatasourceAPI,
@ -903,8 +904,10 @@ export function canTransition({
indexPattern,
filterOperations,
visualizationGroups,
dateRange,
}: ColumnChange & {
filterOperations: (meta: OperationMetadata) => boolean;
dateRange: DateRange;
}): boolean {
const previousColumn = layer.columns[columnId];
if (!previousColumn) {
@ -930,7 +933,7 @@ export function canTransition({
Boolean(newColumn) &&
!newLayer.incompleteColumns?.[columnId] &&
filterOperations(newColumn) &&
!newDefinition.getErrorMessage?.(newLayer, columnId, indexPattern)?.length
!newDefinition.getErrorMessage?.(newLayer, columnId, indexPattern, dateRange)?.length
);
} catch (e) {
return false;
@ -1574,7 +1577,14 @@ export function getErrorMessages(
}
const def = operationDefinitionMap[column.operationType];
if (def.getErrorMessage) {
return def.getErrorMessage(layer, columnId, indexPattern, operationDefinitionMap);
const currentTimeRange = data.query.timefilter.timefilter.getAbsoluteTime();
return def.getErrorMessage(
layer,
columnId,
indexPattern,
{ fromDate: currentTimeRange.from, toDate: currentTimeRange.to },
operationDefinitionMap
);
}
})
.map((errorMessage) => {

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { getDisallowedPreviousShiftMessage } from './time_shift_utils';
import moment from 'moment';
import { getDisallowedPreviousShiftMessage, resolveTimeShift } from './time_shift_utils';
import { FormBasedLayer } from './types';
describe('time_shift_utils', () => {
@ -78,4 +79,51 @@ describe('time_shift_utils', () => {
).toBeUndefined();
});
});
describe('resolveTimeShift', () => {
const dateString = '2022-11-02T00:00:00.000Z';
// shift by 2 days + 2500 s (to get a shift which is not a multiple of the given interval)
const shiftedDate = moment(dateString).subtract(175300, 's').toISOString();
function getDateRange(val = dateString) {
return {
fromDate: moment(val).subtract('1', 'd').toISOString(),
toDate: moment(val).add('1', 'd').toISOString(),
};
}
it('should not change a relative time shift', () => {
for (const val of ['1d', 'previous']) {
expect(resolveTimeShift(val, getDateRange(), 100)).toBe(val);
}
});
it('should change absolute values to relative in seconds (rounded) with start anchor', () => {
expect(resolveTimeShift(`startAt(${shiftedDate})`, getDateRange(), 100))
// the raw value is 88900s, but that's not a multiple of the range interval
// so it will be rounded to the next interval multiple, then decremented by 1 interval unit (1800s)
// in order to include the provided date
.toBe('90000s');
});
it('should change absolute values to relative in seconds (rounded) with end anchor', () => {
expect(resolveTimeShift(`endAt(${shiftedDate})`, getDateRange(), 100))
// the raw value is 261700s, but that's not a multiple of the range interval
// so it will be rounded to the next interval multiple
.toBe('261000s');
});
it('should always include the passed date in the computed interval', () => {
const dateRange = getDateRange();
for (const anchor of ['startAt', 'endAt']) {
const [shift] = resolveTimeShift(`${anchor}(${shiftedDate})`, dateRange, 100)!.split('s');
expect(
moment(shiftedDate).isBetween(
moment(dateRange.fromDate).subtract(Number(shift), 's'),
moment(dateRange.toDate).subtract(Number(shift), 's')
)
);
}
});
});
});

View file

@ -7,15 +7,28 @@
import { i18n } from '@kbn/i18n';
import React from 'react';
import { uniq } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import moment from 'moment';
import { Datatable } from '@kbn/expressions-plugin/common';
import { search } from '@kbn/data-plugin/public';
import { parseTimeShift } from '@kbn/data-plugin/common';
import type { GenericIndexPatternColumn, FormBasedLayer, FormBasedPrivateState } from './types';
import {
calcAutoIntervalNear,
DatatableUtilitiesService,
isAbsoluteTimeShift,
parseAbsoluteTimeShift,
parseTimeShift,
} from '@kbn/data-plugin/common';
import type { DateRange } from '../../../common/types';
import type { FormBasedLayer, FormBasedPrivateState } from './types';
import type { FramePublicAPI, IndexPattern } from '../../types';
export function parseTimeShiftWrapper(timeShiftString: string, dateRange: DateRange) {
return isAbsoluteTimeShift(timeShiftString.trim())
? parseAbsoluteTimeShift(timeShiftString, { from: dateRange.fromDate, to: dateRange.toDate })
.value
: parseTimeShift(timeShiftString);
}
export const timeShiftOptions = [
{
label: i18n.translate('xpack.lens.indexPattern.timeShift.none', {
@ -134,7 +147,7 @@ export function getLayerTimeShiftChecks({
}: ReturnType<typeof getDateHistogramInterval>) {
return {
canShift,
isValueTooSmall: (parsedValue: ReturnType<typeof parseTimeShift>) => {
isValueTooSmall: (parsedValue: ReturnType<typeof parseTimeShiftWrapper>) => {
return (
dateHistogramInterval &&
parsedValue &&
@ -142,7 +155,7 @@ export function getLayerTimeShiftChecks({
parsedValue.asMilliseconds() < dateHistogramInterval.asMilliseconds()
);
},
isValueNotMultiple: (parsedValue: ReturnType<typeof parseTimeShift>) => {
isValueNotMultiple: (parsedValue: ReturnType<typeof parseTimeShiftWrapper>) => {
return (
dateHistogramInterval &&
parsedValue &&
@ -150,7 +163,7 @@ export function getLayerTimeShiftChecks({
!Number.isInteger(parsedValue.asMilliseconds() / dateHistogramInterval.asMilliseconds())
);
},
isInvalid: (parsedValue: ReturnType<typeof parseTimeShift>) => {
isInvalid: (parsedValue: ReturnType<typeof parseTimeShiftWrapper>) => {
return Boolean(
parsedValue === 'invalid' || (hasDateHistogram && parsedValue && parsedValue === 'previous')
);
@ -164,7 +177,9 @@ export function getDisallowedPreviousShiftMessage(
): string[] | undefined {
const currentColumn = layer.columns[columnId];
const hasPreviousShift =
currentColumn.timeShift && parseTimeShift(currentColumn.timeShift) === 'previous';
currentColumn.timeShift &&
!isAbsoluteTimeShift(currentColumn.timeShift) &&
parseTimeShift(currentColumn.timeShift) === 'previous';
if (!hasPreviousShift) {
return;
}
@ -209,27 +224,28 @@ export function getStateTimeShiftWarningMessages(
}
const dateHistogramIntervalExpression = dateHistogramInterval.expression;
const shiftInterval = dateHistogramInterval.interval.asMilliseconds();
let timeShifts: number[] = [];
const timeShifts = new Set<number>();
const timeShiftMap: Record<number, string[]> = {};
Object.entries(layer.columns).forEach(([columnId, column]) => {
if (column.isBucketed) return;
let duration: number = 0;
if (column.timeShift) {
// skip absolute time shifts as underneath it will be converted to be round
// and avoid this type of issues
if (column.timeShift && !isAbsoluteTimeShift(column.timeShift)) {
const parsedTimeShift = parseTimeShift(column.timeShift);
if (parsedTimeShift === 'previous' || parsedTimeShift === 'invalid') {
return;
}
duration = parsedTimeShift.asMilliseconds();
}
timeShifts.push(duration);
timeShifts.add(duration);
if (!timeShiftMap[duration]) {
timeShiftMap[duration] = [];
}
timeShiftMap[duration].push(columnId);
});
timeShifts = uniq(timeShifts);
if (timeShifts.length < 2) {
if (timeShifts.size < 2) {
return;
}
@ -273,13 +289,16 @@ export function getStateTimeShiftWarningMessages(
export function getColumnTimeShiftWarnings(
dateHistogramInterval: ReturnType<typeof getDateHistogramInterval>,
column: GenericIndexPatternColumn
timeShift: string | undefined
) {
const { isValueTooSmall, isValueNotMultiple } = getLayerTimeShiftChecks(dateHistogramInterval);
const warnings: string[] = [];
if (isAbsoluteTimeShift(timeShift)) {
return warnings;
}
const parsedLocalValue = column.timeShift && parseTimeShift(column.timeShift);
const parsedLocalValue = timeShift && parseTimeShift(timeShift);
const localValueTooSmall = parsedLocalValue && isValueTooSmall(parsedLocalValue);
const localValueNotMultiple = parsedLocalValue && isValueNotMultiple(parsedLocalValue);
if (localValueTooSmall) {
@ -299,3 +318,38 @@ export function getColumnTimeShiftWarnings(
}
return warnings;
}
function closestMultipleOfInterval(duration: number, interval: number) {
if (duration % interval === 0) {
return duration;
}
return Math.ceil(duration / interval) * interval;
}
function roundAbsoluteInterval(timeShift: string, dateRange: DateRange, targetBars: number) {
// workout the interval (most probably matching the ES one)
const interval = calcAutoIntervalNear(
targetBars,
moment(dateRange.toDate).diff(moment(dateRange.fromDate))
);
const duration = parseTimeShiftWrapper(timeShift, dateRange);
if (typeof duration !== 'string') {
const roundingOffset = timeShift.startsWith('end') ? interval.asMilliseconds() : 0;
return `${
(closestMultipleOfInterval(duration.asMilliseconds(), interval.asMilliseconds()) -
roundingOffset) /
1000
}s`;
}
}
export function resolveTimeShift(
timeShift: string | undefined,
dateRange: DateRange,
targetBars: number
) {
if (timeShift && isAbsoluteTimeShift(timeShift)) {
return roundAbsoluteInterval(timeShift, dateRange, targetBars);
}
return timeShift;
}

View file

@ -8,7 +8,7 @@
import type { IUiSettingsClient } from '@kbn/core/public';
import { partition, uniq } from 'lodash';
import seedrandom from 'seedrandom';
import {
import type {
AggFunctionsMapping,
EsaggsExpressionFunctionDefinition,
IndexPatternLoadExpressionFunctionDefinition,
@ -21,6 +21,7 @@ import {
ExpressionAstExpressionBuilder,
ExpressionAstFunction,
} from '@kbn/expressions-plugin/public';
import type { DateRange } from '../../../common/types';
import { GenericIndexPatternColumn } from './form_based';
import { operationDefinitionMap } from './operations';
import { FormBasedPrivateState, FormBasedLayer } from './types';
@ -29,6 +30,7 @@ import { FormattedIndexPatternColumn } from './operations/definitions/column_typ
import { isColumnFormatted, isColumnOfType } from './operations/definitions/helpers';
import type { IndexPattern, IndexPatternMap } from '../../types';
import { dedupeAggs } from './dedupe_aggs';
import { resolveTimeShift } from './time_shift_utils';
export type OriginalColumn = { id: string } & GenericIndexPatternColumn;
@ -54,6 +56,7 @@ function getExpressionForLayer(
layer: FormBasedLayer,
indexPattern: IndexPattern,
uiSettings: IUiSettingsClient,
dateRange: DateRange,
searchSessionId?: string
): ExpressionAstExpression | null {
const { columnOrder } = layer;
@ -120,7 +123,10 @@ function getExpressionForLayer(
operationDefinitionMap[col.operationType]?.input === 'fullReference' ||
operationDefinitionMap[col.operationType]?.input === 'managedReference'
);
const hasDateHistogram = columnEntries.some(([, c]) => c.operationType === 'date_histogram');
const firstDateHistogramColumn = columnEntries.find(
([, col]) => col.operationType === 'date_histogram'
);
const hasDateHistogram = Boolean(firstDateHistogramColumn);
if (referenceEntries.length || esAggEntries.length) {
let aggs: ExpressionAstExpressionBuilder[] = [];
@ -137,6 +143,7 @@ function getExpressionForLayer(
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') {
@ -149,7 +156,10 @@ function getExpressionForLayer(
col.reducedTimeRange &&
indexPattern.timeFieldName;
let aggAst = def.toEsAggsFn(
col,
{
...col,
timeShift: resolveTimeShift(col.timeShift, dateRange, histogramBarsTarget),
},
wrapInFilter || wrapInTimeFilter ? `${aggId}-metric` : aggId,
indexPattern,
layer,
@ -171,11 +181,11 @@ function getExpressionForLayer(
schema: 'bucket',
filter: col.filter && queryToAst(col.filter),
timeWindow: wrapInTimeFilter ? col.reducedTimeRange : undefined,
timeShift: col.timeShift,
timeShift: resolveTimeShift(col.timeShift, dateRange, histogramBarsTarget),
}),
]),
customMetric: buildExpression({ type: 'expression', chain: [aggAst] }),
timeShift: col.timeShift,
timeShift: resolveTimeShift(col.timeShift, dateRange, histogramBarsTarget),
}
).toAst();
}
@ -310,10 +320,6 @@ function getExpressionForLayer(
return base;
});
const firstDateHistogramColumn = columnEntries.find(
([, col]) => col.operationType === 'date_histogram'
);
const columnsWithTimeScale = columnEntries.filter(
([, col]) =>
col.timeScale &&
@ -446,6 +452,7 @@ export function toExpression(
layerId: string,
indexPatterns: IndexPatternMap,
uiSettings: IUiSettingsClient,
dateRange: DateRange,
searchSessionId?: string
) {
if (state.layers[layerId]) {
@ -453,6 +460,7 @@ export function toExpression(
state.layers[layerId],
indexPatterns[state.layers[layerId].indexPatternId],
uiSettings,
dateRange,
searchSessionId
);
}

View file

@ -25,6 +25,7 @@ import {
} from '@kbn/data-plugin/public';
import { estypes } from '@elastic/elasticsearch';
import type { DateRange } from '../../../common/types';
import type { FramePublicAPI, IndexPattern, StateSetter } from '../../types';
import { renewIDs } from '../../utils';
import type { FormBasedLayer, FormBasedPersistedState, FormBasedPrivateState } from './types';
@ -54,7 +55,8 @@ import { isQueryValid } from '../../shared_components';
export function isColumnInvalid(
layer: FormBasedLayer,
columnId: string,
indexPattern: IndexPattern
indexPattern: IndexPattern,
dateRange: DateRange | undefined
) {
const column: GenericIndexPatternColumn | undefined = layer.columns[columnId];
if (!column || !indexPattern) return;
@ -64,11 +66,17 @@ export function isColumnInvalid(
const referencesHaveErrors =
true &&
'references' in column &&
Boolean(getReferencesErrors(layer, column, indexPattern).filter(Boolean).length);
Boolean(getReferencesErrors(layer, column, indexPattern, dateRange).filter(Boolean).length);
const operationErrorMessages =
operationDefinition &&
operationDefinition.getErrorMessage?.(layer, columnId, indexPattern, operationDefinitionMap);
operationDefinition.getErrorMessage?.(
layer,
columnId,
indexPattern,
dateRange,
operationDefinitionMap
);
const filterHasError = column.filter ? !isQueryValid(column.filter, indexPattern) : false;
@ -82,7 +90,8 @@ export function isColumnInvalid(
function getReferencesErrors(
layer: FormBasedLayer,
column: ReferenceBasedIndexPatternColumn,
indexPattern: IndexPattern
indexPattern: IndexPattern,
dateRange: DateRange | undefined
) {
return column.references?.map((referenceId: string) => {
const referencedOperation = layer.columns[referenceId]?.operationType;
@ -91,6 +100,7 @@ function getReferencesErrors(
layer,
referenceId,
indexPattern,
dateRange,
operationDefinitionMap
);
});

View file

@ -76,6 +76,10 @@ const expectedIndexPatterns = {
};
const indexPatterns = expectedIndexPatterns;
const dateRange = {
fromDate: '2022-03-17T08:25:00.000Z',
toDate: '2022-04-17T08:25:00.000Z',
};
describe('Textbased Data Source', () => {
let baseState: TextBasedPrivateState;
@ -622,7 +626,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)).toEqual(null);
expect(TextBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange)).toEqual(
null
);
});
it('should generate an expression for an SQL query', async () => {
@ -672,7 +678,7 @@ describe('Textbased Data Source', () => {
],
} as unknown as TextBasedPrivateState;
expect(TextBasedDatasource.toExpression(queryBaseState, 'a', indexPatterns))
expect(TextBasedDatasource.toExpression(queryBaseState, 'a', indexPatterns, dateRange))
.toMatchInlineSnapshot(`
Object {
"chain": Array [

View file

@ -566,7 +566,8 @@ export function LayerPanel(
layerDatasourceState,
dataViews.indexPatterns,
layerId,
columnId
columnId,
dateRange
)
}
>

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Ast, fromExpression } from '@kbn/interpreter';
import type { DateRange } from '../../../common/types';
import { DatasourceStates } from '../../state_management';
import { Visualization, DatasourceMap, DatasourceLayers, IndexPatternMap } from '../../types';
@ -12,6 +13,7 @@ export function getDatasourceExpressionsByLayers(
datasourceMap: DatasourceMap,
datasourceStates: DatasourceStates,
indexPatterns: IndexPatternMap,
dateRange: DateRange,
searchSessionId?: string
): null | Record<string, Ast> {
const datasourceExpressions: Array<[string, Ast | string]> = [];
@ -25,7 +27,13 @@ export function getDatasourceExpressionsByLayers(
const layers = datasource.getLayers(state);
layers.forEach((layerId) => {
const result = datasource.toExpression(state, layerId, indexPatterns, searchSessionId);
const result = datasource.toExpression(
state,
layerId,
indexPatterns,
dateRange,
searchSessionId
);
if (result) {
datasourceExpressions.push([layerId, result]);
}
@ -54,6 +62,7 @@ export function buildExpression({
title,
description,
indexPatterns,
dateRange,
searchSessionId,
}: {
title?: string;
@ -65,6 +74,7 @@ export function buildExpression({
datasourceLayers: DatasourceLayers;
indexPatterns: IndexPatternMap;
searchSessionId?: string;
dateRange: DateRange;
}): Ast | null {
if (visualization === null) {
return null;
@ -74,6 +84,7 @@ export function buildExpression({
datasourceMap,
datasourceStates,
indexPatterns,
dateRange,
searchSessionId
);

View file

@ -13,6 +13,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 {
Datasource,
DatasourceLayers,
@ -321,6 +322,7 @@ export async function persistedStateToExpression(
uiSettings: IUiSettingsClient;
storage: IStorageWrapper;
dataViews: DataViewsContract;
timefilter: TimefilterContract;
}
): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> {
const {
@ -412,6 +414,7 @@ export async function persistedStateToExpression(
visualizationState,
{ datasourceLayers, dataViews: { indexPatterns } as DataViewsState }
);
const currentTimeRange = services.timefilter.getAbsoluteTime();
return {
ast: buildExpression({
@ -423,6 +426,7 @@ export async function persistedStateToExpression(
datasourceStates,
datasourceLayers,
indexPatterns,
dateRange: { fromDate: currentTimeRange.from, toDate: currentTimeRange.to },
}),
errors: validationResult,
};

View file

@ -526,7 +526,8 @@ function getPreviewExpression(
const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers(
datasources,
datasourceStates,
frame.dataViews.indexPatterns
frame.dataViews.indexPatterns,
frame.dateRange
);
return visualization.toPreviewExpression(

View file

@ -310,7 +310,14 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
framePublicAPI
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeVisualization, visualization.state, activeDatasourceId, datasourceMap, datasourceStates]
[
activeVisualization,
visualization.state,
activeDatasourceId,
datasourceMap,
datasourceStates,
framePublicAPI.dateRange,
]
);
// if the expression is undefined, it means we hit an error that should be displayed to the user
@ -324,6 +331,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceStates,
datasourceLayers,
indexPatterns: dataViews.indexPatterns,
dateRange: framePublicAPI.dateRange,
searchSessionId,
});
@ -368,6 +376,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
datasourceLayers,
dataViews.indexPatterns,
searchSessionId,
framePublicAPI.dateRange,
]);
useEffect(() => {

View file

@ -14,6 +14,7 @@ import {
DataPublicPluginSetup,
DataPublicPluginStart,
DataViewsContract,
TimefilterContract,
} from '@kbn/data-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { ChartsPluginSetup } from '@kbn/charts-plugin/public';
@ -55,6 +56,7 @@ export interface EditorFramePlugins {
dataViews: DataViewsContract;
uiSettings: IUiSettingsClient;
storage: IStorageWrapper;
timefilter: TimefilterContract;
}
async function collectAsyncDefinitions<T extends { id: string }>(

View file

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

View file

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

View file

@ -389,6 +389,7 @@ export interface Datasource<T = unknown, P = unknown> {
state: T,
layerId: string,
indexPatterns: IndexPatternMap,
dateRange: DateRange,
searchSessionId?: string
) => ExpressionAstExpression | string | null;
@ -470,7 +471,8 @@ export interface Datasource<T = unknown, P = unknown> {
state: T,
indexPatterns: IndexPatternMap,
layerId: string,
columnId: string
columnId: string,
dateRange?: DateRange
) => boolean;
/**
* Are these datasources equivalent?
@ -611,6 +613,7 @@ export type DatasourceDimensionProps<T> = SharedDimensionProps & {
onRemove?: (accessor: string) => void;
state: T;
activeData?: Record<string, Datatable>;
dateRange: DateRange;
indexPatterns: IndexPatternMap;
hideTooltip?: boolean;
invalid?: boolean;

View file

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