diff --git a/src/platform/packages/shared/kbn-esql-utils/index.ts b/src/platform/packages/shared/kbn-esql-utils/index.ts index ae561270c54da..cced927001cdf 100644 --- a/src/platform/packages/shared/kbn-esql-utils/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/index.ts @@ -52,6 +52,7 @@ export { constructCascadeQuery, mutateQueryStatsGrouping, appendFilteringWhereClauseForCascadeLayout, + hasTimeseriesBucketAggregation, } from './src'; export { ENABLE_ESQL, FEEDBACK_LINK } from './constants'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/index.ts b/src/platform/packages/shared/kbn-esql-utils/src/index.ts index 2ce65a12b89ce..97dbd2aa66e3e 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/index.ts @@ -28,6 +28,7 @@ export { getKqlSearchQueries, getRemoteClustersFromESQLQuery, convertTimeseriesCommandToFrom, + hasTimeseriesBucketAggregation, } from './utils/query_parsing_helpers'; export { getIndexPatternFromESQLQuery } from './utils/get_index_pattern_from_query'; export { queryCannotBeSampled } from './utils/query_cannot_be_sampled'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts index c506e8fad425f..d198876a71670 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts @@ -31,6 +31,7 @@ import { convertTimeseriesCommandToFrom, hasLimitBeforeAggregate, missingSortBeforeLimit, + hasTimeseriesBucketAggregation, } from './query_parsing_helpers'; describe('esql query helpers', () => { @@ -1052,4 +1053,125 @@ describe('esql query helpers', () => { expect(missingSortBeforeLimit('FROM index | LIMIT 10 | SORT field')).toBe(true); }); }); + + describe('hasTimeseriesBucketAggregation', () => { + const mockColumns = [ + { + id: 'BUCKET(@timestamp, 1h)', + isNull: false, + meta: { type: 'date', esType: 'date' }, + name: 'BUCKET(@timestamp, 1h)', + }, + { + id: 'agent.name', + isNull: false, + meta: { type: 'string', esType: 'keyword' }, + name: 'agent.name', + }, + { + id: '@timestamp', + isNull: false, + meta: { type: 'date', esType: 'date' }, + name: '@timestamp', + }, + { + id: 'c3', + isNull: false, + meta: { type: 'number', esType: 'long' }, + name: 'c3', + }, + ] as DatatableColumn[]; + + it('should return false if the query is empty', () => { + expect(hasTimeseriesBucketAggregation('')).toBe(false); + }); + it('should return false if it is not a timeseries command', () => { + expect( + hasTimeseriesBucketAggregation('FROM index | STATS COUNT() BY bucket(@timestamp, 1h)') + ).toBe(false); + }); + it('should return false if there is no BUCKET aggregation', () => { + expect(hasTimeseriesBucketAggregation('TS index | STATS COUNT() BY @timestamp')).toBe(false); + }); + it('should return false if the BUCKET aggregation is not a date column', () => { + expect( + hasTimeseriesBucketAggregation( + 'TS index | STATS COUNT() BY bucket(c3, 20, 100, 200), agent.name', + [ + { + id: 'agent.name', + isNull: false, + meta: { type: 'string', esType: 'keyword' }, + name: 'agent.name', + }, + { + id: 'c3', + isNull: false, + meta: { type: 'number', esType: 'long' }, + name: 'c3', + }, + ] as DatatableColumn[] + ) + ).toBe(false); + }); + it('should return false if bucket column cannot be identified', () => { + expect( + hasTimeseriesBucketAggregation('TS index | STATS COUNT() BY bucket(@timestamp, 1h)') + ).toBe(false); + }); + it('should return false if the last stats command does not contain a BUCKET aggregation', () => { + expect( + hasTimeseriesBucketAggregation( + `TS index + | STATS count_per_day=COUNT(*) BY category=CATEGORIZE(message), @timestamp=BUCKET(@timestamp, 1 day) + | STATS count = SUM(count_per_day), Trend=VALUES(count_per_day) BY category + | KEEP category, count + | STATS sample = SAMPLE(count, 10) BY category`, + mockColumns + ) + ).toBe(false); + }); + it('should return true if the query contains a BUCKET aggregation', () => { + expect( + hasTimeseriesBucketAggregation( + 'TS index | STATS COUNT() BY bucket(@timestamp, 1h)', + mockColumns + ) + ).toBe(true); + }); + it('should return true if the query contains aliased BUCKET aggregation', () => { + const columns = [ + { + id: 't', + isNull: false, + meta: { type: 'date', esType: 'date' }, + name: 't', + }, + ] as DatatableColumn[]; + + expect( + hasTimeseriesBucketAggregation( + 'TS index | STATS COUNT() BY t=bucket(@timestamp, 1h)', + columns + ) + ).toBe(true); + }); + it('should return true if the query contains a BUCKET aggregation with multiple breakdowns', () => { + expect( + hasTimeseriesBucketAggregation( + 'TS index | STATS COUNT() BY bucket(@timestamp, 1h), agent.name', + mockColumns + ) + ).toBe(true); + }); + + it('should return true if the query contains a TBUCKET aggregation with multiple breakdowns', () => { + expect( + hasTimeseriesBucketAggregation( + 'TS index | STATS COUNT() BY tbucket(@timestamp, 1h), agent.name', + mockColumns + ) + ).toBe(true); + }); + }); }); diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts index c505efa8ee6bc..a1b51c55df18a 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts @@ -591,3 +591,41 @@ export const missingSortBeforeLimit = (esql: string): boolean => { } return false; }; + +/** + * Checks if the ESQL query contains a timeseries bucket aggregation. + * @param esql: string - The ESQL query string + * @param columns: DatatableColumn[] - The columns of the datatable + * @returns true if the query contains a timeseries bucket aggregation, false otherwise + */ +export function hasTimeseriesBucketAggregation( + esql: string, + columns: DatatableColumn[] = [] +): boolean { + const { root } = Parser.parse(esql); + + if (!columns.some((column) => column.meta.type === 'date')) { + return false; + } + + const isTimeseriesCommand = Walker.commands(root).some(({ name }) => name === 'ts'); + if (!isTimeseriesCommand) { + return false; + } + + const statsCommands = Walker.commands(root).filter(({ name }) => name === 'stats'); + if (statsCommands.length === 0) { + return false; + } + + const statsByCommands = Walker.matchAll(statsCommands, { type: 'option', name: 'by' }); + if (statsByCommands.length === 0) { + return false; + } + + const lastByCommand = statsByCommands[statsByCommands.length - 1]; + const bucketFunction = Walker.match(lastByCommand, { type: 'function', name: 'bucket' }); + const tbucketFunction = Walker.match(lastByCommand, { type: 'function', name: 'tbucket' }); + + return !!bucketFunction || !!tbucketFunction; +} diff --git a/src/platform/packages/shared/kbn-unified-histogram/__mocks__/lens_vis.ts b/src/platform/packages/shared/kbn-unified-histogram/__mocks__/lens_vis.ts index a6c326c6ec3a2..07d66f1878b52 100644 --- a/src/platform/packages/shared/kbn-unified-histogram/__mocks__/lens_vis.ts +++ b/src/platform/packages/shared/kbn-unified-histogram/__mocks__/lens_vis.ts @@ -11,6 +11,7 @@ import type { DataViewField } from '@kbn/data-views-plugin/common'; import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import type { Suggestion } from '@kbn/lens-plugin/public'; import type { TimeRange } from '@kbn/data-plugin/common'; +import type { ChartType } from '@kbn/visualization-utils'; import { LensVisService } from '../services/lens_vis_service'; import { type QueryParams } from '../utils/external_vis_context'; import { unifiedHistogramServicesMock } from './services'; @@ -36,6 +37,7 @@ export const getLensVisMock = async ({ table, externalVisContext, getModifiedVisAttributes, + onLensSuggestionsApiCall, }: { filters: QueryParams['filters']; query: QueryParams['query']; @@ -50,6 +52,7 @@ export const getLensVisMock = async ({ table?: Datatable; externalVisContext?: UnifiedHistogramVisContext; getModifiedVisAttributes?: Parameters[0]['getModifiedVisAttributes']; + onLensSuggestionsApiCall?: (preferredChartType: ChartType | undefined) => void; }): Promise<{ lensService: LensVisService; visContext: UnifiedHistogramVisContext | undefined; @@ -61,6 +64,8 @@ export const getLensVisMock = async ({ lensSuggestionsApi: allSuggestions ? (...params) => { const context = params[0]; + const preferredChartType = params[3]; + onLensSuggestionsApiCall?.(preferredChartType); if ('query' in context && context.query === query) { return allSuggestions; } diff --git a/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.suggestions.test.ts b/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.suggestions.test.ts index cc507ae859647..82375d250613e 100644 --- a/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.suggestions.test.ts +++ b/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.suggestions.test.ts @@ -13,7 +13,8 @@ import { deepMockedFields, buildDataViewMock } from '@kbn/discover-utils/src/__m import { allSuggestionsMock } from '../__mocks__/suggestions'; import { getLensVisMock } from '../__mocks__/lens_vis'; import { convertDatatableColumnToDataViewFieldSpec } from '@kbn/data-view-utils'; -import { UnifiedHistogramSuggestionType } from '../types'; +import { UnifiedHistogramSuggestionType, type UnifiedHistogramVisContext } from '../types'; +import { ChartType } from '@kbn/visualization-utils'; describe('LensVisService suggestions', () => { const dataViewMock = buildDataViewMock({ @@ -372,4 +373,87 @@ describe('LensVisService suggestions', () => { expect(lensVis.visContext?.attributes.state.query).toStrictEqual(histogramQuery); }); + + test('should use ChartType.Line as preferred chart type when query has timeseries bucket aggregation and no preferred vis attributes', async () => { + let capturedPreferredChartType: ChartType | undefined; + + await getLensVisMock({ + filters: [], + query: { esql: 'TS the-data-view | STATS COUNT() BY bucket(@timestamp, 1h)' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: undefined, + columns: [ + { + id: 'BUCKET(@timestamp, 1h)', + name: 'BUCKET(@timestamp, 1h)', + meta: { type: 'date', esType: 'date' }, + }, + { + id: 'COUNT()', + name: 'COUNT()', + meta: { type: 'number', esType: 'long' }, + }, + ], + isPlainRecord: true, + allSuggestions: allSuggestionsMock, + onLensSuggestionsApiCall: (preferredChartType) => { + capturedPreferredChartType = preferredChartType; + }, + }); + + expect(capturedPreferredChartType).toBe(ChartType.Line); + }); + + test('should use preferred vis attributes chart type instead of Line for timeseries bucket aggregation queries when externalVisContext is provided', async () => { + let capturedPreferredChartType: ChartType | undefined; + + const externalVisContext = { + attributes: { + visualizationType: 'lnsHeatmap', + state: { + query: { esql: 'TS the-data-view | STATS COUNT() BY bucket(@timestamp, 1h)' }, + datasourceStates: { formBased: { layers: {} } }, + }, + }, + requestData: {}, + suggestionType: UnifiedHistogramSuggestionType.lensSuggestion, + } as UnifiedHistogramVisContext; + + await getLensVisMock({ + filters: [], + query: { esql: 'TS the-data-view | STATS COUNT() BY bucket(@timestamp, 1h)' }, + dataView: dataViewMock, + timeInterval: 'auto', + timeRange: { + from: '2023-09-03T08:00:00.000Z', + to: '2023-09-04T08:56:28.274Z', + }, + breakdownField: undefined, + columns: [ + { + id: 'BUCKET(@timestamp, 1h)', + name: 'BUCKET(@timestamp, 1h)', + meta: { type: 'date', esType: 'date' }, + }, + { + id: 'COUNT()', + name: 'COUNT()', + meta: { type: 'number', esType: 'long' }, + }, + ], + isPlainRecord: true, + allSuggestions: allSuggestionsMock, + externalVisContext, + onLensSuggestionsApiCall: (preferredChartType) => { + capturedPreferredChartType = preferredChartType; + }, + }); + + expect(capturedPreferredChartType).toBe(ChartType.Heatmap); + }); }); diff --git a/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.ts b/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.ts index 4affcadcc4b6b..e0c37c771314b 100644 --- a/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.ts +++ b/src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.ts @@ -16,6 +16,7 @@ import { hasTransformationalCommand, getCategorizeField, convertTimeseriesCommandToFrom, + hasTimeseriesBucketAggregation, } from '@kbn/esql-utils'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import type { @@ -657,10 +658,15 @@ export class LensVisService { return []; } - const preferredChartType = preferredVisAttributes + const mappedPreferredChartType = preferredVisAttributes ? mapVisToChartType(preferredVisAttributes.visualizationType) : undefined; + const preferredChartType = + !mappedPreferredChartType && hasTimeseriesBucketAggregation(query.esql, columns) + ? ChartType.Line + : mappedPreferredChartType; + let visAttributes = preferredVisAttributes; if (preferredVisAttributes) { @@ -671,7 +677,7 @@ export class LensVisService { dataViewSpec: dataView?.toSpec(), fieldName: '', textBasedColumns: columns, - query: query && isOfAggregateQueryType(query) ? query : undefined, + query, }; return (