diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 1c70207e3a..e2d034cbca 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -221,6 +221,7 @@ export const readExternalSqlFile = forward("readExternalSqlFile"); // Data Transfer export const startTransfer = forward("startTransfer"); export const cancelTransfer = forward("cancelTransfer"); +export const sortTablesByFkDependency = forward("sortTablesByFkDependency"); // Table File Import export const previewTableImportFile = forward("previewTableImportFile"); diff --git a/apps/desktop/src/lib/databaseExport.ts b/apps/desktop/src/lib/databaseExport.ts index 612008f821..51880824b0 100644 --- a/apps/desktop/src/lib/databaseExport.ts +++ b/apps/desktop/src/lib/databaseExport.ts @@ -26,6 +26,9 @@ export interface BuildDatabaseSqlExportOptions { tables: ExportedTableSql[]; rowLimitPerTable?: number; insertBatchSize?: number; + connectionId?: string; + database?: string; + schema?: string; } export interface BuildExportInsertStatementsOptions { diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 1fe21a2735..21bcbaf2c2 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1056,6 +1056,18 @@ export async function cancelTransfer(transferId: string): Promise { return post("/api/transfer/cancel", { transferId }); } +export interface SortTablesByFkOptions { + connectionId: string; + database: string; + schema: string; + tables: string[]; + parentsFirst: boolean; +} + +export async function sortTablesByFkDependency(options: SortTablesByFkOptions): Promise { + return post("/api/transfer/sort-tables-by-fk", options); +} + // --------------------------------------------------------------------------- // Table File Import // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 62228798a0..80fbdd2451 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1479,6 +1479,24 @@ export async function cancelTransfer(transferId: string): Promise { return invoke("cancel_transfer", { transferId }); } +export interface SortTablesByFkOptions { + connectionId: string; + database: string; + schema: string; + tables: string[]; + parentsFirst: boolean; +} + +export async function sortTablesByFkDependency(options: SortTablesByFkOptions): Promise { + return invoke("sort_tables_by_fk_dependency", { + connectionId: options.connectionId, + database: options.database, + schema: options.schema, + tables: options.tables, + parentsFirst: options.parentsFirst, + }); +} + // --- Table File Import --- export type TableImportMode = "append" | "truncate"; export type TableImportStatus = "running" | "done" | "error" | "cancelled"; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 316b7cdf12..3e141cb218 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -119,6 +119,15 @@ pub struct BuildDatabaseSqlExportOptions { pub row_limit_per_table: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub insert_batch_size: Option, + /// Optional connection info for FK-aware table ordering. + /// When set, the caller should sort tables by dependency before passing them + /// to `build_database_sql_export`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, } pub fn format_export_sql_literal(value: &Value) -> String { @@ -404,9 +413,27 @@ pub async fn export_database_sql_core( } // 7. Separate tables and views - let tables: Vec<_> = all_tables.iter().filter(|t| t.table_type != "VIEW").collect(); + let mut tables: Vec<_> = all_tables.iter().filter(|t| t.table_type != "VIEW").collect(); let views: Vec<_> = all_tables.iter().filter(|t| t.table_type == "VIEW").collect(); + // Sort tables by foreign key dependency so referenced (parent) tables are + // exported before referencing (child) tables. + if tables.len() > 1 { + let table_names: Vec = tables.iter().map(|t| t.name.clone()).collect(); + if let Ok(sorted_names) = crate::transfer::sort_tables_by_fk_dependency( + state, + &request.connection_id, + &request.database, + &request.schema, + &table_names, + true, + ) + .await + { + tables.sort_by_key(|t| sorted_names.iter().position(|n| n == &t.name).unwrap_or(usize::MAX)); + } + } + // 8. Calculate total objects let mut total_objects = tables.len() + views.len(); diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index fec4cd2fb5..3b0de7a843 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -2458,6 +2458,101 @@ pub async fn clear_cancelled(transfer_id: &str) { CANCELLED.write().await.remove(transfer_id); } +/// Sort table names by foreign key dependency. +/// +/// When `parents_first` is true (data transfer / SQL export), referenced (parent) +/// tables come before referencing (child) tables so inserts don't violate FK +/// constraints. +/// +/// When `parents_first` is false (batch drop), referencing (child) tables come +/// first so they are dropped before the tables they reference. +/// +/// Uses Kahn's algorithm for topological sort; tables involved in cycles keep +/// their original relative order after all cycle-free tables. +pub async fn sort_tables_by_fk_dependency( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + tables: &[String], + parents_first: bool, +) -> Result, String> { + if tables.len() <= 1 { + return Ok(tables.to_vec()); + } + + let table_set: HashSet<&str> = tables.iter().map(|t| t.as_str()).collect(); + + // Gather FK relationships for every table. + let mut dependency_map: HashMap> = HashMap::new(); + for table in tables { + let fks = crate::schema::list_foreign_keys_core(state, connection_id, database, schema, table).await?; + let deps: Vec = fks + .iter() + .map(|fk| fk.ref_table.clone()) + .filter(|ref_table| table_set.contains(ref_table.as_str())) + .collect(); + dependency_map.insert(table.clone(), deps); + } + + // Build in-degree and dependents graph. + // parents_first=true: edge ref_table → table (parent before child) + // parents_first=false: edge table → ref_table (child before parent) + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); + + for table in tables { + in_degree.entry(table.as_str()).or_insert(0); + } + for table in tables { + if let Some(deps) = dependency_map.get(table) { + for ref_table in deps { + if parents_first { + // FK-bearing table depends on ref_table — parent comes first. + *in_degree.entry(table.as_str()).or_insert(0) += 1; + dependents.entry(ref_table.as_str()).or_default().push(table.as_str()); + } else { + // ref_table depends on FK-bearing table — child comes first. + *in_degree.entry(ref_table.as_str()).or_insert(0) += 1; + dependents.entry(table.as_str()).or_default().push(ref_table.as_str()); + } + } + } + } + + // Kahn's algorithm. + let mut queue: std::collections::VecDeque<&str> = + in_degree.iter().filter(|(_, °)| deg == 0).map(|(&table, _)| table).collect(); + + let mut sorted: Vec = Vec::new(); + while let Some(table) = queue.pop_front() { + sorted.push(table.to_string()); + if let Some(deps) = dependents.get(table) { + for &dependent in deps { + let deg = in_degree.get_mut(dependent).unwrap(); + *deg -= 1; + if *deg == 0 { + queue.push_back(dependent); + } + } + } + } + + // Append any tables left behind by cycles in their original order. + if sorted.len() < tables.len() { + let sorted_set: HashSet<&str> = sorted.iter().map(|s| s.as_str()).collect(); + let mut remaining: Vec = Vec::new(); + for table in tables { + if !sorted_set.contains(table.as_str()) { + remaining.push(table.clone()); + } + } + sorted.extend(remaining); + } + + Ok(sorted) +} + #[allow(clippy::too_many_arguments)] async fn transfer_mongodb_table( state: &AppState, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 9eb8adfa4e..85a00777de 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -363,6 +363,7 @@ async fn main() { .route("/transfer/start", post(routes::transfer::start_transfer)) .route("/transfer/progress/{transferId}", get(routes::transfer::transfer_progress)) .route("/transfer/cancel", post(routes::transfer::cancel_transfer)) + .route("/transfer/sort-tables-by-fk", post(routes::transfer::sort_tables_by_fk_dependency)) // Database export .route("/export/database", post(routes::database_export::start_database_export)) .route("/export/database/progress/{exportId}", get(routes::database_export::database_export_progress)) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 100a2f01b6..4aabf8cc95 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -702,7 +702,36 @@ pub async fn build_export_sql_insert(Json(req): Json>, Json(req): Json, ) -> Result, AppError> { - dbx_core::database_export::build_database_sql_export(req.options).map(Json).map_err(AppError) + let mut options = req.options; + // Sort tables by FK dependency when connection info is available. + if let (Some(ref conn_id), Some(ref database), Some(ref schema)) = + (&options.connection_id, &options.database, &options.schema) + { + if options.tables.len() > 1 { + let table_names: Vec = options.tables.iter().filter_map(|t| t.table_name.clone()).collect(); + if table_names.len() > 1 { + if let Ok(sorted_names) = dbx_core::transfer::sort_tables_by_fk_dependency( + &state.app, + conn_id, + database, + schema, + &table_names, + true, + ) + .await + { + options.tables.sort_by_key(|t| { + sorted_names + .iter() + .position(|n| Some(n.as_str()) == t.table_name.as_deref()) + .unwrap_or(usize::MAX) + }); + } + } + } + } + dbx_core::database_export::build_database_sql_export(options).map(Json).map_err(AppError) } diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index 281fae5228..2963644962 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -79,6 +79,20 @@ pub async fn start_transfer( }; let tables = req.tables.clone(); + // Sort by FK dependency so referenced tables are transferred first. + let tables = transfer::sort_tables_by_fk_dependency( + &app, + &req.source_connection_id, + &req.source_database, + &req.source_schema, + &tables, + true, + ) + .await + .unwrap_or_else(|e| { + log::warn!("[transfer] failed to sort tables by FK dependency, using original order: {e}"); + tables + }); for (i, table) in tables.iter().enumerate() { if transfer::is_cancelled(&req.transfer_id).await { let progress = transfer::TransferProgress { @@ -195,3 +209,30 @@ pub async fn cancel_transfer( transfer::set_cancelled(&req.transfer_id).await; Json(serde_json::json!({ "cancelled": true })) } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SortTablesByFkRequest { + pub connection_id: String, + pub database: String, + pub schema: String, + pub tables: Vec, + pub parents_first: bool, +} + +pub async fn sort_tables_by_fk_dependency( + State(state): State>, + Json(req): Json, +) -> Result>, AppError> { + transfer::sort_tables_by_fk_dependency( + &state.app, + &req.connection_id, + &req.database, + &req.schema, + &req.tables, + req.parents_first, + ) + .await + .map(Json) + .map_err(AppError) +} diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 62b90eedf7..4cd283c6d1 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -454,9 +454,37 @@ pub fn build_export_sql_insert( } #[tauri::command] -pub fn build_database_sql_export( - options: dbx_core::database_export::BuildDatabaseSqlExportOptions, +pub async fn build_database_sql_export( + state: tauri::State<'_, std::sync::Arc>, + mut options: dbx_core::database_export::BuildDatabaseSqlExportOptions, ) -> Result { + // Sort tables by FK dependency when connection info is available. + if let (Some(ref conn_id), Some(ref database), Some(ref schema)) = + (&options.connection_id, &options.database, &options.schema) + { + if options.tables.len() > 1 { + let table_names: Vec = options.tables.iter().filter_map(|t| t.table_name.clone()).collect(); + if table_names.len() > 1 { + if let Ok(sorted_names) = dbx_core::transfer::sort_tables_by_fk_dependency( + &state, + conn_id, + database, + schema, + &table_names, + true, + ) + .await + { + options.tables.sort_by_key(|t| { + sorted_names + .iter() + .position(|n| Some(n.as_str()) == t.table_name.as_deref()) + .unwrap_or(usize::MAX) + }); + } + } + } + } dbx_core::database_export::build_database_sql_export(options) } diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index bdbe9fba26..032f12f754 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -33,7 +33,22 @@ pub async fn start_transfer( state.get_or_create_pool(&request.target_connection_id, Some(&request.target_database)).await?; tokio::spawn(async move { - let total_tables = request.tables.len(); + // Sort tables by FK dependency so referenced tables are transferred first. + let sorted_tables = dbx_core::transfer::sort_tables_by_fk_dependency( + &state, + &request.source_connection_id, + &request.source_database, + &request.source_schema, + &request.tables, + true, + ) + .await + .unwrap_or_else(|e| { + log::warn!("[transfer] failed to sort tables by FK dependency, using original order: {e}"); + request.tables.clone() + }); + + let total_tables = sorted_tables.len(); log::info!("[transfer] starting transfer_id={} tables={}", transfer_id, total_tables); if matches!(source_db_type, dbx_core::models::connection::DatabaseType::Postgres) @@ -86,7 +101,7 @@ pub async fn start_transfer( } } - for (i, table) in request.tables.iter().enumerate() { + for (i, table) in sorted_tables.iter().enumerate() { if dbx_core::transfer::is_cancelled(&transfer_id).await { emit_progress( &app, @@ -249,3 +264,19 @@ pub async fn cancel_transfer(transfer_id: String) -> Result<(), String> { dbx_core::transfer::set_cancelled(&transfer_id).await; Ok(()) } + +/// Sort table names by foreign key dependency. +/// `parents_first: true` → parent tables first (insert/export order). +/// `parents_first: false` → child tables first (drop order). +#[tauri::command] +pub async fn sort_tables_by_fk_dependency( + state: tauri::State<'_, std::sync::Arc>, + connection_id: String, + database: String, + schema: String, + tables: Vec, + parents_first: bool, +) -> Result, String> { + dbx_core::transfer::sort_tables_by_fk_dependency(&state, &connection_id, &database, &schema, &tables, parents_first) + .await +}