From 3dd034bf06ddd654515929f459dd593a963a982c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 12 Aug 2026 19:44:32 +0000 Subject: [PATCH] feat(oracle): preserve character length units Closes #4739 --- agents/drivers/oracle-go/main.go | 27 +++++++++- agents/drivers/oracle-go/main_test.go | 11 ++-- .../structure/TableStructureEditor.vue | 25 ++++----- .../table/tableStructureEditorState.spec.ts | 30 +++++++++-- .../lib/table/tableStructureEditorState.ts | 14 ++--- .../dbx-core/src/table_structure_sql/tests.rs | 51 +++++++++++++++++++ 6 files changed, 130 insertions(+), 28 deletions(-) diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index 0e775c4364..2140c2ebcb 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -406,6 +406,7 @@ type columnInfo struct { NumericPrecision *int `json:"numeric_precision"` NumericScale *int `json:"numeric_scale"` CharacterMaximumLength *int `json:"character_maximum_length"` + CharacterLengthUnit *string `json:"-"` } type indexInfo struct { @@ -2309,7 +2310,8 @@ SELECT c.COLUMN_NAME, cc.COMMENTS, c.DATA_PRECISION, c.DATA_SCALE, - c.CHAR_LENGTH + c.CHAR_LENGTH, + c.CHAR_USED FROM ALL_TAB_COLUMNS c LEFT JOIN ( SELECT acc.OWNER, acc.TABLE_NAME, acc.COLUMN_NAME @@ -2339,6 +2341,7 @@ ORDER BY c.COLUMN_ID`, []any{schema, table}) &item.NumericPrecision, &item.NumericScale, &item.CharacterMaximumLength, + &item.CharacterLengthUnit, ); err != nil { return nil, err } @@ -3038,6 +3041,9 @@ func oracleColumnTypeDDL(column columnInfo) string { return dataType } if isOracleCharacterType(dataType) && column.CharacterMaximumLength != nil && *column.CharacterMaximumLength > 0 { + if unit := oracleCharacterLengthUnit(dataType, column.CharacterLengthUnit); unit != "" { + return fmt.Sprintf("%s(%d %s)", dataType, *column.CharacterMaximumLength, unit) + } return fmt.Sprintf("%s(%d)", dataType, *column.CharacterMaximumLength) } if dataType == "NUMBER" { @@ -3056,6 +3062,25 @@ func oracleColumnTypeDDL(column columnInfo) string { return dataType } +func oracleCharacterLengthUnit(dataType string, charUsed *string) string { + switch dataType { + case "CHAR", "VARCHAR", "VARCHAR2": + default: + return "" + } + if charUsed == nil { + return "" + } + switch strings.ToUpper(strings.TrimSpace(*charUsed)) { + case "B": + return "BYTE" + case "C": + return "CHAR" + default: + return "" + } +} + func isOracleCharacterType(dataType string) bool { switch dataType { case "CHAR", "VARCHAR2", "VARCHAR", "NCHAR", "NVARCHAR2", "RAW": diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index f6155b315b..ed16a77a78 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -536,9 +536,9 @@ func TestGetTableDDLFallbackPreservesQuotedColumnNames(t *testing.T) { args: []driver.Value{schema, table}, columns: []string{ "COLUMN_NAME", "DATA_TYPE", "NULLABLE", "DATA_DEFAULT", "IS_PRIMARY_KEY", - "COMMENTS", "DATA_PRECISION", "DATA_SCALE", "CHAR_LENGTH", + "COMMENTS", "DATA_PRECISION", "DATA_SCALE", "CHAR_LENGTH", "CHAR_USED", }, - rows: [][]driver.Value{{"FlowId", "VARCHAR2", "N", nil, int64(1), nil, nil, nil, int64(50)}}, + rows: [][]driver.Value{{"FlowId", "VARCHAR2", "N", nil, int64(1), nil, nil, nil, int64(50), "C"}}, }, }) s := newServer() @@ -549,7 +549,7 @@ func TestGetTableDDLFallbackPreservesQuotedColumnNames(t *testing.T) { t.Fatal(err) } want := `CREATE TABLE "ZTZS_ERP2"."ZGJ_FlowSealTemplate" ( - "FlowId" VARCHAR2(50) NOT NULL, + "FlowId" VARCHAR2(50 CHAR) NOT NULL, PRIMARY KEY ("FlowId") )` if got != want { @@ -961,6 +961,8 @@ func TestOracleColumnTypeDDL(t *testing.T) { precision := 10 scale := 2 zeroScale := 0 + byteUnit := "B" + charUnit := "C" tests := []struct { name string @@ -968,6 +970,9 @@ func TestOracleColumnTypeDDL(t *testing.T) { want string }{ {name: "varchar", column: columnInfo{DataType: "VARCHAR2", CharacterMaximumLength: &charLen}, want: "VARCHAR2(64)"}, + {name: "varchar byte semantics", column: columnInfo{DataType: "VARCHAR2", CharacterMaximumLength: &charLen, CharacterLengthUnit: &byteUnit}, want: "VARCHAR2(64 BYTE)"}, + {name: "varchar char semantics", column: columnInfo{DataType: "VARCHAR2", CharacterMaximumLength: &charLen, CharacterLengthUnit: &charUnit}, want: "VARCHAR2(64 CHAR)"}, + {name: "national character type ignores unit", column: columnInfo{DataType: "NVARCHAR2", CharacterMaximumLength: &charLen, CharacterLengthUnit: &charUnit}, want: "NVARCHAR2(64)"}, {name: "number scale", column: columnInfo{DataType: "NUMBER", NumericPrecision: &precision, NumericScale: &scale}, want: "NUMBER(10,2)"}, {name: "number zero scale", column: columnInfo{DataType: "NUMBER", NumericPrecision: &precision, NumericScale: &zeroScale}, want: "NUMBER(10)"}, {name: "timestamp preserves precision", column: columnInfo{DataType: "TIMESTAMP(6)"}, want: "TIMESTAMP(6)"}, diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index dd89ac6be7..9cdc091b99 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -73,7 +73,7 @@ import { parseExtraToColumnExtra, rehydrateColumnDraftsFromMetadata, resolveInsertColumnIndex, - restoreDamengLengthUnitsAfterSave, + restoreCharacterLengthUnitsAfterSave, sameStructureIndexType, splitDataType, toColumnNames, @@ -583,7 +583,7 @@ watch(localStructureDensity, (density, previousDensity) => { function onColResize(e: MouseEvent, col: number) { e.preventDefault(); const widthIndex = columnWidthIndex(col); - const minimumWidth = widthIndex === 3 && databaseType.value === "dameng" ? structureDensityMetric.value.minLengthColumnWidth : structureDensityMetric.value.minColumnWidth; + const minimumWidth = widthIndex === 3 && supportsCharacterLengthUnits.value ? structureDensityMetric.value.minLengthColumnWidth : structureDensityMetric.value.minColumnWidth; colResizing.value = { col: widthIndex, startX: e.clientX, startW: Math.max(colWidths.value[widthIndex] ?? minimumWidth, minimumWidth) }; const onMove = (ev: MouseEvent) => { if (!colResizing.value) return; @@ -622,6 +622,7 @@ function onIndexColResize(e: MouseEvent, col: number) { const connection = computed(() => (props.connectionId ? store.getConfig(props.connectionId) : undefined)); const databaseType = computed(() => tableStructureDatabaseTypeForConnection(connection.value)); +const supportsCharacterLengthUnits = computed(() => databaseType.value === "dameng" || databaseType.value === "oracle"); const usesMysql8SafeDefaults = computed(() => databaseType.value === "mysql" && connection.value?.db_type === "mysql" && connection.value.driver_profile === "mysql"); const structureCapabilities = computed(() => getTableStructureCapabilities(databaseType.value, connection.value?.db_type, connection.value?.database_info?.productVersion)); const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(databaseType.value)); @@ -772,7 +773,7 @@ const visibleColWidths = computed(() => colLabels.value.map((column) => { if (column.key === "actions") return columnActionsWidth.value; const width = colWidths.value[column.widthIndex] ?? structureDensityMetric.value.minColumnWidth; - return column.key === "length" && databaseType.value === "dameng" ? Math.max(width, structureDensityMetric.value.minLengthColumnWidth) : width; + return column.key === "length" && supportsCharacterLengthUnits.value ? Math.max(width, structureDensityMetric.value.minLengthColumnWidth) : width; }), ); @@ -1299,7 +1300,7 @@ async function loadStructure( silent = false, scope: TableStructureRefreshScope = visibleTableStructureRefreshScope(activeTab.value), showErrors = true, - options: { blockSecondaryMetadata?: boolean; preserveDraft?: boolean; damengLengthUnitsAfterSave?: ReadonlyMap; forceDdl?: boolean; forceMetadata?: boolean } = {}, + options: { blockSecondaryMetadata?: boolean; preserveDraft?: boolean; characterLengthUnitsAfterSave?: ReadonlyMap; forceDdl?: boolean; forceMetadata?: boolean } = {}, ) { const connectionId = props.connectionId; const database = props.database; @@ -1352,7 +1353,7 @@ async function loadStructure( // editor shows the correct options for the server version. void loadCharsetMetadata(); const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value); - const hydratedColumnDrafts = databaseType.value === "dameng" && options.damengLengthUnitsAfterSave ? restoreDamengLengthUnitsAfterSave(nextColumnDrafts, options.damengLengthUnitsAfterSave) : nextColumnDrafts; + const hydratedColumnDrafts = supportsCharacterLengthUnits.value && options.characterLengthUnitsAfterSave ? restoreCharacterLengthUnitsAfterSave(databaseType.value, nextColumnDrafts, options.characterLengthUnitsAfterSave) : nextColumnDrafts; columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts); loadedMetadataFacets.add("columns"); if (!options.preserveDraft) selectedColumnId.value = null; @@ -1411,9 +1412,9 @@ async function loadStructure( } } -async function refreshStructureAfterSave(scope: TableStructureRefreshScope, damengLengthUnitsAfterSave: ReadonlyMap) { +async function refreshStructureAfterSave(scope: TableStructureRefreshScope, characterLengthUnitsAfterSave: ReadonlyMap) { try { - await loadStructure(true, scope, false, { blockSecondaryMetadata: true, damengLengthUnitsAfterSave }); + await loadStructure(true, scope, false, { blockSecondaryMetadata: true, characterLengthUnitsAfterSave }); } catch (e) { console.warn("[DBX][structure-editor:post-save-refresh-failed]", e); } finally { @@ -2325,11 +2326,11 @@ async function applyChanges() { saving.value = true; errorMessage.value = ""; const refreshScope = captureStructureRefreshScope(); - const damengLengthUnitsAfterSave = new Map(); - if (databaseType.value === "dameng") { + const characterLengthUnitsAfterSave = new Map(); + if (supportsCharacterLengthUnits.value) { for (const column of columns.value) { - if (!column.markedForDrop && dataTypeLengthUnitValue("dameng", column.dataType)) { - damengLengthUnitsAfterSave.set(column.name.trim().toLowerCase(), column.dataType); + if (!column.markedForDrop && dataTypeLengthUnitValue(databaseType.value, column.dataType)) { + characterLengthUnitsAfterSave.set(column.name.trim().toLowerCase(), column.dataType); } } } @@ -2363,7 +2364,7 @@ async function applyChanges() { postSaveRefreshing.value = true; skipNextRefreshVersion = true; emit("saved", tableComment.value !== originalTableComment.value); - await refreshStructureAfterSave(refreshScope, damengLengthUnitsAfterSave); + await refreshStructureAfterSave(refreshScope, characterLengthUnitsAfterSave); } return true; } catch (e: any) { diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 42f4789933..99e268840d 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -21,7 +21,7 @@ import { parseExtraToColumnExtra, rehydrateColumnDraftsFromMetadata, resolveInsertColumnIndex, - restoreDamengLengthUnitsAfterSave, + restoreCharacterLengthUnitsAfterSave, splitDataType, } from "@/lib/table/tableStructureEditorState"; @@ -189,7 +189,7 @@ describe("tableStructureEditorState", () => { expect(combineDataTypeForDatabase("mysql", "integer", "11")).toBe("integer(11)"); }); - it("offers BYTE and CHAR units only for supported Dameng character types", () => { + it("offers BYTE and CHAR units for supported Oracle-family character types", () => { expect(getDataTypeLengthUnitOptions("dameng", "varchar2(255 CHAR)")).toEqual(["BYTE", "CHAR"]); expect(getDataTypeLengthUnitOptions("dameng", "varchar(255)")).toEqual(["BYTE", "CHAR"]); expect(getDataTypeLengthUnitOptions("dameng", "char(10 BYTE)")).toEqual(["BYTE", "CHAR"]); @@ -197,7 +197,9 @@ describe("tableStructureEditorState", () => { expect(getDataTypeLengthUnitOptions("dameng", "nchar(10)")).toEqual([]); expect(getDataTypeLengthUnitOptions("dameng", "nvarchar2(10)")).toEqual([]); expect(getDataTypeLengthUnitOptions("dameng", "number(10,0)")).toEqual([]); - expect(getDataTypeLengthUnitOptions("oracle", "varchar2(255 CHAR)")).toEqual([]); + expect(getDataTypeLengthUnitOptions("oracle", "varchar2(255 CHAR)")).toEqual(["BYTE", "CHAR"]); + expect(getDataTypeLengthUnitOptions("oracle", "char(10 BYTE)")).toEqual(["BYTE", "CHAR"]); + expect(getDataTypeLengthUnitOptions("oracle", "nvarchar2(10)")).toEqual([]); expect(getDataTypeLengthUnitOptions("mysql", "varchar(255)")).toEqual([]); }); @@ -213,6 +215,12 @@ describe("tableStructureEditorState", () => { expect(combineDataTypeForDatabaseWithLengthUnit("dameng", "varchar2", "255", "")).toBe("varchar2(255)"); }); + it("separates and reconstructs Oracle character length units", () => { + expect(dataTypeLengthInputValue("oracle", "VARCHAR2(255 CHAR)")).toBe("255"); + expect(dataTypeLengthUnitValue("oracle", "VARCHAR2(255 CHAR)")).toBe("CHAR"); + expect(combineDataTypeForDatabaseWithLengthUnit("oracle", "VARCHAR2", "64", "BYTE")).toBe("VARCHAR2(64 BYTE)"); + }); + it("does not reinterpret unsupported length parameters or dialects", () => { expect(dataTypeLengthInputValue("dameng", "varchar2(255 WORD)")).toBe("255 WORD"); expect(dataTypeLengthUnitValue("dameng", "varchar2(255 WORD)")).toBe(""); @@ -235,7 +243,7 @@ describe("tableStructureEditorState", () => { "dameng", ); - const [restored] = restoreDamengLengthUnitsAfterSave([legacyAgentDraft!], new Map([["display_name", "VARCHAR2(255 CHAR)"]])); + const [restored] = restoreCharacterLengthUnitsAfterSave("dameng", [legacyAgentDraft!], new Map([["display_name", "VARCHAR2(255 CHAR)"]])); expect(restored?.dataType).toBe("VARCHAR2(255 CHAR)"); expect(restored?.original?.data_type).toBe("VARCHAR2(255 CHAR)"); @@ -256,12 +264,24 @@ describe("tableStructureEditorState", () => { "dameng", ); - const [restored] = restoreDamengLengthUnitsAfterSave([liveDraft!], new Map([["display_name", "VARCHAR2(255 CHAR)"]])); + const [restored] = restoreCharacterLengthUnitsAfterSave("dameng", [liveDraft!], new Map([["display_name", "VARCHAR2(255 CHAR)"]])); expect(restored?.dataType).toBe("VARCHAR2(255 BYTE)"); expect(restored?.original?.data_type).toBe("VARCHAR2(255 BYTE)"); }); + it("keeps a saved Oracle length unit when refreshed metadata omits it", () => { + const [legacyAgentDraft] = createColumnDrafts( + [{ name: "DISPLAY_NAME", data_type: "VARCHAR2(255)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }], + "oracle", + ); + + const [restored] = restoreCharacterLengthUnitsAfterSave("oracle", [legacyAgentDraft!], new Map([["display_name", "VARCHAR2(255 CHAR)"]])); + + expect(restored?.dataType).toBe("VARCHAR2(255 CHAR)"); + expect(restored?.original?.data_type).toBe("VARCHAR2(255 CHAR)"); + }); + it("does not add MySQL display lengths when choosing SQLite-family types", () => { for (const databaseType of ["sqlite", "rqlite", "turso"] as const) { expect(getDefaultLengthForType(databaseType, "integer")).toBe(""); diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index 07941e627a..eebbf6bac6 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -974,16 +974,16 @@ export function splitDataType(raw: string): { baseType: string; params: string } export type DataTypeLengthUnit = "BYTE" | "CHAR"; -const DAMENG_LENGTH_UNIT_TYPES = new Set(["char", "varchar", "varchar2"]); -const DAMENG_LENGTH_UNITS: readonly DataTypeLengthUnit[] = ["BYTE", "CHAR"]; +const CHARACTER_LENGTH_UNIT_TYPES = new Set(["char", "varchar", "varchar2"]); +const CHARACTER_LENGTH_UNITS: readonly DataTypeLengthUnit[] = ["BYTE", "CHAR"]; function normalizedDataTypeName(rawDataType: string): string { return splitDataType(rawDataType).baseType.trim().replace(/\s+/g, " ").toLowerCase(); } export function getDataTypeLengthUnitOptions(dbType: DatabaseType | undefined, rawDataType: string): readonly DataTypeLengthUnit[] { - if (dbType !== "dameng") return []; - return DAMENG_LENGTH_UNIT_TYPES.has(normalizedDataTypeName(rawDataType)) ? DAMENG_LENGTH_UNITS : []; + if (dbType !== "dameng" && dbType !== "oracle") return []; + return CHARACTER_LENGTH_UNIT_TYPES.has(normalizedDataTypeName(rawDataType)) ? CHARACTER_LENGTH_UNITS : []; } function splitDataTypeLengthParams(dbType: DatabaseType | undefined, rawDataType: string): { length: string; unit: DataTypeLengthUnit | "" } { @@ -1015,13 +1015,13 @@ export function combineDataTypeForDatabaseWithLengthUnit(dbType: DatabaseType | return combineDataTypeForDatabase(dbType, baseType, params); } -export function restoreDamengLengthUnitsAfterSave(columns: EditableStructureColumn[], savedDataTypesByColumn: ReadonlyMap): EditableStructureColumn[] { +export function restoreCharacterLengthUnitsAfterSave(dbType: DatabaseType | undefined, columns: EditableStructureColumn[], savedDataTypesByColumn: ReadonlyMap): EditableStructureColumn[] { if (savedDataTypesByColumn.size === 0) return columns; return columns.map((column) => { - if (dataTypeLengthUnitValue("dameng", column.dataType)) return column; + if (dataTypeLengthUnitValue(dbType, column.dataType)) return column; const savedDataType = savedDataTypesByColumn.get(column.name.trim().toLowerCase()); - if (!savedDataType || !dataTypeLengthUnitValue("dameng", savedDataType)) return column; + if (!savedDataType || !dataTypeLengthUnitValue(dbType, savedDataType)) return column; if (normalizedDataTypeName(savedDataType) !== normalizedDataTypeName(column.dataType)) return column; return { diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index cd277d9685..b20a6a111c 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -849,6 +849,57 @@ fn oracle_timestamp_default_precedes_nullability_in_modify_sql() { ); } +#[test] +fn oracle_create_table_preserves_character_length_units() { + let mut byte_col = column("BYTE_COL"); + byte_col.data_type = "VARCHAR2(12 BYTE)".to_string(); + let mut char_col = column("CHAR_COL"); + char_col.data_type = "VARCHAR2(12 CHAR)".to_string(); + + let result = build_create_table_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Oracle), + schema: Some("DBX_APP".to_string()), + table_name: "DBX_ISSUE_4739".to_string(), + columns: vec![byte_col, char_col], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert!(result.statements[0].contains("\"BYTE_COL\" VARCHAR2(12 BYTE)")); + assert!(result.statements[0].contains("\"CHAR_COL\" VARCHAR2(12 CHAR)")); +} + +#[test] +fn oracle_alter_column_preserves_character_length_unit() { + let mut column = column("DISPLAY_NAME"); + column.data_type = "VARCHAR2(64 CHAR)".to_string(); + column.original = Some(ColumnInfo { + name: "DISPLAY_NAME".to_string(), + data_type: "VARCHAR2(64 BYTE)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Oracle), + schema: Some("DBX_APP".to_string()), + table_name: "DBX_ISSUE_4739".to_string(), + column, + }); + + assert_eq!( + result.statements, + vec!["ALTER TABLE \"DBX_APP\".\"DBX_ISSUE_4739\" MODIFY (\"DISPLAY_NAME\" VARCHAR2(64 CHAR));"] + ); +} + #[test] fn oracle_timestamp_precision_change_does_not_repeat_unchanged_nullability() { let mut col = column("time");