fix(editor): skip duplicate table aliases

This commit is contained in:
vrustx 2026-07-12 12:21:12 +08:00 committed by GitHub
parent 0650428d1c
commit 02e1b943e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 2 deletions

View File

@ -1158,6 +1158,7 @@ export interface SqlCompletionContext {
deleteTarget?: { table: string; schema?: string };
oracleTableFunctionContext?: boolean;
autoAliasTableCompletions: boolean;
tableAliasAfterCursor?: boolean;
contextKind: SqlCompletionContextKind;
}
@ -1286,7 +1287,7 @@ class SqlCompletionProvider {
}
const emptyTableNameCompletion = !context.prefix && (context.suggestTables || context.exclusiveTableSuggestions);
if (!pendingJoinKeyword && !emptyTableNameCompletion && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) {
if (!pendingJoinKeyword && !emptyTableNameCompletion && !context.tableAliasAfterCursor && context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) {
this.items.push(...buildAliasItems(context, this.databaseType));
}
@ -1617,6 +1618,50 @@ function isCallRoutineContext(beforeToken: string): boolean {
return /\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(beforeToken) || /\bcall\s+(?:[A-Za-z_][\w$]*\.)?[A-Za-z_][\w$]*$/i.test(beforeToken);
}
const SQL_IDENTIFIER_CONTINUE_CHAR = /[$_\u200c\u200d\p{ID_Continue}]/u;
function hasTableAliasAfterCursor(sql: string, cursor: number): boolean {
if (hasAliasMarkerAt(sql, cursor, false)) return true;
let pos = cursor;
while (pos < sql.length) {
const codePoint = sql.codePointAt(pos);
if (codePoint === undefined) break;
const char = String.fromCodePoint(codePoint);
if (char !== "." && !SQL_IDENTIFIER_CONTINUE_CHAR.test(char)) break;
// Advance by the full code point so supplementary Unicode identifiers
// do not leave the scan between UTF-16 surrogate halves.
pos += char.length;
}
if (sql[pos] === '"' || sql[pos] === "`" || sql[pos] === "]") pos++;
return hasAliasMarkerAt(sql, pos, true);
}
function hasAliasMarkerAt(sql: string, pos: number, allowImplicitAlias: boolean): boolean {
const following = sql.slice(skipSqlWhitespaceAndComments(sql, pos));
if (/^as\b/i.test(following)) return true;
if (/^(?:"[^"]+"|`[^`]+`|\[[^\]]+\])/.test(following)) return true;
if (!allowImplicitAlias) return false;
const implicitAlias = /^([A-Za-z_][\w$]*)/.exec(following)?.[1]?.toLowerCase();
return !!implicitAlias && !isUnsafeSqlAlias(implicitAlias);
}
function skipSqlWhitespaceAndComments(sql: string, pos: number): number {
for (;;) {
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
if (sql.startsWith("--", pos)) {
const newline = sql.indexOf("\n", pos + 2);
if (newline === -1) return sql.length;
pos = newline + 1;
} else if (sql.startsWith("/*", pos)) {
const end = sql.indexOf("*/", pos + 2);
if (end === -1) return sql.length;
pos = end + 2;
} else {
return pos;
}
}
}
export function getSqlCompletionContext(sql: string, cursor: number): SqlCompletionContext {
// Extract the full statement at cursor position for referenced tables
const fullStatement = extractStatementAt(sql, cursor);
@ -1664,7 +1709,8 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
const afterTableTrigger = 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 autoAliasTableCompletions = lastWord === "from" || lastWord === "join" || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken);
const tableAliasAfterCursor = hasTableAliasAfterCursor(sql, cursor);
const autoAliasTableCompletions = (lastWord === "from" || lastWord === "join" || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken)) && !tableAliasAfterCursor;
const exclusiveColumnSuggestions = !!qualifier && !exclusiveTableSuggestions && !insertInfo;
const activePrefixIsCte = cteDefs.some((cte) => normalizeIdentifierPart(cte.name) === normalizeIdentifierPart(prefix));
if (exclusiveTableSuggestions && prefix && !activePrefixIsCte && referencedTables.length > 1) {
@ -1728,6 +1774,7 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet
deleteTarget: deleteInfo?.target,
oracleTableFunctionContext,
autoAliasTableCompletions,
tableAliasAfterCursor,
contextKind,
};
}

View File

@ -2009,6 +2009,59 @@ test("automatic table aliases avoid reserved words", () => {
assert.equal(tableItem!.apply, "orders AS ord");
});
test("automatic table aliases respect text after the cursor", () => {
const cases: Array<[string, number, string]> = [
["select * from ord AS o", "select * from ord".length, "orders"],
["select * from ord o", "select * from ord".length, "orders"],
["select * from ord where id = 1", "select * from ord".length, "orders AS ord"],
["select * from ord", "select * from ord".length, "orders AS ord"],
["select * from ord, users", "select * from ord".length, "orders AS ord"],
["select * from orders AS o", "select * from or".length, "orders"],
["select * from ord单 AS o", "select * from ord".length, "orders"],
["select * from orde\u0301 AS o", "select * from ord".length, "orders"],
["select * from ord𐐀 AS o", "select * from ord".length, "orders"],
['select * from ord AS "o"', "select * from ord".length, "orders"],
["select * from ord `o`", "select * from ord".length, "orders"],
["select * from ord AS ", "select * from ord".length, "orders"],
["select * from ord /* comment */ AS o", "select * from ord".length, "orders"],
["select * from ord -- comment\n o", "select * from ord".length, "orders"],
["select * from ord\n o", "select * from ord".length, "orders"],
["select * from ord /* ; */ AS o", "select * from ord".length, "orders"],
["select * from ord /* comment */ where id = 1", "select * from ord".length, "orders AS ord"],
];
for (const [sql, cursor, expectedApply] of cases) {
const items = buildSqlCompletionItems(sql, cursor, {
tables,
columnsByTable,
autoAliasTables: true,
});
const tableItem = items.find((item) => item.type === "table" && item.label === "orders");
assert.ok(tableItem, `should suggest orders for ${sql}`);
assert.equal(tableItem!.apply, expectedApply, sql);
}
});
test("table alias suggestions respect text after the cursor", () => {
const aliasedCases: Array<[string, number]> = [
["select * from orders AS o", "select * from orders ".length],
['select * from orders AS "o"', "select * from orders ".length],
["select * from orders AS ", "select * from orders ".length],
["select * from orders /* c */ AS o", "select * from orders ".length],
];
for (const [sql, cursor] of aliasedCases) {
const items = buildSqlCompletionItems(sql, cursor, { tables, columnsByTable });
const aliasItem = items.find((item) => item.type === "snippet" && item.detail === "alias for orders");
assert.equal(aliasItem, undefined, sql);
}
const items = buildSqlCompletionItems("select * from orders ", "select * from orders ".length, { tables, columnsByTable });
const aliasItem = items.find((item) => item.type === "snippet" && item.detail === "alias for orders");
assert.ok(aliasItem, "alias snippet should remain available when no alias follows");
});
test("table alias suggestions avoid SQL keywords", () => {
const items = buildSqlCompletionItems("select * from item_file ", "select * from item_file ".length, {
tables: [{ name: "item_file", schema: "public", type: "table" }],