fix: sort tables by FK dependency in transfer and export
This commit is contained in:
1 parent
71db24813e
commit
c4c427fb67
11 files changed
+292
-6
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user