From 02e1b943e86977161a728947a862ee3a951a45b7 Mon Sep 17 00:00:00 2001 From: vrustx <279631638@qq.com> Date: Sun, 12 Jul 2026 12:21:12 +0800 Subject: [PATCH] fix(editor): skip duplicate table aliases --- apps/desktop/src/lib/sql/sqlCompletion.ts | 51 +++++++++++++++++++++- packages/app-tests/sqlCompletion.test.ts | 53 +++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 66cf9f2fd..023391044 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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, }; } diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 026b78f18..23ec306bf 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -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" }],