Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -52,6 +52,7 @@ export {
constructCascadeQuery,
mutateQueryStatsGrouping,
appendFilteringWhereClauseForCascadeLayout,
hasTimeseriesBucketAggregation,
} from './src';

export { ENABLE_ESQL, FEEDBACK_LINK } from './constants';
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 @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
convertTimeseriesCommandToFrom,
hasLimitBeforeAggregate,
missingSortBeforeLimit,
hasTimeseriesBucketAggregation,
} from './query_parsing_helpers';

describe('esql query helpers', () => {
Expand Down Expand Up @@ -1052,4 +1053,112 @@ 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',
mockColumns
)
).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 bucket aggregation, case insensitive and ignoring spaces', () => {
expect(
hasTimeseriesBucketAggregation(
'TS index | STATS COUNT() BY BUCKET(@timestamp, 1h), agent.name',
mockColumns
)
).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,62 @@ 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);

const isTimeseriesCommand = Walker.commands(root).some(({ name }) => name === 'ts');
if (!isTimeseriesCommand) {
return false;
}

const columnsMap = new Map<string, DatatableColumn>();
columns.forEach((column) => {
const trimmedId = column.id.trim().replace(/\s+/g, '').toLowerCase();
columnsMap.set(trimmedId, column);
});

const statsCommands = Walker.commands(root).filter(({ name }) => name === 'stats');
if (statsCommands.length === 0) {
return false;
}

const byCommands = Walker.matchAll(statsCommands, { type: 'option', name: 'by' });

const lastByCommand = byCommands[Math.max(byCommands.length - 1, 0)];
const bucketColumns: string[] = [];
walk(lastByCommand, {
// STATS ... BY someAlias=BUCKET(column, interval), extracts the alias name
visitColumn: (node, parent) => {
if (isFunctionExpression(parent) && parent.subtype === 'binary-expression') {
bucketColumns.push(node.name);
}
},
// STATS ... BY BUCKET(column, interval), extracts the entire BUCKET function
visitFunction: (node, parent) => {
if (isFunctionExpression(parent) && parent.subtype === 'binary-expression') {
return;
}

if (node.subtype === 'variadic-call' && node.name === 'bucket') {
bucketColumns.push(node.text.trim().replace(/\s+/g, '').toLowerCase());
}
},
});

const isDateHistogram = bucketColumns.some((column) => {
const columnData = columnsMap.get(column);
return columnData?.meta.type === 'date';
});

return isDateHistogram;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Comment on lines +665 to +668
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we'd add a test case for this to lens_vis_service.suggestions.test.ts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks. I'll add a test case there

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


let visAttributes = preferredVisAttributes;

if (preferredVisAttributes) {
Expand All @@ -671,7 +677,7 @@ export class LensVisService {
dataViewSpec: dataView?.toSpec(),
fieldName: '',
textBasedColumns: columns,
query: query && isOfAggregateQueryType(query) ? query : undefined,
query,
};

return (
Expand Down