vespertide_planner/
lib.rs

1use std::collections::HashMap;
2
3use thiserror::Error;
4use vespertide_core::{IndexDef, MigrationAction, MigrationPlan, TableConstraint, TableDef};
5
6#[derive(Debug, Error)]
7pub enum PlannerError {
8    #[error("table already exists: {0}")]
9    TableExists(String),
10    #[error("table not found: {0}")]
11    TableNotFound(String),
12    #[error("column already exists: {0}.{1}")]
13    ColumnExists(String, String),
14    #[error("column not found: {0}.{1}")]
15    ColumnNotFound(String, String),
16    #[error("index not found: {0}.{1}")]
17    IndexNotFound(String, String),
18}
19
20/// Build the next migration plan given the current schema and existing plans.
21/// The baseline schema is reconstructed from already-applied plans and then
22/// diffed against the target `current` schema.
23pub fn plan_next_migration(
24    current: &[TableDef],
25    applied_plans: &[MigrationPlan],
26) -> Result<MigrationPlan, PlannerError> {
27    let baseline = schema_from_plans(applied_plans)?;
28    let mut plan = diff_schemas(&baseline, current)?;
29
30    let next_version = applied_plans
31        .iter()
32        .map(|p| p.version)
33        .max()
34        .unwrap_or(0)
35        .saturating_add(1);
36    plan.version = next_version;
37    Ok(plan)
38}
39
40/// Derive a schema snapshot from existing migration plans.
41pub fn schema_from_plans(plans: &[MigrationPlan]) -> Result<Vec<TableDef>, PlannerError> {
42    let mut schema: Vec<TableDef> = Vec::new();
43    for plan in plans {
44        for action in &plan.actions {
45            apply_action(&mut schema, action)?;
46        }
47    }
48    Ok(schema)
49}
50
51/// Diff two schema snapshots into a migration plan.
52pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
53    let mut actions: Vec<MigrationAction> = Vec::new();
54
55    let from_map: HashMap<_, _> = from.iter().map(|t| (t.name.as_str(), t)).collect();
56    let to_map: HashMap<_, _> = to.iter().map(|t| (t.name.as_str(), t)).collect();
57
58    // Drop tables that disappeared.
59    for name in from_map.keys() {
60        if !to_map.contains_key(name) {
61            actions.push(MigrationAction::DeleteTable {
62                table: (*name).to_string(),
63            });
64        }
65    }
66
67    // Update existing tables and their indexes/columns.
68    for (name, to_tbl) in &to_map {
69        if let Some(from_tbl) = from_map.get(name) {
70            // Columns
71            let from_cols: HashMap<_, _> = from_tbl
72                .columns
73                .iter()
74                .map(|c| (c.name.as_str(), c))
75                .collect();
76            let to_cols: HashMap<_, _> = to_tbl
77                .columns
78                .iter()
79                .map(|c| (c.name.as_str(), c))
80                .collect();
81
82            // Deleted columns
83            for col in from_cols.keys() {
84                if !to_cols.contains_key(col) {
85                    actions.push(MigrationAction::DeleteColumn {
86                        table: (*name).to_string(),
87                        column: (*col).to_string(),
88                    });
89                }
90            }
91
92            // Modified columns
93            for (col, to_def) in &to_cols {
94                if let Some(from_def) = from_cols.get(col) {
95                    if from_def.r#type != to_def.r#type {
96                        actions.push(MigrationAction::ModifyColumnType {
97                            table: (*name).to_string(),
98                            column: (*col).to_string(),
99                            new_type: to_def.r#type.clone(),
100                        });
101                    }
102                }
103            }
104
105            // Added columns
106            for (col, def) in &to_cols {
107                if !from_cols.contains_key(col) {
108                    actions.push(MigrationAction::AddColumn {
109                        table: (*name).to_string(),
110                        column: (*def).clone(),
111                        fill_with: None,
112                    });
113                }
114            }
115
116            // Indexes
117            let from_indexes: HashMap<_, _> = from_tbl
118                .indexes
119                .iter()
120                .map(|i| (i.name.as_str(), i))
121                .collect();
122            let to_indexes: HashMap<_, _> = to_tbl
123                .indexes
124                .iter()
125                .map(|i| (i.name.as_str(), i))
126                .collect();
127
128            for idx in from_indexes.keys() {
129                if !to_indexes.contains_key(idx) {
130                    actions.push(MigrationAction::RemoveIndex {
131                        table: (*name).to_string(),
132                        name: (*idx).to_string(),
133                    });
134                }
135            }
136            for (idx, def) in &to_indexes {
137                if !from_indexes.contains_key(idx) {
138                    actions.push(MigrationAction::AddIndex {
139                        table: (*name).to_string(),
140                        index: (*def).clone(),
141                    });
142                }
143            }
144        }
145    }
146
147    // Create new tables (and their indexes).
148    for (name, tbl) in &to_map {
149        if !from_map.contains_key(name) {
150            actions.push(MigrationAction::CreateTable {
151                table: tbl.name.clone(),
152                columns: tbl.columns.clone(),
153                constraints: tbl.constraints.clone(),
154            });
155            for idx in &tbl.indexes {
156                actions.push(MigrationAction::AddIndex {
157                    table: tbl.name.clone(),
158                    index: idx.clone(),
159                });
160            }
161        }
162    }
163
164    Ok(MigrationPlan {
165        comment: None,
166        created_at: None,
167        version: 0,
168        actions,
169    })
170}
171
172/// Apply a single migration action to an in-memory schema snapshot.
173pub fn apply_action(
174    schema: &mut Vec<TableDef>,
175    action: &MigrationAction,
176) -> Result<(), PlannerError> {
177    match action {
178        MigrationAction::CreateTable {
179            table,
180            columns,
181            constraints,
182        } => {
183            if schema.iter().any(|t| t.name == *table) {
184                return Err(PlannerError::TableExists(table.clone()));
185            }
186            schema.push(TableDef {
187                name: table.clone(),
188                columns: columns.clone(),
189                constraints: constraints.clone(),
190                indexes: Vec::new(),
191            });
192            Ok(())
193        }
194        MigrationAction::DeleteTable { table } => {
195            let before = schema.len();
196            schema.retain(|t| t.name != *table);
197            if schema.len() == before {
198                return Err(PlannerError::TableNotFound(table.clone()));
199            }
200            Ok(())
201        }
202        MigrationAction::AddColumn {
203            table,
204            column,
205            fill_with: _,
206        } => {
207            let tbl = schema
208                .iter_mut()
209                .find(|t| t.name == *table)
210                .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
211            if tbl.columns.iter().any(|c| c.name == column.name) {
212                return Err(PlannerError::ColumnExists(
213                    table.clone(),
214                    column.name.clone(),
215                ));
216            }
217            tbl.columns.push(column.clone());
218            Ok(())
219        }
220        MigrationAction::RenameColumn { table, from, to } => {
221            let tbl = schema
222                .iter_mut()
223                .find(|t| t.name == *table)
224                .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
225            let col = tbl
226                .columns
227                .iter_mut()
228                .find(|c| c.name == *from)
229                .ok_or_else(|| PlannerError::ColumnNotFound(table.clone(), from.clone()))?;
230            col.name = to.clone();
231            rename_column_in_constraints(&mut tbl.constraints, from, to);
232            rename_column_in_indexes(&mut tbl.indexes, from, to);
233            Ok(())
234        }
235        MigrationAction::DeleteColumn { table, column } => {
236            let tbl = schema
237                .iter_mut()
238                .find(|t| t.name == *table)
239                .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
240            let before = tbl.columns.len();
241            tbl.columns.retain(|c| c.name != *column);
242            if tbl.columns.len() == before {
243                return Err(PlannerError::ColumnNotFound(table.clone(), column.clone()));
244            }
245            drop_column_from_constraints(&mut tbl.constraints, column);
246            drop_column_from_indexes(&mut tbl.indexes, column);
247            Ok(())
248        }
249        MigrationAction::ModifyColumnType {
250            table,
251            column,
252            new_type,
253        } => {
254            let tbl = schema
255                .iter_mut()
256                .find(|t| t.name == *table)
257                .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
258            let col = tbl
259                .columns
260                .iter_mut()
261                .find(|c| c.name == *column)
262                .ok_or_else(|| PlannerError::ColumnNotFound(table.clone(), column.clone()))?;
263            col.r#type = new_type.clone();
264            Ok(())
265        }
266        MigrationAction::AddIndex { table, index } => {
267            let tbl = schema
268                .iter_mut()
269                .find(|t| t.name == *table)
270                .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
271            tbl.indexes.push(index.clone());
272            Ok(())
273        }
274        MigrationAction::RemoveIndex { table, name } => {
275            let tbl = schema
276                .iter_mut()
277                .find(|t| t.name == *table)
278                .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
279            let before = tbl.indexes.len();
280            tbl.indexes.retain(|i| i.name != *name);
281            if tbl.indexes.len() == before {
282                return Err(PlannerError::IndexNotFound(table.clone(), name.clone()));
283            }
284            Ok(())
285        }
286        MigrationAction::RenameTable { from, to } => {
287            if schema.iter().any(|t| t.name == *to) {
288                return Err(PlannerError::TableExists(to.clone()));
289            }
290            let tbl = schema
291                .iter_mut()
292                .find(|t| t.name == *from)
293                .ok_or_else(|| PlannerError::TableNotFound(from.clone()))?;
294            tbl.name = to.clone();
295            Ok(())
296        }
297    }
298}
299
300fn rename_column_in_constraints(constraints: &mut [TableConstraint], from: &str, to: &str) {
301    for constraint in constraints {
302        match constraint {
303            TableConstraint::PrimaryKey(cols) => {
304                for c in cols.iter_mut() {
305                    if c == from {
306                        *c = to.to_string();
307                    }
308                }
309            }
310            TableConstraint::Unique { columns, .. } => {
311                for c in columns.iter_mut() {
312                    if c == from {
313                        *c = to.to_string();
314                    }
315                }
316            }
317            TableConstraint::ForeignKey {
318                columns,
319                ref_columns,
320                ..
321            } => {
322                for c in columns.iter_mut() {
323                    if c == from {
324                        *c = to.to_string();
325                    }
326                }
327                for c in ref_columns.iter_mut() {
328                    if c == from {
329                        *c = to.to_string();
330                    }
331                }
332            }
333            TableConstraint::Check { .. } => {}
334        }
335    }
336}
337
338fn rename_column_in_indexes(indexes: &mut [IndexDef], from: &str, to: &str) {
339    for idx in indexes {
340        for c in idx.columns.iter_mut() {
341            if c == from {
342                *c = to.to_string();
343            }
344        }
345    }
346}
347
348fn drop_column_from_constraints(constraints: &mut Vec<TableConstraint>, column: &str) {
349    constraints.retain_mut(|c| match c {
350        TableConstraint::PrimaryKey(cols) => {
351            cols.retain(|c| c != column);
352            !cols.is_empty()
353        }
354        TableConstraint::Unique { columns, .. } => {
355            columns.retain(|c| c != column);
356            !columns.is_empty()
357        }
358        TableConstraint::ForeignKey {
359            columns,
360            ref_columns,
361            ..
362        } => {
363            columns.retain(|c| c != column);
364            ref_columns.retain(|c| c != column);
365            !columns.is_empty() && !ref_columns.is_empty()
366        }
367        TableConstraint::Check { .. } => true,
368    });
369}
370
371fn drop_column_from_indexes(indexes: &mut Vec<IndexDef>, column: &str) {
372    indexes.retain(|idx| !idx.columns.iter().any(|c| c == column));
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use rstest::rstest;
379    use vespertide_core::{ColumnDef, ColumnType, IndexDef, TableConstraint};
380
381    fn col(name: &str, ty: ColumnType) -> ColumnDef {
382        ColumnDef {
383            name: name.to_string(),
384            r#type: ty,
385            nullable: true,
386            default: None,
387        }
388    }
389
390    fn table(
391        name: &str,
392        columns: Vec<ColumnDef>,
393        constraints: Vec<TableConstraint>,
394        indexes: Vec<IndexDef>,
395    ) -> TableDef {
396        TableDef {
397            name: name.to_string(),
398            columns,
399            constraints,
400            indexes,
401        }
402    }
403
404    #[derive(Debug, Clone, Copy)]
405    enum ErrKind {
406        TableExists,
407        TableNotFound,
408        ColumnExists,
409        ColumnNotFound,
410        IndexNotFound,
411    }
412
413    fn assert_err_kind(err: PlannerError, kind: ErrKind) {
414        match (err, kind) {
415            (PlannerError::TableExists(_), ErrKind::TableExists) => {}
416            (PlannerError::TableNotFound(_), ErrKind::TableNotFound) => {}
417            (PlannerError::ColumnExists(_, _), ErrKind::ColumnExists) => {}
418            (PlannerError::ColumnNotFound(_, _), ErrKind::ColumnNotFound) => {}
419            (PlannerError::IndexNotFound(_, _), ErrKind::IndexNotFound) => {}
420            (other, expected) => panic!("unexpected error {other:?}, expected {:?}", expected),
421        }
422    }
423
424    #[rstest]
425    #[case::create_only(
426        vec![MigrationPlan {
427            comment: None,
428            created_at: None,
429            version: 1,
430            actions: vec![MigrationAction::CreateTable {
431                table: "users".into(),
432                columns: vec![col("id", ColumnType::Integer)],
433                constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])],
434            }],
435        }],
436        table(
437            "users",
438            vec![col("id", ColumnType::Integer)],
439            vec![TableConstraint::PrimaryKey(vec!["id".into()])],
440            vec![],
441        )
442    )]
443    #[case::create_and_add_column(
444        vec![
445            MigrationPlan {
446                comment: None,
447                created_at: None,
448                version: 1,
449                actions: vec![MigrationAction::CreateTable {
450                    table: "users".into(),
451                    columns: vec![col("id", ColumnType::Integer)],
452                    constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])],
453                }],
454            },
455            MigrationPlan {
456                comment: None,
457                created_at: None,
458                version: 2,
459                actions: vec![MigrationAction::AddColumn {
460                    table: "users".into(),
461                    column: col("name", ColumnType::Text),
462                    fill_with: None,
463                }],
464            },
465        ],
466        table(
467            "users",
468            vec![
469                col("id", ColumnType::Integer),
470                col("name", ColumnType::Text),
471            ],
472            vec![TableConstraint::PrimaryKey(vec!["id".into()])],
473            vec![],
474        )
475    )]
476    #[case::create_add_column_and_index(
477        vec![
478            MigrationPlan {
479                comment: None,
480                created_at: None,
481                version: 1,
482                actions: vec![MigrationAction::CreateTable {
483                    table: "users".into(),
484                    columns: vec![col("id", ColumnType::Integer)],
485                    constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])],
486                }],
487            },
488            MigrationPlan {
489                comment: None,
490                created_at: None,
491                version: 2,
492                actions: vec![MigrationAction::AddColumn {
493                    table: "users".into(),
494                    column: col("name", ColumnType::Text),
495                    fill_with: None,
496                }],
497            },
498            MigrationPlan {
499                comment: None,
500                created_at: None,
501                version: 3,
502                actions: vec![MigrationAction::AddIndex {
503                    table: "users".into(),
504                    index: IndexDef {
505                        name: "idx_users_name".into(),
506                        columns: vec!["name".into()],
507                        unique: false,
508                    },
509                }],
510            },
511        ],
512        table(
513            "users",
514            vec![
515                col("id", ColumnType::Integer),
516                col("name", ColumnType::Text),
517            ],
518            vec![TableConstraint::PrimaryKey(vec!["id".into()])],
519            vec![IndexDef {
520                name: "idx_users_name".into(),
521                columns: vec!["name".into()],
522                unique: false,
523            }],
524        )
525    )]
526    fn schema_from_plans_applies_actions(
527        #[case] plans: Vec<MigrationPlan>,
528        #[case] expected_users: TableDef,
529    ) {
530        let schema = schema_from_plans(&plans).unwrap();
531        let users = schema.iter().find(|t| t.name == "users").unwrap();
532        assert_eq!(users, &expected_users);
533    }
534
535    #[rstest]
536    #[case::add_column_and_index(
537        vec![table(
538            "users",
539            vec![col("id", ColumnType::Integer)],
540            vec![],
541            vec![],
542        )],
543        vec![table(
544            "users",
545            vec![
546                col("id", ColumnType::Integer),
547                col("name", ColumnType::Text),
548            ],
549            vec![],
550            vec![IndexDef {
551                name: "idx_users_name".into(),
552                columns: vec!["name".into()],
553                unique: false,
554            }],
555        )],
556        vec![
557            MigrationAction::AddColumn {
558                table: "users".into(),
559                column: col("name", ColumnType::Text),
560                fill_with: None,
561            },
562            MigrationAction::AddIndex {
563                table: "users".into(),
564                index: IndexDef {
565                    name: "idx_users_name".into(),
566                    columns: vec!["name".into()],
567                    unique: false,
568                },
569            },
570        ]
571    )]
572    #[case::drop_table(
573        vec![table(
574            "users",
575            vec![col("id", ColumnType::Integer)],
576            vec![],
577            vec![],
578        )],
579        vec![],
580        vec![MigrationAction::DeleteTable {
581            table: "users".into()
582        }]
583    )]
584    #[case::add_table(
585        vec![],
586        vec![table(
587            "users",
588            vec![col("id", ColumnType::Integer)],
589            vec![],
590            vec![IndexDef {
591                name: "idx_users_id".into(),
592                columns: vec!["id".into()],
593                unique: true,
594            }],
595        )],
596        vec![
597            MigrationAction::CreateTable {
598                table: "users".into(),
599                columns: vec![col("id", ColumnType::Integer)],
600                constraints: vec![],
601            },
602            MigrationAction::AddIndex {
603                table: "users".into(),
604                index: IndexDef {
605                    name: "idx_users_id".into(),
606                    columns: vec!["id".into()],
607                    unique: true,
608                },
609            },
610        ]
611    )]
612    fn diff_schemas_detects_additions(
613        #[case] from_schema: Vec<TableDef>,
614        #[case] to_schema: Vec<TableDef>,
615        #[case] expected_actions: Vec<MigrationAction>,
616    ) {
617        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
618        assert_eq!(plan.actions, expected_actions);
619    }
620
621    #[rstest]
622    fn plan_next_migration_sets_next_version() {
623        let applied = vec![MigrationPlan {
624            comment: None,
625            created_at: None,
626            version: 1,
627            actions: vec![MigrationAction::CreateTable {
628                table: "users".into(),
629                columns: vec![col("id", ColumnType::Integer)],
630                constraints: vec![],
631            }],
632        }];
633
634        let target_schema = vec![table(
635            "users",
636            vec![
637                col("id", ColumnType::Integer),
638                col("name", ColumnType::Text),
639            ],
640            vec![],
641            vec![],
642        )];
643
644        let plan = plan_next_migration(&target_schema, &applied).unwrap();
645        assert_eq!(plan.version, 2);
646        assert!(plan.actions.iter().any(
647            |a| matches!(a, MigrationAction::AddColumn { column, .. } if column.name == "name")
648        ));
649    }
650
651    #[rstest]
652    #[case(
653        vec![table("users", vec![], vec![], vec![])],
654        MigrationAction::CreateTable {
655            table: "users".into(),
656            columns: vec![],
657            constraints: vec![],
658        },
659        ErrKind::TableExists
660    )]
661    #[case(
662        vec![],
663        MigrationAction::DeleteTable {
664            table: "users".into()
665        },
666        ErrKind::TableNotFound
667    )]
668    #[case(
669        vec![table(
670            "users",
671            vec![col("id", ColumnType::Integer)],
672            vec![],
673            vec![]
674        )],
675        MigrationAction::AddColumn {
676            table: "users".into(),
677            column: col("id", ColumnType::Integer),
678            fill_with: None,
679        },
680        ErrKind::ColumnExists
681    )]
682    #[case(
683        vec![table(
684            "users",
685            vec![col("id", ColumnType::Integer)],
686            vec![],
687            vec![]
688        )],
689        MigrationAction::DeleteColumn {
690            table: "users".into(),
691            column: "missing".into()
692        },
693        ErrKind::ColumnNotFound
694    )]
695    #[case(
696        vec![table(
697            "users",
698            vec![col("id", ColumnType::Integer)],
699            vec![],
700            vec![]
701        )],
702        MigrationAction::RemoveIndex {
703            table: "users".into(),
704            name: "idx".into()
705        },
706        ErrKind::IndexNotFound
707    )]
708    fn apply_action_reports_errors(
709        #[case] mut schema: Vec<TableDef>,
710        #[case] action: MigrationAction,
711        #[case] expected: ErrKind,
712    ) {
713        let err = apply_action(&mut schema, &action).unwrap_err();
714        assert_err_kind(err, expected);
715    }
716}