Skip to main content

sql_orm_migrate/
diff.rs

1use crate::{
2    AddColumn, AddForeignKey, AlterColumn, ColumnSnapshot, CreateIndex, CreateSchema, CreateTable,
3    DropColumn, DropForeignKey, DropIndex, DropSchema, DropTable, ForeignKeySnapshot,
4    IndexSnapshot, MigrationOperation, ModelSnapshot, RenameColumn, RenameTable, SchemaSnapshot,
5    TableSnapshot,
6};
7use std::collections::{BTreeMap, BTreeSet};
8
9/// Computes the minimum stage-7 diff for schema and table creation/deletion.
10pub fn diff_schema_and_table_operations(
11    previous: &ModelSnapshot,
12    current: &ModelSnapshot,
13) -> Vec<MigrationOperation> {
14    let previous_schemas = schema_map(previous);
15    let current_schemas = schema_map(current);
16    let mut operations = Vec::new();
17
18    for (schema_name, current_schema) in &current_schemas {
19        if !previous_schemas.contains_key(schema_name) {
20            operations.push(MigrationOperation::CreateSchema(CreateSchema::new(
21                schema_name.clone(),
22            )));
23
24            for table in &current_schema.tables {
25                operations.push(MigrationOperation::CreateTable(CreateTable::new(
26                    schema_name.clone(),
27                    table.clone(),
28                )));
29            }
30
31            continue;
32        }
33
34        let previous_tables = table_map(previous_schemas[schema_name]);
35        let current_tables = table_map(current_schema);
36        let mut consumed_previous_tables = BTreeSet::new();
37
38        for (table_name, current_table) in &current_tables {
39            if let Some(renamed_from) = current_table
40                .renamed_from
41                .as_deref()
42                .filter(|renamed_from| *renamed_from != table_name)
43                .filter(|renamed_from| !current_tables.contains_key(*renamed_from))
44                .filter(|_| !previous_tables.contains_key(table_name))
45                && previous_tables.contains_key(renamed_from)
46                && consumed_previous_tables.insert(renamed_from.to_string())
47            {
48                operations.push(MigrationOperation::RenameTable(RenameTable::new(
49                    schema_name.clone(),
50                    renamed_from.to_string(),
51                    table_name.clone(),
52                )));
53                continue;
54            }
55
56            if previous_tables.contains_key(table_name) {
57                consumed_previous_tables.insert(table_name.clone());
58                continue;
59            }
60
61            if !previous_tables.contains_key(table_name) {
62                operations.push(MigrationOperation::CreateTable(CreateTable::new(
63                    schema_name.clone(),
64                    (*current_table).clone(),
65                )));
66            }
67        }
68
69        for table_name in previous_tables.keys() {
70            if !current_tables.contains_key(table_name)
71                && !consumed_previous_tables.contains(table_name)
72            {
73                operations.push(MigrationOperation::DropTable(DropTable::new(
74                    schema_name.clone(),
75                    table_name.clone(),
76                )));
77            }
78        }
79    }
80
81    for (schema_name, previous_schema) in &previous_schemas {
82        if current_schemas.contains_key(schema_name) {
83            continue;
84        }
85
86        let previous_tables = table_map(previous_schema);
87        for table_name in previous_tables.keys() {
88            operations.push(MigrationOperation::DropTable(DropTable::new(
89                schema_name.clone(),
90                table_name.clone(),
91            )));
92        }
93
94        operations.push(MigrationOperation::DropSchema(DropSchema::new(
95            schema_name.clone(),
96        )));
97    }
98
99    operations
100}
101
102/// Computes additive/removal/basic-alteration column operations for tables present
103/// in both snapshots. Table creation/deletion remains the responsibility of
104/// `diff_schema_and_table_operations`.
105pub fn diff_column_operations(
106    previous: &ModelSnapshot,
107    current: &ModelSnapshot,
108) -> Vec<MigrationOperation> {
109    let previous_schemas = schema_map(previous);
110    let current_schemas = schema_map(current);
111    let mut operations = Vec::new();
112
113    for (schema_name, current_schema) in &current_schemas {
114        let Some(previous_schema) = previous_schemas.get(schema_name) else {
115            continue;
116        };
117
118        for (table_name, previous_table, current_table) in
119            matched_table_pairs(previous_schema, current_schema)
120        {
121            let previous_columns = column_map(previous_table);
122            let current_columns = column_map(current_table);
123            let mut consumed_previous_columns = BTreeSet::new();
124
125            for (column_name, current_column) in &current_columns {
126                if let Some(renamed_from) = current_column
127                    .renamed_from
128                    .as_deref()
129                    .filter(|renamed_from| *renamed_from != column_name)
130                    .filter(|renamed_from| !current_columns.contains_key(*renamed_from))
131                    && let Some(previous_column) = previous_columns.get(renamed_from)
132                {
133                    consumed_previous_columns.insert(renamed_from.to_string());
134                    operations.push(MigrationOperation::RenameColumn(RenameColumn::new(
135                        schema_name.clone(),
136                        table_name.to_string(),
137                        renamed_from.to_string(),
138                        column_name.clone(),
139                    )));
140                    push_followup_column_change(
141                        &mut operations,
142                        schema_name,
143                        &table_name,
144                        renamed_previous_column(previous_column, current_column),
145                        current_column,
146                    );
147                    continue;
148                }
149
150                match previous_columns.get(column_name) {
151                    None => operations.push(MigrationOperation::AddColumn(AddColumn::new(
152                        schema_name.clone(),
153                        table_name.to_string(),
154                        (*current_column).clone(),
155                    ))),
156                    Some(previous_column) => {
157                        consumed_previous_columns.insert(column_name.clone());
158                        push_followup_column_change(
159                            &mut operations,
160                            schema_name,
161                            &table_name,
162                            (*previous_column).clone(),
163                            current_column,
164                        );
165                    }
166                }
167            }
168
169            for column_name in previous_columns.keys() {
170                if !consumed_previous_columns.contains(column_name)
171                    && !current_columns.contains_key(column_name)
172                {
173                    operations.push(MigrationOperation::DropColumn(DropColumn::new(
174                        schema_name.clone(),
175                        table_name.clone(),
176                        column_name.clone(),
177                    )));
178                }
179            }
180        }
181    }
182
183    operations
184}
185
186fn push_followup_column_change(
187    operations: &mut Vec<MigrationOperation>,
188    schema_name: &str,
189    table_name: &str,
190    previous_column: ColumnSnapshot,
191    current_column: &ColumnSnapshot,
192) {
193    if columns_equal_for_diff(&previous_column, current_column) {
194        return;
195    }
196
197    if requires_drop_and_add(&previous_column, current_column) {
198        operations.push(MigrationOperation::DropColumn(DropColumn::new(
199            schema_name.to_string(),
200            table_name.to_string(),
201            current_column.name.clone(),
202        )));
203        operations.push(MigrationOperation::AddColumn(AddColumn::new(
204            schema_name.to_string(),
205            table_name.to_string(),
206            current_column.clone(),
207        )));
208    } else {
209        operations.push(MigrationOperation::AlterColumn(AlterColumn::new(
210            schema_name.to_string(),
211            table_name.to_string(),
212            previous_column,
213            current_column.clone(),
214        )));
215    }
216}
217
218fn renamed_previous_column(previous: &ColumnSnapshot, current: &ColumnSnapshot) -> ColumnSnapshot {
219    let mut renamed = previous.clone();
220    renamed.name = current.name.clone();
221    renamed
222}
223
224fn columns_equal_for_diff(previous: &ColumnSnapshot, current: &ColumnSnapshot) -> bool {
225    let mut normalized_current = current.clone();
226    normalized_current.renamed_from = None;
227    *previous == normalized_current
228}
229
230fn requires_drop_and_add(previous: &ColumnSnapshot, current: &ColumnSnapshot) -> bool {
231    previous.computed_sql != current.computed_sql
232}
233
234/// Computes additive/removal operations for indexes and foreign keys in tables
235/// present in both snapshots. Table creation/deletion remains the responsibility
236/// of `diff_schema_and_table_operations`.
237pub fn diff_relational_operations(
238    previous: &ModelSnapshot,
239    current: &ModelSnapshot,
240) -> Vec<MigrationOperation> {
241    let previous_schemas = schema_map(previous);
242    let current_schemas = schema_map(current);
243    let mut operations = Vec::new();
244
245    for (schema_name, current_schema) in &current_schemas {
246        let Some(previous_schema) = previous_schemas.get(schema_name) else {
247            for table in &current_schema.tables {
248                push_create_relational_operations(&mut operations, schema_name, table);
249            }
250            continue;
251        };
252
253        let previous_tables = table_map(previous_schema);
254        let current_tables = table_map(current_schema);
255
256        for (table_name, previous_table, current_table) in
257            matched_table_pairs(previous_schema, current_schema)
258        {
259            let previous_indexes = index_map(previous_table);
260            let current_indexes = index_map(current_table);
261
262            for (index_name, index) in &current_indexes {
263                match previous_indexes.get(index_name) {
264                    None => operations.push(MigrationOperation::CreateIndex(CreateIndex::new(
265                        schema_name.clone(),
266                        table_name.to_string(),
267                        (*index).clone(),
268                    ))),
269                    Some(previous_index) if *previous_index != *index => {
270                        operations.push(MigrationOperation::DropIndex(DropIndex::new(
271                            schema_name.clone(),
272                            table_name.to_string(),
273                            index_name.clone(),
274                        )));
275                        operations.push(MigrationOperation::CreateIndex(CreateIndex::new(
276                            schema_name.clone(),
277                            table_name.to_string(),
278                            (*index).clone(),
279                        )));
280                    }
281                    Some(_) => {}
282                }
283            }
284
285            for index_name in previous_indexes.keys() {
286                if !current_indexes.contains_key(index_name) {
287                    operations.push(MigrationOperation::DropIndex(DropIndex::new(
288                        schema_name.clone(),
289                        table_name.to_string(),
290                        index_name.clone(),
291                    )));
292                }
293            }
294
295            let previous_foreign_keys = foreign_key_map(previous_table);
296            let current_foreign_keys = foreign_key_map(current_table);
297
298            for (foreign_key_name, foreign_key) in &current_foreign_keys {
299                match previous_foreign_keys.get(foreign_key_name) {
300                    None => operations.push(MigrationOperation::AddForeignKey(AddForeignKey::new(
301                        schema_name.clone(),
302                        table_name.to_string(),
303                        (*foreign_key).clone(),
304                    ))),
305                    Some(previous_foreign_key) if *previous_foreign_key != *foreign_key => {
306                        operations.push(MigrationOperation::DropForeignKey(DropForeignKey::new(
307                            schema_name.clone(),
308                            table_name.to_string(),
309                            foreign_key_name.clone(),
310                        )));
311                        operations.push(MigrationOperation::AddForeignKey(AddForeignKey::new(
312                            schema_name.clone(),
313                            table_name.to_string(),
314                            (*foreign_key).clone(),
315                        )));
316                    }
317                    Some(_) => {}
318                }
319            }
320
321            for foreign_key_name in previous_foreign_keys.keys() {
322                if !current_foreign_keys.contains_key(foreign_key_name) {
323                    operations.push(MigrationOperation::DropForeignKey(DropForeignKey::new(
324                        schema_name.clone(),
325                        table_name.to_string(),
326                        foreign_key_name.clone(),
327                    )));
328                }
329            }
330        }
331
332        for (table_name, current_table) in current_tables {
333            if previous_tables.contains_key(&table_name) {
334                continue;
335            }
336
337            if current_table
338                .renamed_from
339                .as_deref()
340                .is_some_and(|renamed_from| previous_tables.contains_key(renamed_from))
341            {
342                continue;
343            }
344
345            push_create_relational_operations(&mut operations, schema_name, current_table);
346        }
347    }
348
349    operations
350}
351
352fn push_create_relational_operations(
353    operations: &mut Vec<MigrationOperation>,
354    schema_name: &str,
355    table: &TableSnapshot,
356) {
357    for index in &table.indexes {
358        operations.push(MigrationOperation::CreateIndex(CreateIndex::new(
359            schema_name.to_string(),
360            table.name.clone(),
361            index.clone(),
362        )));
363    }
364
365    for foreign_key in &table.foreign_keys {
366        operations.push(MigrationOperation::AddForeignKey(AddForeignKey::new(
367            schema_name.to_string(),
368            table.name.clone(),
369            foreign_key.clone(),
370        )));
371    }
372}
373
374fn schema_map(snapshot: &ModelSnapshot) -> BTreeMap<String, &SchemaSnapshot> {
375    snapshot
376        .schemas
377        .iter()
378        .map(|schema| (schema.name.clone(), schema))
379        .collect()
380}
381
382fn table_map(schema: &SchemaSnapshot) -> BTreeMap<String, &TableSnapshot> {
383    schema
384        .tables
385        .iter()
386        .map(|table| (table.name.clone(), table))
387        .collect()
388}
389
390fn matched_table_pairs<'a>(
391    previous_schema: &'a SchemaSnapshot,
392    current_schema: &'a SchemaSnapshot,
393) -> Vec<(String, &'a TableSnapshot, &'a TableSnapshot)> {
394    let previous_tables = table_map(previous_schema);
395    let current_tables = table_map(current_schema);
396    let mut consumed_previous_tables = BTreeSet::new();
397    let mut pairs = Vec::new();
398
399    for (table_name, current_table) in &current_tables {
400        if let Some(previous_table) = previous_tables.get(table_name) {
401            consumed_previous_tables.insert(table_name.clone());
402            pairs.push((table_name.clone(), *previous_table, *current_table));
403            continue;
404        }
405
406        if let Some(renamed_from) = current_table
407            .renamed_from
408            .as_deref()
409            .filter(|renamed_from| *renamed_from != table_name)
410            .filter(|renamed_from| !current_tables.contains_key(*renamed_from))
411            && let Some(previous_table) = previous_tables.get(renamed_from)
412            && consumed_previous_tables.insert(renamed_from.to_string())
413        {
414            pairs.push((table_name.clone(), *previous_table, *current_table));
415        }
416    }
417
418    pairs
419}
420
421fn column_map(table: &TableSnapshot) -> BTreeMap<String, &ColumnSnapshot> {
422    table
423        .columns
424        .iter()
425        .map(|column| (column.name.clone(), column))
426        .collect()
427}
428
429fn index_map(table: &TableSnapshot) -> BTreeMap<String, &IndexSnapshot> {
430    table
431        .indexes
432        .iter()
433        .map(|index| (index.name.clone(), index))
434        .collect()
435}
436
437fn foreign_key_map(table: &TableSnapshot) -> BTreeMap<String, &ForeignKeySnapshot> {
438    table
439        .foreign_keys
440        .iter()
441        .map(|foreign_key| (foreign_key.name.clone(), foreign_key))
442        .collect()
443}
444
445#[cfg(test)]
446mod tests {
447    use super::{
448        diff_column_operations, diff_relational_operations, diff_schema_and_table_operations,
449    };
450    use crate::{
451        AddColumn, AddForeignKey, AlterColumn, ColumnSnapshot, CreateIndex, CreateSchema,
452        CreateTable, DropColumn, DropForeignKey, DropIndex, DropSchema, DropTable,
453        ForeignKeySnapshot, IndexColumnSnapshot, IndexSnapshot, MigrationOperation, ModelSnapshot,
454        RenameColumn, RenameTable, SchemaSnapshot, TableSnapshot,
455    };
456    use sql_orm_core::{IdentityMetadata, ReferentialAction, SqlServerType};
457
458    fn column(
459        name: &str,
460        sql_type: SqlServerType,
461        nullable: bool,
462        max_length: Option<u32>,
463    ) -> ColumnSnapshot {
464        ColumnSnapshot::new(
465            name,
466            sql_type,
467            nullable,
468            name == "id",
469            (name == "id").then(|| IdentityMetadata::new(1, 1)),
470            None,
471            None,
472            name == "version",
473            name != "id" && name != "version",
474            name != "id" && name != "version",
475            max_length,
476            None,
477            None,
478        )
479    }
480
481    fn table(
482        name: &str,
483        columns: Vec<ColumnSnapshot>,
484        indexes: Vec<IndexSnapshot>,
485        foreign_keys: Vec<ForeignKeySnapshot>,
486    ) -> TableSnapshot {
487        TableSnapshot::new(
488            name,
489            columns,
490            Some(format!("pk_{name}")),
491            vec!["id".to_string()],
492            indexes,
493            foreign_keys,
494        )
495    }
496
497    fn schema(name: &str, tables: Vec<TableSnapshot>) -> SchemaSnapshot {
498        SchemaSnapshot::new(name, tables)
499    }
500
501    fn foreign_key(name: &str, schema: &str, table: &str, column: &str) -> ForeignKeySnapshot {
502        ForeignKeySnapshot::new(
503            name,
504            vec![column.to_string()],
505            schema,
506            table,
507            vec!["id".to_string()],
508            ReferentialAction::NoAction,
509            ReferentialAction::NoAction,
510        )
511    }
512
513    fn composite_foreign_key(
514        name: &str,
515        schema: &str,
516        table: &str,
517        columns: &[&str],
518        referenced_columns: &[&str],
519        on_delete: ReferentialAction,
520        on_update: ReferentialAction,
521    ) -> ForeignKeySnapshot {
522        ForeignKeySnapshot::new(
523            name,
524            columns.iter().map(|column| (*column).to_string()).collect(),
525            schema,
526            table,
527            referenced_columns
528                .iter()
529                .map(|column| (*column).to_string())
530                .collect(),
531            on_delete,
532            on_update,
533        )
534    }
535
536    #[test]
537    fn schema_and_table_diff_keeps_safe_operation_order() {
538        let previous = ModelSnapshot::new(vec![
539            schema("legacy", vec![table("old_orders", vec![], vec![], vec![])]),
540            schema("sales", vec![table("orders", vec![], vec![], vec![])]),
541        ]);
542        let current = ModelSnapshot::new(vec![
543            schema(
544                "reporting",
545                vec![table("daily_sales", vec![], vec![], vec![])],
546            ),
547            schema("sales", vec![table("orders", vec![], vec![], vec![])]),
548        ]);
549
550        let operations = diff_schema_and_table_operations(&previous, &current);
551
552        assert_eq!(
553            operations,
554            vec![
555                MigrationOperation::CreateSchema(CreateSchema::new("reporting")),
556                MigrationOperation::CreateTable(CreateTable::new(
557                    "reporting",
558                    table("daily_sales", vec![], vec![], vec![]),
559                )),
560                MigrationOperation::DropTable(DropTable::new("legacy", "old_orders")),
561                MigrationOperation::DropSchema(DropSchema::new("legacy")),
562            ]
563        );
564    }
565
566    #[test]
567    fn schema_and_table_diff_detects_table_creation_and_deletion_in_existing_schema() {
568        let previous = ModelSnapshot::new(vec![schema(
569            "sales",
570            vec![
571                table("customers", vec![], vec![], vec![]),
572                table("orders", vec![], vec![], vec![]),
573            ],
574        )]);
575        let current = ModelSnapshot::new(vec![schema(
576            "sales",
577            vec![
578                table("customers", vec![], vec![], vec![]),
579                table("invoices", vec![], vec![], vec![]),
580            ],
581        )]);
582
583        let operations = diff_schema_and_table_operations(&previous, &current);
584
585        assert_eq!(
586            operations,
587            vec![
588                MigrationOperation::CreateTable(CreateTable::new(
589                    "sales",
590                    table("invoices", vec![], vec![], vec![]),
591                )),
592                MigrationOperation::DropTable(DropTable::new("sales", "orders")),
593            ]
594        );
595    }
596
597    #[test]
598    fn schema_and_table_diff_returns_empty_for_equal_snapshots() {
599        let snapshot = ModelSnapshot::new(vec![schema(
600            "sales",
601            vec![table(
602                "customers",
603                vec![column("id", SqlServerType::BigInt, false, None)],
604                vec![],
605                vec![],
606            )],
607        )]);
608
609        let operations = diff_schema_and_table_operations(&snapshot, &snapshot);
610
611        assert!(operations.is_empty());
612    }
613
614    #[test]
615    fn schema_and_table_diff_emits_explicit_table_rename_without_drop_and_add() {
616        let previous = ModelSnapshot::new(vec![schema(
617            "sales",
618            vec![table("customers", vec![], vec![], vec![])],
619        )]);
620        let current = ModelSnapshot::new(vec![schema(
621            "sales",
622            vec![table("clients", vec![], vec![], vec![]).with_renamed_from("customers")],
623        )]);
624
625        let operations = diff_schema_and_table_operations(&previous, &current);
626
627        assert_eq!(
628            operations,
629            vec![MigrationOperation::RenameTable(RenameTable::new(
630                "sales",
631                "customers",
632                "clients",
633            ))]
634        );
635    }
636
637    #[test]
638    fn column_diff_detects_add_and_drop_in_shared_table() {
639        let previous = ModelSnapshot::new(vec![schema(
640            "sales",
641            vec![table(
642                "customers",
643                vec![
644                    column("id", SqlServerType::BigInt, false, None),
645                    column("email", SqlServerType::NVarChar, false, Some(160)),
646                ],
647                vec![],
648                vec![],
649            )],
650        )]);
651        let current = ModelSnapshot::new(vec![schema(
652            "sales",
653            vec![table(
654                "customers",
655                vec![
656                    column("id", SqlServerType::BigInt, false, None),
657                    column("version", SqlServerType::RowVersion, false, None),
658                ],
659                vec![],
660                vec![],
661            )],
662        )]);
663
664        let operations = diff_column_operations(&previous, &current);
665
666        assert_eq!(
667            operations,
668            vec![
669                MigrationOperation::AddColumn(AddColumn::new(
670                    "sales",
671                    "customers",
672                    column("version", SqlServerType::RowVersion, false, None),
673                )),
674                MigrationOperation::DropColumn(DropColumn::new("sales", "customers", "email")),
675            ]
676        );
677    }
678
679    #[test]
680    fn column_diff_detects_basic_alterations() {
681        let previous = ModelSnapshot::new(vec![schema(
682            "sales",
683            vec![table(
684                "customers",
685                vec![column("email", SqlServerType::NVarChar, false, Some(160))],
686                vec![],
687                vec![],
688            )],
689        )]);
690        let current = ModelSnapshot::new(vec![schema(
691            "sales",
692            vec![table(
693                "customers",
694                vec![column("email", SqlServerType::NVarChar, true, Some(255))],
695                vec![],
696                vec![],
697            )],
698        )]);
699
700        let operations = diff_column_operations(&previous, &current);
701
702        assert_eq!(
703            operations,
704            vec![MigrationOperation::AlterColumn(AlterColumn::new(
705                "sales",
706                "customers",
707                column("email", SqlServerType::NVarChar, false, Some(160)),
708                column("email", SqlServerType::NVarChar, true, Some(255)),
709            ))]
710        );
711    }
712
713    #[test]
714    fn column_diff_renames_column_when_explicit_hint_matches_previous_name() {
715        let previous = ModelSnapshot::new(vec![schema(
716            "sales",
717            vec![table(
718                "customers",
719                vec![column("email", SqlServerType::NVarChar, false, Some(160))],
720                vec![],
721                vec![],
722            )],
723        )]);
724        let current = ModelSnapshot::new(vec![schema(
725            "sales",
726            vec![table(
727                "customers",
728                vec![
729                    column("email_address", SqlServerType::NVarChar, false, Some(160))
730                        .with_renamed_from("email"),
731                ],
732                vec![],
733                vec![],
734            )],
735        )]);
736
737        let operations = diff_column_operations(&previous, &current);
738
739        assert_eq!(
740            operations,
741            vec![MigrationOperation::RenameColumn(RenameColumn::new(
742                "sales",
743                "customers",
744                "email",
745                "email_address",
746            ))]
747        );
748    }
749
750    #[test]
751    fn column_diff_renames_then_alters_column_when_shape_changes() {
752        let previous = ModelSnapshot::new(vec![schema(
753            "sales",
754            vec![table(
755                "customers",
756                vec![column("email", SqlServerType::NVarChar, false, Some(160))],
757                vec![],
758                vec![],
759            )],
760        )]);
761        let current = ModelSnapshot::new(vec![schema(
762            "sales",
763            vec![table(
764                "customers",
765                vec![
766                    column("email_address", SqlServerType::NVarChar, true, Some(255))
767                        .with_renamed_from("email"),
768                ],
769                vec![],
770                vec![],
771            )],
772        )]);
773
774        let operations = diff_column_operations(&previous, &current);
775
776        assert_eq!(
777            operations,
778            vec![
779                MigrationOperation::RenameColumn(RenameColumn::new(
780                    "sales",
781                    "customers",
782                    "email",
783                    "email_address",
784                )),
785                MigrationOperation::AlterColumn(AlterColumn::new(
786                    "sales",
787                    "customers",
788                    column("email_address", SqlServerType::NVarChar, false, Some(160)),
789                    column("email_address", SqlServerType::NVarChar, true, Some(255))
790                        .with_renamed_from("email"),
791                )),
792            ]
793        );
794    }
795
796    #[test]
797    fn column_diff_uses_renamed_table_as_shared_context() {
798        let previous = ModelSnapshot::new(vec![schema(
799            "sales",
800            vec![table(
801                "customers",
802                vec![column("id", SqlServerType::BigInt, false, None)],
803                vec![],
804                vec![],
805            )],
806        )]);
807        let current = ModelSnapshot::new(vec![schema(
808            "sales",
809            vec![
810                table(
811                    "clients",
812                    vec![
813                        column("id", SqlServerType::BigInt, false, None),
814                        column("email", SqlServerType::NVarChar, false, Some(180)),
815                    ],
816                    vec![],
817                    vec![],
818                )
819                .with_renamed_from("customers"),
820            ],
821        )]);
822
823        let operations = diff_column_operations(&previous, &current);
824
825        assert_eq!(
826            operations,
827            vec![MigrationOperation::AddColumn(AddColumn::new(
828                "sales",
829                "clients",
830                column("email", SqlServerType::NVarChar, false, Some(180)),
831            ))]
832        );
833    }
834
835    #[test]
836    fn column_diff_recreates_column_when_computed_expression_changes() {
837        let previous = ModelSnapshot::new(vec![schema(
838            "sales",
839            vec![table(
840                "order_lines",
841                vec![ColumnSnapshot::new(
842                    "line_total",
843                    SqlServerType::Decimal,
844                    false,
845                    false,
846                    None,
847                    None,
848                    Some("[unit_price] * [quantity]".to_string()),
849                    false,
850                    false,
851                    false,
852                    None,
853                    Some(18),
854                    Some(2),
855                )],
856                vec![],
857                vec![],
858            )],
859        )]);
860        let current = ModelSnapshot::new(vec![schema(
861            "sales",
862            vec![table(
863                "order_lines",
864                vec![ColumnSnapshot::new(
865                    "line_total",
866                    SqlServerType::Decimal,
867                    false,
868                    false,
869                    None,
870                    None,
871                    Some("[unit_price] * [quantity] * (1 - [discount])".to_string()),
872                    false,
873                    false,
874                    false,
875                    None,
876                    Some(18),
877                    Some(2),
878                )],
879                vec![],
880                vec![],
881            )],
882        )]);
883
884        let operations = diff_column_operations(&previous, &current);
885
886        assert_eq!(
887            operations,
888            vec![
889                MigrationOperation::DropColumn(DropColumn::new(
890                    "sales",
891                    "order_lines",
892                    "line_total",
893                )),
894                MigrationOperation::AddColumn(AddColumn::new(
895                    "sales",
896                    "order_lines",
897                    ColumnSnapshot::new(
898                        "line_total",
899                        SqlServerType::Decimal,
900                        false,
901                        false,
902                        None,
903                        None,
904                        Some("[unit_price] * [quantity] * (1 - [discount])".to_string()),
905                        false,
906                        false,
907                        false,
908                        None,
909                        Some(18),
910                        Some(2),
911                    ),
912                )),
913            ]
914        );
915    }
916
917    #[test]
918    fn column_diff_recreates_column_when_switching_between_regular_and_computed() {
919        let previous = ModelSnapshot::new(vec![schema(
920            "sales",
921            vec![table(
922                "order_lines",
923                vec![ColumnSnapshot::new(
924                    "line_total",
925                    SqlServerType::Decimal,
926                    false,
927                    false,
928                    None,
929                    None,
930                    None,
931                    false,
932                    true,
933                    true,
934                    None,
935                    Some(18),
936                    Some(2),
937                )],
938                vec![],
939                vec![],
940            )],
941        )]);
942        let current = ModelSnapshot::new(vec![schema(
943            "sales",
944            vec![table(
945                "order_lines",
946                vec![ColumnSnapshot::new(
947                    "line_total",
948                    SqlServerType::Decimal,
949                    false,
950                    false,
951                    None,
952                    None,
953                    Some("[unit_price] * [quantity]".to_string()),
954                    false,
955                    false,
956                    false,
957                    None,
958                    Some(18),
959                    Some(2),
960                )],
961                vec![],
962                vec![],
963            )],
964        )]);
965
966        let operations = diff_column_operations(&previous, &current);
967
968        assert_eq!(
969            operations,
970            vec![
971                MigrationOperation::DropColumn(DropColumn::new(
972                    "sales",
973                    "order_lines",
974                    "line_total",
975                )),
976                MigrationOperation::AddColumn(AddColumn::new(
977                    "sales",
978                    "order_lines",
979                    ColumnSnapshot::new(
980                        "line_total",
981                        SqlServerType::Decimal,
982                        false,
983                        false,
984                        None,
985                        None,
986                        Some("[unit_price] * [quantity]".to_string()),
987                        false,
988                        false,
989                        false,
990                        None,
991                        Some(18),
992                        Some(2),
993                    ),
994                )),
995            ]
996        );
997    }
998
999    #[test]
1000    fn column_diff_ignores_tables_handled_by_table_diff() {
1001        let previous = ModelSnapshot::new(vec![schema(
1002            "sales",
1003            vec![table(
1004                "customers",
1005                vec![column("email", SqlServerType::NVarChar, false, Some(160))],
1006                vec![],
1007                vec![],
1008            )],
1009        )]);
1010        let current = ModelSnapshot::new(vec![schema(
1011            "sales",
1012            vec![table(
1013                "orders",
1014                vec![column("customer_id", SqlServerType::BigInt, false, None)],
1015                vec![],
1016                vec![],
1017            )],
1018        )]);
1019
1020        let operations = diff_column_operations(&previous, &current);
1021
1022        assert!(operations.is_empty());
1023    }
1024
1025    #[test]
1026    fn relational_diff_detects_index_and_foreign_key_additions_and_removals() {
1027        let previous = ModelSnapshot::new(vec![schema(
1028            "sales",
1029            vec![table(
1030                "orders",
1031                vec![column("customer_id", SqlServerType::BigInt, false, None)],
1032                vec![IndexSnapshot::new(
1033                    "ix_orders_customer_id",
1034                    vec![IndexColumnSnapshot::asc("customer_id")],
1035                    false,
1036                )],
1037                vec![],
1038            )],
1039        )]);
1040        let current = ModelSnapshot::new(vec![schema(
1041            "sales",
1042            vec![table(
1043                "orders",
1044                vec![column("customer_id", SqlServerType::BigInt, false, None)],
1045                vec![],
1046                vec![foreign_key(
1047                    "fk_orders_customer_id_customers",
1048                    "sales",
1049                    "customers",
1050                    "customer_id",
1051                )],
1052            )],
1053        )]);
1054
1055        let operations = diff_relational_operations(&previous, &current);
1056
1057        assert_eq!(
1058            operations,
1059            vec![
1060                MigrationOperation::DropIndex(DropIndex::new(
1061                    "sales",
1062                    "orders",
1063                    "ix_orders_customer_id",
1064                )),
1065                MigrationOperation::AddForeignKey(AddForeignKey::new(
1066                    "sales",
1067                    "orders",
1068                    foreign_key(
1069                        "fk_orders_customer_id_customers",
1070                        "sales",
1071                        "customers",
1072                        "customer_id",
1073                    ),
1074                )),
1075            ]
1076        );
1077    }
1078
1079    #[test]
1080    fn relational_diff_emits_indexes_and_foreign_keys_for_new_tables() {
1081        let previous = ModelSnapshot::new(vec![schema("sales", vec![])]);
1082        let current = ModelSnapshot::new(vec![
1083            schema(
1084                "analytics",
1085                vec![table(
1086                    "daily_sales",
1087                    vec![column("customer_id", SqlServerType::BigInt, false, None)],
1088                    vec![IndexSnapshot::new(
1089                        "ix_daily_sales_customer_id",
1090                        vec![IndexColumnSnapshot::asc("customer_id")],
1091                        false,
1092                    )],
1093                    vec![],
1094                )],
1095            ),
1096            schema(
1097                "sales",
1098                vec![table(
1099                    "orders",
1100                    vec![column("customer_id", SqlServerType::BigInt, false, None)],
1101                    vec![IndexSnapshot::new(
1102                        "ix_orders_customer_id",
1103                        vec![IndexColumnSnapshot::asc("customer_id")],
1104                        false,
1105                    )],
1106                    vec![foreign_key(
1107                        "fk_orders_customer_id_customers",
1108                        "sales",
1109                        "customers",
1110                        "customer_id",
1111                    )],
1112                )],
1113            ),
1114        ]);
1115
1116        let operations = diff_relational_operations(&previous, &current);
1117
1118        assert_eq!(
1119            operations,
1120            vec![
1121                MigrationOperation::CreateIndex(CreateIndex::new(
1122                    "analytics",
1123                    "daily_sales",
1124                    IndexSnapshot::new(
1125                        "ix_daily_sales_customer_id",
1126                        vec![IndexColumnSnapshot::asc("customer_id")],
1127                        false,
1128                    ),
1129                )),
1130                MigrationOperation::CreateIndex(CreateIndex::new(
1131                    "sales",
1132                    "orders",
1133                    IndexSnapshot::new(
1134                        "ix_orders_customer_id",
1135                        vec![IndexColumnSnapshot::asc("customer_id")],
1136                        false,
1137                    ),
1138                )),
1139                MigrationOperation::AddForeignKey(AddForeignKey::new(
1140                    "sales",
1141                    "orders",
1142                    foreign_key(
1143                        "fk_orders_customer_id_customers",
1144                        "sales",
1145                        "customers",
1146                        "customer_id",
1147                    ),
1148                )),
1149            ]
1150        );
1151    }
1152
1153    #[test]
1154    fn relational_diff_recreates_foreign_key_when_definition_changes() {
1155        let previous = ModelSnapshot::new(vec![schema(
1156            "sales",
1157            vec![table(
1158                "orders",
1159                vec![column("customer_id", SqlServerType::BigInt, false, None)],
1160                vec![],
1161                vec![foreign_key(
1162                    "fk_orders_customer_id_customers",
1163                    "dbo",
1164                    "customers",
1165                    "customer_id",
1166                )],
1167            )],
1168        )]);
1169        let current = ModelSnapshot::new(vec![schema(
1170            "sales",
1171            vec![table(
1172                "orders",
1173                vec![column("customer_id", SqlServerType::BigInt, false, None)],
1174                vec![IndexSnapshot::new(
1175                    "ix_orders_customer_id",
1176                    vec![IndexColumnSnapshot::asc("customer_id")],
1177                    false,
1178                )],
1179                vec![foreign_key(
1180                    "fk_orders_customer_id_customers",
1181                    "sales",
1182                    "customers",
1183                    "customer_id",
1184                )],
1185            )],
1186        )]);
1187
1188        let operations = diff_relational_operations(&previous, &current);
1189
1190        assert_eq!(
1191            operations,
1192            vec![
1193                MigrationOperation::CreateIndex(CreateIndex::new(
1194                    "sales",
1195                    "orders",
1196                    IndexSnapshot::new(
1197                        "ix_orders_customer_id",
1198                        vec![IndexColumnSnapshot::asc("customer_id")],
1199                        false,
1200                    ),
1201                )),
1202                MigrationOperation::DropForeignKey(DropForeignKey::new(
1203                    "sales",
1204                    "orders",
1205                    "fk_orders_customer_id_customers",
1206                )),
1207                MigrationOperation::AddForeignKey(AddForeignKey::new(
1208                    "sales",
1209                    "orders",
1210                    foreign_key(
1211                        "fk_orders_customer_id_customers",
1212                        "sales",
1213                        "customers",
1214                        "customer_id",
1215                    ),
1216                )),
1217            ]
1218        );
1219    }
1220
1221    #[test]
1222    fn relational_diff_recreates_composite_foreign_key_when_columns_or_actions_change() {
1223        let previous = ModelSnapshot::new(vec![schema(
1224            "sales",
1225            vec![table(
1226                "order_allocations",
1227                vec![
1228                    column("customer_id", SqlServerType::BigInt, false, None),
1229                    column("branch_id", SqlServerType::BigInt, false, None),
1230                ],
1231                vec![],
1232                vec![composite_foreign_key(
1233                    "fk_order_allocations_customer_branch_customers",
1234                    "sales",
1235                    "customers",
1236                    &["customer_id", "branch_id"],
1237                    &["id", "branch_id"],
1238                    ReferentialAction::NoAction,
1239                    ReferentialAction::NoAction,
1240                )],
1241            )],
1242        )]);
1243        let current = ModelSnapshot::new(vec![schema(
1244            "sales",
1245            vec![table(
1246                "order_allocations",
1247                vec![
1248                    column("customer_id", SqlServerType::BigInt, false, None),
1249                    column("branch_id", SqlServerType::BigInt, false, None),
1250                ],
1251                vec![],
1252                vec![composite_foreign_key(
1253                    "fk_order_allocations_customer_branch_customers",
1254                    "sales",
1255                    "customers",
1256                    &["customer_id", "branch_id"],
1257                    &["id", "branch_id"],
1258                    ReferentialAction::SetDefault,
1259                    ReferentialAction::Cascade,
1260                )],
1261            )],
1262        )]);
1263
1264        let operations = diff_relational_operations(&previous, &current);
1265
1266        assert_eq!(
1267            operations,
1268            vec![
1269                MigrationOperation::DropForeignKey(DropForeignKey::new(
1270                    "sales",
1271                    "order_allocations",
1272                    "fk_order_allocations_customer_branch_customers",
1273                )),
1274                MigrationOperation::AddForeignKey(AddForeignKey::new(
1275                    "sales",
1276                    "order_allocations",
1277                    composite_foreign_key(
1278                        "fk_order_allocations_customer_branch_customers",
1279                        "sales",
1280                        "customers",
1281                        &["customer_id", "branch_id"],
1282                        &["id", "branch_id"],
1283                        ReferentialAction::SetDefault,
1284                        ReferentialAction::Cascade,
1285                    ),
1286                )),
1287            ]
1288        );
1289    }
1290
1291    #[test]
1292    fn relational_diff_recreates_index_when_composite_definition_changes() {
1293        let previous = ModelSnapshot::new(vec![schema(
1294            "sales",
1295            vec![table(
1296                "orders",
1297                vec![
1298                    column("customer_id", SqlServerType::BigInt, false, None),
1299                    column("total_cents", SqlServerType::BigInt, false, None),
1300                ],
1301                vec![IndexSnapshot::new(
1302                    "ix_orders_customer_total",
1303                    vec![IndexColumnSnapshot::asc("customer_id")],
1304                    false,
1305                )],
1306                vec![],
1307            )],
1308        )]);
1309        let current = ModelSnapshot::new(vec![schema(
1310            "sales",
1311            vec![table(
1312                "orders",
1313                vec![
1314                    column("customer_id", SqlServerType::BigInt, false, None),
1315                    column("total_cents", SqlServerType::BigInt, false, None),
1316                ],
1317                vec![IndexSnapshot::new(
1318                    "ix_orders_customer_total",
1319                    vec![
1320                        IndexColumnSnapshot::asc("customer_id"),
1321                        IndexColumnSnapshot::desc("total_cents"),
1322                    ],
1323                    false,
1324                )],
1325                vec![],
1326            )],
1327        )]);
1328
1329        let operations = diff_relational_operations(&previous, &current);
1330
1331        assert_eq!(
1332            operations,
1333            vec![
1334                MigrationOperation::DropIndex(DropIndex::new(
1335                    "sales",
1336                    "orders",
1337                    "ix_orders_customer_total",
1338                )),
1339                MigrationOperation::CreateIndex(CreateIndex::new(
1340                    "sales",
1341                    "orders",
1342                    IndexSnapshot::new(
1343                        "ix_orders_customer_total",
1344                        vec![
1345                            IndexColumnSnapshot::asc("customer_id"),
1346                            IndexColumnSnapshot::desc("total_cents"),
1347                        ],
1348                        false,
1349                    ),
1350                )),
1351            ]
1352        );
1353    }
1354
1355    #[test]
1356    fn full_diff_on_minimal_snapshots_is_stable_when_combined() {
1357        let previous = ModelSnapshot::new(vec![schema(
1358            "sales",
1359            vec![table(
1360                "customers",
1361                vec![
1362                    column("id", SqlServerType::BigInt, false, None),
1363                    column("email", SqlServerType::NVarChar, false, Some(160)),
1364                ],
1365                vec![],
1366                vec![],
1367            )],
1368        )]);
1369        let current = ModelSnapshot::new(vec![
1370            schema(
1371                "reporting",
1372                vec![table(
1373                    "daily_sales",
1374                    vec![column("id", SqlServerType::BigInt, false, None)],
1375                    vec![],
1376                    vec![],
1377                )],
1378            ),
1379            schema(
1380                "sales",
1381                vec![
1382                    table(
1383                        "customers",
1384                        vec![
1385                            column("id", SqlServerType::BigInt, false, None),
1386                            column("email", SqlServerType::NVarChar, true, Some(255)),
1387                            column("version", SqlServerType::RowVersion, false, None),
1388                        ],
1389                        vec![IndexSnapshot::new(
1390                            "ix_customers_email",
1391                            vec![IndexColumnSnapshot::asc("email")],
1392                            true,
1393                        )],
1394                        vec![],
1395                    ),
1396                    table(
1397                        "orders",
1398                        vec![
1399                            column("id", SqlServerType::BigInt, false, None),
1400                            column("customer_id", SqlServerType::BigInt, false, None),
1401                        ],
1402                        vec![IndexSnapshot::new(
1403                            "ix_orders_customer_id",
1404                            vec![IndexColumnSnapshot::asc("customer_id")],
1405                            false,
1406                        )],
1407                        vec![foreign_key(
1408                            "fk_orders_customer_id_customers",
1409                            "sales",
1410                            "customers",
1411                            "customer_id",
1412                        )],
1413                    ),
1414                ],
1415            ),
1416        ]);
1417
1418        let mut operations = diff_schema_and_table_operations(&previous, &current);
1419        operations.extend(diff_column_operations(&previous, &current));
1420        operations.extend(diff_relational_operations(&previous, &current));
1421
1422        assert_eq!(
1423            operations,
1424            vec![
1425                MigrationOperation::CreateSchema(CreateSchema::new("reporting")),
1426                MigrationOperation::CreateTable(CreateTable::new(
1427                    "reporting",
1428                    table(
1429                        "daily_sales",
1430                        vec![column("id", SqlServerType::BigInt, false, None)],
1431                        vec![],
1432                        vec![],
1433                    ),
1434                )),
1435                MigrationOperation::CreateTable(CreateTable::new(
1436                    "sales",
1437                    table(
1438                        "orders",
1439                        vec![
1440                            column("id", SqlServerType::BigInt, false, None),
1441                            column("customer_id", SqlServerType::BigInt, false, None),
1442                        ],
1443                        vec![IndexSnapshot::new(
1444                            "ix_orders_customer_id",
1445                            vec![IndexColumnSnapshot::asc("customer_id")],
1446                            false,
1447                        )],
1448                        vec![foreign_key(
1449                            "fk_orders_customer_id_customers",
1450                            "sales",
1451                            "customers",
1452                            "customer_id",
1453                        )],
1454                    ),
1455                )),
1456                MigrationOperation::AlterColumn(AlterColumn::new(
1457                    "sales",
1458                    "customers",
1459                    column("email", SqlServerType::NVarChar, false, Some(160)),
1460                    column("email", SqlServerType::NVarChar, true, Some(255)),
1461                )),
1462                MigrationOperation::AddColumn(AddColumn::new(
1463                    "sales",
1464                    "customers",
1465                    column("version", SqlServerType::RowVersion, false, None),
1466                )),
1467                MigrationOperation::CreateIndex(CreateIndex::new(
1468                    "sales",
1469                    "customers",
1470                    IndexSnapshot::new(
1471                        "ix_customers_email",
1472                        vec![IndexColumnSnapshot::asc("email")],
1473                        true,
1474                    ),
1475                )),
1476                MigrationOperation::CreateIndex(CreateIndex::new(
1477                    "sales",
1478                    "orders",
1479                    IndexSnapshot::new(
1480                        "ix_orders_customer_id",
1481                        vec![IndexColumnSnapshot::asc("customer_id")],
1482                        false,
1483                    ),
1484                )),
1485                MigrationOperation::AddForeignKey(AddForeignKey::new(
1486                    "sales",
1487                    "orders",
1488                    foreign_key(
1489                        "fk_orders_customer_id_customers",
1490                        "sales",
1491                        "customers",
1492                        "customer_id",
1493                    ),
1494                )),
1495            ]
1496        );
1497    }
1498}