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 && !fk_matches(current_fk, expected_fk)
964 {
965 let name = fk_effective_name(table, current_fk);
967 diff.add_op(SchemaOperation::DropForeignKey {
968 table: table.to_string(),
969 name,
970 table_info: Some(current_table.clone()),
971 });
972 diff.add_op(SchemaOperation::AddForeignKey {
973 table: table.to_string(),
974 fk: (*expected_fk).clone(),
975 table_info: Some(current_table.clone()),
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 && (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
1088fn column_signature(col: &ColumnInfo, dialect: Dialect) -> String {
1093 let ty = normalize_type(&col.sql_type, dialect);
1094 let default = col.default.as_deref().unwrap_or("");
1095 format!(
1096 "type={};nullable={};default={};pk={};ai={}",
1097 ty, col.nullable, default, col.primary_key, col.auto_increment
1098 )
1099}
1100
1101fn detect_column_renames(
1102 removed: &[&ColumnInfo],
1103 added: &[&ColumnInfo],
1104 dialect: Dialect,
1105) -> Vec<(String, String)> {
1106 let mut removed_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
1107 let mut added_by_sig: HashMap<String, Vec<&ColumnInfo>> = HashMap::new();
1108
1109 for col in removed {
1110 removed_by_sig
1111 .entry(column_signature(col, dialect))
1112 .or_default()
1113 .push(*col);
1114 }
1115 for col in added {
1116 added_by_sig
1117 .entry(column_signature(col, dialect))
1118 .or_default()
1119 .push(*col);
1120 }
1121
1122 let mut renames = Vec::new();
1123 for (sig, removed_cols) in removed_by_sig {
1124 if removed_cols.len() != 1 {
1125 continue;
1126 }
1127 let Some(added_cols) = added_by_sig.get(&sig) else {
1128 continue;
1129 };
1130 if added_cols.len() != 1 {
1131 continue;
1132 }
1133 renames.push((removed_cols[0].name.clone(), added_cols[0].name.clone()));
1134 }
1135
1136 renames.sort_by(|a, b| a.0.cmp(&b.0));
1137 renames
1138}
1139
1140fn table_signature(table: &TableInfo, dialect: Dialect) -> String {
1141 let mut parts = Vec::new();
1142
1143 let mut cols: Vec<String> = table
1144 .columns
1145 .iter()
1146 .map(|c| {
1147 let ty = normalize_type(&c.sql_type, dialect);
1148 let default = c.default.as_deref().unwrap_or("");
1149 format!(
1150 "{}:{}:{}:{}:{}:{}",
1151 c.name, ty, c.nullable, default, c.primary_key, c.auto_increment
1152 )
1153 })
1154 .collect();
1155 cols.sort();
1156 parts.push(format!("cols={}", cols.join(",")));
1157
1158 let mut pk = table.primary_key.clone();
1159 pk.sort();
1160 parts.push(format!("pk={}", pk.join(",")));
1161
1162 let mut fks: Vec<String> = table
1163 .foreign_keys
1164 .iter()
1165 .map(|fk| {
1166 let on_delete = fk.on_delete.as_deref().unwrap_or("");
1167 let on_update = fk.on_update.as_deref().unwrap_or("");
1168 format!(
1169 "{}->{}.{}:{}:{}",
1170 fk.column, fk.foreign_table, fk.foreign_column, on_delete, on_update
1171 )
1172 })
1173 .collect();
1174 fks.sort();
1175 parts.push(format!("fks={}", fks.join("|")));
1176
1177 let mut uniques: Vec<String> = table
1178 .unique_constraints
1179 .iter()
1180 .map(|u| {
1181 let mut cols = u.columns.clone();
1182 cols.sort();
1183 cols.join(",")
1184 })
1185 .collect();
1186 uniques.sort();
1187 parts.push(format!("uniques={}", uniques.join("|")));
1188
1189 let mut checks: Vec<String> = table
1190 .check_constraints
1191 .iter()
1192 .map(|c| c.expression.trim().to_string())
1193 .collect();
1194 checks.sort();
1195 parts.push(format!("checks={}", checks.join("|")));
1196
1197 let mut indexes: Vec<String> = table
1198 .indexes
1199 .iter()
1200 .map(|i| {
1201 let ty = i.index_type.as_deref().unwrap_or("");
1202 format!("{}:{}:{}:{}", i.columns.join(","), i.unique, i.primary, ty)
1203 })
1204 .collect();
1205 indexes.sort();
1206 parts.push(format!("indexes={}", indexes.join("|")));
1207
1208 parts.join(";")
1209}
1210
1211fn detect_table_renames(
1212 current: &DatabaseSchema,
1213 expected: &DatabaseSchema,
1214 dialect: Dialect,
1215) -> Vec<(String, String)> {
1216 let current_only: Vec<&TableInfo> = current
1217 .tables
1218 .values()
1219 .filter(|t| !expected.tables.contains_key(&t.name))
1220 .collect();
1221 let expected_only: Vec<&TableInfo> = expected
1222 .tables
1223 .values()
1224 .filter(|t| !current.tables.contains_key(&t.name))
1225 .collect();
1226
1227 let mut current_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
1228 let mut expected_by_sig: HashMap<String, Vec<&TableInfo>> = HashMap::new();
1229
1230 for table in current_only {
1231 current_by_sig
1232 .entry(table_signature(table, dialect))
1233 .or_default()
1234 .push(table);
1235 }
1236 for table in expected_only {
1237 expected_by_sig
1238 .entry(table_signature(table, dialect))
1239 .or_default()
1240 .push(table);
1241 }
1242
1243 let mut renames = Vec::new();
1244 for (sig, current_tables) in current_by_sig {
1245 if current_tables.len() != 1 {
1246 continue;
1247 }
1248 let Some(expected_tables) = expected_by_sig.get(&sig) else {
1249 continue;
1250 };
1251 if expected_tables.len() != 1 {
1252 continue;
1253 }
1254
1255 renames.push((
1256 current_tables[0].name.clone(),
1257 expected_tables[0].name.clone(),
1258 ));
1259 }
1260
1261 renames.sort_by(|a, b| a.0.cmp(&b.0));
1262 renames
1263}
1264
1265fn normalize_type(sql_type: &str, dialect: Dialect) -> String {
1271 let upper = sql_type.to_uppercase();
1272
1273 match dialect {
1274 Dialect::Sqlite => {
1275 if upper.contains("INT") {
1277 "INTEGER".to_string()
1278 } else if upper.contains("CHAR") || upper.contains("TEXT") || upper.contains("CLOB") {
1279 "TEXT".to_string()
1280 } else if upper.contains("REAL") || upper.contains("FLOAT") || upper.contains("DOUB") {
1281 "REAL".to_string()
1282 } else if upper.contains("BLOB") || upper.is_empty() {
1283 "BLOB".to_string()
1284 } else {
1285 upper
1286 }
1287 }
1288 Dialect::Postgres => match upper.as_str() {
1289 "INT" | "INT4" => "INTEGER".to_string(),
1290 "INT8" => "BIGINT".to_string(),
1291 "INT2" => "SMALLINT".to_string(),
1292 "FLOAT4" => "REAL".to_string(),
1293 "FLOAT8" => "DOUBLE PRECISION".to_string(),
1294 "BOOL" => "BOOLEAN".to_string(),
1295 "SERIAL" => "INTEGER".to_string(),
1296 "BIGSERIAL" => "BIGINT".to_string(),
1297 "SMALLSERIAL" => "SMALLINT".to_string(),
1298 _ => upper,
1299 },
1300 Dialect::Mysql => match upper.as_str() {
1301 "INTEGER" => "INT".to_string(),
1302 "BOOL" | "BOOLEAN" => "TINYINT".to_string(),
1303 _ => upper,
1304 },
1305 }
1306}
1307
1308#[cfg(test)]
1313mod tests {
1314 use super::*;
1315 use crate::introspect::ParsedSqlType;
1316
1317 fn make_column(name: &str, sql_type: &str, nullable: bool) -> ColumnInfo {
1318 ColumnInfo {
1319 name: name.to_string(),
1320 sql_type: sql_type.to_string(),
1321 parsed_type: ParsedSqlType::parse(sql_type),
1322 nullable,
1323 default: None,
1324 primary_key: false,
1325 auto_increment: false,
1326 comment: None,
1327 }
1328 }
1329
1330 fn make_table(name: &str, columns: Vec<ColumnInfo>) -> TableInfo {
1331 TableInfo {
1332 name: name.to_string(),
1333 columns,
1334 primary_key: Vec::new(),
1335 foreign_keys: Vec::new(),
1336 unique_constraints: Vec::new(),
1337 check_constraints: Vec::new(),
1338 indexes: Vec::new(),
1339 comment: None,
1340 }
1341 }
1342
1343 #[test]
1344 fn test_schema_diff_new_table() {
1345 let current = DatabaseSchema::new(Dialect::Sqlite);
1346 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1347 expected.tables.insert(
1348 "heroes".to_string(),
1349 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1350 );
1351
1352 let diff = schema_diff(¤t, &expected);
1353 assert_eq!(diff.len(), 1);
1354 assert!(
1355 matches!(&diff.operations[0], SchemaOperation::CreateTable(t) if t.name == "heroes")
1356 );
1357 }
1358
1359 #[test]
1360 fn test_schema_diff_rename_table() {
1361 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1362 current.tables.insert(
1363 "heroes_old".to_string(),
1364 make_table("heroes_old", vec![make_column("id", "INTEGER", false)]),
1365 );
1366
1367 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1368 expected.tables.insert(
1369 "heroes".to_string(),
1370 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1371 );
1372
1373 let diff = schema_diff(¤t, &expected);
1374 assert!(diff.operations.iter().any(|op| {
1375 matches!(op, SchemaOperation::RenameTable { from, to } if from == "heroes_old" && to == "heroes")
1376 }));
1377 assert!(!diff.operations.iter().any(|op| matches!(
1378 op,
1379 SchemaOperation::CreateTable(_) | SchemaOperation::DropTable(_)
1380 )));
1381 }
1382
1383 #[test]
1384 fn test_schema_diff_drop_table() {
1385 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1386 current.tables.insert(
1387 "heroes".to_string(),
1388 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1389 );
1390 let expected = DatabaseSchema::new(Dialect::Sqlite);
1391
1392 let diff = schema_diff(¤t, &expected);
1393 assert_eq!(diff.len(), 1);
1394 assert!(
1395 matches!(&diff.operations[0], SchemaOperation::DropTable(name) if name == "heroes")
1396 );
1397 assert!(diff.has_destructive());
1398 assert!(diff.requires_confirmation());
1399 assert_eq!(diff.warnings.len(), 1);
1400 assert_eq!(diff.warnings[0].severity, WarningSeverity::DataLoss);
1401 }
1402
1403 #[test]
1404 fn test_schema_diff_drop_table_allow_policy() {
1405 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1406 current.tables.insert(
1407 "heroes".to_string(),
1408 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1409 );
1410 let expected = DatabaseSchema::new(Dialect::Sqlite);
1411
1412 let diff = schema_diff_with_policy(¤t, &expected, DestructivePolicy::Allow);
1413 assert_eq!(diff.len(), 1);
1414 assert!(diff.has_destructive());
1415 assert!(!diff.requires_confirmation());
1416 assert!(diff.warnings.is_empty());
1417 }
1418
1419 #[test]
1420 fn test_schema_diff_drop_table_skip_policy() {
1421 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1422 current.tables.insert(
1423 "heroes".to_string(),
1424 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1425 );
1426 let expected = DatabaseSchema::new(Dialect::Sqlite);
1427
1428 let diff = schema_diff_with_policy(¤t, &expected, DestructivePolicy::Skip);
1429 assert!(diff.operations.is_empty());
1430 assert!(!diff.has_destructive());
1431 assert!(!diff.requires_confirmation());
1432 assert!(
1433 diff.warnings
1434 .iter()
1435 .any(|w| w.message.contains("Skipped destructive operation"))
1436 );
1437 }
1438
1439 #[test]
1440 fn test_schema_diff_add_column() {
1441 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1442 current.tables.insert(
1443 "heroes".to_string(),
1444 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1445 );
1446
1447 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1448 expected.tables.insert(
1449 "heroes".to_string(),
1450 make_table(
1451 "heroes",
1452 vec![
1453 make_column("id", "INTEGER", false),
1454 make_column("name", "TEXT", false),
1455 ],
1456 ),
1457 );
1458
1459 let diff = schema_diff(¤t, &expected);
1460 assert!(diff
1461 .operations
1462 .iter()
1463 .any(|op| matches!(op, SchemaOperation::AddColumn { table, column } if table == "heroes" && column.name == "name")));
1464 }
1465
1466 #[test]
1467 fn test_schema_diff_drop_column() {
1468 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1469 current.tables.insert(
1470 "heroes".to_string(),
1471 make_table(
1472 "heroes",
1473 vec![
1474 make_column("id", "INTEGER", false),
1475 make_column("old_field", "TEXT", true),
1476 ],
1477 ),
1478 );
1479
1480 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1481 expected.tables.insert(
1482 "heroes".to_string(),
1483 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1484 );
1485
1486 let diff = schema_diff(¤t, &expected);
1487 assert!(diff.has_destructive());
1488 assert!(diff.operations.iter().any(
1489 |op| matches!(op, SchemaOperation::DropColumn { table, column, table_info: Some(_), .. } if table == "heroes" && column == "old_field")
1490 ));
1491 }
1492
1493 #[test]
1494 fn test_sqlite_refreshes_table_info_for_multiple_recreate_ops_on_same_table() {
1495 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1496 current.tables.insert(
1497 "heroes".to_string(),
1498 make_table(
1499 "heroes",
1500 vec![
1501 make_column("id", "INTEGER", false),
1502 make_column("old_field", "TEXT", true),
1503 make_column("name", "TEXT", false),
1504 ],
1505 ),
1506 );
1507
1508 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1509 let mut name = make_column("name", "TEXT", false);
1510 name.default = Some("'anon'".to_string());
1511 expected.tables.insert(
1512 "heroes".to_string(),
1513 make_table("heroes", vec![make_column("id", "INTEGER", false), name]),
1514 );
1515
1516 let diff = schema_diff(¤t, &expected);
1517
1518 assert!(
1520 diff.operations.iter().any(|op| matches!(
1521 op,
1522 SchemaOperation::DropColumn { table, column, .. } if table == "heroes" && column == "old_field"
1523 )),
1524 "Expected DropColumn(old_field) op"
1525 );
1526
1527 let alter_default_table_info = diff.operations.iter().find_map(|op| match op {
1528 SchemaOperation::AlterColumnDefault {
1529 table,
1530 column,
1531 to_default,
1532 table_info,
1533 ..
1534 } if table == "heroes"
1535 && column == "name"
1536 && to_default.as_deref() == Some("'anon'") =>
1537 {
1538 table_info.as_ref()
1539 }
1540 _ => None,
1541 });
1542 let table_info =
1543 alter_default_table_info.expect("Expected AlterColumnDefault(name) op with table_info");
1544
1545 assert!(
1547 table_info.column("old_field").is_none(),
1548 "Expected stale column to be absent from refreshed table_info"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_schema_diff_rename_column() {
1554 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1555 current.tables.insert(
1556 "heroes".to_string(),
1557 make_table("heroes", vec![make_column("old_name", "TEXT", false)]),
1558 );
1559
1560 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1561 expected.tables.insert(
1562 "heroes".to_string(),
1563 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1564 );
1565
1566 let diff = schema_diff(¤t, &expected);
1567 assert!(diff.operations.iter().any(|op| {
1568 matches!(op, SchemaOperation::RenameColumn { table, from, to } if table == "heroes" && from == "old_name" && to == "name")
1569 }));
1570 assert!(!diff.operations.iter().any(|op| matches!(
1571 op,
1572 SchemaOperation::AddColumn { .. } | SchemaOperation::DropColumn { .. }
1573 )));
1574 assert!(!diff.has_destructive());
1575 }
1576
1577 #[test]
1578 fn test_schema_diff_alter_column_type() {
1579 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1580 current.tables.insert(
1581 "heroes".to_string(),
1582 make_table("heroes", vec![make_column("age", "INTEGER", false)]),
1583 );
1584
1585 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1586 expected.tables.insert(
1587 "heroes".to_string(),
1588 make_table("heroes", vec![make_column("age", "REAL", false)]),
1589 );
1590
1591 let diff = schema_diff(¤t, &expected);
1592 assert!(diff.operations.iter().any(
1593 |op| matches!(op, SchemaOperation::AlterColumnType { table, column, .. } if table == "heroes" && column == "age")
1594 ));
1595 }
1596
1597 #[test]
1598 fn test_schema_diff_alter_nullable() {
1599 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1600 current.tables.insert(
1601 "heroes".to_string(),
1602 make_table("heroes", vec![make_column("name", "TEXT", true)]),
1603 );
1604
1605 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1606 expected.tables.insert(
1607 "heroes".to_string(),
1608 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1609 );
1610
1611 let diff = schema_diff(¤t, &expected);
1612 assert!(diff.operations.iter().any(
1613 |op| matches!(op, SchemaOperation::AlterColumnNullable { table, column, to_nullable: false, .. } if table == "heroes" && column.name == "name")
1614 ));
1615 }
1616
1617 #[test]
1618 fn test_schema_diff_empty() {
1619 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1620 current.tables.insert(
1621 "heroes".to_string(),
1622 make_table("heroes", vec![make_column("id", "INTEGER", false)]),
1623 );
1624
1625 let expected = current.clone();
1626
1627 let diff = schema_diff(¤t, &expected);
1628 assert!(diff.is_empty());
1629 }
1630
1631 #[test]
1632 fn test_schema_diff_foreign_key_add() {
1633 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1634 current.tables.insert(
1635 "heroes".to_string(),
1636 make_table("heroes", vec![make_column("team_id", "INTEGER", true)]),
1637 );
1638
1639 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1640 let mut heroes = make_table("heroes", vec![make_column("team_id", "INTEGER", true)]);
1641 heroes.foreign_keys.push(ForeignKeyInfo {
1642 name: Some("fk_heroes_team".to_string()),
1643 column: "team_id".to_string(),
1644 foreign_table: "teams".to_string(),
1645 foreign_column: "id".to_string(),
1646 on_delete: Some("CASCADE".to_string()),
1647 on_update: None,
1648 });
1649 expected.tables.insert("heroes".to_string(), heroes);
1650
1651 let diff = schema_diff(¤t, &expected);
1652 let op = diff.operations.iter().find_map(|op| match op {
1653 SchemaOperation::AddForeignKey {
1654 table,
1655 fk,
1656 table_info,
1657 } if table == "heroes" && fk.column == "team_id" => Some(table_info),
1658 _ => None,
1659 });
1660 assert!(op.is_some(), "Expected AddForeignKey op for heroes.team_id");
1661 assert!(
1662 op.unwrap().is_some(),
1663 "Expected table_info on AddForeignKey op"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_schema_diff_primary_key_add_attaches_table_info() {
1669 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1670 let mut current_table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1671 current_table.primary_key.clear();
1672 current.tables.insert("heroes".to_string(), current_table);
1673
1674 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1675 let mut expected_table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1676 expected_table.primary_key = vec!["id".to_string()];
1677 expected.tables.insert("heroes".to_string(), expected_table);
1678
1679 let diff = schema_diff(¤t, &expected);
1680 let op = diff.operations.iter().find_map(|op| match op {
1681 SchemaOperation::AddPrimaryKey {
1682 table,
1683 columns,
1684 table_info,
1685 } if table == "heroes" && columns == &vec!["id".to_string()] => Some(table_info),
1686 _ => None,
1687 });
1688 assert!(op.is_some(), "Expected AddPrimaryKey op for heroes(id)");
1689 assert!(
1690 op.unwrap().is_some(),
1691 "Expected table_info on AddPrimaryKey op"
1692 );
1693 }
1694
1695 #[test]
1696 fn test_schema_diff_unique_add_attaches_table_info() {
1697 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1698 current.tables.insert(
1699 "heroes".to_string(),
1700 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1701 );
1702
1703 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1704 let mut expected_table = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1705 expected_table
1706 .unique_constraints
1707 .push(UniqueConstraintInfo {
1708 name: Some("uk_heroes_name".to_string()),
1709 columns: vec!["name".to_string()],
1710 });
1711 expected.tables.insert("heroes".to_string(), expected_table);
1712
1713 let diff = schema_diff(¤t, &expected);
1714 let op = diff.operations.iter().find_map(|op| match op {
1715 SchemaOperation::AddUnique {
1716 table,
1717 constraint,
1718 table_info,
1719 } if table == "heroes" && constraint.columns == vec!["name".to_string()] => {
1720 Some(table_info)
1721 }
1722 _ => None,
1723 });
1724 assert!(op.is_some(), "Expected AddUnique op for heroes(name)");
1725 assert!(op.unwrap().is_some(), "Expected table_info on AddUnique op");
1726 }
1727
1728 #[test]
1729 fn test_schema_diff_index_add() {
1730 let mut current = DatabaseSchema::new(Dialect::Sqlite);
1731 current.tables.insert(
1732 "heroes".to_string(),
1733 make_table("heroes", vec![make_column("name", "TEXT", false)]),
1734 );
1735
1736 let mut expected = DatabaseSchema::new(Dialect::Sqlite);
1737 let mut heroes = make_table("heroes", vec![make_column("name", "TEXT", false)]);
1738 heroes.indexes.push(IndexInfo {
1739 name: "idx_heroes_name".to_string(),
1740 columns: vec!["name".to_string()],
1741 unique: false,
1742 index_type: None,
1743 primary: false,
1744 });
1745 expected.tables.insert("heroes".to_string(), heroes);
1746
1747 let diff = schema_diff(¤t, &expected);
1748 assert!(diff.operations.iter().any(
1749 |op| matches!(op, SchemaOperation::CreateIndex { table, index } if table == "heroes" && index.name == "idx_heroes_name")
1750 ));
1751 }
1752
1753 #[test]
1754 fn test_operation_ordering() {
1755 let mut diff = SchemaDiff::new(DestructivePolicy::Warn);
1756
1757 diff.add_op(SchemaOperation::AddForeignKey {
1759 table: "heroes".to_string(),
1760 fk: ForeignKeyInfo {
1761 name: None,
1762 column: "team_id".to_string(),
1763 foreign_table: "teams".to_string(),
1764 foreign_column: "id".to_string(),
1765 on_delete: None,
1766 on_update: None,
1767 },
1768 table_info: None,
1769 });
1770 diff.add_op(SchemaOperation::DropForeignKey {
1771 table: "old".to_string(),
1772 name: "fk_old".to_string(),
1773 table_info: None,
1774 });
1775 diff.add_op(SchemaOperation::AddColumn {
1776 table: "heroes".to_string(),
1777 column: make_column("age", "INTEGER", true),
1778 });
1779
1780 diff.order_operations();
1781
1782 assert!(matches!(
1784 &diff.operations[0],
1785 SchemaOperation::DropForeignKey { .. }
1786 ));
1787 assert!(matches!(
1789 &diff.operations[1],
1790 SchemaOperation::AddColumn { .. }
1791 ));
1792 assert!(matches!(
1793 &diff.operations[2],
1794 SchemaOperation::AddForeignKey { .. }
1795 ));
1796 }
1797
1798 #[test]
1799 fn test_type_normalization_sqlite() {
1800 assert_eq!(normalize_type("INT", Dialect::Sqlite), "INTEGER");
1801 assert_eq!(normalize_type("BIGINT", Dialect::Sqlite), "INTEGER");
1802 assert_eq!(normalize_type("VARCHAR(100)", Dialect::Sqlite), "TEXT");
1803 assert_eq!(normalize_type("FLOAT", Dialect::Sqlite), "REAL");
1804 }
1805
1806 #[test]
1807 fn test_type_normalization_postgres() {
1808 assert_eq!(normalize_type("INT", Dialect::Postgres), "INTEGER");
1809 assert_eq!(normalize_type("INT4", Dialect::Postgres), "INTEGER");
1810 assert_eq!(normalize_type("INT8", Dialect::Postgres), "BIGINT");
1811 assert_eq!(normalize_type("SERIAL", Dialect::Postgres), "INTEGER");
1812 }
1813
1814 #[test]
1815 fn test_type_normalization_mysql() {
1816 assert_eq!(normalize_type("INTEGER", Dialect::Mysql), "INT");
1817 assert_eq!(normalize_type("BOOLEAN", Dialect::Mysql), "TINYINT");
1818 }
1819
1820 #[test]
1821 fn test_schema_operation_is_destructive() {
1822 assert!(SchemaOperation::DropTable("heroes".to_string()).is_destructive());
1823 assert!(
1824 SchemaOperation::DropColumn {
1825 table: "heroes".to_string(),
1826 column: "age".to_string(),
1827 table_info: None,
1828 }
1829 .is_destructive()
1830 );
1831 assert!(
1832 SchemaOperation::AlterColumnType {
1833 table: "heroes".to_string(),
1834 column: "age".to_string(),
1835 from_type: "TEXT".to_string(),
1836 to_type: "INTEGER".to_string(),
1837 table_info: None,
1838 }
1839 .is_destructive()
1840 );
1841 assert!(
1842 !SchemaOperation::AddColumn {
1843 table: "heroes".to_string(),
1844 column: make_column("name", "TEXT", false),
1845 }
1846 .is_destructive()
1847 );
1848 }
1849
1850 #[test]
1851 fn test_schema_operation_inverse() {
1852 let table = make_table("heroes", vec![make_column("id", "INTEGER", false)]);
1853 let op = SchemaOperation::CreateTable(table);
1854 assert!(matches!(op.inverse(), Some(SchemaOperation::DropTable(name)) if name == "heroes"));
1855
1856 let op = SchemaOperation::AlterColumnType {
1857 table: "heroes".to_string(),
1858 column: "age".to_string(),
1859 from_type: "TEXT".to_string(),
1860 to_type: "INTEGER".to_string(),
1861 table_info: None,
1862 };
1863 assert!(
1864 matches!(op.inverse(), Some(SchemaOperation::AlterColumnType { from_type, to_type, .. }) if from_type == "INTEGER" && to_type == "TEXT")
1865 );
1866 }
1867}