From d26c77ceb6ed3d2d23f92cacf90f1d2cfe572a89 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 27 Jul 2026 16:45:38 +0800 Subject: [PATCH] fix(editor): support Unicode SQL completion prefixes --- .../src/lib/sql/semantic/completion.ts | 13 +++- apps/desktop/src/lib/sql/sqlCompletion.ts | 15 ++-- packages/app-tests/sqlCompletion.test.ts | 74 +++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index 323a212194..1d969dddf5 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -183,7 +183,12 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: } const scope = sqlSemanticCompletionScope(model); - const qualifier = model.cursorIntent.qualifierParts.length > 0 ? model.cursorIntent.qualifierParts.join(".") : undefined; + const contextKind = semanticContextKind(model); + const basePrefixHasNonAscii = Array.from(base.prefix).some((character) => (character.codePointAt(0) ?? 0) > 0x7f); + const useBaseTrailingIdentifier = basePrefixHasNonAscii && model.cursorIntent.prefix.length === 0 && model.cursorIntent.replacementRange.start === model.cursorIntent.replacementRange.end && base.contextKind === contextKind; + const prefix = useBaseTrailingIdentifier ? base.prefix : model.cursorIntent.prefix; + const qualifierParts = model.cursorIntent.qualifierParts.length > 0 ? [...model.cursorIntent.qualifierParts] : useBaseTrailingIdentifier ? base.qualifierParts : undefined; + const qualifier = qualifierParts?.join("."); const referencedTables = sqlSemanticReferencedTables(model); const mutationTarget = semanticMutationTarget(model); const mutationDatabase = mutationTarget?.metadataTarget?.database; @@ -195,9 +200,9 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: return { ...base, - prefix: model.cursorIntent.prefix, + prefix, qualifier, - qualifierParts: model.cursorIntent.qualifierParts.length > 0 ? [...model.cursorIntent.qualifierParts] : undefined, + qualifierParts, suggestTables, suggestColumns, suggestKeywords: scope.kind === "keyword" || (!suggestTables && !suggestColumns && !suggestRoutines), @@ -215,6 +220,6 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: updateTarget: model.cursorIntent.kind === "update_column" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.updateTarget, deleteTarget: model.cursorIntent.kind === "delete_target" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.deleteTarget, onStar: model.cursorIntent.kind === "star" || base.onStar, - contextKind: semanticContextKind(model), + contextKind, }; } diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 18b5ec969a..413302f091 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1721,6 +1721,8 @@ 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_START_CHAR = /[@_\p{ID_Start}]/u; +const SQL_IDENTIFIER_SUFFIX = /[$@_\u200c\u200d\p{ID_Continue}]+$/u; const SQL_IDENTIFIER_CONTINUE_CHAR = /[$_\u200c\u200d\p{ID_Continue}]/u; function hasTableAliasAfterCursor(sql: string, cursor: number): boolean { @@ -2022,13 +2024,12 @@ function parseTrailingIdentifierPart(input: string, endExclusive: number): { sta return null; } - let start = end; - while (start >= 0 && /[A-Za-z0-9_$@]/.test(input[start] ?? "")) start -= 1; - start += 1; - if (start >= endExclusive) return null; - const raw = input.slice(start, endExclusive); - if (!/^[@A-Za-z_][\w$@]*$/.test(raw)) return null; - return { start, raw }; + const match = SQL_IDENTIFIER_SUFFIX.exec(input.slice(0, endExclusive)); + if (!match) return null; + const raw = match[0]; + const firstCodePoint = raw.codePointAt(0); + if (firstCodePoint === undefined || !SQL_IDENTIFIER_START_CHAR.test(String.fromCodePoint(firstCodePoint))) return null; + return { start: match.index, raw }; } /** diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 8a2f7b9226..6277bc7a97 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -16,6 +16,8 @@ import { type SqlCompletionObject, type SqlCompletionTable, } from "../../apps/desktop/src/lib/sql/sqlCompletion.ts"; +import { sqlCompletionContextFromSemantic } from "../../apps/desktop/src/lib/sql/semantic/completion.ts"; +import { buildSqlSemanticModel } from "../../apps/desktop/src/lib/sql/semantic/model.ts"; const tables: SqlCompletionTable[] = [ { name: "users", schema: "public", type: "table" }, @@ -471,6 +473,78 @@ test("suggests matching table names after FROM", () => { ); }); +test("suggests SQL Server tables for unquoted Chinese prefixes", () => { + const sql = "select * from 客户"; + const context = getSqlCompletionContext(sql, sql.length); + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "客户订单", schema: "dbo", type: "table" }, + { name: "订单", schema: "dbo", type: "table" }, + ], + columnsByTable: new Map(), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + assert.equal(context.prefix, "客户"); + assert.equal(context.suggestTables, true); + assert.equal(context.exclusiveTableSuggestions, true); + assert.deepEqual( + items.filter((item) => item.type === "table").map((item) => item.label), + ["客户订单"], + ); + assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true); +}); + +test("preserves Unicode prefixes through the semantic completion context", () => { + const sql = "select * from dbo.客户"; + const legacy = getSqlCompletionContext(sql, sql.length); + const semantic = buildSqlSemanticModel(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" }); + const context = sqlCompletionContextFromSemantic(semantic, legacy); + + assert.equal(context.prefix, "客户"); + assert.equal(context.qualifier, "dbo"); + assert.deepEqual(context.qualifierParts, ["dbo"]); + assert.equal(sql.slice(sql.length - context.prefix.length), "客户"); +}); + +test("parses Unicode identifier start and continuation code points", () => { + for (const prefix of ["Δelta", "a\u0301", "𐐀表"] as const) { + const sql = `select * from ${prefix}`; + const context = getSqlCompletionContext(sql, sql.length); + + assert.equal(context.prefix, prefix); + assert.equal(sql.slice(sql.length - context.prefix.length), prefix); + } +}); + +test("preserves ASCII, qualified, and quoted identifier parsing", () => { + const cases = [ + { sql: "select * from ord", prefix: "ord", qualifier: undefined, qualifierParts: undefined }, + { sql: "select * from dbo.客户", prefix: "客户", qualifier: "dbo", qualifierParts: ["dbo"] }, + { sql: "select * from [sales].ord", prefix: "ord", qualifier: "sales", qualifierParts: ["sales"] }, + { sql: 'select * from "sales".ord', prefix: "ord", qualifier: "sales", qualifierParts: ["sales"] }, + ] as const; + + for (const expected of cases) { + const context = getSqlCompletionContext(expected.sql, expected.sql.length); + + assert.equal(context.prefix, expected.prefix); + assert.equal(context.qualifier, expected.qualifier); + assert.deepEqual(context.qualifierParts, expected.qualifierParts); + } +}); + +test("uses the full Unicode prefix as the replacement range without result reuse", () => { + const sql = "select * from dbo.客户"; + const context = getSqlCompletionContext(sql, sql.length); + const replacementFrom = sql.length - context.prefix.length; + + assert.equal(replacementFrom, sql.indexOf("客户")); + assert.equal(sql.slice(replacementFrom), "客户"); + assert.equal(getSqlCompletionResultValidFor(sql, sql.length), undefined); +}); + test("ranks prefix matches above substring matches for table names", () => { const sql = "select * from user"; const items = buildSqlCompletionItems(sql, sql.length, {