vespertide_planner/
schema.rs

1use vespertide_core::{MigrationPlan, TableDef};
2
3use crate::apply::apply_action;
4use crate::error::PlannerError;
5
6/// Derive a schema snapshot from existing migration plans.
7pub fn schema_from_plans(plans: &[MigrationPlan]) -> Result<Vec<TableDef>, PlannerError> {
8    let mut schema: Vec<TableDef> = Vec::new();
9    for plan in plans {
10        for action in &plan.actions {
11            apply_action(&mut schema, action)?;
12        }
13    }
14    Ok(schema)
15}
16
17#[cfg(test)]
18mod tests {
19    use super::*;
20    use rstest::rstest;
21    use vespertide_core::{
22        ColumnDef, ColumnType, MigrationAction, SimpleColumnType, TableConstraint,
23    };
24
25    fn col(name: &str, ty: ColumnType) -> ColumnDef {
26        ColumnDef {
27            name: name.to_string(),
28            r#type: ty,
29            nullable: true,
30            default: None,
31            comment: None,
32            primary_key: None,
33            unique: None,
34            index: None,
35            foreign_key: None,
36        }
37    }
38
39    fn table(name: &str, columns: Vec<ColumnDef>, constraints: Vec<TableConstraint>) -> TableDef {
40        TableDef {
41            name: name.to_string(),
42            columns,
43            constraints,
44        }
45    }
46
47    #[rstest]
48    #[case::create_only(
49        vec![MigrationPlan {
50            comment: None,
51            created_at: None,
52            version: 1,
53            actions: vec![MigrationAction::CreateTable {
54                table: "users".into(),
55                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
56                constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
57            }],
58        }],
59        table(
60            "users",
61            vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
62            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
63        )
64    )]
65    #[case::create_and_add_column(
66        vec![
67            MigrationPlan {
68                comment: None,
69                created_at: None,
70                version: 1,
71                actions: vec![MigrationAction::CreateTable {
72                    table: "users".into(),
73                    columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
74                    constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
75                }],
76            },
77            MigrationPlan {
78                comment: None,
79                created_at: None,
80                version: 2,
81                actions: vec![MigrationAction::AddColumn {
82                    table: "users".into(),
83                    column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
84                    fill_with: None,
85                }],
86            },
87        ],
88        table(
89            "users",
90            vec![
91                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
92                col("name", ColumnType::Simple(SimpleColumnType::Text)),
93            ],
94            vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
95        )
96    )]
97    #[case::create_add_column_and_index(
98        vec![
99            MigrationPlan {
100                comment: None,
101                created_at: None,
102                version: 1,
103                actions: vec![MigrationAction::CreateTable {
104                    table: "users".into(),
105                    columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
106                    constraints: vec![TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] }],
107                }],
108            },
109            MigrationPlan {
110                comment: None,
111                created_at: None,
112                version: 2,
113                actions: vec![MigrationAction::AddColumn {
114                    table: "users".into(),
115                    column: Box::new(col("name", ColumnType::Simple(SimpleColumnType::Text))),
116                    fill_with: None,
117                }],
118            },
119            MigrationPlan {
120                comment: None,
121                created_at: None,
122                version: 3,
123                actions: vec![MigrationAction::AddConstraint {
124                    table: "users".into(),
125                    constraint: TableConstraint::Index {
126                        name: Some("ix_users__name".into()),
127                        columns: vec!["name".into()],
128                    },
129                }],
130            },
131        ],
132        table(
133            "users",
134            vec![
135                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
136                col("name", ColumnType::Simple(SimpleColumnType::Text)),
137            ],
138            vec![
139                TableConstraint::PrimaryKey{ auto_increment: false, columns: vec!["id".into()] },
140                TableConstraint::Index {
141                    name: Some("ix_users__name".into()),
142                    columns: vec!["name".into()],
143                },
144            ],
145        )
146    )]
147    fn schema_from_plans_applies_actions(
148        #[case] plans: Vec<MigrationPlan>,
149        #[case] expected_users: TableDef,
150    ) {
151        let schema = schema_from_plans(&plans).unwrap();
152        let users = schema.iter().find(|t| t.name == "users").unwrap();
153        assert_eq!(users, &expected_users);
154    }
155
156    /// Test that RemoveConstraint works when table was created with both
157    /// inline unique column AND table-level unique constraint for the same column
158    #[test]
159    fn remove_constraint_with_inline_and_table_level_unique() {
160        use vespertide_core::StrOrBoolOrArray;
161
162        // Simulate migration 0001: CreateTable with both inline unique and table-level constraint
163        let create_plan = MigrationPlan {
164            comment: None,
165            created_at: None,
166            version: 1,
167            actions: vec![MigrationAction::CreateTable {
168                table: "users".into(),
169                columns: vec![ColumnDef {
170                    name: "email".into(),
171                    r#type: ColumnType::Simple(SimpleColumnType::Text),
172                    nullable: false,
173                    default: None,
174                    comment: None,
175                    primary_key: None,
176                    unique: Some(StrOrBoolOrArray::Bool(true)), // inline unique
177                    index: None,
178                    foreign_key: None,
179                }],
180                constraints: vec![TableConstraint::Unique {
181                    name: None,
182                    columns: vec!["email".into()],
183                }], // table-level unique (duplicate!)
184            }],
185        };
186
187        // Migration 0002: RemoveConstraint
188        let remove_plan = MigrationPlan {
189            comment: None,
190            created_at: None,
191            version: 2,
192            actions: vec![MigrationAction::RemoveConstraint {
193                table: "users".into(),
194                constraint: TableConstraint::Unique {
195                    name: None,
196                    columns: vec!["email".into()],
197                },
198            }],
199        };
200
201        let schema = schema_from_plans(&[create_plan, remove_plan]).unwrap();
202        let users = schema.iter().find(|t| t.name == "users").unwrap();
203
204        println!("Constraints after apply: {:?}", users.constraints);
205        println!("Column unique field: {:?}", users.columns[0].unique);
206
207        // After apply_action:
208        // - constraints is empty (RemoveConstraint removed the table-level one)
209        // - but column still has unique: Some(Bool(true))!
210
211        // Now simulate what diff_schemas does - it normalizes the baseline
212        let normalized = users.clone().normalize().unwrap();
213        println!("Constraints after normalize: {:?}", normalized.constraints);
214
215        // After normalize:
216        // - inline unique (column.unique = true) is converted to table-level constraint
217        // - So we'd still have one unique constraint!
218
219        // This is the bug: diff_schemas normalizes both baseline and target,
220        // but the baseline still has inline unique that gets re-added.
221        assert!(
222            normalized.constraints.is_empty(),
223            "Expected no constraints after normalize, but got: {:?}",
224            normalized.constraints
225        );
226    }
227}