Skip to main content

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