mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-22 19:58:51 +00:00
fix: sort tables by FK dependency in transfer and export
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface BuildDatabaseSqlExportOptions {
|
||||
tables: ExportedTableSql[];
|
||||
rowLimitPerTable?: number;
|
||||
insertBatchSize?: number;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
export interface BuildExportInsertStatementsOptions {
|
||||
|
||||
@@ -1056,6 +1056,18 @@ export async function cancelTransfer(transferId: string): Promise<void> {
|
||||
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<string[]> {
|
||||
return post("/api/transfer/sort-tables-by-fk", options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table File Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1479,6 +1479,24 @@ export async function cancelTransfer(transferId: string): Promise<void> {
|
||||
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<string[]> {
|
||||
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";
|
||||
|
||||
@@ -119,6 +119,15 @@ pub struct BuildDatabaseSqlExportOptions {
|
||||
pub row_limit_per_table: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub insert_batch_size: Option<usize>,
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
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<String> = 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();
|
||||
|
||||
|
||||
@@ -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<Vec<String>, 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<String, Vec<String>> = HashMap::new();
|
||||
for table in tables {
|
||||
let fks = crate::schema::list_foreign_keys_core(state, connection_id, database, schema, table).await?;
|
||||
let deps: Vec<String> = 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<String> = 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<String> = 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<F>(
|
||||
state: &AppState,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -702,7 +702,36 @@ pub async fn build_export_sql_insert(Json(req): Json<BuildExportSqlInsertRequest
|
||||
}
|
||||
|
||||
pub async fn build_database_sql_export(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<BuildDatabaseSqlExportRequest>,
|
||||
) -> Result<Json<String>, 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<String> = 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)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub parents_first: bool,
|
||||
}
|
||||
|
||||
pub async fn sort_tables_by_fk_dependency(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<SortTablesByFkRequest>,
|
||||
) -> Result<Json<Vec<String>>, 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)
|
||||
}
|
||||
|
||||
@@ -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<dbx_core::connection::AppState>>,
|
||||
mut options: dbx_core::database_export::BuildDatabaseSqlExportOptions,
|
||||
) -> Result<String, String> {
|
||||
// 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<String> = 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
tables: Vec<String>,
|
||||
parents_first: bool,
|
||||
) -> Result<Vec<String>, String> {
|
||||
dbx_core::transfer::sort_tables_by_fk_dependency(&state, &connection_id, &database, &schema, &tables, parents_first)
|
||||
.await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user