[Lens] Bind all time fields to the time picker (#63874)

* Bind non primary time fields to timepicker

* Fix typescript argument types

* Allow auto interval on all fields

* Remove lens_auto_date function

* Fix existing jest tests and add test todos

* Remove lens_auto_date from esarchives

* Add TimeBuckets jest tests

* Fix typo in esarchiver

* Address review feedback

* Make code a bit better readable

* Fix default time field retrieval

* Fix TS errors

* Add esaggs interpreter tests

* Change public API doc of data plugin

* Add toExpression tests for index pattern datasource

* Add migration stub

* Add full migration

* Fix naming inconsistency in esaggs

* Fix naming issue

* Revert archives to un-migrated version

* Ignore expressions that are already migrated

* test: remove extra spaces and  timeField=\\"products.created_on\\"} to timeField=\"products.created_on\"}

* Rename all timeField -> timeFields

* Combine duplicate functions

* Fix boolean error and add test for it

* Commit API changes

Co-authored-by: Wylie Conlon <wylieconlon@gmail.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
This commit is contained in:
Tim Roes 2020-04-30 02:10:14 +02:00 committed by GitHub
parent a907c9bda5
commit 9b65cbd92b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 635 additions and 251 deletions

View file

@ -7,7 +7,10 @@
<b>Signature:</b>
```typescript
export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined;
export declare function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: {
forceNow?: Date;
fieldName?: string;
}): import("../..").RangeFilter | undefined;
```
## Parameters
@ -16,7 +19,7 @@ export declare function getTime(indexPattern: IIndexPattern | undefined, timeRan
| --- | --- | --- |
| indexPattern | <code>IIndexPattern &#124; undefined</code> | |
| timeRange | <code>TimeRange</code> | |
| forceNow | <code>Date</code> | |
| options | <code>{</code><br/><code> forceNow?: Date;</code><br/><code> fieldName?: string;</code><br/><code>}</code> | |
<b>Returns:</b>

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) &gt; [getTimeField](./kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md)
## IIndexPattern.getTimeField() method
<b>Signature:</b>
```typescript
getTimeField?(): IFieldType | undefined;
```
<b>Returns:</b>
`IFieldType | undefined`

View file

@ -21,3 +21,9 @@ export interface IIndexPattern
| [title](./kibana-plugin-plugins-data-public.iindexpattern.title.md) | <code>string</code> | |
| [type](./kibana-plugin-plugins-data-public.iindexpattern.type.md) | <code>string</code> | |
## Methods
| Method | Description |
| --- | --- |
| [getTimeField()](./kibana-plugin-plugins-data-public.iindexpattern.gettimefield.md) | |

View file

@ -43,7 +43,7 @@
| [getEsPreference(uiSettings, sessionId)](./kibana-plugin-plugins-data-public.getespreference.md) | |
| [getQueryLog(uiSettings, storage, appName, language)](./kibana-plugin-plugins-data-public.getquerylog.md) | |
| [getSearchErrorType({ message })](./kibana-plugin-plugins-data-public.getsearcherrortype.md) | |
| [getTime(indexPattern, timeRange, forceNow)](./kibana-plugin-plugins-data-public.gettime.md) | |
| [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-public.gettime.md) | |
| [plugin(initializerContext)](./kibana-plugin-plugins-data-public.plugin.md) | |
## Interfaces

View file

@ -26,6 +26,7 @@ export interface IIndexPattern {
id?: string;
type?: string;
timeFieldName?: string;
getTimeField?(): IFieldType | undefined;
fieldFormatMap?: Record<
string,
{

View file

@ -699,7 +699,10 @@ export function getSearchErrorType({ message }: Pick<SearchError, 'message'>): "
// Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, forceNow?: Date): import("../..").RangeFilter | undefined;
export function getTime(indexPattern: IIndexPattern | undefined, timeRange: TimeRange, options?: {
forceNow?: Date;
fieldName?: string;
}): import("../..").RangeFilter | undefined;
// Warning: (ae-missing-release-tag) "IAggConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@ -842,6 +845,8 @@ export interface IIndexPattern {
// (undocumented)
fields: IFieldType[];
// (undocumented)
getTimeField?(): IFieldType | undefined;
// (undocumented)
id?: string;
// (undocumented)
timeFieldName?: string;

View file

@ -51,5 +51,43 @@ describe('get_time', () => {
});
clock.restore();
});
test('build range filter for non-primary field', () => {
const clock = sinon.useFakeTimers(moment.utc([2000, 1, 1, 0, 0, 0, 0]).valueOf());
const filter = getTime(
{
id: 'test',
title: 'test',
timeFieldName: 'date',
fields: [
{
name: 'date',
type: 'date',
esTypes: ['date'],
aggregatable: true,
searchable: true,
filterable: true,
},
{
name: 'myCustomDate',
type: 'date',
esTypes: ['date'],
aggregatable: true,
searchable: true,
filterable: true,
},
],
} as any,
{ from: 'now-60y', to: 'now' },
{ fieldName: 'myCustomDate' }
);
expect(filter!.range.myCustomDate).toEqual({
gte: '1940-02-01T00:00:00.000Z',
lte: '2000-02-01T00:00:00.000Z',
format: 'strict_date_optional_time',
});
clock.restore();
});
});
});

View file

@ -19,7 +19,7 @@
import dateMath from '@elastic/datemath';
import { IIndexPattern } from '../..';
import { TimeRange, IFieldType, buildRangeFilter } from '../../../common';
import { TimeRange, buildRangeFilter } from '../../../common';
interface CalculateBoundsOptions {
forceNow?: Date;
@ -35,18 +35,27 @@ export function calculateBounds(timeRange: TimeRange, options: CalculateBoundsOp
export function getTime(
indexPattern: IIndexPattern | undefined,
timeRange: TimeRange,
options?: { forceNow?: Date; fieldName?: string }
) {
return createTimeRangeFilter(
indexPattern,
timeRange,
options?.fieldName || indexPattern?.timeFieldName,
options?.forceNow
);
}
function createTimeRangeFilter(
indexPattern: IIndexPattern | undefined,
timeRange: TimeRange,
fieldName?: string,
forceNow?: Date
) {
if (!indexPattern) {
// in CI, we sometimes seem to fail here.
return;
}
const timefield: IFieldType | undefined = indexPattern.fields.find(
field => field.name === indexPattern.timeFieldName
);
if (!timefield) {
const field = indexPattern.fields.find(f => f.name === (fieldName || indexPattern.timeFieldName));
if (!field) {
return;
}
@ -55,7 +64,7 @@ export function getTime(
return;
}
return buildRangeFilter(
timefield,
field,
{
...(bounds.min && { gte: bounds.min.toISOString() }),
...(bounds.max && { lte: bounds.max.toISOString() }),

View file

@ -22,6 +22,6 @@ export { TimefilterService, TimefilterSetup } from './timefilter_service';
export * from './types';
export { Timefilter, TimefilterContract } from './timefilter';
export { TimeHistory, TimeHistoryContract } from './time_history';
export { getTime } from './get_time';
export { getTime, calculateBounds } from './get_time';
export { changeTimeFilter } from './lib/change_time_filter';
export { extractTimeFilter } from './lib/extract_time_filter';

View file

@ -164,7 +164,9 @@ export class Timefilter {
};
public createFilter = (indexPattern: IndexPattern, timeRange?: TimeRange) => {
return getTime(indexPattern, timeRange ? timeRange : this._time, this.getForceNow());
return getTime(indexPattern, timeRange ? timeRange : this._time, {
forceNow: this.getForceNow(),
});
};
public getBounds(): TimeRangeBounds {

View file

@ -45,7 +45,7 @@ const updateTimeBuckets = (
customBuckets?: IBucketDateHistogramAggConfig['buckets']
) => {
const bounds =
agg.params.timeRange && agg.fieldIsTimeField()
agg.params.timeRange && (agg.fieldIsTimeField() || agg.params.interval === 'auto')
? timefilter.calculateBounds(agg.params.timeRange)
: undefined;
const buckets = customBuckets || agg.buckets;

View file

@ -32,8 +32,15 @@ import { Adapters } from '../../../../../plugins/inspector/public';
import { IAggConfigs } from '../aggs';
import { ISearchSource } from '../search_source';
import { tabifyAggResponse } from '../tabify';
import { Filter, Query, serializeFieldFormat, TimeRange } from '../../../common';
import { FilterManager, getTime } from '../../query';
import {
Filter,
Query,
serializeFieldFormat,
TimeRange,
IIndexPattern,
isRangeFilter,
} from '../../../common';
import { FilterManager, calculateBounds, getTime } from '../../query';
import { getSearchService, getQueryService, getIndexPatterns } from '../../services';
import { buildTabularInspectorData } from './build_tabular_inspector_data';
import { getRequestInspectorStats, getResponseInspectorStats, serializeAggConfig } from './utils';
@ -42,6 +49,8 @@ export interface RequestHandlerParams {
searchSource: ISearchSource;
aggs: IAggConfigs;
timeRange?: TimeRange;
timeFields?: string[];
indexPattern?: IIndexPattern;
query?: Query;
filters?: Filter[];
forceFetch: boolean;
@ -65,12 +74,15 @@ interface Arguments {
partialRows: boolean;
includeFormatHints: boolean;
aggConfigs: string;
timeFields?: string[];
}
const handleCourierRequest = async ({
searchSource,
aggs,
timeRange,
timeFields,
indexPattern,
query,
filters,
forceFetch,
@ -111,9 +123,19 @@ const handleCourierRequest = async ({
return aggs.onSearchRequestStart(paramSearchSource, options);
});
if (timeRange) {
// If timeFields have been specified, use the specified ones, otherwise use primary time field of index
// pattern if it's available.
const defaultTimeField = indexPattern?.getTimeField?.();
const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : [];
const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields;
// If a timeRange has been specified and we had at least one timeField available, create range
// filters for that those time fields
if (timeRange && allTimeFields.length > 0) {
timeFilterSearchSource.setField('filter', () => {
return getTime(searchSource.getField('index'), timeRange);
return allTimeFields
.map(fieldName => getTime(indexPattern, timeRange, { fieldName }))
.filter(isRangeFilter);
});
}
@ -181,11 +203,13 @@ const handleCourierRequest = async ({
(searchSource as any).finalResponse = resp;
const parsedTimeRange = timeRange ? getTime(aggs.indexPattern, timeRange) : null;
const parsedTimeRange = timeRange ? calculateBounds(timeRange) : null;
const tabifyParams = {
metricsAtAllLevels,
partialRows,
timeRange: parsedTimeRange ? parsedTimeRange.range : undefined,
timeRange: parsedTimeRange
? { from: parsedTimeRange.min, to: parsedTimeRange.max, timeFields: allTimeFields }
: undefined,
};
const tabifyCacheHash = calculateObjectHash({ tabifyAggs: aggs, ...tabifyParams });
@ -242,6 +266,11 @@ export const esaggs = (): ExpressionFunctionDefinition<typeof name, Input, Argum
default: '""',
help: '',
},
timeFields: {
types: ['string'],
help: '',
multi: true,
},
},
async fn(input, args, { inspectorAdapters, abortSignal }) {
const indexPatterns = getIndexPatterns();
@ -261,9 +290,11 @@ export const esaggs = (): ExpressionFunctionDefinition<typeof name, Input, Argum
const response = await handleCourierRequest({
searchSource,
aggs,
indexPattern,
timeRange: get(input, 'timeRange', undefined),
query: get(input, 'query', undefined),
filters: get(input, 'filters', undefined),
timeFields: args.timeFields,
forceFetch: true,
metricsAtAllLevels: args.metricsAtAllLevels,
partialRows: args.partialRows,

View file

@ -19,6 +19,11 @@
import { TabifyBuckets } from './buckets';
import { AggGroupNames } from '../aggs';
import moment from 'moment';
interface Bucket {
key: number | string;
}
describe('Buckets wrapper', () => {
const check = (aggResp: any, count: number, keys: string[]) => {
@ -187,9 +192,9 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
gte: 150,
lte: 350,
name: 'date',
from: moment(150),
to: moment(350),
timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
@ -204,9 +209,9 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
gte: 150,
lte: 350,
name: 'date',
from: moment(150),
to: moment(350),
timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
@ -221,9 +226,9 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
gte: 100,
lte: 400,
name: 'date',
from: moment(100),
to: moment(400),
timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
@ -238,13 +243,47 @@ describe('Buckets wrapper', () => {
},
};
const timeRange = {
gte: 150,
lte: 350,
name: 'date',
from: moment(150),
to: moment(350),
timeFields: ['date'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
expect(buckets).toHaveLength(4);
});
test('does drop bucket when multiple time fields specified', () => {
const aggParams = {
drop_partials: true,
field: {
name: 'date',
},
};
const timeRange = {
from: moment(100),
to: moment(350),
timeFields: ['date', 'other_datefield'],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([100, 200]);
});
test('does not drop bucket when no timeFields have been specified', () => {
const aggParams = {
drop_partials: true,
field: {
name: 'date',
},
};
const timeRange = {
from: moment(100),
to: moment(350),
timeFields: [],
};
const buckets = new TabifyBuckets(aggResp, aggParams, timeRange);
expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([0, 100, 200, 300]);
});
});
});

View file

@ -20,7 +20,7 @@
import { get, isPlainObject, keys, findKey } from 'lodash';
import moment from 'moment';
import { IAggConfig } from '../aggs';
import { AggResponseBucket, TabbedRangeFilterParams } from './types';
import { AggResponseBucket, TabbedRangeFilterParams, TimeRangeInformation } from './types';
type AggParams = IAggConfig['params'] & {
drop_partials: boolean;
@ -36,7 +36,7 @@ export class TabifyBuckets {
buckets: any;
_keys: any[] = [];
constructor(aggResp: any, aggParams?: AggParams, timeRange?: TabbedRangeFilterParams) {
constructor(aggResp: any, aggParams?: AggParams, timeRange?: TimeRangeInformation) {
if (aggResp && aggResp.buckets) {
this.buckets = aggResp.buckets;
} else if (aggResp) {
@ -107,12 +107,12 @@ export class TabifyBuckets {
// dropPartials should only be called if the aggParam setting is enabled,
// and the agg field is the same as the Time Range.
private dropPartials(params: AggParams, timeRange?: TabbedRangeFilterParams) {
private dropPartials(params: AggParams, timeRange?: TimeRangeInformation) {
if (
!timeRange ||
this.buckets.length <= 1 ||
this.objectMode ||
params.field.name !== timeRange.name
!timeRange.timeFields.includes(params.field.name)
) {
return;
}
@ -120,10 +120,10 @@ export class TabifyBuckets {
const interval = this.buckets[1].key - this.buckets[0].key;
this.buckets = this.buckets.filter((bucket: AggResponseBucket) => {
if (moment(bucket.key).isBefore(timeRange.gte)) {
if (moment(bucket.key).isBefore(timeRange.from)) {
return false;
}
if (moment(bucket.key + interval).isAfter(timeRange.lte)) {
if (moment(bucket.key + interval).isAfter(timeRange.to)) {
return false;
}
return true;

View file

@ -20,7 +20,7 @@
import { get } from 'lodash';
import { TabbedAggResponseWriter } from './response_writer';
import { TabifyBuckets } from './buckets';
import { TabbedResponseWriterOptions, TabbedRangeFilterParams } from './types';
import { TabbedResponseWriterOptions } from './types';
import { AggResponseBucket } from './types';
import { AggGroupNames, IAggConfigs } from '../aggs';
@ -54,7 +54,7 @@ export function tabifyAggResponse(
switch (agg.type.type) {
case AggGroupNames.Buckets:
const aggBucket = get(bucket, agg.id);
const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, timeRange);
const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, respOpts?.timeRange);
if (tabifyBuckets.length) {
tabifyBuckets.forEach((subBucket, tabifyBucketKey) => {
@ -153,20 +153,6 @@ export function tabifyAggResponse(
doc_count: esResponse.hits.total,
};
let timeRange: TabbedRangeFilterParams | undefined;
// Extract the time range object if provided
if (respOpts && respOpts.timeRange) {
const [timeRangeKey] = Object.keys(respOpts.timeRange);
if (timeRangeKey) {
timeRange = {
name: timeRangeKey,
...respOpts.timeRange[timeRangeKey],
};
}
}
collectBucket(aggConfigs, write, topLevelBucket, '', 1);
return write.response();

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { Moment } from 'moment';
import { RangeFilterParams } from '../../../common';
import { IAggConfig } from '../aggs';
@ -25,11 +26,18 @@ export interface TabbedRangeFilterParams extends RangeFilterParams {
name: string;
}
/** @internal */
export interface TimeRangeInformation {
from?: Moment;
to?: Moment;
timeFields: string[];
}
/** @internal **/
export interface TabbedResponseWriterOptions {
metricsAtAllLevels: boolean;
partialRows: boolean;
timeRange?: { [key: string]: RangeFilterParams };
timeRange?: TimeRangeInformation;
}
/** @internal */

View file

@ -408,6 +408,8 @@ export interface IIndexPattern {
// (undocumented)
fields: IFieldType[];
// (undocumented)
getTimeField?(): IFieldType | undefined;
// (undocumented)
id?: string;
// (undocumented)
timeFieldName?: string;

View file

@ -0,0 +1,93 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { ExpectExpression, expectExpressionProvider } from './helpers';
import { FtrProviderContext } from '../../../functional/ftr_provider_context';
function getCell(esaggsResult: any, column: number, row: number): unknown | undefined {
const columnId = esaggsResult?.columns[column]?.id;
if (!columnId) {
return;
}
return esaggsResult?.rows[row]?.[columnId];
}
export default function({
getService,
updateBaselines,
}: FtrProviderContext & { updateBaselines: boolean }) {
let expectExpression: ExpectExpression;
describe('esaggs pipeline expression tests', () => {
before(() => {
expectExpression = expectExpressionProvider({ getService, updateBaselines });
});
describe('correctly renders tagcloud', () => {
it('filters on index pattern primary date field by default', async () => {
const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }];
const timeRange = {
from: '2006-09-21T00:00:00Z',
to: '2015-09-22T00:00:00Z',
};
const expression = `
kibana_context timeRange='${JSON.stringify(timeRange)}'
| esaggs index='logstash-*' aggConfigs='${JSON.stringify(aggConfigs)}'
`;
const result = await expectExpression('esaggs_primary_timefield', expression).getResponse();
expect(getCell(result, 0, 0)).to.be(9375);
});
it('filters on the specified date field', async () => {
const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }];
const timeRange = {
from: '2006-09-21T00:00:00Z',
to: '2015-09-22T00:00:00Z',
};
const expression = `
kibana_context timeRange='${JSON.stringify(timeRange)}'
| esaggs index='logstash-*' timeFields='relatedContent.article:published_time' aggConfigs='${JSON.stringify(
aggConfigs
)}'
`;
const result = await expectExpression('esaggs_other_timefield', expression).getResponse();
expect(getCell(result, 0, 0)).to.be(11134);
});
it('filters on multiple specified date field', async () => {
const aggConfigs = [{ id: 1, enabled: true, type: 'count', schema: 'metric', params: {} }];
const timeRange = {
from: '2006-09-21T00:00:00Z',
to: '2015-09-22T00:00:00Z',
};
const expression = `
kibana_context timeRange='${JSON.stringify(timeRange)}'
| esaggs index='logstash-*' timeFields='relatedContent.article:published_time' timeFields='@timestamp' aggConfigs='${JSON.stringify(
aggConfigs
)}'
`;
const result = await expectExpression(
'esaggs_multiple_timefields',
expression
).getResponse();
expect(getCell(result, 0, 0)).to.be(7452);
});
});
});
}

View file

@ -46,5 +46,6 @@ export default function({ getService, getPageObjects, loadTestFile }: FtrProvide
loadTestFile(require.resolve('./basic'));
loadTestFile(require.resolve('./tag_cloud'));
loadTestFile(require.resolve('./metric'));
loadTestFile(require.resolve('./esaggs'));
});
}

View file

@ -1,83 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { getAutoDate } from './auto_date';
describe('auto_date', () => {
let autoDate: ReturnType<typeof getAutoDate>;
beforeEach(() => {
autoDate = getAutoDate({ data: dataPluginMock.createSetupContract() });
});
it('should do nothing if no time range is provided', () => {
const result = autoDate.fn(
{
type: 'kibana_context',
},
{
aggConfigs: 'canttouchthis',
},
// eslint-disable-next-line
{} as any
);
expect(result).toEqual('canttouchthis');
});
it('should not change anything if there are no auto date histograms', () => {
const aggConfigs = JSON.stringify([
{ type: 'date_histogram', params: { interval: '35h' } },
{ type: 'count' },
]);
const result = autoDate.fn(
{
timeRange: {
from: 'now-10d',
to: 'now',
},
type: 'kibana_context',
},
{
aggConfigs,
},
// eslint-disable-next-line
{} as any
);
expect(result).toEqual(aggConfigs);
});
it('should change auto date histograms', () => {
const aggConfigs = JSON.stringify([
{ type: 'date_histogram', params: { interval: 'auto' } },
{ type: 'count' },
]);
const result = autoDate.fn(
{
timeRange: {
from: 'now-10d',
to: 'now',
},
type: 'kibana_context',
},
{
aggConfigs,
},
// eslint-disable-next-line
{} as any
);
const interval = JSON.parse(result).find(
(agg: { type: string }) => agg.type === 'date_histogram'
).params.interval;
expect(interval).toBeTruthy();
expect(typeof interval).toEqual('string');
expect(interval).not.toEqual('auto');
});
});

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DataPublicPluginSetup } from '../../../../../src/plugins/data/public';
import {
ExpressionFunctionDefinition,
KibanaContext,
} from '../../../../../src/plugins/expressions/public';
interface LensAutoDateProps {
aggConfigs: string;
}
export function getAutoDate(deps: {
data: DataPublicPluginSetup;
}): ExpressionFunctionDefinition<
'lens_auto_date',
KibanaContext | null,
LensAutoDateProps,
string
> {
function autoIntervalFromContext(ctx?: KibanaContext | null) {
if (!ctx || !ctx.timeRange) {
return;
}
return deps.data.search.aggs.calculateAutoTimeExpression(ctx.timeRange);
}
/**
* Convert all 'auto' date histograms into a concrete value (e.g. 2h).
* This allows us to support 'auto' on all date fields, and opens the
* door to future customizations (e.g. adjusting the level of detail, etc).
*/
return {
name: 'lens_auto_date',
aliases: [],
help: '',
inputTypes: ['kibana_context', 'null'],
args: {
aggConfigs: {
types: ['string'],
default: '""',
help: '',
},
},
fn(input, args) {
const interval = autoIntervalFromContext(input);
if (!interval) {
return args.aggConfigs;
}
const configs = JSON.parse(args.aggConfigs) as Array<{
type: string;
params: { interval: string };
}>;
const updatedConfigs = configs.map(c => {
if (c.type !== 'date_histogram' || !c.params || c.params.interval !== 'auto') {
return c;
}
return {
...c,
params: {
...c.params,
interval,
},
};
});
return JSON.stringify(updatedConfigs);
},
};
}

View file

@ -8,7 +8,6 @@ import { CoreSetup } from 'kibana/public';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
import { getIndexPatternDatasource } from './indexpattern';
import { renameColumns } from './rename_columns';
import { getAutoDate } from './auto_date';
import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public';
import {
DataPublicPluginSetup,
@ -31,10 +30,9 @@ export class IndexPatternDatasource {
setup(
core: CoreSetup<IndexPatternDatasourceStartPlugins>,
{ data: dataSetup, expressions, editorFrame }: IndexPatternDatasourceSetupPlugins
{ expressions, editorFrame }: IndexPatternDatasourceSetupPlugins
) {
expressions.registerFunction(renameColumns);
expressions.registerFunction(getAutoDate({ data: dataSetup }));
editorFrame.registerDatasource(
core.getStartServices().then(([coreStart, { data }]) =>

View file

@ -10,6 +10,7 @@ import { DatasourcePublicAPI, Operation, Datasource } from '../types';
import { coreMock } from 'src/core/public/mocks';
import { IndexPatternPersistedState, IndexPatternPrivateState } from './types';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { Ast } from '@kbn/interpreter/common';
jest.mock('./loader');
jest.mock('../id_generator');
@ -262,20 +263,7 @@ describe('IndexPattern Data Source', () => {
Object {
"arguments": Object {
"aggConfigs": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"aggConfigs": Array [
"[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]",
],
},
"function": "lens_auto_date",
"type": "function",
},
],
"type": "expression",
},
"[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]",
],
"includeFormatHints": Array [
true,
@ -289,6 +277,9 @@ describe('IndexPattern Data Source', () => {
"partialRows": Array [
false,
],
"timeFields": Array [
"timestamp",
],
},
"function": "esaggs",
"type": "function",
@ -307,6 +298,89 @@ describe('IndexPattern Data Source', () => {
}
`);
});
it('should put all time fields used in date_histograms to the esaggs timeFields parameter', async () => {
const queryPersistedState: IndexPatternPersistedState = {
currentIndexPatternId: '1',
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2', 'col3'],
columns: {
col1: {
label: 'Count of records',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
col2: {
label: 'Date',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: 'auto',
},
},
col3: {
label: 'Date 2',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'another_datefield',
params: {
interval: 'auto',
},
},
},
},
},
};
const state = stateFromPersistedState(queryPersistedState);
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
});
it('should not put date fields used outside date_histograms to the esaggs timeFields parameter', async () => {
const queryPersistedState: IndexPatternPersistedState = {
currentIndexPatternId: '1',
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
col1: {
label: 'Count of records',
dataType: 'date',
isBucketed: false,
sourceField: 'timefield',
operationType: 'cardinality',
},
col2: {
label: 'Date',
dataType: 'date',
isBucketed: true,
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
interval: 'auto',
},
},
},
},
},
};
const state = stateFromPersistedState(queryPersistedState);
const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']);
expect(ast.chain[0].arguments.timeFields).not.toContain('timefield');
});
});
describe('#insertLayer', () => {

View file

@ -10,6 +10,7 @@ import { IndexPatternColumn } from './indexpattern';
import { operationDefinitionMap } from './operations';
import { IndexPattern, IndexPatternPrivateState } from './types';
import { OriginalColumn } from './rename_columns';
import { dateHistogramOperation } from './operations/definitions';
function getExpressionForLayer(
indexPattern: IndexPattern,
@ -68,6 +69,12 @@ function getExpressionForLayer(
return base;
});
const allDateHistogramFields = Object.values(columns)
.map(column =>
column.operationType === dateHistogramOperation.type ? column.sourceField : null
)
.filter((field): field is string => Boolean(field));
return {
type: 'expression',
chain: [
@ -79,20 +86,8 @@ function getExpressionForLayer(
metricsAtAllLevels: [false],
partialRows: [false],
includeFormatHints: [true],
aggConfigs: [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_auto_date',
arguments: {
aggConfigs: [JSON.stringify(aggs)],
},
},
],
},
],
timeFields: allDateHistogramFields,
aggConfigs: [JSON.stringify(aggs)],
},
},
{

View file

@ -158,4 +158,124 @@ describe('Lens migrations', () => {
]);
});
});
describe('7.8.0 auto timestamp', () => {
const context = {} as SavedObjectMigrationContext;
const example = {
type: 'lens',
attributes: {
expression: `kibana
| kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]"
| lens_merge_tables layerIds="bd09dc71-a7e2-42d0-83bd-85df8291f03c"
tables={esaggs
index="ff959d40-b880-11e8-a6d9-e546fe2bba5f"
metricsAtAllLevels=false
partialRows=false
includeFormatHints=true
aggConfigs={
lens_auto_date
aggConfigs="[{\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"products.created_on\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"auto\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}},{\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]"
}
| lens_rename_columns idMap="{\\"col-0-1d9cc16c-1460-41de-88f8-471932ecbc97\\":{\\"label\\":\\"products.created_on\\",\\"dataType\\":\\"date\\",\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"products.created_on\\",\\"isBucketed\\":true,\\"scale\\":\\"interval\\",\\"params\\":{\\"interval\\":\\"auto\\"},\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\"},\\"col-1-66115819-8481-4917-a6dc-8ffb10dd02df\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"operationType\\":\\"count\\",\\"suggestedPriority\\":0,\\"isBucketed\\":false,\\"scale\\":\\"ratio\\",\\"sourceField\\":\\"Records\\",\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\"}}"
}
| lens_xy_chart
xTitle="products.created_on"
yTitle="Count of records"
legend={lens_xy_legendConfig isVisible=true position="right"}
layers={lens_xy_layer
layerId="bd09dc71-a7e2-42d0-83bd-85df8291f03c"
hide=false
xAccessor="1d9cc16c-1460-41de-88f8-471932ecbc97"
yScaleType="linear"
xScaleType="time"
isHistogram=true
seriesType="bar_stacked"
accessors="66115819-8481-4917-a6dc-8ffb10dd02df"
columnToLabel="{\\"66115819-8481-4917-a6dc-8ffb10dd02df\\":\\"Count of records\\"}"
}
`,
state: {
datasourceStates: {
indexpattern: {
currentIndexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
layers: {
'bd09dc71-a7e2-42d0-83bd-85df8291f03c': {
indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
columns: {
'1d9cc16c-1460-41de-88f8-471932ecbc97': {
label: 'products.created_on',
dataType: 'date',
operationType: 'date_histogram',
sourceField: 'products.created_on',
isBucketed: true,
scale: 'interval',
params: { interval: 'auto' },
},
'66115819-8481-4917-a6dc-8ffb10dd02df': {
label: 'Count of records',
dataType: 'number',
operationType: 'count',
suggestedPriority: 0,
isBucketed: false,
scale: 'ratio',
sourceField: 'Records',
},
},
columnOrder: [
'1d9cc16c-1460-41de-88f8-471932ecbc97',
'66115819-8481-4917-a6dc-8ffb10dd02df',
],
},
},
},
},
datasourceMetaData: {
filterableIndexPatterns: [
{ id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', title: 'kibana_sample_data_ecommerce' },
],
},
visualization: {
legend: { isVisible: true, position: 'right' },
preferredSeriesType: 'bar_stacked',
layers: [
{
layerId: 'bd09dc71-a7e2-42d0-83bd-85df8291f03c',
accessors: ['66115819-8481-4917-a6dc-8ffb10dd02df'],
position: 'top',
seriesType: 'bar_stacked',
showGridlines: false,
xAccessor: '1d9cc16c-1460-41de-88f8-471932ecbc97',
},
],
},
query: { query: '', language: 'kuery' },
filters: [],
},
title: 'Bar chart',
visualizationType: 'lnsXY',
},
};
it('should remove the lens_auto_date expression', () => {
const result = migrations['7.8.0'](example, context);
expect(result.attributes.expression).toContain(`timeFields=\"products.created_on\"`);
});
it('should handle pre-migrated expression', () => {
const input = {
type: 'lens',
attributes: {
...example.attributes,
expression: `kibana
| kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]"
| lens_merge_tables layerIds="bd09dc71-a7e2-42d0-83bd-85df8291f03c"
tables={esaggs index="ff959d40-b880-11e8-a6d9-e546fe2bba5f" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"1d9cc16c-1460-41de-88f8-471932ecbc97\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"products.created_on\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"auto\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}},{\\"id\\":\\"66115819-8481-4917-a6dc-8ffb10dd02df\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}}]" timeFields=\"products.created_on\"}
| lens_xy_chart xTitle="products.created_on" yTitle="Count of records" legend={lens_xy_legendConfig isVisible=true position="right"} layers={}`,
},
};
const result = migrations['7.8.0'](input, context);
expect(result).toEqual(input);
});
});
});

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { cloneDeep } from 'lodash';
import { cloneDeep, flow } from 'lodash';
import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter/common';
import { SavedObjectMigrationFn } from 'src/core/server';
interface XYLayerPre77 {
@ -14,6 +15,122 @@ interface XYLayerPre77 {
accessors: string[];
}
/**
* Removes the `lens_auto_date` subexpression from a stored expression
* string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"}
*/
const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => {
const expression: string = doc.attributes?.expression;
try {
const ast = fromExpression(expression);
const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => {
if (topNode.function !== 'lens_merge_tables') {
return topNode;
}
return {
...topNode,
arguments: {
...topNode.arguments,
tables: (topNode.arguments.tables as Ast[]).map(middleNode => {
return {
type: 'expression',
chain: middleNode.chain.map(node => {
// Check for sub-expression in aggConfigs
if (
node.function === 'esaggs' &&
typeof node.arguments.aggConfigs[0] !== 'string'
) {
return {
...node,
arguments: {
...node.arguments,
aggConfigs: (node.arguments.aggConfigs[0] as Ast).chain[0].arguments
.aggConfigs,
},
};
}
return node;
}),
};
}),
},
};
});
return {
...doc,
attributes: {
...doc.attributes,
expression: toExpression({ ...ast, chain: newChain }),
},
};
} catch (e) {
context.log.warning(e.message);
return { ...doc };
}
};
/**
* Adds missing timeField arguments to esaggs in the Lens expression
*/
const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => {
const expression: string = doc.attributes?.expression;
try {
const ast = fromExpression(expression);
const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => {
if (topNode.function !== 'lens_merge_tables') {
return topNode;
}
return {
...topNode,
arguments: {
...topNode.arguments,
tables: (topNode.arguments.tables as Ast[]).map(middleNode => {
return {
type: 'expression',
chain: middleNode.chain.map(node => {
// Skip if there are any timeField arguments already, because that indicates
// the fix is already applied
if (node.function !== 'esaggs' || node.arguments.timeFields) {
return node;
}
const timeFields: string[] = [];
JSON.parse(node.arguments.aggConfigs[0] as string).forEach(
(agg: { type: string; params: { field: string } }) => {
if (agg.type !== 'date_histogram') {
return;
}
timeFields.push(agg.params.field);
}
);
return {
...node,
arguments: {
...node.arguments,
timeFields,
},
};
}),
};
}),
},
};
});
return {
...doc,
attributes: {
...doc.attributes,
expression: toExpression({ ...ast, chain: newChain }),
},
};
} catch (e) {
context.log.warning(e.message);
return { ...doc };
}
};
export const migrations: Record<string, SavedObjectMigrationFn> = {
'7.7.0': doc => {
const newDoc = cloneDeep(doc);
@ -34,4 +151,7 @@ export const migrations: Record<string, SavedObjectMigrationFn> = {
}
return newDoc;
},
// The order of these migrations matter, since the timefield migration relies on the aggConfigs
// sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was).
'7.8.0': flow(removeLensAutoDate, addTimeFieldToEsaggs),
};