1use crate::introspect::{
8 ColumnInfo, DatabaseSchema, Dialect, ForeignKeyInfo, IndexInfo, ParsedSqlType, TableInfo,
9 UniqueConstraintInfo,
10};
11use std::collections::{HashMap, HashSet};
12
13fn fk_effective_name(table: &str, fk: &ForeignKeyInfo) -> String {
14 fk.name
15 .clone()
16 .unwrap_or_else(|| format!("fk_{}_{}", table, fk.column))
17}
18
19fn unique_effective_name(table: &str, constraint: &UniqueConstraintInfo) -> String {
20 constraint
21 .name
22 .clone()
23 .unwrap_or_else(|| format!("uk_{}_{}", table, constraint.columns.join("_")))
24}
25
26#[derive(Debug, Clone)]
32pub enum SchemaOperation {
33 CreateTable(TableInfo),
36 DropTable(String),
38 RenameTable { from: String, to: String },
40
41 AddColumn { table: String, column: ColumnInfo },
44 DropColumn {
50 table: String,
51 column: String,
52 table_info: Option<TableInfo>,
53 },
54 AlterColumnType {
56 table: String,
57 column: String,
58 from_type: String,
59 to_type: String,
60 table_info: Option<TableInfo>,
61 },
62 AlterColumnNullable {
64 table: String,
65 column: ColumnInfo,
66 from_nullable: bool,
67 to_nullable: bool,
68 table_info: Option<TableInfo>,
69 },
70 AlterColumnDefault {
72 table: String,
73 column: String,
74 from_default: Option<String>,
75 to_default: Option<String>,
76 table_info: Option<TableInfo>,
77 },
78 RenameColumn {
80 table: String,
81 from: String,
82 to: String,
83 },
84
85 AddPrimaryKey {
88 table: String,
89 columns: Vec<String>,
90 table_info: Option<TableInfo>,
91 },
92 DropPrimaryKey {
94 table: String,
95 table_info: Option<TableInfo>,
96 },
97
98 AddForeignKey {
101 table: String,
102 fk: ForeignKeyInfo,
103 table_info: Option<TableInfo>,
104 },
105 DropForeignKey {
107 table: String,
108 name: String,
109 table_info: Option<TableInfo>,
110 },
111
112 AddUnique {
115 table: String,
116 constraint: UniqueConstraintInfo,
117 table_info: Option<TableInfo>,
118 },
119 DropUnique {
121 table: String,
122 name: String,
123 table_info: Option<TableInfo>,
124 },
125
126 CreateIndex { table: String, index: IndexInfo },
129 DropIndex { table: String, name: String },
131}
132
133impl SchemaOperation {
134 pub fn is_destructive(&self) -> bool {
136 matches!(
137 self,
138 SchemaOperation::DropTable(_)
139 | SchemaOperation::DropColumn { .. }
140 | SchemaOperation::AlterColumnType { .. }
141 )
142 }
143
144 pub fn inverse(&self) -> Option<Self> {
149 match self {
150 SchemaOperation::CreateTable(table) => {
151 Some(SchemaOperation::DropTable(table.name.clone()))
152 }
153 SchemaOperation::DropTable(_) => None,
154 SchemaOperation::RenameTable { from, to } => Some(SchemaOperation::RenameTable {
155 from: to.clone(),
156 to: from.clone(),
157 }),
158 SchemaOperation::AddColumn { table, column } => Some(SchemaOperation::DropColumn {
159 table: table.clone(),
160 column: column.name.clone(),
161 table_info: None,
162 }),
163 SchemaOperation::DropColumn { .. } => None,
164 SchemaOperation::AlterColumnType {
165 table,
166 column,
167 from_type,
168 to_type,
169 ..
170 } => Some(SchemaOperation::AlterColumnType {
171 table: table.clone(),
172 column: column.clone(),
173 from_type: to_type.clone(),
174 to_type: from_type.clone(),
175 table_info: None,
176 }),
177 SchemaOperation::AlterColumnNullable {
178 table,
179 column,
180 from_nullable,
181 to_nullable,
182 ..
183 } => Some(SchemaOperation::AlterColumnNullable {
184 table: table.clone(),
185 column: {
186 let mut col = column.clone();
187 col.nullable = *from_nullable;
188 col
189 },
190 from_nullable: *to_nullable,
191 to_nullable: *from_nullable,
192 table_info: None,
193 }),
194 SchemaOperation::AlterColumnDefault {
195 table,
196 column,
197 from_default,
198 to_default,
199 ..
200 } => Some(SchemaOperation::AlterColumnDefault {
201 table: table.clone(),
202 column: column.clone(),
203 from_default: to_default.clone(),
204 to_default: from_default.clone(),
205 table_info: None,
206 }),
207 SchemaOperation::RenameColumn { table, from, to } => {
208 Some(SchemaOperation::RenameColumn {
209 table: table.clone(),
210 from: to.clone(),
211 to: from.clone(),
212 })
213 }
214 SchemaOperation::AddPrimaryKey { table, .. } => Some(SchemaOperation::DropPrimaryKey {
215 table: table.clone(),
216 table_info: None,
217 }),
218 SchemaOperation::DropPrimaryKey { .. } => None,
219 SchemaOperation::AddForeignKey { table, fk, .. } => {
220 Some(SchemaOperation::DropForeignKey {
221 table: table.clone(),
222 name: fk_effective_name(table, fk),
223 table_info: None,
224 })
225 }
226 SchemaOperation::DropForeignKey { .. } => None,
227 SchemaOperation::AddUnique {
228 table, constraint, ..
229 } => Some(SchemaOperation::DropUnique {
230 table: table.clone(),
231 name: unique_effective_name(table, constraint),
232 table_info: None,
233 }),
234 SchemaOperation::DropUnique { .. } => None,
235 SchemaOperation::CreateIndex { table, index } => Some(SchemaOperation::DropIndex {
236 table: table.clone(),
237 name: index.name.clone(),
238 }),
239 SchemaOperation::DropIndex { .. } => None,
240 }
241 }
242
243 pub fn table(&self) -> Option<&str> {
245 match self {
246 SchemaOperation::CreateTable(t) => Some(&t.name),
247 SchemaOperation::DropTable(name) => Some(name),
248 SchemaOperation::RenameTable { from, .. } => Some(from),
249 SchemaOperation::AddColumn { table, .. }
250 | SchemaOperation::DropColumn { table, .. }
251 | SchemaOperation::AlterColumnType { table, .. }
252 | SchemaOperation::AlterColumnNullable { table, .. }
253 | SchemaOperation::AlterColumnDefault { table, .. }
254 | SchemaOperation::RenameColumn { table, .. }
255 | SchemaOperation::AddPrimaryKey { table, .. }
256 | SchemaOperation::DropPrimaryKey { table, .. }
257 | SchemaOperation::AddForeignKey { table, .. }
258 | SchemaOperation::DropForeignKey { table, .. }
259 | SchemaOperation::AddUnique { table, .. }
260 | SchemaOperation::DropUnique { table, .. }
261 | SchemaOperation::CreateIndex { table, .. }
262 | SchemaOperation::DropIndex { table, .. } => Some(table),
263 }
264 }
265
266 fn priority(&self) -> u8 {
268 match self {
283 SchemaOperation::DropForeignKey { .. } => 1,
284 SchemaOperation::DropIndex { .. } => 2,
285 SchemaOperation::DropUnique { .. } => 3,
286 SchemaOperation::DropPrimaryKey { .. } => 4,
287 SchemaOperation::DropColumn { .. } => 5,
288 SchemaOperation::AlterColumnType { .. } => 6,
289 SchemaOperation::AlterColumnNullable { .. } => 7,
290 SchemaOperation::AlterColumnDefault { .. } => 8,
291 SchemaOperation::AddColumn { .. } => 9,
292 SchemaOperation::CreateTable(_) => 10,
293 SchemaOperation::RenameTable { .. } => 11,
294 SchemaOperation::RenameColumn { .. } => 12,
295 SchemaOperation::AddPrimaryKey { .. } => 13,
296 SchemaOperation::AddUnique { .. } => 14,
297 SchemaOperation::CreateIndex { .. } => 15,
298 SchemaOperation::AddForeignKey { .. } => 16,
299 SchemaOperation::DropTable(_) => 17,
300 }
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum WarningSeverity {
311 Info,
313 Warning,
315 DataLoss,
317}
318
319#[derive(Debug, Clone)]
321pub struct DiffWarning {
322 pub severity: WarningSeverity,
324 pub message: String,
326 pub operation_index: Option<usize>,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
332pub enum DestructivePolicy {
333 Skip,
335 #[default]
337 Warn,
338 Allow,
340}
341
342#[derive(Debug)]
344pub struct SchemaDiff {
345 pub destructive_policy: DestructivePolicy,
347 pub operations: Vec<SchemaOperation>,
349 pub warnings: Vec<DiffWarning>,
351}
352
353impl SchemaDiff {
354 pub fn new(destructive_policy: DestructivePolicy) -> Self {
356 Self {
357 destructive_policy,
358 operations: Vec::new(),
359 warnings: Vec::new(),
360 }
361 }
362
363 pub fn is_empty(&self) -> bool {
365 self.operations.is_empty()
366 }
367
368 pub fn len(&self) -> usize {
370 self.operations.len()
371 }
372
373 pub fn has_destructive(&self) -> bool {
375 self.operations.iter().any(|op| op.is_destructive())
376 }
377
378 pub fn destructive_operations(&self) -> Vec<&SchemaOperation> {
380 self.operations
381 .iter()
382 .filter(|op| op.is_destructive())
383 .collect()
384 }
385
386 pub fn requires_confirmation(&self) -> bool {
388 self.destructive_policy == DestructivePolicy::Warn && self.has_destructive()
389 }
390
391 pub fn order_operations(&mut self) {
393 self.operations.sort_by_key(|op| op.priority());
394 }
395
396 fn sqlite_refresh_table_infos(&mut self, current: &DatabaseSchema) {
405 let mut state: HashMap<String, TableInfo> = current
406 .tables
407 .iter()
408 .map(|(name, t)| (name.clone(), t.clone()))
409 .collect();
410
411 for op in &mut self.operations {
412 match op {
413 SchemaOperation::CreateTable(t) => {
414 state.insert(t.name.clone(), t.clone());
415 continue;
416 }
417 SchemaOperation::DropTable(name) => {
418 state.remove(name);
419 continue;
420 }
421 SchemaOperation::RenameTable { from, to } => {
422 if let Some(mut t) = state.remove(from) {
423 t.name.clone_from(to);
424 state.insert(to.clone(), t);
425 }
426 continue;
427 }
428 _ => {}
429 }
430
431 let Some(table) = op.table().map(str::to_string) else {
432 continue;
433 };
434
435 let before = state.get(&table).cloned();
436
437 match op {
438 SchemaOperation::DropColumn { table_info, .. }
439 | SchemaOperation::AlterColumnType { table_info, .. }
440 | SchemaOperation::AlterColumnNullable { table_info, .. }
441 | SchemaOperation::AlterColumnDefault { table_info, .. }
442 | SchemaOperation::AddPrimaryKey { table_info, .. }
443 | SchemaOperation::DropPrimaryKey { table_info, .. }
444 | SchemaOperation::AddForeignKey { table_info, .. }
445 | SchemaOperation::DropForeignKey { table_info, .. }
446 | SchemaOperation::AddUnique { table_info, .. }
447 | SchemaOperation::DropUnique { table_info, .. } => {
448 table_info.clone_from(&before);
449 }
450 _ => {}
451 }
452
453 if let Some(table_state) = state.get_mut(&table) {
454 sqlite_apply_op_to_table_info(table_state, op);
455 }
456 }
457 }
458
459 fn add_op(&mut self, op: SchemaOperation) -> usize {
461 let index = self.operations.len();
462 self.operations.push(op);
463 index
464 }
465
466 fn warn(
468 &mut self,
469 severity: WarningSeverity,
470 message: impl Into<String>,
471 operation_index: Option<usize>,
472 ) {
473 self.warnings.push(DiffWarning {
474 severity,
475 message: message.into(),
476 operation_index,
477 });
478 }
479
480 fn add_destructive_op(
481 &mut self,
482 op: SchemaOperation,
483 warn_severity: WarningSeverity,
484 warn_message: impl Into<String>,
485 ) {
486 let warn_message = warn_message.into();
487 match self.destructive_policy {
488 DestructivePolicy::Skip => {
489 self.warn(
490 WarningSeverity::Warning,
491 format!("Skipped destructive operation: {}", warn_message),
492 None,
493 );
494 }
495 DestructivePolicy::Warn => {
496 let op_index = self.add_op(op);
497 self.warn(warn_severity, warn_message, Some(op_index));
498 }
499 DestructivePolicy::Allow => {
500 self.add_op(op);
501 }
502 }
503 }
504}
505
506impl Default for SchemaDiff {
507 fn default() -> Self {
508 Self::new(DestructivePolicy::Warn)
509 }
510}
511
512pub fn schema_diff(current: &DatabaseSchema, expected: &DatabaseSchema) -> SchemaDiff {
533 schema_diff_with_policy(current, expected, DestructivePolicy::Warn)
534}
535
536pub fn schema_diff_with_policy(
538 current: &DatabaseSchema,
539 expected: &DatabaseSchema,
540 destructive_policy: DestructivePolicy,
541) -> SchemaDiff {
542 SchemaDiffer::new(destructive_policy).diff(current, expected)
543}
544
545#[derive(Debug, Clone, Copy)]
547pub struct SchemaDiffer {
548 destructive_policy: DestructivePolicy,
549}
550
551impl SchemaDiffer {
552 pub const fn new(destructive_policy: DestructivePolicy) -> Self {
553 Self { destructive_policy }
554 }
555
556 pub fn diff(&self, current: &DatabaseSchema, expected: &DatabaseSchema) -> SchemaDiff {
557 let mut diff = SchemaDiff::new(self.destructive_policy);
558
559 let renames = detect_table_renames(current, expected, expected.dialect);
561 let mut renamed_from: HashSet<&str> = HashSet::new();
562 let mut renamed_to: HashSet<&str> = HashSet::new();
563 for (from, to) in &renames {
564 renamed_from.insert(from.as_str());
565 renamed_to.insert(to.as_str());
566 diff.add_op(SchemaOperation::RenameTable {
567 from: from.clone(),
568 to: to.clone(),
569 });
570 }
571
572 for (name, table) in &expected.tables {
574 if renamed_to.contains(name.as_str()) {
575 continue;
576 }
577 if !current.tables.contains_key(name) {
578 diff.add_op(SchemaOperation::CreateTable(table.clone()));
579 }
580 }
581
582 for name in current.tables.keys() {
584 if renamed_from.contains(name.as_str()) {
585 continue;
586 }
587 if !expected.tables.contains_key(name) {
588 diff.add_destructive_op(
589 SchemaOperation::DropTable(name.clone()),
590 WarningSeverity::DataLoss,
591 format!("Dropping table '{}' will delete all data", name),
592 );
593 }
594 }
595
596 for (name, expected_table) in &expected.tables {
598 if let Some(current_table) = current.tables.get(name) {
599 diff_table(current_table, expected_table, expected.dialect, &mut diff);
600 }
601 }
602
603 diff.order_operations();
605
606 if expected.dialect == Dialect::Sqlite {
607 diff.sqlite_refresh_table_infos(current);
608 }
609
610 diff
611 }
612}
613
614fn sqlite_apply_op_to_table_info(table: &mut TableInfo, op: &SchemaOperation) {
615 match op {
616 SchemaOperation::AddColumn { column, .. } => {
617 table.columns.push(column.clone());
618 }
619 SchemaOperation::DropColumn { column, .. } => {
620 table.columns.retain(|c| c.name != *column);
621 table.primary_key.retain(|c| c != column);
622 table.foreign_keys.retain(|fk| fk.column != *column);
623 table
624 .unique_constraints
625 .retain(|uc| !uc.columns.iter().any(|c| c == column));
626 table
627 .indexes
628 .retain(|idx| !idx.columns.iter().any(|c| c == column));
629 }
630 SchemaOperation::AlterColumnType {
631 column, to_type, ..
632 } => {
633 if let Some(col) = table.columns.iter_mut().find(|c| c.name == *column) {
634 col.sql_type.clone_from(to_type);
635 col.parsed_type = ParsedSqlType::parse(to_type);
636 }
637 }
638 SchemaOperation::AlterColumnNullable {
639 column,
640 to_nullable,
641 ..
642 } => {
643 if let Some(col) = table.columns.iter_mut().find(|c| c.name == column.name) {
644 col.nullable = *to_nullable;
645 }
646 }
647 SchemaOperation::AlterColumnDefault {
648 column, to_default, ..
649 } => {
650 if let Some(col) = table.columns.iter_mut().find(|c| c.name == *column) {
651 col.default.clone_from(to_default);
652 }
653 }
654 SchemaOperation::RenameColumn { from, to, .. } => {
655 if let Some(col) = table.columns.iter_mut().find(|c| c.name == *from) {
656 col.name.clone_from(to);
657 }
658 for pk in &mut table.primary_key {
659 if pk == from {
660 pk.clone_from(to);
661 }
662 }
663 for fk in &mut table.foreign_keys {
664 if fk.column == *from {
665 fk.column.clone_from(to);
666 }
667 }
668 for uc in &mut table.unique_constraints {
669 for c in &mut uc.columns {
670 if c == from {
671 c.clone_from(to);
672 }
673 }
674 }
675 for idx in &mut table.indexes {
676 for c in &mut idx.columns {
677 if c == from {
678 c.clone_from(to);
679 }
680 }
681 }
682 }
683 SchemaOperation::AddPrimaryKey { columns, .. } => {
684 table.primary_key.clone_from(columns);
685 for col in &mut table.columns {
686 col.primary_key = table.primary_key.iter().any(|c| c == &col.name);
687 }
688 }
689 SchemaOperation::DropPrimaryKey { .. } => {
690 table.primary_key.clear();
691 for col in &mut table.columns {
692 col.primary_key = false;
693 }
694 }
695 SchemaOperation::AddForeignKey { fk, .. } => {
696 let name = fk_effective_name(&table.name, fk);
697 table
698 .foreign_keys
699 .retain(|existing| fk_effective_name(&table.name, existing) != name);
700 table.foreign_keys.push(fk.clone());
701 }
702 SchemaOperation::DropForeignKey { name, .. } => {
703 table
704 .foreign_keys
705 .retain(|fk| fk_effective_name(&table.name, fk) != *name);
706 }
707 SchemaOperation::AddUnique { constraint, .. } => {
708 let name = unique_effective_name(&table.name, constraint);
709 table
710 .unique_constraints
711 .retain(|existing| unique_effective_name(&table.name, existing) != name);
712 table.unique_constraints.push(constraint.clone());
713 }
714 SchemaOperation::DropUnique { name, .. } => {
715 table
716 .unique_constraints
717 .retain(|uc| unique_effective_name(&table.name, uc) != *name);
718 }
719 SchemaOperation::CreateIndex { index, .. } => {
720 table.indexes.retain(|i| i.name != index.name);
721 table.indexes.push(index.clone());
722 }
723 SchemaOperation::DropIndex { name, .. } => {
724 table.indexes.retain(|i| i.name != *name);
725 }
726 SchemaOperation::CreateTable(_)
727 | SchemaOperation::DropTable(_)
728 | SchemaOperation::RenameTable { .. } => {}
729 }
730}
731
732fn diff_table(current: &TableInfo, expected: &TableInfo, dialect: Dialect, diff: &mut SchemaDiff) {
734 let table = ¤t.name;
735
736 diff_columns(current, expected, dialect, diff);
738
739 diff_primary_key(current, &expected.primary_key, diff);
741
742 diff_foreign_keys(current, &expected.foreign_keys, diff);
744
745 diff_unique_constraints(current, &expected.unique_constraints, diff);
747
748 diff_indexes(table, ¤t.indexes, &expected.indexes, diff);
750}
751
752fn diff_columns(
754 current_table: &TableInfo,
755 expected_table: &TableInfo,
756 dialect: Dialect,
757 diff: &mut SchemaDiff,
758) {
759 let table = current_table.name.as_str();
760 let current = current_table.columns.as_slice();
761 let expected = expected_table.columns.as_slice();
762 let current_map: HashMap<&str, &ColumnInfo> =
763 current.iter().map(|c| (c.name.as_str(), c)).collect();
764 let expected_map: HashMap<&str, &ColumnInfo> =
765 expected.iter().map(|c| (c.name.as_str(), c)).collect();
766
767 let removed: Vec<&ColumnInfo> = current
769 .iter()
770 .filter(|c| !expected_map.contains_key(c.name.as_str()))
771 .collect();
772 let added: Vec<&ColumnInfo> = expected
773 .iter()
774 .filter(|c| !current_map.contains_key(c.name.as_str()))
775 .collect();
776
777 let col_renames = detect_column_renames(&removed, &added, dialect);
778 let mut renamed_from: HashSet<&str> = HashSet::new();
779 let mut renamed_to: HashSet<&str> = HashSet::new();
780 for (from, to) in &col_renames {
781 renamed_from.insert(from.as_str());
782 renamed_to.insert(to.as_str());
783 diff.add_op(SchemaOperation::RenameColumn {
784 table: table.to_string(),
785 from: from.clone(),
786 to: to.clone(),
787 });
788 }
789
790 for (name, col) in &expected_map {
792 if renamed_to.contains(*name) {
793 continue;
794 }
795 if !current_map.contains_key(name) {
796 diff.add_op(SchemaOperation::AddColumn {
797 table: table.to_string(),
798 column: (*col).clone(),
799 });
800 }
801 }
802
803 for name in current_map.keys() {
805 if renamed_from.contains(*name) {
806 continue;
807 }
808 if !expected_map.contains_key(name) {
809 diff.add_destructive_op(
810 SchemaOperation::DropColumn {
811 table: table.to_string(),
812 column: (*name).to_string(),
813 table_info: Some(current_table.clone()),
814 },
815 WarningSeverity::DataLoss,
816 format!("Dropping column '{}.{}' will delete data", table, name),
817 );
818 }
819 }
820
821 for (name, expected_col) in &expected_map {
823 if let Some(current_col) = current_map.get(name) {
824 diff_column_details(current_table, current_col, expected_col, dialect, diff);
825 }
826 }
827}
828
829fn diff_column_details(
831 current_table: &TableInfo,
832 current: &ColumnInfo,
833 expected: &ColumnInfo,
834 dialect: Dialect,
835 diff: &mut SchemaDiff,
836) {
837 let table = current_table.name.as_str();
838 let col = ¤t.name;
839
840 let current_type = normalize_type(¤t.sql_type, dialect);
842 let expected_type = normalize_type(&expected.sql_type, dialect);
843
844 if current_type != expected_type {
845 diff.add_destructive_op(
846 SchemaOperation::AlterColumnType {
847 table: table.to_string(),
848 column: col.clone(),
849 from_type: current.sql_type.clone(),
850 to_type: expected.sql_type.clone(),
851 table_info: Some(current_table.clone()),
852 },
853 WarningSeverity::Warning,
854 format!(
855 "Changing type of '{}.{}' from {} to {} may cause data conversion issues",
856 table, col, current.sql_type, expected.sql_type
857 ),
858 );
859 }
860
861 if current.nullable != expected.nullable {
863 let op_index = diff.add_op(SchemaOperation::AlterColumnNullable {
864 table: table.to_string(),
865 column: (*expected).clone(),
866 from_nullable: current.nullable,
867 to_nullable: expected.nullable,
868 table_info: Some(current_table.clone()),
869 });
870
871 if !expected.nullable {
872 diff.warn(
873 WarningSeverity::Warning,
874 format!(
875 "Making '{}.{}' NOT NULL may fail if column contains NULL values",
876 table, col
877 ),
878 Some(op_index),
879 );
880 }
881 }
882
883 if current.default != expected.default {
885 diff.add_op(SchemaOperation::AlterColumnDefault {
886 table: table.to_string(),
887 column: col.clone(),
888 from_default: current.default.clone(),
889 to_default: expected.default.clone(),
890 table_info: Some(current_table.clone()),
891 });
892 }
893}
894
895fn diff_primary_key(current_table: &TableInfo, expected_pk: &[String], diff: &mut SchemaDiff) {
897 let table = current_table.name.as_str();
898 let current = current_table.primary_key.as_slice();
899 let expected = expected_pk;
900 let current_set: HashSet<&str> = current.iter().map(|s| s.as_str()).collect();
901 let expected_set: HashSet<&str> = expected.iter().map(|s| s.as_str()).collect();
902
903 if current_set != expected_set {
904 if !current.is_empty() {
906 diff.add_op(SchemaOperation::DropPrimaryKey {
907 table: table.to_string(),
908 table_info: Some(current_table.clone()),
909 });
910 }
911
912 if !expected.is_empty() {
914 diff.add_op(SchemaOperation::AddPrimaryKey {
915 table: table.to_string(),
916 columns: expected.to_vec(),
917 table_info: Some(current_table.clone()),
918 });
919 }
920 }
921}
922
923fn diff_foreign_keys(
925 current_table: &TableInfo,
926 expected: &[ForeignKeyInfo],
927 diff: &mut SchemaDiff,
928) {
929 let table = current_table.name.as_str();
930 let current = current_table.foreign_keys.as_slice();
931 let current_map: HashMap<&str, &ForeignKeyInfo> =
933 current.iter().map(|fk| (fk.column.as_str(), fk)).collect();
934 let expected_map: HashMap<&str, &ForeignKeyInfo> =
935 expected.iter().map(|fk| (fk.column.as_str(), fk)).collect();
936
937 for (col, fk) in &expected_map {
939 if !current_map.contains_key(col) {
940 diff.add_op(SchemaOperation::AddForeignKey {
941 table: table.to_string(),
942 fk: (*fk).clone(),
943 table_info: Some(current_table.clone()),
944 });
945 }
946 }
947
948 for (col, fk) in ¤t_map {
950 if !expected_map.contains_key(col) {
951 let name = fk_effective_name(table, fk);
952 diff.add_op(SchemaOperation::DropForeignKey {
953 table: table.to_string(),
954 name,
955 table_info: Some(current_table.clone()),
956 });
957 }
958 }
959
960 for (col, expected_fk) in &expected_map {
962 if let Some(current_fk) = current_map.get(col) {
963 if !fk_matches(current_fk, expected_fk) {
964 let name = fk_effective_name(table, current_fk);
966 diff.add_op(SchemaOperation::DropForeignKey {
967 table: table.to_string(),
968 name,
969 table_info: Some(current_table.clone()),
970 });
971 diff.add_op(SchemaOperation::AddForeignKey {
972 table: table.to_string(),
973 fk: (*expected_fk).clone(),
974 table_info: Some(current_table.clone()),
975 });
976 }
977 }
978 }
979}
980
981fn fk_matches(current: &ForeignKeyInfo, expected: &ForeignKeyInfo) -> bool {
983 current.foreign_table == expected.foreign_table
984 && current.foreign_column == expected.foreign_column
985 && current.on_delete == expected.on_delete
986 && current.on_update == expected.on_update
987}
988
989fn diff_unique_constraints(
991 current_table: &TableInfo,
992 expected: &[UniqueConstraintInfo],
993 diff: &mut SchemaDiff,
994) {
995 let table = current_table.name.as_str();
996 let current = current_table.unique_constraints.as_slice();
997 let current_set: HashSet<Vec<&str>> = current
999 .iter()
1000 .map(|u| u.columns.iter().map(|s| s.as_str()).collect())
1001 .collect();
1002 let expected_set: HashSet<Vec<&str>> = expected
1003 .iter()
1004 .map(|u| u.columns.iter().map(|s| s.as_str()).collect())
1005 .collect();
1006
1007 for constraint in expected {
1009 let cols: Vec<&str> = constraint.columns.iter().map(|s| s.as_str()).collect();
1010 if !current_set.contains(&cols) {
1011 diff.add_op(SchemaOperation::AddUnique {
1012 table: table.to_string(),
1013 constraint: constraint.clone(),
1014 table_info: Some(current_table.clone()),
1015 });
1016 }
1017 }
1018
1019 for constraint in current {
1021 let cols: Vec<&str> = constraint.columns.iter().map(|s| s.as_str()).collect();
1022 if !expected_set.contains(&cols) {
1023 let name = unique_effective_name(table, constraint);
1024 diff.add_op(SchemaOperation::DropUnique {
1025 table: table.to_string(),
1026 name,
1027 table_info: Some(current_table.clone()),
1028 });
1029 }
1030 }
1031}
1032
1033fn diff_indexes(table: &str, current: &[IndexInfo], expected: &[IndexInfo], diff: &mut SchemaDiff) {
1035 let current_filtered: Vec<_> = current.iter().filter(|i| !i.primary).collect();
1037 let expected_filtered: Vec<_> = expected.iter().filter(|i| !i.primary).collect();
1038
1039 let current_map: HashMap<&str, &&IndexInfo> = current_filtered
1041 .iter()
1042 .map(|i| (i.name.as_str(), i))
1043 .collect();
1044 let expected_map: HashMap<&str, &&IndexInfo> = expected_filtered
1045 .iter()
1046 .map(|i| (i.name.as_str(), i))
1047 .collect();
1048
1049 for (name, index) in &expected_map {
1051 if !current_map.contains_key(name) {
1052 diff.add_op(SchemaOperation::CreateIndex {
1053 table: table.to_string(),
1054 index: (**index).clone(),
1055 });
1056 }
1057 }
1058
1059 for name in current_map.keys() {
1061 if !expected_map.contains_key(name) {
1062 diff.add_op(SchemaOperation::DropIndex {
1063 table: table.to_string(),
1064 name: (*name).to_string(),
1065 });
1066 }
1067 }
1068
1069 for (name, expected_idx) in &expected_map {
1071 if let Some(current_idx) = current_map.get(name) {
1072 if current_idx.columns != expected_idx.columns
1073 || current_idx.unique != expected_idx.unique
1074 {
1075 diff.add_op(SchemaOperation::DropIndex {
1077 table: table.to_string(),
1078 name: (*name).to_string(),
1079 });
1080 diff.add_op(SchemaOperation::CreateIndex {
1081 table: table.to_string(),
1082 index: (**expected_idx).clone(),
1083 });
1084 }
1085 }
1086 }
1087}
1088
1089fn column_signature(col: &ColumnInfo, dialect: Dialect) -> String {
1094 let ty = normalize_type(&col.sql_type, dialect);
1095 let default = col.default.as_deref().unwrap_or("");
1096 format!(
1097 "type={};nullable={};default={};pk={};ai={}",
1098 ty, col.nullable, default, col.primary_key, col.auto_increment
1099 )
1100}
1101
1102fn detect_column_renames(
1103 removed: &[&ColumnInfo],
1104 added: &[&ColumnInfo],
1105 dialect: Dialect,
1106) -> Vec<(String, String)> {
1107 let mut removed_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
1108 let mut added_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
1109
1110 for col in removed {
1111 removed_by_sig
1112 .entry(column_signature(col, dialect))
1113 .or_default()
1114 .push(*col);
1115 }
1116 for col in added {
1117 added_by_sig
1118 .entry(column_signature(col, dialect))
1119 .or_default()
1120 .push(*col);
1121 }
1122
1123 let mut renames = Vec::new();
1124 for (sig, removed_cols) in removed_by_sig {
1125 if removed_cols.len() != 1 {
1126 continue;
1127 }
1128 let Some(added_cols) = added_by_sig.get(&sig) else {
1129 continue;
1130 };
1131 if added_cols.len() != 1 {
1132 continue;
1133 }
1134 renames.push((removed_cols[0].name.clone(), added_cols[0].name.clone()));
1135 }
1136
1137 renames.sort_by(|a, b| a.0.cmp(&b.0));
1138 renames
1139}
1140
1141fn table_signature(table: &TableInfo, dialect: Dialect) -> String {
1142 let mut parts = Vec::new();
1143
1144 let mut cols: Vec<String> = table
1145 .columns
1146 .iter()
1147 .map(|c| {
1148 let ty = normalize_type(&c.sql_type, dialect);
1149 let default = c.default.as_deref().unwrap_or("");
1150 format!(
1151 "{}:{}:{}:{}:{}:{}",
1152 c.name, ty, c.nullable, default, c.primary_key, c.auto_increment
1153 )
1154 })
1155 .collect();
1156 cols.sort();
1157 parts.push(format!("cols={}", cols.join(",")));
1158
1159 let mut pk = table.primary_key.clone();
1160 pk.sort();
1161 parts.push(format!("pk={}", pk.join(",")));
1162
1163 let mut fks: Vec<String> = table
1164 .foreign_keys
1165 .iter()
1166 .map(|fk| {
1167 let on_delete = fk.on_delete.as_deref().unwrap_or("");
1168 let on_update = fk.on_update.as_deref().unwrap_or("");
1169 format!(
1170 "{}->{}.{}:{}:{}",
1171 fk.column, fk.foreign_table, fk.foreign_column, on_delete, on_update
1172 )
1173 })
1174 .collect();
1175 fks.sort();
1176 parts.push(format!("fks={}", fks.join("|")));
1177
1178 let mut uniques: Vec<String> = table
1179 .unique_constraints
1180 .iter()
1181 .map(|u| {
1182 let mut cols = u.columns.clone();
1183 cols.sort();
1184 cols.join(",")
1185 })
1186 .collect();
1187 uniques.sort();
1188 parts.push(format!("uniques={}", uniques.join("|")));
1189
1190 let mut checks: Vec<String> = table
1191 .check_constraints
1192 .iter()
1193 .map(|c| c.expression.trim().to_string())
1194 .collect();
1195 checks.sort();
1196 parts.push(format!("checks={}", checks.join("|")));
1197
1198 let mut indexes: Vec<String> = table
1199 .indexes
1200 .iter()
1201 .map(|i| {
1202 let ty = i.index_type.as_deref().unwrap_or("");
1203 format!("{}:{}:{}:{}", i.columns.join(","), i.unique, i.primary, ty)
1204 })
1205 .collect();
1206 indexes.sort();
1207 parts.push(format!("indexes={}", indexes.join("|")));
1208
1209 parts.join(";")
1210}
1211
1212fn detect_table_renames(
1213 current: &DatabaseSchema,
1214 expected: &DatabaseSchema,
1215 dialect: Dialect,
1216) -> Vec<(String, String)> {
1217 let current_only: Vec<&TableInfo> = current
1218 .tables
1219 .values()
1220 .filter(|t| !expected.tables.contains_key(&t.name))
1221 .collect();
1222 let expected_only: Vec<&TableInfo> = expected
1223 .tables
1224 .values()
1225 .filter(|t| !current.tables.contains_key(&t.name))
1226 .collect();
1227
1228 let mut current_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
1229 let mut expected_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
1230
1231 for table in current_only {
1232 current_by_sig
1233 .entry(table_signature(table, dialect))
1234 .or_default()
1235 .push(table);
1236 }
1237 for table in expected_only {
1238 expected_by_sig
1239 .entry(table_signature(table, dialect))
1240 .or_default()
1241 .push(table);
1242 }
1243
1244 let mut renames = Vec::new();
1245 for (sig, current_tables) in current_by_sig {
1246 if current_tables.len() != 1 {
1247 continue;
1248 }
1249 let Some(expected_tables) = expected_by_sig.get(&sig) else {
1250 continue;
1251 };
1252 if expected_tables.len() != 1 {
1253 continue;
1254 }
1255
1256 renames.push((
1257 current_tables[0].name.clone(),
1258 expected_tables[0].name.clone(),
1259 ));
1260 }
1261
1262 renames.sort_by(|a, b| a.0.cmp(&b.0));
1263 renames
1264}
1265
1266fn normalize_type(sql_type: &str, dialect: Dialect) -> String {
1272 let upper = sql_type.to_uppercase();
1273
1274 match dialect {
1275 Dialect::Sqlite => {
1276 if upper.contains("INT") {
1278 "INTEGER".to_string()
1279 } else if upper.contains("CHAR") || upper.contains("TEXT") || upper.contains("CLOB") {
1280 "TEXT".to_string()
1281 } else if upper.contains("REAL") || upper.contains("FLOAT") || upper.contains("DOUB") {
1282 "REAL".to_string()
1283 } else if upper.contains("BLOB") || upper.is_empty() {
1284 "BLOB".to_string()
1285 } else {
1286 upper
1287 }
1288 }
1289 Dialect::Postgres => match upper.as_str() {
1290 "INT" | "INT4" => "INTEGER".to_string(),
1291 "INT8" => "BIGINT".to_string(),
1292 "INT2" => "SMALLINT".to_string(),
1293 "FLOAT4" => "REAL".to_string(),
1294 "FLOAT8" => "DOUBLE PRECISION".to_string(),
1295 "BOOL" => "BOOLEAN".to_string(),
1296 "SERIAL" => "INTEGER".to_string(),
1297 "BIGSERIAL" => "BIGINT".to_string(),
1298 "SMALLSERIAL" => "SMALLINT".to_string(),
1299 _ => upper,
1300 },
1301 Dialect::Mysql => match upper.as_str() {
1302 "INTEGER" => "INT".to_string(),
1303 "BOOL" | "BOOLEAN" => "TINYINT".to_string(),
1304 _ => upper,
1305 },
1306 }
1307}
1308
1309#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use crate::introspect::ParsedSqlType;
1317
1318 fn make_column(name: &str, sql_type: &str, nullable: bool) -> ColumnInfo {
1319 ColumnInfo {
1320 name: name.to_string(),
1321 sql_type: sql_type.to_string(),
1322 parsed_type: ParsedSqlType::parse(sql_type),
1323 nullable,
1324 default: None,
1325 primary_key: false,
1326 auto_increment: false,
1327 comment: None,
1328 }
1329 }
1330
1331 fn make_table(name: &str, columns: Vec<ColumnInfo>) -> TableInfo {
1332 TableInfo {
1333 name: name.to_string(),
1334 columns,
1335 primary_key: Vec::new(),
1336 foreign_keys: Vec::new(),
1337 unique_constraints: Vec::new(),
1338 check_constraints: Vec::new(),
1339 indexes: Vec::new(),
1340 comment: None,
1341 }
1342 }
1343
1344 #[test]
1345 fn test_schema_diff_new_table() {
1346 let current = DatabaseSchema::new(Dialect::Sqlite);
1347 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1348 expected.tables.insert(
1349 "heroes".to_string(),
1350 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1351 );
1352
1353 let diff = schema_diff(¤t, &expected);
1354 assert_eq!(diff.len(), 1);
1355 assert!(
1356 matches!(&diff.operations[0], SchemaOperation::CreateTable(t) if t.name == "heroes")
1357 );
1358 }
1359
1360 #[test]
1361 fn test_schema_diff_rename_table() {
1362 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1363 current.tables.insert(
1364 "heroes_old".to_string(),
1365 make_table("heroes_old", vec![make_column("id", "INTEGER", false)]),
1366 );
1367
1368 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1369 expected.tables.insert(
1370 "heroes".to_string(),
1371 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1372 );
1373
1374 let diff = schema_diff(¤t, &expected);
1375 assert!(diff.operations.iter().any(|op| {
1376 matches!(op, SchemaOperation::RenameTable { from, to } if from == "heroes_old" && to == "heroes")
1377 }));
1378 assert!(!diff.operations.iter().any(|op| matches!(
1379 op,
1380 SchemaOperation::CreateTable(_) | SchemaOperation::DropTable(_)
1381 )));
1382 }
1383
1384 #[test]
1385 fn test_schema_diff_drop_table() {
1386 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1387 current.tables.insert(
1388 "heroes".to_string(),
1389 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1390 );
1391 let expected = DatabaseSchema::new(Dialect::Sqlite);
1392
1393 let diff = schema_diff(¤t, &expected);
1394 assert_eq!(diff.len(), 1);
1395 assert!(
1396 matches!(&diff.operations[0], SchemaOperation::DropTable(name) if name == "heroes")
1397 );
1398 assert!(diff.has_destructive());
1399 assert!(diff.requires_confirmation());
1400 assert_eq!(diff.warnings.len(), 1);
1401 assert_eq!(diff.warnings[0].severity, WarningSeverity::DataLoss);
1402 }
1403
1404 #[test]
1405 fn test_schema_diff_drop_table_allow_policy() {
1406 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1407 current.tables.insert(
1408 "heroes".to_string(),
1409 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1410 );
1411 let expected = DatabaseSchema::new(Dialect::Sqlite);
1412
1413 let diff = schema_diff_with_policy(¤t, &expected, DestructivePolicy::Allow);
1414 assert_eq!(diff.len(), 1);
1415 assert!(diff.has_destructive());
1416 assert!(!diff.requires_confirmation());
1417 assert!(diff.warnings.is_empty());
1418 }
1419
1420 #[test]
1421 fn test_schema_diff_drop_table_skip_policy() {
1422 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1423 current.tables.insert(
1424 "heroes".to_string(),
1425 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1426 );
1427 let expected = DatabaseSchema::new(Dialect::Sqlite);
1428
1429 let diff = schema_diff_with_policy(¤t, &expected, DestructivePolicy::Skip);
1430 assert!(diff.operations.is_empty());
1431 assert!(!diff.has_destructive());
1432 assert!(!diff.requires_confirmation());
1433 assert!(
1434 diff.warnings
1435 .iter()
1436 .any(|w| w.message.contains("Skipped destructive operation"))
1437 );
1438 }
1439
1440 #[test]
1441 fn test_schema_diff_add_column() {
1442 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1443 current.tables.insert(
1444 "heroes".to_string(),
1445 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1446 );
1447
1448 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1449 expected.tables.insert(
1450 "heroes".to_string(),
1451 make_table(
1452 "heroes",
1453 vec![
1454 make_column("id", "INTEGER", false),
1455 make_column("name", "TEXT", false),
1456 ],
1457 ),
1458 );
1459
1460 let diff = schema_diff(¤t, &expected);
1461 assert!(diff
1462 .operations
1463 .iter()
1464 .any(|op| matches!(op, SchemaOperation::AddColumn { table, column } if table == "heroes" && column.name == "name")));
1465 }
1466
1467 #[test]
1468 fn test_schema_diff_drop_column() {
1469 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1470 current.tables.insert(
1471 "heroes".to_string(),
1472 make_table(
1473 "heroes",
1474 vec![
1475 make_column("id", "INTEGER", false),
1476 make_column("old_field", "TEXT", true),
1477 ],
1478 ),
1479 );
1480
1481 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1482 expected.tables.insert(
1483 "heroes".to_string(),
1484 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1485 );
1486
1487 let diff = schema_diff(¤t, &expected);
1488 assert!(diff.has_destructive());
1489 assert!(diff.operations.iter().any(
1490 |op| matches!(op, SchemaOperation::DropColumn { table, column, table_info: Some(_), .. } if table == "heroes" && column == "old_field")
1491 ));
1492 }
1493
1494 #[test]
1495 fn test_sqlite_refreshes_table_info_for_multiple_recreate_ops_on_same_table() {
1496 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1497 current.tables.insert(
1498 "heroes".to_string(),
1499 make_table(
1500 "heroes",
1501 vec![
1502 make_column("id", "INTEGER", false),
1503 make_column("old_field", "TEXT", true),
1504 make_column("name", "TEXT", false),
1505 ],
1506 ),
1507 );
1508
1509 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1510 let mut name = make_column("name", "TEXT", false);
1511 name.default = Some("'anon'".to_string());
1512 expected.tables.insert(
1513 "heroes".to_string(),
1514 make_table("heroes", vec![make_column("id", "INTEGER", false), name]),
1515 );
1516
1517 let diff = schema_diff(¤t, &expected);
1518
1519 assert!(
1521 diff.operations.iter().any(|op| matches!(
1522 op,
1523 SchemaOperation::DropColumn { table, column, .. } if table == "heroes" && column == "old_field"
1524 )),
1525 "Expected DropColumn(old_field) op"
1526 );
1527
1528 let alter_default_table_info = diff.operations.iter().find_map(|op| match op {
1529 SchemaOperation::AlterColumnDefault {
1530 table,
1531 column,
1532 to_default,
1533 table_info,
1534 ..
1535 } if table == "heroes"
1536 && column == "name"
1537 && to_default.as_deref() == Some("'anon'") =>
1538 {
1539 table_info.as_ref()
1540 }
1541 _ => None,
1542 });
1543 let table_info =
1544 alter_default_table_info.expect("Expected AlterColumnDefault(name) op with table_info");
1545
1546 assert!(
1548 table_info.column("old_field").is_none(),
1549 "Expected stale column to be absent from refreshed table_info"
1550 );
1551 }
1552
1553 #[test]
1554 fn test_schema_diff_rename_column() {
1555 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1556 current.tables.insert(
1557 "heroes".to_string(),
1558 make_table("heroes", vec![make_column("old_name", "TEXT", false)]),
1559 );
1560
1561 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1562 expected.tables.insert(
1563 "heroes".to_string(),
1564 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1565 );
1566
1567 let diff = schema_diff(¤t, &expected);
1568 assert!(diff.operations.iter().any(|op| {
1569 matches!(op, SchemaOperation::RenameColumn { table, from, to } if table == "heroes" && from == "old_name" && to == "name")
1570 }));
1571 assert!(!diff.operations.iter().any(|op| matches!(
1572 op,
1573 SchemaOperation::AddColumn { .. } | SchemaOperation::DropColumn { .. }
1574 )));
1575 assert!(!diff.has_destructive());
1576 }
1577
1578 #[test]
1579 fn test_schema_diff_alter_column_type() {
1580 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1581 current.tables.insert(
1582 "heroes".to_string(),
1583 make_table("heroes", vec![make_column("age", "INTEGER", false)]),
1584 );
1585
1586 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1587 expected.tables.insert(
1588 "heroes".to_string(),
1589 make_table("heroes", vec![make_column("age", "REAL", false)]),
1590 );
1591
1592 let diff = schema_diff(¤t, &expected);
1593 assert!(diff.operations.iter().any(
1594 |op| matches!(op, SchemaOperation::AlterColumnType { table, column, .. } if table == "heroes" && column == "age")
1595 ));
1596 }
1597
1598 #[test]
1599 fn test_schema_diff_alter_nullable() {
1600 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1601 current.tables.insert(
1602 "heroes".to_string(),
1603 make_table("heroes", vec![make_column("name", "TEXT", true)]),
1604 );
1605
1606 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1607 expected.tables.insert(
1608 "heroes".to_string(),
1609 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1610 );
1611
1612 let diff = schema_diff(¤t, &expected);
1613 assert!(diff.operations.iter().any(
1614 |op| matches!(op, SchemaOperation::AlterColumnNullable { table, column, to_nullable: false, .. } if table == "heroes" && column.name == "name")
1615 ));
1616 }
1617
1618 #[test]
1619 fn test_schema_diff_empty() {
1620 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1621 current.tables.insert(
1622 "heroes".to_string(),
1623 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1624 );
1625
1626 let expected = current.clone();
1627
1628 let diff = schema_diff(¤t, &expected);
1629 assert!(diff.is_empty());
1630 }
1631
1632 #[test]
1633 fn test_schema_diff_foreign_key_add() {
1634 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1635 current.tables.insert(
1636 "heroes".to_string(),
1637 make_table("heroes", vec![make_column("team_id", "INTEGER", true)]),
1638 );
1639
1640 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1641 let mut heroes = make_table("heroes", vec![make_column("team_id", "INTEGER", true)]);
1642 heroes.foreign_keys.push(ForeignKeyInfo {
1643 name: Some("fk_heroes_team".to_string()),
1644 column: "team_id".to_string(),
1645 foreign_table: "teams".to_string(),
1646 foreign_column: "id".to_string(),
1647 on_delete: Some("CASCADE".to_string()),
1648 on_update: None,
1649 });
1650 expected.tables.insert("heroes".to_string(), heroes);
1651
1652 let diff = schema_diff(¤t, &expected);
1653 let op = diff.operations.iter().find_map(|op| match op {
1654 SchemaOperation::AddForeignKey {
1655 table,
1656 fk,
1657 table_info,
1658 } if table == "heroes" && fk.column == "team_id" => Some(table_info),
1659 _ => None,
1660 });
1661 assert!(op.is_some(), "Expected AddForeignKey op for heroes.team_id");
1662 assert!(
1663 op.unwrap().is_some(),
1664 "Expected table_info on AddForeignKey op"
1665 );
1666 }
1667
1668 #[test]
1669 fn test_schema_diff_primary_key_add_attaches_table_info() {
1670 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1671 let mut current_table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1672 current_table.primary_key.clear();
1673 current.tables.insert("heroes".to_string(), current_table);
1674
1675 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1676 let mut expected_table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1677 expected_table.primary_key = vec!["id".to_string()];
1678 expected.tables.insert("heroes".to_string(), expected_table);
1679
1680 let diff = schema_diff(¤t, &expected);
1681 let op = diff.operations.iter().find_map(|op| match op {
1682 SchemaOperation::AddPrimaryKey {
1683 table,
1684 columns,
1685 table_info,
1686 } if table == "heroes" && columns == &vec!["id".to_string()] => Some(table_info),
1687 _ => None,
1688 });
1689 assert!(op.is_some(), "Expected AddPrimaryKey op for heroes(id)");
1690 assert!(
1691 op.unwrap().is_some(),
1692 "Expected table_info on AddPrimaryKey op"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_schema_diff_unique_add_attaches_table_info() {
1698 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1699 current.tables.insert(
1700 "heroes".to_string(),
1701 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1702 );
1703
1704 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1705 let mut expected_table = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1706 expected_table
1707 .unique_constraints
1708 .push(UniqueConstraintInfo {
1709 name: Some("uk_heroes_name".to_string()),
1710 columns: vec!["name".to_string()],
1711 });
1712 expected.tables.insert("heroes".to_string(), expected_table);
1713
1714 let diff = schema_diff(¤t, &expected);
1715 let op = diff.operations.iter().find_map(|op| match op {
1716 SchemaOperation::AddUnique {
1717 table,
1718 constraint,
1719 table_info,
1720 } if table == "heroes" && constraint.columns == vec!["name".to_string()] => {
1721 Some(table_info)
1722 }
1723 _ => None,
1724 });
1725 assert!(op.is_some(), "Expected AddUnique op for heroes(name)");
1726 assert!(op.unwrap().is_some(), "Expected table_info on AddUnique op");
1727 }
1728
1729 #[test]
1730 fn test_schema_diff_index_add() {
1731 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1732 current.tables.insert(
1733 "heroes".to_string(),
1734 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1735 );
1736
1737 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1738 let mut heroes = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1739 heroes.indexes.push(IndexInfo {
1740 name: "idx_heroes_name".to_string(),
1741 columns: vec!["name".to_string()],
1742 unique: false,
1743 index_type: None,
1744 primary: false,
1745 });
1746 expected.tables.insert("heroes".to_string(), heroes);
1747
1748 let diff = schema_diff(¤t, &expected);
1749 assert!(diff.operations.iter().any(
1750 |op| matches!(op, SchemaOperation::CreateIndex { table, index } if table == "heroes" && index.name == "idx_heroes_name")
1751 ));
1752 }
1753
1754 #[test]
1755 fn test_operation_ordering() {
1756 let mut diff = SchemaDiff::new(DestructivePolicy::Warn);
1757
1758 diff.add_op(SchemaOperation::AddForeignKey {
1760 table: "heroes".to_string(),
1761 fk: ForeignKeyInfo {
1762 name: None,
1763 column: "team_id".to_string(),
1764 foreign_table: "teams".to_string(),
1765 foreign_column: "id".to_string(),
1766 on_delete: None,
1767 on_update: None,
1768 },
1769 table_info: None,
1770 });
1771 diff.add_op(SchemaOperation::DropForeignKey {
1772 table: "old".to_string(),
1773 name: "fk_old".to_string(),
1774 table_info: None,
1775 });
1776 diff.add_op(SchemaOperation::AddColumn {
1777 table: "heroes".to_string(),
1778 column: make_column("age", "INTEGER", true),
1779 });
1780
1781 diff.order_operations();
1782
1783 assert!(matches!(
1785 &diff.operations[0],
1786 SchemaOperation::DropForeignKey { .. }
1787 ));
1788 assert!(matches!(
1790 &diff.operations[1],
1791 SchemaOperation::AddColumn { .. }
1792 ));
1793 assert!(matches!(
1794 &diff.operations[2],
1795 SchemaOperation::AddForeignKey { .. }
1796 ));
1797 }
1798
1799 #[test]
1800 fn test_type_normalization_sqlite() {
1801 assert_eq!(normalize_type("INT", Dialect::Sqlite), "INTEGER");
1802 assert_eq!(normalize_type("BIGINT", Dialect::Sqlite), "INTEGER");
1803 assert_eq!(normalize_type("VARCHAR(100)", Dialect::Sqlite), "TEXT");
1804 assert_eq!(normalize_type("FLOAT", Dialect::Sqlite), "REAL");
1805 }
1806
1807 #[test]
1808 fn test_type_normalization_postgres() {
1809 assert_eq!(normalize_type("INT", Dialect::Postgres), "INTEGER");
1810 assert_eq!(normalize_type("INT4", Dialect::Postgres), "INTEGER");
1811 assert_eq!(normalize_type("INT8", Dialect::Postgres), "BIGINT");
1812 assert_eq!(normalize_type("SERIAL", Dialect::Postgres), "INTEGER");
1813 }
1814
1815 #[test]
1816 fn test_type_normalization_mysql() {
1817 assert_eq!(normalize_type("INTEGER", Dialect::Mysql), "INT");
1818 assert_eq!(normalize_type("BOOLEAN", Dialect::Mysql), "TINYINT");
1819 }
1820
1821 #[test]
1822 fn test_schema_operation_is_destructive() {
1823 assert!(SchemaOperation::DropTable("heroes".to_string()).is_destructive());
1824 assert!(
1825 SchemaOperation::DropColumn {
1826 table: "heroes".to_string(),
1827 column: "age".to_string(),
1828 table_info: None,
1829 }
1830 .is_destructive()
1831 );
1832 assert!(
1833 SchemaOperation::AlterColumnType {
1834 table: "heroes".to_string(),
1835 column: "age".to_string(),
1836 from_type: "TEXT".to_string(),
1837 to_type: "INTEGER".to_string(),
1838 table_info: None,
1839 }
1840 .is_destructive()
1841 );
1842 assert!(
1843 !SchemaOperation::AddColumn {
1844 table: "heroes".to_string(),
1845 column: make_column("name", "TEXT", false),
1846 }
1847 .is_destructive()
1848 );
1849 }
1850
1851 #[test]
1852 fn test_schema_operation_inverse() {
1853 let table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1854 let op = SchemaOperation::CreateTable(table);
1855 assert!(matches!(op.inverse(), Some(SchemaOperation::DropTable(name)) if name == "heroes"));
1856
1857 let op = SchemaOperation::AlterColumnType {
1858 table: "heroes".to_string(),
1859 column: "age".to_string(),
1860 from_type: "TEXT".to_string(),
1861 to_type: "INTEGER".to_string(),
1862 table_info: None,
1863 };
1864 assert!(
1865 matches!(op.inverse(), Some(SchemaOperation::AlterColumnType { from_type, to_type, .. }) if from_type == "INTEGER" && to_type == "TEXT")
1866 );
1867 }
1868}