fix(sql): narrow completion suggestions

This commit is contained in:
t8y2 2026-05-22 01:36:26 +08:00
parent 7a3721952a
commit af5eb9cb2f
2 changed files with 99 additions and 12 deletions

View File

@ -258,6 +258,7 @@ const SQL_KEYWORDS = [
];
const TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "table", "describe", "explain", "apply"]);
const EXCLUSIVE_TABLE_TRIGGER_KEYWORDS = new Set(["from", "join", "update", "into", "apply"]);
const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]);
const MAX_TABLE_COMPLETION_ITEMS = 200;
@ -349,6 +350,8 @@ export interface SqlCompletionContext {
suggestColumns: boolean;
suggestKeywords: boolean;
suggestJoinConditions: boolean;
exclusiveTableSuggestions: boolean;
exclusiveColumnSuggestions: boolean;
prioritizeSelectAliases: boolean;
selectAliases: string[];
referencedTables: SqlCompletionReferencedTable[];
@ -382,14 +385,16 @@ export function buildSqlCompletionItemsFromContext(
): SqlCompletionItem[] {
const items: SqlCompletionItem[] = [];
items.push(...buildSnippetItems(context.prefix));
items.push(...buildFunctionSnippetItems(context.prefix));
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions) {
items.push(...buildSnippetItems(context.prefix));
items.push(...buildFunctionSnippetItems(context.prefix));
}
if (context.prioritizeSelectAliases) {
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.prioritizeSelectAliases) {
items.push(...buildSelectAliasItems(context));
}
if (context.suggestJoinConditions) {
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && context.suggestJoinConditions) {
items.push(...buildJoinConditionItems(context, input.columnsByTable));
}
@ -398,11 +403,11 @@ export function buildSqlCompletionItemsFromContext(
items.push(...buildKeywordItems(context.prefix));
}
if (context.suggestColumns) {
if (!context.exclusiveTableSuggestions && context.suggestColumns) {
items.push(...buildColumnItems(context, input.columnsByTable));
}
if (context.suggestTables) {
if (!context.exclusiveColumnSuggestions && context.suggestTables) {
items.push(...buildTableItems(context.prefix, input.tables));
}
@ -492,8 +497,8 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
const plainMatch = /([A-Za-z_][\w$]*)$/.exec(beforeCursor);
const prefix = dottedMatch?.[2] ?? plainMatch?.[1] ?? "";
const qualifier = dottedMatch?.[1];
const bareStart = qualifier
? beforeCursor.length - prefix.length
const bareStart = dottedMatch
? beforeCursor.length - dottedMatch[0].length
: beforeCursor.length - (plainMatch?.[1]?.length ?? 0);
const beforeToken = beforeCursor.slice(0, Math.max(0, bareStart)).trimEnd();
const lastWord = /([A-Za-z_][\w$]*)$/.exec(beforeToken)?.[1]?.toLowerCase() ?? "";
@ -504,6 +509,11 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
TABLE_TRIGGER_KEYWORDS.has(lastWord) ||
(JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) ||
isInTableListContext(beforeToken);
const exclusiveTableSuggestions =
EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) ||
(JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) ||
isInTableListContext(beforeToken);
const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions;
// Check if we're in a context where columns are expected
const inColumnContext = isInColumnContext(beforeCursor);
@ -520,8 +530,10 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
// 2. We're in a column context (WHERE, ON, SELECT, etc.) AND there are referenced tables
suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0),
// Always suggest keywords
suggestKeywords: true,
suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions,
suggestJoinConditions: inJoinConditionContext && referencedTables.length >= 2,
exclusiveTableSuggestions,
exclusiveColumnSuggestions,
prioritizeSelectAliases,
selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [],
referencedTables,

View File

@ -12,6 +12,7 @@ const tables: SqlCompletionTable[] = [
{ name: "users", schema: "public", type: "table" },
{ name: "user_profiles", schema: "public", type: "table" },
{ name: "orders", schema: "public", type: "table" },
{ name: "ticket_summary", schema: "public", type: "view" },
];
const columnsByTable = new Map<string, SqlCompletionColumn[]>([
@ -84,6 +85,33 @@ test("suggests columns for an explicit alias qualifier", () => {
);
});
test("suggests only matching columns for an explicit alias qualifier prefix", () => {
const sql = "select u.na from public.users u join public.orders o on u.id = o.user_id";
const cursor = "select u.na".length;
const items = buildSqlCompletionItems(sql, cursor, {
tables,
columnsByTable,
});
assert.deepEqual(
items.map((item) => [item.label, item.type, item.detail]),
[["name", "column", "public.users"]],
);
});
test("keeps explicit alias column suggestions scoped to the alias table", () => {
const sql = "select * from public.users u join public.orders o on u.id = o.user_id where o.st";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
assert.deepEqual(
items.map((item) => [item.label, item.type, item.detail]),
[["status", "column", "public.orders"]],
);
});
test("suggests columns from referenced tables in select list", () => {
const sql = "select na from public.users u join public.orders o on u.id = o.user_id";
const cursor = "select na".length;
@ -127,15 +155,62 @@ test("suggests keywords when typing without context", () => {
assert.ok(items.some((item) => item.type === "keyword" && item.label === "USING"));
});
test("always includes keywords alongside table suggestions", () => {
test("suggests only matching table names after FROM object input", () => {
const sql = "select * from us";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
assert.ok(items.some((item) => item.type === "table"));
assert.ok(items.some((item) => item.type === "keyword" && item.label === "USING"));
assert.ok(items.length > 0);
assert.deepEqual([...new Set(items.map((item) => item.type))], ["table"]);
assert.deepEqual(
items.map((item) => item.label),
["users", "user_profiles"],
);
});
test("keeps schema-qualified FROM object input in table suggestion mode", () => {
const sql = "select * from public.us";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
assert.ok(items.length > 0);
assert.deepEqual([...new Set(items.map((item) => item.type))], ["table"]);
assert.deepEqual(
items.map((item) => item.label),
["users", "user_profiles"],
);
});
test("includes views in exclusive FROM object suggestions", () => {
const sql = "select * from tick";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
assert.deepEqual(
items.map((item) => [item.label, item.type, item.detail]),
[["ticket_summary", "table", "public.ticket_summary"]],
);
});
test("suggests only table names after JOIN object input", () => {
const sql = "select * from users join us";
const items = buildSqlCompletionItems(sql, sql.length, {
tables,
columnsByTable,
});
assert.ok(items.length > 0);
assert.deepEqual([...new Set(items.map((item) => item.type))], ["table"]);
assert.deepEqual(
items.map((item) => item.label),
["users", "user_profiles"],
);
});
test("suggests SQL Server IF keyword for conditional DDL", () => {