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