Skip to content

Commit 678d7e6

Browse files
authored
Merge pull request #75 from dev-five-git/rm-column-fk-issue
Fix fk removal issue
2 parents 8c1caf1 + dc9c3a4 commit 678d7e6

3 files changed

Lines changed: 231 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"crates/vespertide/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch"},"note":"Fix duplicated foreign key removal when a column is dropped","date":"2026-01-19T05:58:18.303172700Z"}

crates/vespertide-core/src/schema/constraint.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,17 @@ pub enum TableConstraint {
3838
columns: Vec<ColumnName>,
3939
},
4040
}
41+
42+
impl TableConstraint {
43+
/// Returns the columns referenced by this constraint.
44+
/// For Check constraints, returns an empty slice (expression-based, not column-based).
45+
pub fn columns(&self) -> &[ColumnName] {
46+
match self {
47+
TableConstraint::PrimaryKey { columns, .. } => columns,
48+
TableConstraint::Unique { columns, .. } => columns,
49+
TableConstraint::ForeignKey { columns, .. } => columns,
50+
TableConstraint::Index { columns, .. } => columns,
51+
TableConstraint::Check { .. } => &[],
52+
}
53+
}
54+
}

crates/vespertide-planner/src/diff.rs

Lines changed: 216 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -286,14 +286,18 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan,
286286
.map(|c| (c.name.as_str(), c))
287287
.collect();
288288

289-
// Deleted columns
290-
for col in from_cols.keys() {
291-
if !to_cols.contains_key(col) {
292-
actions.push(MigrationAction::DeleteColumn {
293-
table: name.to_string(),
294-
column: col.to_string(),
295-
});
296-
}
289+
// Deleted columns - collect the set of columns being deleted for this table
290+
let deleted_columns: BTreeSet<&str> = from_cols
291+
.keys()
292+
.filter(|col| !to_cols.contains_key(*col))
293+
.copied()
294+
.collect();
295+
296+
for col in &deleted_columns {
297+
actions.push(MigrationAction::DeleteColumn {
298+
table: name.to_string(),
299+
column: col.to_string(),
300+
});
297301
}
298302

299303
// Modified columns - type changes
@@ -365,12 +369,25 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan,
365369
}
366370

367371
// Constraints - compare and detect additions/removals (includes indexes)
372+
// Skip RemoveConstraint for constraints where ALL columns are being deleted
373+
// (the constraint will be automatically dropped when the column is dropped)
368374
for from_constraint in &from_tbl.constraints {
369375
if !to_tbl.constraints.contains(from_constraint) {
370-
actions.push(MigrationAction::RemoveConstraint {
371-
table: name.to_string(),
372-
constraint: from_constraint.clone(),
373-
});
376+
// Get the columns referenced by this constraint
377+
let constraint_columns = from_constraint.columns();
378+
379+
// Skip if ALL columns of the constraint are being deleted
380+
let all_columns_deleted = !constraint_columns.is_empty()
381+
&& constraint_columns
382+
.iter()
383+
.all(|col| deleted_columns.contains(col.as_str()));
384+
385+
if !all_columns_deleted {
386+
actions.push(MigrationAction::RemoveConstraint {
387+
table: name.to_string(),
388+
constraint: from_constraint.clone(),
389+
});
390+
}
374391
}
375392
}
376393
for to_constraint in &to_tbl.constraints {
@@ -3212,4 +3229,191 @@ mod tests {
32123229
));
32133230
}
32143231
}
3232+
3233+
mod constraint_removal_on_deleted_columns {
3234+
use super::*;
3235+
3236+
fn fk(columns: Vec<&str>, ref_table: &str, ref_columns: Vec<&str>) -> TableConstraint {
3237+
TableConstraint::ForeignKey {
3238+
name: None,
3239+
columns: columns.into_iter().map(|s| s.to_string()).collect(),
3240+
ref_table: ref_table.to_string(),
3241+
ref_columns: ref_columns.into_iter().map(|s| s.to_string()).collect(),
3242+
on_delete: None,
3243+
on_update: None,
3244+
}
3245+
}
3246+
3247+
#[test]
3248+
fn skip_remove_constraint_when_all_columns_deleted() {
3249+
// When a column with FK and index is deleted, the constraints should NOT
3250+
// generate separate RemoveConstraint actions (they are dropped with the column)
3251+
let from = vec![table(
3252+
"project",
3253+
vec![
3254+
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3255+
col("template_id", ColumnType::Simple(SimpleColumnType::Integer)),
3256+
],
3257+
vec![
3258+
fk(vec!["template_id"], "book_template", vec!["id"]),
3259+
idx("ix_project__template_id", vec!["template_id"]),
3260+
],
3261+
)];
3262+
3263+
let to = vec![table(
3264+
"project",
3265+
vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3266+
vec![],
3267+
)];
3268+
3269+
let plan = diff_schemas(&from, &to).unwrap();
3270+
3271+
// Should only have DeleteColumn, NO RemoveConstraint actions
3272+
assert_eq!(plan.actions.len(), 1);
3273+
assert!(matches!(
3274+
&plan.actions[0],
3275+
MigrationAction::DeleteColumn { table, column }
3276+
if table == "project" && column == "template_id"
3277+
));
3278+
3279+
// Explicitly verify no RemoveConstraint
3280+
let has_remove_constraint = plan
3281+
.actions
3282+
.iter()
3283+
.any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3284+
assert!(
3285+
!has_remove_constraint,
3286+
"Should NOT have RemoveConstraint when column is deleted"
3287+
);
3288+
}
3289+
3290+
#[test]
3291+
fn keep_remove_constraint_when_only_some_columns_deleted() {
3292+
// If a composite constraint has some columns remaining, RemoveConstraint is needed
3293+
let from = vec![table(
3294+
"orders",
3295+
vec![
3296+
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3297+
col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3298+
col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3299+
],
3300+
vec![idx(
3301+
"ix_orders__user_product",
3302+
vec!["user_id", "product_id"],
3303+
)],
3304+
)];
3305+
3306+
let to = vec![table(
3307+
"orders",
3308+
vec![
3309+
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3310+
col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3311+
// product_id is deleted, but user_id remains
3312+
],
3313+
vec![],
3314+
)];
3315+
3316+
let plan = diff_schemas(&from, &to).unwrap();
3317+
3318+
// Should have both DeleteColumn AND RemoveConstraint
3319+
// (because user_id is still there, the composite index needs explicit removal)
3320+
let has_delete_column = plan.actions.iter().any(|a| {
3321+
matches!(
3322+
a,
3323+
MigrationAction::DeleteColumn { table, column }
3324+
if table == "orders" && column == "product_id"
3325+
)
3326+
});
3327+
assert!(has_delete_column, "Should have DeleteColumn for product_id");
3328+
3329+
let has_remove_constraint = plan.actions.iter().any(|a| {
3330+
matches!(
3331+
a,
3332+
MigrationAction::RemoveConstraint { table, .. }
3333+
if table == "orders"
3334+
)
3335+
});
3336+
assert!(
3337+
has_remove_constraint,
3338+
"Should have RemoveConstraint for composite index when only some columns deleted"
3339+
);
3340+
}
3341+
3342+
#[test]
3343+
fn skip_remove_constraint_when_all_composite_columns_deleted() {
3344+
// If ALL columns of a composite constraint are deleted, skip RemoveConstraint
3345+
let from = vec![table(
3346+
"orders",
3347+
vec![
3348+
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3349+
col("user_id", ColumnType::Simple(SimpleColumnType::Integer)),
3350+
col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
3351+
],
3352+
vec![idx(
3353+
"ix_orders__user_product",
3354+
vec!["user_id", "product_id"],
3355+
)],
3356+
)];
3357+
3358+
let to = vec![table(
3359+
"orders",
3360+
vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
3361+
vec![],
3362+
)];
3363+
3364+
let plan = diff_schemas(&from, &to).unwrap();
3365+
3366+
// Should only have DeleteColumn actions, no RemoveConstraint
3367+
let delete_columns: Vec<_> = plan
3368+
.actions
3369+
.iter()
3370+
.filter(|a| matches!(a, MigrationAction::DeleteColumn { .. }))
3371+
.collect();
3372+
assert_eq!(
3373+
delete_columns.len(),
3374+
2,
3375+
"Should have 2 DeleteColumn actions"
3376+
);
3377+
3378+
let has_remove_constraint = plan
3379+
.actions
3380+
.iter()
3381+
.any(|a| matches!(a, MigrationAction::RemoveConstraint { .. }));
3382+
assert!(
3383+
!has_remove_constraint,
3384+
"Should NOT have RemoveConstraint when all composite columns deleted"
3385+
);
3386+
}
3387+
3388+
#[test]
3389+
fn keep_remove_constraint_when_no_columns_deleted() {
3390+
// Normal case: constraint removed but columns remain
3391+
let from = vec![table(
3392+
"users",
3393+
vec![
3394+
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3395+
col("email", ColumnType::Simple(SimpleColumnType::Text)),
3396+
],
3397+
vec![idx("ix_users__email", vec!["email"])],
3398+
)];
3399+
3400+
let to = vec![table(
3401+
"users",
3402+
vec![
3403+
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
3404+
col("email", ColumnType::Simple(SimpleColumnType::Text)),
3405+
],
3406+
vec![], // Index removed but column remains
3407+
)];
3408+
3409+
let plan = diff_schemas(&from, &to).unwrap();
3410+
3411+
assert_eq!(plan.actions.len(), 1);
3412+
assert!(matches!(
3413+
&plan.actions[0],
3414+
MigrationAction::RemoveConstraint { table, .. }
3415+
if table == "users"
3416+
));
3417+
}
3418+
}
32153419
}

0 commit comments

Comments
 (0)