From cd43ff07f0b2f31a3dd6d5681d62cd2c83f59782 Mon Sep 17 00:00:00 2001 From: Eve <92513238+Eve-z1@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:58:15 +0800 Subject: [PATCH] fix(completion): quote SQL Server identifiers --- apps/desktop/src/lib/sql/sqlCompletion.ts | 24 ++++++++-- packages/app-tests/sqlCompletion.test.ts | 57 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 2b6efdfabc..bbb405396f 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -3498,7 +3498,15 @@ function splitQualifiedNameRawParts(input: string): string[] { continue; } if (ch === "[" && !inDoubleQuote && !inBacktick) inBracket = true; - if (ch === "]" && inBracket) inBracket = false; + if (ch === "]" && inBracket) { + current += ch; + if (input[i + 1] === "]") { + current += input[++i]; + } else { + inBracket = false; + } + continue; + } if (ch === "." && !inDoubleQuote && !inBacktick && !inBracket) { parts.push(current.trim()); current = ""; @@ -3537,6 +3545,8 @@ export function quoteSqlIdentifier(identifier: string, dialect?: SqlCompletionAp } const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase())); +const SQLSERVER_IDENTIFIER_KEYWORDS = new Set(sqlDialectCompletionWords(MSSQL.spec.keywords).map((keyword) => keyword.toLowerCase())); +const SQLSERVER_REGULAR_IDENTIFIER = /^[\p{L}_][\p{L}\p{Nd}_@$#]*$/u; // Unlike quoteSqlIdentifier (also used by the data grid condition editor, which // intentionally leaves MySQL identifiers unquoted and applies backticks itself @@ -3547,14 +3557,19 @@ function quoteCompletionApplyIdentifier(identifier: string, dialect?: SqlComplet if (!requiresMysqlIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier; return `\`${identifier.replaceAll("`", "``")}\``; } + if (dialect === "sqlserver") { + if (SQLSERVER_REGULAR_IDENTIFIER.test(identifier) && !requiresMysqlIdentifierQuote(identifier.toLowerCase(), SQLSERVER_IDENTIFIER_KEYWORDS)) return identifier; + const escaped = identifier.replaceAll("]", "]]"); + return `[${escaped}]`; + } return quoteSqlIdentifier(identifier, dialect); } function quoteCompletionApplyName(applyName: string, dialect?: SqlCompletionApplyDialect): string { - if (dialect !== "mysql" && dialect !== "oracle") return applyName; + if (dialect !== "mysql" && dialect !== "oracle" && dialect !== "sqlserver") return applyName; const parts = splitQualifiedNameRawParts(applyName); if (parts.length === 0) return applyName; - return parts.map((part) => (isQuotedIdentifier(part) ? part : quoteCompletionApplyIdentifier(part, dialect))).join("."); + return parts.map((part) => ((dialect === "sqlserver" && !part) || isQuotedIdentifier(part) ? part : quoteCompletionApplyIdentifier(part, dialect))).join("."); } function quoteCompletionRoutineIdentifier(identifier: string, dialect?: SqlCompletionApplyDialect): string { @@ -3640,7 +3655,8 @@ function buildTableItems( .map((table) => { const qualifiedByContext = !!qualifierSchema && !!table.schema && normalizeIdentifierPart(qualifierSchema) === normalizeIdentifierPart(table.schema); const { ambiguousTableName, defaultApplyName } = resolveTableSchemaQualification(table, dialect, databaseType, currentSchema, schemasByTableName); - const suppliedApplyName = table.applyName?.trim(); + const rawSuppliedApplyName = table.applyName?.trim(); + const suppliedApplyName = dialect === "sqlserver" && rawSuppliedApplyName ? quoteCompletionApplyName(rawSuppliedApplyName, dialect) : rawSuppliedApplyName; const suppliedApplyNameIsQualified = suppliedApplyName?.includes(".") === true; const applyName = qualifiedByContext ? quoteCompletionApplyIdentifier(table.name, dialect) : ambiguousTableName && !!table.schema && (!suppliedApplyName || !suppliedApplyNameIsQualified) ? defaultApplyName : (suppliedApplyName ?? defaultApplyName); const alias = autoAliasTables ? generateTableCompletionAlias(table.name, existingAliases) : ""; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index d94984cabb..556681fe70 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -995,6 +995,63 @@ test("replaces typed Unicode prefixes through semantic SQL Server completion", ( } }); +test("brackets only SQL Server completion identifiers that require delimiters", () => { + const sql = "select * from "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "Orders", schema: "dbo", type: "table" }, + { name: "名称", schema: "dbo", type: "table" }, + { name: "04保险事前", schema: "dbo", type: "table" }, + { name: "含]括号", schema: "dbo", type: "table" }, + { name: "BACKUP", schema: "dbo", type: "table" }, + ], + columnsByTable: new Map(), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + assert.deepEqual(Object.fromEntries(items.filter((item) => item.type === "table").map((item) => [item.label, item.apply])), { + Orders: "Orders", + 名称: "名称", + "04保险事前": "[04保险事前]", + "含]括号": "[含]]括号]", + BACKUP: "[BACKUP]", + }); +}); + +test("quotes qualified SQL Server table apply names", () => { + const sql = "select * from "; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "04保险事前", schema: "dbo", type: "table", applyName: "dbo.04保险事前" }, + { name: "04归档", schema: "dbo", type: "table", applyName: "dbo.[04归档]" }, + { name: "04省略模式", schema: "dbo", type: "table", applyName: "datacenter..04省略模式" }, + { name: "含].括号", schema: "dbo", type: "table", applyName: "dbo.[含]].括号]" }, + ], + columnsByTable: new Map(), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + assert.equal(items.find((item) => item.label === "04保险事前")?.apply, "dbo.[04保险事前]"); + assert.equal(items.find((item) => item.label === "04归档")?.apply, "dbo.[04归档]"); + assert.equal(items.find((item) => item.label === "04省略模式")?.apply, "datacenter..[04省略模式]"); + assert.equal(items.find((item) => item.label === "含].括号")?.apply, "dbo.[含]].括号]"); +}); + +test("quotes qualified SQL Server routine apply names", () => { + const sql = "select run"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "run_report", schema: "dbo", type: "procedure", applyName: "dbo.04备份" }], + columnsByTable: new Map(), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + assert.equal(items.find((item) => item.label === "run_report")?.apply, "dbo.[04备份]()"); +}); + test("replaces a Unicode prefix inside an open SQL Server bracket identifier", () => { const sql = "select * from test where [名"; const cursor = sql.length;