1use petgraph::Undirected;
4use petgraph::graph::Graph;
5use petgraph::visit::EdgeRef;
6use regex::Regex;
7use std::collections::{BTreeMap, HashMap};
8use strsim::{jaro_winkler, levenshtein};
9
10use super::model_registry::ManyToManyMetadata;
11
12#[derive(
14 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
15)]
16pub enum ForeignKeyAction {
17 Restrict,
19 Cascade,
21 SetNull,
23 NoAction,
25 SetDefault,
27}
28
29impl ForeignKeyAction {
30 pub fn to_sql_keyword(&self) -> &'static str {
32 match self {
33 ForeignKeyAction::Restrict => "RESTRICT",
34 ForeignKeyAction::Cascade => "CASCADE",
35 ForeignKeyAction::SetNull => "SET NULL",
36 ForeignKeyAction::NoAction => "NO ACTION",
37 ForeignKeyAction::SetDefault => "SET DEFAULT",
38 }
39 }
40}
41
42impl From<ForeignKeyAction> for reinhardt_query::prelude::ForeignKeyAction {
43 fn from(action: ForeignKeyAction) -> Self {
44 match action {
45 ForeignKeyAction::Restrict => reinhardt_query::prelude::ForeignKeyAction::Restrict,
46 ForeignKeyAction::Cascade => reinhardt_query::prelude::ForeignKeyAction::Cascade,
47 ForeignKeyAction::SetNull => reinhardt_query::prelude::ForeignKeyAction::SetNull,
48 ForeignKeyAction::NoAction => reinhardt_query::prelude::ForeignKeyAction::NoAction,
49 ForeignKeyAction::SetDefault => reinhardt_query::prelude::ForeignKeyAction::SetDefault,
50 }
51 }
52}
53
54impl From<reinhardt_query::prelude::ForeignKeyAction> for ForeignKeyAction {
55 fn from(action: reinhardt_query::prelude::ForeignKeyAction) -> Self {
56 match action {
57 reinhardt_query::prelude::ForeignKeyAction::Restrict => ForeignKeyAction::Restrict,
58 reinhardt_query::prelude::ForeignKeyAction::Cascade => ForeignKeyAction::Cascade,
59 reinhardt_query::prelude::ForeignKeyAction::SetNull => ForeignKeyAction::SetNull,
60 reinhardt_query::prelude::ForeignKeyAction::NoAction => ForeignKeyAction::NoAction,
61 reinhardt_query::prelude::ForeignKeyAction::SetDefault => ForeignKeyAction::SetDefault,
62 _ => ForeignKeyAction::NoAction,
64 }
65 }
66}
67
68pub use crate::naming::to_snake_case;
88
89pub fn to_pascal_case(name: &str) -> String {
106 name.split(['_', '.', '-', ' '])
107 .filter(|word| !word.is_empty())
108 .map(|word| {
109 let mut chars = word.chars();
110 match chars.next() {
111 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
112 None => String::new(),
113 }
114 })
115 .collect()
116}
117
118#[derive(Debug, Clone, PartialEq)]
120pub struct ForeignKeyInfo {
121 pub referenced_table: String,
123 pub referenced_column: String,
125 pub on_delete: ForeignKeyAction,
127 pub on_update: ForeignKeyAction,
129}
130
131#[derive(Debug, Clone)]
133pub struct FieldState {
134 pub name: String,
136 pub field_type: super::FieldType,
138 pub nullable: bool,
140 pub params: std::collections::HashMap<String, String>,
142 pub foreign_key: Option<ForeignKeyInfo>,
144}
145
146impl FieldState {
147 pub fn new(name: impl Into<String>, field_type: super::FieldType, nullable: bool) -> Self {
149 Self {
150 name: name.into(),
151 field_type,
152 nullable,
153 params: std::collections::HashMap::new(),
154 foreign_key: None,
155 }
156 }
157
158 pub fn with_foreign_key(
160 name: impl Into<String>,
161 field_type: super::FieldType,
162 nullable: bool,
163 foreign_key: ForeignKeyInfo,
164 ) -> Self {
165 Self {
166 name: name.into(),
167 field_type,
168 nullable,
169 params: std::collections::HashMap::new(),
170 foreign_key: Some(foreign_key),
171 }
172 }
173}
174
175#[derive(Debug, Clone)]
179pub struct ModelState {
180 pub app_label: String,
182 pub name: String,
184 pub table_name: String,
186 pub fields: std::collections::BTreeMap<String, FieldState>,
188 pub options: std::collections::HashMap<String, String>,
190 pub base_model: Option<String>,
192 pub inheritance_type: Option<String>,
194 pub discriminator_column: Option<String>,
196 pub indexes: Vec<IndexDefinition>,
198 pub constraints: Vec<ConstraintDefinition>,
200 pub many_to_many_fields: Vec<ManyToManyMetadata>,
202}
203
204#[derive(Debug, Clone, PartialEq)]
206pub struct IndexDefinition {
207 pub name: String,
209 pub fields: Vec<String>,
211 pub unique: bool,
213}
214
215#[derive(Debug, Clone, PartialEq)]
217pub struct ConstraintDefinition {
218 pub name: String,
220 pub constraint_type: String,
222 pub fields: Vec<String>,
224 pub expression: Option<String>,
226 pub foreign_key_info: Option<ForeignKeyConstraintInfo>,
228}
229
230#[derive(Debug, Clone, PartialEq)]
232pub struct ForeignKeyConstraintInfo {
233 pub referenced_table: String,
235 pub referenced_columns: Vec<String>,
237 pub on_delete: ForeignKeyAction,
239 pub on_update: ForeignKeyAction,
241}
242
243fn is_single_field_unique(c: &ConstraintDefinition) -> bool {
251 c.constraint_type.eq_ignore_ascii_case("unique") && c.fields.len() == 1
252}
253
254fn parse_single_column_unique(constraint_sql: &str) -> Option<&str> {
263 let after_unique = constraint_sql.split(" UNIQUE (").nth(1)?;
267 let close = after_unique.find(')')?;
268 let body = after_unique[..close].trim();
269 if body.contains(',') || body.is_empty() {
270 return None;
271 }
272 Some(body)
273}
274
275impl ConstraintDefinition {
276 pub fn to_constraint(&self) -> super::operations::Constraint {
278 match self.constraint_type.as_str() {
279 "unique" => super::operations::Constraint::Unique {
280 name: self.name.clone(),
281 columns: self.fields.clone(),
282 },
283 "check" => super::operations::Constraint::Check {
284 name: self.name.clone(),
285 expression: self.expression.clone().unwrap_or_default(),
286 },
287 "foreign_key" => {
288 if let Some(fk_info) = &self.foreign_key_info {
289 super::operations::Constraint::ForeignKey {
290 name: self.name.clone(),
291 columns: self.fields.clone(),
292 referenced_table: fk_info.referenced_table.clone(),
293 referenced_columns: fk_info.referenced_columns.clone(),
294 on_delete: fk_info.on_delete,
295 on_update: fk_info.on_update,
296 deferrable: None,
297 }
298 } else {
299 super::operations::Constraint::ForeignKey {
301 name: self.name.clone(),
302 columns: self.fields.clone(),
303 referenced_table: String::new(),
304 referenced_columns: vec!["id".to_string()],
305 on_delete: ForeignKeyAction::Cascade,
306 on_update: ForeignKeyAction::Cascade,
307 deferrable: None,
308 }
309 }
310 }
311 "one_to_one" => {
312 if let Some(fk_info) = &self.foreign_key_info {
313 super::operations::Constraint::OneToOne {
314 name: self.name.clone(),
315 column: self.fields.first().cloned().unwrap_or_default(),
316 referenced_table: fk_info.referenced_table.clone(),
317 referenced_column: fk_info
318 .referenced_columns
319 .first()
320 .cloned()
321 .unwrap_or_else(|| "id".to_string()),
322 on_delete: fk_info.on_delete,
323 on_update: fk_info.on_update,
324 deferrable: None,
325 }
326 } else {
327 super::operations::Constraint::OneToOne {
329 name: self.name.clone(),
330 column: self.fields.first().cloned().unwrap_or_default(),
331 referenced_table: String::new(),
332 referenced_column: "id".to_string(),
333 on_delete: ForeignKeyAction::Cascade,
334 on_update: ForeignKeyAction::Cascade,
335 deferrable: None,
336 }
337 }
338 }
339 _ => {
340 super::operations::Constraint::Check {
342 name: self.name.clone(),
343 expression: self.expression.clone().unwrap_or_default(),
344 }
345 }
346 }
347 }
348}
349
350impl ModelState {
351 pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Self {
365 let name_str = name.into();
366 let table_name = to_snake_case(&name_str);
368
369 Self {
370 app_label: app_label.into(),
371 name: name_str,
372 table_name,
373 fields: std::collections::BTreeMap::new(),
374 options: std::collections::HashMap::new(),
375 base_model: None,
376 inheritance_type: None,
377 discriminator_column: None,
378 indexes: Vec::new(),
379 constraints: Vec::new(),
380 many_to_many_fields: Vec::new(),
381 }
382 }
383
384 pub fn add_field(&mut self, field: FieldState) {
398 self.fields.insert(field.name.clone(), field);
399 }
400
401 pub fn get_field(&self, name: &str) -> Option<&FieldState> {
417 self.fields.get(name)
418 }
419
420 pub fn has_field(&self, name: &str) -> bool {
435 self.fields.contains_key(name)
436 }
437
438 pub fn rename_field(&mut self, old_name: &str, new_name: String) {
454 if let Some(mut field) = self.fields.remove(old_name) {
455 field.name = new_name.clone();
456 self.fields.insert(new_name, field);
457 }
458 }
459
460 pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
479 self.constraints.push(constraint);
480 }
481
482 pub fn add_foreign_key_constraint_from_field(&mut self, field_name: &str) {
484 if let Some(field) = self.fields.get(field_name)
485 && let Some(ref fk_info) = field.foreign_key
486 {
487 let constraint = ConstraintDefinition {
488 name: format!("fk_{}_{}", self.table_name, field_name),
489 constraint_type: "foreign_key".to_string(),
490 fields: vec![field_name.to_string()],
491 expression: None,
492 foreign_key_info: Some(ForeignKeyConstraintInfo {
493 referenced_table: fk_info.referenced_table.clone(),
494 referenced_columns: vec![fk_info.referenced_column.clone()],
495 on_delete: fk_info.on_delete,
496 on_update: fk_info.on_update,
497 }),
498 };
499 self.add_constraint(constraint);
500 }
501 }
502}
503
504#[derive(Debug, Clone)]
521pub struct ProjectState {
522 pub models: std::collections::BTreeMap<(String, String), ModelState>,
524}
525
526impl Default for ProjectState {
527 fn default() -> Self {
528 Self::new()
529 }
530}
531
532impl ProjectState {
533 pub fn to_database_schema(&self) -> super::schema_diff::DatabaseSchema {
535 let mut tables = BTreeMap::new();
536
537 for ((app_label, model_name), model_state) in &self.models {
538 let mut columns = BTreeMap::new();
539 for (field_name, field_state) in &model_state.fields {
540 let data_type = field_state.field_type.clone();
544 let nullable = field_state.nullable;
545 let primary_key = field_state
546 .params
547 .get("primary_key")
548 .is_some_and(|s| s == "true");
549 let auto_increment = field_state
550 .params
551 .get("auto_increment")
552 .is_some_and(|s| s == "true");
553 let default = field_state.params.get("default").cloned();
554
555 columns.insert(
556 field_name.clone(),
557 super::schema_diff::ColumnSchema {
558 name: field_name.clone(),
559 data_type,
560 nullable,
561 default,
562 primary_key,
563 auto_increment,
564 },
565 );
566 }
567 let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
569 .constraints
570 .iter()
571 .map(|c| super::schema_diff::ConstraintSchema {
572 name: c.name.clone(),
573 constraint_type: c.constraint_type.clone(),
574 definition: c.fields.join(", "),
575 foreign_key_info: None,
576 })
577 .collect();
578
579 let indexes: Vec<super::schema_diff::IndexSchema> = model_state
581 .indexes
582 .iter()
583 .map(|idx| super::schema_diff::IndexSchema {
584 name: idx.name.clone(),
585 columns: idx.fields.clone(),
586 unique: idx.unique,
587 })
588 .collect();
589
590 let table_key = format!("{}_{}", app_label, model_name.to_lowercase());
593 tables.insert(
594 table_key,
595 super::schema_diff::TableSchema {
596 name: model_state.table_name.clone(),
597 columns,
598 indexes,
599 constraints,
600 },
601 );
602 }
603
604 super::schema_diff::DatabaseSchema { tables }
605 }
606
607 pub fn to_database_schema_for_app(
622 &self,
623 app_label: &str,
624 ) -> super::schema_diff::DatabaseSchema {
625 let mut tables = BTreeMap::new();
626
627 for ((this_app_label, model_name), model_state) in &self.models {
628 if this_app_label == app_label {
630 let mut columns = BTreeMap::new();
631 for (field_name, field_state) in &model_state.fields {
632 let data_type = field_state.field_type.clone();
633 let nullable = field_state.nullable;
634 let primary_key = field_state
635 .params
636 .get("primary_key")
637 .is_some_and(|s| s == "true");
638 let auto_increment = field_state
639 .params
640 .get("auto_increment")
641 .is_some_and(|s| s == "true");
642 let default = field_state.params.get("default").cloned();
643
644 columns.insert(
645 field_name.clone(),
646 super::schema_diff::ColumnSchema {
647 name: field_name.clone(),
648 data_type,
649 nullable,
650 default,
651 primary_key,
652 auto_increment,
653 },
654 );
655 }
656
657 let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
659 .constraints
660 .iter()
661 .map(|c| super::schema_diff::ConstraintSchema {
662 name: c.name.clone(),
663 constraint_type: c.constraint_type.clone(),
664 definition: c.fields.join(", "),
665 foreign_key_info: None,
666 })
667 .collect();
668
669 let indexes: Vec<super::schema_diff::IndexSchema> = model_state
671 .indexes
672 .iter()
673 .map(|idx| super::schema_diff::IndexSchema {
674 name: idx.name.clone(),
675 columns: idx.fields.clone(),
676 unique: idx.unique,
677 })
678 .collect();
679
680 let table_key = format!("{}_{}", this_app_label, model_name.to_lowercase());
683 tables.insert(
684 table_key,
685 super::schema_diff::TableSchema {
686 name: model_state.table_name.clone(),
687 columns,
688 indexes,
689 constraints,
690 },
691 );
692 }
693 }
694
695 super::schema_diff::DatabaseSchema { tables }
696 }
697
698 pub fn new() -> Self {
709 Self {
710 models: std::collections::BTreeMap::new(),
711 }
712 }
713
714 pub fn add_model(&mut self, model: ModelState) {
729 let key = (model.app_label.clone(), model.name.clone());
730 self.models.insert(key, model);
731 }
732
733 pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<&ModelState> {
749 self.models
750 .get(&(app_label.to_string(), model_name.to_string()))
751 }
752
753 pub fn get_model_mut(&mut self, app_label: &str, model_name: &str) -> Option<&mut ModelState> {
772 self.models
773 .get_mut(&(app_label.to_string(), model_name.to_string()))
774 }
775
776 fn get_primary_key_type(&self, app_label: &str, model_name: &str) -> super::FieldType {
796 if let Some(model_state) = self.get_model(app_label, model_name) {
798 if let Some((_, id_field)) = model_state
800 .fields
801 .iter()
802 .find(|(name, _)| name.as_str() == "id")
803 {
804 return id_field.field_type.clone();
805 }
806
807 if let Some((_, pk_field)) = model_state
809 .fields
810 .iter()
811 .find(|(_, f)| f.params.get("primary_key").map(String::as_str) == Some("true"))
812 {
813 return pk_field.field_type.clone();
814 }
815 }
816
817 if let Some(model_meta) =
819 super::model_registry::global_registry().get_model(app_label, model_name)
820 {
821 if let Some(id_field) = model_meta.fields.get("id") {
823 return id_field.field_type.clone();
824 }
825
826 for field_meta in model_meta.fields.values() {
828 if field_meta.params.get("primary_key").map(String::as_str) == Some("true") {
829 return field_meta.field_type.clone();
830 }
831 }
832 }
833
834 super::FieldType::Uuid
836 }
837
838 pub fn get_model_by_table_name(
855 &self,
856 app_label: &str,
857 table_name: &str,
858 ) -> Option<&ModelState> {
859 self.models
860 .values()
861 .find(|model| model.app_label == app_label && model.table_name == table_name)
862 }
863
864 pub fn filter_by_app(&self, app_label: &str) -> Self {
885 let mut filtered = Self::new();
886 for ((app, _model_name), model_state) in &self.models {
887 if app == app_label {
888 filtered.add_model(model_state.clone());
889 }
890 }
891 filtered
892 }
893
894 pub fn remove_model(&mut self, app_label: &str, model_name: &str) -> Option<ModelState> {
909 self.models
910 .remove(&(app_label.to_string(), model_name.to_string()))
911 }
912
913 pub fn rename_model(&mut self, app_label: &str, old_name: &str, new_name: String) {
929 if let Some(mut model) = self
930 .models
931 .remove(&(app_label.to_string(), old_name.to_string()))
932 {
933 model.name = new_name.clone();
934 self.models.insert((app_label.to_string(), new_name), model);
935 }
936 }
937
938 pub fn from_global_registry() -> Self {
951 use super::model_registry::global_registry;
952
953 let registry = global_registry();
954 let models_metadata = registry.get_models();
955
956 let mut state = ProjectState::new();
957 let mut intermediate_tables = Vec::new();
958
959 for metadata in &models_metadata {
961 let model_state = metadata.to_model_state();
962 state.add_model(model_state);
963 }
964
965 for metadata in &models_metadata {
967 for m2m in &metadata.many_to_many_fields {
968 let intermediate_table = state.create_intermediate_table_for_m2m(
970 &metadata.app_label,
971 &metadata.model_name,
972 &metadata.table_name,
973 m2m,
974 );
975 intermediate_tables.push(intermediate_table);
976 }
977 }
978
979 for table in intermediate_tables {
981 state.add_model(table);
982 }
983
984 state
985 }
986
987 fn create_intermediate_table_for_m2m(
1010 &self,
1011 source_app_label: &str,
1012 source_model_name: &str,
1013 source_table_name: &str,
1014 m2m: &super::model_registry::ManyToManyMetadata,
1015 ) -> ModelState {
1016 let table_name = m2m.through.clone().unwrap_or_else(|| {
1022 crate::m2m_naming::default_through_table(source_table_name, &m2m.field_name)
1023 });
1024
1025 let model_name = format!("{}{}", source_model_name, to_pascal_case(&m2m.field_name));
1028
1029 let mut model_state = ModelState::new(source_app_label, &model_name);
1030 model_state.table_name = table_name.clone();
1031
1032 let mut id_field = FieldState::new("id".to_string(), super::FieldType::Integer, false);
1034 id_field
1035 .params
1036 .insert("primary_key".to_string(), "true".to_string());
1037 id_field
1038 .params
1039 .insert("auto_increment".to_string(), "true".to_string());
1040 model_state.add_field(id_field);
1041
1042 let source_pk_type = self.get_primary_key_type(source_app_label, source_model_name);
1044 let (target_app, target_model) = if m2m.to_model.contains('.') {
1046 let parts: Vec<&str> = m2m.to_model.split('.').collect();
1047 (parts[0], parts[1])
1048 } else {
1049 (source_app_label, m2m.to_model.as_str())
1050 };
1051
1052 let target_pk_type = self.get_primary_key_type(target_app, target_model);
1053
1054 let target_table_name = self
1057 .get_model(target_app, target_model)
1058 .map(|m| m.table_name.clone())
1059 .unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(target_model)));
1060
1061 let (default_source_col, default_target_col) =
1070 crate::m2m_naming::default_m2m_columns(source_table_name, &target_table_name);
1071 let source_field_name = m2m.source_field.clone().unwrap_or(default_source_col);
1072 let target_field_name = m2m.target_field.clone().unwrap_or(default_target_col);
1073
1074 let mut from_field =
1076 FieldState::new(source_field_name.clone(), source_pk_type.clone(), false);
1077 from_field
1078 .params
1079 .insert("not_null".to_string(), "true".to_string());
1080 from_field.foreign_key = Some(ForeignKeyInfo {
1081 referenced_table: source_table_name.to_string(),
1082 referenced_column: "id".to_string(),
1083 on_delete: ForeignKeyAction::Cascade,
1084 on_update: ForeignKeyAction::Cascade,
1085 });
1086 model_state.add_field(from_field);
1087
1088 let mut to_field = FieldState::new(target_field_name.clone(), target_pk_type, false);
1090 to_field
1091 .params
1092 .insert("not_null".to_string(), "true".to_string());
1093 to_field.foreign_key = Some(ForeignKeyInfo {
1094 referenced_table: target_table_name,
1095 referenced_column: "id".to_string(),
1096 on_delete: ForeignKeyAction::Cascade,
1097 on_update: ForeignKeyAction::Cascade,
1098 });
1099 model_state.add_field(to_field);
1100
1101 model_state.add_foreign_key_constraint_from_field(&source_field_name);
1103 model_state.add_foreign_key_constraint_from_field(&target_field_name);
1104
1105 let unique_constraint = ConstraintDefinition {
1107 name: format!("{}_unique", table_name),
1108 constraint_type: "unique".to_string(),
1109 fields: vec![source_field_name, target_field_name],
1110 expression: None,
1111 foreign_key_info: None,
1112 };
1113 model_state.constraints.push(unique_constraint);
1114
1115 model_state
1116 }
1117
1118 pub fn from_migrations(migrations: &[super::migration::Migration]) -> Self {
1134 let mut state = Self::new();
1135 for migration in migrations {
1136 state.apply_migration_operations(&migration.operations, &migration.app_label);
1137 }
1138 state
1139 }
1140
1141 pub fn apply_migration_operations(
1154 &mut self,
1155 operations: &[super::operations::Operation],
1156 app_label: &str,
1157 ) {
1158 use super::operations::Operation;
1159
1160 for op in operations {
1161 match op {
1162 Operation::CreateTable { name, columns, .. } => {
1163 let model_name = Self::table_name_to_model_name(name, app_label);
1167 let mut model = ModelState::new(app_label, model_name);
1168 model.table_name = name.to_string();
1169
1170 for col in columns {
1172 let field = self.column_def_to_field_state(col);
1173 model.add_field(field);
1174 }
1175
1176 self.add_model(model);
1177 }
1178 Operation::DropTable { name } => {
1179 let keys_to_remove: Vec<_> = self
1181 .models
1182 .iter()
1183 .filter(|(_, model)| model.table_name == *name)
1184 .map(|(key, _)| key.clone())
1185 .collect();
1186
1187 for key in keys_to_remove {
1188 self.models.remove(&key);
1189 }
1190 }
1191 Operation::AddColumn { table, column, .. } => {
1192 let field = self.column_def_to_field_state(column);
1194 if let Some(model) = self.find_model_by_table_mut(table) {
1195 model.add_field(field);
1196 }
1197 }
1198 Operation::DropColumn { table, column } => {
1199 if let Some(model) = self.find_model_by_table_mut(table) {
1201 model.fields.remove(column);
1202 }
1203 }
1204 Operation::AlterColumn {
1205 table,
1206 column,
1207 new_definition,
1208 ..
1209 } => {
1210 let new_field = self.column_def_to_field_state(new_definition);
1212 let mut updated_field = new_field;
1214 updated_field.name = column.to_string();
1215
1216 if let Some(model) = self.find_model_by_table_mut(table) {
1218 model.fields.insert(column.to_string(), updated_field);
1219 } else {
1220 let model_name = Self::table_name_to_model_name(table, app_label);
1224 let mut model = ModelState::new(app_label, model_name);
1225 model.table_name = table.to_string();
1226 model.add_field(updated_field);
1227 self.add_model(model);
1228 }
1229 }
1230 Operation::RenameTable { old_name, new_name } => {
1231 if let Some(model) = self.find_model_by_table_mut(old_name) {
1233 model.table_name = new_name.to_string();
1234 }
1235 }
1236 Operation::RenameColumn {
1237 table,
1238 old_name,
1239 new_name,
1240 } => {
1241 if let Some(model) = self.find_model_by_table_mut(table) {
1243 model.rename_field(old_name, new_name.to_string());
1244 }
1245 }
1246 _ => {
1248 }
1251 }
1252 }
1253 }
1254
1255 pub fn find_model_by_table(&self, table_name: &str) -> Option<&ModelState> {
1257 self.models
1258 .values()
1259 .find(|model| model.table_name == table_name)
1260 }
1261
1262 pub fn find_model_by_table_mut(&mut self, table_name: &str) -> Option<&mut ModelState> {
1264 self.models
1265 .values_mut()
1266 .find(|model| model.table_name == table_name)
1267 }
1268
1269 fn table_name_to_model_name(table_name: &str, app_label: &str) -> String {
1278 let prefix = format!("{}_", app_label);
1280 let name_without_prefix = if table_name.starts_with(&prefix) {
1281 &table_name[prefix.len()..]
1282 } else {
1283 table_name
1284 };
1285
1286 name_without_prefix
1288 .split('_')
1289 .map(|word| {
1290 let mut chars = word.chars();
1291 match chars.next() {
1292 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1293 None => String::new(),
1294 }
1295 })
1296 .collect()
1297 }
1298
1299 fn column_def_to_field_state(&self, col: &super::operations::ColumnDefinition) -> FieldState {
1301 let mut params = std::collections::HashMap::new();
1302
1303 if col.primary_key {
1304 params.insert("primary_key".to_string(), "true".to_string());
1305 }
1306 if col.auto_increment {
1307 params.insert("auto_increment".to_string(), "true".to_string());
1308 }
1309 if col.unique {
1310 params.insert("unique".to_string(), "true".to_string());
1311 }
1312 if let Some(default) = &col.default {
1313 params.insert("default".to_string(), default.to_string());
1314 }
1315
1316 FieldState {
1317 name: col.name.to_string(),
1318 field_type: col.type_definition.clone(),
1319 nullable: !col.not_null,
1320 params,
1321 foreign_key: None,
1322 }
1323 }
1324}
1325
1326#[non_exhaustive]
1354#[derive(Debug, Clone)]
1355pub struct SimilarityConfig {
1356 model_threshold: f64,
1359 field_threshold: f64,
1362 jaro_winkler_weight: f64,
1365 levenshtein_weight: f64,
1369}
1370
1371impl SimilarityConfig {
1372 pub fn new(model_threshold: f64, field_threshold: f64) -> Result<Self, String> {
1401 Self::with_weights(model_threshold, field_threshold, 0.7, 0.3)
1402 }
1403
1404 pub fn with_weights(
1435 model_threshold: f64,
1436 field_threshold: f64,
1437 jaro_winkler_weight: f64,
1438 levenshtein_weight: f64,
1439 ) -> Result<Self, String> {
1440 if !(0.45..=0.95).contains(&model_threshold) {
1444 return Err(format!(
1445 "model_threshold must be between 0.45 and 0.95, got {}",
1446 model_threshold
1447 ));
1448 }
1449 if !(0.45..=0.95).contains(&field_threshold) {
1450 return Err(format!(
1451 "field_threshold must be between 0.45 and 0.95, got {}",
1452 field_threshold
1453 ));
1454 }
1455
1456 if !(0.0..=1.0).contains(&jaro_winkler_weight) {
1458 return Err(format!(
1459 "jaro_winkler_weight must be between 0.0 and 1.0, got {}",
1460 jaro_winkler_weight
1461 ));
1462 }
1463 if !(0.0..=1.0).contains(&levenshtein_weight) {
1464 return Err(format!(
1465 "levenshtein_weight must be between 0.0 and 1.0, got {}",
1466 levenshtein_weight
1467 ));
1468 }
1469
1470 let weight_sum = jaro_winkler_weight + levenshtein_weight;
1472 if (weight_sum - 1.0).abs() > 0.01 {
1473 return Err(format!(
1474 "jaro_winkler_weight + levenshtein_weight must sum to 1.0, got {} + {} = {}",
1475 jaro_winkler_weight, levenshtein_weight, weight_sum
1476 ));
1477 }
1478
1479 Ok(Self {
1480 model_threshold,
1481 field_threshold,
1482 jaro_winkler_weight,
1483 levenshtein_weight,
1484 })
1485 }
1486
1487 pub fn model_threshold(&self) -> f64 {
1489 self.model_threshold
1490 }
1491
1492 pub fn field_threshold(&self) -> f64 {
1494 self.field_threshold
1495 }
1496}
1497
1498impl Default for SimilarityConfig {
1499 fn default() -> Self {
1506 Self {
1507 model_threshold: 0.7,
1508 field_threshold: 0.8,
1509 jaro_winkler_weight: 0.7,
1510 levenshtein_weight: 0.3,
1511 }
1512 }
1513}
1514
1515pub struct MigrationAutodetector {
1541 from_state: ProjectState,
1542 to_state: ProjectState,
1543 similarity_config: SimilarityConfig,
1544}
1545
1546type MovedModelInfo = (String, String, String, bool, Option<String>, Option<String>);
1548
1549type ModelMatchResult = ((String, String), (String, String), f64);
1551
1552#[derive(Debug, Clone, Default)]
1554pub struct DetectedChanges {
1555 pub created_models: Vec<(String, String)>,
1557 pub deleted_models: Vec<(String, String)>,
1559 pub added_fields: Vec<(String, String, String)>,
1561 pub removed_fields: Vec<(String, String, String)>,
1563 pub altered_fields: Vec<(String, String, String)>,
1565 pub renamed_models: Vec<(String, String, String)>,
1567 pub moved_models: Vec<MovedModelInfo>,
1569 pub renamed_fields: Vec<(String, String, String, String)>,
1571 pub added_indexes: Vec<(String, String, IndexDefinition)>,
1573 pub removed_indexes: Vec<(String, String, String)>,
1575 pub added_constraints: Vec<(String, String, ConstraintDefinition)>,
1577 pub removed_constraints: Vec<(String, String, String)>,
1579 pub added_composite_primary_keys: Vec<(String, String, ConstraintDefinition)>,
1581 pub removed_composite_primary_keys: Vec<(String, String, String)>,
1583 pub auto_increment_resets: Vec<(String, String, String, i64)>,
1585 pub model_dependencies: std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
1589 pub created_many_to_many: Vec<(String, String, String, ManyToManyMetadata)>,
1592}
1593
1594impl DetectedChanges {
1595 pub fn order_models_by_dependency(&self) -> Vec<(String, String)> {
1634 use std::collections::{HashMap, HashSet, VecDeque};
1635
1636 let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
1638 let mut all_models: HashSet<(String, String)> = HashSet::new();
1639
1640 for model in &self.created_models {
1642 all_models.insert(model.clone());
1643 in_degree.entry(model.clone()).or_insert(0);
1644 }
1645
1646 for model in &self.moved_models {
1647 let model_key = (model.1.clone(), model.2.clone()); all_models.insert(model_key.clone());
1649 in_degree.entry(model_key).or_insert(0);
1650 }
1651
1652 for (dependent, dependencies) in &self.model_dependencies {
1654 for dependency in dependencies {
1655 all_models.insert(dependency.clone());
1656 in_degree.entry(dependency.clone()).or_insert(0);
1657 *in_degree.entry(dependent.clone()).or_insert(0) += 1;
1658 }
1659 }
1660
1661 let mut queue: VecDeque<(String, String)> = VecDeque::new();
1663 for model in &all_models {
1664 if in_degree.get(model).copied().unwrap_or(0) == 0 {
1665 queue.push_back(model.clone());
1666 }
1667 }
1668
1669 let mut ordered = Vec::new();
1670
1671 while let Some(model) = queue.pop_front() {
1672 ordered.push(model.clone());
1673
1674 for (dependent, dependencies) in &self.model_dependencies {
1678 if dependencies.contains(&model)
1679 && let Some(degree) = in_degree.get_mut(dependent)
1680 {
1681 *degree -= 1;
1682 if *degree == 0 {
1683 queue.push_back(dependent.clone());
1684 }
1685 }
1686 }
1687 }
1688
1689 if ordered.len() < all_models.len() {
1691 let unordered_models: Vec<_> = all_models
1693 .iter()
1694 .filter(|model| !ordered.contains(model))
1695 .map(|(app, name)| format!("{}.{}", app, name))
1696 .collect();
1697
1698 eprintln!(
1699 "⚠️ Warning: Circular dependency detected in models: [{}]",
1700 unordered_models.join(", ")
1701 );
1702 eprintln!(
1703 " Falling back to original order. Migration operations may need manual reordering."
1704 );
1705
1706 all_models.into_iter().collect()
1707 } else {
1708 ordered
1709 }
1710 }
1711
1712 pub fn check_circular_dependencies(&self) -> Result<(), Vec<(String, String)>> {
1747 use std::collections::HashSet;
1748
1749 let mut visited: HashSet<(String, String)> = HashSet::new();
1750 let mut rec_stack: HashSet<(String, String)> = HashSet::new();
1751 let mut path: Vec<(String, String)> = Vec::new();
1752
1753 fn dfs(
1754 model: &(String, String),
1755 deps: &BTreeMap<(String, String), Vec<(String, String)>>,
1756 visited: &mut HashSet<(String, String)>,
1757 rec_stack: &mut HashSet<(String, String)>,
1758 path: &mut Vec<(String, String)>,
1759 ) -> Option<Vec<(String, String)>> {
1760 visited.insert(model.clone());
1761 rec_stack.insert(model.clone());
1762 path.push(model.clone());
1763
1764 if let Some(dependencies) = deps.get(model) {
1765 for dep in dependencies {
1766 if !visited.contains(dep) {
1767 if let Some(cycle) = dfs(dep, deps, visited, rec_stack, path) {
1768 return Some(cycle);
1769 }
1770 } else if rec_stack.contains(dep) {
1771 let cycle_start = path.iter().position(|m| m == dep).unwrap();
1773 return Some(path[cycle_start..].to_vec());
1774 }
1775 }
1776 }
1777
1778 path.pop();
1779 rec_stack.remove(model);
1780 None
1781 }
1782
1783 for model in self.model_dependencies.keys() {
1784 if !visited.contains(model)
1785 && let Some(cycle) = dfs(
1786 model,
1787 &self.model_dependencies,
1788 &mut visited,
1789 &mut rec_stack,
1790 &mut path,
1791 ) {
1792 return Err(cycle);
1793 }
1794 }
1795
1796 Ok(())
1797 }
1798
1799 pub fn remove_operations(&mut self, refs: &[OperationRef]) {
1839 for op_ref in refs {
1840 match op_ref {
1841 OperationRef::RenamedModel {
1842 app_label,
1843 old_name,
1844 new_name,
1845 } => {
1846 self.renamed_models.retain(|(app, old, new)| {
1847 !(app == app_label && old == old_name && new == new_name)
1848 });
1849 }
1850 OperationRef::MovedModel {
1851 from_app,
1852 to_app,
1853 model_name,
1854 } => {
1855 self.moved_models.retain(|info| {
1857 !(&info.0 == from_app && &info.1 == to_app && &info.2 == model_name)
1858 });
1859 }
1860 OperationRef::AddedField {
1861 app_label,
1862 model_name,
1863 field_name,
1864 } => {
1865 self.added_fields.retain(|(app, model, field)| {
1866 !(app == app_label && model == model_name && field == field_name)
1867 });
1868 }
1869 OperationRef::RenamedField {
1870 app_label,
1871 model_name,
1872 old_name,
1873 new_name,
1874 } => {
1875 self.renamed_fields.retain(|(app, model, old, new)| {
1876 !(app == app_label
1877 && model == model_name
1878 && old == old_name && new == new_name)
1879 });
1880 }
1881 OperationRef::RemovedField {
1882 app_label,
1883 model_name,
1884 field_name,
1885 } => {
1886 self.removed_fields.retain(|(app, model, field)| {
1887 !(app == app_label && model == model_name && field == field_name)
1888 });
1889 }
1890 OperationRef::AlteredField {
1891 app_label,
1892 model_name,
1893 field_name,
1894 } => {
1895 self.altered_fields.retain(|(app, model, field)| {
1896 !(app == app_label && model == model_name && field == field_name)
1897 });
1898 }
1899 OperationRef::CreatedModel {
1900 app_label,
1901 model_name,
1902 } => {
1903 self.created_models
1904 .retain(|(app, model)| !(app == app_label && model == model_name));
1905 }
1906 OperationRef::DeletedModel {
1907 app_label,
1908 model_name,
1909 } => {
1910 self.deleted_models
1911 .retain(|(app, model)| !(app == app_label && model == model_name));
1912 }
1913 }
1914 }
1915 }
1916}
1917
1918#[derive(Debug, Clone)]
1945pub struct ChangeHistoryEntry {
1946 pub timestamp: std::time::SystemTime,
1948 pub change_type: String,
1950 pub app_label: String,
1952 pub model_name: String,
1954 pub field_name: Option<String>,
1956 pub old_value: Option<String>,
1958 pub new_value: Option<String>,
1960}
1961
1962#[derive(Debug, Clone)]
1968pub struct PatternFrequency {
1969 pub pattern: String,
1971 pub frequency: usize,
1973 pub last_seen: std::time::SystemTime,
1975 pub contexts: Vec<String>,
1977}
1978
1979#[derive(Debug, Clone)]
2008pub struct ChangeTracker {
2009 history: Vec<ChangeHistoryEntry>,
2011 patterns: HashMap<String, PatternFrequency>,
2013 max_history_size: usize,
2015}
2016
2017impl ChangeTracker {
2018 pub fn new() -> Self {
2022 Self {
2023 history: Vec::new(),
2024 patterns: HashMap::new(),
2025 max_history_size: 1000,
2026 }
2027 }
2028
2029 pub fn with_capacity(max_size: usize) -> Self {
2031 Self {
2032 history: Vec::with_capacity(max_size),
2033 patterns: HashMap::new(),
2034 max_history_size: max_size,
2035 }
2036 }
2037
2038 pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
2045 let entry = ChangeHistoryEntry {
2046 timestamp: std::time::SystemTime::now(),
2047 change_type: "RenameModel".to_string(),
2048 app_label: app_label.to_string(),
2049 model_name: new_name.to_string(),
2050 field_name: None,
2051 old_value: Some(old_name.to_string()),
2052 new_value: Some(new_name.to_string()),
2053 };
2054
2055 self.add_entry(entry);
2056 self.update_pattern(
2057 &format!("RenameModel:{}->{}", old_name, new_name),
2058 app_label,
2059 );
2060 }
2061
2062 pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
2064 let entry = ChangeHistoryEntry {
2065 timestamp: std::time::SystemTime::now(),
2066 change_type: "MoveModel".to_string(),
2067 app_label: to_app.to_string(),
2068 model_name: model_name.to_string(),
2069 field_name: None,
2070 old_value: Some(from_app.to_string()),
2071 new_value: Some(to_app.to_string()),
2072 };
2073
2074 self.add_entry(entry);
2075 self.update_pattern(
2076 &format!("MoveModel:{}->{}:{}", from_app, to_app, model_name),
2077 to_app,
2078 );
2079 }
2080
2081 pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
2083 let entry = ChangeHistoryEntry {
2084 timestamp: std::time::SystemTime::now(),
2085 change_type: "AddField".to_string(),
2086 app_label: app_label.to_string(),
2087 model_name: model_name.to_string(),
2088 field_name: Some(field_name.to_string()),
2089 old_value: None,
2090 new_value: Some(field_name.to_string()),
2091 };
2092
2093 self.add_entry(entry);
2094 self.update_pattern(
2095 &format!("AddField:{}:{}", model_name, field_name),
2096 app_label,
2097 );
2098 }
2099
2100 pub fn record_field_rename(
2102 &mut self,
2103 app_label: &str,
2104 model_name: &str,
2105 old_name: &str,
2106 new_name: &str,
2107 ) {
2108 let entry = ChangeHistoryEntry {
2109 timestamp: std::time::SystemTime::now(),
2110 change_type: "RenameField".to_string(),
2111 app_label: app_label.to_string(),
2112 model_name: model_name.to_string(),
2113 field_name: Some(new_name.to_string()),
2114 old_value: Some(old_name.to_string()),
2115 new_value: Some(new_name.to_string()),
2116 };
2117
2118 self.add_entry(entry);
2119 self.update_pattern(
2120 &format!("RenameField:{}:{}->{}", model_name, old_name, new_name),
2121 app_label,
2122 );
2123 }
2124
2125 fn add_entry(&mut self, entry: ChangeHistoryEntry) {
2127 self.history.push(entry);
2128
2129 if self.history.len() > self.max_history_size {
2131 self.history.remove(0);
2132 }
2133 }
2134
2135 fn update_pattern(&mut self, pattern: &str, context: &str) {
2137 self.patterns
2138 .entry(pattern.to_string())
2139 .and_modify(|pf| {
2140 pf.frequency += 1;
2141 pf.last_seen = std::time::SystemTime::now();
2142 if !pf.contexts.contains(&context.to_string()) {
2143 pf.contexts.push(context.to_string());
2144 }
2145 })
2146 .or_insert(PatternFrequency {
2147 pattern: pattern.to_string(),
2148 frequency: 1,
2149 last_seen: std::time::SystemTime::now(),
2150 contexts: vec![context.to_string()],
2151 });
2152 }
2153
2154 pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
2158 let mut patterns: Vec<_> = self
2159 .patterns
2160 .values()
2161 .filter(|p| p.frequency >= min_frequency)
2162 .cloned()
2163 .collect();
2164
2165 patterns.sort_by(|a, b| b.frequency.cmp(&a.frequency));
2166 patterns
2167 }
2168
2169 pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
2174 let now = std::time::SystemTime::now();
2175 self.history
2176 .iter()
2177 .filter(|entry| {
2178 now.duration_since(entry.timestamp)
2179 .map(|d| d < duration)
2180 .unwrap_or(false)
2181 })
2182 .collect()
2183 }
2184
2185 pub fn analyze_cooccurrence(
2190 &self,
2191 window: std::time::Duration,
2192 ) -> HashMap<(String, String), usize> {
2193 let mut cooccurrences = HashMap::new();
2194
2195 for i in 0..self.history.len() {
2196 for j in (i + 1)..self.history.len() {
2197 if let Ok(diff) = self.history[j]
2198 .timestamp
2199 .duration_since(self.history[i].timestamp)
2200 && diff <= window
2201 {
2202 let pattern1 = format!(
2203 "{}:{}",
2204 self.history[i].change_type, self.history[i].model_name
2205 );
2206 let pattern2 = format!(
2207 "{}:{}",
2208 self.history[j].change_type, self.history[j].model_name
2209 );
2210 let key = if pattern1 < pattern2 {
2211 (pattern1, pattern2)
2212 } else {
2213 (pattern2, pattern1)
2214 };
2215 *cooccurrences.entry(key).or_insert(0) += 1;
2216 }
2217 }
2218 }
2219
2220 cooccurrences
2221 }
2222
2223 pub fn clear(&mut self) {
2225 self.history.clear();
2226 self.patterns.clear();
2227 }
2228
2229 pub fn len(&self) -> usize {
2231 self.history.len()
2232 }
2233
2234 pub fn is_empty(&self) -> bool {
2236 self.history.is_empty()
2237 }
2238}
2239
2240impl Default for ChangeTracker {
2241 fn default() -> Self {
2242 Self::new()
2243 }
2244}
2245
2246#[derive(Debug, Clone)]
2250pub struct PatternMatch {
2251 pub pattern: String,
2253 pub start: usize,
2255 pub end: usize,
2257 pub matched_text: String,
2259}
2260
2261#[derive(Debug, Clone)]
2288pub struct PatternMatcher {
2289 patterns: Vec<String>,
2291 automaton: Option<aho_corasick::AhoCorasick>,
2293}
2294
2295impl PatternMatcher {
2296 pub fn new() -> Self {
2298 Self {
2299 patterns: Vec::new(),
2300 automaton: None,
2301 }
2302 }
2303
2304 pub fn add_pattern(&mut self, pattern: &str) {
2309 self.patterns.push(pattern.to_string());
2310 self.automaton = None;
2312 }
2313
2314 pub fn add_patterns<I, S>(&mut self, patterns: I)
2316 where
2317 I: IntoIterator<Item = S>,
2318 S: AsRef<str>,
2319 {
2320 for pattern in patterns {
2321 self.patterns.push(pattern.as_ref().to_string());
2322 }
2323 self.automaton = None;
2324 }
2325
2326 pub fn build(&mut self) -> Result<(), String> {
2331 if self.patterns.is_empty() {
2332 return Err("No patterns to build automaton".to_string());
2333 }
2334
2335 self.automaton = Some(
2336 aho_corasick::AhoCorasick::new(&self.patterns)
2337 .map_err(|e| format!("Failed to build Aho-Corasick automaton: {}", e))?,
2338 );
2339
2340 Ok(())
2341 }
2342
2343 pub fn find_all(&self, text: &str) -> Vec<PatternMatch> {
2347 let Some(ref automaton) = self.automaton else {
2348 return Vec::new();
2349 };
2350
2351 automaton
2352 .find_iter(text)
2353 .map(|mat| PatternMatch {
2354 pattern: self.patterns[mat.pattern().as_usize()].clone(),
2355 start: mat.start(),
2356 end: mat.end(),
2357 matched_text: text[mat.start()..mat.end()].to_string(),
2358 })
2359 .collect()
2360 }
2361
2362 pub fn contains_any(&self, text: &str) -> bool {
2364 self.automaton
2365 .as_ref()
2366 .map(|ac| ac.is_match(text))
2367 .unwrap_or(false)
2368 }
2369
2370 pub fn find_first(&self, text: &str) -> Option<PatternMatch> {
2372 let automaton = self.automaton.as_ref()?;
2373 let mat = automaton.find(text)?;
2374
2375 Some(PatternMatch {
2376 pattern: self.patterns[mat.pattern().as_usize()].clone(),
2377 start: mat.start(),
2378 end: mat.end(),
2379 matched_text: text[mat.start()..mat.end()].to_string(),
2380 })
2381 }
2382
2383 pub fn replace_all(&self, text: &str, replacements: &HashMap<String, String>) -> String {
2392 let Some(ref automaton) = self.automaton else {
2393 return text.to_string();
2394 };
2395
2396 let mut result = String::new();
2397 let mut last_end = 0;
2398
2399 for mat in automaton.find_iter(text) {
2400 result.push_str(&text[last_end..mat.start()]);
2402
2403 let pattern = &self.patterns[mat.pattern().as_usize()];
2405 if let Some(replacement) = replacements.get(pattern) {
2406 result.push_str(replacement);
2407 } else {
2408 result.push_str(&text[mat.start()..mat.end()]);
2409 }
2410
2411 last_end = mat.end();
2412 }
2413
2414 result.push_str(&text[last_end..]);
2416 result
2417 }
2418
2419 pub fn patterns(&self) -> &[String] {
2421 &self.patterns
2422 }
2423
2424 pub fn clear(&mut self) {
2426 self.patterns.clear();
2427 self.automaton = None;
2428 }
2429
2430 pub fn is_built(&self) -> bool {
2432 self.automaton.is_some()
2433 }
2434}
2435
2436impl Default for PatternMatcher {
2437 fn default() -> Self {
2438 Self::new()
2439 }
2440}
2441
2442#[derive(Debug, Clone, PartialEq)]
2448pub enum RuleCondition {
2449 ModelRename {
2451 from_pattern: String,
2453 to_pattern: String,
2455 },
2456 ModelMove {
2458 app_pattern: String,
2460 },
2461 FieldAddition {
2463 field_name_pattern: String,
2465 },
2466 FieldRename {
2468 from_pattern: String,
2470 to_pattern: String,
2472 },
2473 MultipleModelRenames {
2475 min_count: usize,
2477 },
2478 MultipleFieldAdditions {
2480 model_pattern: String,
2482 min_count: usize,
2484 },
2485}
2486
2487#[derive(Debug, Clone, PartialEq)]
2492pub enum OperationRef {
2493 RenamedModel {
2495 app_label: String,
2497 old_name: String,
2499 new_name: String,
2501 },
2502 MovedModel {
2504 from_app: String,
2506 to_app: String,
2508 model_name: String,
2510 },
2511 AddedField {
2513 app_label: String,
2515 model_name: String,
2517 field_name: String,
2519 },
2520 RenamedField {
2522 app_label: String,
2524 model_name: String,
2526 old_name: String,
2528 new_name: String,
2530 },
2531 RemovedField {
2533 app_label: String,
2535 model_name: String,
2537 field_name: String,
2539 },
2540 AlteredField {
2542 app_label: String,
2544 model_name: String,
2546 field_name: String,
2548 },
2549 CreatedModel {
2551 app_label: String,
2553 model_name: String,
2555 },
2556 DeletedModel {
2558 app_label: String,
2560 model_name: String,
2562 },
2563}
2564
2565#[derive(Debug, Clone, PartialEq)]
2567pub struct InferredIntent {
2568 pub intent_type: String,
2570 pub confidence: f64,
2572 pub description: String,
2574 pub evidence: Vec<String>,
2576 pub related_operations: Vec<OperationRef>,
2581}
2582
2583#[derive(Debug, Clone)]
2585pub struct InferenceRule {
2586 pub name: String,
2588 pub conditions: Vec<RuleCondition>,
2590 pub optional_conditions: Vec<RuleCondition>,
2592 pub intent_type: String,
2594 pub base_confidence: f64,
2596 pub confidence_boost_per_optional: f64,
2598}
2599
2600#[derive(Debug, Clone)]
2637pub struct InferenceEngine {
2638 rules: Vec<InferenceRule>,
2640 change_tracker: ChangeTracker,
2660}
2661
2662impl Default for InferenceEngine {
2663 fn default() -> Self {
2664 Self::new()
2665 }
2666}
2667
2668impl InferenceEngine {
2669 pub fn new() -> Self {
2671 Self {
2672 rules: Vec::new(),
2673 change_tracker: ChangeTracker::new(),
2674 }
2675 }
2676
2677 pub fn add_rule(&mut self, rule: InferenceRule) {
2679 self.rules.push(rule);
2680 }
2681
2682 pub fn add_default_rules(&mut self) {
2684 self.add_rule(InferenceRule {
2686 name: "model_refactoring".to_string(),
2687 conditions: vec![RuleCondition::ModelRename {
2688 from_pattern: ".*".to_string(),
2689 to_pattern: ".*".to_string(),
2690 }],
2691 optional_conditions: vec![RuleCondition::MultipleModelRenames { min_count: 2 }],
2692 intent_type: "Refactoring: Model rename".to_string(),
2693 base_confidence: 0.7,
2694 confidence_boost_per_optional: 0.1,
2695 });
2696
2697 self.add_rule(InferenceRule {
2699 name: "add_timestamp_tracking".to_string(),
2700 conditions: vec![RuleCondition::FieldAddition {
2701 field_name_pattern: "created_at".to_string(),
2702 }],
2703 optional_conditions: vec![RuleCondition::FieldAddition {
2704 field_name_pattern: "updated_at".to_string(),
2705 }],
2706 intent_type: "Add timestamp tracking".to_string(),
2707 base_confidence: 0.8,
2708 confidence_boost_per_optional: 0.15,
2709 });
2710
2711 self.add_rule(InferenceRule {
2713 name: "cross_app_move".to_string(),
2714 conditions: vec![RuleCondition::ModelMove {
2715 app_pattern: ".*".to_string(),
2716 }],
2717 optional_conditions: vec![],
2718 intent_type: "Cross-app model organization".to_string(),
2719 base_confidence: 0.75,
2720 confidence_boost_per_optional: 0.0,
2721 });
2722
2723 self.add_rule(InferenceRule {
2725 name: "field_refactoring".to_string(),
2726 conditions: vec![RuleCondition::FieldRename {
2727 from_pattern: ".*".to_string(),
2728 to_pattern: ".*".to_string(),
2729 }],
2730 optional_conditions: vec![RuleCondition::MultipleFieldAdditions {
2731 model_pattern: ".*".to_string(),
2732 min_count: 2,
2733 }],
2734 intent_type: "Refactoring: Field rename".to_string(),
2735 base_confidence: 0.65,
2736 confidence_boost_per_optional: 0.1,
2737 });
2738
2739 self.add_rule(InferenceRule {
2741 name: "model_normalization".to_string(),
2742 conditions: vec![RuleCondition::MultipleFieldAdditions {
2743 model_pattern: ".*".to_string(),
2744 min_count: 3,
2745 }],
2746 optional_conditions: vec![],
2747 intent_type: "Schema normalization".to_string(),
2748 base_confidence: 0.6,
2749 confidence_boost_per_optional: 0.0,
2750 });
2751 }
2752
2753 fn matches_pattern(value: &str, pattern: &str) -> bool {
2760 if pattern == ".*" {
2762 return true;
2763 }
2764
2765 if value == pattern {
2767 return true;
2768 }
2769
2770 if let Ok(re) = Regex::new(pattern) {
2772 re.is_match(value)
2773 } else {
2774 false
2776 }
2777 }
2778
2779 pub fn rules(&self) -> &[InferenceRule] {
2781 &self.rules
2782 }
2783
2784 pub fn infer_intents(
2786 &self,
2787 model_renames: &[(String, String, String, String)], model_moves: &[(String, String, String, String)], field_additions: &[(String, String, String)], field_renames: &[(String, String, String, String)], ) -> Vec<InferredIntent> {
2792 let mut intents = Vec::new();
2793
2794 for rule in &self.rules {
2795 let mut matches_required = true;
2796 let mut optional_matches = 0;
2797 let mut evidence = Vec::new();
2798
2799 for condition in &rule.conditions {
2801 match condition {
2802 RuleCondition::ModelRename {
2803 from_pattern,
2804 to_pattern,
2805 } => {
2806 if model_renames.is_empty() {
2807 matches_required = false;
2808 break;
2809 }
2810
2811 let mut matched = false;
2813 for (from_app, from_model, to_app, to_model) in model_renames {
2814 let from_name = format!("{}.{}", from_app, from_model);
2815 let to_name = format!("{}.{}", to_app, to_model);
2816
2817 if Self::matches_pattern(&from_name, from_pattern)
2818 && Self::matches_pattern(&to_name, to_pattern)
2819 {
2820 evidence.push(format!(
2821 "Model renamed: {} → {} (pattern: {} → {})",
2822 from_name, to_name, from_pattern, to_pattern
2823 ));
2824 matched = true;
2825 break;
2826 }
2827 }
2828
2829 if !matched {
2830 matches_required = false;
2831 break;
2832 }
2833 }
2834 RuleCondition::ModelMove { app_pattern } => {
2835 if model_moves.is_empty() {
2836 matches_required = false;
2837 break;
2838 }
2839
2840 let mut matched = false;
2842 for (from_app, from_model, to_app, to_model) in model_moves {
2843 if Self::matches_pattern(to_app, app_pattern) {
2844 evidence.push(format!(
2845 "Model moved: {}.{} → {}.{} (app pattern: {})",
2846 from_app, from_model, to_app, to_model, app_pattern
2847 ));
2848 matched = true;
2849 break;
2850 }
2851 }
2852
2853 if !matched {
2854 matches_required = false;
2855 break;
2856 }
2857 }
2858 RuleCondition::FieldAddition { field_name_pattern } => {
2859 let matching_fields: Vec<_> = field_additions
2860 .iter()
2861 .filter(|(_, _, field)| {
2862 Self::matches_pattern(field, field_name_pattern)
2863 })
2864 .collect();
2865
2866 if matching_fields.is_empty() {
2867 matches_required = false;
2868 break;
2869 }
2870 evidence.push(format!(
2871 "Field added: {}.{}.{} (pattern: {})",
2872 matching_fields[0].0,
2873 matching_fields[0].1,
2874 matching_fields[0].2,
2875 field_name_pattern
2876 ));
2877 }
2878 RuleCondition::FieldRename {
2879 from_pattern,
2880 to_pattern,
2881 } => {
2882 if field_renames.is_empty() {
2883 matches_required = false;
2884 break;
2885 }
2886
2887 let mut matched = false;
2889 for (app, model, from_field, to_field) in field_renames {
2890 if Self::matches_pattern(from_field, from_pattern)
2891 && Self::matches_pattern(to_field, to_pattern)
2892 {
2893 evidence.push(format!(
2894 "Field renamed: {}.{}.{} → {} (pattern: {} → {})",
2895 app, model, from_field, to_field, from_pattern, to_pattern
2896 ));
2897 matched = true;
2898 break;
2899 }
2900 }
2901
2902 if !matched {
2903 matches_required = false;
2904 break;
2905 }
2906 }
2907 RuleCondition::MultipleModelRenames { min_count } => {
2908 if model_renames.len() < *min_count {
2909 matches_required = false;
2910 break;
2911 }
2912 evidence.push(format!("Multiple model renames: {}", model_renames.len()));
2913 }
2914 RuleCondition::MultipleFieldAdditions {
2915 model_pattern,
2916 min_count,
2917 } => {
2918 let count = field_additions
2919 .iter()
2920 .filter(|(_, model, _)| Self::matches_pattern(model, model_pattern))
2921 .count();
2922
2923 if count < *min_count {
2924 matches_required = false;
2925 break;
2926 }
2927 evidence.push(format!(
2928 "Multiple field additions: {} (pattern: {}, min: {})",
2929 count, model_pattern, min_count
2930 ));
2931 }
2932 }
2933 }
2934
2935 if !matches_required {
2936 continue;
2937 }
2938
2939 for condition in &rule.optional_conditions {
2941 match condition {
2942 RuleCondition::FieldAddition { field_name_pattern } => {
2943 if field_additions
2944 .iter()
2945 .any(|(_, _, field)| field.contains(field_name_pattern.as_str()))
2946 {
2947 optional_matches += 1;
2948 evidence.push(format!("Optional field added: {}", field_name_pattern));
2949 }
2950 }
2951 RuleCondition::MultipleModelRenames { min_count } => {
2952 if model_renames.len() >= *min_count {
2953 optional_matches += 1;
2954 evidence.push(format!("Multiple renames: {}", model_renames.len()));
2955 }
2956 }
2957 _ => {}
2958 }
2959 }
2960
2961 let confidence = rule.base_confidence
2963 + (optional_matches as f64 * rule.confidence_boost_per_optional);
2964 let confidence = confidence.min(1.0);
2965
2966 intents.push(InferredIntent {
2967 intent_type: rule.intent_type.clone(),
2968 confidence,
2969 description: format!("Detected: {}", rule.name),
2970 evidence,
2971 related_operations: Vec::new(),
2972 });
2973 }
2974
2975 intents.sort_by(|a, b| {
2977 b.confidence
2978 .partial_cmp(&a.confidence)
2979 .unwrap_or(std::cmp::Ordering::Equal)
2980 });
2981
2982 intents
2983 }
2984
2985 pub fn infer_from_detected_changes(&self, changes: &DetectedChanges) -> Vec<InferredIntent> {
2995 let model_renames: Vec<(String, String, String, String)> = changes
2997 .renamed_models
2998 .iter()
2999 .map(|(app, old_name, new_name)| {
3000 (app.clone(), old_name.clone(), app.clone(), new_name.clone())
3001 })
3002 .collect();
3003
3004 let model_moves: Vec<(String, String, String, String)> = changes
3006 .moved_models
3007 .iter()
3008 .map(|(from_app, to_app, model, _, _, _)| {
3009 (
3010 from_app.clone(),
3011 model.clone(),
3012 to_app.clone(),
3013 model.clone(),
3014 )
3015 })
3016 .collect();
3017
3018 let field_additions: Vec<(String, String, String)> = changes
3020 .added_fields
3021 .iter()
3022 .map(|(app, model, field)| (app.clone(), model.clone(), field.clone()))
3023 .collect();
3024
3025 let field_renames: Vec<(String, String, String, String)> = changes
3027 .renamed_fields
3028 .iter()
3029 .map(|(app, model, old_name, new_name)| {
3030 (
3031 app.clone(),
3032 model.clone(),
3033 old_name.clone(),
3034 new_name.clone(),
3035 )
3036 })
3037 .collect();
3038
3039 let mut intents = self.infer_intents(
3041 &model_renames,
3042 &model_moves,
3043 &field_additions,
3044 &field_renames,
3045 );
3046
3047 for intent in &mut intents {
3049 for evidence_str in &intent.evidence {
3052 if evidence_str.starts_with("Model renamed:") {
3054 for (app, old_name, new_name) in &changes.renamed_models {
3055 intent.related_operations.push(OperationRef::RenamedModel {
3056 app_label: app.clone(),
3057 old_name: old_name.clone(),
3058 new_name: new_name.clone(),
3059 });
3060 }
3061 }
3062 else if evidence_str.starts_with("Model moved:") {
3064 for (from_app, to_app, model, _, _, _) in &changes.moved_models {
3065 intent.related_operations.push(OperationRef::MovedModel {
3066 from_app: from_app.clone(),
3067 to_app: to_app.clone(),
3068 model_name: model.clone(),
3069 });
3070 }
3071 }
3072 else if evidence_str.starts_with("Field added:") {
3074 for (app, model, field) in &changes.added_fields {
3075 intent.related_operations.push(OperationRef::AddedField {
3076 app_label: app.clone(),
3077 model_name: model.clone(),
3078 field_name: field.clone(),
3079 });
3080 }
3081 }
3082 else if evidence_str.starts_with("Field renamed:") {
3084 for (app, model, old_name, new_name) in &changes.renamed_fields {
3085 intent.related_operations.push(OperationRef::RenamedField {
3086 app_label: app.clone(),
3087 model_name: model.clone(),
3088 old_name: old_name.clone(),
3089 new_name: new_name.clone(),
3090 });
3091 }
3092 }
3093 else if evidence_str.starts_with("Multiple model renames:") {
3095 for (app, old_name, new_name) in &changes.renamed_models {
3096 intent.related_operations.push(OperationRef::RenamedModel {
3097 app_label: app.clone(),
3098 old_name: old_name.clone(),
3099 new_name: new_name.clone(),
3100 });
3101 }
3102 }
3103 else if evidence_str.starts_with("Multiple field additions:")
3105 || evidence_str.starts_with("Optional field added:")
3106 {
3107 for (app, model, field) in &changes.added_fields {
3108 intent.related_operations.push(OperationRef::AddedField {
3109 app_label: app.clone(),
3110 model_name: model.clone(),
3111 field_name: field.clone(),
3112 });
3113 }
3114 }
3115 }
3116
3117 intent
3119 .related_operations
3120 .sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
3121 intent.related_operations.dedup();
3122 }
3123
3124 intents
3125 }
3126
3127 pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
3136 self.change_tracker
3137 .record_model_rename(app_label, old_name, new_name);
3138 }
3139
3140 pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
3147 self.change_tracker
3148 .record_model_move(from_app, to_app, model_name);
3149 }
3150
3151 pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
3158 self.change_tracker
3159 .record_field_addition(app_label, model_name, field_name);
3160 }
3161
3162 pub fn record_field_rename(
3170 &mut self,
3171 app_label: &str,
3172 model_name: &str,
3173 old_name: &str,
3174 new_name: &str,
3175 ) {
3176 self.change_tracker
3177 .record_field_rename(app_label, model_name, old_name, new_name);
3178 }
3179
3180 pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
3188 self.change_tracker.get_frequent_patterns(min_frequency)
3189 }
3190
3191 pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
3196 self.change_tracker.get_recent_changes(duration)
3197 }
3198
3199 pub fn analyze_cooccurrence(
3207 &self,
3208 window: std::time::Duration,
3209 ) -> HashMap<(String, String), usize> {
3210 self.change_tracker.analyze_cooccurrence(window)
3211 }
3212}
3213
3214pub struct MigrationPrompt {
3227 auto_accept_threshold: f64,
3230
3231 theme: dialoguer::theme::ColorfulTheme,
3233}
3234
3235impl std::fmt::Debug for MigrationPrompt {
3236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3237 f.debug_struct("MigrationPrompt")
3238 .field("auto_accept_threshold", &self.auto_accept_threshold)
3239 .field("theme", &"ColorfulTheme")
3240 .finish()
3241 }
3242}
3243
3244impl MigrationPrompt {
3245 pub fn new() -> Self {
3247 Self {
3248 auto_accept_threshold: 0.85,
3249 theme: dialoguer::theme::ColorfulTheme::default(),
3250 }
3251 }
3252
3253 pub fn with_threshold(threshold: f64) -> Self {
3255 Self {
3256 auto_accept_threshold: threshold,
3257 theme: dialoguer::theme::ColorfulTheme::default(),
3258 }
3259 }
3260
3261 pub fn auto_accept_threshold(&self) -> f64 {
3263 self.auto_accept_threshold
3264 }
3265
3266 pub fn confirm_intent(
3270 &self,
3271 intent: &InferredIntent,
3272 ) -> Result<bool, Box<dyn std::error::Error>> {
3273 if intent.confidence >= self.auto_accept_threshold {
3275 println!(
3276 "✓ Auto-accepting (confidence: {:.1}%): {}",
3277 intent.confidence * 100.0,
3278 intent.intent_type
3279 );
3280 return Ok(true);
3281 }
3282
3283 let message = format!(
3285 "Detected: {} (confidence: {:.1}%)\nDetails: {}\n\nAccept this change?",
3286 intent.intent_type,
3287 intent.confidence * 100.0,
3288 intent.description
3289 );
3290
3291 if !intent.evidence.is_empty() {
3293 println!("\nEvidence:");
3294 for evidence in &intent.evidence {
3295 println!(" • {}", evidence);
3296 }
3297 }
3298
3299 dialoguer::Confirm::with_theme(&self.theme)
3301 .with_prompt(message)
3302 .default(true)
3303 .interact()
3304 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3305 }
3306
3307 pub fn select_intent(
3311 &self,
3312 alternatives: &[InferredIntent],
3313 prompt: &str,
3314 ) -> Result<Option<usize>, Box<dyn std::error::Error>> {
3315 if alternatives.is_empty() {
3316 return Ok(None);
3317 }
3318
3319 if alternatives.len() == 1 {
3321 let confirmed = self.confirm_intent(&alternatives[0])?;
3322 return Ok(if confirmed { Some(0) } else { None });
3323 }
3324
3325 let items: Vec<String> = alternatives
3327 .iter()
3328 .map(|intent| {
3329 format!(
3330 "{} (confidence: {:.1}%) - {}",
3331 intent.intent_type,
3332 intent.confidence * 100.0,
3333 intent.description
3334 )
3335 })
3336 .collect();
3337
3338 println!("\n{}", prompt);
3340 println!("Multiple possibilities detected:\n");
3341
3342 let mut items_with_none = items.clone();
3344 items_with_none.push("None of the above / Skip".to_string());
3345
3346 let selection = dialoguer::Select::with_theme(&self.theme)
3348 .items(&items_with_none)
3349 .default(0)
3350 .interact()
3351 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
3352
3353 if selection >= items.len() {
3355 Ok(None)
3356 } else {
3357 Ok(Some(selection))
3358 }
3359 }
3360
3361 pub fn multi_select_intents(
3365 &self,
3366 alternatives: &[InferredIntent],
3367 prompt: &str,
3368 ) -> Result<Vec<usize>, Box<dyn std::error::Error>> {
3369 if alternatives.is_empty() {
3370 return Ok(Vec::new());
3371 }
3372
3373 let items: Vec<String> = alternatives
3375 .iter()
3376 .map(|intent| {
3377 format!(
3378 "{} (confidence: {:.1}%) - {}",
3379 intent.intent_type,
3380 intent.confidence * 100.0,
3381 intent.description
3382 )
3383 })
3384 .collect();
3385
3386 println!("\n{}", prompt);
3388 println!("Select all that apply:\n");
3389
3390 let selections = dialoguer::MultiSelect::with_theme(&self.theme)
3392 .items(&items)
3393 .interact()
3394 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
3395
3396 Ok(selections)
3397 }
3398
3399 pub fn confirm_model_rename(
3401 &self,
3402 from_app: &str,
3403 from_model: &str,
3404 to_app: &str,
3405 to_model: &str,
3406 confidence: f64,
3407 ) -> Result<bool, Box<dyn std::error::Error>> {
3408 if confidence >= self.auto_accept_threshold {
3410 println!(
3411 "✓ Auto-accepting model rename (confidence: {:.1}%): {}.{} → {}.{}",
3412 confidence * 100.0,
3413 from_app,
3414 from_model,
3415 to_app,
3416 to_model
3417 );
3418 return Ok(true);
3419 }
3420
3421 let message = format!(
3422 "Rename model from {}.{} to {}.{}?\n(confidence: {:.1}%)",
3423 from_app,
3424 from_model,
3425 to_app,
3426 to_model,
3427 confidence * 100.0
3428 );
3429
3430 dialoguer::Confirm::with_theme(&self.theme)
3431 .with_prompt(message)
3432 .default(true)
3433 .interact()
3434 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3435 }
3436
3437 pub fn confirm_field_rename(
3439 &self,
3440 model: &str,
3441 from_field: &str,
3442 to_field: &str,
3443 confidence: f64,
3444 ) -> Result<bool, Box<dyn std::error::Error>> {
3445 if confidence >= self.auto_accept_threshold {
3447 println!(
3448 "✓ Auto-accepting field rename (confidence: {:.1}%): {}.{} → {}.{}",
3449 confidence * 100.0,
3450 model,
3451 from_field,
3452 model,
3453 to_field
3454 );
3455 return Ok(true);
3456 }
3457
3458 let message = format!(
3459 "Rename field in model {}:\n {} → {}?\n(confidence: {:.1}%)",
3460 model,
3461 from_field,
3462 to_field,
3463 confidence * 100.0
3464 );
3465
3466 dialoguer::Confirm::with_theme(&self.theme)
3467 .with_prompt(message)
3468 .default(true)
3469 .interact()
3470 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3471 }
3472
3473 pub fn with_progress<F, T>(
3475 &self,
3476 message: &str,
3477 total: u64,
3478 operation: F,
3479 ) -> Result<T, Box<dyn std::error::Error>>
3480 where
3481 F: FnOnce(&indicatif::ProgressBar) -> Result<T, Box<dyn std::error::Error>>,
3482 {
3483 let pb = indicatif::ProgressBar::new(total);
3484 pb.set_style(
3485 indicatif::ProgressStyle::default_bar()
3486 .template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
3487 .expect("Failed to create progress bar template")
3488 .progress_chars("#>-"),
3489 );
3490 pb.set_message(message.to_string());
3491
3492 let result = operation(&pb)?;
3493
3494 pb.finish_with_message("Done");
3495 Ok(result)
3496 }
3497}
3498
3499impl Default for MigrationPrompt {
3500 fn default() -> Self {
3501 Self::new()
3502 }
3503}
3504
3505pub trait InteractiveAutodetector {
3507 fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>>;
3509
3510 fn apply_intents_interactive(
3512 &self,
3513 intents: Vec<InferredIntent>,
3514 changes: &mut DetectedChanges,
3515 ) -> Result<(), Box<dyn std::error::Error>>;
3516}
3517
3518impl InteractiveAutodetector for MigrationAutodetector {
3519 fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>> {
3520 let prompt = MigrationPrompt::new();
3521 let mut changes = self.detect_changes();
3522
3523 let mut engine = InferenceEngine::new();
3525 engine.add_default_rules();
3526
3527 let intents = engine.infer_from_detected_changes(&changes);
3529
3530 let ambiguous_intents: Vec<_> = intents
3532 .into_iter()
3533 .filter(|intent| intent.confidence < prompt.auto_accept_threshold)
3534 .collect();
3535
3536 if !ambiguous_intents.is_empty() {
3538 println!(
3539 "\n⚠️ Found {} ambiguous change(s) requiring confirmation:",
3540 ambiguous_intents.len()
3541 );
3542
3543 for intent in &ambiguous_intents {
3544 let confirmed = prompt.confirm_intent(intent)?;
3545
3546 if !confirmed {
3547 println!("✗ Skipped: {}", intent.description);
3548 if !intent.related_operations.is_empty() {
3551 changes.remove_operations(&intent.related_operations);
3552 println!(
3553 " → Removed {} related operation(s) from migration",
3554 intent.related_operations.len()
3555 );
3556 }
3557 }
3558 }
3559 }
3560
3561 self.detect_model_dependencies(&mut changes);
3563
3564 if let Err(cycle) = changes.check_circular_dependencies() {
3566 println!("\n⚠️ Warning: Circular dependency detected: {:?}", cycle);
3567
3568 let should_continue = dialoguer::Confirm::new()
3569 .with_prompt("Continue anyway? (may require manual intervention)")
3570 .default(false)
3571 .interact()?;
3572
3573 if !should_continue {
3574 return Err("Aborted due to circular dependency".into());
3575 }
3576 }
3577
3578 Ok(changes)
3579 }
3580
3581 fn apply_intents_interactive(
3582 &self,
3583 intents: Vec<InferredIntent>,
3584 _changes: &mut DetectedChanges,
3585 ) -> Result<(), Box<dyn std::error::Error>> {
3586 let prompt = MigrationPrompt::new();
3587
3588 let mut high_confidence = Vec::new();
3590 let mut medium_confidence = Vec::new();
3591 let mut low_confidence = Vec::new();
3592
3593 for intent in intents {
3594 if intent.confidence >= 0.85 {
3595 high_confidence.push(intent);
3596 } else if intent.confidence >= 0.65 {
3597 medium_confidence.push(intent);
3598 } else {
3599 low_confidence.push(intent);
3600 }
3601 }
3602
3603 println!(
3605 "\n✓ Auto-applying {} high-confidence change(s):",
3606 high_confidence.len()
3607 );
3608 for intent in &high_confidence {
3609 println!(
3610 " • {} (confidence: {:.1}%)",
3611 intent.description,
3612 intent.confidence * 100.0
3613 );
3614 }
3615
3616 if !medium_confidence.is_empty() {
3618 println!(
3619 "\n⚠️ Review {} medium-confidence change(s):",
3620 medium_confidence.len()
3621 );
3622
3623 for intent in &medium_confidence {
3624 let confirmed = prompt.confirm_intent(intent)?;
3625 if confirmed {
3626 println!(" ✓ Accepted: {}", intent.description);
3627 } else {
3628 println!(" ✗ Rejected: {}", intent.description);
3629 }
3630 }
3631 }
3632
3633 if !low_confidence.is_empty() {
3635 let selections = prompt.multi_select_intents(
3636 &low_confidence,
3637 "⚠️ Select low-confidence changes to apply:",
3638 )?;
3639
3640 for idx in selections {
3641 println!(" ✓ Accepted: {}", low_confidence[idx].description);
3642 }
3643 }
3644
3645 Ok(())
3646 }
3647}
3648
3649impl MigrationAutodetector {
3650 pub fn new(from_state: ProjectState, to_state: ProjectState) -> Self {
3663 Self {
3664 from_state,
3665 to_state,
3666 similarity_config: SimilarityConfig::default(),
3667 }
3668 }
3669
3670 pub fn with_config(
3684 from_state: ProjectState,
3685 to_state: ProjectState,
3686 similarity_config: SimilarityConfig,
3687 ) -> Self {
3688 Self {
3689 from_state,
3690 to_state,
3691 similarity_config,
3692 }
3693 }
3694
3695 pub fn detect_changes(&self) -> DetectedChanges {
3717 let mut changes = DetectedChanges::default();
3718
3719 self.detect_created_models(&mut changes);
3721 self.detect_deleted_models(&mut changes);
3722 self.detect_renamed_models(&mut changes);
3723
3724 self.detect_added_fields(&mut changes);
3726 self.detect_removed_fields(&mut changes);
3727 self.detect_altered_fields(&mut changes);
3728 self.detect_renamed_fields(&mut changes);
3729
3730 self.detect_added_indexes(&mut changes);
3732 self.detect_removed_indexes(&mut changes);
3733 self.detect_added_constraints(&mut changes);
3734 self.detect_removed_constraints(&mut changes);
3735 self.detect_composite_pk_changes(&mut changes);
3736 self.detect_auto_increment_resets(&mut changes);
3737
3738 self.detect_created_many_to_many(&mut changes);
3740
3741 self.detect_model_dependencies(&mut changes);
3743
3744 changes.created_models.sort();
3747 changes.deleted_models.sort();
3748 changes.added_fields.sort();
3749 changes.removed_fields.sort();
3750 changes.altered_fields.sort();
3751 changes.renamed_models.sort();
3752 changes.renamed_fields.sort();
3753
3754 changes
3756 .added_indexes
3757 .sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
3758 changes.removed_indexes.sort();
3759 changes
3760 .added_constraints
3761 .sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
3762 changes.removed_constraints.sort();
3763 changes
3764 .added_composite_primary_keys
3765 .sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
3766 changes.removed_composite_primary_keys.sort();
3767 changes.auto_increment_resets.sort();
3768 changes
3769 .created_many_to_many
3770 .sort_by(|a, b| (&a.0, &a.1, &a.2).cmp(&(&b.0, &b.1, &b.2)));
3771
3772 changes
3773 }
3774
3775 fn detect_created_models(&self, changes: &mut DetectedChanges) {
3779 for ((app_label, model_name), to_model) in &self.to_state.models {
3780 if self
3782 .from_state
3783 .get_model_by_table_name(app_label, &to_model.table_name)
3784 .is_none()
3785 {
3786 changes
3787 .created_models
3788 .push((app_label.clone(), model_name.clone()));
3789 }
3790 }
3791 }
3792
3793 fn detect_deleted_models(&self, changes: &mut DetectedChanges) {
3797 for ((app_label, model_name), from_model) in &self.from_state.models {
3798 if self
3800 .to_state
3801 .get_model_by_table_name(app_label, &from_model.table_name)
3802 .is_none()
3803 {
3804 changes
3805 .deleted_models
3806 .push((app_label.clone(), model_name.clone()));
3807 }
3808 }
3809 }
3810
3811 fn detect_added_fields(&self, changes: &mut DetectedChanges) {
3815 for ((app_label, model_name), to_model) in &self.to_state.models {
3816 if let Some(from_model) = self
3818 .from_state
3819 .get_model_by_table_name(app_label, &to_model.table_name)
3820 {
3821 for field_name in to_model.fields.keys() {
3822 if !from_model.fields.contains_key(field_name) {
3823 changes.added_fields.push((
3824 app_label.clone(),
3825 model_name.clone(),
3826 field_name.clone(),
3827 ));
3828 }
3829 }
3830 }
3831 }
3832 }
3833
3834 fn detect_removed_fields(&self, changes: &mut DetectedChanges) {
3838 for ((app_label, model_name), from_model) in &self.from_state.models {
3839 if let Some(to_model) = self
3841 .to_state
3842 .get_model_by_table_name(app_label, &from_model.table_name)
3843 {
3844 for field_name in from_model.fields.keys() {
3845 if !to_model.fields.contains_key(field_name) {
3846 changes.removed_fields.push((
3847 app_label.clone(),
3848 model_name.clone(),
3849 field_name.clone(),
3850 ));
3851 }
3852 }
3853 }
3854 }
3855 }
3856
3857 fn detect_altered_fields(&self, changes: &mut DetectedChanges) {
3861 for ((app_label, model_name), to_model) in &self.to_state.models {
3862 if let Some(from_model) = self
3864 .from_state
3865 .get_model_by_table_name(app_label, &to_model.table_name)
3866 {
3867 for (field_name, to_field) in &to_model.fields {
3868 if let Some(from_field) = from_model.fields.get(field_name) {
3869 if self.has_field_changed(field_name, from_field, to_field) {
3871 changes.altered_fields.push((
3872 app_label.clone(),
3873 model_name.clone(),
3874 field_name.clone(),
3875 ));
3876 }
3877 }
3878 }
3879 }
3880 }
3881 }
3882
3883 fn has_field_changed(
3906 &self,
3907 field_name: &str,
3908 from_field: &FieldState,
3909 to_field: &FieldState,
3910 ) -> bool {
3911 if from_field.field_type != to_field.field_type {
3915 return true;
3916 }
3917 if from_field.nullable != to_field.nullable {
3918 return true;
3919 }
3920
3921 let from_def = super::ColumnDefinition::from_field_state(field_name, from_field);
3924 let to_def = super::ColumnDefinition::from_field_state(field_name, to_field);
3925 from_def.primary_key != to_def.primary_key
3926 || from_def.auto_increment != to_def.auto_increment
3927 || from_def.unique != to_def.unique
3928 || from_def.default != to_def.default
3929 }
3930
3931 fn detect_renamed_models(&self, changes: &mut DetectedChanges) {
3977 let deleted: Vec<_> = self
3979 .from_state
3980 .models
3981 .keys()
3982 .filter(|k| !self.to_state.models.contains_key(k))
3983 .collect();
3984
3985 let created: Vec<_> = self
3986 .to_state
3987 .models
3988 .keys()
3989 .filter(|k| !self.from_state.models.contains_key(k))
3990 .collect();
3991
3992 let matches = self.find_optimal_model_matches(&deleted, &created);
3995
3996 for (deleted_key, created_key, _similarity) in matches {
3997 if deleted_key.0 == created_key.0 {
3999 let old_table = self
4002 .from_state
4003 .get_model(&deleted_key.0, &deleted_key.1)
4004 .map(|m| m.table_name.as_str());
4005 let new_table = self
4006 .to_state
4007 .get_model(&created_key.0, &created_key.1)
4008 .map(|m| m.table_name.as_str());
4009
4010 if old_table != new_table {
4011 changes
4012 .renamed_models
4013 .push((deleted_key.0, deleted_key.1, created_key.1));
4014 }
4015 } else {
4016 let old_table = format!("{}_{}", deleted_key.0, deleted_key.1.to_lowercase());
4019 let new_table = format!("{}_{}", created_key.0, created_key.1.to_lowercase());
4020 let rename_table = old_table != new_table || deleted_key.1 != created_key.1;
4021
4022 changes.moved_models.push((
4023 deleted_key.0, created_key.0, created_key.1, rename_table,
4027 if rename_table { Some(old_table) } else { None },
4028 if rename_table { Some(new_table) } else { None },
4029 ));
4030 }
4031 }
4032 }
4033
4034 fn detect_renamed_fields(&self, changes: &mut DetectedChanges) {
4075 for ((app_label, model_name), from_model) in &self.from_state.models {
4077 if let Some(to_model) = self.to_state.get_model(app_label, model_name) {
4078 let removed_fields: Vec<_> = from_model
4080 .fields
4081 .iter()
4082 .filter(|(name, _)| !to_model.fields.contains_key(*name))
4083 .collect();
4084
4085 let added_fields: Vec<_> = to_model
4086 .fields
4087 .iter()
4088 .filter(|(name, _)| !from_model.fields.contains_key(*name))
4089 .collect();
4090
4091 for (removed_name, removed_field) in &removed_fields {
4093 for (added_name, added_field) in &added_fields {
4094 if removed_field.field_type == added_field.field_type
4096 && removed_field.nullable == added_field.nullable
4097 {
4098 changes.renamed_fields.push((
4099 app_label.clone(),
4100 model_name.clone(),
4101 removed_name.to_string(),
4102 added_name.to_string(),
4103 ));
4104 break;
4105 }
4106 }
4107 }
4108 }
4109 }
4110 }
4111
4112 fn calculate_model_similarity(&self, from_model: &ModelState, to_model: &ModelState) -> f64 {
4147 if from_model.fields.is_empty() && to_model.fields.is_empty() {
4148 return 1.0;
4149 }
4150
4151 if from_model.fields.is_empty() || to_model.fields.is_empty() {
4152 return 0.0;
4153 }
4154
4155 let mut total_similarity = 0.0;
4156 let total_fields = from_model.fields.len().max(to_model.fields.len());
4157
4158 let mut matched_to_fields = std::collections::HashSet::new();
4160
4161 for (from_field_name, from_field) in &from_model.fields {
4162 let mut best_match_score = 0.0;
4163 let mut best_match_name = None;
4164
4165 for (to_field_name, to_field) in &to_model.fields {
4167 if matched_to_fields.contains(to_field_name) {
4168 continue;
4169 }
4170
4171 let similarity = self.calculate_field_similarity(
4172 from_field_name,
4173 to_field_name,
4174 from_field,
4175 to_field,
4176 );
4177
4178 if similarity > best_match_score {
4179 best_match_score = similarity;
4180 best_match_name = Some(to_field_name.clone());
4181 }
4182 }
4183
4184 if let Some(matched_name) = best_match_name {
4185 matched_to_fields.insert(matched_name);
4186 total_similarity += best_match_score;
4187 }
4188 }
4189
4190 total_similarity / total_fields as f64
4191 }
4192
4193 fn calculate_field_similarity(
4225 &self,
4226 from_field_name: &str,
4227 to_field_name: &str,
4228 from_field: &FieldState,
4229 to_field: &FieldState,
4230 ) -> f64 {
4231 if from_field.field_type != to_field.field_type {
4233 return 0.0;
4234 }
4235
4236 let jaro_winkler_sim = jaro_winkler(from_field_name, to_field_name);
4238
4239 let lev_distance = levenshtein(from_field_name, to_field_name);
4241 let max_len = from_field_name.len().max(to_field_name.len()) as f64;
4242 let levenshtein_sim = if max_len > 0.0 {
4243 1.0 - (lev_distance as f64 / max_len)
4244 } else {
4245 1.0 };
4247
4248 let name_similarity = self.similarity_config.jaro_winkler_weight * jaro_winkler_sim
4250 + self.similarity_config.levenshtein_weight * levenshtein_sim;
4251
4252 let nullable_boost = if from_field.nullable == to_field.nullable {
4254 0.1
4255 } else {
4256 0.0
4257 };
4258
4259 (name_similarity + nullable_boost).min(1.0)
4260 }
4261
4262 fn find_optimal_model_matches(
4296 &self,
4297 deleted: &[&(String, String)],
4298 created: &[&(String, String)],
4299 ) -> Vec<ModelMatchResult> {
4300 let mut graph = Graph::<(), f64, Undirected>::new_undirected();
4301 let mut deleted_nodes = Vec::new();
4302 let mut created_nodes = Vec::new();
4303
4304 for _ in deleted {
4306 deleted_nodes.push(graph.add_node(()));
4307 }
4308
4309 for _ in created {
4311 created_nodes.push(graph.add_node(()));
4312 }
4313
4314 for (i, deleted_key) in deleted.iter().enumerate() {
4316 if let Some(from_model) = self.from_state.models.get(*deleted_key) {
4317 for (j, created_key) in created.iter().enumerate() {
4318 if let Some(to_model) = self.to_state.models.get(*created_key) {
4319 let similarity = self.calculate_model_similarity(from_model, to_model);
4320
4321 if similarity >= self.similarity_config.model_threshold() {
4323 graph.add_edge(deleted_nodes[i], created_nodes[j], similarity);
4324 }
4325 }
4326 }
4327 }
4328 }
4329
4330 let mut matches = Vec::new();
4333 let mut used_deleted = std::collections::HashSet::new();
4334 let mut used_created = std::collections::HashSet::new();
4335
4336 let mut weighted_edges: Vec<_> = graph
4338 .edge_references()
4339 .map(|e| (e.source(), e.target(), *e.weight()))
4340 .collect();
4341 weighted_edges.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
4342
4343 for (source, target, weight) in weighted_edges {
4345 let source_idx = deleted_nodes.iter().position(|&n| n == source);
4346 let target_idx = created_nodes.iter().position(|&n| n == target);
4347
4348 if let (Some(i), Some(j)) = (source_idx, target_idx)
4349 && !used_deleted.contains(&i)
4350 && !used_created.contains(&j)
4351 {
4352 matches.push((deleted[i].clone(), created[j].clone(), weight));
4353 used_deleted.insert(i);
4354 used_created.insert(j);
4355 }
4356 }
4357
4358 matches
4359 }
4360
4361 fn detect_added_indexes(&self, changes: &mut DetectedChanges) {
4366 for ((app_label, model_name), to_model) in &self.to_state.models {
4367 if let Some(from_model) = self
4368 .from_state
4369 .get_model_by_table_name(app_label, &to_model.table_name)
4370 {
4371 for to_index in &to_model.indexes {
4372 if !from_model
4374 .indexes
4375 .iter()
4376 .any(|idx| idx.name == to_index.name)
4377 {
4378 changes.added_indexes.push((
4379 app_label.clone(),
4380 model_name.clone(),
4381 to_index.clone(),
4382 ));
4383 }
4384 }
4385 }
4386 }
4387 }
4388
4389 fn detect_removed_indexes(&self, changes: &mut DetectedChanges) {
4394 for ((app_label, model_name), from_model) in &self.from_state.models {
4395 if let Some(to_model) = self
4396 .to_state
4397 .get_model_by_table_name(app_label, &from_model.table_name)
4398 {
4399 for from_index in &from_model.indexes {
4400 if !to_model
4402 .indexes
4403 .iter()
4404 .any(|idx| idx.name == from_index.name)
4405 {
4406 changes.removed_indexes.push((
4407 app_label.clone(),
4408 model_name.clone(),
4409 from_index.name.clone(),
4410 ));
4411 }
4412 }
4413 }
4414 }
4415 }
4416
4417 fn detect_added_constraints(&self, changes: &mut DetectedChanges) {
4442 for ((app_label, model_name), to_model) in &self.to_state.models {
4443 if let Some(from_model) = self
4444 .from_state
4445 .get_model_by_table_name(app_label, &to_model.table_name)
4446 {
4447 for to_constraint in &to_model.constraints {
4448 if from_model
4449 .constraints
4450 .iter()
4451 .any(|c| c.name == to_constraint.name)
4452 {
4453 continue;
4454 }
4455 if Self::single_field_unique_already_present(to_constraint, from_model) {
4456 continue;
4457 }
4458 changes.added_constraints.push((
4459 app_label.clone(),
4460 model_name.clone(),
4461 to_constraint.clone(),
4462 ));
4463 }
4464 }
4465 }
4466 }
4467
4468 fn detect_removed_constraints(&self, changes: &mut DetectedChanges) {
4484 for ((app_label, model_name), from_model) in &self.from_state.models {
4485 if let Some(to_model) = self
4486 .to_state
4487 .get_model_by_table_name(app_label, &from_model.table_name)
4488 {
4489 for from_constraint in &from_model.constraints {
4490 if to_model
4491 .constraints
4492 .iter()
4493 .any(|c| c.name == from_constraint.name)
4494 {
4495 continue;
4496 }
4497 if Self::single_field_unique_already_present(from_constraint, to_model) {
4498 continue;
4499 }
4500 changes.removed_constraints.push((
4501 app_label.clone(),
4502 model_name.clone(),
4503 from_constraint.name.clone(),
4504 ));
4505 }
4506 }
4507 }
4508 }
4509
4510 fn single_field_unique_already_present(
4521 candidate: &ConstraintDefinition,
4522 other_side: &ModelState,
4523 ) -> bool {
4524 if !is_single_field_unique(candidate) {
4525 return false;
4526 }
4527 let column = &candidate.fields[0];
4528 let covered_by_constraint = other_side
4529 .constraints
4530 .iter()
4531 .any(|c| is_single_field_unique(c) && &c.fields[0] == column);
4532 if covered_by_constraint {
4533 return true;
4534 }
4535 other_side
4536 .fields
4537 .get(column)
4538 .and_then(|f| f.params.get("unique"))
4539 .map(String::as_str)
4540 == Some("true")
4541 }
4542
4543 fn dedup_redundant_unique_add_constraints(
4571 by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
4572 ) {
4573 use std::collections::HashSet;
4574
4575 for operations in by_app.values_mut() {
4576 let mut covered: HashSet<(String, String)> = HashSet::new();
4578 let mut keep = Vec::with_capacity(operations.len());
4579 for op in operations.drain(..) {
4580 match &op {
4581 super::Operation::CreateTable {
4582 name,
4583 columns,
4584 constraints,
4585 ..
4586 } => {
4587 for col in columns {
4588 if col.unique {
4589 covered.insert((name.clone(), col.name.clone()));
4590 }
4591 }
4592 for c in constraints {
4593 if let super::operations::Constraint::Unique { columns, .. } = c
4594 && columns.len() == 1
4595 {
4596 covered.insert((name.clone(), columns[0].clone()));
4597 }
4598 }
4599 keep.push(op);
4600 }
4601 super::Operation::AddColumn { table, column, .. } => {
4602 if column.unique {
4603 covered.insert((table.clone(), column.name.clone()));
4604 }
4605 keep.push(op);
4606 }
4607 super::Operation::AddConstraint {
4608 table,
4609 constraint_sql,
4610 } => {
4611 if let Some(col) = parse_single_column_unique(constraint_sql) {
4612 let key = (table.clone(), col.to_string());
4613 if covered.contains(&key) {
4614 continue;
4616 }
4617 covered.insert(key);
4618 }
4619 keep.push(op);
4620 }
4621 _ => keep.push(op),
4622 }
4623 }
4624 *operations = keep;
4625 }
4626 }
4627
4628 fn detect_composite_pk_changes(&self, changes: &mut DetectedChanges) {
4638 for ((app_label, model_name), to_model) in &self.to_state.models {
4639 let from_model = self
4640 .from_state
4641 .get_model_by_table_name(app_label, &to_model.table_name);
4642 for constraint in &to_model.constraints {
4643 if constraint.constraint_type != "primary_key" || constraint.fields.len() < 2 {
4644 continue;
4645 }
4646 let from_pk = from_model
4647 .and_then(|m| m.constraints.iter().find(|c| c.name == constraint.name));
4648 match from_pk {
4649 Some(existing) if existing.fields == constraint.fields => {
4650 }
4652 Some(_) => {
4653 changes.removed_composite_primary_keys.push((
4655 app_label.clone(),
4656 model_name.clone(),
4657 constraint.name.clone(),
4658 ));
4659 changes.added_composite_primary_keys.push((
4660 app_label.clone(),
4661 model_name.clone(),
4662 constraint.clone(),
4663 ));
4664 }
4665 None => {
4666 changes.added_composite_primary_keys.push((
4668 app_label.clone(),
4669 model_name.clone(),
4670 constraint.clone(),
4671 ));
4672 }
4673 }
4674 }
4675 }
4676 }
4677
4678 fn detect_auto_increment_resets(&self, changes: &mut DetectedChanges) {
4683 for ((app_label, model_name), to_model) in &self.to_state.models {
4684 let Some(value_str) = to_model.options.get("sequence_reset") else {
4685 continue;
4686 };
4687 let from_value = self
4688 .from_state
4689 .get_model(app_label, model_name)
4690 .and_then(|m| m.options.get("sequence_reset"))
4691 .map(String::as_str);
4692 if from_value == Some(value_str.as_str()) {
4693 continue;
4694 }
4695 let Ok(value) = value_str.parse::<i64>() else {
4696 eprintln!(
4697 "Invalid sequence_reset value for {}.{}: {:?}. Expected an integer.",
4698 app_label, model_name, value_str
4699 );
4700 continue;
4701 };
4702 let Some(column) = to_model
4703 .fields
4704 .iter()
4705 .find(|(_, f)| f.params.get("auto_increment").is_some_and(|v| v == "true"))
4706 .map(|(name, _)| name.clone())
4707 else {
4708 continue;
4709 };
4710 changes.auto_increment_resets.push((
4711 app_label.clone(),
4712 model_name.clone(),
4713 column,
4714 value,
4715 ));
4716 }
4717 }
4718
4719 fn generate_intermediate_table(
4737 &self,
4738 app_label: &str,
4739 model_name: &str,
4740 field_name: &str,
4741 to_model: &str,
4742 through_table: &Option<String>,
4743 ) -> Option<super::Operation> {
4744 let source_table = self
4748 .to_state
4749 .get_model(app_label, model_name)
4750 .map(|m| m.table_name.clone())
4751 .unwrap_or_else(|| {
4752 format!("{}_{}", to_snake_case(app_label), to_snake_case(model_name))
4753 });
4754
4755 let (target_app, target_model) = self.parse_model_reference(to_model, app_label)?;
4757 let target_table = self
4758 .to_state
4759 .get_model(&target_app, &target_model)
4760 .map(|m| m.table_name.clone())
4761 .or_else(|| {
4762 super::model_registry::global_registry()
4763 .get_models()
4764 .iter()
4765 .find(|m| m.app_label == target_app && m.model_name == target_model)
4766 .map(|m| m.table_name.clone())
4767 })
4768 .unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
4769
4770 let table_name = if let Some(custom_name) = through_table {
4776 custom_name.clone()
4777 } else {
4778 format!(
4779 "{}_{}",
4780 source_table.to_lowercase(),
4781 to_snake_case(field_name)
4782 )
4783 };
4784
4785 let source_table_lower = source_table.to_lowercase();
4791 let target_table_lower = target_table.to_lowercase();
4792 let (source_column, target_column) = if source_table_lower == target_table_lower {
4793 (
4794 format!("from_{}_id", source_table_lower),
4795 format!("to_{}_id", target_table_lower),
4796 )
4797 } else {
4798 (
4799 format!("{}_id", source_table_lower),
4800 format!("{}_id", target_table_lower),
4801 )
4802 };
4803
4804 let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
4809 let target_pk_type = self
4810 .to_state
4811 .get_primary_key_type(&target_app, &target_model);
4812
4813 let columns = vec![
4815 super::ColumnDefinition {
4817 name: "id".to_string(),
4818 type_definition: super::FieldType::BigInteger,
4819 not_null: true,
4820 unique: false,
4821 primary_key: true,
4822 auto_increment: true,
4823 default: None,
4824 },
4825 super::ColumnDefinition {
4827 name: source_column.clone(),
4828 type_definition: source_pk_type,
4829 not_null: true,
4830 unique: false,
4831 primary_key: false,
4832 auto_increment: false,
4833 default: None,
4834 },
4835 super::ColumnDefinition {
4837 name: target_column.clone(),
4838 type_definition: target_pk_type,
4839 not_null: true,
4840 unique: false,
4841 primary_key: false,
4842 auto_increment: false,
4843 default: None,
4844 },
4845 ];
4846
4847 let constraints = vec![
4849 super::Constraint::ForeignKey {
4851 name: format!("fk_{}_{}", table_name, source_column),
4852 columns: vec![source_column.clone()],
4853 referenced_table: source_table.clone(),
4854 referenced_columns: vec!["id".to_string()],
4855 on_delete: super::ForeignKeyAction::Cascade,
4856 on_update: super::ForeignKeyAction::Cascade,
4857 deferrable: None,
4858 },
4859 super::Constraint::ForeignKey {
4861 name: format!("fk_{}_{}", table_name, target_column),
4862 columns: vec![target_column.clone()],
4863 referenced_table: target_table.clone(),
4864 referenced_columns: vec!["id".to_string()],
4865 on_delete: super::ForeignKeyAction::Cascade,
4866 on_update: super::ForeignKeyAction::Cascade,
4867 deferrable: None,
4868 },
4869 super::Constraint::Unique {
4871 name: format!(
4872 "uq_{}_{}_{}",
4873 table_name,
4874 source_column.replace("_id", ""),
4875 target_column.replace("_id", "")
4876 ),
4877 columns: vec![source_column, target_column],
4878 },
4879 ];
4880
4881 Some(super::Operation::CreateTable {
4882 name: table_name,
4883 columns,
4884 constraints,
4885 without_rowid: None,
4886 interleave_in_parent: None,
4887 partition: None,
4888 })
4889 }
4890
4891 fn sort_operations_by_dependency(
4938 &self,
4939 mut operations: Vec<super::Operation>,
4940 ) -> Vec<super::Operation> {
4941 let mut sorted = Vec::new();
4942
4943 let create_tables: Vec<_> = operations
4945 .iter()
4946 .filter(|op| matches!(op, super::Operation::CreateTable { .. }))
4947 .cloned()
4948 .collect();
4949 operations.retain(|op| !matches!(op, super::Operation::CreateTable { .. }));
4950
4951 let field_ops: Vec<_> = operations
4953 .iter()
4954 .filter(|op| {
4955 matches!(
4956 op,
4957 super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
4958 )
4959 })
4960 .cloned()
4961 .collect();
4962 operations.retain(|op| {
4963 !matches!(
4964 op,
4965 super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
4966 )
4967 });
4968
4969 sorted.extend(create_tables);
4971 sorted.extend(field_ops);
4972 sorted.extend(operations); sorted
4975 }
4976
4977 pub fn generate_operations(&self) -> Vec<super::Operation> {
4979 let changes = self.detect_changes();
4980 let mut by_app: std::collections::BTreeMap<String, Vec<super::Operation>> =
4981 std::collections::BTreeMap::new();
4982
4983 self.emit_shared_per_app_operations(&changes, &mut by_app);
4988
4989 for (app_label, model_name) in &changes.created_models {
4995 if let Some(model) = self.to_state.get_model(app_label, model_name) {
4996 for (field_name, field_state) in &model.fields {
4997 if let super::FieldType::ManyToMany { to, through } = &field_state.field_type
4998 && let Some(operation) = self.generate_intermediate_table(
4999 app_label, model_name, field_name, to, through,
5000 ) {
5001 by_app.entry(app_label.clone()).or_default().push(operation);
5002 }
5003 }
5004 }
5005 }
5006 for (app_label, model_name, field_name) in &changes.added_fields {
5007 if let Some(model) = self.to_state.get_model(app_label, model_name)
5008 && let Some(field) = model.get_field(field_name)
5009 && let super::FieldType::ManyToMany { to, through } = &field.field_type
5010 && let Some(operation) =
5011 self.generate_intermediate_table(app_label, model_name, field_name, to, through)
5012 {
5013 by_app.entry(app_label.clone()).or_default().push(operation);
5014 }
5015 }
5016
5017 Self::dedup_redundant_unique_add_constraints(&mut by_app);
5029
5030 let operations: Vec<super::Operation> = by_app.into_values().flatten().collect();
5032 self.sort_operations_by_dependency(operations)
5033 }
5034
5035 fn emit_shared_per_app_operations(
5050 &self,
5051 changes: &DetectedChanges,
5052 by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
5053 ) {
5054 for (app_label, model_name) in &changes.created_models {
5056 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5057 let mut columns = Vec::new();
5058 for (field_name, field_state) in &model.fields {
5059 columns.push(super::ColumnDefinition::from_field_state(
5060 field_name.clone(),
5061 field_state,
5062 ));
5063 }
5064
5065 let constraints: Vec<super::operations::Constraint> = model
5066 .constraints
5067 .iter()
5068 .map(|c| c.to_constraint())
5069 .collect();
5070
5071 by_app
5072 .entry(app_label.clone())
5073 .or_default()
5074 .push(super::Operation::CreateTable {
5075 name: model.table_name.clone(),
5076 columns,
5077 constraints,
5078 without_rowid: None,
5079 interleave_in_parent: None,
5080 partition: None,
5081 });
5082 }
5083 }
5084
5085 for (app_label, model_name, field_name) in &changes.added_fields {
5093 if let Some(model) = self.to_state.get_model(app_label, model_name)
5094 && let Some(field) = model.get_field(field_name)
5095 {
5096 by_app
5097 .entry(app_label.clone())
5098 .or_default()
5099 .push(super::Operation::AddColumn {
5100 table: model.table_name.clone(),
5101 column: super::ColumnDefinition::from_field_state(
5102 field_name.clone(),
5103 field,
5104 ),
5105 mysql_options: None,
5106 });
5107 }
5108 }
5109
5110 for (app_label, model_name, field_name) in &changes.altered_fields {
5112 if let Some(model) = self.to_state.get_model(app_label, model_name)
5113 && let Some(field) = model.get_field(field_name)
5114 {
5115 by_app
5116 .entry(app_label.clone())
5117 .or_default()
5118 .push(super::Operation::AlterColumn {
5119 table: model.table_name.clone(),
5120 old_definition: None,
5121 column: field_name.clone(),
5122 new_definition: super::ColumnDefinition::from_field_state(
5123 field_name.clone(),
5124 field,
5125 ),
5126 mysql_options: None,
5127 });
5128 }
5129 }
5130
5131 for (app_label, model_name, field_name) in &changes.removed_fields {
5133 if let Some(model) = self.from_state.get_model(app_label, model_name) {
5134 by_app
5135 .entry(app_label.clone())
5136 .or_default()
5137 .push(super::Operation::DropColumn {
5138 table: model.table_name.clone(),
5139 column: field_name.clone(),
5140 });
5141 }
5142 }
5143
5144 for (app_label, model_name) in &changes.deleted_models {
5146 if let Some(model) = self.from_state.get_model(app_label, model_name) {
5147 by_app
5148 .entry(app_label.clone())
5149 .or_default()
5150 .push(super::Operation::DropTable {
5151 name: model.table_name.clone(),
5152 });
5153 }
5154 }
5155
5156 for (app_label, model_name, constraint_name) in &changes.removed_composite_primary_keys {
5158 if let Some(model) = self.from_state.get_model(app_label, model_name) {
5159 by_app.entry(app_label.clone()).or_default().push(
5160 super::Operation::DropConstraint {
5161 table: model.table_name.clone(),
5162 constraint_name: constraint_name.clone(),
5163 },
5164 );
5165 }
5166 }
5167
5168 for (app_label, model_name, constraint) in &changes.added_composite_primary_keys {
5170 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5171 by_app.entry(app_label.clone()).or_default().push(
5172 super::Operation::CreateCompositePrimaryKey {
5173 table: model.table_name.clone(),
5174 columns: constraint.fields.clone(),
5175 constraint_name: Some(constraint.name.clone()),
5176 },
5177 );
5178 }
5179 }
5180
5181 for (app_label, model_name, constraint_name) in &changes.removed_constraints {
5187 let Some(from_model) = self.from_state.get_model(app_label, model_name) else {
5188 continue;
5189 };
5190 let is_composite_pk = from_model
5191 .constraints
5192 .iter()
5193 .find(|c| &c.name == constraint_name)
5194 .is_some_and(|c| c.constraint_type == "primary_key" && c.fields.len() >= 2);
5195 if is_composite_pk {
5196 continue;
5197 }
5198 by_app
5199 .entry(app_label.clone())
5200 .or_default()
5201 .push(super::Operation::DropConstraint {
5202 table: from_model.table_name.clone(),
5203 constraint_name: constraint_name.clone(),
5204 });
5205 }
5206
5207 for (app_label, model_name, constraint) in &changes.added_constraints {
5221 if constraint.constraint_type == "primary_key" && constraint.fields.len() >= 2 {
5222 continue;
5223 }
5224 let Some(to_model) = self.to_state.get_model(app_label, model_name) else {
5225 continue;
5226 };
5227 let constraint_sql = constraint.to_constraint().to_string();
5228 by_app
5229 .entry(app_label.clone())
5230 .or_default()
5231 .push(super::Operation::AddConstraint {
5232 table: to_model.table_name.clone(),
5233 constraint_sql,
5234 });
5235 }
5236
5237 for (app_label, model_name, column, value) in &changes.auto_increment_resets {
5239 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5240 by_app.entry(app_label.clone()).or_default().push(
5241 super::Operation::SetAutoIncrementValue {
5242 table: model.table_name.clone(),
5243 column: column.clone(),
5244 value: *value,
5245 },
5246 );
5247 }
5248 }
5249 }
5250
5251 pub fn generate_migrations(&self) -> Vec<super::Migration> {
5293 let changes = self.detect_changes();
5294 let mut migrations_by_app: std::collections::BTreeMap<String, Vec<super::Operation>> =
5295 std::collections::BTreeMap::new();
5296
5297 self.emit_shared_per_app_operations(&changes, &mut migrations_by_app);
5302
5303 for (app_label, model_name, through_table, m2m) in &changes.created_many_to_many {
5305 let source_table = self
5310 .to_state
5311 .get_model(app_label, model_name)
5312 .map(|m| m.table_name.clone())
5313 .unwrap_or_else(|| format!("{}_{}", app_label, model_name.to_lowercase()));
5314
5315 let (parsed_target_app, parsed_target_model) = self
5323 .parse_model_reference(&m2m.to_model, app_label)
5324 .unwrap_or_else(|| (app_label.to_string(), m2m.to_model.clone()));
5325
5326 let target_table = self
5334 .to_state
5335 .get_model(&parsed_target_app, &parsed_target_model)
5336 .map(|model| model.table_name.clone())
5337 .or_else(|| {
5338 super::model_registry::global_registry()
5339 .get_models()
5340 .iter()
5341 .find(|m| {
5342 m.app_label == parsed_target_app && m.model_name == parsed_target_model
5343 })
5344 .map(|m| m.table_name.clone())
5345 })
5346 .unwrap_or_else(|| {
5347 format!(
5348 "{}_{}",
5349 parsed_target_app,
5350 parsed_target_model.to_lowercase()
5351 )
5352 });
5353
5354 let (default_source_col, default_target_col) =
5360 crate::m2m_naming::default_m2m_columns(&source_table, &target_table);
5361 let source_column = m2m.source_field.clone().unwrap_or(default_source_col);
5362 let target_column = m2m.target_field.clone().unwrap_or(default_target_col);
5363
5364 let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
5366
5367 let target_pk_type = self
5371 .to_state
5372 .get_primary_key_type(&parsed_target_app, &parsed_target_model);
5373
5374 let columns = vec![
5376 super::ColumnDefinition {
5377 name: "id".to_string(),
5378 type_definition: super::FieldType::Integer,
5379 not_null: true,
5380 unique: false,
5381 primary_key: true,
5382 auto_increment: true,
5383 default: None,
5384 },
5385 super::ColumnDefinition {
5386 name: source_column.clone(),
5387 type_definition: source_pk_type.clone(),
5388 not_null: true,
5389 unique: false,
5390 primary_key: false,
5391 auto_increment: false,
5392 default: None,
5393 },
5394 super::ColumnDefinition {
5395 name: target_column.clone(),
5396 type_definition: target_pk_type,
5397 not_null: true,
5398 unique: false,
5399 primary_key: false,
5400 auto_increment: false,
5401 default: None,
5402 },
5403 ];
5404
5405 let constraints = vec![
5407 super::operations::Constraint::ForeignKey {
5408 name: format!("fk_{}_{}", through_table, source_column),
5409 columns: vec![source_column.clone()],
5410 referenced_table: source_table.clone(),
5411 referenced_columns: vec!["id".to_string()],
5412 on_delete: ForeignKeyAction::Cascade,
5413 on_update: ForeignKeyAction::Cascade,
5414 deferrable: None,
5415 },
5416 super::operations::Constraint::ForeignKey {
5417 name: format!("fk_{}_{}", through_table, target_column),
5418 columns: vec![target_column.clone()],
5419 referenced_table: target_table,
5420 referenced_columns: vec!["id".to_string()],
5421 on_delete: ForeignKeyAction::Cascade,
5422 on_update: ForeignKeyAction::Cascade,
5423 deferrable: None,
5424 },
5425 super::operations::Constraint::Unique {
5427 name: format!("{}_unique", through_table),
5428 columns: vec![source_column, target_column],
5429 },
5430 ];
5431
5432 migrations_by_app
5433 .entry(app_label.clone())
5434 .or_default()
5435 .push(super::Operation::CreateTable {
5436 name: through_table.clone(),
5437 columns,
5438 constraints,
5439 without_rowid: None,
5440 interleave_in_parent: None,
5441 partition: None,
5442 });
5443 }
5444
5445 for (app_label, old_name, new_name) in &changes.renamed_models {
5447 if let Some(model) = self.to_state.get_model(app_label, new_name) {
5448 let old_table_name = self
5450 .from_state
5451 .get_model(app_label, old_name)
5452 .map(|m| m.table_name.clone())
5453 .unwrap_or_else(|| format!("{}_{}", app_label, old_name.to_lowercase()));
5454
5455 if old_table_name != model.table_name {
5457 migrations_by_app
5458 .entry(app_label.clone())
5459 .or_default()
5460 .push(super::Operation::RenameTable {
5461 old_name: old_table_name,
5462 new_name: model.table_name.clone(),
5463 });
5464 }
5465 }
5466 }
5467
5468 for (from_app, to_app, model_name, rename_table, old_table, new_table) in
5471 &changes.moved_models
5472 {
5473 let old_table_name = old_table.clone().unwrap_or_else(|| {
5475 self.from_state
5476 .get_model(from_app, model_name)
5477 .map(|m| m.table_name.clone())
5478 .unwrap_or_else(|| format!("{}_{}", from_app, model_name.to_lowercase()))
5479 });
5480
5481 let new_table_name = new_table.clone().unwrap_or_else(|| {
5482 self.to_state
5483 .get_model(to_app, model_name)
5484 .map(|m| m.table_name.clone())
5485 .unwrap_or_else(|| format!("{}_{}", to_app, model_name.to_lowercase()))
5486 });
5487
5488 migrations_by_app.entry(to_app.clone()).or_default().push(
5490 super::Operation::MoveModel {
5491 model_name: model_name.clone(),
5492 from_app: from_app.clone(),
5493 to_app: to_app.clone(),
5494 rename_table: *rename_table,
5495 old_table_name: if *rename_table {
5496 Some(old_table_name)
5497 } else {
5498 None
5499 },
5500 new_table_name: if *rename_table {
5501 Some(new_table_name)
5502 } else {
5503 None
5504 },
5505 },
5506 );
5507 }
5508
5509 Self::dedup_redundant_unique_add_constraints(&mut migrations_by_app);
5516
5517 let mut migrations = Vec::new();
5519 for (app_label, operations) in migrations_by_app {
5520 let migration_name = "autodetected".to_string();
5523
5524 let mut migration = super::Migration::new(&migration_name, &app_label);
5525 for operation in operations {
5526 migration = migration.add_operation(operation);
5527 }
5528 migrations.push(migration);
5529 }
5530
5531 migrations
5532 }
5533
5534 fn detect_created_many_to_many(&self, changes: &mut DetectedChanges) {
5577 for ((app_label, model_name), model_state) in &self.to_state.models {
5578 for m2m in &model_state.many_to_many_fields {
5579 let through_table = m2m.through.clone().unwrap_or_else(|| {
5596 crate::m2m_naming::default_through_table(
5597 &model_state.table_name,
5598 &m2m.field_name,
5599 )
5600 });
5601
5602 let exists_in_from = self
5612 .from_state
5613 .find_model_by_table(&through_table)
5614 .is_some();
5615
5616 if !exists_in_from {
5617 changes.created_many_to_many.push((
5619 app_label.clone(),
5620 model_name.clone(),
5621 through_table.clone(),
5622 m2m.clone(),
5623 ));
5624
5625 let target_app = self
5628 .find_model_app(&m2m.to_model)
5629 .unwrap_or_else(|| app_label.clone());
5630
5631 changes
5632 .model_dependencies
5633 .entry((app_label.clone(), through_table))
5634 .or_default()
5635 .extend(vec![
5636 (app_label.clone(), model_name.clone()),
5637 (target_app, m2m.to_model.clone()),
5638 ]);
5639 }
5640 }
5641 }
5642 }
5643
5644 fn find_model_app(&self, model_name: &str) -> Option<String> {
5649 for (app_label, name) in self.to_state.models.keys() {
5651 if name == model_name {
5652 return Some(app_label.clone());
5653 }
5654 }
5655
5656 for model_meta in super::model_registry::global_registry().get_models() {
5659 if model_meta.model_name == model_name {
5660 return Some(model_meta.app_label.clone());
5661 }
5662 }
5663
5664 None
5665 }
5666
5667 fn detect_model_dependencies(&self, changes: &mut DetectedChanges) {
5705 for ((app_label, model_name), model) in &self.to_state.models {
5707 let mut dependencies = Vec::new();
5708
5709 for field in model.fields.values() {
5711 match &field.field_type {
5712 super::FieldType::ForeignKey { to_table, .. } => {
5714 if let Some(dep) = self.find_model_by_table_name(to_table) {
5716 if dep != (app_label.clone(), model_name.clone()) {
5718 dependencies.push(dep);
5719 }
5720 }
5721 }
5722 super::FieldType::OneToOne { to, .. } => {
5724 if let Some(dep) = self.parse_model_reference(to, app_label)
5726 && dep != (app_label.clone(), model_name.clone())
5727 {
5728 dependencies.push(dep);
5729 }
5730 }
5731 super::FieldType::ManyToMany { to, .. } => {
5733 if let Some(dep) = self.parse_model_reference(to, app_label)
5735 && dep != (app_label.clone(), model_name.clone())
5736 {
5737 dependencies.push(dep);
5738 }
5739 }
5740 super::FieldType::Custom(s) => {
5742 if let Some(referenced_model) = self.extract_related_model(s, app_label)
5743 && referenced_model != (app_label.clone(), model_name.clone())
5744 {
5745 dependencies.push(referenced_model);
5746 }
5747 }
5748 _ => {}
5750 }
5751 }
5752
5753 if !dependencies.is_empty() {
5755 changes
5756 .model_dependencies
5757 .insert((app_label.clone(), model_name.clone()), dependencies);
5758 }
5759 }
5760 }
5761
5762 fn extract_related_model(
5778 &self,
5779 field_type: &str,
5780 current_app: &str,
5781 ) -> Option<(String, String)> {
5782 if let Some(inner) = field_type
5784 .strip_prefix("ForeignKey(")
5785 .and_then(|s| s.strip_suffix(")"))
5786 {
5787 return self.parse_model_reference(inner, current_app);
5788 }
5789
5790 if let Some(inner) = field_type
5792 .strip_prefix("ManyToManyField(")
5793 .and_then(|s| s.strip_suffix(")"))
5794 {
5795 return self.parse_model_reference(inner, current_app);
5796 }
5797
5798 if let Some(inner) = field_type
5800 .strip_prefix("OneToOneField(")
5801 .and_then(|s| s.strip_suffix(")"))
5802 {
5803 return self.parse_model_reference(inner, current_app);
5804 }
5805
5806 None
5807 }
5808
5809 fn parse_model_reference(
5823 &self,
5824 reference: &str,
5825 current_app: &str,
5826 ) -> Option<(String, String)> {
5827 let parts: Vec<&str> = reference.split('.').collect();
5828 match parts.as_slice() {
5829 [app, model] => Some((app.to_string(), model.to_string())),
5831 [model] => {
5833 Some((current_app.to_string(), model.to_string()))
5835 }
5836 _ => None,
5838 }
5839 }
5840
5841 fn find_model_by_table_name(&self, table_name: &str) -> Option<(String, String)> {
5857 for (app_label, model_name) in self.to_state.models.keys() {
5859 let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
5861 if django_table == table_name {
5862 return Some((app_label.clone(), model_name.clone()));
5863 }
5864
5865 if model_name.to_lowercase() == table_name {
5867 return Some((app_label.clone(), model_name.clone()));
5868 }
5869 }
5870
5871 for (app_label, model_name) in self.from_state.models.keys() {
5873 let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
5874 if django_table == table_name {
5875 return Some((app_label.clone(), model_name.clone()));
5876 }
5877
5878 if model_name.to_lowercase() == table_name {
5879 return Some((app_label.clone(), model_name.clone()));
5880 }
5881 }
5882
5883 None
5884 }
5885}
5886
5887impl ModelState {
5888 pub fn remove_field(&mut self, name: &str) {
5904 self.fields.remove(name);
5905 }
5906
5907 pub fn alter_field(&mut self, name: &str, new_field: FieldState) {
5926 self.fields.insert(name.to_string(), new_field);
5927 }
5928}
5929
5930#[cfg(test)]
5931mod tests {
5932 use super::*;
5933 use rstest::rstest;
5934
5935 fn build_project_state(models: Vec<((String, String), ModelState)>) -> ProjectState {
5937 let mut state = ProjectState::new();
5938 for (key, model) in models {
5939 state.models.insert(key, model);
5940 }
5941 state
5942 }
5943
5944 fn build_model_state(
5946 app_label: &str,
5947 name: &str,
5948 fields: Vec<FieldState>,
5949 indexes: Vec<IndexDefinition>,
5950 constraints: Vec<ConstraintDefinition>,
5951 ) -> ModelState {
5952 let mut field_map = std::collections::BTreeMap::new();
5953 for f in fields {
5954 field_map.insert(f.name.clone(), f);
5955 }
5956 ModelState {
5957 app_label: app_label.to_string(),
5958 name: name.to_string(),
5959 table_name: format!("{}_{}", app_label, name.to_lowercase()),
5960 fields: field_map,
5961 options: std::collections::HashMap::new(),
5962 base_model: None,
5963 inheritance_type: None,
5964 discriminator_column: None,
5965 indexes,
5966 constraints,
5967 many_to_many_fields: Vec::new(),
5968 }
5969 }
5970
5971 #[rstest]
5972 fn to_database_schema_uses_app_prefixed_table_key() {
5973 let model = build_model_state(
5975 "blog",
5976 "Post",
5977 vec![FieldState::new(
5978 "id",
5979 super::super::FieldType::Integer,
5980 false,
5981 )],
5982 Vec::new(),
5983 Vec::new(),
5984 );
5985 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
5986
5987 let schema = state.to_database_schema();
5989
5990 assert_eq!(schema.tables.len(), 1);
5992 assert!(
5993 schema.tables.contains_key("blog_post"),
5994 "table key should be app_label + '_' + lowercase model name"
5995 );
5996 let table = &schema.tables["blog_post"];
5997 assert_eq!(table.name, "blog_post");
5998 }
5999
6000 #[rstest]
6001 fn to_database_schema_prevents_cross_app_collision() {
6002 let blog_user = build_model_state(
6005 "blog",
6006 "User",
6007 vec![FieldState::new(
6008 "id",
6009 super::super::FieldType::Integer,
6010 false,
6011 )],
6012 Vec::new(),
6013 Vec::new(),
6014 );
6015 let auth_user = build_model_state(
6016 "auth",
6017 "User",
6018 vec![FieldState::new(
6019 "id",
6020 super::super::FieldType::Integer,
6021 false,
6022 )],
6023 Vec::new(),
6024 Vec::new(),
6025 );
6026 let state = build_project_state(vec![
6027 (("blog".to_string(), "User".to_string()), blog_user),
6028 (("auth".to_string(), "User".to_string()), auth_user),
6029 ]);
6030
6031 let schema = state.to_database_schema();
6033
6034 assert_eq!(schema.tables.len(), 2);
6036 assert!(schema.tables.contains_key("blog_user"));
6037 assert!(schema.tables.contains_key("auth_user"));
6038 }
6039
6040 #[rstest]
6041 fn to_database_schema_propagates_indexes() {
6042 let indexes = vec![
6044 IndexDefinition {
6045 name: "idx_title".to_string(),
6046 fields: vec!["title".to_string()],
6047 unique: false,
6048 },
6049 IndexDefinition {
6050 name: "idx_slug_unique".to_string(),
6051 fields: vec!["slug".to_string()],
6052 unique: true,
6053 },
6054 ];
6055 let model = build_model_state(
6056 "blog",
6057 "Post",
6058 vec![
6059 FieldState::new("title", super::super::FieldType::VarChar(255), false),
6060 FieldState::new("slug", super::super::FieldType::VarChar(100), false),
6061 ],
6062 indexes,
6063 Vec::new(),
6064 );
6065 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6066
6067 let schema = state.to_database_schema();
6069
6070 let table = &schema.tables["blog_post"];
6072 assert_eq!(table.indexes.len(), 2);
6073 assert_eq!(table.indexes[0].name, "idx_title");
6074 assert_eq!(table.indexes[0].columns, vec!["title".to_string()]);
6075 assert!(!table.indexes[0].unique);
6076 assert_eq!(table.indexes[1].name, "idx_slug_unique");
6077 assert!(table.indexes[1].unique);
6078 }
6079
6080 #[rstest]
6081 fn to_database_schema_propagates_constraints() {
6082 let constraints = vec![ConstraintDefinition {
6084 name: "uq_email".to_string(),
6085 constraint_type: "unique".to_string(),
6086 fields: vec!["email".to_string()],
6087 expression: None,
6088 foreign_key_info: None,
6089 }];
6090 let model = build_model_state(
6091 "auth",
6092 "Account",
6093 vec![FieldState::new(
6094 "email",
6095 super::super::FieldType::VarChar(255),
6096 false,
6097 )],
6098 Vec::new(),
6099 constraints,
6100 );
6101 let state = build_project_state(vec![(("auth".to_string(), "Account".to_string()), model)]);
6102
6103 let schema = state.to_database_schema();
6105
6106 let table = &schema.tables["auth_account"];
6108 assert_eq!(table.constraints.len(), 1);
6109 assert_eq!(table.constraints[0].name, "uq_email");
6110 assert_eq!(table.constraints[0].constraint_type, "unique");
6111 assert_eq!(table.constraints[0].definition, "email");
6112 }
6113
6114 #[rstest]
6115 fn to_database_schema_maps_field_params() {
6116 let mut field = FieldState::new("id", super::super::FieldType::Integer, false);
6118 field
6119 .params
6120 .insert("primary_key".to_string(), "true".to_string());
6121 field
6122 .params
6123 .insert("auto_increment".to_string(), "true".to_string());
6124 field.params.insert("default".to_string(), "0".to_string());
6125
6126 let mut nullable_field = FieldState::new("bio", super::super::FieldType::Text, true);
6127 nullable_field
6128 .params
6129 .insert("default".to_string(), "''".to_string());
6130
6131 let model = build_model_state(
6132 "users",
6133 "Profile",
6134 vec![field, nullable_field],
6135 Vec::new(),
6136 Vec::new(),
6137 );
6138 let state =
6139 build_project_state(vec![(("users".to_string(), "Profile".to_string()), model)]);
6140
6141 let schema = state.to_database_schema();
6143
6144 let table = &schema.tables["users_profile"];
6146 let id_col = &table.columns["id"];
6147 assert!(id_col.primary_key);
6148 assert!(id_col.auto_increment);
6149 assert_eq!(id_col.default, Some("0".to_string()));
6150 assert!(!id_col.nullable);
6151
6152 let bio_col = &table.columns["bio"];
6153 assert!(!bio_col.primary_key);
6154 assert!(!bio_col.auto_increment);
6155 assert!(bio_col.nullable);
6156 assert_eq!(bio_col.default, Some("''".to_string()));
6157 }
6158
6159 #[rstest]
6160 fn to_database_schema_for_app_filters_by_app_label() {
6161 let blog_post = build_model_state(
6163 "blog",
6164 "Post",
6165 vec![FieldState::new(
6166 "id",
6167 super::super::FieldType::Integer,
6168 false,
6169 )],
6170 Vec::new(),
6171 Vec::new(),
6172 );
6173 let auth_user = build_model_state(
6174 "auth",
6175 "User",
6176 vec![FieldState::new(
6177 "id",
6178 super::super::FieldType::Integer,
6179 false,
6180 )],
6181 Vec::new(),
6182 Vec::new(),
6183 );
6184 let state = build_project_state(vec![
6185 (("blog".to_string(), "Post".to_string()), blog_post),
6186 (("auth".to_string(), "User".to_string()), auth_user),
6187 ]);
6188
6189 let blog_schema = state.to_database_schema_for_app("blog");
6191 let auth_schema = state.to_database_schema_for_app("auth");
6192 let empty_schema = state.to_database_schema_for_app("nonexistent");
6193
6194 assert_eq!(blog_schema.tables.len(), 1);
6196 assert!(blog_schema.tables.contains_key("blog_post"));
6197
6198 assert_eq!(auth_schema.tables.len(), 1);
6199 assert!(auth_schema.tables.contains_key("auth_user"));
6200
6201 assert_eq!(empty_schema.tables.len(), 0);
6202 }
6203
6204 #[rstest]
6205 fn to_database_schema_for_app_propagates_indexes_and_constraints() {
6206 let indexes = vec![IndexDefinition {
6208 name: "idx_created".to_string(),
6209 fields: vec!["created_at".to_string()],
6210 unique: false,
6211 }];
6212 let constraints = vec![ConstraintDefinition {
6213 name: "ck_status".to_string(),
6214 constraint_type: "check".to_string(),
6215 fields: vec!["status".to_string()],
6216 expression: Some("status IN ('draft', 'published')".to_string()),
6217 foreign_key_info: None,
6218 }];
6219 let model = build_model_state(
6220 "blog",
6221 "Post",
6222 vec![
6223 FieldState::new("created_at", super::super::FieldType::DateTime, false),
6224 FieldState::new("status", super::super::FieldType::VarChar(20), false),
6225 ],
6226 indexes,
6227 constraints,
6228 );
6229 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6230
6231 let schema = state.to_database_schema_for_app("blog");
6233
6234 let table = &schema.tables["blog_post"];
6236 assert_eq!(table.indexes.len(), 1);
6237 assert_eq!(table.indexes[0].name, "idx_created");
6238 assert_eq!(table.indexes[0].columns, vec!["created_at".to_string()]);
6239
6240 assert_eq!(table.constraints.len(), 1);
6241 assert_eq!(table.constraints[0].name, "ck_status");
6242 assert_eq!(table.constraints[0].constraint_type, "check");
6243 assert_eq!(table.constraints[0].definition, "status");
6244 }
6245
6246 fn build_model_state_with_table_name(
6248 app_label: &str,
6249 name: &str,
6250 table_name: &str,
6251 fields: Vec<FieldState>,
6252 ) -> ModelState {
6253 let mut field_map = std::collections::BTreeMap::new();
6254 for f in fields {
6255 field_map.insert(f.name.clone(), f);
6256 }
6257 ModelState {
6258 app_label: app_label.to_string(),
6259 name: name.to_string(),
6260 table_name: table_name.to_string(),
6261 fields: field_map,
6262 options: std::collections::HashMap::new(),
6263 base_model: None,
6264 inheritance_type: None,
6265 discriminator_column: None,
6266 indexes: Vec::new(),
6267 constraints: Vec::new(),
6268 many_to_many_fields: Vec::new(),
6269 }
6270 }
6271
6272 #[rstest]
6273 fn to_database_schema_respects_custom_table_name() {
6274 let model = build_model_state_with_table_name(
6276 "blog",
6277 "Post",
6278 "custom_posts_table",
6279 vec![FieldState::new(
6280 "id",
6281 super::super::FieldType::Integer,
6282 false,
6283 )],
6284 );
6285 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6286
6287 let schema = state.to_database_schema();
6289
6290 assert!(schema.tables.contains_key("blog_post"));
6293 let table = &schema.tables["blog_post"];
6295 assert_eq!(table.name, "custom_posts_table");
6296 }
6297
6298 #[rstest]
6299 fn to_database_schema_for_app_respects_custom_table_name() {
6300 let model = build_model_state_with_table_name(
6302 "blog",
6303 "Post",
6304 "custom_posts_table",
6305 vec![FieldState::new(
6306 "id",
6307 super::super::FieldType::Integer,
6308 false,
6309 )],
6310 );
6311 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6312
6313 let schema = state.to_database_schema_for_app("blog");
6315
6316 assert!(schema.tables.contains_key("blog_post"));
6318 let table = &schema.tables["blog_post"];
6319 assert_eq!(table.name, "custom_posts_table");
6320 }
6321
6322 fn sample_fields() -> Vec<FieldState> {
6326 vec![
6327 FieldState::new("id", super::super::FieldType::Integer, false),
6328 FieldState::new("name", super::super::FieldType::VarChar(255), false),
6329 ]
6330 }
6331
6332 #[rstest]
6348 fn detect_created_many_to_many_recognises_existing_through_table_by_table_name() {
6349 use super::super::model_registry::ManyToManyMetadata;
6350
6351 let from_room = build_model_state_with_table_name("dm", "Room", "dm_room", sample_fields());
6356 let from_through = build_model_state_with_table_name(
6357 "dm",
6358 "RoomMembers",
6359 "dm_room_members",
6360 sample_fields(),
6361 );
6362 let from_state = build_project_state(vec![
6363 (("dm".to_string(), "Room".to_string()), from_room),
6364 (("dm".to_string(), "RoomMembers".to_string()), from_through),
6365 ]);
6366
6367 let mut to_room =
6372 build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
6373 to_room
6374 .many_to_many_fields
6375 .push(ManyToManyMetadata::new("members", "User"));
6376 let to_through = build_model_state_with_table_name(
6377 "dm",
6378 "DMRoomMembers",
6379 "dm_room_members",
6380 sample_fields(),
6381 );
6382 let to_state = build_project_state(vec![
6383 (("dm".to_string(), "DMRoom".to_string()), to_room),
6384 (("dm".to_string(), "DMRoomMembers".to_string()), to_through),
6385 ]);
6386
6387 let detector = MigrationAutodetector::new(from_state, to_state);
6388
6389 let changes = detector.detect_changes();
6391
6392 assert!(
6395 changes.created_many_to_many.is_empty(),
6396 "M2M through table already exists in from_state; expected no \
6397 created_many_to_many, got {:?}",
6398 changes.created_many_to_many
6399 );
6400 }
6401
6402 #[rstest]
6403 fn detect_renamed_models_skips_struct_only_rename_with_same_table_name() {
6404 let from_model =
6406 build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
6407 let to_model =
6408 build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
6409
6410 let from_state = build_project_state(vec![(
6411 ("myapp".to_string(), "Clusters".to_string()),
6412 from_model,
6413 )]);
6414 let to_state = build_project_state(vec![(
6415 ("myapp".to_string(), "Cluster".to_string()),
6416 to_model,
6417 )]);
6418
6419 let detector = MigrationAutodetector::new(from_state, to_state);
6420
6421 let changes = detector.detect_changes();
6423
6424 assert!(
6426 changes.renamed_models.is_empty(),
6427 "struct-only rename with same table name should not produce renamed_models"
6428 );
6429 }
6430
6431 #[rstest]
6432 fn detect_renamed_models_detects_actual_table_rename() {
6433 let from_model =
6435 build_model_state_with_table_name("myapp", "OldModel", "old_table", sample_fields());
6436 let to_model =
6437 build_model_state_with_table_name("myapp", "NewModel", "new_table", sample_fields());
6438
6439 let from_state = build_project_state(vec![(
6440 ("myapp".to_string(), "OldModel".to_string()),
6441 from_model,
6442 )]);
6443 let to_state = build_project_state(vec![(
6444 ("myapp".to_string(), "NewModel".to_string()),
6445 to_model,
6446 )]);
6447
6448 let detector = MigrationAutodetector::new(from_state, to_state);
6449
6450 let changes = detector.detect_changes();
6452
6453 assert_eq!(
6455 changes.renamed_models.len(),
6456 1,
6457 "actual table rename should be detected"
6458 );
6459 assert_eq!(changes.renamed_models[0].1, "OldModel");
6460 assert_eq!(changes.renamed_models[0].2, "NewModel");
6461 }
6462
6463 #[rstest]
6464 fn has_field_changed_ignores_non_schema_params() {
6465 let from_field = FieldState {
6467 name: "email".to_string(),
6468 field_type: super::super::FieldType::VarChar(255),
6469 nullable: false,
6470 params: std::collections::HashMap::new(),
6471 foreign_key: None,
6472 };
6473 let mut to_params = std::collections::HashMap::new();
6474 to_params.insert("max_length".to_string(), "255".to_string());
6475 to_params.insert("null".to_string(), "false".to_string());
6476 to_params.insert("blank".to_string(), "false".to_string());
6477 let to_field = FieldState {
6478 name: "email".to_string(),
6479 field_type: super::super::FieldType::VarChar(255),
6480 nullable: false,
6481 params: to_params,
6482 foreign_key: None,
6483 };
6484
6485 let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
6486
6487 let changed = detector.has_field_changed("email", &from_field, &to_field);
6489
6490 assert!(
6492 !changed,
6493 "fields with identical schema but different non-schema params should not be detected as changed"
6494 );
6495 }
6496
6497 #[rstest]
6498 fn generate_operations_empty_for_struct_only_rename() {
6499 let from_model =
6501 build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
6502 let to_model =
6503 build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
6504
6505 let from_state = build_project_state(vec![(
6506 ("myapp".to_string(), "Clusters".to_string()),
6507 from_model,
6508 )]);
6509 let to_state = build_project_state(vec![(
6510 ("myapp".to_string(), "Cluster".to_string()),
6511 to_model,
6512 )]);
6513
6514 let detector = MigrationAutodetector::new(from_state, to_state);
6515
6516 let operations = detector.generate_operations();
6518
6519 assert!(
6521 operations.is_empty(),
6522 "struct-only rename with same table name and identical fields should produce no operations, got: {:?}",
6523 operations
6524 );
6525 }
6526
6527 #[rstest]
6528 fn detect_composite_pk_added_emits_create_composite_primary_key() {
6529 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6531 let tenant_id_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
6532
6533 let from_model = build_model_state(
6534 "billing",
6535 "Invoice",
6536 vec![id_field.clone(), tenant_id_field.clone()],
6537 Vec::new(),
6538 Vec::new(),
6539 );
6540 let composite_pk = ConstraintDefinition {
6541 name: "billing_invoice_pkey".to_string(),
6542 constraint_type: "primary_key".to_string(),
6543 fields: vec!["id".to_string(), "tenant_id".to_string()],
6544 expression: None,
6545 foreign_key_info: None,
6546 };
6547 let to_model = build_model_state(
6548 "billing",
6549 "Invoice",
6550 vec![id_field, tenant_id_field],
6551 Vec::new(),
6552 vec![composite_pk],
6553 );
6554
6555 let from_state = build_project_state(vec![(
6556 ("billing".to_string(), "Invoice".to_string()),
6557 from_model,
6558 )]);
6559 let to_state = build_project_state(vec![(
6560 ("billing".to_string(), "Invoice".to_string()),
6561 to_model,
6562 )]);
6563 let detector = MigrationAutodetector::new(from_state, to_state);
6564
6565 let operations = detector.generate_operations();
6567
6568 assert_eq!(operations.len(), 1);
6570 assert!(
6571 matches!(
6572 &operations[0],
6573 super::super::Operation::CreateCompositePrimaryKey {
6574 table,
6575 columns,
6576 ..
6577 } if table == "billing_invoice"
6578 && columns == &["id".to_string(), "tenant_id".to_string()]
6579 ),
6580 "expected CreateCompositePrimaryKey, got: {:?}",
6581 operations
6582 );
6583 }
6584
6585 #[rstest]
6586 fn detect_composite_pk_unchanged_emits_no_operations() {
6587 let composite_pk = ConstraintDefinition {
6589 name: "billing_invoice_pkey".to_string(),
6590 constraint_type: "primary_key".to_string(),
6591 fields: vec!["id".to_string(), "tenant_id".to_string()],
6592 expression: None,
6593 foreign_key_info: None,
6594 };
6595 let from_model = build_model_state(
6596 "billing",
6597 "Invoice",
6598 vec![
6599 FieldState::new("id", super::super::FieldType::Integer, false),
6600 FieldState::new("tenant_id", super::super::FieldType::Integer, false),
6601 ],
6602 Vec::new(),
6603 vec![composite_pk.clone()],
6604 );
6605 let to_model = build_model_state(
6606 "billing",
6607 "Invoice",
6608 vec![
6609 FieldState::new("id", super::super::FieldType::Integer, false),
6610 FieldState::new("tenant_id", super::super::FieldType::Integer, false),
6611 ],
6612 Vec::new(),
6613 vec![composite_pk],
6614 );
6615
6616 let from_state = build_project_state(vec![(
6617 ("billing".to_string(), "Invoice".to_string()),
6618 from_model,
6619 )]);
6620 let to_state = build_project_state(vec![(
6621 ("billing".to_string(), "Invoice".to_string()),
6622 to_model,
6623 )]);
6624 let detector = MigrationAutodetector::new(from_state, to_state);
6625
6626 let operations = detector.generate_operations();
6628
6629 assert!(
6631 operations.is_empty(),
6632 "unchanged composite PK should produce no operations, got: {:?}",
6633 operations
6634 );
6635 }
6636
6637 #[rstest]
6638 fn detect_composite_pk_changed_fields_emits_drop_and_create() {
6639 let composite_pk_from = ConstraintDefinition {
6641 name: "billing_invoice_pkey".to_string(),
6642 constraint_type: "primary_key".to_string(),
6643 fields: vec!["id".to_string(), "tenant_id".to_string()],
6644 expression: None,
6645 foreign_key_info: None,
6646 };
6647 let composite_pk_to = ConstraintDefinition {
6648 name: "billing_invoice_pkey".to_string(),
6649 constraint_type: "primary_key".to_string(),
6650 fields: vec!["id".to_string(), "org_id".to_string()],
6651 expression: None,
6652 foreign_key_info: None,
6653 };
6654 let from_model = build_model_state(
6655 "billing",
6656 "Invoice",
6657 vec![
6658 FieldState::new("id", super::super::FieldType::Integer, false),
6659 FieldState::new("tenant_id", super::super::FieldType::Integer, false),
6660 ],
6661 Vec::new(),
6662 vec![composite_pk_from],
6663 );
6664 let to_model = build_model_state(
6665 "billing",
6666 "Invoice",
6667 vec![
6668 FieldState::new("id", super::super::FieldType::Integer, false),
6669 FieldState::new("org_id", super::super::FieldType::Integer, false),
6670 ],
6671 Vec::new(),
6672 vec![composite_pk_to],
6673 );
6674 let from_state = build_project_state(vec![(
6675 ("billing".to_string(), "Invoice".to_string()),
6676 from_model,
6677 )]);
6678 let to_state = build_project_state(vec![(
6679 ("billing".to_string(), "Invoice".to_string()),
6680 to_model,
6681 )]);
6682 let detector = MigrationAutodetector::new(from_state, to_state);
6683
6684 let operations = detector.generate_operations();
6686
6687 let drop_op = operations.iter().find(|op| {
6689 matches!(op, super::super::Operation::DropConstraint { constraint_name, .. }
6690 if constraint_name == "billing_invoice_pkey")
6691 });
6692 let create_op = operations.iter().find(|op| {
6693 matches!(op, super::super::Operation::CreateCompositePrimaryKey { columns, .. }
6694 if columns == &["id".to_string(), "org_id".to_string()])
6695 });
6696 assert!(
6697 drop_op.is_some(),
6698 "expected DropConstraint for modified composite PK, got: {:?}",
6699 operations
6700 );
6701 assert!(
6702 create_op.is_some(),
6703 "expected CreateCompositePrimaryKey with new fields, got: {:?}",
6704 operations
6705 );
6706 }
6707
6708 #[rstest]
6709 fn detect_sequence_reset_emits_set_auto_increment_value() {
6710 let mut id_field = FieldState::new("id", super::super::FieldType::BigInteger, false);
6712 id_field
6713 .params
6714 .insert("auto_increment".to_string(), "true".to_string());
6715
6716 let from_model = build_model_state(
6717 "shop",
6718 "Order",
6719 vec![id_field.clone()],
6720 Vec::new(),
6721 Vec::new(),
6722 );
6723 let mut to_model =
6724 build_model_state("shop", "Order", vec![id_field], Vec::new(), Vec::new());
6725 to_model
6726 .options
6727 .insert("sequence_reset".to_string(), "1000".to_string());
6728
6729 let from_state = build_project_state(vec![(
6730 ("shop".to_string(), "Order".to_string()),
6731 from_model,
6732 )]);
6733 let to_state =
6734 build_project_state(vec![(("shop".to_string(), "Order".to_string()), to_model)]);
6735 let detector = MigrationAutodetector::new(from_state, to_state);
6736
6737 let operations = detector.generate_operations();
6739
6740 assert_eq!(operations.len(), 1);
6742 assert!(
6743 matches!(
6744 &operations[0],
6745 super::super::Operation::SetAutoIncrementValue {
6746 table,
6747 column,
6748 value,
6749 } if table == "shop_order" && column == "id" && *value == 1000
6750 ),
6751 "expected SetAutoIncrementValue, got: {:?}",
6752 operations
6753 );
6754 }
6755
6756 #[rstest]
6757 fn detect_added_unique_together_emits_add_constraint() {
6758 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6762 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
6763 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
6764
6765 let from_model = build_model_state(
6766 "clusters",
6767 "Cluster",
6768 vec![id_field.clone(), org_field.clone(), name_field.clone()],
6769 Vec::new(),
6770 Vec::new(),
6771 );
6772 let unique_constraint = ConstraintDefinition {
6773 name: "clusters_cluster_organization_id_name_uniq".to_string(),
6774 constraint_type: "unique".to_string(),
6775 fields: vec!["organization_id".to_string(), "name".to_string()],
6776 expression: None,
6777 foreign_key_info: None,
6778 };
6779 let to_model = build_model_state(
6780 "clusters",
6781 "Cluster",
6782 vec![id_field, org_field, name_field],
6783 Vec::new(),
6784 vec![unique_constraint],
6785 );
6786
6787 let from_state = build_project_state(vec![(
6788 ("clusters".to_string(), "Cluster".to_string()),
6789 from_model,
6790 )]);
6791 let to_state = build_project_state(vec![(
6792 ("clusters".to_string(), "Cluster".to_string()),
6793 to_model,
6794 )]);
6795 let detector = MigrationAutodetector::new(from_state, to_state);
6796
6797 let operations = detector.generate_operations();
6799
6800 assert_eq!(
6803 operations.len(),
6804 1,
6805 "expected exactly one AddConstraint operation, got: {:?}",
6806 operations
6807 );
6808 let super::super::Operation::AddConstraint {
6809 table,
6810 constraint_sql,
6811 } = &operations[0]
6812 else {
6813 panic!(
6814 "expected Operation::AddConstraint, got: {:?}",
6815 operations[0]
6816 );
6817 };
6818 assert_eq!(table, "clusters_cluster");
6819 assert!(
6820 constraint_sql.contains("UNIQUE"),
6821 "constraint SQL should declare UNIQUE, got: {}",
6822 constraint_sql
6823 );
6824 assert!(
6825 constraint_sql.contains("organization_id"),
6826 "constraint SQL should reference organization_id, got: {}",
6827 constraint_sql
6828 );
6829 assert!(
6830 constraint_sql.contains("name"),
6831 "constraint SQL should reference name, got: {}",
6832 constraint_sql
6833 );
6834 assert!(
6835 constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
6836 "constraint SQL should carry the constraint name, got: {}",
6837 constraint_sql
6838 );
6839 }
6840
6841 #[rstest]
6842 fn detect_removed_unique_together_emits_drop_constraint() {
6843 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6847 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
6848 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
6849
6850 let unique_constraint = ConstraintDefinition {
6851 name: "clusters_cluster_organization_id_name_uniq".to_string(),
6852 constraint_type: "unique".to_string(),
6853 fields: vec!["organization_id".to_string(), "name".to_string()],
6854 expression: None,
6855 foreign_key_info: None,
6856 };
6857 let from_model = build_model_state(
6858 "clusters",
6859 "Cluster",
6860 vec![id_field.clone(), org_field.clone(), name_field.clone()],
6861 Vec::new(),
6862 vec![unique_constraint],
6863 );
6864 let to_model = build_model_state(
6865 "clusters",
6866 "Cluster",
6867 vec![id_field, org_field, name_field],
6868 Vec::new(),
6869 Vec::new(),
6870 );
6871
6872 let from_state = build_project_state(vec![(
6873 ("clusters".to_string(), "Cluster".to_string()),
6874 from_model,
6875 )]);
6876 let to_state = build_project_state(vec![(
6877 ("clusters".to_string(), "Cluster".to_string()),
6878 to_model,
6879 )]);
6880 let detector = MigrationAutodetector::new(from_state, to_state);
6881
6882 let operations = detector.generate_operations();
6884
6885 assert_eq!(
6887 operations.len(),
6888 1,
6889 "expected exactly one DropConstraint operation, got: {:?}",
6890 operations
6891 );
6892 let super::super::Operation::DropConstraint {
6893 table,
6894 constraint_name,
6895 } = &operations[0]
6896 else {
6897 panic!(
6898 "expected Operation::DropConstraint, got: {:?}",
6899 operations[0]
6900 );
6901 };
6902 assert_eq!(table, "clusters_cluster");
6903 assert_eq!(
6904 constraint_name,
6905 "clusters_cluster_organization_id_name_uniq"
6906 );
6907 }
6908
6909 #[rstest]
6910 fn detect_added_unique_together_via_offline_reconstructed_from_state() {
6911 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6924 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
6925 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
6926
6927 let mut from_model = build_model_state(
6929 "clusters",
6930 "Clusters",
6931 vec![id_field.clone(), org_field.clone(), name_field.clone()],
6932 Vec::new(),
6933 Vec::new(),
6934 );
6935 from_model.table_name = "clusters_cluster".to_string();
6936
6937 let unique_constraint = ConstraintDefinition {
6939 name: "clusters_cluster_organization_id_name_uniq".to_string(),
6940 constraint_type: "unique".to_string(),
6941 fields: vec!["organization_id".to_string(), "name".to_string()],
6942 expression: None,
6943 foreign_key_info: None,
6944 };
6945 let to_model = build_model_state(
6946 "clusters",
6947 "Cluster",
6948 vec![id_field, org_field, name_field],
6949 Vec::new(),
6950 vec![unique_constraint],
6951 );
6952
6953 let from_state = build_project_state(vec![(
6954 ("clusters".to_string(), "Clusters".to_string()),
6955 from_model,
6956 )]);
6957 let to_state = build_project_state(vec![(
6958 ("clusters".to_string(), "Cluster".to_string()),
6959 to_model,
6960 )]);
6961 let detector = MigrationAutodetector::new(from_state, to_state);
6962
6963 let operations = detector.generate_operations();
6965
6966 assert_eq!(
6970 operations.len(),
6971 1,
6972 "expected exactly one AddConstraint operation, got: {:?}",
6973 operations
6974 );
6975 let super::super::Operation::AddConstraint {
6976 table,
6977 constraint_sql,
6978 } = &operations[0]
6979 else {
6980 panic!(
6981 "expected Operation::AddConstraint, got: {:?}",
6982 operations[0]
6983 );
6984 };
6985 assert_eq!(table, "clusters_cluster");
6986 assert!(
6987 constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
6988 "constraint SQL should carry the constraint name, got: {}",
6989 constraint_sql
6990 );
6991 }
6992
6993 #[rstest]
6994 fn detect_removed_unique_together_via_offline_reconstructed_from_state() {
6995 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7001 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7002 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7003
7004 let unique_constraint = ConstraintDefinition {
7005 name: "clusters_cluster_organization_id_name_uniq".to_string(),
7006 constraint_type: "unique".to_string(),
7007 fields: vec!["organization_id".to_string(), "name".to_string()],
7008 expression: None,
7009 foreign_key_info: None,
7010 };
7011 let mut from_model = build_model_state(
7012 "clusters",
7013 "Clusters",
7014 vec![id_field.clone(), org_field.clone(), name_field.clone()],
7015 Vec::new(),
7016 vec![unique_constraint],
7017 );
7018 from_model.table_name = "clusters_cluster".to_string();
7019
7020 let to_model = build_model_state(
7021 "clusters",
7022 "Cluster",
7023 vec![id_field, org_field, name_field],
7024 Vec::new(),
7025 Vec::new(),
7026 );
7027
7028 let from_state = build_project_state(vec![(
7029 ("clusters".to_string(), "Clusters".to_string()),
7030 from_model,
7031 )]);
7032 let to_state = build_project_state(vec![(
7033 ("clusters".to_string(), "Cluster".to_string()),
7034 to_model,
7035 )]);
7036 let detector = MigrationAutodetector::new(from_state, to_state);
7037
7038 let operations = detector.generate_operations();
7040
7041 assert_eq!(
7043 operations.len(),
7044 1,
7045 "expected exactly one DropConstraint operation, got: {:?}",
7046 operations
7047 );
7048 let super::super::Operation::DropConstraint {
7049 table,
7050 constraint_name,
7051 } = &operations[0]
7052 else {
7053 panic!(
7054 "expected Operation::DropConstraint, got: {:?}",
7055 operations[0]
7056 );
7057 };
7058 assert_eq!(table, "clusters_cluster");
7059 assert_eq!(
7060 constraint_name,
7061 "clusters_cluster_organization_id_name_uniq"
7062 );
7063 }
7064
7065 #[rstest]
7066 fn has_field_changed_ignores_param_population_skew() {
7067 let mut from_params = std::collections::HashMap::new();
7080 from_params.insert("primary_key".to_string(), "true".to_string());
7081 from_params.insert("auto_increment".to_string(), "true".to_string());
7082 let from_field = FieldState {
7083 name: "id".to_string(),
7084 field_type: super::super::FieldType::BigInteger,
7085 nullable: false,
7086 params: from_params,
7087 foreign_key: None,
7088 };
7089
7090 let mut to_params = std::collections::HashMap::new();
7098 to_params.insert("primary_key".to_string(), "true".to_string());
7099 to_params.insert("auto_increment".to_string(), "true".to_string());
7100 to_params.insert("not_null".to_string(), "true".to_string());
7101 to_params.insert("null".to_string(), "false".to_string());
7102 to_params.insert("unique".to_string(), "false".to_string());
7103 let to_field = FieldState {
7104 name: "id".to_string(),
7105 field_type: super::super::FieldType::BigInteger,
7106 nullable: false,
7107 params: to_params,
7108 foreign_key: None,
7109 };
7110
7111 let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
7112
7113 let changed = detector.has_field_changed("id", &from_field, &to_field);
7115
7116 assert!(
7119 !changed,
7120 "identical schema with asymmetric param populations between migration replay and macro registry must not be detected as changed"
7121 );
7122 }
7123
7124 #[rstest]
7125 fn generate_operations_no_spurious_altercolumn_for_pk_via_offline_reconstructed_state() {
7126 let mut from_id_params = std::collections::HashMap::new();
7140 from_id_params.insert("primary_key".to_string(), "true".to_string());
7141 from_id_params.insert("auto_increment".to_string(), "true".to_string());
7142 let from_id_field = FieldState {
7143 name: "id".to_string(),
7144 field_type: super::super::FieldType::BigInteger,
7145 nullable: false,
7146 params: from_id_params,
7147 foreign_key: None,
7148 };
7149 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7150 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7151
7152 let mut from_model = build_model_state(
7155 "clusters",
7156 "Clusters",
7157 vec![from_id_field, org_field.clone(), name_field.clone()],
7158 Vec::new(),
7159 Vec::new(),
7160 );
7161 from_model.table_name = "clusters_cluster".to_string();
7162
7163 let mut to_id_params = std::collections::HashMap::new();
7170 to_id_params.insert("primary_key".to_string(), "true".to_string());
7171 to_id_params.insert("auto_increment".to_string(), "true".to_string());
7172 to_id_params.insert("not_null".to_string(), "true".to_string());
7173 to_id_params.insert("null".to_string(), "false".to_string());
7174 to_id_params.insert("unique".to_string(), "false".to_string());
7175 let to_id_field = FieldState {
7176 name: "id".to_string(),
7177 field_type: super::super::FieldType::BigInteger,
7178 nullable: false,
7179 params: to_id_params,
7180 foreign_key: None,
7181 };
7182 let unique_constraint = ConstraintDefinition {
7183 name: "clusters_cluster_organization_id_name_uniq".to_string(),
7184 constraint_type: "unique".to_string(),
7185 fields: vec!["organization_id".to_string(), "name".to_string()],
7186 expression: None,
7187 foreign_key_info: None,
7188 };
7189 let to_model = build_model_state(
7190 "clusters",
7191 "Cluster",
7192 vec![to_id_field, org_field, name_field],
7193 Vec::new(),
7194 vec![unique_constraint],
7195 );
7196
7197 let from_state = build_project_state(vec![(
7198 ("clusters".to_string(), "Clusters".to_string()),
7199 from_model,
7200 )]);
7201 let to_state = build_project_state(vec![(
7202 ("clusters".to_string(), "Cluster".to_string()),
7203 to_model,
7204 )]);
7205 let detector = MigrationAutodetector::new(from_state, to_state);
7206
7207 let operations = detector.generate_operations();
7209
7210 assert!(
7214 !operations
7215 .iter()
7216 .any(|op| matches!(op, super::super::Operation::AlterColumn { .. })),
7217 "no AlterColumn must be emitted for unchanged PK under offline state reconstruction, got: {:?}",
7218 operations
7219 );
7220 assert_eq!(
7221 operations.len(),
7222 1,
7223 "expected exactly one AddConstraint operation, got: {:?}",
7224 operations
7225 );
7226 assert!(
7227 matches!(
7228 &operations[0],
7229 super::super::Operation::AddConstraint { .. }
7230 ),
7231 "expected the single operation to be AddConstraint, got: {:?}",
7232 operations[0]
7233 );
7234 }
7235
7236 #[rstest]
7237 fn generate_operations_no_spurious_altercolumn_for_option_pk_via_apply_migration_operations() {
7238 let mut id_meta =
7269 super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger);
7270 id_meta = id_meta
7271 .with_param("primary_key", "true")
7272 .with_param("auto_increment", "true")
7273 .with_param("not_null", "true")
7274 .with_param("null", "false");
7275 let mut name_meta =
7276 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255));
7277 name_meta = name_meta
7278 .with_param("max_length", "255")
7279 .with_param("not_null", "true")
7280 .with_param("null", "false");
7281
7282 let mut metadata =
7283 super::super::model_registry::ModelMetadata::new("clusters", "Cluster", "clusters");
7284 metadata.add_field("id".to_string(), id_meta);
7285 metadata.add_field("name".to_string(), name_meta);
7286
7287 let to_model = metadata.to_model_state();
7288 let to_id = to_model.fields.get("id").expect("id field present");
7292 assert!(
7293 !to_id.nullable,
7294 "to_state PK FieldState.nullable must be false; got nullable=true \
7295 with params={:?}. Did the #[model] macro regress to emitting \
7296 null=\"true\" for Option<T> PKs?",
7297 to_id.params
7298 );
7299
7300 let to_state = build_project_state(vec![(
7301 ("clusters".to_string(), "Cluster".to_string()),
7302 to_model,
7303 )]);
7304
7305 let create_clusters = super::super::Operation::CreateTable {
7310 name: "clusters".to_string(),
7311 columns: vec![
7312 super::super::ColumnDefinition {
7313 name: "id".to_string(),
7314 type_definition: super::super::FieldType::BigInteger,
7315 not_null: true,
7316 unique: false,
7317 primary_key: true,
7318 auto_increment: true,
7319 default: None,
7320 },
7321 super::super::ColumnDefinition {
7322 name: "name".to_string(),
7323 type_definition: super::super::FieldType::VarChar(255),
7324 not_null: true,
7325 unique: false,
7326 primary_key: false,
7327 auto_increment: false,
7328 default: None,
7329 },
7330 ],
7331 constraints: vec![],
7332 without_rowid: None,
7333 interleave_in_parent: None,
7334 partition: None,
7335 };
7336 let mut from_state = ProjectState::new();
7337 from_state.apply_migration_operations(&[create_clusters], "clusters");
7338
7339 let from_clusters = from_state
7342 .find_model_by_table("clusters")
7343 .expect("clusters model present in from_state");
7344 assert!(
7345 !from_clusters
7346 .fields
7347 .get("id")
7348 .expect("id field in from_state")
7349 .nullable,
7350 "from_state PK FieldState.nullable must be false (column_def_to_field_state derives \
7351 from not_null); got nullable=true"
7352 );
7353
7354 let detector = MigrationAutodetector::new(from_state, to_state);
7355
7356 let direct_ops = detector.generate_operations();
7360 let migrations = detector.generate_migrations();
7361 let migration_ops: Vec<&super::super::Operation> = migrations
7362 .iter()
7363 .flat_map(|m| m.operations.iter())
7364 .collect();
7365
7366 assert!(
7369 !direct_ops.iter().any(|op| matches!(
7370 op,
7371 super::super::Operation::AlterColumn { column, .. } if column == "id"
7372 )),
7373 "generate_operations() emitted spurious AlterColumn for unchanged `id` PK \
7374 under apply_migration_operations from_state. ops={:?}",
7375 direct_ops
7376 );
7377 assert!(
7378 !migration_ops.iter().any(|op| matches!(
7379 op,
7380 super::super::Operation::AlterColumn { column, .. } if column == "id"
7381 )),
7382 "generate_migrations() emitted spurious AlterColumn for unchanged `id` PK \
7383 under apply_migration_operations from_state. ops={:?}",
7384 migration_ops
7385 );
7386 }
7387
7388 #[rstest]
7389 fn generate_migrations_emits_add_constraint_for_added_unique_together() {
7390 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7403 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7404 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7405
7406 let mut from_model = build_model_state(
7410 "clusters",
7411 "Cluster",
7412 vec![id_field.clone(), org_field.clone(), name_field.clone()],
7413 Vec::new(),
7414 Vec::new(),
7415 );
7416 from_model.table_name = "clusters_cluster".to_string();
7417
7418 let unique_constraint = ConstraintDefinition {
7421 name: "clusters_cluster_organization_id_name_uniq".to_string(),
7422 constraint_type: "unique".to_string(),
7423 fields: vec!["organization_id".to_string(), "name".to_string()],
7424 expression: None,
7425 foreign_key_info: None,
7426 };
7427 let mut to_model = build_model_state(
7428 "clusters",
7429 "Cluster",
7430 vec![id_field, org_field, name_field],
7431 Vec::new(),
7432 vec![unique_constraint],
7433 );
7434 to_model.table_name = "clusters_cluster".to_string();
7435
7436 let from_state = build_project_state(vec![(
7437 ("clusters".to_string(), "Cluster".to_string()),
7438 from_model,
7439 )]);
7440 let to_state = build_project_state(vec![(
7441 ("clusters".to_string(), "Cluster".to_string()),
7442 to_model,
7443 )]);
7444 let detector = MigrationAutodetector::new(from_state, to_state);
7445
7446 let migrations = detector.generate_migrations();
7448
7449 assert_eq!(
7452 migrations.len(),
7453 1,
7454 "expected exactly one Migration, got: {:?}",
7455 migrations
7456 );
7457 assert_eq!(migrations[0].app_label, "clusters");
7458 assert_eq!(
7459 migrations[0].operations.len(),
7460 1,
7461 "expected exactly one operation in the migration, got: {:?}",
7462 migrations[0].operations
7463 );
7464 let super::super::Operation::AddConstraint {
7465 table,
7466 constraint_sql,
7467 } = &migrations[0].operations[0]
7468 else {
7469 panic!(
7470 "expected Operation::AddConstraint, got: {:?}",
7471 migrations[0].operations[0]
7472 );
7473 };
7474 assert_eq!(table, "clusters_cluster");
7475 assert!(
7476 constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
7477 "constraint SQL should carry the constraint name, got: {}",
7478 constraint_sql
7479 );
7480 }
7481
7482 #[rstest]
7483 fn generate_migrations_emits_drop_constraint_for_removed_unique_together() {
7484 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7489 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7490 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7491
7492 let unique_constraint = ConstraintDefinition {
7493 name: "clusters_cluster_organization_id_name_uniq".to_string(),
7494 constraint_type: "unique".to_string(),
7495 fields: vec!["organization_id".to_string(), "name".to_string()],
7496 expression: None,
7497 foreign_key_info: None,
7498 };
7499 let mut from_model = build_model_state(
7500 "clusters",
7501 "Cluster",
7502 vec![id_field.clone(), org_field.clone(), name_field.clone()],
7503 Vec::new(),
7504 vec![unique_constraint],
7505 );
7506 from_model.table_name = "clusters_cluster".to_string();
7507
7508 let mut to_model = build_model_state(
7509 "clusters",
7510 "Cluster",
7511 vec![id_field, org_field, name_field],
7512 Vec::new(),
7513 Vec::new(),
7514 );
7515 to_model.table_name = "clusters_cluster".to_string();
7516
7517 let from_state = build_project_state(vec![(
7518 ("clusters".to_string(), "Cluster".to_string()),
7519 from_model,
7520 )]);
7521 let to_state = build_project_state(vec![(
7522 ("clusters".to_string(), "Cluster".to_string()),
7523 to_model,
7524 )]);
7525 let detector = MigrationAutodetector::new(from_state, to_state);
7526
7527 let migrations = detector.generate_migrations();
7529
7530 assert_eq!(
7532 migrations.len(),
7533 1,
7534 "expected exactly one Migration, got: {:?}",
7535 migrations
7536 );
7537 assert_eq!(migrations[0].app_label, "clusters");
7538 assert_eq!(
7539 migrations[0].operations.len(),
7540 1,
7541 "expected exactly one operation in the migration, got: {:?}",
7542 migrations[0].operations
7543 );
7544 let super::super::Operation::DropConstraint {
7545 table,
7546 constraint_name,
7547 } = &migrations[0].operations[0]
7548 else {
7549 panic!(
7550 "expected Operation::DropConstraint, got: {:?}",
7551 migrations[0].operations[0]
7552 );
7553 };
7554 assert_eq!(table, "clusters_cluster");
7555 assert_eq!(
7556 constraint_name,
7557 "clusters_cluster_organization_id_name_uniq"
7558 );
7559 }
7560
7561 #[rstest]
7562 fn shared_per_app_emissions_are_consistent_between_generate_paths() {
7563 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7579 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7580 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7581 let new_col = FieldState::new("region", super::super::FieldType::VarChar(64), false);
7582
7583 let mut from_model = build_model_state(
7584 "clusters",
7585 "Cluster",
7586 vec![id_field.clone(), org_field.clone(), name_field.clone()],
7587 Vec::new(),
7588 Vec::new(),
7589 );
7590 from_model.table_name = "clusters_cluster".to_string();
7591
7592 let unique_constraint = ConstraintDefinition {
7593 name: "clusters_cluster_organization_id_name_uniq".to_string(),
7594 constraint_type: "unique".to_string(),
7595 fields: vec!["organization_id".to_string(), "name".to_string()],
7596 expression: None,
7597 foreign_key_info: None,
7598 };
7599 let mut to_model = build_model_state(
7600 "clusters",
7601 "Cluster",
7602 vec![id_field, org_field, name_field, new_col],
7603 Vec::new(),
7604 vec![unique_constraint],
7605 );
7606 to_model.table_name = "clusters_cluster".to_string();
7607
7608 let from_state = build_project_state(vec![(
7609 ("clusters".to_string(), "Cluster".to_string()),
7610 from_model,
7611 )]);
7612 let to_state = build_project_state(vec![(
7613 ("clusters".to_string(), "Cluster".to_string()),
7614 to_model,
7615 )]);
7616 let detector = MigrationAutodetector::new(from_state, to_state);
7617
7618 let ops = detector.generate_operations();
7620 let migrations = detector.generate_migrations();
7621
7622 let mig_ops: Vec<&super::super::Operation> = migrations
7627 .iter()
7628 .flat_map(|m| m.operations.iter())
7629 .collect();
7630
7631 assert_eq!(
7632 ops.len(),
7633 mig_ops.len(),
7634 "shared per-app emissions diverged between generate_operations() ({:?}) and generate_migrations() ({:?})",
7635 ops,
7636 mig_ops
7637 );
7638 for op in &ops {
7641 assert!(
7642 mig_ops.iter().any(|m| *m == op),
7643 "generate_operations() produced {:?} but generate_migrations() did not",
7644 op
7645 );
7646 }
7647 for op in &mig_ops {
7649 assert!(
7650 ops.iter().any(|o| o == *op),
7651 "generate_migrations() produced {:?} but generate_operations() did not",
7652 op
7653 );
7654 }
7655 }
7656
7657 #[rstest]
7658 fn detect_added_composite_pk_does_not_double_emit_add_constraint() {
7659 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7664 let tenant_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
7665
7666 let from_model = build_model_state(
7667 "billing",
7668 "Invoice",
7669 vec![id_field.clone(), tenant_field.clone()],
7670 Vec::new(),
7671 Vec::new(),
7672 );
7673 let composite_pk = ConstraintDefinition {
7674 name: "billing_invoice_pkey".to_string(),
7675 constraint_type: "primary_key".to_string(),
7676 fields: vec!["id".to_string(), "tenant_id".to_string()],
7677 expression: None,
7678 foreign_key_info: None,
7679 };
7680 let to_model = build_model_state(
7681 "billing",
7682 "Invoice",
7683 vec![id_field, tenant_field],
7684 Vec::new(),
7685 vec![composite_pk],
7686 );
7687 let from_state = build_project_state(vec![(
7688 ("billing".to_string(), "Invoice".to_string()),
7689 from_model,
7690 )]);
7691 let to_state = build_project_state(vec![(
7692 ("billing".to_string(), "Invoice".to_string()),
7693 to_model,
7694 )]);
7695 let detector = MigrationAutodetector::new(from_state, to_state);
7696
7697 let operations = detector.generate_operations();
7699
7700 assert_eq!(operations.len(), 1, "got: {:?}", operations);
7703 assert!(
7704 matches!(
7705 &operations[0],
7706 super::super::Operation::CreateCompositePrimaryKey { columns, .. }
7707 if columns == &["id".to_string(), "tenant_id".to_string()]
7708 ),
7709 "expected only CreateCompositePrimaryKey, got: {:?}",
7710 operations
7711 );
7712 }
7713
7714 #[rstest]
7722 fn inline_unique_param_on_from_side_does_not_emit_redundant_add_constraint() {
7723 let mut username_field =
7727 FieldState::new("username", super::super::FieldType::VarChar(150), false);
7728 username_field
7729 .params
7730 .insert("unique".to_string(), "true".to_string());
7731 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7732 let from_model = build_model_state(
7733 "users",
7734 "User",
7735 vec![id_field.clone(), username_field.clone()],
7736 Vec::new(),
7737 Vec::new(),
7738 );
7739
7740 let synthesised = ConstraintDefinition {
7745 name: "users_user_username_uniq".to_string(),
7746 constraint_type: "unique".to_string(),
7747 fields: vec!["username".to_string()],
7748 expression: None,
7749 foreign_key_info: None,
7750 };
7751 let to_model = build_model_state(
7752 "users",
7753 "User",
7754 vec![id_field, username_field],
7755 Vec::new(),
7756 vec![synthesised],
7757 );
7758
7759 let from_state = build_project_state(vec![(
7760 ("users".to_string(), "User".to_string()),
7761 from_model,
7762 )]);
7763 let to_state =
7764 build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
7765 let detector = MigrationAutodetector::new(from_state, to_state);
7766
7767 let operations = detector.generate_operations();
7769
7770 assert!(
7773 operations
7774 .iter()
7775 .all(|op| !matches!(op, super::super::Operation::AddConstraint { .. })),
7776 "expected NO Operation::AddConstraint, got: {:?}",
7777 operations
7778 );
7779 }
7780
7781 #[rstest]
7789 fn single_field_unique_constraint_renames_do_not_emit_redundant_add_constraint() {
7790 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7792 let username_field =
7793 FieldState::new("username", super::super::FieldType::VarChar(150), false);
7794 let auto_named = ConstraintDefinition {
7795 name: "sqlite_autoindex_users_1".to_string(),
7796 constraint_type: "unique".to_string(),
7797 fields: vec!["username".to_string()],
7798 expression: None,
7799 foreign_key_info: None,
7800 };
7801 let model_named = ConstraintDefinition {
7802 name: "users_user_username_uniq".to_string(),
7803 constraint_type: "unique".to_string(),
7804 fields: vec!["username".to_string()],
7805 expression: None,
7806 foreign_key_info: None,
7807 };
7808 let from_model = build_model_state(
7809 "users",
7810 "User",
7811 vec![id_field.clone(), username_field.clone()],
7812 Vec::new(),
7813 vec![auto_named],
7814 );
7815 let to_model = build_model_state(
7816 "users",
7817 "User",
7818 vec![id_field, username_field],
7819 Vec::new(),
7820 vec![model_named],
7821 );
7822 let from_state = build_project_state(vec![(
7823 ("users".to_string(), "User".to_string()),
7824 from_model,
7825 )]);
7826 let to_state =
7827 build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
7828 let detector = MigrationAutodetector::new(from_state, to_state);
7829
7830 let operations = detector.generate_operations();
7832
7833 let constraint_ops: Vec<_> = operations
7839 .iter()
7840 .filter(|op| {
7841 matches!(
7842 op,
7843 super::super::Operation::AddConstraint { .. }
7844 | super::super::Operation::DropConstraint { .. }
7845 )
7846 })
7847 .collect();
7848 assert!(
7849 constraint_ops.is_empty(),
7850 "expected no Add/DropConstraint ops, got: {:?}",
7851 constraint_ops
7852 );
7853 }
7854
7855 #[rstest]
7862 fn from_side_unique_constraint_matched_by_inline_unique_on_to_side_emits_no_drop() {
7863 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7865 let mut username_field =
7866 FieldState::new("username", super::super::FieldType::VarChar(150), false);
7867 username_field
7868 .params
7869 .insert("unique".to_string(), "true".to_string());
7870 let unique_constraint = ConstraintDefinition {
7871 name: "users_user_username_uniq".to_string(),
7872 constraint_type: "unique".to_string(),
7873 fields: vec!["username".to_string()],
7874 expression: None,
7875 foreign_key_info: None,
7876 };
7877 let bare_username =
7879 FieldState::new("username", super::super::FieldType::VarChar(150), false);
7880 let from_model = build_model_state(
7881 "users",
7882 "User",
7883 vec![id_field.clone(), bare_username],
7884 Vec::new(),
7885 vec![unique_constraint],
7886 );
7887 let to_model = build_model_state(
7889 "users",
7890 "User",
7891 vec![id_field, username_field],
7892 Vec::new(),
7893 Vec::new(),
7894 );
7895 let from_state = build_project_state(vec![(
7896 ("users".to_string(), "User".to_string()),
7897 from_model,
7898 )]);
7899 let to_state =
7900 build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
7901 let detector = MigrationAutodetector::new(from_state, to_state);
7902
7903 let operations = detector.generate_operations();
7905
7906 assert!(
7909 operations
7910 .iter()
7911 .all(|op| !matches!(op, super::super::Operation::DropConstraint { .. })),
7912 "expected NO Operation::DropConstraint, got: {:?}",
7913 operations
7914 );
7915 }
7916
7917 #[rstest]
7926 fn dedup_pass_drops_add_constraint_redundant_with_unique_add_column() {
7927 let ops = vec![
7929 super::super::Operation::AddColumn {
7930 table: "users".to_string(),
7931 column: super::super::ColumnDefinition {
7932 name: "username".to_string(),
7933 type_definition: super::super::FieldType::VarChar(150),
7934 not_null: true,
7935 unique: true,
7936 primary_key: false,
7937 auto_increment: false,
7938 default: None,
7939 },
7940 mysql_options: None,
7941 },
7942 super::super::Operation::AddConstraint {
7943 table: "users".to_string(),
7944 constraint_sql: "CONSTRAINT users_user_username_uniq UNIQUE (username)".to_string(),
7945 },
7946 ];
7947 let mut by_app: std::collections::BTreeMap<String, Vec<super::super::Operation>> =
7948 std::collections::BTreeMap::new();
7949 by_app.insert("users".to_string(), ops);
7950
7951 MigrationAutodetector::dedup_redundant_unique_add_constraints(&mut by_app);
7953
7954 let remaining = &by_app["users"];
7956 assert_eq!(
7957 remaining.len(),
7958 1,
7959 "expected one operation after dedup, got: {:?}",
7960 remaining
7961 );
7962 assert!(
7963 matches!(remaining[0], super::super::Operation::AddColumn { .. }),
7964 "expected the surviving op to be AddColumn, got: {:?}",
7965 remaining[0]
7966 );
7967 }
7968}