Skip to content

Commit ca5109f

Browse files
committed
feat: prefer line char when the query is ts date histogram
1 parent f401420 commit ca5109f

File tree

6 files changed

+179
-29
lines changed

6 files changed

+179
-29
lines changed

src/platform/packages/shared/kbn-esql-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export {
5252
constructCascadeQuery,
5353
mutateQueryStatsGrouping,
5454
appendFilteringWhereClauseForCascadeLayout,
55+
hasTimeseriesBucketAggregation,
5556
} from './src';
5657

5758
export { ENABLE_ESQL, FEEDBACK_LINK } from './constants';

src/platform/packages/shared/kbn-esql-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export {
2828
getKqlSearchQueries,
2929
getRemoteClustersFromESQLQuery,
3030
convertTimeseriesCommandToFrom,
31+
hasTimeseriesBucketAggregation,
3132
} from './utils/query_parsing_helpers';
3233
export { getIndexPatternFromESQLQuery } from './utils/get_index_pattern_from_query';
3334
export { queryCannotBeSampled } from './utils/query_cannot_be_sampled';

src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
convertTimeseriesCommandToFrom,
3232
hasLimitBeforeAggregate,
3333
missingSortBeforeLimit,
34+
hasTimeseriesBucketAggregation,
3435
} from './query_parsing_helpers';
3536

3637
describe('esql query helpers', () => {
@@ -1052,4 +1053,112 @@ describe('esql query helpers', () => {
10521053
expect(missingSortBeforeLimit('FROM index | LIMIT 10 | SORT field')).toBe(true);
10531054
});
10541055
});
1056+
1057+
describe('hasTimeseriesBucketAggregation', () => {
1058+
const mockColumns = [
1059+
{
1060+
id: 'BUCKET(@timestamp, 1h)',
1061+
isNull: false,
1062+
meta: { type: 'date', esType: 'date' },
1063+
name: 'BUCKET(@timestamp, 1h)',
1064+
},
1065+
{
1066+
id: 'agent.name',
1067+
isNull: false,
1068+
meta: { type: 'string', esType: 'keyword' },
1069+
name: 'agent.name',
1070+
},
1071+
{
1072+
id: '@timestamp',
1073+
isNull: false,
1074+
meta: { type: 'date', esType: 'date' },
1075+
name: '@timestamp',
1076+
},
1077+
{
1078+
id: 'c3',
1079+
isNull: false,
1080+
meta: { type: 'number', esType: 'long' },
1081+
name: 'c3',
1082+
},
1083+
] as DatatableColumn[];
1084+
1085+
it('should return false if the query is empty', () => {
1086+
expect(hasTimeseriesBucketAggregation('')).toBe(false);
1087+
});
1088+
it('should return false if it is not a timeseries command', () => {
1089+
expect(
1090+
hasTimeseriesBucketAggregation('FROM index | STATS COUNT() BY bucket(@timestamp, 1h)')
1091+
).toBe(false);
1092+
});
1093+
it('should return false if there is no bucket aggregation', () => {
1094+
expect(hasTimeseriesBucketAggregation('TS index | STATS COUNT() BY @timestamp')).toBe(false);
1095+
});
1096+
it('should return false if the bucket aggregation is not a date column', () => {
1097+
expect(
1098+
hasTimeseriesBucketAggregation(
1099+
'TS index | STATS COUNT() BY bucket(c3, 20, 100, 200), agent.name',
1100+
mockColumns
1101+
)
1102+
).toBe(false);
1103+
});
1104+
it('should return false if bucket column cannot be identified', () => {
1105+
expect(
1106+
hasTimeseriesBucketAggregation('TS index | STATS COUNT() BY bucket(@timestamp, 1h)')
1107+
).toBe(false);
1108+
});
1109+
it('should return false if the last stats command does not contain a bucket aggregation', () => {
1110+
expect(
1111+
hasTimeseriesBucketAggregation(
1112+
`TS index
1113+
| STATS count_per_day=COUNT(*) BY category=CATEGORIZE(message), @timestamp=BUCKET(@timestamp, 1 day)
1114+
| STATS count = SUM(count_per_day), Trend=VALUES(count_per_day) BY category
1115+
| KEEP category, count
1116+
| STATS sample = SAMPLE(count, 10) BY category`,
1117+
mockColumns
1118+
)
1119+
).toBe(false);
1120+
});
1121+
it('should return true if the query contains a bucket aggregation', () => {
1122+
expect(
1123+
hasTimeseriesBucketAggregation(
1124+
'TS index | STATS COUNT() BY bucket(@timestamp, 1h)',
1125+
mockColumns
1126+
)
1127+
).toBe(true);
1128+
});
1129+
it('should return true if the query contains aliased bucket aggregation', () => {
1130+
const columns = [
1131+
{
1132+
id: 't',
1133+
isNull: false,
1134+
meta: { type: 'date', esType: 'date' },
1135+
name: 't',
1136+
},
1137+
] as DatatableColumn[];
1138+
1139+
expect(
1140+
hasTimeseriesBucketAggregation(
1141+
'TS index | STATS COUNT() BY t=bucket(@timestamp, 1h)',
1142+
columns
1143+
)
1144+
).toBe(true);
1145+
});
1146+
it('should return true if the query contains a bucket aggregation with multiple breakdowns', () => {
1147+
expect(
1148+
hasTimeseriesBucketAggregation(
1149+
'TS index | STATS COUNT() BY bucket(@timestamp, 1h), agent.name',
1150+
mockColumns
1151+
)
1152+
).toBe(true);
1153+
});
1154+
1155+
it('should return true if the query bucket aggregation, case insensitive and ignoring spaces', () => {
1156+
expect(
1157+
hasTimeseriesBucketAggregation(
1158+
'TS index | STATS COUNT() BY BUCKET(@timestamp, 1h), agent.name',
1159+
mockColumns
1160+
)
1161+
).toBe(true);
1162+
});
1163+
});
10551164
});

src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,61 @@ export const missingSortBeforeLimit = (esql: string): boolean => {
591591
}
592592
return false;
593593
};
594+
595+
/**
596+
* Checks if the ESQL query contains a bucket aggregation.
597+
* @param esql: string - The ESQL query string
598+
* @returns true if the query contains a bucket aggregation, false otherwise
599+
*/
600+
export function hasTimeseriesBucketAggregation(
601+
esql: string,
602+
columns: DatatableColumn[] = []
603+
): boolean {
604+
const { root } = Parser.parse(esql);
605+
606+
const isTimeseriesCommand = Walker.commands(root).some(({ name }) => name === 'ts');
607+
if (!isTimeseriesCommand) {
608+
return false;
609+
}
610+
611+
const columnsMap = new Map<string, DatatableColumn>();
612+
columns.forEach((column) => {
613+
const trimmedId = column.id.trim().replace(/\s+/g, '').toLowerCase();
614+
columnsMap.set(trimmedId, column);
615+
});
616+
617+
const statsCommands = Walker.commands(root).filter(({ name }) => name === 'stats');
618+
if (statsCommands.length === 0) {
619+
return false;
620+
}
621+
622+
const byCommands = Walker.matchAll(statsCommands, { type: 'option', name: 'by' });
623+
624+
const lastByCommand = byCommands[Math.max(byCommands.length - 1, 0)];
625+
const bucketColumns: string[] = [];
626+
walk(lastByCommand, {
627+
// STATS ... BY someAlias=BUCKET(column, interval), extracts the alias name
628+
visitColumn: (node, parent) => {
629+
if (isFunctionExpression(parent) && parent.subtype === 'binary-expression') {
630+
bucketColumns.push(node.name);
631+
}
632+
},
633+
// STATS ... BY BUCKET(column, interval), extracts the entire BUCKET function
634+
visitFunction: (node, parent) => {
635+
if (isFunctionExpression(parent) && parent.subtype === 'binary-expression') {
636+
return;
637+
}
638+
639+
if (node.subtype === 'variadic-call' && node.name === 'bucket') {
640+
bucketColumns.push(node.text.trim().replace(/\s+/g, '').toLowerCase());
641+
}
642+
},
643+
});
644+
645+
const isDateHistogram = bucketColumns.some((column) => {
646+
const columnData = columnsMap.get(column);
647+
return columnData?.meta.type === 'date';
648+
});
649+
650+
return isDateHistogram;
651+
}

src/platform/packages/shared/kbn-unified-histogram/services/lens_vis_service.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
hasTransformationalCommand,
1717
getCategorizeField,
1818
convertTimeseriesCommandToFrom,
19+
hasTimeseriesBucketAggregation,
1920
} from '@kbn/esql-utils';
2021
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
2122
import type {
@@ -657,10 +658,15 @@ export class LensVisService {
657658
return [];
658659
}
659660

660-
const preferredChartType = preferredVisAttributes
661+
const mappedPreferredChartType = preferredVisAttributes
661662
? mapVisToChartType(preferredVisAttributes.visualizationType)
662663
: undefined;
663664

665+
const preferredChartType =
666+
!mappedPreferredChartType && hasTimeseriesBucketAggregation(query.esql, columns)
667+
? ChartType.Line
668+
: mappedPreferredChartType;
669+
664670
let visAttributes = preferredVisAttributes;
665671

666672
if (preferredVisAttributes) {
@@ -671,15 +677,15 @@ export class LensVisService {
671677
dataViewSpec: dataView?.toSpec(),
672678
fieldName: '',
673679
textBasedColumns: columns,
674-
query: query && isOfAggregateQueryType(query) ? query : undefined,
680+
query,
675681
};
676682

677683
return (
678684
this.lensSuggestionsApi(
679685
context,
680686
dataView,
681687
['lnsDatatable'],
682-
hasESQLBucketAggregation(query.esql) ? ChartType.Line : preferredChartType,
688+
preferredChartType,
683689
visAttributes
684690
) ?? []
685691
);
@@ -859,29 +865,3 @@ function areSuggestionAndVisContextAndQueryParamsStillCompatible({
859865
isSuggestionShapeAndVisContextCompatible(suggestion, externalVisContext)
860866
);
861867
}
862-
863-
function hasESQLBucketAggregation(esql?: string): boolean {
864-
if (!esql) {
865-
return false;
866-
}
867-
868-
const { root } = Parser.parse(esql);
869-
const statsCommands = Walker.matchAll(root, { type: 'command', name: 'stats' });
870-
if (statsCommands.length === 0) {
871-
return false;
872-
}
873-
874-
if (!Walker.hasFunction(statsCommands[statsCommands.length - 1], 'bucket')) {
875-
return false;
876-
}
877-
878-
const hasValidAggregations = Walker.findAll(
879-
statsCommands,
880-
(node) =>
881-
isFunctionExpression(node) &&
882-
node.subtype === 'variadic-call' &&
883-
node.name.toLowerCase() !== 'bucket'
884-
);
885-
886-
return hasValidAggregations.length > 0;
887-
}

x-pack/platform/plugins/shared/lens/public/lens_suggestions_api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export const suggestionsApi = ({
9393
activeVisualization: initialVisualization,
9494
visualizationState: undefined,
9595
visualizeTriggerFieldContext: context,
96+
subVisualizationId: preferredChartType,
9697
dataViews,
9798
});
9899
if (!suggestions.length) return [];

0 commit comments

Comments
 (0)