fix(structure): keep Oracle case-sensitive identifiers quoted when quoting off
This commit is contained in:
8 files changed
+140
-12
No files matched your search
+86
-7
@@ -248,7 +248,7 @@ import TableStructureEditor from "@/components/structure/TableStructureEditor.vu
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function draft(isPrimaryKey = false, identity?: { seed: number; increment: number }) {
|
||||
function draft(isPrimaryKey = false, identity?: { seed: number; increment: number }, columnName = "id", foreignKeyRefTable?: string) {
|
||||
const isNullable = identity ? false : !isPrimaryKey;
|
||||
return {
|
||||
initialized: true,
|
||||
@@ -259,7 +259,7 @@ function draft(isPrimaryKey = false, identity?: { seed: number; increment: numbe
|
||||
columns: [
|
||||
{
|
||||
id: "existing:id",
|
||||
name: "id",
|
||||
name: columnName,
|
||||
dataType: "INT",
|
||||
isNullable,
|
||||
defaultValue: "",
|
||||
@@ -267,7 +267,7 @@ function draft(isPrimaryKey = false, identity?: { seed: number; increment: numbe
|
||||
isPrimaryKey,
|
||||
extra: identity ? { autoIncrement: true, identity: { ...identity } } : {},
|
||||
original: {
|
||||
name: "id",
|
||||
name: columnName,
|
||||
data_type: "INT",
|
||||
is_nullable: isNullable,
|
||||
column_default: null,
|
||||
@@ -280,12 +280,39 @@ function draft(isPrimaryKey = false, identity?: { seed: number; increment: numbe
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
foreignKeys: foreignKeyRefTable
|
||||
? [
|
||||
{
|
||||
id: "existing:fk",
|
||||
name: "FK_T_9649",
|
||||
column: columnName,
|
||||
refSchema: "",
|
||||
refTable: foreignKeyRefTable,
|
||||
refColumn: "ID",
|
||||
onUpdate: "",
|
||||
onDelete: "",
|
||||
original: {
|
||||
name: "FK_T_9649",
|
||||
column: columnName,
|
||||
ref_schema: null,
|
||||
ref_table: foreignKeyRefTable,
|
||||
ref_column: "ID",
|
||||
on_update: null,
|
||||
on_delete: null,
|
||||
},
|
||||
markedForDrop: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
triggers: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function mountEditor(databaseType: "sqlserver" | "postgres" | "sqlite" | "oracle" | "oceanbase-oracle" | "iris" | "dameng" | "duckdb" | "informix", isPrimaryKey = false, options: { database?: string; dynamicTypes?: string[]; identity?: { seed: number; increment: number } } = {}) {
|
||||
async function mountEditor(
|
||||
databaseType: "sqlserver" | "postgres" | "sqlite" | "oracle" | "oceanbase-oracle" | "iris" | "dameng" | "duckdb" | "informix",
|
||||
isPrimaryKey = false,
|
||||
options: { database?: string; dynamicTypes?: string[]; identity?: { seed: number; increment: number }; tableName?: string; columnName?: string; foreignKeyRefTable?: string } = {},
|
||||
) {
|
||||
mocks.connection.db_type = databaseType;
|
||||
mocks.connection.name = databaseType;
|
||||
mocks.connection.driver_label = databaseType;
|
||||
@@ -299,8 +326,8 @@ async function mountEditor(databaseType: "sqlserver" | "postgres" | "sqlite" | "
|
||||
connectionId: mocks.connection.id,
|
||||
database: options.database ?? "test",
|
||||
schema: "SYSDBA",
|
||||
tableName: "users",
|
||||
draft: draft(isPrimaryKey, options.identity),
|
||||
tableName: options.tableName ?? "users",
|
||||
draft: draft(isPrimaryKey, options.identity, options.columnName, options.foreignKeyRefTable),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
@@ -597,6 +624,58 @@ describe("TableStructureEditor primary key editing", () => {
|
||||
expect(root.textContent).not.toContain(`"${schema}"."demo_table"`);
|
||||
});
|
||||
|
||||
it("keeps case-sensitive Oracle identifiers quoted when quoting is disabled (#9649)", async () => {
|
||||
mocks.editorSettings.generateSqlQuoteIdentifiers = false;
|
||||
const root = await mountEditor("oracle", false, { tableName: "T_9649", columnName: "cName" });
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValueOnce({
|
||||
statements: ['ALTER TABLE "DBX_TEST"."T_9649" MODIFY ("cName" VARCHAR2(120 BYTE))', 'ALTER TABLE "DBX_TEST"."T_9649" ADD ("cNabcs" clob)', 'COMMENT ON COLUMN "DBX_TEST"."T_9649"."cName" IS \'中文名\''],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
|
||||
await vi.waitFor(() => expect(root.textContent).toContain('MODIFY ("cName" VARCHAR2(120 BYTE))'));
|
||||
expect(root.textContent).toContain('ADD ("cNabcs" clob)');
|
||||
expect(root.textContent).toContain("COMMENT ON COLUMN DBX_TEST.T_9649.\"cName\" IS '中文名'");
|
||||
expect(root.textContent).not.toContain("CNAME");
|
||||
});
|
||||
|
||||
it("keeps a quoted Oracle table name when quoting is disabled (#9649)", async () => {
|
||||
mocks.editorSettings.generateSqlQuoteIdentifiers = false;
|
||||
const root = await mountEditor("oracle", false, { tableName: "t_9649_lower", columnName: "ID" });
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: ['ALTER TABLE "DBX_TEST"."t_9649_lower" MODIFY ("ID" NUMBER(12))'], warnings: [] });
|
||||
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
|
||||
await vi.waitFor(() => expect(root.textContent).toContain('ALTER TABLE DBX_TEST."t_9649_lower" MODIFY (ID NUMBER(12))'));
|
||||
expect(root.textContent).not.toContain("T_9649_LOWER");
|
||||
});
|
||||
|
||||
it("keeps case-sensitive foreign key referenced names quoted when quoting is disabled (#9649)", async () => {
|
||||
mocks.editorSettings.generateSqlQuoteIdentifiers = false;
|
||||
const root = await mountEditor("oracle", false, { tableName: "T_9649", columnName: "ID", foreignKeyRefTable: "cRefTab" });
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValueOnce({
|
||||
statements: ['ALTER TABLE "DBX_TEST"."T_9649" ADD CONSTRAINT "FK_T_9649" FOREIGN KEY ("ID") REFERENCES "DBX_TEST"."cRefTab"("ID")'],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
|
||||
await vi.waitFor(() => expect(root.textContent).toContain('REFERENCES DBX_TEST."cRefTab"(ID)'));
|
||||
expect(root.textContent).not.toContain("CREFTAB");
|
||||
});
|
||||
|
||||
it("still folds plain Oracle identifiers when quoting is disabled (#8997)", async () => {
|
||||
mocks.editorSettings.generateSqlQuoteIdentifiers = false;
|
||||
const root = await mountEditor("oracle", false, { tableName: "TEST", columnName: "ID" });
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: ['ALTER TABLE "SYSTEM"."TEST" ADD ("NEW_COL" VARCHAR2(20))'], warnings: [] });
|
||||
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
|
||||
await vi.waitFor(() => expect(root.textContent).toContain("ALTER TABLE SYSTEM.TEST ADD (NEW_COL VARCHAR2(20))"));
|
||||
expect(root.textContent).not.toContain('"NEW_COL"');
|
||||
});
|
||||
|
||||
it("keeps SQLite rebuild SQL aligned with the guarded apply plan", async () => {
|
||||
mocks.editorSettings.generateSqlQuoteIdentifiers = false;
|
||||
const root = await mountEditor("sqlite");
|
||||
|
||||
@@ -373,7 +373,7 @@ function scheduleDdlEditorInit() {
|
||||
function formatDdlForDisplay(sql: string, dialect: SqlFormatDialect, generated = false): string {
|
||||
const unqualified = applyDdlDatabaseQualifier(sql, dialect, databaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog);
|
||||
if (settingsStore.editorSettings.generateSqlQuoteIdentifiers) return unqualified;
|
||||
return generated ? formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false) : omitDdlIdentifierQuotes(unqualified, dialect);
|
||||
return generated ? formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false, { preserveCaseSensitiveIdentifiers: tableStoresCaseSensitiveIdentifiers.value }) : omitDdlIdentifierQuotes(unqualified, dialect);
|
||||
}
|
||||
|
||||
function ddlRequest() {
|
||||
@@ -1165,6 +1165,26 @@ const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
|
||||
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
|
||||
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
|
||||
const isCreateMode = computed(() => !props.tableName);
|
||||
/**
|
||||
* Whether the edited table already stores a lowercase or mixed-case identifier.
|
||||
* Oracle folds bare identifiers to uppercase, so dequoting such a name points at
|
||||
* a column that does not exist (`CNAME` instead of `"cName"`, ORA-00904) or
|
||||
* silently creates a differently-cased column for a newly added field (#9649).
|
||||
* Tables whose names are all in the dialect's default case keep the
|
||||
* PL/SQL-Developer-style folding requested in #8997.
|
||||
*/
|
||||
const tableStoresCaseSensitiveIdentifiers = computed(() => {
|
||||
if (isCreateMode.value) return false;
|
||||
const databaseInfo = connection.value?.database_info;
|
||||
const requiresQuotesForIdentity = (name: string | null | undefined) => !!name && tableStructureIdentifierComparisonKey(name, databaseType.value, databaseInfo).startsWith("quoted:");
|
||||
// 外键的引用侧(被引用 schema/表/列)与约束名一样进入生成的 REFERENCES
|
||||
// 子句,同样需要纳入大小写敏感扫描,否则会被折叠改写身份。
|
||||
const foreignKeyIdentifiers = foreignKeys.value.flatMap((foreignKey) => {
|
||||
const original = foreignKey.original;
|
||||
return original ? [original.name, original.ref_schema, original.ref_table, original.ref_column] : [];
|
||||
});
|
||||
return [props.tableName, ...columns.value.map((column) => column.original?.name), ...indexes.value.map((index) => index.original?.name), ...foreignKeyIdentifiers, ...triggers.value.map((trigger) => trigger.original?.name)].some(requiresQuotesForIdentity);
|
||||
});
|
||||
const usesSqliteRebuildStrategy = computed(() => !isCreateMode.value && structureCapabilities.value.alterStrategy === "sqlite-rebuild");
|
||||
const hasSqliteTypeChange = computed(() => usesSqliteRebuildStrategy.value && hasExistingColumnTypeChange(columns.value));
|
||||
const canAddColumn = computed(() => canAddTableStructureColumn(databaseType.value, isCreateMode.value));
|
||||
|
||||
@@ -142,6 +142,22 @@ describe("omitDdlIdentifierQuotes", () => {
|
||||
const ddl = 'ALTER TABLE "SYSTEM"."TEST" RENAME COLUMN "ABCD" TO "AbCd";';
|
||||
expect(formatGeneratedDdlIdentifierQuotes(ddl, "oracle", true)).toBe(ddl);
|
||||
});
|
||||
|
||||
it("keeps case-sensitive Oracle identifiers quoted when the edited table preserves case (#9649)", () => {
|
||||
const ddl = 'ALTER TABLE "DBX_TEST"."T_9649" MODIFY ("cName" VARCHAR2(120 BYTE));\nALTER TABLE "DBX_TEST"."T_9649" ADD ("cNabcs" clob);\nCOMMENT ON COLUMN "DBX_TEST"."T_9649"."cName" IS \'中文名\';';
|
||||
const expected = 'ALTER TABLE DBX_TEST.T_9649 MODIFY ("cName" VARCHAR2(120 BYTE));\nALTER TABLE DBX_TEST.T_9649 ADD ("cNabcs" clob);\nCOMMENT ON COLUMN DBX_TEST.T_9649."cName" IS \'中文名\';';
|
||||
expect(formatGeneratedDdlIdentifierQuotes(ddl, "oracle", false, { preserveCaseSensitiveIdentifiers: true })).toBe(expected);
|
||||
});
|
||||
|
||||
it("still folds plain Oracle identifiers while preserving case-sensitive ones (#9649)", () => {
|
||||
const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("ID" NUMBER(10), "NEW_COL" VARCHAR2(20), "with space" VARCHAR2(20));';
|
||||
expect(formatGeneratedDdlIdentifierQuotes(ddl, "oracle", false, { preserveCaseSensitiveIdentifiers: true })).toBe('ALTER TABLE SYSTEM.TEST ADD (ID NUMBER(10), NEW_COL VARCHAR2(20), "with space" VARCHAR2(20));');
|
||||
});
|
||||
|
||||
it("ignores the case-preservation option for dialects that keep their own rules", () => {
|
||||
const ddl = 'ALTER TABLE "user_login_log" ADD "status" INT;';
|
||||
expect(formatGeneratedDdlIdentifierQuotes(ddl, "dameng", false, { preserveCaseSensitiveIdentifiers: true })).toBe(ddl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ddlFormatDialectFor", () => {
|
||||
|
||||
@@ -64,25 +64,38 @@ export function omitDdlIdentifierQuotes(sql: string, dialect: SqlFormatDialect):
|
||||
|
||||
const SIMPLE_ORACLE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$#]*$/;
|
||||
|
||||
export interface GeneratedDdlIdentifierQuoteOptions {
|
||||
/**
|
||||
* Set by structure editors whose edited table already stores a lowercase or
|
||||
* mixed-case identifier. Oracle folds bare identifiers to uppercase, so
|
||||
* unquoting `"cName"` turns the statement into `CNAME` — a different column,
|
||||
* which fails with ORA-00904 or silently creates a differently-cased column.
|
||||
* Preserving the case keeps quotes on every name that would change identity
|
||||
* while plain names keep the folding behaviour requested in #8997.
|
||||
*/
|
||||
preserveCaseSensitiveIdentifiers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the identifier-quoting preference to SQL generated by a structure
|
||||
* editor. Oracle folds bare identifiers to uppercase, so safe quoted names
|
||||
* are uppercased before their quotes are removed. Dameng keeps its stricter
|
||||
* case-sensitive identifier rules through omitDdlIdentifierQuotes instead.
|
||||
*/
|
||||
export function formatGeneratedDdlIdentifierQuotes(sql: string, dialect: SqlFormatDialect, quoteIdentifiers: boolean): string {
|
||||
export function formatGeneratedDdlIdentifierQuotes(sql: string, dialect: SqlFormatDialect, quoteIdentifiers: boolean, options: GeneratedDdlIdentifierQuoteOptions = {}): string {
|
||||
if (quoteIdentifiers) return sql;
|
||||
if (dialect !== "oracle") return omitDdlIdentifierQuotes(sql, dialect);
|
||||
|
||||
const preserveCaseSensitive = options.preserveCaseSensitiveIdentifiers === true;
|
||||
const tokens = tokenizeSqlSemantic(sql, dialect);
|
||||
const replacements: Array<{ start: number; end: number; value: string }> = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.kind !== "quoted_identifier") continue;
|
||||
const identifier = unquoteSqlSemanticIdentifier(token);
|
||||
if (SIMPLE_ORACLE_IDENTIFIER.test(identifier) && !requiresOracleIdentifierQuote(identifier.toUpperCase())) {
|
||||
replacements.push({ start: token.span.start, end: token.span.end, value: identifier.toUpperCase() });
|
||||
}
|
||||
if (!SIMPLE_ORACLE_IDENTIFIER.test(identifier) || requiresOracleIdentifierQuote(identifier.toUpperCase())) continue;
|
||||
if (preserveCaseSensitive && identifier !== identifier.toUpperCase()) continue;
|
||||
replacements.push({ start: token.span.start, end: token.span.end, value: identifier.toUpperCase() });
|
||||
}
|
||||
|
||||
let result = sql;
|
||||
|
||||
Reference in new issue
Block a user