fix(sql): quote case-sensitive Oracle identifiers in completion

This commit is contained in:
dienaso
2026-09-19 09:47:04 +08:00
committed by GitHub
parent 5a59777246
commit 8518a5bd25
6 changed files with 101 additions and 8 deletions
@@ -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('"');
@@ -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"()');
});
});
@@ -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", () => {
@@ -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<string, string>, 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);
+5 -3
View File
@@ -1372,7 +1372,7 @@ type SqlCompletionApplyDialect = "mysql" | "postgres" | "sqlserver" | "oracle" |
// folding and quoting rules.
const MYSQL_LIKE_IDENTIFIER_DATABASES = new Set<DatabaseType>(["mysql", "clickhouse", "hive", "argo", "kyuubi", "impala", "spark", "databend", "tdengine", "access", "doris", "starrocks"]);
const POSTGRES_LIKE_IDENTIFIER_DATABASES = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "kingbase", "highgo", "uxdb", "vastbase", "kwdb", "opengauss"]);
const ORACLE_COMPAT_IDENTIFIER_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "yashandb", "oscar", "xugu"]);
export const ORACLE_COMPAT_IDENTIFIER_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "yashandb", "oscar", "xugu"]);
const UPPER_FOLDING_IDENTIFIER_DATABASES = new Set<DatabaseType>(["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);
}
+7 -1
View File
@@ -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", () => {