mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sql): quote case-sensitive Oracle identifiers in completion
This commit is contained in:
+10
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user