Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,23 +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 encodedQuery = encodeURIComponent(query);
const pendingRequest = http
.get(`${TIMEFIELD_ROUTE}${encodedQuery}`)
.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,53 @@
/*
* 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
// eslint-disable-next-line @kbn/eslint/no_unsafe_dynamic_http_path -- buildPath can't be used in common package
.get(`${TIMEFIELD_ROUTE}${encodeURIComponent(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 @@ -5,13 +5,9 @@
* 2.0.
*/

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 type { DataViewsContract, DataViewField } from '@kbn/data-views-plugin/public';
import {
ensureIndexPattern,
ensureESQLTimeFieldOnAdHocDataViews,
loadIndexPatternRefs,
loadIndexPatterns,
buildIndexPatternField,
Expand All @@ -23,10 +19,6 @@ jest.mock('@kbn/esql-utils', () => ({
getESQLAdHocDataview: jest.fn(),
}));

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

describe('loader', () => {
describe('loadIndexPatternRefs', () => {
it('should return a list of sorted indexpattern refs', async () => {
Expand Down Expand Up @@ -358,205 +350,4 @@ describe('loader', () => {
expect(field.meta).toEqual(true);
});
});

describe('ensureESQLTimeFieldOnAdHocDataViews', () => {
const mockHttp = {} as HttpStart;
const mockDataViews = mockDataViewsService() as unknown as DataViewsContract;

beforeEach(() => {
mockGetESQLAdHocDataview.mockReset();
});

it('should return adHocDataViews unchanged when textBasedState is undefined', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {
dv1: { id: 'dv1', title: 'logs-*' },
};

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

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

it('should return adHocDataViews unchanged when layers is empty', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {
dv1: { id: 'dv1', title: 'logs-*' },
};

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

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

it('should skip layers without an ES|QL query', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {};
const textBasedState = {
layers: {
layer1: { columns: [], index: 'dv1' },
},
} as unknown as TextBasedPersistedState;

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

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

it('should skip enrichment when the existing spec already has a timeFieldName', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {
dv1: { id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' },
};
const textBasedState = {
layers: {
layer1: { columns: [], index: 'dv1', query: { esql: 'FROM logs-*' } },
},
} as unknown as TextBasedPersistedState;

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

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

it('should call getESQLAdHocDataview when spec is missing timeFieldName', 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;

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

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

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

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

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

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

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

it('should handle mixed layers: only enrich specs missing timeFieldName', async () => {
const adHocDataViews: Record<string, DataViewSpec> = {
dv1: { id: 'dv1', title: 'logs-*', timeFieldName: '@timestamp' },
dv2: { id: 'dv2', title: 'metrics-*' },
};
const textBasedState = {
layers: {
layer1: { columns: [], index: 'dv1', query: { esql: 'FROM logs-*' } },
layer2: { columns: [], index: 'dv2', query: { esql: 'FROM metrics-*' } },
},
} as unknown as TextBasedPersistedState;

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

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

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

it('should not mutate the original adHocDataViews object', 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;

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

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

expect(result).not.toBe(adHocDataViews);
expect(adHocDataViews.dv1.timeFieldName).toBeUndefined();
});
});
});
Loading
Loading