Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion packages/kbn-optimizer/limits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ pageLoadAssetSize:
kibanaUtils: 54161
kql: 15428
kubernetesSecurity: 6807
lens: 86000
lens: 97240
licenseManagement: 8265
licensing: 10073
links: 8620
Expand Down
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