Skip to content
Merged
1 change: 1 addition & 0 deletions src/platform/packages/shared/kbn-esql-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

export {
getESQLAdHocDataview,
getESQLTimeFieldFromQuery,
getIndexPatternFromESQLQuery,
getSourceCommandFromESQLQuery,
hasTransformationalCommand,
Expand Down
1 change: 1 addition & 0 deletions src/platform/packages/shared/kbn-esql-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

export { getESQLAdHocDataview, getIndexForESQLQuery } from './utils/get_esql_adhoc_dataview';
export { getESQLTimeFieldFromQuery } from './utils/get_esql_time_field_from_query';
export { getInitialESQLQuery } from './utils/get_initial_esql_query';
export { getESQLWithSafeLimit } from './utils/get_esql_with_safe_limit';
export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,9 @@
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { HttpStart } from '@kbn/core/public';
import { ESQL_TYPE } from '@kbn/data-view-utils';
import { LRUCache } from 'lru-cache';
import {
type ESQLSourceResult,
SOURCES_AUTOCOMPLETE_ROUTE,
TIMEFIELD_ROUTE,
} from '@kbn/esql-types';
import { type ESQLSourceResult, SOURCES_AUTOCOMPLETE_ROUTE } from '@kbn/esql-types';
import { getIndexPatternFromESQLQuery } from './get_index_pattern_from_query';

// Caches the in-flight or resolved TIMEFIELD_ROUTE promise by query.
// Storing the Promise (not the resolved value) deduplicates concurrent calls:
// if multiple callers request the same query before the first resolves,
// they all await the same promise instead of each firing a separate HTTP request.
const timeFieldCache = new LRUCache<string, Promise<string | undefined>>({ max: 100 });
import { getESQLTimeFieldFromQuery } from './get_esql_time_field_from_query';

// uses browser sha256 method with fallback if unavailable
async function sha256(str: string) {
Expand Down Expand Up @@ -83,22 +73,7 @@ export async function getESQLAdHocDataview({
// optional http service to use to fetch the time field, if needed
http?: HttpStart;
}) {
let timeFieldName: string | undefined;
if (timeFieldCache.has(query)) {
timeFieldName = await timeFieldCache.get(query);
} else if (http) {
const pendingRequest = http
.post(TIMEFIELD_ROUTE, { body: JSON.stringify({ query }) })
.then((response) => (response as { timeField?: string } | undefined)?.timeField)
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to fetch the timefield', error);
timeFieldCache.delete(query);
return undefined;
});
timeFieldCache.set(query, pendingRequest);
timeFieldName = await pendingRequest;
}
const timeFieldName = await getESQLTimeFieldFromQuery({ query, http });

const indexPattern = getIndexPatternFromESQLQuery(query);
const prefix = options?.idPrefix ?? 'esql';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HttpStart } from '@kbn/core/public';
import { TIMEFIELD_ROUTE } from '@kbn/esql-types';
import { LRUCache } from 'lru-cache';

// Caches the in-flight or resolved TIMEFIELD_ROUTE promise by query.
// Storing the Promise (not the resolved value) deduplicates concurrent calls:
// if multiple callers request the same query before the first resolves,
// they all await the same promise instead of each firing a separate HTTP request.
const timeFieldCache = new LRUCache<string, Promise<string | undefined>>({ max: 100 });

/**
* Resolves the default time field for an ES|QL query by calling the timefield API.
*
* When `http` is omitted, returns `undefined` (unless a prior successful request
* for the same query left a value in the in-memory cache).
*
* Concurrent requests for the same query share one HTTP request via an LRU-backed promise cache.
*/
export async function getESQLTimeFieldFromQuery({
query,
http,
}: {
query: string;
http?: HttpStart;
}): Promise<string | undefined> {
const cached = timeFieldCache.get(query);
if (cached !== undefined) {
return cached;
}
if (!http) {
return undefined;
}
const pendingRequest = http
.post(TIMEFIELD_ROUTE, { body: JSON.stringify({ query }) })
.then((response) => (response as { timeField?: string } | undefined)?.timeField)
.catch((error) => {
// eslint-disable-next-line no-console
console.error('Failed to fetch the timefield', error);
timeFieldCache.delete(query);
return undefined;
});
timeFieldCache.set(query, pendingRequest);
return pendingRequest;
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ describe('extendedDataLayerConfig', () => {
type: 'extendedDataLayer',
layerType: LayerTypes.DATA,
...fullArgs,
xScaleType: 'time', // xAccessor `c` is a date type column
isHistogram: true,
table: data,
showLines: true,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type { ExpressionValueVisDimension } from '@kbn/chart-expressions-common';
import { validateAccessor } from '@kbn/chart-expressions-common';
import type { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types';
import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants';
import { EXTENDED_DATA_LAYER, LayerTypes, XScaleTypes } from '../constants';
import { getAccessors, normalizeTable, getShowLines } from '../helpers';
import {
validateLinesVisibilityForChartType,
Expand Down Expand Up @@ -41,9 +41,16 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args,

const showLines = getShowLines(args);

const xScaleType =
table.columns.find((column) => column.id === accessors.xAccessor)?.meta.type === 'date'
? XScaleTypes.TIME
: args.xScaleType;

return {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
type: EXTENDED_DATA_LAYER,
...args,
xScaleType,
isHistogram: xScaleType === XScaleTypes.TIME || args.isHistogram,
layerType: LayerTypes.DATA,
...accessors,
table: normalizedTable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import type { DataViewsContract, DataViewField, DataViewSpec } from '@kbn/data-views-plugin/public';
import type { TextBasedPersistedState } from '@kbn/lens-common';
import type { HttpStart } from '@kbn/core/public';
import { getESQLAdHocDataview } from '@kbn/esql-utils';
import { getESQLTimeFieldFromQuery } from '@kbn/esql-utils';
import {
ensureIndexPattern,
ensureESQLTimeFieldOnAdHocDataViews,
Expand All @@ -20,11 +20,11 @@ import { sampleIndexPatterns, mockDataViewsService } from './mocks';
import { documentField } from '../datasources/form_based/document_field';

jest.mock('@kbn/esql-utils', () => ({
getESQLAdHocDataview: jest.fn(),
getESQLTimeFieldFromQuery: jest.fn(),
}));

const mockGetESQLAdHocDataview = getESQLAdHocDataview as jest.MockedFunction<
typeof getESQLAdHocDataview
const mockGetESQLTimeFieldFromQuery = getESQLTimeFieldFromQuery as jest.MockedFunction<
typeof getESQLTimeFieldFromQuery
>;

describe('loader', () => {
Expand Down Expand Up @@ -364,7 +364,8 @@ describe('loader', () => {
const mockDataViews = mockDataViewsService() as unknown as DataViewsContract;

beforeEach(() => {
mockGetESQLAdHocDataview.mockReset();
mockGetESQLTimeFieldFromQuery.mockReset();
(mockDataViews.clearInstanceCache as jest.Mock).mockClear();
});

it('should return adHocDataViews unchanged when textBasedState is undefined', async () => {
Expand All @@ -380,7 +381,7 @@ describe('loader', () => {
});

expect(result).toBe(adHocDataViews);
expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled();
expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled();
});

it('should return adHocDataViews unchanged when layers is empty', async () => {
Expand All @@ -396,7 +397,7 @@ describe('loader', () => {
});

expect(result).toEqual(adHocDataViews);
expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled();
expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled();
});

it('should skip layers without an ES|QL query', async () => {
Expand All @@ -415,7 +416,7 @@ describe('loader', () => {
});

expect(result).toEqual({});
expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled();
expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled();
});

it('should skip enrichment when the existing spec already has a timeFieldName', async () => {
Expand All @@ -436,10 +437,11 @@ describe('loader', () => {
});

expect(result).toEqual(adHocDataViews);
expect(mockGetESQLAdHocDataview).not.toHaveBeenCalled();
expect(mockGetESQLTimeFieldFromQuery).not.toHaveBeenCalled();
expect(mockDataViews.clearInstanceCache).not.toHaveBeenCalled();
});

it('should call getESQLAdHocDataview when spec is missing timeFieldName', async () => {
it('should patch existing spec with timeFieldName and evict stale cache', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {
dv1: { id: 'dv1', title: 'logs-*' },
};
Expand All @@ -449,10 +451,7 @@ describe('loader', () => {
},
} as unknown as TextBasedPersistedState;

mockGetESQLAdHocDataview.mockResolvedValue({
id: 'dv1',
toSpec: () => ({ id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' }),
} as never);
mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp');

const result = await ensureESQLTimeFieldOnAdHocDataViews({
adHocDataViews,
Expand All @@ -461,31 +460,23 @@ describe('loader', () => {
http: mockHttp,
});

expect(mockGetESQLAdHocDataview).toHaveBeenCalledWith(
expect.objectContaining({
query: 'FROM logs-*',
options: {
skipFetchFields: true,
createNewInstanceEvenIfCachedOneAvailable: true,
},
http: mockHttp,
})
);
expect(mockGetESQLTimeFieldFromQuery).toHaveBeenCalledWith({
query: 'FROM logs-*',
http: mockHttp,
});
expect(result.dv1).toEqual({ id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' });
expect(mockDataViews.clearInstanceCache).toHaveBeenCalledWith('dv1');
});

it('should use freshDataView.id as the key when layer.index is falsy', async () => {
it('should not patch spec when layer.index has no matching entry', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {};
const textBasedState = {
layers: {
layer1: { columns: [], query: { esql: 'FROM logs-*' } },
layer1: { columns: [], index: 'missing-id', query: { esql: 'FROM logs-*' } },
},
} as unknown as TextBasedPersistedState;

mockGetESQLAdHocDataview.mockResolvedValue({
id: 'generated-id',
toSpec: () => ({ id: 'generated-id', title: 'logs-*', timeFieldName: '@timestamp' }),
} as never);
mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp');

const result = await ensureESQLTimeFieldOnAdHocDataViews({
adHocDataViews,
Expand All @@ -494,11 +485,8 @@ describe('loader', () => {
http: mockHttp,
});

expect(result['generated-id']).toEqual({
id: 'generated-id',
title: 'logs-*',
timeFieldName: '@timestamp',
});
expect(result).toEqual({});
expect(mockDataViews.clearInstanceCache).not.toHaveBeenCalled();
});

it('should handle mixed layers: only enrich specs missing timeFieldName', async () => {
Expand All @@ -513,10 +501,7 @@ describe('loader', () => {
},
} as unknown as TextBasedPersistedState;

mockGetESQLAdHocDataview.mockResolvedValue({
id: 'dv2',
toSpec: () => ({ id: 'dv2', title: 'metrics-*', timeFieldName: '@timestamp' }),
} as never);
mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp');

const result = await ensureESQLTimeFieldOnAdHocDataViews({
adHocDataViews,
Expand All @@ -525,12 +510,15 @@ describe('loader', () => {
http: mockHttp,
});

expect(mockGetESQLAdHocDataview).toHaveBeenCalledTimes(1);
expect(mockGetESQLAdHocDataview).toHaveBeenCalledWith(
expect.objectContaining({ query: 'FROM metrics-*' })
);
expect(mockGetESQLTimeFieldFromQuery).toHaveBeenCalledTimes(1);
expect(mockGetESQLTimeFieldFromQuery).toHaveBeenCalledWith({
query: 'FROM metrics-*',
http: mockHttp,
});
expect(result.dv1).toEqual(adHocDataViews.dv1);
expect(result.dv2.timeFieldName).toBe('@timestamp');
expect(mockDataViews.clearInstanceCache).toHaveBeenCalledTimes(1);
expect(mockDataViews.clearInstanceCache).toHaveBeenCalledWith('dv2');
});

it('should not mutate the original adHocDataViews object', async () => {
Expand All @@ -543,10 +531,7 @@ describe('loader', () => {
},
} as unknown as TextBasedPersistedState;

mockGetESQLAdHocDataview.mockResolvedValue({
id: 'dv1',
toSpec: () => ({ id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' }),
} as never);
mockGetESQLTimeFieldFromQuery.mockResolvedValue('@timestamp');

const result = await ensureESQLTimeFieldOnAdHocDataViews({
adHocDataViews,
Expand All @@ -558,5 +543,28 @@ describe('loader', () => {
expect(result).not.toBe(adHocDataViews);
expect(adHocDataViews.dv1.timeFieldName).toBeUndefined();
});

it('should leave spec unchanged when time field resolves to undefined', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {
dv1: { id: 'dv1', title: 'logs-*' },
};
const textBasedState = {
layers: {
layer1: { columns: [], index: 'dv1', query: { esql: 'FROM logs-*' } },
},
} as unknown as TextBasedPersistedState;

mockGetESQLTimeFieldFromQuery.mockResolvedValue(undefined);

const result = await ensureESQLTimeFieldOnAdHocDataViews({
adHocDataViews,
textBasedState,
dataViewsService: mockDataViews,
http: mockHttp,
});

expect(result.dv1).toEqual({ id: 'dv1', title: 'logs-*' });
expect(mockDataViews.clearInstanceCache).not.toHaveBeenCalled();
});
});
});
Loading
Loading