vespertide_planner/
diff.rs

1use std::collections::HashMap;
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableDef};
4
5use crate::error::PlannerError;
6
7/// Diff two schema snapshots into a migration plan.
8pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
9    let mut actions: Vec<MigrationAction> = Vec::new();
10
11    let from_map: HashMap<_, _> = from.iter().map(|t| (t.name.as_str(), t)).collect();
12    let to_map: HashMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
13
14    // Drop tables that disappeared.
15    for name in from_map.keys() {
16        if !to_map.contains_key(name) {
17            actions.push(MigrationAction::DeleteTable {
18                table: (*name).to_string(),
19            });
20        }
21    }
22
23    // Update existing tables and their indexes/columns.
24    for (name, to_tbl) in &to_map {
25        if let Some(from_tbl) = from_map.get(name) {
26            // Columns
27            let from_cols: HashMap<_, _> = from_tbl
28                .columns
29                .iter()
30                .map(|c| (c.name.as_str(), c))
31                .collect();
32            let to_cols: HashMap<_, _> = to_tbl
33                .columns
34                .iter()
35                .map(|c| (c.name.as_str(), c))
36                .collect();
37
38            // Deleted columns
39            for col in from_cols.keys() {
40                if !to_cols.contains_key(col) {
41                    actions.push(MigrationAction::DeleteColumn {
42                        table: (*name).to_string(),
43                        column: (*col).to_string(),
44                    });
45                }
46            }
47
48            // Modified columns
49            for (col, to_def) in &to_cols {
50                if let Some(from_def) = from_cols.get(col)
51                    && from_def.r#type != to_def.r#type
52                {
53                    actions.push(MigrationAction::ModifyColumnType {
54                        table: (*name).to_string(),
55                        column: (*col).to_string(),
56                        new_type: to_def.r#type.clone(),
57                    });
58                }
59            }
60
61            // Added columns
62            for (col, def) in &to_cols {
63                if !from_cols.contains_key(col) {
64                    actions.push(MigrationAction::AddColumn {
65                        table: (*name).to_string(),
66                        column: (*def).clone(),
67                        fill_with: None,
68                    });
69                }
70            }
71
72            // Indexes
73            let from_indexes: HashMap<_, _> = from_tbl
74                .indexes
75                .iter()
76                .map(|i| (i.name.as_str(), i))
77                .collect();
78            let to_indexes: HashMap<_, _> = to_tbl
79                .indexes
80                .iter()
81                .map(|i| (i.name.as_str(), i))
82                .collect();
83
84            for idx in from_indexes.keys() {
85                if !to_indexes.contains_key(idx) {
86                    actions.push(MigrationAction::RemoveIndex {
87                        table: (*name).to_string(),
88                        name: (*idx).to_string(),
89                    });
90                }
91            }
92            for (idx, def) in &to_indexes {
93                if !from_indexes.contains_key(idx) {
94                    actions.push(MigrationAction::AddIndex {
95                        table: (*name).to_string(),
96                        index: (*def).clone(),
97                    });
98                }
99            }
100        }
101    }
102
103    // Create new tables (and their indexes).
104    for (name, tbl) in &to_map {
105        if !from_map.contains_key(name) {
106            actions.push(MigrationAction::CreateTable {
107                table: tbl.name.clone(),
108                columns: tbl.columns.clone(),
109                constraints: tbl.constraints.clone(),
110            });
111            for idx in &tbl.indexes {
112                actions.push(MigrationAction::AddIndex {
113                    table: tbl.name.clone(),
114                    index: idx.clone(),
115                });
116            }
117        }
118    }
119
120    Ok(MigrationPlan {
121        comment: None,
122        created_at: None,
123        version: 0,
124        actions,
125    })
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use rstest::rstest;
132    use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction};
133
134    fn col(name: &str, ty: ColumnType) -> ColumnDef {
135        ColumnDef {
136            name: name.to_string(),
137            r#type: ty,
138            nullable: true,
139            default: None,
140        }
141    }
142
143    fn table(
144        name: &str,
145        columns: Vec<ColumnDef>,
146        constraints: Vec<vespertide_core::TableConstraint>,
147        indexes: Vec<IndexDef>,
148    ) -> TableDef {
149        TableDef {
150            name: name.to_string(),
151            columns,
152            constraints,
153            indexes,
154        }
155    }
156
157    #[rstest]
158    #[case::add_column_and_index(
159        vec![table(
160            "users",
161            vec![col("id", ColumnType::Integer)],
162            vec![],
163            vec![],
164        )],
165        vec![table(
166            "users",
167            vec![
168                col("id", ColumnType::Integer),
169                col("name", ColumnType::Text),
170            ],
171            vec![],
172            vec![IndexDef {
173                name: "idx_users_name".into(),
174                columns: vec!["name".into()],
175                unique: false,
176            }],
177        )],
178        vec![
179            MigrationAction::AddColumn {
180                table: "users".into(),
181                column: col("name", ColumnType::Text),
182                fill_with: None,
183            },
184            MigrationAction::AddIndex {
185                table: "users".into(),
186                index: IndexDef {
187                    name: "idx_users_name".into(),
188                    columns: vec!["name".into()],
189                    unique: false,
190                },
191            },
192        ]
193    )]
194    #[case::drop_table(
195        vec![table(
196            "users",
197            vec![col("id", ColumnType::Integer)],
198            vec![],
199            vec![],
200        )],
201        vec![],
202        vec![MigrationAction::DeleteTable {
203            table: "users".into()
204        }]
205    )]
206    #[case::add_table(
207        vec![],
208        vec![table(
209            "users",
210            vec![col("id", ColumnType::Integer)],
211            vec![],
212            vec![IndexDef {
213                name: "idx_users_id".into(),
214                columns: vec!["id".into()],
215                unique: true,
216            }],
217        )],
218        vec![
219            MigrationAction::CreateTable {
220                table: "users".into(),
221                columns: vec![col("id", ColumnType::Integer)],
222                constraints: vec![],
223            },
224            MigrationAction::AddIndex {
225                table: "users".into(),
226                index: IndexDef {
227                    name: "idx_users_id".into(),
228                    columns: vec!["id".into()],
229                    unique: true,
230                },
231            },
232        ]
233    )]
234    #[case::delete_column(
235        vec![table(
236            "users",
237            vec![col("id", ColumnType::Integer), col("name", ColumnType::Text)],
238            vec![],
239            vec![],
240        )],
241        vec![table(
242            "users",
243            vec![col("id", ColumnType::Integer)],
244            vec![],
245            vec![],
246        )],
247        vec![MigrationAction::DeleteColumn {
248            table: "users".into(),
249            column: "name".into(),
250        }]
251    )]
252    #[case::modify_column_type(
253        vec![table(
254            "users",
255            vec![col("id", ColumnType::Integer)],
256            vec![],
257            vec![],
258        )],
259        vec![table(
260            "users",
261            vec![col("id", ColumnType::Text)],
262            vec![],
263            vec![],
264        )],
265        vec![MigrationAction::ModifyColumnType {
266            table: "users".into(),
267            column: "id".into(),
268            new_type: ColumnType::Text,
269        }]
270    )]
271    #[case::remove_index(
272        vec![table(
273            "users",
274            vec![col("id", ColumnType::Integer)],
275            vec![],
276            vec![IndexDef {
277                name: "idx_users_id".into(),
278                columns: vec!["id".into()],
279                unique: false,
280            }],
281        )],
282        vec![table(
283            "users",
284            vec![col("id", ColumnType::Integer)],
285            vec![],
286            vec![],
287        )],
288        vec![MigrationAction::RemoveIndex {
289            table: "users".into(),
290            name: "idx_users_id".into(),
291        }]
292    )]
293    #[case::add_index_existing_table(
294        vec![table(
295            "users",
296            vec![col("id", ColumnType::Integer)],
297            vec![],
298            vec![],
299        )],
300        vec![table(
301            "users",
302            vec![col("id", ColumnType::Integer)],
303            vec![],
304            vec![IndexDef {
305                name: "idx_users_id".into(),
306                columns: vec!["id".into()],
307                unique: true,
308            }],
309        )],
310        vec![MigrationAction::AddIndex {
311            table: "users".into(),
312            index: IndexDef {
313                name: "idx_users_id".into(),
314                columns: vec!["id".into()],
315                unique: true,
316            },
317        }]
318    )]
319    fn diff_schemas_detects_additions(
320        #[case] from_schema: Vec<TableDef>,
321        #[case] to_schema: Vec<TableDef>,
322        #[case] expected_actions: Vec<MigrationAction>,
323    ) {
324        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
325        assert_eq!(plan.actions, expected_actions);
326    }
327}