From c2cf8d39602f12f30fc335d5ebe415a56304a903 Mon Sep 17 00:00:00 2001 From: zipg Date: Sun, 16 Aug 2026 15:27:08 +0800 Subject: [PATCH] fix(completion): avoid unnecessary MySQL identifier quotes --- apps/desktop/src/lib/sql/sqlIdentifier.ts | 7 ++- packages/app-tests/sqlCompletion.test.ts | 68 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/sql/sqlIdentifier.ts b/apps/desktop/src/lib/sql/sqlIdentifier.ts index 12e8e279c2..808ecfe4c0 100644 --- a/apps/desktop/src/lib/sql/sqlIdentifier.ts +++ b/apps/desktop/src/lib/sql/sqlIdentifier.ts @@ -1,4 +1,5 @@ const SIMPLE_LOWER_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/; +const SIMPLE_MYSQL_IDENTIFIER = /^[A-Za-z_$\u0080-\uFFFF][A-Za-z0-9_$\u0080-\uFFFF]*$/u; // PostgreSQL pg_get_keywords() categories R and T. GaussDB's PostgreSQL- // compatible modes use the same identifier constraints for bare names. @@ -191,14 +192,16 @@ export function requiresPostgresIdentifierQuote(identifier: string, additionalKe } export function requiresMysqlIdentifierQuote(identifier: string, additionalKeywords?: ReadonlySet): boolean { - return requiresPostgresIdentifierQuote(identifier, additionalKeywords) || MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(identifier); + if (!SIMPLE_MYSQL_IDENTIFIER.test(identifier)) return true; + const normalized = identifier.toLowerCase(); + return POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(normalized) || additionalKeywords?.has(normalized) === true || MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(normalized); } export function quoteGaussDbJdbcIdentifier(identifier: string, identifierQuote: string): string { if (isExplicitlyQuotedSqlIdentifier(identifier)) return identifier; const quote = identifierQuote.trim(); if (!quote) return identifier; - const requiresQuote = quote === "`" ? requiresMysqlIdentifierQuote(identifier) : requiresPostgresIdentifierQuote(identifier); + const requiresQuote = quote === "`" ? requiresPostgresIdentifierQuote(identifier) || MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(identifier.toLowerCase()) : requiresPostgresIdentifierQuote(identifier); if (!requiresQuote) return identifier; return `${quote}${identifier.replaceAll(quote, quote + quote)}${quote}`; } diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index b0bec3814d..535268d977 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -522,6 +522,63 @@ test("leaves safe MySQL table identifiers unquoted when completion inserts them" assert.equal(table?.apply, "article"); }); +test("leaves safe mixed-case MySQL identifiers unquoted when completion inserts them", () => { + const schemaSql = "select * from Sales"; + const schemaItems = buildSqlCompletionItems(schemaSql, schemaSql.length, { + tables: [], + columnsByTable: new Map(), + schemas: ["SalesDB"], + dialect: "mysql", + }); + assert.equal(schemaItems.find((item) => item.type === "schema" && item.label === "SalesDB")?.apply, "SalesDB."); + + const tableSql = "select * from OrderI"; + const tableItems = buildSqlCompletionItems(tableSql, tableSql.length, { + tables: [{ name: "OrderItems", schema: "SalesDB", type: "table" }], + columnsByTable: new Map(), + dialect: "mysql", + }); + assert.equal(tableItems.find((item) => item.type === "table" && item.label === "OrderItems")?.apply, "OrderItems"); + + const columnSql = "select from OrderItems"; + const columnItems = buildSqlCompletionItems(columnSql, "select ".length, { + tables: [{ name: "OrderItems", schema: "SalesDB", type: "table" }], + columnsByTable: new Map([ + [ + "SalesDB.OrderItems", + [ + { name: "MixedCase", table: "OrderItems", schema: "SalesDB", dataType: "int" }, + { name: "UPPER_COL", table: "OrderItems", schema: "SalesDB", dataType: "int" }, + ], + ], + ]), + dialect: "mysql", + }); + assert.equal(columnItems.find((item) => item.type === "column" && item.label === "MixedCase")?.apply, "MixedCase"); + assert.equal(columnItems.find((item) => item.type === "column" && item.label === "UPPER_COL")?.apply, "UPPER_COL"); +}); + +test("does not reuse MySQL backticks for Caché JDBC completion identifiers", () => { + const schemaSql = "select top 100 * from SQL"; + const schemaItems = buildSqlCompletionItems(schemaSql, schemaSql.length, { + tables: [], + columnsByTable: new Map(), + schemas: ["SQLUser"], + databaseType: "iris", + dialect: "mysql", + }); + assert.equal(schemaItems.find((item) => item.type === "schema" && item.label === "SQLUser")?.apply, "SQLUser."); + + const tableSql = "select top 100 * from SQLUser.PA"; + const tableItems = buildSqlCompletionItems(tableSql, tableSql.length, { + tables: [{ name: "PA_Adm", schema: "SQLUser", type: "table" }], + columnsByTable: new Map(), + databaseType: "iris", + dialect: "mysql", + }); + assert.equal(tableItems.find((item) => item.type === "table" && item.label === "PA_Adm")?.apply, "PA_Adm"); +}); + test("quotes MySQL reserved-word column identifiers with backticks when completion inserts them", () => { const reservedColumns = ["order", "do", "returning", "ilike", "window", "true"]; const reservedColumnsByTable = new Map([["public.bookings", reservedColumns.map((name) => ({ name, table: "bookings", schema: "public", dataType: "text" }))]]); @@ -538,6 +595,17 @@ test("quotes MySQL reserved-word column identifiers with backticks when completi } }); +test("quotes MySQL reserved identifiers case-insensitively", () => { + const sql = "select Ord from bookings"; + const items = buildSqlCompletionItems(sql, "select Ord".length, { + tables: [{ name: "bookings", schema: "public", type: "table" }], + columnsByTable: new Map([["public.bookings", [{ name: "Order", table: "bookings", schema: "public", dataType: "text" }]]]), + dialect: "mysql", + }); + + assert.equal(items.find((item) => item.type === "column" && item.label === "Order")?.apply, "`Order`"); +}); + test("quotes MySQL-only reserved identifiers in schema, table, column, and join completions", () => { const schemaSql = "select * from data"; const schemaItems = buildSqlCompletionItems(schemaSql, schemaSql.length, {