1use crate::introspect::{
8 ColumnInfo, DatabaseSchema, Dialect, ForeignKeyInfo, IndexInfo, TableInfo, UniqueConstraintInfo,
9};
10use std::collections::{HashMap, HashSet};
11
12fn fk_effective_name(table: &str, fk: &ForeignKeyInfo) -> String {
13 fk.name
14 .clone()
15 .unwrap_or_else(|| format!("fk_{}_{}", table, fk.column))
16}
17
18fn unique_effective_name(constraint: &UniqueConstraintInfo) -> String {
19 constraint
20 .name
21 .clone()
22 .unwrap_or_else(|| format!("uk_{}", constraint.columns.join("_")))
23}
24
25#[derive(Debug, Clone)]
31pub enum SchemaOperation {
32 CreateTable(TableInfo),
35 DropTable(String),
37 RenameTable { from: String, to: String },
39
40 AddColumn { table: String, column: ColumnInfo },
43 DropColumn { table: String, column: String },
45 AlterColumnType {
47 table: String,
48 column: String,
49 from_type: String,
50 to_type: String,
51 },
52 AlterColumnNullable {
54 table: String,
55 column: String,
56 from_nullable: bool,
57 to_nullable: bool,
58 },
59 AlterColumnDefault {
61 table: String,
62 column: String,
63 from_default: Option<String>,
64 to_default: Option<String>,
65 },
66 RenameColumn {
68 table: String,
69 from: String,
70 to: String,
71 },
72
73 AddPrimaryKey { table: String, columns: Vec<String> },
76 DropPrimaryKey { table: String },
78
79 AddForeignKey { table: String, fk: ForeignKeyInfo },
82 DropForeignKey { table: String, name: String },
84
85 AddUnique {
88 table: String,
89 constraint: UniqueConstraintInfo,
90 },
91 DropUnique { table: String, name: String },
93
94 CreateIndex { table: String, index: IndexInfo },
97 DropIndex { table: String, name: String },
99}
100
101impl SchemaOperation {
102 pub fn is_destructive(&self) -> bool {
104 matches!(
105 self,
106 SchemaOperation::DropTable(_)
107 | SchemaOperation::DropColumn { .. }
108 | SchemaOperation::AlterColumnType { .. }
109 )
110 }
111
112 pub fn inverse(&self) -> Option<Self> {
117 match self {
118 SchemaOperation::CreateTable(table) => {
119 Some(SchemaOperation::DropTable(table.name.clone()))
120 }
121 SchemaOperation::DropTable(_) => None,
122 SchemaOperation::RenameTable { from, to } => Some(SchemaOperation::RenameTable {
123 from: to.clone(),
124 to: from.clone(),
125 }),
126 SchemaOperation::AddColumn { table, column } => Some(SchemaOperation::DropColumn {
127 table: table.clone(),
128 column: column.name.clone(),
129 }),
130 SchemaOperation::DropColumn { .. } => None,
131 SchemaOperation::AlterColumnType {
132 table,
133 column,
134 from_type,
135 to_type,
136 } => Some(SchemaOperation::AlterColumnType {
137 table: table.clone(),
138 column: column.clone(),
139 from_type: to_type.clone(),
140 to_type: from_type.clone(),
141 }),
142 SchemaOperation::AlterColumnNullable {
143 table,
144 column,
145 from_nullable,
146 to_nullable,
147 } => Some(SchemaOperation::AlterColumnNullable {
148 table: table.clone(),
149 column: column.clone(),
150 from_nullable: *to_nullable,
151 to_nullable: *from_nullable,
152 }),
153 SchemaOperation::AlterColumnDefault {
154 table,
155 column,
156 from_default,
157 to_default,
158 } => Some(SchemaOperation::AlterColumnDefault {
159 table: table.clone(),
160 column: column.clone(),
161 from_default: to_default.clone(),
162 to_default: from_default.clone(),
163 }),
164 SchemaOperation::RenameColumn { table, from, to } => {
165 Some(SchemaOperation::RenameColumn {
166 table: table.clone(),
167 from: to.clone(),
168 to: from.clone(),
169 })
170 }
171 SchemaOperation::AddPrimaryKey { table, .. } => Some(SchemaOperation::DropPrimaryKey {
172 table: table.clone(),
173 }),
174 SchemaOperation::DropPrimaryKey { .. } => None,
175 SchemaOperation::AddForeignKey { table, fk } => Some(SchemaOperation::DropForeignKey {
176 table: table.clone(),
177 name: fk_effective_name(table, fk),
178 }),
179 SchemaOperation::DropForeignKey { .. } => None,
180 SchemaOperation::AddUnique { table, constraint } => Some(SchemaOperation::DropUnique {
181 table: table.clone(),
182 name: unique_effective_name(constraint),
183 }),
184 SchemaOperation::DropUnique { .. } => None,
185 SchemaOperation::CreateIndex { table, index } => Some(SchemaOperation::DropIndex {
186 table: table.clone(),
187 name: index.name.clone(),
188 }),
189 SchemaOperation::DropIndex { .. } => None,
190 }
191 }
192
193 pub fn table(&self) -> Option<&str> {
195 match self {
196 SchemaOperation::CreateTable(t) => Some(&t.name),
197 SchemaOperation::DropTable(name) => Some(name),
198 SchemaOperation::RenameTable { from, .. } => Some(from),
199 SchemaOperation::AddColumn { table, .. }
200 | SchemaOperation::DropColumn { table, .. }
201 | SchemaOperation::AlterColumnType { table, .. }
202 | SchemaOperation::AlterColumnNullable { table, .. }
203 | SchemaOperation::AlterColumnDefault { table, .. }
204 | SchemaOperation::RenameColumn { table, .. }
205 | SchemaOperation::AddPrimaryKey { table, .. }
206 | SchemaOperation::DropPrimaryKey { table }
207 | SchemaOperation::AddForeignKey { table, .. }
208 | SchemaOperation::DropForeignKey { table, .. }
209 | SchemaOperation::AddUnique { table, .. }
210 | SchemaOperation::DropUnique { table, .. }
211 | SchemaOperation::CreateIndex { table, .. }
212 | SchemaOperation::DropIndex { table, .. } => Some(table),
213 }
214 }
215
216 fn priority(&self) -> u8 {
218 match self {
233 SchemaOperation::DropForeignKey { .. } => 1,
234 SchemaOperation::DropIndex { .. } => 2,
235 SchemaOperation::DropUnique { .. } => 3,
236 SchemaOperation::DropPrimaryKey { .. } => 4,
237 SchemaOperation::DropColumn { .. } => 5,
238 SchemaOperation::AlterColumnType { .. } => 6,
239 SchemaOperation::AlterColumnNullable { .. } => 7,
240 SchemaOperation::AlterColumnDefault { .. } => 8,
241 SchemaOperation::AddColumn { .. } => 9,
242 SchemaOperation::CreateTable(_) => 10,
243 SchemaOperation::RenameTable { .. } => 11,
244 SchemaOperation::RenameColumn { .. } => 12,
245 SchemaOperation::AddPrimaryKey { .. } => 13,
246 SchemaOperation::AddUnique { .. } => 14,
247 SchemaOperation::CreateIndex { .. } => 15,
248 SchemaOperation::AddForeignKey { .. } => 16,
249 SchemaOperation::DropTable(_) => 17,
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum WarningSeverity {
261 Info,
263 Warning,
265 DataLoss,
267}
268
269#[derive(Debug, Clone)]
271pub struct DiffWarning {
272 pub severity: WarningSeverity,
274 pub message: String,
276 pub operation_index: Option<usize>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum DestructivePolicy {
283 Skip,
285 #[default]
287 Warn,
288 Allow,
290}
291
292#[derive(Debug)]
294pub struct SchemaDiff {
295 pub destructive_policy: DestructivePolicy,
297 pub operations: Vec<SchemaOperation>,
299 pub warnings: Vec<DiffWarning>,
301}
302
303impl SchemaDiff {
304 pub fn new(destructive_policy: DestructivePolicy) -> Self {
306 Self {
307 destructive_policy,
308 operations: Vec::new(),
309 warnings: Vec::new(),
310 }
311 }
312
313 pub fn is_empty(&self) -> bool {
315 self.operations.is_empty()
316 }
317
318 pub fn len(&self) -> usize {
320 self.operations.len()
321 }
322
323 pub fn has_destructive(&self) -> bool {
325 self.operations.iter().any(|op| op.is_destructive())
326 }
327
328 pub fn destructive_operations(&self) -> Vec<&SchemaOperation> {
330 self.operations
331 .iter()
332 .filter(|op| op.is_destructive())
333 .collect()
334 }
335
336 pub fn requires_confirmation(&self) -> bool {
338 self.destructive_policy == DestructivePolicy::Warn && self.has_destructive()
339 }
340
341 pub fn order_operations(&mut self) {
343 self.operations.sort_by_key(|op| op.priority());
344 }
345
346 fn add_op(&mut self, op: SchemaOperation) -> usize {
348 let index = self.operations.len();
349 self.operations.push(op);
350 index
351 }
352
353 fn warn(
355 &mut self,
356 severity: WarningSeverity,
357 message: impl Into<String>,
358 operation_index: Option<usize>,
359 ) {
360 self.warnings.push(DiffWarning {
361 severity,
362 message: message.into(),
363 operation_index,
364 });
365 }
366
367 fn add_destructive_op(
368 &mut self,
369 op: SchemaOperation,
370 warn_severity: WarningSeverity,
371 warn_message: impl Into<String>,
372 ) {
373 let warn_message = warn_message.into();
374 match self.destructive_policy {
375 DestructivePolicy::Skip => {
376 self.warn(
377 WarningSeverity::Warning,
378 format!("Skipped destructive operation: {}", warn_message),
379 None,
380 );
381 }
382 DestructivePolicy::Warn => {
383 let op_index = self.add_op(op);
384 self.warn(warn_severity, warn_message, Some(op_index));
385 }
386 DestructivePolicy::Allow => {
387 self.add_op(op);
388 }
389 }
390 }
391}
392
393impl Default for SchemaDiff {
394 fn default() -> Self {
395 Self::new(DestructivePolicy::Warn)
396 }
397}
398
399pub fn schema_diff(current: &DatabaseSchema, expected: &DatabaseSchema) -> SchemaDiff {
420 schema_diff_with_policy(current, expected, DestructivePolicy::Warn)
421}
422
423pub fn schema_diff_with_policy(
425 current: &DatabaseSchema,
426 expected: &DatabaseSchema,
427 destructive_policy: DestructivePolicy,
428) -> SchemaDiff {
429 SchemaDiffer::new(destructive_policy).diff(current, expected)
430}
431
432#[derive(Debug, Clone, Copy)]
434pub struct SchemaDiffer {
435 destructive_policy: DestructivePolicy,
436}
437
438impl SchemaDiffer {
439 pub const fn new(destructive_policy: DestructivePolicy) -> Self {
440 Self { destructive_policy }
441 }
442
443 pub fn diff(&self, current: &DatabaseSchema, expected: &DatabaseSchema) -> SchemaDiff {
444 let mut diff = SchemaDiff::new(self.destructive_policy);
445
446 let renames = detect_table_renames(current, expected, expected.dialect);
448 let mut renamed_from: HashSet<&str> = HashSet::new();
449 let mut renamed_to: HashSet<&str> = HashSet::new();
450 for (from, to) in &renames {
451 renamed_from.insert(from.as_str());
452 renamed_to.insert(to.as_str());
453 diff.add_op(SchemaOperation::RenameTable {
454 from: from.clone(),
455 to: to.clone(),
456 });
457 }
458
459 for (name, table) in &expected.tables {
461 if renamed_to.contains(name.as_str()) {
462 continue;
463 }
464 if !current.tables.contains_key(name) {
465 diff.add_op(SchemaOperation::CreateTable(table.clone()));
466 }
467 }
468
469 for name in current.tables.keys() {
471 if renamed_from.contains(name.as_str()) {
472 continue;
473 }
474 if !expected.tables.contains_key(name) {
475 diff.add_destructive_op(
476 SchemaOperation::DropTable(name.clone()),
477 WarningSeverity::DataLoss,
478 format!("Dropping table '{}' will delete all data", name),
479 );
480 }
481 }
482
483 for (name, expected_table) in &expected.tables {
485 if let Some(current_table) = current.tables.get(name) {
486 diff_table(current_table, expected_table, expected.dialect, &mut diff);
487 }
488 }
489
490 diff.order_operations();
492
493 diff
494 }
495}
496
497fn diff_table(current: &TableInfo, expected: &TableInfo, dialect: Dialect, diff: &mut SchemaDiff) {
499 let table = ¤t.name;
500
501 diff_columns(table, ¤t.columns, &expected.columns, dialect, diff);
503
504 diff_primary_key(table, ¤t.primary_key, &expected.primary_key, diff);
506
507 diff_foreign_keys(table, ¤t.foreign_keys, &expected.foreign_keys, diff);
509
510 diff_unique_constraints(
512 table,
513 ¤t.unique_constraints,
514 &expected.unique_constraints,
515 diff,
516 );
517
518 diff_indexes(table, ¤t.indexes, &expected.indexes, diff);
520}
521
522fn diff_columns(
524 table: &str,
525 current: &[ColumnInfo],
526 expected: &[ColumnInfo],
527 dialect: Dialect,
528 diff: &mut SchemaDiff,
529) {
530 let current_map: HashMap<&str, &ColumnInfo> =
531 current.iter().map(|c| (c.name.as_str(), c)).collect();
532 let expected_map: HashMap<&str, &ColumnInfo> =
533 expected.iter().map(|c| (c.name.as_str(), c)).collect();
534
535 let removed: Vec<&ColumnInfo> = current
537 .iter()
538 .filter(|c| !expected_map.contains_key(c.name.as_str()))
539 .collect();
540 let added: Vec<&ColumnInfo> = expected
541 .iter()
542 .filter(|c| !current_map.contains_key(c.name.as_str()))
543 .collect();
544
545 let col_renames = detect_column_renames(&removed, &added, dialect);
546 let mut renamed_from: HashSet<&str> = HashSet::new();
547 let mut renamed_to: HashSet<&str> = HashSet::new();
548 for (from, to) in &col_renames {
549 renamed_from.insert(from.as_str());
550 renamed_to.insert(to.as_str());
551 diff.add_op(SchemaOperation::RenameColumn {
552 table: table.to_string(),
553 from: from.clone(),
554 to: to.clone(),
555 });
556 }
557
558 for (name, col) in &expected_map {
560 if renamed_to.contains(*name) {
561 continue;
562 }
563 if !current_map.contains_key(name) {
564 diff.add_op(SchemaOperation::AddColumn {
565 table: table.to_string(),
566 column: (*col).clone(),
567 });
568 }
569 }
570
571 for name in current_map.keys() {
573 if renamed_from.contains(*name) {
574 continue;
575 }
576 if !expected_map.contains_key(name) {
577 diff.add_destructive_op(
578 SchemaOperation::DropColumn {
579 table: table.to_string(),
580 column: (*name).to_string(),
581 },
582 WarningSeverity::DataLoss,
583 format!("Dropping column '{}.{}' will delete data", table, name),
584 );
585 }
586 }
587
588 for (name, expected_col) in &expected_map {
590 if let Some(current_col) = current_map.get(name) {
591 diff_column_details(table, current_col, expected_col, dialect, diff);
592 }
593 }
594}
595
596fn diff_column_details(
598 table: &str,
599 current: &ColumnInfo,
600 expected: &ColumnInfo,
601 dialect: Dialect,
602 diff: &mut SchemaDiff,
603) {
604 let col = ¤t.name;
605
606 let current_type = normalize_type(¤t.sql_type, dialect);
608 let expected_type = normalize_type(&expected.sql_type, dialect);
609
610 if current_type != expected_type {
611 diff.add_destructive_op(
612 SchemaOperation::AlterColumnType {
613 table: table.to_string(),
614 column: col.clone(),
615 from_type: current.sql_type.clone(),
616 to_type: expected.sql_type.clone(),
617 },
618 WarningSeverity::Warning,
619 format!(
620 "Changing type of '{}.{}' from {} to {} may cause data conversion issues",
621 table, col, current.sql_type, expected.sql_type
622 ),
623 );
624 }
625
626 if current.nullable != expected.nullable {
628 let op_index = diff.add_op(SchemaOperation::AlterColumnNullable {
629 table: table.to_string(),
630 column: col.clone(),
631 from_nullable: current.nullable,
632 to_nullable: expected.nullable,
633 });
634
635 if !expected.nullable {
636 diff.warn(
637 WarningSeverity::Warning,
638 format!(
639 "Making '{}.{}' NOT NULL may fail if column contains NULL values",
640 table, col
641 ),
642 Some(op_index),
643 );
644 }
645 }
646
647 if current.default != expected.default {
649 diff.add_op(SchemaOperation::AlterColumnDefault {
650 table: table.to_string(),
651 column: col.clone(),
652 from_default: current.default.clone(),
653 to_default: expected.default.clone(),
654 });
655 }
656}
657
658fn diff_primary_key(table: &str, current: &[String], expected: &[String], diff: &mut SchemaDiff) {
660 let current_set: HashSet<&str> = current.iter().map(|s| s.as_str()).collect();
661 let expected_set: HashSet<&str> = expected.iter().map(|s| s.as_str()).collect();
662
663 if current_set != expected_set {
664 if !current.is_empty() {
666 diff.add_op(SchemaOperation::DropPrimaryKey {
667 table: table.to_string(),
668 });
669 }
670
671 if !expected.is_empty() {
673 diff.add_op(SchemaOperation::AddPrimaryKey {
674 table: table.to_string(),
675 columns: expected.to_vec(),
676 });
677 }
678 }
679}
680
681fn diff_foreign_keys(
683 table: &str,
684 current: &[ForeignKeyInfo],
685 expected: &[ForeignKeyInfo],
686 diff: &mut SchemaDiff,
687) {
688 let current_map: HashMap<&str, &ForeignKeyInfo> =
690 current.iter().map(|fk| (fk.column.as_str(), fk)).collect();
691 let expected_map: HashMap<&str, &ForeignKeyInfo> =
692 expected.iter().map(|fk| (fk.column.as_str(), fk)).collect();
693
694 for (col, fk) in &expected_map {
696 if !current_map.contains_key(col) {
697 diff.add_op(SchemaOperation::AddForeignKey {
698 table: table.to_string(),
699 fk: (*fk).clone(),
700 });
701 }
702 }
703
704 for (col, fk) in ¤t_map {
706 if !expected_map.contains_key(col) {
707 let name = fk_effective_name(table, fk);
708 diff.add_op(SchemaOperation::DropForeignKey {
709 table: table.to_string(),
710 name,
711 });
712 }
713 }
714
715 for (col, expected_fk) in &expected_map {
717 if let Some(current_fk) = current_map.get(col) {
718 if !fk_matches(current_fk, expected_fk) {
719 let name = fk_effective_name(table, current_fk);
721 diff.add_op(SchemaOperation::DropForeignKey {
722 table: table.to_string(),
723 name,
724 });
725 diff.add_op(SchemaOperation::AddForeignKey {
726 table: table.to_string(),
727 fk: (*expected_fk).clone(),
728 });
729 }
730 }
731 }
732}
733
734fn fk_matches(current: &ForeignKeyInfo, expected: &ForeignKeyInfo) -> bool {
736 current.foreign_table == expected.foreign_table
737 && current.foreign_column == expected.foreign_column
738 && current.on_delete == expected.on_delete
739 && current.on_update == expected.on_update
740}
741
742fn diff_unique_constraints(
744 table: &str,
745 current: &[UniqueConstraintInfo],
746 expected: &[UniqueConstraintInfo],
747 diff: &mut SchemaDiff,
748) {
749 let current_set: HashSet<Vec<&str>> = current
751 .iter()
752 .map(|u| u.columns.iter().map(|s| s.as_str()).collect())
753 .collect();
754 let expected_set: HashSet<Vec<&str>> = expected
755 .iter()
756 .map(|u| u.columns.iter().map(|s| s.as_str()).collect())
757 .collect();
758
759 for constraint in expected {
761 let cols: Vec<&str> = constraint.columns.iter().map(|s| s.as_str()).collect();
762 if !current_set.contains(&cols) {
763 diff.add_op(SchemaOperation::AddUnique {
764 table: table.to_string(),
765 constraint: constraint.clone(),
766 });
767 }
768 }
769
770 for constraint in current {
772 let cols: Vec<&str> = constraint.columns.iter().map(|s| s.as_str()).collect();
773 if !expected_set.contains(&cols) {
774 let name = unique_effective_name(constraint);
775 diff.add_op(SchemaOperation::DropUnique {
776 table: table.to_string(),
777 name,
778 });
779 }
780 }
781}
782
783fn diff_indexes(table: &str, current: &[IndexInfo], expected: &[IndexInfo], diff: &mut SchemaDiff) {
785 let current_filtered: Vec<_> = current.iter().filter(|i| !i.primary).collect();
787 let expected_filtered: Vec<_> = expected.iter().filter(|i| !i.primary).collect();
788
789 let current_map: HashMap<&str, &&IndexInfo> = current_filtered
791 .iter()
792 .map(|i| (i.name.as_str(), i))
793 .collect();
794 let expected_map: HashMap<&str, &&IndexInfo> = expected_filtered
795 .iter()
796 .map(|i| (i.name.as_str(), i))
797 .collect();
798
799 for (name, index) in &expected_map {
801 if !current_map.contains_key(name) {
802 diff.add_op(SchemaOperation::CreateIndex {
803 table: table.to_string(),
804 index: (**index).clone(),
805 });
806 }
807 }
808
809 for name in current_map.keys() {
811 if !expected_map.contains_key(name) {
812 diff.add_op(SchemaOperation::DropIndex {
813 table: table.to_string(),
814 name: (*name).to_string(),
815 });
816 }
817 }
818
819 for (name, expected_idx) in &expected_map {
821 if let Some(current_idx) = current_map.get(name) {
822 if current_idx.columns != expected_idx.columns
823 || current_idx.unique != expected_idx.unique
824 {
825 diff.add_op(SchemaOperation::DropIndex {
827 table: table.to_string(),
828 name: (*name).to_string(),
829 });
830 diff.add_op(SchemaOperation::CreateIndex {
831 table: table.to_string(),
832 index: (**expected_idx).clone(),
833 });
834 }
835 }
836 }
837}
838
839fn column_signature(col: &ColumnInfo, dialect: Dialect) -> String {
844 let ty = normalize_type(&col.sql_type, dialect);
845 let default = col.default.as_deref().unwrap_or("");
846 format!(
847 "type={};nullable={};default={};pk={};ai={}",
848 ty, col.nullable, default, col.primary_key, col.auto_increment
849 )
850}
851
852fn detect_column_renames(
853 removed: &[&ColumnInfo],
854 added: &[&ColumnInfo],
855 dialect: Dialect,
856) -> Vec<(String, String)> {
857 let mut removed_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
858 let mut added_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
859
860 for col in removed {
861 removed_by_sig
862 .entry(column_signature(col, dialect))
863 .or_default()
864 .push(*col);
865 }
866 for col in added {
867 added_by_sig
868 .entry(column_signature(col, dialect))
869 .or_default()
870 .push(*col);
871 }
872
873 let mut renames = Vec::new();
874 for (sig, removed_cols) in removed_by_sig {
875 if removed_cols.len() != 1 {
876 continue;
877 }
878 let Some(added_cols) = added_by_sig.get(&sig) else {
879 continue;
880 };
881 if added_cols.len() != 1 {
882 continue;
883 }
884 renames.push((removed_cols[0].name.clone(), added_cols[0].name.clone()));
885 }
886
887 renames.sort_by(|a, b| a.0.cmp(&b.0));
888 renames
889}
890
891fn table_signature(table: &TableInfo, dialect: Dialect) -> String {
892 let mut parts = Vec::new();
893
894 let mut cols: Vec<String> = table
895 .columns
896 .iter()
897 .map(|c| {
898 let ty = normalize_type(&c.sql_type, dialect);
899 let default = c.default.as_deref().unwrap_or("");
900 format!(
901 "{}:{}:{}:{}:{}:{}",
902 c.name, ty, c.nullable, default, c.primary_key, c.auto_increment
903 )
904 })
905 .collect();
906 cols.sort();
907 parts.push(format!("cols={}", cols.join(",")));
908
909 let mut pk = table.primary_key.clone();
910 pk.sort();
911 parts.push(format!("pk={}", pk.join(",")));
912
913 let mut fks: Vec<String> = table
914 .foreign_keys
915 .iter()
916 .map(|fk| {
917 let on_delete = fk.on_delete.as_deref().unwrap_or("");
918 let on_update = fk.on_update.as_deref().unwrap_or("");
919 format!(
920 "{}->{}.{}:{}:{}",
921 fk.column, fk.foreign_table, fk.foreign_column, on_delete, on_update
922 )
923 })
924 .collect();
925 fks.sort();
926 parts.push(format!("fks={}", fks.join("|")));
927
928 let mut uniques: Vec<String> = table
929 .unique_constraints
930 .iter()
931 .map(|u| {
932 let mut cols = u.columns.clone();
933 cols.sort();
934 cols.join(",")
935 })
936 .collect();
937 uniques.sort();
938 parts.push(format!("uniques={}", uniques.join("|")));
939
940 let mut checks: Vec<String> = table
941 .check_constraints
942 .iter()
943 .map(|c| c.expression.trim().to_string())
944 .collect();
945 checks.sort();
946 parts.push(format!("checks={}", checks.join("|")));
947
948 let mut indexes: Vec<String> = table
949 .indexes
950 .iter()
951 .map(|i| {
952 let ty = i.index_type.as_deref().unwrap_or("");
953 format!("{}:{}:{}:{}", i.columns.join(","), i.unique, i.primary, ty)
954 })
955 .collect();
956 indexes.sort();
957 parts.push(format!("indexes={}", indexes.join("|")));
958
959 parts.join(";")
960}
961
962fn detect_table_renames(
963 current: &DatabaseSchema,
964 expected: &DatabaseSchema,
965 dialect: Dialect,
966) -> Vec<(String, String)> {
967 let current_only: Vec<&TableInfo> = current
968 .tables
969 .values()
970 .filter(|t| !expected.tables.contains_key(&t.name))
971 .collect();
972 let expected_only: Vec<&TableInfo> = expected
973 .tables
974 .values()
975 .filter(|t| !current.tables.contains_key(&t.name))
976 .collect();
977
978 let mut current_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
979 let mut expected_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
980
981 for table in current_only {
982 current_by_sig
983 .entry(table_signature(table, dialect))
984 .or_default()
985 .push(table);
986 }
987 for table in expected_only {
988 expected_by_sig
989 .entry(table_signature(table, dialect))
990 .or_default()
991 .push(table);
992 }
993
994 let mut renames = Vec::new();
995 for (sig, current_tables) in current_by_sig {
996 if current_tables.len() != 1 {
997 continue;
998 }
999 let Some(expected_tables) = expected_by_sig.get(&sig) else {
1000 continue;
1001 };
1002 if expected_tables.len() != 1 {
1003 continue;
1004 }
1005
1006 renames.push((
1007 current_tables[0].name.clone(),
1008 expected_tables[0].name.clone(),
1009 ));
1010 }
1011
1012 renames.sort_by(|a, b| a.0.cmp(&b.0));
1013 renames
1014}
1015
1016fn normalize_type(sql_type: &str, dialect: Dialect) -> String {
1022 let upper = sql_type.to_uppercase();
1023
1024 match dialect {
1025 Dialect::Sqlite => {
1026 if upper.contains("INT") {
1028 "INTEGER".to_string()
1029 } else if upper.contains("CHAR") || upper.contains("TEXT") || upper.contains("CLOB") {
1030 "TEXT".to_string()
1031 } else if upper.contains("REAL") || upper.contains("FLOAT") || upper.contains("DOUB") {
1032 "REAL".to_string()
1033 } else if upper.contains("BLOB") || upper.is_empty() {
1034 "BLOB".to_string()
1035 } else {
1036 upper
1037 }
1038 }
1039 Dialect::Postgres => match upper.as_str() {
1040 "INT" | "INT4" => "INTEGER".to_string(),
1041 "INT8" => "BIGINT".to_string(),
1042 "INT2" => "SMALLINT".to_string(),
1043 "FLOAT4" => "REAL".to_string(),
1044 "FLOAT8" => "DOUBLE PRECISION".to_string(),
1045 "BOOL" => "BOOLEAN".to_string(),
1046 "SERIAL" => "INTEGER".to_string(),
1047 "BIGSERIAL" => "BIGINT".to_string(),
1048 "SMALLSERIAL" => "SMALLINT".to_string(),
1049 _ => upper,
1050 },
1051 Dialect::Mysql => match upper.as_str() {
1052 "INTEGER" => "INT".to_string(),
1053 "BOOL" | "BOOLEAN" => "TINYINT".to_string(),
1054 _ => upper,
1055 },
1056 }
1057}
1058
1059#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use crate::introspect::ParsedSqlType;
1067
1068 fn make_column(name: &str, sql_type: &str, nullable: bool) -> ColumnInfo {
1069 ColumnInfo {
1070 name: name.to_string(),
1071 sql_type: sql_type.to_string(),
1072 parsed_type: ParsedSqlType::parse(sql_type),
1073 nullable,
1074 default: None,
1075 primary_key: false,
1076 auto_increment: false,
1077 comment: None,
1078 }
1079 }
1080
1081 fn make_table(name: &str, columns: Vec<ColumnInfo>) -> TableInfo {
1082 TableInfo {
1083 name: name.to_string(),
1084 columns,
1085 primary_key: Vec::new(),
1086 foreign_keys: Vec::new(),
1087 unique_constraints: Vec::new(),
1088 check_constraints: Vec::new(),
1089 indexes: Vec::new(),
1090 comment: None,
1091 }
1092 }
1093
1094 #[test]
1095 fn test_schema_diff_new_table() {
1096 let current = DatabaseSchema::new(Dialect::Sqlite);
1097 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1098 expected.tables.insert(
1099 "heroes".to_string(),
1100 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1101 );
1102
1103 let diff = schema_diff(¤t, &expected);
1104 assert_eq!(diff.len(), 1);
1105 assert!(
1106 matches!(&diff.operations[0], SchemaOperation::CreateTable(t) if t.name == "heroes")
1107 );
1108 }
1109
1110 #[test]
1111 fn test_schema_diff_rename_table() {
1112 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1113 current.tables.insert(
1114 "heroes_old".to_string(),
1115 make_table("heroes_old", vec![make_column("id", "INTEGER", false)]),
1116 );
1117
1118 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1119 expected.tables.insert(
1120 "heroes".to_string(),
1121 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1122 );
1123
1124 let diff = schema_diff(¤t, &expected);
1125 assert!(diff.operations.iter().any(|op| {
1126 matches!(op, SchemaOperation::RenameTable { from, to } if from == "heroes_old" && to == "heroes")
1127 }));
1128 assert!(!diff.operations.iter().any(|op| matches!(
1129 op,
1130 SchemaOperation::CreateTable(_) | SchemaOperation::DropTable(_)
1131 )));
1132 }
1133
1134 #[test]
1135 fn test_schema_diff_drop_table() {
1136 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1137 current.tables.insert(
1138 "heroes".to_string(),
1139 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1140 );
1141 let expected = DatabaseSchema::new(Dialect::Sqlite);
1142
1143 let diff = schema_diff(¤t, &expected);
1144 assert_eq!(diff.len(), 1);
1145 assert!(
1146 matches!(&diff.operations[0], SchemaOperation::DropTable(name) if name == "heroes")
1147 );
1148 assert!(diff.has_destructive());
1149 assert!(diff.requires_confirmation());
1150 assert_eq!(diff.warnings.len(), 1);
1151 assert_eq!(diff.warnings[0].severity, WarningSeverity::DataLoss);
1152 }
1153
1154 #[test]
1155 fn test_schema_diff_drop_table_allow_policy() {
1156 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1157 current.tables.insert(
1158 "heroes".to_string(),
1159 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1160 );
1161 let expected = DatabaseSchema::new(Dialect::Sqlite);
1162
1163 let diff = schema_diff_with_policy(¤t, &expected, DestructivePolicy::Allow);
1164 assert_eq!(diff.len(), 1);
1165 assert!(diff.has_destructive());
1166 assert!(!diff.requires_confirmation());
1167 assert!(diff.warnings.is_empty());
1168 }
1169
1170 #[test]
1171 fn test_schema_diff_drop_table_skip_policy() {
1172 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1173 current.tables.insert(
1174 "heroes".to_string(),
1175 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1176 );
1177 let expected = DatabaseSchema::new(Dialect::Sqlite);
1178
1179 let diff = schema_diff_with_policy(¤t, &expected, DestructivePolicy::Skip);
1180 assert!(diff.operations.is_empty());
1181 assert!(!diff.has_destructive());
1182 assert!(!diff.requires_confirmation());
1183 assert!(
1184 diff.warnings
1185 .iter()
1186 .any(|w| w.message.contains("Skipped destructive operation"))
1187 );
1188 }
1189
1190 #[test]
1191 fn test_schema_diff_add_column() {
1192 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1193 current.tables.insert(
1194 "heroes".to_string(),
1195 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1196 );
1197
1198 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1199 expected.tables.insert(
1200 "heroes".to_string(),
1201 make_table(
1202 "heroes",
1203 vec![
1204 make_column("id", "INTEGER", false),
1205 make_column("name", "TEXT", false),
1206 ],
1207 ),
1208 );
1209
1210 let diff = schema_diff(¤t, &expected);
1211 assert!(diff
1212 .operations
1213 .iter()
1214 .any(|op| matches!(op, SchemaOperation::AddColumn { table, column } if table == "heroes" && column.name == "name")));
1215 }
1216
1217 #[test]
1218 fn test_schema_diff_drop_column() {
1219 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1220 current.tables.insert(
1221 "heroes".to_string(),
1222 make_table(
1223 "heroes",
1224 vec![
1225 make_column("id", "INTEGER", false),
1226 make_column("old_field", "TEXT", true),
1227 ],
1228 ),
1229 );
1230
1231 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1232 expected.tables.insert(
1233 "heroes".to_string(),
1234 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1235 );
1236
1237 let diff = schema_diff(¤t, &expected);
1238 assert!(diff.has_destructive());
1239 assert!(diff.operations.iter().any(
1240 |op| matches!(op, SchemaOperation::DropColumn { table, column } if table == "heroes" && column == "old_field")
1241 ));
1242 }
1243
1244 #[test]
1245 fn test_schema_diff_rename_column() {
1246 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1247 current.tables.insert(
1248 "heroes".to_string(),
1249 make_table("heroes", vec![make_column("old_name", "TEXT", false)]),
1250 );
1251
1252 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1253 expected.tables.insert(
1254 "heroes".to_string(),
1255 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1256 );
1257
1258 let diff = schema_diff(¤t, &expected);
1259 assert!(diff.operations.iter().any(|op| {
1260 matches!(op, SchemaOperation::RenameColumn { table, from, to } if table == "heroes" && from == "old_name" && to == "name")
1261 }));
1262 assert!(!diff.operations.iter().any(|op| matches!(
1263 op,
1264 SchemaOperation::AddColumn { .. } | SchemaOperation::DropColumn { .. }
1265 )));
1266 assert!(!diff.has_destructive());
1267 }
1268
1269 #[test]
1270 fn test_schema_diff_alter_column_type() {
1271 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1272 current.tables.insert(
1273 "heroes".to_string(),
1274 make_table("heroes", vec![make_column("age", "INTEGER", false)]),
1275 );
1276
1277 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1278 expected.tables.insert(
1279 "heroes".to_string(),
1280 make_table("heroes", vec![make_column("age", "REAL", false)]),
1281 );
1282
1283 let diff = schema_diff(¤t, &expected);
1284 assert!(diff.operations.iter().any(
1285 |op| matches!(op, SchemaOperation::AlterColumnType { table, column, .. } if table == "heroes" && column == "age")
1286 ));
1287 }
1288
1289 #[test]
1290 fn test_schema_diff_alter_nullable() {
1291 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1292 current.tables.insert(
1293 "heroes".to_string(),
1294 make_table("heroes", vec![make_column("name", "TEXT", true)]),
1295 );
1296
1297 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1298 expected.tables.insert(
1299 "heroes".to_string(),
1300 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1301 );
1302
1303 let diff = schema_diff(¤t, &expected);
1304 assert!(diff.operations.iter().any(
1305 |op| matches!(op, SchemaOperation::AlterColumnNullable { table, column, to_nullable: false, .. } if table == "heroes" && column == "name")
1306 ));
1307 }
1308
1309 #[test]
1310 fn test_schema_diff_empty() {
1311 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1312 current.tables.insert(
1313 "heroes".to_string(),
1314 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1315 );
1316
1317 let expected = current.clone();
1318
1319 let diff = schema_diff(¤t, &expected);
1320 assert!(diff.is_empty());
1321 }
1322
1323 #[test]
1324 fn test_schema_diff_foreign_key_add() {
1325 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1326 current.tables.insert(
1327 "heroes".to_string(),
1328 make_table("heroes", vec![make_column("team_id", "INTEGER", true)]),
1329 );
1330
1331 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1332 let mut heroes = make_table("heroes", vec![make_column("team_id", "INTEGER", true)]);
1333 heroes.foreign_keys.push(ForeignKeyInfo {
1334 name: Some("fk_heroes_team".to_string()),
1335 column: "team_id".to_string(),
1336 foreign_table: "teams".to_string(),
1337 foreign_column: "id".to_string(),
1338 on_delete: Some("CASCADE".to_string()),
1339 on_update: None,
1340 });
1341 expected.tables.insert("heroes".to_string(), heroes);
1342
1343 let diff = schema_diff(¤t, &expected);
1344 assert!(diff
1345 .operations
1346 .iter()
1347 .any(|op| matches!(op, SchemaOperation::AddForeignKey { table, fk } if table == "heroes" && fk.column == "team_id")));
1348 }
1349
1350 #[test]
1351 fn test_schema_diff_index_add() {
1352 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1353 current.tables.insert(
1354 "heroes".to_string(),
1355 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1356 );
1357
1358 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1359 let mut heroes = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1360 heroes.indexes.push(IndexInfo {
1361 name: "idx_heroes_name".to_string(),
1362 columns: vec!["name".to_string()],
1363 unique: false,
1364 index_type: None,
1365 primary: false,
1366 });
1367 expected.tables.insert("heroes".to_string(), heroes);
1368
1369 let diff = schema_diff(¤t, &expected);
1370 assert!(diff.operations.iter().any(
1371 |op| matches!(op, SchemaOperation::CreateIndex { table, index } if table == "heroes" && index.name == "idx_heroes_name")
1372 ));
1373 }
1374
1375 #[test]
1376 fn test_operation_ordering() {
1377 let mut diff = SchemaDiff::new(DestructivePolicy::Warn);
1378
1379 diff.add_op(SchemaOperation::AddForeignKey {
1381 table: "heroes".to_string(),
1382 fk: ForeignKeyInfo {
1383 name: None,
1384 column: "team_id".to_string(),
1385 foreign_table: "teams".to_string(),
1386 foreign_column: "id".to_string(),
1387 on_delete: None,
1388 on_update: None,
1389 },
1390 });
1391 diff.add_op(SchemaOperation::DropForeignKey {
1392 table: "old".to_string(),
1393 name: "fk_old".to_string(),
1394 });
1395 diff.add_op(SchemaOperation::AddColumn {
1396 table: "heroes".to_string(),
1397 column: make_column("age", "INTEGER", true),
1398 });
1399
1400 diff.order_operations();
1401
1402 assert!(matches!(
1404 &diff.operations[0],
1405 SchemaOperation::DropForeignKey { .. }
1406 ));
1407 assert!(matches!(
1409 &diff.operations[1],
1410 SchemaOperation::AddColumn { .. }
1411 ));
1412 assert!(matches!(
1413 &diff.operations[2],
1414 SchemaOperation::AddForeignKey { .. }
1415 ));
1416 }
1417
1418 #[test]
1419 fn test_type_normalization_sqlite() {
1420 assert_eq!(normalize_type("INT", Dialect::Sqlite), "INTEGER");
1421 assert_eq!(normalize_type("BIGINT", Dialect::Sqlite), "INTEGER");
1422 assert_eq!(normalize_type("VARCHAR(100)", Dialect::Sqlite), "TEXT");
1423 assert_eq!(normalize_type("FLOAT", Dialect::Sqlite), "REAL");
1424 }
1425
1426 #[test]
1427 fn test_type_normalization_postgres() {
1428 assert_eq!(normalize_type("INT", Dialect::Postgres), "INTEGER");
1429 assert_eq!(normalize_type("INT4", Dialect::Postgres), "INTEGER");
1430 assert_eq!(normalize_type("INT8", Dialect::Postgres), "BIGINT");
1431 assert_eq!(normalize_type("SERIAL", Dialect::Postgres), "INTEGER");
1432 }
1433
1434 #[test]
1435 fn test_type_normalization_mysql() {
1436 assert_eq!(normalize_type("INTEGER", Dialect::Mysql), "INT");
1437 assert_eq!(normalize_type("BOOLEAN", Dialect::Mysql), "TINYINT");
1438 }
1439
1440 #[test]
1441 fn test_schema_operation_is_destructive() {
1442 assert!(SchemaOperation::DropTable("heroes".to_string()).is_destructive());
1443 assert!(
1444 SchemaOperation::DropColumn {
1445 table: "heroes".to_string(),
1446 column: "age".to_string(),
1447 }
1448 .is_destructive()
1449 );
1450 assert!(
1451 SchemaOperation::AlterColumnType {
1452 table: "heroes".to_string(),
1453 column: "age".to_string(),
1454 from_type: "TEXT".to_string(),
1455 to_type: "INTEGER".to_string(),
1456 }
1457 .is_destructive()
1458 );
1459 assert!(
1460 !SchemaOperation::AddColumn {
1461 table: "heroes".to_string(),
1462 column: make_column("name", "TEXT", false),
1463 }
1464 .is_destructive()
1465 );
1466 }
1467
1468 #[test]
1469 fn test_schema_operation_inverse() {
1470 let table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1471 let op = SchemaOperation::CreateTable(table);
1472 assert!(matches!(op.inverse(), Some(SchemaOperation::DropTable(name)) if name == "heroes"));
1473
1474 let op = SchemaOperation::AlterColumnType {
1475 table: "heroes".to_string(),
1476 column: "age".to_string(),
1477 from_type: "TEXT".to_string(),
1478 to_type: "INTEGER".to_string(),
1479 };
1480 assert!(
1481 matches!(op.inverse(), Some(SchemaOperation::AlterColumnType { from_type, to_type, .. }) if from_type == "INTEGER" && to_type == "TEXT")
1482 );
1483 }
1484}