From 8518a5bd25baf8fc2ed9f95ed6f88a5ee1032efd Mon Sep 17 00:00:00 2001 From: dienaso <89768641@qq.com> Date: Sat, 19 Sep 2026 09:47:04 +0800 Subject: [PATCH] fix(sql): quote case-sensitive Oracle identifiers in completion --- .../dataGridConditionCompletion.spec.ts | 11 ++- .../sql/oracleIdentifierQuoting.spec.ts | 72 +++++++++++++++++++ .../sql/sqlCompletion.context.spec.ts | 3 +- .../dataGrid/dataGridConditionCompletion.ts | 7 +- apps/desktop/src/lib/sql/sqlCompletion.ts | 8 ++- packages/app-tests/sqlCompletion.test.ts | 8 ++- 6 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/oracleIdentifierQuoting.spec.ts diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts index 0ebea81ec8..0434afdde3 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts @@ -22,13 +22,22 @@ describe("dataGridConditionColumnOptions", () => { ]); }); - it.each(["mysql", "sqlserver", "oracle"] as const)("keeps existing %s condition insertions unchanged", (databaseType) => { + it.each(["mysql", "sqlserver"] as const)("keeps existing %s condition insertions unchanged", (databaseType) => { expect(dataGridConditionColumnOptions(["OrderId", "order"], databaseType)).toEqual([ { name: "OrderId", insertText: "OrderId" }, { name: "order", insertText: "order" }, ]); }); + it.each(["oracle", "oceanbase-oracle", "yashandb", "oscar", "xugu"] as const)("quotes case-sensitive Oracle-family columns for %s while keeping foldable names bare", (databaseType) => { + expect(dataGridConditionColumnOptions(["Id", "NO", "OrderId", "V$SESSION"], databaseType)).toEqual([ + { name: "Id", insertText: '"Id"' }, + { name: "NO", insertText: "NO" }, + { name: "OrderId", insertText: '"OrderId"' }, + { name: "V$SESSION", insertText: "V$SESSION" }, + ]); + }); + it("uses the active dialect identifier quote while preserving runtime overrides", () => { expect(dataGridConditionIdentifierQuote("postgres")).toBe('"'); expect(dataGridConditionIdentifierQuote("oracle")).toBe('"'); diff --git a/apps/desktop/src/lib/__tests__/sql/oracleIdentifierQuoting.spec.ts b/apps/desktop/src/lib/__tests__/sql/oracleIdentifierQuoting.spec.ts new file mode 100644 index 0000000000..210b564d18 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/oracleIdentifierQuoting.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { buildSqlCompletionItems, quoteSqlIdentifier } from "@/lib/sql/sqlCompletion"; + +describe("quoteSqlIdentifier oracle folding polarity", () => { + it.each([ + ["ID", "ID"], + ["V$SESSION", "V$SESSION"], + ["ACCOUNT$SYS", "ACCOUNT$SYS"], + ["Id", '"Id"'], + ["id", '"id"'], + ["SELECT", '"SELECT"'], + ["_SYS", '"_SYS"'], + ['we"ird', '"we""ird"'], + ] as const)("quotes %s as %s for the oracle apply dialect", (identifier, expected) => { + expect(quoteSqlIdentifier(identifier, "oracle")).toBe(expected); + }); + + it.each([ + ["upper", "Id", '"Id"'], + ["mysql", "Id", "Id"], + ["postgres", "Id", '"Id"'], + ["postgres", "order", '"order"'], + ["postgres", "article", "article"], + ] as const)("keeps neighbour dialects unchanged: %s %s -> %s", (dialect, identifier, expected) => { + expect(quoteSqlIdentifier(identifier, dialect)).toBe(expected); + }); +}); + +describe("oracle completion apply identifiers", () => { + // Reproduction for #9526: the reported table stores mixed-case names quoted, + // so bare inserts fold to the wrong (uppercase) name and fail with ORA-00904. + const oracleInput = { + databaseType: "oracle" as const, + tables: [{ name: "BomTemplate", schema: "ZTZS_ERP2", type: "table" as const }], + columnsByTable: new Map([ + [ + "BomTemplate", + [ + { name: "Id", table: "BomTemplate" }, + { name: "NO", table: "BomTemplate" }, + ], + ], + ]), + schemas: ["ZTZS_ERP2"], + }; + + it("quotes a mixed-case column suggestion after WHERE", () => { + const sql = "SELECT * FROM BomTemplate WHERE Id"; + const items = buildSqlCompletionItems(sql, sql.length, oracleInput); + const column = items.find((item) => item.label === "Id"); + expect(column?.apply).toBe('"Id"'); + }); + + it("keeps an all-uppercase column suggestion bare", () => { + const sql = "SELECT * FROM BomTemplate WHERE N"; + const items = buildSqlCompletionItems(sql, sql.length, oracleInput); + const column = items.find((item) => item.label === "NO"); + expect(column?.apply).toBe("NO"); + }); + + it("quotes a mixed-case routine suggestion", () => { + const sql = "CALL GetBo"; + const items = buildSqlCompletionItems(sql, sql.length, { + databaseType: "oracle" as const, + tables: [], + columnsByTable: new Map(), + objects: [{ name: "GetBom", type: "procedure" }], + }); + const routine = items.find((item) => item.label === "GetBom"); + expect(routine?.apply).toBe('"GetBom"()'); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 384f526197..49edce3c7d 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -711,7 +711,8 @@ describe("sqlCompletion table aliases", () => { }); const table = items.find((item) => item.label === "order_items" && item.type === "table"); - expect(table?.apply).toBe("order_items oi"); + // The lowercase table name must stay quoted (#9526); the generated alias is a new identifier and stays bare. + expect(table?.apply).toBe('"order_items" oi'); }); it("never adds generated aliases to Cassandra table completions", () => { diff --git a/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts b/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts index 6899010cb8..87d74e2d7a 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts @@ -1,6 +1,6 @@ import type { DataGridConditionColumnOption } from "@/composables/useDataGridConditionEditor"; import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect"; -import { quoteSqlIdentifier } from "@/lib/sql/sqlCompletion"; +import { ORACLE_COMPAT_IDENTIFIER_DATABASES, quoteSqlIdentifier } from "@/lib/sql/sqlCompletion"; import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; import type { ColumnInfo, DatabaseType } from "@/types/database"; @@ -49,7 +49,10 @@ function addIoTDBConnectionParams(target: Map, raw: string | und } export function dataGridConditionColumnOptions(columns: readonly DataGridConditionColumnOption[], databaseType?: DatabaseType): DataGridConditionColumnOption[] { - const dialect = codeMirrorSqlDialect(databaseType); + // Oracle-family identifier quoting needs the completion apply dialect; the + // CodeMirror syntax dialect has no Oracle entry and falls back to MySQL, + // which never quotes, so case-sensitive columns would insert unresolvable. + const dialect = databaseType !== undefined && ORACLE_COMPAT_IDENTIFIER_DATABASES.has(databaseType) ? "oracle" : codeMirrorSqlDialect(databaseType); return columns.map((column) => { const name = typeof column === "string" ? column : column.name; const insertText = quoteSqlIdentifier(name, dialect); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 6f094f910e..5298c8ad82 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1372,7 +1372,7 @@ type SqlCompletionApplyDialect = "mysql" | "postgres" | "sqlserver" | "oracle" | // folding and quoting rules. const MYSQL_LIKE_IDENTIFIER_DATABASES = new Set(["mysql", "clickhouse", "hive", "argo", "kyuubi", "impala", "spark", "databend", "tdengine", "access", "doris", "starrocks"]); const POSTGRES_LIKE_IDENTIFIER_DATABASES = new Set(["postgres", "redshift", "gaussdb", "kingbase", "highgo", "uxdb", "vastbase", "kwdb", "opengauss"]); -const ORACLE_COMPAT_IDENTIFIER_DATABASES = new Set(["oracle", "oceanbase-oracle", "yashandb", "oscar", "xugu"]); +export const ORACLE_COMPAT_IDENTIFIER_DATABASES = new Set(["oracle", "oceanbase-oracle", "yashandb", "oscar", "xugu"]); const UPPER_FOLDING_IDENTIFIER_DATABASES = new Set(["dameng", "db2"]); function sqlCompletionApplyDialect(databaseType: DatabaseType | undefined, fallback: "mysql" | "postgres" | "sqlserver" | undefined): SqlCompletionApplyDialect | undefined { @@ -3737,7 +3737,9 @@ function unquoteIdentifier(value: string): string { export function quoteSqlIdentifier(identifier: string, dialect?: SqlCompletionApplyDialect): string { if (dialect === "oracle") { - if (/^[A-Za-z][A-Za-z0-9_$#]*$/.test(identifier) && !POSTGRES_IDENTIFIER_KEYWORDS.has(identifier.toLowerCase())) return identifier; + // Oracle folds bare identifiers to uppercase, so only all-uppercase names + // stay resolvable unquoted; mixed-case names keep their quotes. + if (/^[A-Z][A-Z0-9_$#]*$/.test(identifier) && !POSTGRES_IDENTIFIER_KEYWORDS.has(identifier.toLowerCase())) return identifier; return `"${identifier.replaceAll('"', '""')}"`; } if (dialect === "upper") { @@ -3777,7 +3779,7 @@ function quoteCompletionApplyName(applyName: string, dialect?: SqlCompletionAppl } function quoteCompletionRoutineIdentifier(identifier: string, dialect?: SqlCompletionApplyDialect): string { - if (dialect === "oracle" && /^[A-Za-z][A-Za-z0-9_$#]*$/.test(identifier) && !POSTGRES_IDENTIFIER_KEYWORDS.has(identifier.toLowerCase())) return identifier; + if (dialect === "oracle" && /^[A-Z][A-Z0-9_$#]*$/.test(identifier) && !POSTGRES_IDENTIFIER_KEYWORDS.has(identifier.toLowerCase())) return identifier; return quoteCompletionApplyIdentifier(identifier, dialect); } diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 21e3ec7e80..76f3d52c4b 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -2776,6 +2776,7 @@ test("suggests package members after package qualifier", () => { objects: [ { name: "PAYROLL", schema: "HR", type: "package" }, { name: "calculate_bonus", schema: "HR", type: "function", parentSchema: "HR", parentName: "PAYROLL" }, + { name: "CALCULATE_TAX", schema: "HR", type: "function", parentSchema: "HR", parentName: "PAYROLL" }, ], databaseType: "oracle", }); @@ -2783,7 +2784,12 @@ test("suggests package members after package qualifier", () => { const member = items.find((item) => item.label === "calculate_bonus"); assert.ok(member); assert.equal(member.type, "function"); - assert.equal(member.apply, "calculate_bonus()"); + // The lowercase metadata name is the stored form, so it must stay quoted to survive Oracle folding (#9526). + assert.equal(member.apply, '"calculate_bonus"()'); + + const upperMember = items.find((item) => item.label === "CALCULATE_TAX"); + assert.ok(upperMember); + assert.equal(upperMember.apply, "CALCULATE_TAX()"); }); test("keeps Oracle functions available in qualified expression routine context", () => {