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
9pub 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 ¤t_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 ¤t_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 ¤t_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
102pub 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 ¤t_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 ¤t_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
234pub 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 ¤t_schemas {
246 let Some(previous_schema) = previous_schemas.get(schema_name) else {
247 for table in ¤t_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 ¤t_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 ¤t_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 ¤t_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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
1419 operations.extend(diff_column_operations(&previous, ¤t));
1420 operations.extend(diff_relational_operations(&previous, ¤t));
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}