mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
@@ -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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user