diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts index 9e67d77c2e..7da9243bb6 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -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"); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 7bcd257a08..630c088770 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -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)); diff --git a/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts b/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts index 2214532890..a28d18fa36 100644 --- a/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts +++ b/apps/desktop/src/lib/sql/__tests__/ddlDisplay.spec.ts @@ -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", () => { diff --git a/apps/desktop/src/lib/sql/ddlDisplay.ts b/apps/desktop/src/lib/sql/ddlDisplay.ts index b40bd6b2de..986e1d5053 100644 --- a/apps/desktop/src/lib/sql/ddlDisplay.ts +++ b/apps/desktop/src/lib/sql/ddlDisplay.ts @@ -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; diff --git a/docs/screenshots/issue-9649-plain-table-still-folds.png b/docs/screenshots/issue-9649-plain-table-still-folds.png new file mode 100644 index 0000000000..4238a697e6 Binary files /dev/null and b/docs/screenshots/issue-9649-plain-table-still-folds.png differ diff --git a/docs/screenshots/issue-9649-quoted-table-after.png b/docs/screenshots/issue-9649-quoted-table-after.png new file mode 100644 index 0000000000..f891b26a90 Binary files /dev/null and b/docs/screenshots/issue-9649-quoted-table-after.png differ diff --git a/docs/screenshots/issue-9649-quoted-table-before.png b/docs/screenshots/issue-9649-quoted-table-before.png new file mode 100644 index 0000000000..4eded52f61 Binary files /dev/null and b/docs/screenshots/issue-9649-quoted-table-before.png differ diff --git a/docs/screenshots/issue-9649-quoted-table-name-after.png b/docs/screenshots/issue-9649-quoted-table-name-after.png new file mode 100644 index 0000000000..fdb0157b91 Binary files /dev/null and b/docs/screenshots/issue-9649-quoted-table-name-after.png differ