From 4da237d95581f4a87d6850b957a613f201893e11 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 1 Jul 2026 16:05:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=20Oracle=20?= =?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84=E5=B7=B2=E6=9C=89=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: staff --- .../structure/TableStructureEditor.vue | 57 +++- .../src/lib/tableStructureEditorState.ts | 68 +++++ crates/dbx-core/src/schema.rs | 248 +++++++++++++++++- .../src/table_structure_sql/columns.rs | 7 + .../dbx-core/src/table_structure_sql/tests.rs | 46 ++++ .../tableStructureEditorState.test.ts | 68 +++++ 6 files changed, 488 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index a38ed6bff5..670f0a117c 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -45,6 +45,7 @@ import { isSqlServerIdentityCompatibleDataType, isProtectedManticoreIdColumn, parseExtraToColumnExtra, + rehydrateColumnDraftsFromMetadata, splitDataType, toColumnNames, applyManticoreDdlColumnExtras, @@ -618,6 +619,7 @@ let skipNextRefreshVersion = false; let restoringDraft = false; let syncingDraft = false; let draftHydrated = false; +let hydratingRestoredDraft = false; function cloneDraftValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -647,6 +649,7 @@ function syncDraftToParent() { function restoreDraft(draft: TableStructureEditorDraft) { restoringDraft = true; + draftHydrated = false; activeTab.value = draft.activeTab || "columns"; newTableName.value = draft.newTableName || ""; tableComment.value = draft.tableComment || ""; @@ -656,7 +659,45 @@ function restoreDraft(draft: TableStructureEditorDraft) { foreignKeys.value = cloneDraftValue(draft.foreignKeys || []); triggers.value = cloneDraftValue(draft.triggers || []); restoringDraft = false; - draftHydrated = true; + draftHydrated = !needsColumnDraftMetadataHydration(); +} + +function needsColumnDraftMetadataHydration() { + return !isCreateMode.value && columns.value.some((column) => !column.original && !column.id.startsWith("new:") && !!column.name.trim()); +} + +async function hydrateRestoredDraftFromDatabase() { + if (!needsColumnDraftMetadataHydration() || hydratingRestoredDraft) return; + const connectionId = props.connectionId; + const database = props.database; + const schema = metadataSchema.value; + const tableName = props.tableName; + if (!connectionId || !database || !tableName) return; + + hydratingRestoredDraft = true; + let shouldRefreshPreview = false; + try { + await store.ensureConnected(connectionId); + let nextColumns = await api.getColumns(connectionId, database, schema, tableName); + if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { + try { + const ddl = await api.getTableDdl(connectionId, database, schema, tableName); + ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter); + ddlFetched.value = true; + nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); + } catch { + /* ignore — Manticore column properties can still come from SHOW COLUMNS when available */ + } + } + columns.value = rehydrateColumnDraftsFromMetadata(columns.value, nextColumns, databaseType.value); + markDraftHydratedAndSync(); + shouldRefreshPreview = true; + } catch (e: any) { + console.warn("[DBX][structure-editor:draft-hydration-failed]", e); + } finally { + hydratingRestoredDraft = false; + if (shouldRefreshPreview) scheduleSqlPreviewRefresh(); + } } function markDraftHydratedAndSync() { @@ -736,6 +777,18 @@ async function loadDynamicDataTypeOptions() { } function scheduleSqlPreviewRefresh() { + if (hydratingRestoredDraft || needsColumnDraftMetadataHydration()) { + if (sqlPreviewDebounceTimer) { + clearTimeout(sqlPreviewDebounceTimer); + sqlPreviewDebounceTimer = undefined; + } + sqlPreviewRequestId++; + deferredSqlPreviewRefresh = false; + pendingStatements.value = []; + warnings.value = []; + sqlPreviewLoading.value = true; + return; + } if (!hasPendingStructureChanges()) { clearSqlPreviewState(); return; @@ -1589,6 +1642,7 @@ onMounted(() => { if (props.draft?.initialized) { restoreDraft(props.draft); applyInitialStructureTab(); + void hydrateRestoredDraftFromDatabase(); } else if (isCreateMode.value) { markDraftHydratedAndSync(); } else { @@ -1601,6 +1655,7 @@ onActivated(() => { void loadDynamicDataTypeOptions(); if (props.draft?.initialized && !draftHydrated) { restoreDraft(props.draft); + void hydrateRestoredDraftFromDatabase(); } }); onDeactivated(unregisterStructureEditorShortcuts); diff --git a/apps/desktop/src/lib/tableStructureEditorState.ts b/apps/desktop/src/lib/tableStructureEditorState.ts index 64579a3806..583263af23 100644 --- a/apps/desktop/src/lib/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/tableStructureEditorState.ts @@ -606,6 +606,74 @@ export function createColumnDrafts(columns: ColumnInfo[], databaseType?: Databas }); } +function existingColumnIdName(id: string): string | undefined { + const prefix = "existing:"; + return id.startsWith(prefix) ? id.slice(prefix.length) : undefined; +} + +function isNewColumnDraftId(id: string): boolean { + return id.startsWith("new:"); +} + +function uniqueNames(names: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const name of names) { + if (!name) continue; + if (seen.has(name)) continue; + seen.add(name); + result.push(name); + } + return result; +} + +function findColumnDraftByName(columns: EditableStructureColumn[], names: string[], usedIndexes: Set): number | undefined { + for (const name of names) { + const exactIndex = columns.findIndex((column, index) => !usedIndexes.has(index) && column.name === name); + if (exactIndex >= 0) return exactIndex; + } + + for (const name of names) { + const lowerName = name.toLowerCase(); + const matches = columns.map((column, index) => ({ column, index })).filter(({ column, index }) => !usedIndexes.has(index) && column.name.toLowerCase() === lowerName); + if (matches.length === 1) return matches[0]!.index; + } + + return undefined; +} + +export function rehydrateColumnDraftsFromMetadata(draftColumns: EditableStructureColumn[], columns: ColumnInfo[], databaseType?: DatabaseType): EditableStructureColumn[] { + const metadataDrafts = createColumnDrafts(columns, databaseType); + if (!metadataDrafts.length) return draftColumns; + if (!draftColumns.length) return metadataDrafts; + + const usedMetadataIndexes = new Set(); + const nextColumns = draftColumns.map((column) => { + if (!column.original && isNewColumnDraftId(column.id)) return column; + + const needsHydration = !column.original || column.originalPosition === undefined; + const candidates = uniqueNames([column.original?.name, existingColumnIdName(column.id), column.name]); + const metadataIndex = findColumnDraftByName(metadataDrafts, candidates, usedMetadataIndexes); + if (metadataIndex === undefined) return column; + usedMetadataIndexes.add(metadataIndex); + if (!needsHydration) return column; + + const metadataDraft = metadataDrafts[metadataIndex]!; + return { + ...column, + original: column.original ?? metadataDraft.original, + originalPosition: column.originalPosition ?? metadataDraft.originalPosition, + }; + }); + + if (usedMetadataIndexes.size === 0) { + return [...metadataDrafts, ...draftColumns]; + } + + const missingMetadataDrafts = metadataDrafts.filter((_, index) => !usedMetadataIndexes.has(index)); + return [...nextColumns, ...missingMetadataDrafts]; +} + export function createIndexDrafts(indexes: IndexInfo[]): EditableStructureIndex[] { return indexes.map((index) => ({ id: `existing:${index.name}`, diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 22db827503..ef4fa6d53b 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -954,6 +954,122 @@ fn oracle_table_comments_from_query_result(result: db::QueryResult) -> HashMap String { + format!( + "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_DEFAULT, \ + c.DATA_LENGTH, c.DATA_PRECISION, c.DATA_SCALE, c.COLUMN_ID, \ + CASE WHEN cc.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \ + cm.COMMENTS \ + FROM ALL_TAB_COLUMNS c \ + LEFT JOIN ( \ + SELECT cols.OWNER, cols.TABLE_NAME, cols.COLUMN_NAME \ + FROM ALL_CONS_COLUMNS cols \ + JOIN ALL_CONSTRAINTS con \ + ON con.CONSTRAINT_NAME = cols.CONSTRAINT_NAME \ + AND con.OWNER = cols.OWNER \ + AND con.CONSTRAINT_TYPE = 'P' \ + ) cc ON cc.OWNER = c.OWNER AND cc.TABLE_NAME = c.TABLE_NAME AND cc.COLUMN_NAME = c.COLUMN_NAME \ + LEFT JOIN ALL_COL_COMMENTS cm \ + ON cm.OWNER = c.OWNER AND cm.TABLE_NAME = c.TABLE_NAME AND cm.COLUMN_NAME = c.COLUMN_NAME \ + WHERE c.OWNER = {} AND c.TABLE_NAME = {} \ + ORDER BY c.COLUMN_ID", + oracle_owner_filter(schema), + sql_string(table), + ) +} + +fn oracle_column_type(data_type: &str, precision: Option, scale: Option, length: Option) -> String { + match data_type.to_ascii_uppercase().as_str() { + "NUMBER" => match (precision, scale) { + (Some(precision), Some(scale)) if scale > 0 => format!("NUMBER({precision},{scale})"), + (Some(precision), _) => format!("NUMBER({precision})"), + _ => "NUMBER".to_string(), + }, + "VARCHAR2" | "NVARCHAR2" | "CHAR" | "NCHAR" | "RAW" => match length { + Some(length) => format!("{data_type}({length})"), + None => data_type.to_string(), + }, + _ => data_type.to_string(), + } +} + +fn oracle_columns_from_query_result(result: db::QueryResult) -> Vec { + result + .rows + .into_iter() + .filter_map(|row| { + let name = query_result_cell_string(&row, 0)?; + let data_type = query_result_cell_string(&row, 1).unwrap_or_default(); + let nullable = query_result_cell_string(&row, 2).unwrap_or_default(); + let default_value = query_result_cell_string(&row, 3) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let length = query_result_cell_i64(&row, 4).and_then(|value| i32::try_from(value).ok()); + let precision = query_result_cell_i64(&row, 5).and_then(|value| i32::try_from(value).ok()); + let scale = query_result_cell_i64(&row, 6).and_then(|value| i32::try_from(value).ok()); + let is_primary_key = query_result_cell_i64(&row, 8).unwrap_or(0) == 1; + let comment = query_result_cell_string(&row, 9).filter(|value| !value.trim().is_empty()); + Some(db::ColumnInfo { + name, + data_type: oracle_column_type(&data_type, precision, scale, length), + is_nullable: nullable == "Y", + column_default: default_value, + is_primary_key, + extra: None, + comment, + numeric_precision: precision, + numeric_scale: scale, + character_maximum_length: length, + }) + }) + .collect() +} + +async fn oracle_columns_via_sql( + database: &str, + schema: &str, + table: &str, + client: &mut db::agent_driver::AgentDriverClient, + timeout_duration: Option, +) -> Result, String> { + let sql = oracle_columns_sql(schema, table); + let result = client + .execute_query_with_timeout::( + agent_execute_query_params( + &sql, + if database.is_empty() { None } else { Some(database) }, + if schema.is_empty() { None } else { Some(schema) }, + QueryExecutionOptions { max_rows: Some(10_000), ..Default::default() }, + ), + timeout_duration, + ) + .await?; + Ok(deduplicate_column_infos(oracle_columns_from_query_result(result))) +} + +async fn external_driver_oracle_columns_via_sql( + session: Arc, + config: &ConnectionConfig, + database: &str, + schema: &str, + table: &str, +) -> Result, String> { + let result: db::QueryResult = session + .invoke_with_timeout( + "executeQuery", + serde_json::json!({ + "connection": config, + "database": database, + "schema": schema, + "sql": oracle_columns_sql(schema, table), + "maxRows": 10_000 + }), + agent_metadata_timeout(Some(config)), + ) + .await?; + Ok(deduplicate_column_infos(oracle_columns_from_query_result(result))) +} + fn oracle_object_statistics_sql(schema: &str) -> String { oracle_object_statistics_owner_segments_sql(schema, "ALL_SEGMENTS") } @@ -1676,11 +1792,12 @@ mod tests { clickhouse_metadata_database, deduplicate_column_infos, filter_mysql_system_databases_for_config, filter_table_infos, filter_visible_schema_names, is_agent_postgres_metadata_fallback_config, is_retryable_metadata_error, mysql_table_metadata_catalog, normalize_information_schema_table_type, - oracle_object_statistics_dba_segments_sql, oracle_object_statistics_from_query_result, - oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql, - oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql, - oracle_table_comments_from_query_result, oracle_table_comments_sql, presto_like_information_schema_tables_sql, - presto_like_tables_from_query_result, visible_schema_filter, + oracle_columns_from_query_result, oracle_columns_sql, oracle_object_statistics_dba_segments_sql, + oracle_object_statistics_from_query_result, oracle_object_statistics_rows_only_sql, + oracle_object_statistics_sql, oracle_object_statistics_user_segments_sql, + oracle_table_comment_from_query_result, oracle_table_comment_sql, oracle_table_comments_from_query_result, + oracle_table_comments_sql, presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, + visible_schema_filter, }; #[cfg(feature = "duckdb-bundled")] use super::{ @@ -2186,6 +2303,79 @@ mod tests { assert!(!comments.contains_key("PRODUCTS")); } + #[test] + fn oracle_columns_sql_uses_exact_table_name_for_quoted_lowercase_tables() { + let sql = oracle_columns_sql("DBX_TEST", "test"); + + assert!(sql.contains("ALL_TAB_COLUMNS")); + assert!(sql.contains("ALL_COL_COMMENTS")); + assert!(sql.contains("c.OWNER = 'DBX_TEST'")); + assert!(sql.contains("c.TABLE_NAME = 'test'")); + } + + #[test] + fn oracle_columns_from_query_result_maps_types_comments_and_primary_key() { + let result = db::QueryResult { + columns: vec![ + "COLUMN_NAME".to_string(), + "DATA_TYPE".to_string(), + "NULLABLE".to_string(), + "DATA_DEFAULT".to_string(), + "DATA_LENGTH".to_string(), + "DATA_PRECISION".to_string(), + "DATA_SCALE".to_string(), + "COLUMN_ID".to_string(), + "IS_PK".to_string(), + "COMMENTS".to_string(), + ], + column_types: Vec::new(), + column_sortables: Vec::new(), + rows: vec![ + vec![ + serde_json::json!("id"), + serde_json::json!("VARCHAR2"), + serde_json::json!("N"), + serde_json::Value::Null, + serde_json::json!("255"), + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::json!("1"), + serde_json::json!("1"), + serde_json::json!("identifier"), + ], + vec![ + serde_json::json!("data"), + serde_json::json!("TIMESTAMP"), + serde_json::json!("Y"), + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::json!("2"), + serde_json::json!("0"), + serde_json::Value::Null, + ], + ], + affected_rows: 0, + execution_time_ms: 0, + truncated: false, + session_id: None, + has_more: false, + }; + + let columns = oracle_columns_from_query_result(result); + + assert_eq!(columns.len(), 2); + assert_eq!(columns[0].name, "id"); + assert_eq!(columns[0].data_type, "VARCHAR2(255)"); + assert!(!columns[0].is_nullable); + assert!(columns[0].is_primary_key); + assert_eq!(columns[0].comment.as_deref(), Some("identifier")); + assert_eq!(columns[1].name, "data"); + assert_eq!(columns[1].data_type, "TIMESTAMP"); + assert!(columns[1].is_nullable); + } + #[test] fn oracle_object_statistics_sql_reads_rows_and_segment_bytes() { let sql = oracle_object_statistics_sql("app's"); @@ -2989,6 +3179,30 @@ pub async fn get_columns_core( agent_metadata_timeout(Some(config.as_ref())), ) .await?; + if columns.is_empty() && config.db_type == DatabaseType::Oracle { + match external_driver_oracle_columns_via_sql( + session.clone(), + config.as_ref(), + database, + schema, + table, + ) + .await + { + Ok(fallback_columns) if !fallback_columns.is_empty() => return Ok(fallback_columns), + Ok(_) => {} + Err(error) => { + log::warn!( + "[schema][external-driver:get_columns:oracle-fallback-failed] connection_id={} database={} schema={} table={} error={}", + connection_id, + database, + schema, + table, + error + ); + } + } + } return Ok(deduplicate_column_infos(columns)); } #[cfg(feature = "duckdb-bundled")] @@ -3045,6 +3259,30 @@ pub async fn get_columns_core( Ok(columns) if !columns.is_empty() => return Ok(deduplicate_column_infos(columns)), Ok(columns) => { if let Some(config) = fallback_config.as_ref() { + if config.db_type == DatabaseType::Oracle { + match oracle_columns_via_sql( + database, + schema, + table, + &mut client, + agent_metadata_timeout(Some(config)), + ) + .await + { + Ok(fallback_columns) if !fallback_columns.is_empty() => return Ok(fallback_columns), + Ok(_) => {} + Err(error) => { + log::warn!( + "[schema][agent:get_columns:oracle-fallback-failed] connection_id={} database={} schema={} table={} error={}", + connection_id, + database, + schema, + table, + error + ); + } + } + } match native_postgres_metadata_pool(state, connection_id, database, config).await { Ok(Some(pool)) => { return db::postgres::get_columns(&pool, schema, table) diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index 6f7ec55788..3f9b940459 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -20,6 +20,13 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name); let database_label = database_label(options.database_type); let active_columns: Vec<_> = options.columns.iter().filter(|column| !column.marked_for_drop).collect(); + if dialect == StructureDialect::Oracle + && active_columns.is_empty() + && options.columns.iter().any(|column| column.marked_for_drop) + { + warnings.push("Oracle does not allow dropping all columns from a table. Keep at least one column or drop the table instead.".to_string()); + return Vec::new(); + } let has_original_column_positions = active_columns.iter().any(|column| column.original_position.is_some()); let mut simulated_column_order = if has_original_column_positions { original_active_column_order(&active_columns) } else { Vec::new() }; diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index e9fe92e72f..01b931390b 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -246,6 +246,52 @@ fn builds_informix_column_and_index_changes() { ); } +#[test] +fn oracle_does_not_generate_drop_sql_for_all_columns() { + let mut id = column("id"); + id.marked_for_drop = true; + id.original = Some(ColumnInfo { + name: "id".to_string(), + data_type: "varchar2(255)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + }); + let mut name = column("name"); + name.marked_for_drop = true; + name.original = Some(ColumnInfo { + name: "name".to_string(), + data_type: "varchar2(255)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + }); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Oracle), + schema: Some("DBX_TEST".to_string()), + table_name: "test".to_string(), + columns: vec![id, name], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.statements, Vec::::new()); + assert_eq!( + result.warnings, + vec![ + "Oracle does not allow dropping all columns from a table. Keep at least one column or drop the table instead." + ] + ); +} + #[test] fn iris_drop_index_includes_table_name() { let mut old_index = index("index_id", &["ID"]); diff --git a/packages/app-tests/tableStructureEditorState.test.ts b/packages/app-tests/tableStructureEditorState.test.ts index cbd7f4ae20..4aca95037d 100644 --- a/packages/app-tests/tableStructureEditorState.test.ts +++ b/packages/app-tests/tableStructureEditorState.test.ts @@ -15,6 +15,7 @@ import { isSqlServerIdentityCompatibleDataType, normalizeDataTypeParams, parseExtraToColumnExtra, + rehydrateColumnDraftsFromMetadata, toColumnNames, } from "../../apps/desktop/src/lib/tableStructureEditorState.ts"; import type { ColumnInfo, IndexInfo } from "../../apps/desktop/src/types/database.ts"; @@ -93,6 +94,73 @@ test("creates editable column drafts from column metadata", () => { ); }); +test("rehydrates restored existing column drafts from live metadata", () => { + const drafts = rehydrateColumnDraftsFromMetadata( + [ + { + id: "existing:id", + name: "id", + dataType: "varchar(10)", + isNullable: true, + defaultValue: "", + comment: "", + isPrimaryKey: false, + extra: {}, + markedForDrop: false, + }, + { + id: "existing:data", + name: "data", + dataType: "timestamp", + isNullable: true, + defaultValue: "", + comment: "", + isPrimaryKey: false, + extra: {}, + markedForDrop: false, + }, + { + id: "new:note", + name: "note", + dataType: "varchar2(100)", + isNullable: true, + defaultValue: "", + comment: "", + isPrimaryKey: false, + extra: {}, + markedForDrop: false, + }, + ], + [ + { + name: "id", + data_type: "varchar(10)", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: null, + }, + { + name: "data", + data_type: "timestamp", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + comment: null, + }, + ], + "oracle", + ); + + assert.equal(drafts[0].original?.name, "id"); + assert.equal(drafts[0].originalPosition, 0); + assert.equal(drafts[1].original?.name, "data"); + assert.equal(drafts[1].originalPosition, 1); + assert.equal(drafts[2].original, undefined); +}); + test("normalizes PostgreSQL string default casts in editable column drafts", () => { const drafts = createColumnDrafts( [