feat(sql): add group-by multi-column completion

Closes #6939
This commit is contained in:
t8y2
2026-08-22 03:02:42 +00:00
parent d33161bb71
commit 62657446f3
2 changed files with 140 additions and 1 deletions
+40 -1
View File
@@ -1350,6 +1350,7 @@ export interface SqlCompletionContext {
statementKind: SqlStatementKind;
tableTriggerWord?: string;
isGroupBy: boolean;
isEmptyGroupBy: boolean;
nonAggregatedSelectColumns: string[];
comparisonLeftColumn?: string;
onStar: boolean;
@@ -1518,7 +1519,12 @@ class SqlCompletionProvider {
}
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.prioritizeSelectAliases) {
this.items.push(...buildSelectAliasItems(context));
const selectAliasItems = buildSelectAliasItems(context);
this.items.push(...selectAliasItems);
if (context.isEmptyGroupBy && !context.prefix) {
const allSelectAliasesItem = buildGroupByAllSelectAliasItem(context, selectAliasItems, this.input.columnsByTable, this.dialect);
if (allSelectAliasesItem) this.items.push(allSelectAliasesItem);
}
}
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.isGroupBy && context.nonAggregatedSelectColumns.length > 0) {
@@ -2266,6 +2272,7 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
statementKind,
tableTriggerWord: lastWord || undefined,
isGroupBy: isInGroupByContext(beforeCursor),
isEmptyGroupBy: isEmptyGroupByContext(beforeCursor),
nonAggregatedSelectColumns: extractNonAggregatedSelectColumns(fullStatement),
comparisonLeftColumn: detectComparisonLeftColumn(beforeCursor),
onStar: detectOnStar(beforeCursor),
@@ -2579,6 +2586,16 @@ function isInGroupByContext(beforeCursor: string): boolean {
return !/\b(?:where|having|limit|offset|union|intersect|except|join|from)\b/.test(segment);
}
function isEmptyGroupByContext(beforeCursor: string): boolean {
if (!isInGroupByContext(beforeCursor)) return false;
const cleaned = beforeCursor
.replace(/'[^']*'/g, "''")
.replace(/"[^"]*"/g, '""')
.toLowerCase();
const lastGroupBy = cleaned.lastIndexOf("group by");
return lastGroupBy >= 0 && cleaned.slice(lastGroupBy + "group by".length).trim().length === 0;
}
const AGGREGATE_FUNCTION_PATTERN = /^(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT|STRING_AGG|ARRAY_AGG|JSON_ARRAYAGG|JSON_OBJECTAGG)\s*\(/i;
function extractNonAggregatedSelectColumns(sql: string): string[] {
@@ -4741,6 +4758,28 @@ function buildSelectAliasItems(context: SqlCompletionContext): SqlCompletionItem
}));
}
function buildGroupByAllSelectAliasItem(context: SqlCompletionContext, selectAliasItems: SqlCompletionItem[], columnsByTable: Map<string, SqlCompletionColumn[]>, dialect?: SqlCompletionApplyDialect): SqlCompletionItem | null {
const nonAggregatedAliases = new Set(context.nonAggregatedSelectColumns.map((column) => column.toLowerCase()));
const columnApplyByName = new Map(buildNonAggregatedColumnItems(context, columnsByTable, dialect).map((item) => [item.label.toLowerCase(), item.apply ?? item.label]));
const seen = new Set<string>();
const safeAliasItems = selectAliasItems.filter((item) => {
const key = item.label.toLowerCase();
if (!nonAggregatedAliases.has(key) || seen.has(key)) return false;
seen.add(key);
return true;
});
if (safeAliasItems.length < 2) return null;
return {
label: safeAliasItems.map((item) => item.label).join(", "),
type: "snippet",
detail: "All non-aggregated SELECT aliases",
apply: safeAliasItems.map((item) => columnApplyByName.get(item.label.toLowerCase()) ?? item.apply ?? item.label).join(", "),
boost: 3650,
dedupeKey: "group-by-all-select-aliases",
};
}
function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, dialect?: SqlCompletionApplyDialect): SqlCompletionItem[] {
const nonAggSet = new Set(context.nonAggregatedSelectColumns.map((c) => c.toLowerCase()));
const seen = new Set<string>();
+100
View File
@@ -47,6 +47,18 @@ const columnsByTable = new Map<string, SqlCompletionColumn[]>([
],
]);
function buildSemanticSqlCompletionItems(sql: string, options: { columns?: Map<string, SqlCompletionColumn[]>; databaseType?: DatabaseType } = {}) {
const databaseType = options.databaseType ?? "mysql";
const dialect = databaseType === "sqlserver" ? "sqlserver" : databaseType === "postgres" ? "postgres" : "mysql";
const semanticOptions = { databaseType, dialect } as const;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, sql.length, semanticOptions), getSqlCompletionContext(sql, sql.length, semanticOptions));
return buildSqlCompletionItemsFromContext(context, {
tables,
columnsByTable: options.columns ?? columnsByTable,
...semanticOptions,
});
}
const mysqlCrossDatabaseColumnsByTable = new Map<string, SqlCompletionColumn[]>([
[
"other_db.orders",
@@ -2574,6 +2586,94 @@ test("prioritizes select aliases in GROUP BY completion", () => {
assert.equal(items[0]?.detail, "SELECT alias");
});
test("offers all safe SELECT aliases as one completion for an empty GROUP BY", () => {
const sql = "select voucher_date, member_id, min(voucher_date) from dwq_orders_actual_sale_amount_df group by ";
const items = buildSemanticSqlCompletionItems(sql);
const combined = items.find((item) => item.detail === "All non-aggregated SELECT aliases");
assert.deepEqual(combined, {
label: "voucher_date, member_id",
type: "snippet",
detail: "All non-aggregated SELECT aliases",
apply: "voucher_date, member_id",
boost: 3650,
dedupeKey: "group-by-all-select-aliases",
});
assert.ok(items.indexOf(combined!) < items.findIndex((item) => item.label === "voucher_date" && item.detail === "SELECT alias"));
assert.deepEqual(
items.filter((item) => item.detail === "SELECT alias").map((item) => item.label),
["voucher_date", "member_id"],
);
});
test("preserves SELECT order and deduplicates combined GROUP BY aliases case-insensitively", () => {
const sql = "select region as region_key, status as REGION_KEY, email as state, sum(id) as total from public.users group by ";
const items = buildSemanticSqlCompletionItems(sql);
const combined = items.find((item) => item.detail === "All non-aggregated SELECT aliases");
assert.equal(combined?.label, "region_key, state");
assert.equal(combined?.apply, "region_key, state");
});
test("uses stable expression aliases and existing quoted column apply text in combined GROUP BY completion", () => {
const sql = "select date(created_at) as day, u.order, sum(u.id) as total from public.users u group by ";
const items = buildSemanticSqlCompletionItems(sql, {
columns: new Map([
[
"public.users",
[
{ name: "order", table: "users", schema: "public" },
{ name: "created_at", table: "users", schema: "public" },
{ name: "id", table: "users", schema: "public" },
],
],
]),
});
const combined = items.find((item) => item.detail === "All non-aggregated SELECT aliases");
assert.equal(combined?.label, "day, order");
assert.equal(combined?.apply, "day, `order`");
});
test("hides combined GROUP BY completion for unsafe or incomplete candidate sets", () => {
for (const sql of ["select name, count(id) as total from public.users group by ", "select lower(name), email, count(id) as total from public.users group by ", "select count(id) as total, sum(id) as sum_id from public.users group by "]) {
const items = buildSemanticSqlCompletionItems(sql);
assert.equal(
items.some((item) => item.detail === "All non-aggregated SELECT aliases"),
false,
sql,
);
}
});
test("hides combined GROUP BY completion after existing expressions or a non-empty prefix", () => {
for (const sql of ["select name, email from public.users group by name, ", "select name, email from public.users group by na"]) {
const items = buildSemanticSqlCompletionItems(sql);
assert.equal(
items.some((item) => item.detail === "All non-aggregated SELECT aliases"),
false,
sql,
);
}
});
test("does not add combined SELECT alias completion to ORDER BY", () => {
const sql = "select name as display_name, email as display_email from public.users order by ";
const items = buildSemanticSqlCompletionItems(sql);
assert.equal(
items.some((item) => item.detail === "All non-aggregated SELECT aliases"),
false,
);
assert.deepEqual(
items.slice(0, 2).map((item) => [item.label, item.detail]),
[
["display_name", "SELECT alias"],
["display_email", "SELECT alias"],
],
);
});
test("suggests likely join condition snippets after ON", () => {
const sql = "select * from public.users u join public.orders o on ";
const items = buildSqlCompletionItems(sql, sql.length, {