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.
8/// Both schemas are normalized to convert inline column constraints
9/// (primary_key, unique, index, foreign_key) to table-level constraints.
10pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
11    let mut actions: Vec<MigrationAction> = Vec::new();
12
13    // Normalize both schemas to ensure inline constraints are converted to table-level
14    let from_normalized: Vec<TableDef> = from
15        .iter()
16        .map(|t| {
17            t.normalize().map_err(|e| {
18                PlannerError::TableValidation(format!("Failed to normalize table '{}': {}", t.name, e))
19            })
20        })
21        .collect::<Result<Vec<_>, _>>()?;
22    let to_normalized: Vec<TableDef> = to
23        .iter()
24        .map(|t| {
25            t.normalize().map_err(|e| {
26                PlannerError::TableValidation(format!("Failed to normalize table '{}': {}", t.name, e))
27            })
28        })
29        .collect::<Result<Vec<_>, _>>()?;
30
31    let from_map: HashMap<_, _> = from_normalized
32        .iter()
33        .map(|t| (t.name.as_str(), t))
34        .collect();
35    let to_map: HashMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
36
37    // Drop tables that disappeared.
38    for name in from_map.keys() {
39        if !to_map.contains_key(name) {
40            actions.push(MigrationAction::DeleteTable {
41                table: (*name).to_string(),
42            });
43        }
44    }
45
46    // Update existing tables and their indexes/columns.
47    for (name, to_tbl) in &to_map {
48        if let Some(from_tbl) = from_map.get(name) {
49            // Columns
50            let from_cols: HashMap<_, _> = from_tbl
51                .columns
52                .iter()
53                .map(|c| (c.name.as_str(), c))
54                .collect();
55            let to_cols: HashMap<_, _> = to_tbl
56                .columns
57                .iter()
58                .map(|c| (c.name.as_str(), c))
59                .collect();
60
61            // Deleted columns
62            for col in from_cols.keys() {
63                if !to_cols.contains_key(col) {
64                    actions.push(MigrationAction::DeleteColumn {
65                        table: (*name).to_string(),
66                        column: (*col).to_string(),
67                    });
68                }
69            }
70
71            // Modified columns
72            for (col, to_def) in &to_cols {
73                if let Some(from_def) = from_cols.get(col)
74                    && from_def.r#type != to_def.r#type
75                {
76                    actions.push(MigrationAction::ModifyColumnType {
77                        table: (*name).to_string(),
78                        column: (*col).to_string(),
79                        new_type: to_def.r#type.clone(),
80                    });
81                }
82            }
83
84            // Added columns
85            for (col, def) in &to_cols {
86                if !from_cols.contains_key(col) {
87                    actions.push(MigrationAction::AddColumn {
88                        table: (*name).to_string(),
89                        column: (*def).clone(),
90                        fill_with: None,
91                    });
92                }
93            }
94
95            // Indexes
96            let from_indexes: HashMap<_, _> = from_tbl
97                .indexes
98                .iter()
99                .map(|i| (i.name.as_str(), i))
100                .collect();
101            let to_indexes: HashMap<_, _> = to_tbl
102                .indexes
103                .iter()
104                .map(|i| (i.name.as_str(), i))
105                .collect();
106
107            for idx in from_indexes.keys() {
108                if !to_indexes.contains_key(idx) {
109                    actions.push(MigrationAction::RemoveIndex {
110                        table: (*name).to_string(),
111                        name: (*idx).to_string(),
112                    });
113                }
114            }
115            for (idx, def) in &to_indexes {
116                if !from_indexes.contains_key(idx) {
117                    actions.push(MigrationAction::AddIndex {
118                        table: (*name).to_string(),
119                        index: (*def).clone(),
120                    });
121                }
122            }
123
124            // Constraints - compare and detect additions/removals
125            for from_constraint in &from_tbl.constraints {
126                if !to_tbl.constraints.contains(from_constraint) {
127                    actions.push(MigrationAction::RemoveConstraint {
128                        table: (*name).to_string(),
129                        constraint: from_constraint.clone(),
130                    });
131                }
132            }
133            for to_constraint in &to_tbl.constraints {
134                if !from_tbl.constraints.contains(to_constraint) {
135                    actions.push(MigrationAction::AddConstraint {
136                        table: (*name).to_string(),
137                        constraint: to_constraint.clone(),
138                    });
139            }
140        }
141    }
142}
143
144    // Create new tables (and their indexes).
145    for (name, tbl) in &to_map {
146        if !from_map.contains_key(name) {
147            actions.push(MigrationAction::CreateTable {
148                table: tbl.name.clone(),
149                columns: tbl.columns.clone(),
150                constraints: tbl.constraints.clone(),
151            });
152            for idx in &tbl.indexes {
153                actions.push(MigrationAction::AddIndex {
154                    table: tbl.name.clone(),
155                    index: idx.clone(),
156                });
157            }
158        }
159    }
160
161    Ok(MigrationPlan {
162        comment: None,
163        created_at: None,
164        version: 0,
165        actions,
166    })
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use rstest::rstest;
173    use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction};
174
175    fn col(name: &str, ty: ColumnType) -> ColumnDef {
176        ColumnDef {
177            name: name.to_string(),
178            r#type: ty,
179            nullable: true,
180            default: None,
181            comment: None,
182            primary_key: None,
183            unique: None,
184            index: None,
185            foreign_key: None,
186        }
187    }
188
189    fn table(
190        name: &str,
191        columns: Vec<ColumnDef>,
192        constraints: Vec<vespertide_core::TableConstraint>,
193        indexes: Vec<IndexDef>,
194    ) -> TableDef {
195        TableDef {
196            name: name.to_string(),
197            columns,
198            constraints,
199            indexes,
200        }
201    }
202
203    #[rstest]
204    #[case::add_column_and_index(
205        vec![table(
206            "users",
207            vec![col("id", ColumnType::Integer)],
208            vec![],
209            vec![],
210        )],
211        vec![table(
212            "users",
213            vec![
214                col("id", ColumnType::Integer),
215                col("name", ColumnType::Text),
216            ],
217            vec![],
218            vec![IndexDef {
219                name: "idx_users_name".into(),
220                columns: vec!["name".into()],
221                unique: false,
222            }],
223        )],
224        vec![
225            MigrationAction::AddColumn {
226                table: "users".into(),
227                column: col("name", ColumnType::Text),
228                fill_with: None,
229            },
230            MigrationAction::AddIndex {
231                table: "users".into(),
232                index: IndexDef {
233                    name: "idx_users_name".into(),
234                    columns: vec!["name".into()],
235                    unique: false,
236                },
237            },
238        ]
239    )]
240    #[case::drop_table(
241        vec![table(
242            "users",
243            vec![col("id", ColumnType::Integer)],
244            vec![],
245            vec![],
246        )],
247        vec![],
248        vec![MigrationAction::DeleteTable {
249            table: "users".into()
250        }]
251    )]
252    #[case::add_table(
253        vec![],
254        vec![table(
255            "users",
256            vec![col("id", ColumnType::Integer)],
257            vec![],
258            vec![IndexDef {
259                name: "idx_users_id".into(),
260                columns: vec!["id".into()],
261                unique: true,
262            }],
263        )],
264        vec![
265            MigrationAction::CreateTable {
266                table: "users".into(),
267                columns: vec![col("id", ColumnType::Integer)],
268                constraints: vec![],
269            },
270            MigrationAction::AddIndex {
271                table: "users".into(),
272                index: IndexDef {
273                    name: "idx_users_id".into(),
274                    columns: vec!["id".into()],
275                    unique: true,
276                },
277            },
278        ]
279    )]
280    #[case::delete_column(
281        vec![table(
282            "users",
283            vec![col("id", ColumnType::Integer), col("name", ColumnType::Text)],
284            vec![],
285            vec![],
286        )],
287        vec![table(
288            "users",
289            vec![col("id", ColumnType::Integer)],
290            vec![],
291            vec![],
292        )],
293        vec![MigrationAction::DeleteColumn {
294            table: "users".into(),
295            column: "name".into(),
296        }]
297    )]
298    #[case::modify_column_type(
299        vec![table(
300            "users",
301            vec![col("id", ColumnType::Integer)],
302            vec![],
303            vec![],
304        )],
305        vec![table(
306            "users",
307            vec![col("id", ColumnType::Text)],
308            vec![],
309            vec![],
310        )],
311        vec![MigrationAction::ModifyColumnType {
312            table: "users".into(),
313            column: "id".into(),
314            new_type: ColumnType::Text,
315        }]
316    )]
317    #[case::remove_index(
318        vec![table(
319            "users",
320            vec![col("id", ColumnType::Integer)],
321            vec![],
322            vec![IndexDef {
323                name: "idx_users_id".into(),
324                columns: vec!["id".into()],
325                unique: false,
326            }],
327        )],
328        vec![table(
329            "users",
330            vec![col("id", ColumnType::Integer)],
331            vec![],
332            vec![],
333        )],
334        vec![MigrationAction::RemoveIndex {
335            table: "users".into(),
336            name: "idx_users_id".into(),
337        }]
338    )]
339    #[case::add_index_existing_table(
340        vec![table(
341            "users",
342            vec![col("id", ColumnType::Integer)],
343            vec![],
344            vec![],
345        )],
346        vec![table(
347            "users",
348            vec![col("id", ColumnType::Integer)],
349            vec![],
350            vec![IndexDef {
351                name: "idx_users_id".into(),
352                columns: vec!["id".into()],
353                unique: true,
354            }],
355        )],
356        vec![MigrationAction::AddIndex {
357            table: "users".into(),
358            index: IndexDef {
359                name: "idx_users_id".into(),
360                columns: vec!["id".into()],
361                unique: true,
362            },
363        }]
364    )]
365    fn diff_schemas_detects_additions(
366        #[case] from_schema: Vec<TableDef>,
367        #[case] to_schema: Vec<TableDef>,
368        #[case] expected_actions: Vec<MigrationAction>,
369    ) {
370        let plan = diff_schemas(&from_schema, &to_schema).unwrap();
371        assert_eq!(plan.actions, expected_actions);
372    }
373
374    // Tests for inline column constraints normalization
375    mod inline_constraints {
376        use super::*;
377        use vespertide_core::schema::foreign_key::ForeignKeyDef;
378        use vespertide_core::{StrOrBoolOrArray, TableConstraint};
379
380        fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
381            ColumnDef {
382                name: name.to_string(),
383                r#type: ty,
384                nullable: false,
385                default: None,
386                comment: None,
387                primary_key: Some(true),
388                unique: None,
389                index: None,
390                foreign_key: None,
391            }
392        }
393
394        fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
395            ColumnDef {
396                name: name.to_string(),
397                r#type: ty,
398                nullable: true,
399                default: None,
400                comment: None,
401                primary_key: None,
402                unique: Some(StrOrBoolOrArray::Bool(true)),
403                index: None,
404                foreign_key: None,
405            }
406        }
407
408        fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
409            ColumnDef {
410                name: name.to_string(),
411                r#type: ty,
412                nullable: true,
413                default: None,
414                comment: None,
415                primary_key: None,
416                unique: None,
417                index: Some(StrOrBoolOrArray::Bool(true)),
418                foreign_key: None,
419            }
420        }
421
422        fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
423            ColumnDef {
424                name: name.to_string(),
425                r#type: ty,
426                nullable: true,
427                default: None,
428                comment: None,
429                primary_key: None,
430                unique: None,
431                index: None,
432                foreign_key: Some(ForeignKeyDef {
433                    ref_table: ref_table.to_string(),
434                    ref_columns: vec![ref_col.to_string()],
435                    on_delete: None,
436                    on_update: None,
437                }),
438            }
439        }
440
441        #[test]
442        fn create_table_with_inline_pk() {
443            let plan = diff_schemas(
444                &[],
445                &[table(
446                    "users",
447                    vec![
448                        col_with_pk("id", ColumnType::Integer),
449                        col("name", ColumnType::Text),
450                    ],
451                    vec![],
452                    vec![],
453                )],
454            )
455            .unwrap();
456
457            assert_eq!(plan.actions.len(), 1);
458            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
459                assert_eq!(constraints.len(), 1);
460                assert!(matches!(
461                    &constraints[0],
462                    TableConstraint::PrimaryKey { columns } if columns == &["id".to_string()]
463                ));
464            } else {
465                panic!("Expected CreateTable action");
466            }
467        }
468
469        #[test]
470        fn create_table_with_inline_unique() {
471            let plan = diff_schemas(
472                &[],
473                &[table(
474                    "users",
475                    vec![
476                        col("id", ColumnType::Integer),
477                        col_with_unique("email", ColumnType::Text),
478                    ],
479                    vec![],
480                    vec![],
481                )],
482            )
483            .unwrap();
484
485            assert_eq!(plan.actions.len(), 1);
486            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
487                assert_eq!(constraints.len(), 1);
488                assert!(matches!(
489                    &constraints[0],
490                    TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
491                ));
492            } else {
493                panic!("Expected CreateTable action");
494            }
495        }
496
497        #[test]
498        fn create_table_with_inline_index() {
499            let plan = diff_schemas(
500                &[],
501                &[table(
502                    "users",
503                    vec![
504                        col("id", ColumnType::Integer),
505                        col_with_index("name", ColumnType::Text),
506                    ],
507                    vec![],
508                    vec![],
509                )],
510            )
511            .unwrap();
512
513            // Should have CreateTable + AddIndex
514            assert_eq!(plan.actions.len(), 2);
515            assert!(matches!(
516                &plan.actions[0],
517                MigrationAction::CreateTable { .. }
518            ));
519            if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
520                assert_eq!(index.name, "idx_users_name");
521                assert_eq!(index.columns, vec!["name".to_string()]);
522            } else {
523                panic!("Expected AddIndex action");
524            }
525        }
526
527        #[test]
528        fn create_table_with_inline_fk() {
529            let plan = diff_schemas(
530                &[],
531                &[table(
532                    "posts",
533                    vec![
534                        col("id", ColumnType::Integer),
535                        col_with_fk("user_id", ColumnType::Integer, "users", "id"),
536                    ],
537                    vec![],
538                    vec![],
539                )],
540            )
541            .unwrap();
542
543            assert_eq!(plan.actions.len(), 1);
544            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
545                assert_eq!(constraints.len(), 1);
546                assert!(matches!(
547                    &constraints[0],
548                    TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
549                        if columns == &["user_id".to_string()]
550                        && ref_table == "users"
551                        && ref_columns == &["id".to_string()]
552                ));
553            } else {
554                panic!("Expected CreateTable action");
555            }
556        }
557
558        #[test]
559        fn add_index_via_inline_constraint() {
560            // Existing table without index -> table with inline index
561            let plan = diff_schemas(
562                &[table(
563                    "users",
564                    vec![
565                        col("id", ColumnType::Integer),
566                        col("name", ColumnType::Text),
567                    ],
568                    vec![],
569                    vec![],
570                )],
571                &[table(
572                    "users",
573                    vec![
574                        col("id", ColumnType::Integer),
575                        col_with_index("name", ColumnType::Text),
576                    ],
577                    vec![],
578                    vec![],
579                )],
580            )
581            .unwrap();
582
583            assert_eq!(plan.actions.len(), 1);
584            if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
585                assert_eq!(table, "users");
586                assert_eq!(index.name, "idx_users_name");
587                assert_eq!(index.columns, vec!["name".to_string()]);
588            } else {
589                panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
590            }
591        }
592
593        #[test]
594        fn create_table_with_all_inline_constraints() {
595            let mut id_col = col("id", ColumnType::Integer);
596            id_col.primary_key = Some(true);
597            id_col.nullable = false;
598
599            let mut email_col = col("email", ColumnType::Text);
600            email_col.unique = Some(StrOrBoolOrArray::Bool(true));
601
602            let mut name_col = col("name", ColumnType::Text);
603            name_col.index = Some(StrOrBoolOrArray::Bool(true));
604
605            let mut org_id_col = col("org_id", ColumnType::Integer);
606            org_id_col.foreign_key = Some(ForeignKeyDef {
607                ref_table: "orgs".into(),
608                ref_columns: vec!["id".into()],
609                on_delete: None,
610                on_update: None,
611            });
612
613            let plan = diff_schemas(
614                &[],
615                &[table(
616                    "users",
617                    vec![id_col, email_col, name_col, org_id_col],
618                    vec![],
619                    vec![],
620                )],
621            )
622            .unwrap();
623
624            // Should have CreateTable + AddIndex
625            assert_eq!(plan.actions.len(), 2);
626
627            if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
628                // Should have: PrimaryKey, Unique, ForeignKey (3 constraints)
629                assert_eq!(constraints.len(), 3);
630            } else {
631                panic!("Expected CreateTable action");
632            }
633
634            // Check for AddIndex action
635            assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
636        }
637
638        #[test]
639        fn add_constraint_to_existing_table() {
640            // Add a unique constraint to an existing table
641            let from_schema = vec![table(
642                "users",
643                vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)],
644                vec![],
645                vec![],
646            )];
647
648            let to_schema = vec![table(
649                "users",
650                vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)],
651                vec![vespertide_core::TableConstraint::Unique {
652                    name: Some("uq_users_email".into()),
653                    columns: vec!["email".into()],
654                }],
655                vec![],
656            )];
657
658            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
659            assert_eq!(plan.actions.len(), 1);
660            if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
661                assert_eq!(table, "users");
662                assert!(matches!(
663                    constraint,
664                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
665                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
666                ));
667            } else {
668                panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
669            }
670        }
671
672        #[test]
673        fn remove_constraint_from_existing_table() {
674            // Remove a unique constraint from an existing table
675            let from_schema = vec![table(
676                "users",
677                vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)],
678                vec![vespertide_core::TableConstraint::Unique {
679                    name: Some("uq_users_email".into()),
680                    columns: vec!["email".into()],
681                }],
682                vec![],
683            )];
684
685            let to_schema = vec![table(
686                "users",
687                vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)],
688                vec![],
689                vec![],
690            )];
691
692            let plan = diff_schemas(&from_schema, &to_schema).unwrap();
693            assert_eq!(plan.actions.len(), 1);
694            if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
695                assert_eq!(table, "users");
696                assert!(matches!(
697                    constraint,
698                    vespertide_core::TableConstraint::Unique { name: Some(n), columns }
699                        if n == "uq_users_email" && columns == &vec!["email".to_string()]
700                ));
701            } else {
702                panic!("Expected RemoveConstraint action, got {:?}", plan.actions[0]);
703            }
704        }
705
706        #[test]
707        fn diff_schemas_with_normalize_error() {
708            // Test that normalize errors are properly propagated
709            let mut col1 = col("col1", ColumnType::Text);
710            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
711
712            let table = TableDef {
713                name: "test".into(),
714                columns: vec![
715                    col("id", ColumnType::Integer),
716                    col1.clone(),
717                    {
718                        // Same column with same index name - should error
719                        let mut c = col1.clone();
720                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
721                        c
722                    },
723                ],
724                constraints: vec![],
725                indexes: vec![],
726            };
727
728            let result = diff_schemas(&[], &[table]);
729            assert!(result.is_err());
730            if let Err(PlannerError::TableValidation(msg)) = result {
731                assert!(msg.contains("Failed to normalize table"));
732                assert!(msg.contains("Duplicate index"));
733            } else {
734                panic!("Expected TableValidation error, got {:?}", result);
735            }
736        }
737
738        #[test]
739        fn diff_schemas_with_normalize_error_in_from_schema() {
740            // Test that normalize errors in 'from' schema are properly propagated
741            let mut col1 = col("col1", ColumnType::Text);
742            col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
743
744            let table = TableDef {
745                name: "test".into(),
746                columns: vec![
747                    col("id", ColumnType::Integer),
748                    col1.clone(),
749                    {
750                        // Same column with same index name - should error
751                        let mut c = col1.clone();
752                        c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
753                        c
754                    },
755                ],
756                constraints: vec![],
757                indexes: vec![],
758            };
759
760            // 'from' schema has the invalid table
761            let result = diff_schemas(&[table], &[]);
762            assert!(result.is_err());
763            if let Err(PlannerError::TableValidation(msg)) = result {
764                assert!(msg.contains("Failed to normalize table"));
765                assert!(msg.contains("Duplicate index"));
766            } else {
767                panic!("Expected TableValidation error, got {:?}", result);
768            }
769        }
770    }
771}