diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 003e222082..b27275db7b 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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, 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(); + 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, dialect?: SqlCompletionApplyDialect): SqlCompletionItem[] { const nonAggSet = new Set(context.nonAggregatedSelectColumns.map((c) => c.toLowerCase())); const seen = new Set(); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 4fccff0cf1..f80670a4ed 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -47,6 +47,18 @@ const columnsByTable = new Map([ ], ]); +function buildSemanticSqlCompletionItems(sql: string, options: { columns?: Map; 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([ [ "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, {