1use petgraph::Undirected;
4use petgraph::graph::Graph;
5use petgraph::visit::EdgeRef;
6use regex::Regex;
7use std::collections::{BTreeMap, BTreeSet, 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) =
1046 self.resolve_model_reference(&m2m.to_model, source_app_label);
1047
1048 let target_pk_type = self.get_primary_key_type(&target_app, &target_model);
1049
1050 let target_table_name = self
1053 .get_model(&target_app, &target_model)
1054 .map(|m| m.table_name.clone())
1055 .unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
1056
1057 let (default_source_col, default_target_col) =
1066 crate::m2m_naming::default_m2m_columns(source_table_name, &target_table_name);
1067 let source_field_name = m2m.source_field.clone().unwrap_or(default_source_col);
1068 let target_field_name = m2m.target_field.clone().unwrap_or(default_target_col);
1069
1070 let mut from_field =
1072 FieldState::new(source_field_name.clone(), source_pk_type.clone(), false);
1073 from_field
1074 .params
1075 .insert("not_null".to_string(), "true".to_string());
1076 from_field.foreign_key = Some(ForeignKeyInfo {
1077 referenced_table: source_table_name.to_string(),
1078 referenced_column: "id".to_string(),
1079 on_delete: ForeignKeyAction::Cascade,
1080 on_update: ForeignKeyAction::Cascade,
1081 });
1082 model_state.add_field(from_field);
1083
1084 let mut to_field = FieldState::new(target_field_name.clone(), target_pk_type, false);
1086 to_field
1087 .params
1088 .insert("not_null".to_string(), "true".to_string());
1089 to_field.foreign_key = Some(ForeignKeyInfo {
1090 referenced_table: target_table_name,
1091 referenced_column: "id".to_string(),
1092 on_delete: ForeignKeyAction::Cascade,
1093 on_update: ForeignKeyAction::Cascade,
1094 });
1095 model_state.add_field(to_field);
1096
1097 model_state.add_foreign_key_constraint_from_field(&source_field_name);
1099 model_state.add_foreign_key_constraint_from_field(&target_field_name);
1100
1101 let unique_constraint = ConstraintDefinition {
1103 name: format!("{}_unique", table_name),
1104 constraint_type: "unique".to_string(),
1105 fields: vec![source_field_name, target_field_name],
1106 expression: None,
1107 foreign_key_info: None,
1108 };
1109 model_state.constraints.push(unique_constraint);
1110
1111 model_state
1112 }
1113
1114 fn resolve_model_reference(&self, reference: &str, current_app: &str) -> (String, String) {
1115 let parts: Vec<&str> = reference.split('.').collect();
1116 match parts.as_slice() {
1117 [app, model] => (app.to_string(), model.to_string()),
1118 [model] => {
1119 let model = model.to_string();
1120 if self.get_model(current_app, &model).is_some() {
1121 (current_app.to_string(), model)
1122 } else {
1123 let app = self
1124 .models
1125 .keys()
1126 .find_map(|(app_label, model_name)| {
1127 (model_name == &model).then(|| app_label.clone())
1128 })
1129 .or_else(|| {
1130 super::model_registry::global_registry()
1131 .get_models()
1132 .iter()
1133 .find(|metadata| metadata.model_name == model)
1134 .map(|metadata| metadata.app_label.clone())
1135 })
1136 .unwrap_or_else(|| current_app.to_string());
1137 (app, model)
1138 }
1139 }
1140 _ => (current_app.to_string(), reference.to_string()),
1141 }
1142 }
1143
1144 pub fn from_migrations(migrations: &[super::migration::Migration]) -> Self {
1160 let mut state = Self::new();
1161 for migration in migrations {
1162 state.apply_migration_operations(&migration.operations, &migration.app_label);
1163 }
1164 state
1165 }
1166
1167 pub fn apply_migration_operations(
1180 &mut self,
1181 operations: &[super::operations::Operation],
1182 app_label: &str,
1183 ) {
1184 use super::operations::Operation;
1185
1186 for op in operations {
1187 match op {
1188 Operation::CreateTable {
1189 name,
1190 columns,
1191 constraints,
1192 ..
1193 } => {
1194 let model_name = Self::table_name_to_model_name(name, app_label);
1198 let mut model = ModelState::new(app_label, model_name);
1199 model.table_name = name.to_string();
1200
1201 for col in columns {
1203 let field = self.column_def_to_field_state(col);
1204 model.add_field(field);
1205 }
1206 for constraint in constraints {
1207 model
1208 .constraints
1209 .push(Self::constraint_to_definition(constraint));
1210 }
1211
1212 self.add_model(model);
1213 }
1214 Operation::DropTable { name } => {
1215 let keys_to_remove: Vec<_> = self
1217 .models
1218 .iter()
1219 .filter(|(_, model)| model.table_name == *name)
1220 .map(|(key, _)| key.clone())
1221 .collect();
1222
1223 for key in keys_to_remove {
1224 self.models.remove(&key);
1225 }
1226 }
1227 Operation::AddColumn { table, column, .. } => {
1228 let field = self.column_def_to_field_state(column);
1230 if let Some(model) = self.find_model_by_table_mut(table) {
1231 model.add_field(field);
1232 }
1233 }
1234 Operation::DropColumn { table, column } => {
1235 if let Some(model) = self.find_model_by_table_mut(table) {
1237 model.fields.remove(column);
1238 model.constraints.retain(|constraint| {
1239 !constraint.fields.iter().any(|field| field == column)
1240 });
1241 }
1242 }
1243 Operation::AlterColumn {
1244 table,
1245 column,
1246 new_definition,
1247 ..
1248 } => {
1249 let new_field = self.column_def_to_field_state(new_definition);
1251 let mut updated_field = new_field;
1253 updated_field.name = column.to_string();
1254
1255 if let Some(model) = self.find_model_by_table_mut(table) {
1257 model.fields.insert(column.to_string(), updated_field);
1258 } else {
1259 let model_name = Self::table_name_to_model_name(table, app_label);
1263 let mut model = ModelState::new(app_label, model_name);
1264 model.table_name = table.to_string();
1265 model.add_field(updated_field);
1266 self.add_model(model);
1267 }
1268 }
1269 Operation::RenameTable { old_name, new_name } => {
1270 if let Some(model) = self.find_model_by_table_mut(old_name) {
1272 model.table_name = new_name.to_string();
1273 }
1274 }
1275 Operation::RenameColumn {
1276 table,
1277 old_name,
1278 new_name,
1279 } => {
1280 if let Some(model) = self.find_model_by_table_mut(table) {
1282 model.rename_field(old_name, new_name.to_string());
1283 for constraint in &mut model.constraints {
1284 for field in &mut constraint.fields {
1285 if field == old_name {
1286 *field = new_name.to_string();
1287 }
1288 }
1289 }
1290 }
1291 }
1292 Operation::AddConstraint {
1293 table,
1294 constraint_sql,
1295 } => {
1296 if let Some(model) = self.find_model_by_table_mut(table)
1297 && let Some(constraint) =
1298 Self::constraint_definition_from_sql(constraint_sql)
1299 && !model.constraints.iter().any(|c| c.name == constraint.name)
1300 {
1301 model.constraints.push(constraint);
1302 }
1303 }
1304 Operation::DropConstraint {
1305 table,
1306 constraint_name,
1307 } => {
1308 if let Some(model) = self.find_model_by_table_mut(table) {
1309 model
1310 .constraints
1311 .retain(|constraint| constraint.name != *constraint_name);
1312 }
1313 }
1314 _ => {
1316 }
1319 }
1320 }
1321 }
1322
1323 pub fn find_model_by_table(&self, table_name: &str) -> Option<&ModelState> {
1325 self.models
1326 .values()
1327 .find(|model| model.table_name == table_name)
1328 }
1329
1330 pub fn find_model_by_table_mut(&mut self, table_name: &str) -> Option<&mut ModelState> {
1332 self.models
1333 .values_mut()
1334 .find(|model| model.table_name == table_name)
1335 }
1336
1337 fn table_name_to_model_name(table_name: &str, app_label: &str) -> String {
1346 let prefix = format!("{}_", app_label);
1348 let name_without_prefix = if table_name.starts_with(&prefix) {
1349 &table_name[prefix.len()..]
1350 } else {
1351 table_name
1352 };
1353
1354 name_without_prefix
1356 .split('_')
1357 .map(|word| {
1358 let mut chars = word.chars();
1359 match chars.next() {
1360 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1361 None => String::new(),
1362 }
1363 })
1364 .collect()
1365 }
1366
1367 fn constraint_to_definition(
1368 constraint: &super::operations::Constraint,
1369 ) -> ConstraintDefinition {
1370 match constraint {
1371 super::operations::Constraint::PrimaryKey { name, columns } => ConstraintDefinition {
1372 name: name.clone(),
1373 constraint_type: "primary_key".to_string(),
1374 fields: columns.clone(),
1375 expression: None,
1376 foreign_key_info: None,
1377 },
1378 super::operations::Constraint::ForeignKey {
1379 name,
1380 columns,
1381 referenced_table,
1382 referenced_columns,
1383 on_delete,
1384 on_update,
1385 ..
1386 } => ConstraintDefinition {
1387 name: name.clone(),
1388 constraint_type: "foreign_key".to_string(),
1389 fields: columns.clone(),
1390 expression: None,
1391 foreign_key_info: Some(ForeignKeyConstraintInfo {
1392 referenced_table: referenced_table.clone(),
1393 referenced_columns: referenced_columns.clone(),
1394 on_delete: *on_delete,
1395 on_update: *on_update,
1396 }),
1397 },
1398 super::operations::Constraint::Unique { name, columns } => ConstraintDefinition {
1399 name: name.clone(),
1400 constraint_type: "unique".to_string(),
1401 fields: columns.clone(),
1402 expression: None,
1403 foreign_key_info: None,
1404 },
1405 super::operations::Constraint::Check { name, expression } => ConstraintDefinition {
1406 name: name.clone(),
1407 constraint_type: "check".to_string(),
1408 fields: Vec::new(),
1409 expression: Some(expression.clone()),
1410 foreign_key_info: None,
1411 },
1412 super::operations::Constraint::OneToOne {
1413 name,
1414 column,
1415 referenced_table,
1416 referenced_column,
1417 on_delete,
1418 on_update,
1419 ..
1420 } => ConstraintDefinition {
1421 name: name.clone(),
1422 constraint_type: "one_to_one".to_string(),
1423 fields: vec![column.clone()],
1424 expression: None,
1425 foreign_key_info: Some(ForeignKeyConstraintInfo {
1426 referenced_table: referenced_table.clone(),
1427 referenced_columns: vec![referenced_column.clone()],
1428 on_delete: *on_delete,
1429 on_update: *on_update,
1430 }),
1431 },
1432 super::operations::Constraint::ManyToMany {
1433 name,
1434 source_column,
1435 target_column,
1436 ..
1437 } => ConstraintDefinition {
1438 name: name.clone(),
1439 constraint_type: "many_to_many".to_string(),
1440 fields: vec![source_column.clone(), target_column.clone()],
1441 expression: None,
1442 foreign_key_info: None,
1443 },
1444 super::operations::Constraint::Exclude { name, elements, .. } => ConstraintDefinition {
1445 name: name.clone(),
1446 constraint_type: "exclude".to_string(),
1447 fields: elements.iter().map(|(field, _)| field.clone()).collect(),
1448 expression: None,
1449 foreign_key_info: None,
1450 },
1451 }
1452 }
1453
1454 fn trim_sql_identifier(identifier: &str) -> String {
1455 let trimmed = identifier.trim();
1456 if let Some(stripped) = trimmed
1457 .strip_prefix('"')
1458 .and_then(|value| value.strip_suffix('"'))
1459 .or_else(|| {
1460 trimmed
1461 .strip_prefix('`')
1462 .and_then(|value| value.strip_suffix('`'))
1463 })
1464 .or_else(|| {
1465 trimmed
1466 .strip_prefix('\'')
1467 .and_then(|value| value.strip_suffix('\''))
1468 }) {
1469 stripped.to_string()
1470 } else {
1471 trimmed.to_string()
1472 }
1473 }
1474
1475 fn parse_constraint_identifier_list(identifier_list: &str) -> Vec<String> {
1476 identifier_list
1477 .split(',')
1478 .map(Self::trim_sql_identifier)
1479 .filter(|identifier| !identifier.is_empty())
1480 .collect()
1481 }
1482
1483 fn foreign_key_action_from_clause(clauses: &str, clause: &str) -> Option<ForeignKeyAction> {
1484 let upper_clauses = clauses.to_ascii_uppercase();
1485 let tail = upper_clauses.split_once(clause)?.1.trim_start();
1486 if tail.starts_with("SET NULL") {
1487 Some(ForeignKeyAction::SetNull)
1488 } else if tail.starts_with("SET DEFAULT") {
1489 Some(ForeignKeyAction::SetDefault)
1490 } else if tail.starts_with("NO ACTION") {
1491 Some(ForeignKeyAction::NoAction)
1492 } else if tail.starts_with("CASCADE") {
1493 Some(ForeignKeyAction::Cascade)
1494 } else if tail.starts_with("RESTRICT") {
1495 Some(ForeignKeyAction::Restrict)
1496 } else {
1497 None
1498 }
1499 }
1500
1501 fn constraint_definition_from_sql(constraint_sql: &str) -> Option<ConstraintDefinition> {
1502 let rest = constraint_sql.trim().strip_prefix("CONSTRAINT ")?;
1503 let (name, body) = rest.split_once(' ')?;
1504 let body = body.trim();
1505 let upper_body = body.to_ascii_uppercase();
1506 if upper_body.starts_with("UNIQUE (") {
1507 let open = body.find('(')?;
1508 let close = body[open + 1..].find(')')? + open + 1;
1509 let fields = body[open + 1..close]
1510 .split(',')
1511 .map(|field| field.trim().trim_matches('"').to_string())
1512 .filter(|field| !field.is_empty())
1513 .collect::<Vec<_>>();
1514 if fields.is_empty() {
1515 return None;
1516 }
1517 return Some(ConstraintDefinition {
1518 name: name.trim_matches('"').to_string(),
1519 constraint_type: "unique".to_string(),
1520 fields,
1521 expression: None,
1522 foreign_key_info: None,
1523 });
1524 }
1525 if upper_body.starts_with("CHECK (") {
1526 let open = body.find('(')?;
1527 let close = body.rfind(')')?;
1528 return Some(ConstraintDefinition {
1529 name: name.trim_matches('"').to_string(),
1530 constraint_type: "check".to_string(),
1531 fields: Vec::new(),
1532 expression: Some(body[open + 1..close].trim().to_string()),
1533 foreign_key_info: None,
1534 });
1535 }
1536 if upper_body.starts_with("FOREIGN KEY") {
1537 let open = body.find('(')?;
1538 let close = body[open + 1..].find(')')? + open + 1;
1539 let fields = Self::parse_constraint_identifier_list(&body[open + 1..close]);
1540 if fields.is_empty() {
1541 return None;
1542 }
1543
1544 let after_fields = body[close + 1..].trim_start();
1545 if !after_fields.to_ascii_uppercase().starts_with("REFERENCES") {
1546 return None;
1547 }
1548 let after_references = after_fields["REFERENCES".len()..].trim_start();
1549 let referenced_open = after_references.find('(')?;
1550 let referenced_table = Self::trim_sql_identifier(&after_references[..referenced_open]);
1551 if referenced_table.is_empty() {
1552 return None;
1553 }
1554
1555 let referenced_close =
1556 after_references[referenced_open + 1..].find(')')? + referenced_open + 1;
1557 let referenced_columns = Self::parse_constraint_identifier_list(
1558 &after_references[referenced_open + 1..referenced_close],
1559 );
1560 if referenced_columns.is_empty() {
1561 return None;
1562 }
1563
1564 let clauses = &after_references[referenced_close + 1..];
1565 return Some(ConstraintDefinition {
1566 name: name.trim_matches('"').to_string(),
1567 constraint_type: "foreign_key".to_string(),
1568 fields,
1569 expression: None,
1570 foreign_key_info: Some(ForeignKeyConstraintInfo {
1571 referenced_table,
1572 referenced_columns,
1573 on_delete: Self::foreign_key_action_from_clause(clauses, "ON DELETE")
1574 .unwrap_or(ForeignKeyAction::NoAction),
1575 on_update: Self::foreign_key_action_from_clause(clauses, "ON UPDATE")
1576 .unwrap_or(ForeignKeyAction::NoAction),
1577 }),
1578 });
1579 }
1580 None
1581 }
1582
1583 fn column_def_to_field_state(&self, col: &super::operations::ColumnDefinition) -> FieldState {
1585 let mut params = std::collections::HashMap::new();
1586
1587 if col.primary_key {
1588 params.insert("primary_key".to_string(), "true".to_string());
1589 }
1590 if col.auto_increment {
1591 params.insert("auto_increment".to_string(), "true".to_string());
1592 }
1593 if col.unique {
1594 params.insert("unique".to_string(), "true".to_string());
1595 }
1596 if let Some(default) = &col.default {
1597 params.insert("default".to_string(), default.to_string());
1598 }
1599
1600 FieldState {
1601 name: col.name.to_string(),
1602 field_type: col.type_definition.clone(),
1603 nullable: !col.not_null,
1604 params,
1605 foreign_key: None,
1606 }
1607 }
1608}
1609
1610#[non_exhaustive]
1638#[derive(Debug, Clone)]
1639pub struct SimilarityConfig {
1640 model_threshold: f64,
1643 field_threshold: f64,
1646 jaro_winkler_weight: f64,
1649 levenshtein_weight: f64,
1653}
1654
1655impl SimilarityConfig {
1656 pub fn new(model_threshold: f64, field_threshold: f64) -> Result<Self, String> {
1685 Self::with_weights(model_threshold, field_threshold, 0.7, 0.3)
1686 }
1687
1688 pub fn with_weights(
1719 model_threshold: f64,
1720 field_threshold: f64,
1721 jaro_winkler_weight: f64,
1722 levenshtein_weight: f64,
1723 ) -> Result<Self, String> {
1724 if !(0.45..=0.95).contains(&model_threshold) {
1728 return Err(format!(
1729 "model_threshold must be between 0.45 and 0.95, got {}",
1730 model_threshold
1731 ));
1732 }
1733 if !(0.45..=0.95).contains(&field_threshold) {
1734 return Err(format!(
1735 "field_threshold must be between 0.45 and 0.95, got {}",
1736 field_threshold
1737 ));
1738 }
1739
1740 if !(0.0..=1.0).contains(&jaro_winkler_weight) {
1742 return Err(format!(
1743 "jaro_winkler_weight must be between 0.0 and 1.0, got {}",
1744 jaro_winkler_weight
1745 ));
1746 }
1747 if !(0.0..=1.0).contains(&levenshtein_weight) {
1748 return Err(format!(
1749 "levenshtein_weight must be between 0.0 and 1.0, got {}",
1750 levenshtein_weight
1751 ));
1752 }
1753
1754 let weight_sum = jaro_winkler_weight + levenshtein_weight;
1756 if (weight_sum - 1.0).abs() > 0.01 {
1757 return Err(format!(
1758 "jaro_winkler_weight + levenshtein_weight must sum to 1.0, got {} + {} = {}",
1759 jaro_winkler_weight, levenshtein_weight, weight_sum
1760 ));
1761 }
1762
1763 Ok(Self {
1764 model_threshold,
1765 field_threshold,
1766 jaro_winkler_weight,
1767 levenshtein_weight,
1768 })
1769 }
1770
1771 pub fn model_threshold(&self) -> f64 {
1773 self.model_threshold
1774 }
1775
1776 pub fn field_threshold(&self) -> f64 {
1778 self.field_threshold
1779 }
1780}
1781
1782impl Default for SimilarityConfig {
1783 fn default() -> Self {
1790 Self {
1791 model_threshold: 0.7,
1792 field_threshold: 0.8,
1793 jaro_winkler_weight: 0.7,
1794 levenshtein_weight: 0.3,
1795 }
1796 }
1797}
1798
1799pub struct MigrationAutodetector {
1825 from_state: ProjectState,
1826 to_state: ProjectState,
1827 similarity_config: SimilarityConfig,
1828}
1829
1830type MovedModelInfo = (
1833 String,
1834 String,
1835 String,
1836 String,
1837 bool,
1838 Option<String>,
1839 Option<String>,
1840);
1841
1842type ModelMatchResult = ((String, String), (String, String), f64);
1844
1845#[derive(Debug, Clone, Default)]
1847pub struct DetectedChanges {
1848 pub created_models: Vec<(String, String)>,
1850 pub deleted_models: Vec<(String, String)>,
1852 pub added_fields: Vec<(String, String, String)>,
1854 pub removed_fields: Vec<(String, String, String)>,
1856 pub altered_fields: Vec<(String, String, String)>,
1858 pub renamed_models: Vec<(String, String, String)>,
1860 pub moved_models: Vec<MovedModelInfo>,
1862 pub renamed_fields: Vec<(String, String, String, String)>,
1864 pub added_indexes: Vec<(String, String, IndexDefinition)>,
1866 pub removed_indexes: Vec<(String, String, String)>,
1868 pub added_constraints: Vec<(String, String, ConstraintDefinition)>,
1870 pub removed_constraints: Vec<(String, String, String)>,
1872 pub added_composite_primary_keys: Vec<(String, String, ConstraintDefinition)>,
1874 pub removed_composite_primary_keys: Vec<(String, String, String)>,
1876 pub auto_increment_resets: Vec<(String, String, String, i64)>,
1878 pub model_dependencies: std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
1882 pub created_many_to_many: Vec<(String, String, String, ManyToManyMetadata)>,
1885}
1886
1887impl DetectedChanges {
1888 pub fn order_models_by_dependency(&self) -> Vec<(String, String)> {
1927 use std::collections::{HashMap, HashSet, VecDeque};
1928
1929 let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
1931 let mut all_models: HashSet<(String, String)> = HashSet::new();
1932
1933 for model in &self.created_models {
1935 all_models.insert(model.clone());
1936 in_degree.entry(model.clone()).or_insert(0);
1937 }
1938
1939 for model in &self.moved_models {
1940 let model_key = (model.2.clone(), model.3.clone()); all_models.insert(model_key.clone());
1942 in_degree.entry(model_key).or_insert(0);
1943 }
1944
1945 for (dependent, dependencies) in &self.model_dependencies {
1947 for dependency in dependencies {
1948 all_models.insert(dependency.clone());
1949 in_degree.entry(dependency.clone()).or_insert(0);
1950 *in_degree.entry(dependent.clone()).or_insert(0) += 1;
1951 }
1952 }
1953
1954 let mut queue: VecDeque<(String, String)> = VecDeque::new();
1956 for model in &all_models {
1957 if in_degree.get(model).copied().unwrap_or(0) == 0 {
1958 queue.push_back(model.clone());
1959 }
1960 }
1961
1962 let mut ordered = Vec::new();
1963
1964 while let Some(model) = queue.pop_front() {
1965 ordered.push(model.clone());
1966
1967 for (dependent, dependencies) in &self.model_dependencies {
1971 if dependencies.contains(&model)
1972 && let Some(degree) = in_degree.get_mut(dependent)
1973 {
1974 *degree -= 1;
1975 if *degree == 0 {
1976 queue.push_back(dependent.clone());
1977 }
1978 }
1979 }
1980 }
1981
1982 if ordered.len() < all_models.len() {
1984 let unordered_models: Vec<_> = all_models
1986 .iter()
1987 .filter(|model| !ordered.contains(model))
1988 .map(|(app, name)| format!("{}.{}", app, name))
1989 .collect();
1990
1991 eprintln!(
1992 "⚠️ Warning: Circular dependency detected in models: [{}]",
1993 unordered_models.join(", ")
1994 );
1995 eprintln!(
1996 " Falling back to original order. Migration operations may need manual reordering."
1997 );
1998
1999 all_models.into_iter().collect()
2000 } else {
2001 ordered
2002 }
2003 }
2004
2005 pub fn check_circular_dependencies(&self) -> Result<(), Vec<(String, String)>> {
2040 use std::collections::HashSet;
2041
2042 let mut visited: HashSet<(String, String)> = HashSet::new();
2043 let mut rec_stack: HashSet<(String, String)> = HashSet::new();
2044 let mut path: Vec<(String, String)> = Vec::new();
2045
2046 fn dfs(
2047 model: &(String, String),
2048 deps: &BTreeMap<(String, String), Vec<(String, String)>>,
2049 visited: &mut HashSet<(String, String)>,
2050 rec_stack: &mut HashSet<(String, String)>,
2051 path: &mut Vec<(String, String)>,
2052 ) -> Option<Vec<(String, String)>> {
2053 visited.insert(model.clone());
2054 rec_stack.insert(model.clone());
2055 path.push(model.clone());
2056
2057 if let Some(dependencies) = deps.get(model) {
2058 for dep in dependencies {
2059 if !visited.contains(dep) {
2060 if let Some(cycle) = dfs(dep, deps, visited, rec_stack, path) {
2061 return Some(cycle);
2062 }
2063 } else if rec_stack.contains(dep) {
2064 let cycle_start = path.iter().position(|m| m == dep).unwrap();
2066 return Some(path[cycle_start..].to_vec());
2067 }
2068 }
2069 }
2070
2071 path.pop();
2072 rec_stack.remove(model);
2073 None
2074 }
2075
2076 for model in self.model_dependencies.keys() {
2077 if !visited.contains(model)
2078 && let Some(cycle) = dfs(
2079 model,
2080 &self.model_dependencies,
2081 &mut visited,
2082 &mut rec_stack,
2083 &mut path,
2084 ) {
2085 return Err(cycle);
2086 }
2087 }
2088
2089 Ok(())
2090 }
2091
2092 pub fn remove_operations(&mut self, refs: &[OperationRef]) {
2132 for op_ref in refs {
2133 match op_ref {
2134 OperationRef::RenamedModel {
2135 app_label,
2136 old_name,
2137 new_name,
2138 } => {
2139 self.renamed_models.retain(|(app, old, new)| {
2140 !(app == app_label && old == old_name && new == new_name)
2141 });
2142 }
2143 OperationRef::MovedModel {
2144 from_app,
2145 to_app,
2146 model_name,
2147 } => {
2148 self.moved_models.retain(|info| {
2150 !(&info.0 == from_app
2151 && &info.2 == to_app && (&info.1 == model_name || &info.3 == model_name))
2152 });
2153 }
2154 OperationRef::AddedField {
2155 app_label,
2156 model_name,
2157 field_name,
2158 } => {
2159 self.added_fields.retain(|(app, model, field)| {
2160 !(app == app_label && model == model_name && field == field_name)
2161 });
2162 }
2163 OperationRef::RenamedField {
2164 app_label,
2165 model_name,
2166 old_name,
2167 new_name,
2168 } => {
2169 self.renamed_fields.retain(|(app, model, old, new)| {
2170 !(app == app_label
2171 && model == model_name
2172 && old == old_name && new == new_name)
2173 });
2174 }
2175 OperationRef::RemovedField {
2176 app_label,
2177 model_name,
2178 field_name,
2179 } => {
2180 self.removed_fields.retain(|(app, model, field)| {
2181 !(app == app_label && model == model_name && field == field_name)
2182 });
2183 }
2184 OperationRef::AlteredField {
2185 app_label,
2186 model_name,
2187 field_name,
2188 } => {
2189 self.altered_fields.retain(|(app, model, field)| {
2190 !(app == app_label && model == model_name && field == field_name)
2191 });
2192 }
2193 OperationRef::CreatedModel {
2194 app_label,
2195 model_name,
2196 } => {
2197 self.created_models
2198 .retain(|(app, model)| !(app == app_label && model == model_name));
2199 }
2200 OperationRef::DeletedModel {
2201 app_label,
2202 model_name,
2203 } => {
2204 self.deleted_models
2205 .retain(|(app, model)| !(app == app_label && model == model_name));
2206 }
2207 }
2208 }
2209 }
2210}
2211
2212#[derive(Debug, Clone)]
2239pub struct ChangeHistoryEntry {
2240 pub timestamp: std::time::SystemTime,
2242 pub change_type: String,
2244 pub app_label: String,
2246 pub model_name: String,
2248 pub field_name: Option<String>,
2250 pub old_value: Option<String>,
2252 pub new_value: Option<String>,
2254}
2255
2256#[derive(Debug, Clone)]
2262pub struct PatternFrequency {
2263 pub pattern: String,
2265 pub frequency: usize,
2267 pub last_seen: std::time::SystemTime,
2269 pub contexts: Vec<String>,
2271}
2272
2273#[derive(Debug, Clone)]
2302pub struct ChangeTracker {
2303 history: Vec<ChangeHistoryEntry>,
2305 patterns: HashMap<String, PatternFrequency>,
2307 max_history_size: usize,
2309}
2310
2311impl ChangeTracker {
2312 pub fn new() -> Self {
2316 Self {
2317 history: Vec::new(),
2318 patterns: HashMap::new(),
2319 max_history_size: 1000,
2320 }
2321 }
2322
2323 pub fn with_capacity(max_size: usize) -> Self {
2325 Self {
2326 history: Vec::with_capacity(max_size),
2327 patterns: HashMap::new(),
2328 max_history_size: max_size,
2329 }
2330 }
2331
2332 pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
2339 let entry = ChangeHistoryEntry {
2340 timestamp: std::time::SystemTime::now(),
2341 change_type: "RenameModel".to_string(),
2342 app_label: app_label.to_string(),
2343 model_name: new_name.to_string(),
2344 field_name: None,
2345 old_value: Some(old_name.to_string()),
2346 new_value: Some(new_name.to_string()),
2347 };
2348
2349 self.add_entry(entry);
2350 self.update_pattern(
2351 &format!("RenameModel:{}->{}", old_name, new_name),
2352 app_label,
2353 );
2354 }
2355
2356 pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
2358 let entry = ChangeHistoryEntry {
2359 timestamp: std::time::SystemTime::now(),
2360 change_type: "MoveModel".to_string(),
2361 app_label: to_app.to_string(),
2362 model_name: model_name.to_string(),
2363 field_name: None,
2364 old_value: Some(from_app.to_string()),
2365 new_value: Some(to_app.to_string()),
2366 };
2367
2368 self.add_entry(entry);
2369 self.update_pattern(
2370 &format!("MoveModel:{}->{}:{}", from_app, to_app, model_name),
2371 to_app,
2372 );
2373 }
2374
2375 pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
2377 let entry = ChangeHistoryEntry {
2378 timestamp: std::time::SystemTime::now(),
2379 change_type: "AddField".to_string(),
2380 app_label: app_label.to_string(),
2381 model_name: model_name.to_string(),
2382 field_name: Some(field_name.to_string()),
2383 old_value: None,
2384 new_value: Some(field_name.to_string()),
2385 };
2386
2387 self.add_entry(entry);
2388 self.update_pattern(
2389 &format!("AddField:{}:{}", model_name, field_name),
2390 app_label,
2391 );
2392 }
2393
2394 pub fn record_field_rename(
2396 &mut self,
2397 app_label: &str,
2398 model_name: &str,
2399 old_name: &str,
2400 new_name: &str,
2401 ) {
2402 let entry = ChangeHistoryEntry {
2403 timestamp: std::time::SystemTime::now(),
2404 change_type: "RenameField".to_string(),
2405 app_label: app_label.to_string(),
2406 model_name: model_name.to_string(),
2407 field_name: Some(new_name.to_string()),
2408 old_value: Some(old_name.to_string()),
2409 new_value: Some(new_name.to_string()),
2410 };
2411
2412 self.add_entry(entry);
2413 self.update_pattern(
2414 &format!("RenameField:{}:{}->{}", model_name, old_name, new_name),
2415 app_label,
2416 );
2417 }
2418
2419 fn add_entry(&mut self, entry: ChangeHistoryEntry) {
2421 self.history.push(entry);
2422
2423 if self.history.len() > self.max_history_size {
2425 self.history.remove(0);
2426 }
2427 }
2428
2429 fn update_pattern(&mut self, pattern: &str, context: &str) {
2431 self.patterns
2432 .entry(pattern.to_string())
2433 .and_modify(|pf| {
2434 pf.frequency += 1;
2435 pf.last_seen = std::time::SystemTime::now();
2436 if !pf.contexts.contains(&context.to_string()) {
2437 pf.contexts.push(context.to_string());
2438 }
2439 })
2440 .or_insert(PatternFrequency {
2441 pattern: pattern.to_string(),
2442 frequency: 1,
2443 last_seen: std::time::SystemTime::now(),
2444 contexts: vec![context.to_string()],
2445 });
2446 }
2447
2448 pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
2452 let mut patterns: Vec<_> = self
2453 .patterns
2454 .values()
2455 .filter(|p| p.frequency >= min_frequency)
2456 .cloned()
2457 .collect();
2458
2459 patterns.sort_by_key(|pattern| std::cmp::Reverse(pattern.frequency));
2460 patterns
2461 }
2462
2463 pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
2468 let now = std::time::SystemTime::now();
2469 self.history
2470 .iter()
2471 .filter(|entry| {
2472 now.duration_since(entry.timestamp)
2473 .map(|d| d < duration)
2474 .unwrap_or(false)
2475 })
2476 .collect()
2477 }
2478
2479 pub fn analyze_cooccurrence(
2484 &self,
2485 window: std::time::Duration,
2486 ) -> HashMap<(String, String), usize> {
2487 let mut cooccurrences = HashMap::new();
2488
2489 for i in 0..self.history.len() {
2490 for j in (i + 1)..self.history.len() {
2491 if let Ok(diff) = self.history[j]
2492 .timestamp
2493 .duration_since(self.history[i].timestamp)
2494 && diff <= window
2495 {
2496 let pattern1 = format!(
2497 "{}:{}",
2498 self.history[i].change_type, self.history[i].model_name
2499 );
2500 let pattern2 = format!(
2501 "{}:{}",
2502 self.history[j].change_type, self.history[j].model_name
2503 );
2504 let key = if pattern1 < pattern2 {
2505 (pattern1, pattern2)
2506 } else {
2507 (pattern2, pattern1)
2508 };
2509 *cooccurrences.entry(key).or_insert(0) += 1;
2510 }
2511 }
2512 }
2513
2514 cooccurrences
2515 }
2516
2517 pub fn clear(&mut self) {
2519 self.history.clear();
2520 self.patterns.clear();
2521 }
2522
2523 pub fn len(&self) -> usize {
2525 self.history.len()
2526 }
2527
2528 pub fn is_empty(&self) -> bool {
2530 self.history.is_empty()
2531 }
2532}
2533
2534impl Default for ChangeTracker {
2535 fn default() -> Self {
2536 Self::new()
2537 }
2538}
2539
2540#[derive(Debug, Clone)]
2544pub struct PatternMatch {
2545 pub pattern: String,
2547 pub start: usize,
2549 pub end: usize,
2551 pub matched_text: String,
2553}
2554
2555#[derive(Debug, Clone)]
2582pub struct PatternMatcher {
2583 patterns: Vec<String>,
2585 automaton: Option<aho_corasick::AhoCorasick>,
2587}
2588
2589impl PatternMatcher {
2590 pub fn new() -> Self {
2592 Self {
2593 patterns: Vec::new(),
2594 automaton: None,
2595 }
2596 }
2597
2598 pub fn add_pattern(&mut self, pattern: &str) {
2603 self.patterns.push(pattern.to_string());
2604 self.automaton = None;
2606 }
2607
2608 pub fn add_patterns<I, S>(&mut self, patterns: I)
2610 where
2611 I: IntoIterator<Item = S>,
2612 S: AsRef<str>,
2613 {
2614 for pattern in patterns {
2615 self.patterns.push(pattern.as_ref().to_string());
2616 }
2617 self.automaton = None;
2618 }
2619
2620 pub fn build(&mut self) -> Result<(), String> {
2625 if self.patterns.is_empty() {
2626 return Err("No patterns to build automaton".to_string());
2627 }
2628
2629 self.automaton = Some(
2630 aho_corasick::AhoCorasick::new(&self.patterns)
2631 .map_err(|e| format!("Failed to build Aho-Corasick automaton: {}", e))?,
2632 );
2633
2634 Ok(())
2635 }
2636
2637 pub fn find_all(&self, text: &str) -> Vec<PatternMatch> {
2641 let Some(ref automaton) = self.automaton else {
2642 return Vec::new();
2643 };
2644
2645 automaton
2646 .find_iter(text)
2647 .map(|mat| PatternMatch {
2648 pattern: self.patterns[mat.pattern().as_usize()].clone(),
2649 start: mat.start(),
2650 end: mat.end(),
2651 matched_text: text[mat.start()..mat.end()].to_string(),
2652 })
2653 .collect()
2654 }
2655
2656 pub fn contains_any(&self, text: &str) -> bool {
2658 self.automaton
2659 .as_ref()
2660 .map(|ac| ac.is_match(text))
2661 .unwrap_or(false)
2662 }
2663
2664 pub fn find_first(&self, text: &str) -> Option<PatternMatch> {
2666 let automaton = self.automaton.as_ref()?;
2667 let mat = automaton.find(text)?;
2668
2669 Some(PatternMatch {
2670 pattern: self.patterns[mat.pattern().as_usize()].clone(),
2671 start: mat.start(),
2672 end: mat.end(),
2673 matched_text: text[mat.start()..mat.end()].to_string(),
2674 })
2675 }
2676
2677 pub fn replace_all(&self, text: &str, replacements: &HashMap<String, String>) -> String {
2686 let Some(ref automaton) = self.automaton else {
2687 return text.to_string();
2688 };
2689
2690 let mut result = String::new();
2691 let mut last_end = 0;
2692
2693 for mat in automaton.find_iter(text) {
2694 result.push_str(&text[last_end..mat.start()]);
2696
2697 let pattern = &self.patterns[mat.pattern().as_usize()];
2699 if let Some(replacement) = replacements.get(pattern) {
2700 result.push_str(replacement);
2701 } else {
2702 result.push_str(&text[mat.start()..mat.end()]);
2703 }
2704
2705 last_end = mat.end();
2706 }
2707
2708 result.push_str(&text[last_end..]);
2710 result
2711 }
2712
2713 pub fn patterns(&self) -> &[String] {
2715 &self.patterns
2716 }
2717
2718 pub fn clear(&mut self) {
2720 self.patterns.clear();
2721 self.automaton = None;
2722 }
2723
2724 pub fn is_built(&self) -> bool {
2726 self.automaton.is_some()
2727 }
2728}
2729
2730impl Default for PatternMatcher {
2731 fn default() -> Self {
2732 Self::new()
2733 }
2734}
2735
2736#[derive(Debug, Clone, PartialEq)]
2742pub enum RuleCondition {
2743 ModelRename {
2745 from_pattern: String,
2747 to_pattern: String,
2749 },
2750 ModelMove {
2752 app_pattern: String,
2754 },
2755 FieldAddition {
2757 field_name_pattern: String,
2759 },
2760 FieldRename {
2762 from_pattern: String,
2764 to_pattern: String,
2766 },
2767 MultipleModelRenames {
2769 min_count: usize,
2771 },
2772 MultipleFieldAdditions {
2774 model_pattern: String,
2776 min_count: usize,
2778 },
2779}
2780
2781#[derive(Debug, Clone, PartialEq)]
2786pub enum OperationRef {
2787 RenamedModel {
2789 app_label: String,
2791 old_name: String,
2793 new_name: String,
2795 },
2796 MovedModel {
2798 from_app: String,
2800 to_app: String,
2802 model_name: String,
2804 },
2805 AddedField {
2807 app_label: String,
2809 model_name: String,
2811 field_name: String,
2813 },
2814 RenamedField {
2816 app_label: String,
2818 model_name: String,
2820 old_name: String,
2822 new_name: String,
2824 },
2825 RemovedField {
2827 app_label: String,
2829 model_name: String,
2831 field_name: String,
2833 },
2834 AlteredField {
2836 app_label: String,
2838 model_name: String,
2840 field_name: String,
2842 },
2843 CreatedModel {
2845 app_label: String,
2847 model_name: String,
2849 },
2850 DeletedModel {
2852 app_label: String,
2854 model_name: String,
2856 },
2857}
2858
2859#[derive(Debug, Clone, PartialEq)]
2861pub struct InferredIntent {
2862 pub intent_type: String,
2864 pub confidence: f64,
2866 pub description: String,
2868 pub evidence: Vec<String>,
2870 pub related_operations: Vec<OperationRef>,
2875}
2876
2877#[derive(Debug, Clone)]
2879pub struct InferenceRule {
2880 pub name: String,
2882 pub conditions: Vec<RuleCondition>,
2884 pub optional_conditions: Vec<RuleCondition>,
2886 pub intent_type: String,
2888 pub base_confidence: f64,
2890 pub confidence_boost_per_optional: f64,
2892}
2893
2894#[derive(Debug, Clone)]
2931pub struct InferenceEngine {
2932 rules: Vec<InferenceRule>,
2934 change_tracker: ChangeTracker,
2954}
2955
2956impl Default for InferenceEngine {
2957 fn default() -> Self {
2958 Self::new()
2959 }
2960}
2961
2962impl InferenceEngine {
2963 pub fn new() -> Self {
2965 Self {
2966 rules: Vec::new(),
2967 change_tracker: ChangeTracker::new(),
2968 }
2969 }
2970
2971 pub fn add_rule(&mut self, rule: InferenceRule) {
2973 self.rules.push(rule);
2974 }
2975
2976 pub fn add_default_rules(&mut self) {
2978 self.add_rule(InferenceRule {
2980 name: "model_refactoring".to_string(),
2981 conditions: vec![RuleCondition::ModelRename {
2982 from_pattern: ".*".to_string(),
2983 to_pattern: ".*".to_string(),
2984 }],
2985 optional_conditions: vec![RuleCondition::MultipleModelRenames { min_count: 2 }],
2986 intent_type: "Refactoring: Model rename".to_string(),
2987 base_confidence: 0.7,
2988 confidence_boost_per_optional: 0.1,
2989 });
2990
2991 self.add_rule(InferenceRule {
2993 name: "add_timestamp_tracking".to_string(),
2994 conditions: vec![RuleCondition::FieldAddition {
2995 field_name_pattern: "created_at".to_string(),
2996 }],
2997 optional_conditions: vec![RuleCondition::FieldAddition {
2998 field_name_pattern: "updated_at".to_string(),
2999 }],
3000 intent_type: "Add timestamp tracking".to_string(),
3001 base_confidence: 0.8,
3002 confidence_boost_per_optional: 0.15,
3003 });
3004
3005 self.add_rule(InferenceRule {
3007 name: "cross_app_move".to_string(),
3008 conditions: vec![RuleCondition::ModelMove {
3009 app_pattern: ".*".to_string(),
3010 }],
3011 optional_conditions: vec![],
3012 intent_type: "Cross-app model organization".to_string(),
3013 base_confidence: 0.75,
3014 confidence_boost_per_optional: 0.0,
3015 });
3016
3017 self.add_rule(InferenceRule {
3019 name: "field_refactoring".to_string(),
3020 conditions: vec![RuleCondition::FieldRename {
3021 from_pattern: ".*".to_string(),
3022 to_pattern: ".*".to_string(),
3023 }],
3024 optional_conditions: vec![RuleCondition::MultipleFieldAdditions {
3025 model_pattern: ".*".to_string(),
3026 min_count: 2,
3027 }],
3028 intent_type: "Refactoring: Field rename".to_string(),
3029 base_confidence: 0.65,
3030 confidence_boost_per_optional: 0.1,
3031 });
3032
3033 self.add_rule(InferenceRule {
3035 name: "model_normalization".to_string(),
3036 conditions: vec![RuleCondition::MultipleFieldAdditions {
3037 model_pattern: ".*".to_string(),
3038 min_count: 3,
3039 }],
3040 optional_conditions: vec![],
3041 intent_type: "Schema normalization".to_string(),
3042 base_confidence: 0.6,
3043 confidence_boost_per_optional: 0.0,
3044 });
3045 }
3046
3047 fn matches_pattern(value: &str, pattern: &str) -> bool {
3054 if pattern == ".*" {
3056 return true;
3057 }
3058
3059 if value == pattern {
3061 return true;
3062 }
3063
3064 if let Ok(re) = Regex::new(pattern) {
3066 re.is_match(value)
3067 } else {
3068 false
3070 }
3071 }
3072
3073 pub fn rules(&self) -> &[InferenceRule] {
3075 &self.rules
3076 }
3077
3078 pub fn infer_intents(
3080 &self,
3081 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> {
3086 let mut intents = Vec::new();
3087
3088 for rule in &self.rules {
3089 let mut matches_required = true;
3090 let mut optional_matches = 0;
3091 let mut evidence = Vec::new();
3092
3093 for condition in &rule.conditions {
3095 match condition {
3096 RuleCondition::ModelRename {
3097 from_pattern,
3098 to_pattern,
3099 } => {
3100 if model_renames.is_empty() {
3101 matches_required = false;
3102 break;
3103 }
3104
3105 let mut matched = false;
3107 for (from_app, from_model, to_app, to_model) in model_renames {
3108 let from_name = format!("{}.{}", from_app, from_model);
3109 let to_name = format!("{}.{}", to_app, to_model);
3110
3111 if Self::matches_pattern(&from_name, from_pattern)
3112 && Self::matches_pattern(&to_name, to_pattern)
3113 {
3114 evidence.push(format!(
3115 "Model renamed: {} → {} (pattern: {} → {})",
3116 from_name, to_name, from_pattern, to_pattern
3117 ));
3118 matched = true;
3119 break;
3120 }
3121 }
3122
3123 if !matched {
3124 matches_required = false;
3125 break;
3126 }
3127 }
3128 RuleCondition::ModelMove { app_pattern } => {
3129 if model_moves.is_empty() {
3130 matches_required = false;
3131 break;
3132 }
3133
3134 let mut matched = false;
3136 for (from_app, from_model, to_app, to_model) in model_moves {
3137 if Self::matches_pattern(to_app, app_pattern) {
3138 evidence.push(format!(
3139 "Model moved: {}.{} → {}.{} (app pattern: {})",
3140 from_app, from_model, to_app, to_model, app_pattern
3141 ));
3142 matched = true;
3143 break;
3144 }
3145 }
3146
3147 if !matched {
3148 matches_required = false;
3149 break;
3150 }
3151 }
3152 RuleCondition::FieldAddition { field_name_pattern } => {
3153 let matching_fields: Vec<_> = field_additions
3154 .iter()
3155 .filter(|(_, _, field)| {
3156 Self::matches_pattern(field, field_name_pattern)
3157 })
3158 .collect();
3159
3160 if matching_fields.is_empty() {
3161 matches_required = false;
3162 break;
3163 }
3164 evidence.push(format!(
3165 "Field added: {}.{}.{} (pattern: {})",
3166 matching_fields[0].0,
3167 matching_fields[0].1,
3168 matching_fields[0].2,
3169 field_name_pattern
3170 ));
3171 }
3172 RuleCondition::FieldRename {
3173 from_pattern,
3174 to_pattern,
3175 } => {
3176 if field_renames.is_empty() {
3177 matches_required = false;
3178 break;
3179 }
3180
3181 let mut matched = false;
3183 for (app, model, from_field, to_field) in field_renames {
3184 if Self::matches_pattern(from_field, from_pattern)
3185 && Self::matches_pattern(to_field, to_pattern)
3186 {
3187 evidence.push(format!(
3188 "Field renamed: {}.{}.{} → {} (pattern: {} → {})",
3189 app, model, from_field, to_field, from_pattern, to_pattern
3190 ));
3191 matched = true;
3192 break;
3193 }
3194 }
3195
3196 if !matched {
3197 matches_required = false;
3198 break;
3199 }
3200 }
3201 RuleCondition::MultipleModelRenames { min_count } => {
3202 if model_renames.len() < *min_count {
3203 matches_required = false;
3204 break;
3205 }
3206 evidence.push(format!("Multiple model renames: {}", model_renames.len()));
3207 }
3208 RuleCondition::MultipleFieldAdditions {
3209 model_pattern,
3210 min_count,
3211 } => {
3212 let count = field_additions
3213 .iter()
3214 .filter(|(_, model, _)| Self::matches_pattern(model, model_pattern))
3215 .count();
3216
3217 if count < *min_count {
3218 matches_required = false;
3219 break;
3220 }
3221 evidence.push(format!(
3222 "Multiple field additions: {} (pattern: {}, min: {})",
3223 count, model_pattern, min_count
3224 ));
3225 }
3226 }
3227 }
3228
3229 if !matches_required {
3230 continue;
3231 }
3232
3233 for condition in &rule.optional_conditions {
3235 match condition {
3236 RuleCondition::FieldAddition { field_name_pattern } => {
3237 if field_additions
3238 .iter()
3239 .any(|(_, _, field)| field.contains(field_name_pattern.as_str()))
3240 {
3241 optional_matches += 1;
3242 evidence.push(format!("Optional field added: {}", field_name_pattern));
3243 }
3244 }
3245 RuleCondition::MultipleModelRenames { min_count }
3246 if model_renames.len() >= *min_count =>
3247 {
3248 optional_matches += 1;
3249 evidence.push(format!("Multiple renames: {}", model_renames.len()));
3250 }
3251 _ => {}
3252 }
3253 }
3254
3255 let confidence = rule.base_confidence
3257 + (optional_matches as f64 * rule.confidence_boost_per_optional);
3258 let confidence = confidence.min(1.0);
3259
3260 intents.push(InferredIntent {
3261 intent_type: rule.intent_type.clone(),
3262 confidence,
3263 description: format!("Detected: {}", rule.name),
3264 evidence,
3265 related_operations: Vec::new(),
3266 });
3267 }
3268
3269 intents.sort_by(|a, b| {
3271 b.confidence
3272 .partial_cmp(&a.confidence)
3273 .unwrap_or(std::cmp::Ordering::Equal)
3274 });
3275
3276 intents
3277 }
3278
3279 pub fn infer_from_detected_changes(&self, changes: &DetectedChanges) -> Vec<InferredIntent> {
3289 let model_renames: Vec<(String, String, String, String)> = changes
3291 .renamed_models
3292 .iter()
3293 .map(|(app, old_name, new_name)| {
3294 (app.clone(), old_name.clone(), app.clone(), new_name.clone())
3295 })
3296 .collect();
3297
3298 let model_moves: Vec<(String, String, String, String)> = changes
3300 .moved_models
3301 .iter()
3302 .map(|(from_app, from_model, to_app, to_model, _, _, _)| {
3303 (
3304 from_app.clone(),
3305 from_model.clone(),
3306 to_app.clone(),
3307 to_model.clone(),
3308 )
3309 })
3310 .collect();
3311
3312 let field_additions: Vec<(String, String, String)> = changes
3314 .added_fields
3315 .iter()
3316 .map(|(app, model, field)| (app.clone(), model.clone(), field.clone()))
3317 .collect();
3318
3319 let field_renames: Vec<(String, String, String, String)> = changes
3321 .renamed_fields
3322 .iter()
3323 .map(|(app, model, old_name, new_name)| {
3324 (
3325 app.clone(),
3326 model.clone(),
3327 old_name.clone(),
3328 new_name.clone(),
3329 )
3330 })
3331 .collect();
3332
3333 let mut intents = self.infer_intents(
3335 &model_renames,
3336 &model_moves,
3337 &field_additions,
3338 &field_renames,
3339 );
3340
3341 for intent in &mut intents {
3343 for evidence_str in &intent.evidence {
3346 if evidence_str.starts_with("Model renamed:") {
3348 for (app, old_name, new_name) in &changes.renamed_models {
3349 intent.related_operations.push(OperationRef::RenamedModel {
3350 app_label: app.clone(),
3351 old_name: old_name.clone(),
3352 new_name: new_name.clone(),
3353 });
3354 }
3355 }
3356 else if evidence_str.starts_with("Model moved:") {
3358 for (from_app, _from_model, to_app, to_model, _, _, _) in &changes.moved_models
3359 {
3360 intent.related_operations.push(OperationRef::MovedModel {
3361 from_app: from_app.clone(),
3362 to_app: to_app.clone(),
3363 model_name: to_model.clone(),
3364 });
3365 }
3366 }
3367 else if evidence_str.starts_with("Field added:") {
3369 for (app, model, field) in &changes.added_fields {
3370 intent.related_operations.push(OperationRef::AddedField {
3371 app_label: app.clone(),
3372 model_name: model.clone(),
3373 field_name: field.clone(),
3374 });
3375 }
3376 }
3377 else if evidence_str.starts_with("Field renamed:") {
3379 for (app, model, old_name, new_name) in &changes.renamed_fields {
3380 intent.related_operations.push(OperationRef::RenamedField {
3381 app_label: app.clone(),
3382 model_name: model.clone(),
3383 old_name: old_name.clone(),
3384 new_name: new_name.clone(),
3385 });
3386 }
3387 }
3388 else if evidence_str.starts_with("Multiple model renames:") {
3390 for (app, old_name, new_name) in &changes.renamed_models {
3391 intent.related_operations.push(OperationRef::RenamedModel {
3392 app_label: app.clone(),
3393 old_name: old_name.clone(),
3394 new_name: new_name.clone(),
3395 });
3396 }
3397 }
3398 else if evidence_str.starts_with("Multiple field additions:")
3400 || evidence_str.starts_with("Optional field added:")
3401 {
3402 for (app, model, field) in &changes.added_fields {
3403 intent.related_operations.push(OperationRef::AddedField {
3404 app_label: app.clone(),
3405 model_name: model.clone(),
3406 field_name: field.clone(),
3407 });
3408 }
3409 }
3410 }
3411
3412 intent
3414 .related_operations
3415 .sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
3416 intent.related_operations.dedup();
3417 }
3418
3419 intents
3420 }
3421
3422 pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
3431 self.change_tracker
3432 .record_model_rename(app_label, old_name, new_name);
3433 }
3434
3435 pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
3442 self.change_tracker
3443 .record_model_move(from_app, to_app, model_name);
3444 }
3445
3446 pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
3453 self.change_tracker
3454 .record_field_addition(app_label, model_name, field_name);
3455 }
3456
3457 pub fn record_field_rename(
3465 &mut self,
3466 app_label: &str,
3467 model_name: &str,
3468 old_name: &str,
3469 new_name: &str,
3470 ) {
3471 self.change_tracker
3472 .record_field_rename(app_label, model_name, old_name, new_name);
3473 }
3474
3475 pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
3483 self.change_tracker.get_frequent_patterns(min_frequency)
3484 }
3485
3486 pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
3491 self.change_tracker.get_recent_changes(duration)
3492 }
3493
3494 pub fn analyze_cooccurrence(
3502 &self,
3503 window: std::time::Duration,
3504 ) -> HashMap<(String, String), usize> {
3505 self.change_tracker.analyze_cooccurrence(window)
3506 }
3507}
3508
3509pub struct MigrationPrompt {
3522 auto_accept_threshold: f64,
3525
3526 theme: dialoguer::theme::ColorfulTheme,
3528}
3529
3530impl std::fmt::Debug for MigrationPrompt {
3531 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3532 f.debug_struct("MigrationPrompt")
3533 .field("auto_accept_threshold", &self.auto_accept_threshold)
3534 .field("theme", &"ColorfulTheme")
3535 .finish()
3536 }
3537}
3538
3539impl MigrationPrompt {
3540 pub fn new() -> Self {
3542 Self {
3543 auto_accept_threshold: 0.85,
3544 theme: dialoguer::theme::ColorfulTheme::default(),
3545 }
3546 }
3547
3548 pub fn with_threshold(threshold: f64) -> Self {
3550 Self {
3551 auto_accept_threshold: threshold,
3552 theme: dialoguer::theme::ColorfulTheme::default(),
3553 }
3554 }
3555
3556 pub fn auto_accept_threshold(&self) -> f64 {
3558 self.auto_accept_threshold
3559 }
3560
3561 pub fn confirm_intent(
3565 &self,
3566 intent: &InferredIntent,
3567 ) -> Result<bool, Box<dyn std::error::Error>> {
3568 if intent.confidence >= self.auto_accept_threshold {
3570 println!(
3571 "✓ Auto-accepting (confidence: {:.1}%): {}",
3572 intent.confidence * 100.0,
3573 intent.intent_type
3574 );
3575 return Ok(true);
3576 }
3577
3578 let message = format!(
3580 "Detected: {} (confidence: {:.1}%)\nDetails: {}\n\nAccept this change?",
3581 intent.intent_type,
3582 intent.confidence * 100.0,
3583 intent.description
3584 );
3585
3586 if !intent.evidence.is_empty() {
3588 println!("\nEvidence:");
3589 for evidence in &intent.evidence {
3590 println!(" • {}", evidence);
3591 }
3592 }
3593
3594 dialoguer::Confirm::with_theme(&self.theme)
3596 .with_prompt(message)
3597 .default(true)
3598 .interact()
3599 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3600 }
3601
3602 pub fn select_intent(
3606 &self,
3607 alternatives: &[InferredIntent],
3608 prompt: &str,
3609 ) -> Result<Option<usize>, Box<dyn std::error::Error>> {
3610 if alternatives.is_empty() {
3611 return Ok(None);
3612 }
3613
3614 if alternatives.len() == 1 {
3616 let confirmed = self.confirm_intent(&alternatives[0])?;
3617 return Ok(if confirmed { Some(0) } else { None });
3618 }
3619
3620 let items: Vec<String> = alternatives
3622 .iter()
3623 .map(|intent| {
3624 format!(
3625 "{} (confidence: {:.1}%) - {}",
3626 intent.intent_type,
3627 intent.confidence * 100.0,
3628 intent.description
3629 )
3630 })
3631 .collect();
3632
3633 println!("\n{}", prompt);
3635 println!("Multiple possibilities detected:\n");
3636
3637 let mut items_with_none = items.clone();
3639 items_with_none.push("None of the above / Skip".to_string());
3640
3641 let selection = dialoguer::Select::with_theme(&self.theme)
3643 .items(&items_with_none)
3644 .default(0)
3645 .interact()
3646 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
3647
3648 if selection >= items.len() {
3650 Ok(None)
3651 } else {
3652 Ok(Some(selection))
3653 }
3654 }
3655
3656 pub fn multi_select_intents(
3660 &self,
3661 alternatives: &[InferredIntent],
3662 prompt: &str,
3663 ) -> Result<Vec<usize>, Box<dyn std::error::Error>> {
3664 if alternatives.is_empty() {
3665 return Ok(Vec::new());
3666 }
3667
3668 let items: Vec<String> = alternatives
3670 .iter()
3671 .map(|intent| {
3672 format!(
3673 "{} (confidence: {:.1}%) - {}",
3674 intent.intent_type,
3675 intent.confidence * 100.0,
3676 intent.description
3677 )
3678 })
3679 .collect();
3680
3681 println!("\n{}", prompt);
3683 println!("Select all that apply:\n");
3684
3685 let selections = dialoguer::MultiSelect::with_theme(&self.theme)
3687 .items(&items)
3688 .interact()
3689 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
3690
3691 Ok(selections)
3692 }
3693
3694 pub fn confirm_model_rename(
3696 &self,
3697 from_app: &str,
3698 from_model: &str,
3699 to_app: &str,
3700 to_model: &str,
3701 confidence: f64,
3702 ) -> Result<bool, Box<dyn std::error::Error>> {
3703 if confidence >= self.auto_accept_threshold {
3705 println!(
3706 "✓ Auto-accepting model rename (confidence: {:.1}%): {}.{} → {}.{}",
3707 confidence * 100.0,
3708 from_app,
3709 from_model,
3710 to_app,
3711 to_model
3712 );
3713 return Ok(true);
3714 }
3715
3716 let message = format!(
3717 "Rename model from {}.{} to {}.{}?\n(confidence: {:.1}%)",
3718 from_app,
3719 from_model,
3720 to_app,
3721 to_model,
3722 confidence * 100.0
3723 );
3724
3725 dialoguer::Confirm::with_theme(&self.theme)
3726 .with_prompt(message)
3727 .default(true)
3728 .interact()
3729 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3730 }
3731
3732 pub fn confirm_field_rename(
3734 &self,
3735 model: &str,
3736 from_field: &str,
3737 to_field: &str,
3738 confidence: f64,
3739 ) -> Result<bool, Box<dyn std::error::Error>> {
3740 if confidence >= self.auto_accept_threshold {
3742 println!(
3743 "✓ Auto-accepting field rename (confidence: {:.1}%): {}.{} → {}.{}",
3744 confidence * 100.0,
3745 model,
3746 from_field,
3747 model,
3748 to_field
3749 );
3750 return Ok(true);
3751 }
3752
3753 let message = format!(
3754 "Rename field in model {}:\n {} → {}?\n(confidence: {:.1}%)",
3755 model,
3756 from_field,
3757 to_field,
3758 confidence * 100.0
3759 );
3760
3761 dialoguer::Confirm::with_theme(&self.theme)
3762 .with_prompt(message)
3763 .default(true)
3764 .interact()
3765 .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3766 }
3767
3768 pub fn with_progress<F, T>(
3770 &self,
3771 message: &str,
3772 total: u64,
3773 operation: F,
3774 ) -> Result<T, Box<dyn std::error::Error>>
3775 where
3776 F: FnOnce(&indicatif::ProgressBar) -> Result<T, Box<dyn std::error::Error>>,
3777 {
3778 let pb = indicatif::ProgressBar::new(total);
3779 pb.set_style(
3780 indicatif::ProgressStyle::default_bar()
3781 .template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
3782 .expect("Failed to create progress bar template")
3783 .progress_chars("#>-"),
3784 );
3785 pb.set_message(message.to_string());
3786
3787 let result = operation(&pb)?;
3788
3789 pb.finish_with_message("Done");
3790 Ok(result)
3791 }
3792}
3793
3794impl Default for MigrationPrompt {
3795 fn default() -> Self {
3796 Self::new()
3797 }
3798}
3799
3800pub trait InteractiveAutodetector {
3802 fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>>;
3804
3805 fn apply_intents_interactive(
3807 &self,
3808 intents: Vec<InferredIntent>,
3809 changes: &mut DetectedChanges,
3810 ) -> Result<(), Box<dyn std::error::Error>>;
3811}
3812
3813impl InteractiveAutodetector for MigrationAutodetector {
3814 fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>> {
3815 let prompt = MigrationPrompt::new();
3816 let mut changes = self.detect_changes();
3817
3818 let mut engine = InferenceEngine::new();
3820 engine.add_default_rules();
3821
3822 let intents = engine.infer_from_detected_changes(&changes);
3824
3825 let ambiguous_intents: Vec<_> = intents
3827 .into_iter()
3828 .filter(|intent| intent.confidence < prompt.auto_accept_threshold)
3829 .collect();
3830
3831 if !ambiguous_intents.is_empty() {
3833 println!(
3834 "\n⚠️ Found {} ambiguous change(s) requiring confirmation:",
3835 ambiguous_intents.len()
3836 );
3837
3838 for intent in &ambiguous_intents {
3839 let confirmed = prompt.confirm_intent(intent)?;
3840
3841 if !confirmed {
3842 println!("✗ Skipped: {}", intent.description);
3843 if !intent.related_operations.is_empty() {
3846 changes.remove_operations(&intent.related_operations);
3847 println!(
3848 " → Removed {} related operation(s) from migration",
3849 intent.related_operations.len()
3850 );
3851 }
3852 }
3853 }
3854 }
3855
3856 self.detect_model_dependencies(&mut changes);
3858
3859 if let Err(cycle) = changes.check_circular_dependencies() {
3861 println!("\n⚠️ Warning: Circular dependency detected: {:?}", cycle);
3862
3863 let should_continue = dialoguer::Confirm::new()
3864 .with_prompt("Continue anyway? (may require manual intervention)")
3865 .default(false)
3866 .interact()?;
3867
3868 if !should_continue {
3869 return Err("Aborted due to circular dependency".into());
3870 }
3871 }
3872
3873 Ok(changes)
3874 }
3875
3876 fn apply_intents_interactive(
3877 &self,
3878 intents: Vec<InferredIntent>,
3879 _changes: &mut DetectedChanges,
3880 ) -> Result<(), Box<dyn std::error::Error>> {
3881 let prompt = MigrationPrompt::new();
3882
3883 let mut high_confidence = Vec::new();
3885 let mut medium_confidence = Vec::new();
3886 let mut low_confidence = Vec::new();
3887
3888 for intent in intents {
3889 if intent.confidence >= 0.85 {
3890 high_confidence.push(intent);
3891 } else if intent.confidence >= 0.65 {
3892 medium_confidence.push(intent);
3893 } else {
3894 low_confidence.push(intent);
3895 }
3896 }
3897
3898 println!(
3900 "\n✓ Auto-applying {} high-confidence change(s):",
3901 high_confidence.len()
3902 );
3903 for intent in &high_confidence {
3904 println!(
3905 " • {} (confidence: {:.1}%)",
3906 intent.description,
3907 intent.confidence * 100.0
3908 );
3909 }
3910
3911 if !medium_confidence.is_empty() {
3913 println!(
3914 "\n⚠️ Review {} medium-confidence change(s):",
3915 medium_confidence.len()
3916 );
3917
3918 for intent in &medium_confidence {
3919 let confirmed = prompt.confirm_intent(intent)?;
3920 if confirmed {
3921 println!(" ✓ Accepted: {}", intent.description);
3922 } else {
3923 println!(" ✗ Rejected: {}", intent.description);
3924 }
3925 }
3926 }
3927
3928 if !low_confidence.is_empty() {
3930 let selections = prompt.multi_select_intents(
3931 &low_confidence,
3932 "⚠️ Select low-confidence changes to apply:",
3933 )?;
3934
3935 for idx in selections {
3936 println!(" ✓ Accepted: {}", low_confidence[idx].description);
3937 }
3938 }
3939
3940 Ok(())
3941 }
3942}
3943
3944impl MigrationAutodetector {
3945 pub fn new(from_state: ProjectState, to_state: ProjectState) -> Self {
3958 Self {
3959 from_state,
3960 to_state,
3961 similarity_config: SimilarityConfig::default(),
3962 }
3963 }
3964
3965 pub fn with_config(
3979 from_state: ProjectState,
3980 to_state: ProjectState,
3981 similarity_config: SimilarityConfig,
3982 ) -> Self {
3983 Self {
3984 from_state,
3985 to_state,
3986 similarity_config,
3987 }
3988 }
3989
3990 pub fn detect_changes(&self) -> DetectedChanges {
4012 self.detect_changes_internal(false)
4013 .expect("non-strict autodetection must not fail")
4014 }
4015
4016 pub fn try_detect_changes(&self) -> super::Result<DetectedChanges> {
4023 self.detect_changes_internal(true)
4024 }
4025
4026 fn detect_changes_internal(
4027 &self,
4028 strict_rename_ambiguity: bool,
4029 ) -> super::Result<DetectedChanges> {
4030 let mut changes = DetectedChanges::default();
4031
4032 self.detect_created_models(&mut changes);
4034 self.detect_deleted_models(&mut changes);
4035 self.detect_renamed_models(&mut changes);
4036
4037 self.detect_added_fields(&mut changes);
4039 self.detect_removed_fields(&mut changes);
4040 self.detect_altered_fields(&mut changes);
4041 self.detect_renamed_fields(&mut changes, strict_rename_ambiguity)?;
4042
4043 self.detect_added_indexes(&mut changes);
4045 self.detect_removed_indexes(&mut changes);
4046 self.detect_added_constraints(&mut changes);
4047 self.detect_removed_constraints(&mut changes);
4048 self.detect_composite_pk_changes(&mut changes);
4049 self.detect_auto_increment_resets(&mut changes);
4050
4051 self.detect_created_many_to_many(&mut changes);
4053
4054 self.detect_model_dependencies(&mut changes);
4056
4057 changes.created_models.sort();
4060 changes.deleted_models.sort();
4061 changes.added_fields.sort();
4062 changes.removed_fields.sort();
4063 changes.altered_fields.sort();
4064 changes.renamed_models.sort();
4065 changes.renamed_fields.sort();
4066
4067 changes
4069 .added_indexes
4070 .sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
4071 changes.removed_indexes.sort();
4072 changes
4073 .added_constraints
4074 .sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
4075 changes.removed_constraints.sort();
4076 changes
4077 .added_composite_primary_keys
4078 .sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
4079 changes.removed_composite_primary_keys.sort();
4080 changes.auto_increment_resets.sort();
4081 changes
4082 .created_many_to_many
4083 .sort_by(|a, b| (&a.0, &a.1, &a.2).cmp(&(&b.0, &b.1, &b.2)));
4084
4085 Ok(changes)
4086 }
4087
4088 fn detect_created_models(&self, changes: &mut DetectedChanges) {
4092 for ((app_label, model_name), to_model) in &self.to_state.models {
4093 if self
4095 .from_state
4096 .get_model_by_table_name(app_label, &to_model.table_name)
4097 .is_none()
4098 {
4099 changes
4100 .created_models
4101 .push((app_label.clone(), model_name.clone()));
4102 }
4103 }
4104 }
4105
4106 fn detect_deleted_models(&self, changes: &mut DetectedChanges) {
4110 for ((app_label, model_name), from_model) in &self.from_state.models {
4111 if self
4113 .to_state
4114 .get_model_by_table_name(app_label, &from_model.table_name)
4115 .is_none()
4116 {
4117 changes
4118 .deleted_models
4119 .push((app_label.clone(), model_name.clone()));
4120 }
4121 }
4122 }
4123
4124 fn detect_added_fields(&self, changes: &mut DetectedChanges) {
4128 for ((app_label, model_name), to_model) in &self.to_state.models {
4129 if let Some(from_model) =
4133 self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4134 {
4135 for field_name in to_model.fields.keys() {
4136 if !from_model.fields.contains_key(field_name) {
4137 changes.added_fields.push((
4138 app_label.clone(),
4139 model_name.clone(),
4140 field_name.clone(),
4141 ));
4142 }
4143 }
4144 }
4145 }
4146 }
4147
4148 fn detect_removed_fields(&self, changes: &mut DetectedChanges) {
4152 for ((app_label, model_name), from_model) in &self.from_state.models {
4153 if let Some(to_model) =
4157 self.matching_to_model_for_from_model(app_label, model_name, from_model, changes)
4158 {
4159 for field_name in from_model.fields.keys() {
4160 if !to_model.fields.contains_key(field_name) {
4161 changes.removed_fields.push((
4162 app_label.clone(),
4163 model_name.clone(),
4164 field_name.clone(),
4165 ));
4166 }
4167 }
4168 }
4169 }
4170 }
4171
4172 fn detect_altered_fields(&self, changes: &mut DetectedChanges) {
4176 for ((app_label, model_name), to_model) in &self.to_state.models {
4177 if let Some(from_model) =
4181 self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4182 {
4183 for (field_name, to_field) in &to_model.fields {
4184 if let Some(from_field) = from_model.fields.get(field_name) {
4185 if self.has_field_changed_in_model_context(
4187 field_name, from_model, to_model, from_field, to_field,
4188 ) {
4189 changes.altered_fields.push((
4190 app_label.clone(),
4191 model_name.clone(),
4192 field_name.clone(),
4193 ));
4194 }
4195 }
4196 }
4197 }
4198 }
4199 }
4200
4201 fn matching_from_model_for_to_model<'a>(
4202 &'a self,
4203 app_label: &str,
4204 to_model_name: &str,
4205 to_model: &ModelState,
4206 changes: &DetectedChanges,
4207 ) -> Option<&'a ModelState> {
4208 self.from_state
4209 .get_model_by_table_name(app_label, &to_model.table_name)
4210 .or_else(|| {
4211 changes
4212 .renamed_models
4213 .iter()
4214 .find(|(app, _old_name, new_name)| {
4215 app == app_label && new_name == to_model_name
4216 })
4217 .and_then(|(_app, old_name, _new_name)| {
4218 self.from_state.get_model(app_label, old_name)
4219 })
4220 })
4221 .or_else(|| {
4222 changes
4223 .moved_models
4224 .iter()
4225 .find(|(_from_app, _from_model, to_app, to_model, _, _, _)| {
4226 to_app == app_label && to_model == to_model_name
4227 })
4228 .and_then(|(from_app, from_model, _to_app, _to_model, _, _, _)| {
4229 self.from_state.get_model(from_app, from_model)
4230 })
4231 })
4232 }
4233
4234 fn matching_to_model_for_from_model<'a>(
4235 &'a self,
4236 app_label: &str,
4237 from_model_name: &str,
4238 from_model: &ModelState,
4239 changes: &DetectedChanges,
4240 ) -> Option<&'a ModelState> {
4241 self.to_state
4242 .get_model_by_table_name(app_label, &from_model.table_name)
4243 .or_else(|| {
4244 changes
4245 .renamed_models
4246 .iter()
4247 .find(|(app, old_name, _new_name)| {
4248 app == app_label && old_name == from_model_name
4249 })
4250 .and_then(|(_app, _old_name, new_name)| {
4251 self.to_state.get_model(app_label, new_name)
4252 })
4253 })
4254 .or_else(|| {
4255 changes
4256 .moved_models
4257 .iter()
4258 .find(|(from_app, from_model, _to_app, _to_model, _, _, _)| {
4259 from_app == app_label && from_model == from_model_name
4260 })
4261 .and_then(|(_from_app, _from_model, to_app, to_model, _, _, _)| {
4262 self.to_state.get_model(to_app, to_model)
4263 })
4264 })
4265 }
4266
4267 fn has_field_changed_in_model_context(
4268 &self,
4269 field_name: &str,
4270 from_model: &ModelState,
4271 to_model: &ModelState,
4272 from_field: &FieldState,
4273 to_field: &FieldState,
4274 ) -> bool {
4275 let from_unique = Self::single_field_unique_column_already_present(from_model, field_name);
4276 let to_unique = Self::single_field_unique_column_already_present(to_model, field_name);
4277 self.has_field_changed_with_unique(
4278 field_name,
4279 from_field,
4280 to_field,
4281 Some(from_unique),
4282 Some(to_unique),
4283 )
4284 }
4285
4286 fn has_field_changed_with_unique(
4287 &self,
4288 field_name: &str,
4289 from_field: &FieldState,
4290 to_field: &FieldState,
4291 from_unique: Option<bool>,
4292 to_unique: Option<bool>,
4293 ) -> bool {
4294 let mut from_def = super::ColumnDefinition::from_field_state(field_name, from_field);
4297 let mut to_def = super::ColumnDefinition::from_field_state(field_name, to_field);
4298 from_def.auto_increment =
4299 Self::canonical_auto_increment(&from_def.type_definition, from_def.auto_increment);
4300 to_def.auto_increment =
4301 Self::canonical_auto_increment(&to_def.type_definition, to_def.auto_increment);
4302 if let Some(unique) = from_unique {
4303 from_def.unique = unique;
4304 }
4305 if let Some(unique) = to_unique {
4306 to_def.unique = unique;
4307 }
4308 from_def.type_definition != to_def.type_definition
4309 || from_def.not_null != to_def.not_null
4310 || from_def.primary_key != to_def.primary_key
4311 || from_def.auto_increment != to_def.auto_increment
4312 || from_def.unique != to_def.unique
4313 || from_def.default != to_def.default
4314 }
4315
4316 fn canonical_auto_increment(field_type: &super::FieldType, auto_increment: bool) -> bool {
4317 auto_increment
4318 && matches!(
4319 field_type,
4320 super::FieldType::BigInteger
4321 | super::FieldType::Integer
4322 | super::FieldType::SmallInteger
4323 | super::FieldType::TinyInt
4324 | super::FieldType::MediumInt
4325 )
4326 }
4327
4328 fn detect_renamed_models(&self, changes: &mut DetectedChanges) {
4374 let deleted: Vec<_> = self
4376 .from_state
4377 .models
4378 .keys()
4379 .filter(|k| !self.to_state.models.contains_key(k))
4380 .collect();
4381
4382 let created: Vec<_> = self
4383 .to_state
4384 .models
4385 .keys()
4386 .filter(|k| !self.from_state.models.contains_key(k))
4387 .collect();
4388
4389 let matches = self.find_optimal_model_matches(&deleted, &created);
4392
4393 for (deleted_key, created_key, _similarity) in matches {
4394 if deleted_key.0 == created_key.0 {
4396 let app_label = deleted_key.0.clone();
4397 let old_model_name = deleted_key.1.clone();
4398 let new_model_name = created_key.1.clone();
4399 let old_table = self
4402 .from_state
4403 .get_model(&app_label, &old_model_name)
4404 .map(|m| m.table_name.as_str());
4405 let new_table = self
4406 .to_state
4407 .get_model(&app_label, &new_model_name)
4408 .map(|m| m.table_name.as_str());
4409
4410 if old_table != new_table {
4411 changes.renamed_models.push((
4412 app_label.clone(),
4413 old_model_name.clone(),
4414 new_model_name.clone(),
4415 ));
4416 changes
4417 .created_models
4418 .retain(|(app, model)| !(app == &app_label && model == &new_model_name));
4419 changes
4420 .deleted_models
4421 .retain(|(app, model)| !(app == &app_label && model == &old_model_name));
4422 }
4423 } else {
4424 let from_app = deleted_key.0.clone();
4425 let to_app = created_key.0.clone();
4426 let model_name = created_key.1.clone();
4427 let deleted_model_name = deleted_key.1.clone();
4428 let old_table = self
4431 .from_state
4432 .get_model(&from_app, &deleted_model_name)
4433 .map(|model| model.table_name.clone())
4434 .unwrap_or_else(|| {
4435 format!("{}_{}", from_app, deleted_model_name.to_lowercase())
4436 });
4437 let new_table = self
4438 .to_state
4439 .get_model(&to_app, &model_name)
4440 .map(|model| model.table_name.clone())
4441 .unwrap_or_else(|| format!("{}_{}", to_app, model_name.to_lowercase()));
4442 let rename_table = old_table != new_table;
4443
4444 changes.moved_models.push((
4445 from_app.clone(),
4446 deleted_model_name.clone(),
4447 to_app.clone(),
4448 model_name.clone(),
4449 rename_table,
4450 if rename_table { Some(old_table) } else { None },
4451 if rename_table { Some(new_table) } else { None },
4452 ));
4453 changes
4454 .created_models
4455 .retain(|(app, model)| !(app == &to_app && model == &model_name));
4456 changes
4457 .deleted_models
4458 .retain(|(app, model)| !(app == &from_app && model == &deleted_model_name));
4459 }
4460 }
4461 }
4462
4463 fn detect_renamed_fields(
4504 &self,
4505 changes: &mut DetectedChanges,
4506 strict_rename_ambiguity: bool,
4507 ) -> super::Result<()> {
4508 let mut confirmed_renames = Vec::new();
4509 let mut ambiguous_groups = Vec::new();
4510
4511 for ((app_label, model_name), to_model) in &self.to_state.models {
4512 let Some(from_model) =
4513 self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4514 else {
4515 continue;
4516 };
4517
4518 let removed_fields: Vec<_> = from_model
4519 .fields
4520 .iter()
4521 .filter(|(name, _)| !to_model.fields.contains_key(*name))
4522 .collect();
4523 let added_fields: Vec<_> = to_model
4524 .fields
4525 .iter()
4526 .filter(|(name, _)| !from_model.fields.contains_key(*name))
4527 .collect();
4528
4529 if removed_fields.is_empty() || added_fields.is_empty() {
4530 continue;
4531 }
4532
4533 let mut old_to_new: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4534 let mut new_to_old: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4535
4536 for (removed_name, removed_field) in &removed_fields {
4537 for (added_name, added_field) in &added_fields {
4538 if Self::field_definitions_match_for_rename(
4539 removed_name,
4540 removed_field,
4541 added_name,
4542 added_field,
4543 ) {
4544 old_to_new
4545 .entry((*removed_name).clone())
4546 .or_default()
4547 .insert((*added_name).clone());
4548 new_to_old
4549 .entry((*added_name).clone())
4550 .or_default()
4551 .insert((*removed_name).clone());
4552 }
4553 }
4554 }
4555
4556 if old_to_new.is_empty() {
4557 continue;
4558 }
4559
4560 for (old_name, new_names) in &old_to_new {
4561 if new_names.len() == 1 {
4562 let new_name = new_names.iter().next().expect("one candidate");
4563 if new_to_old
4564 .get(new_name)
4565 .is_some_and(|old_names| old_names.len() == 1)
4566 {
4567 confirmed_renames.push((
4568 app_label.clone(),
4569 model_name.clone(),
4570 from_model.name.clone(),
4571 to_model.table_name.clone(),
4572 old_name.clone(),
4573 new_name.clone(),
4574 ));
4575 continue;
4576 }
4577 }
4578
4579 ambiguous_groups.push(format!(
4580 "{}.{} (table {}): old [{}] -> new [{}]",
4581 app_label,
4582 model_name,
4583 to_model.table_name,
4584 old_name,
4585 new_names.iter().cloned().collect::<Vec<_>>().join(", ")
4586 ));
4587 }
4588
4589 for (new_name, old_names) in &new_to_old {
4590 if old_names.len() > 1 {
4591 ambiguous_groups.push(format!(
4592 "{}.{} (table {}): old [{}] -> new [{}]",
4593 app_label,
4594 model_name,
4595 to_model.table_name,
4596 old_names.iter().cloned().collect::<Vec<_>>().join(", "),
4597 new_name
4598 ));
4599 }
4600 }
4601 }
4602
4603 ambiguous_groups.sort();
4604 ambiguous_groups.dedup();
4605 if strict_rename_ambiguity && !ambiguous_groups.is_empty() {
4606 return Err(super::MigrationError::InvalidMigration(format!(
4607 "Ambiguous field rename candidates detected. \
4608 Reinhardt will not emit destructive AddColumn + DropColumn operations for \
4609 rename-like changes. Split the change or make the rename intent explicit. \
4610 Candidates: {}",
4611 ambiguous_groups.join("; ")
4612 )));
4613 }
4614
4615 for (app_label, model_name, from_model_name, table_name, old_name, new_name) in
4616 confirmed_renames
4617 {
4618 changes.renamed_fields.push((
4619 app_label.clone(),
4620 model_name.clone(),
4621 old_name.clone(),
4622 new_name.clone(),
4623 ));
4624 changes.added_fields.retain(|(app, model, field)| {
4625 !(app == &app_label && model == &model_name && field == &new_name)
4626 });
4627 changes.removed_fields.retain(|(app, model, field)| {
4628 !(app == &app_label
4629 && field == &old_name
4630 && (model == &from_model_name
4631 || self
4632 .from_state
4633 .get_model(app, model)
4634 .is_some_and(|from_model| from_model.table_name == table_name)))
4635 });
4636 changes.altered_fields.retain(|(app, model, field)| {
4637 !(app == &app_label && model == &model_name && field == &new_name)
4638 });
4639 }
4640
4641 Ok(())
4642 }
4643
4644 fn field_definitions_match_for_rename(
4645 from_name: &str,
4646 from_field: &FieldState,
4647 to_name: &str,
4648 to_field: &FieldState,
4649 ) -> bool {
4650 if from_field.field_type != to_field.field_type
4651 || from_field.nullable != to_field.nullable
4652 || from_field.foreign_key != to_field.foreign_key
4653 {
4654 return false;
4655 }
4656
4657 let mut from_def = super::ColumnDefinition::from_field_state(from_name, from_field);
4658 let mut to_def = super::ColumnDefinition::from_field_state(to_name, to_field);
4659 from_def.name = "__renamed_field__".to_string();
4660 to_def.name = "__renamed_field__".to_string();
4661 from_def == to_def
4662 }
4663
4664 fn calculate_model_similarity(&self, from_model: &ModelState, to_model: &ModelState) -> f64 {
4699 if from_model.fields.is_empty() && to_model.fields.is_empty() {
4700 return 1.0;
4701 }
4702
4703 if from_model.fields.is_empty() || to_model.fields.is_empty() {
4704 return 0.0;
4705 }
4706
4707 let mut total_similarity = 0.0;
4708 let total_fields = from_model.fields.len().max(to_model.fields.len());
4709
4710 let mut matched_to_fields = std::collections::HashSet::new();
4712
4713 for (from_field_name, from_field) in &from_model.fields {
4714 let mut best_match_score = 0.0;
4715 let mut best_match_name = None;
4716
4717 for (to_field_name, to_field) in &to_model.fields {
4719 if matched_to_fields.contains(to_field_name) {
4720 continue;
4721 }
4722
4723 let similarity = self.calculate_field_similarity(
4724 from_field_name,
4725 to_field_name,
4726 from_field,
4727 to_field,
4728 );
4729
4730 if similarity > best_match_score {
4731 best_match_score = similarity;
4732 best_match_name = Some(to_field_name.clone());
4733 }
4734 }
4735
4736 if let Some(matched_name) = best_match_name {
4737 matched_to_fields.insert(matched_name);
4738 total_similarity += best_match_score;
4739 }
4740 }
4741
4742 total_similarity / total_fields as f64
4743 }
4744
4745 fn calculate_field_similarity(
4777 &self,
4778 from_field_name: &str,
4779 to_field_name: &str,
4780 from_field: &FieldState,
4781 to_field: &FieldState,
4782 ) -> f64 {
4783 if from_field.field_type != to_field.field_type {
4785 return 0.0;
4786 }
4787
4788 let jaro_winkler_sim = jaro_winkler(from_field_name, to_field_name);
4790
4791 let lev_distance = levenshtein(from_field_name, to_field_name);
4793 let max_len = from_field_name.len().max(to_field_name.len()) as f64;
4794 let levenshtein_sim = if max_len > 0.0 {
4795 1.0 - (lev_distance as f64 / max_len)
4796 } else {
4797 1.0 };
4799
4800 let name_similarity = self.similarity_config.jaro_winkler_weight * jaro_winkler_sim
4802 + self.similarity_config.levenshtein_weight * levenshtein_sim;
4803
4804 let nullable_boost = if from_field.nullable == to_field.nullable {
4806 0.1
4807 } else {
4808 0.0
4809 };
4810
4811 (name_similarity + nullable_boost).min(1.0)
4812 }
4813
4814 fn find_optimal_model_matches(
4848 &self,
4849 deleted: &[&(String, String)],
4850 created: &[&(String, String)],
4851 ) -> Vec<ModelMatchResult> {
4852 let mut graph = Graph::<(), f64, Undirected>::new_undirected();
4853 let mut deleted_nodes = Vec::new();
4854 let mut created_nodes = Vec::new();
4855
4856 for _ in deleted {
4858 deleted_nodes.push(graph.add_node(()));
4859 }
4860
4861 for _ in created {
4863 created_nodes.push(graph.add_node(()));
4864 }
4865
4866 for (i, deleted_key) in deleted.iter().enumerate() {
4868 if let Some(from_model) = self.from_state.models.get(*deleted_key) {
4869 for (j, created_key) in created.iter().enumerate() {
4870 if let Some(to_model) = self.to_state.models.get(*created_key) {
4871 let similarity = self.calculate_model_similarity(from_model, to_model);
4872
4873 if similarity >= self.similarity_config.model_threshold() {
4875 graph.add_edge(deleted_nodes[i], created_nodes[j], similarity);
4876 }
4877 }
4878 }
4879 }
4880 }
4881
4882 let mut matches = Vec::new();
4885 let mut used_deleted = std::collections::HashSet::new();
4886 let mut used_created = std::collections::HashSet::new();
4887
4888 let mut weighted_edges: Vec<_> = graph
4890 .edge_references()
4891 .map(|e| (e.source(), e.target(), *e.weight()))
4892 .collect();
4893 weighted_edges.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
4894
4895 for (source, target, weight) in weighted_edges {
4897 let source_idx = deleted_nodes.iter().position(|&n| n == source);
4898 let target_idx = created_nodes.iter().position(|&n| n == target);
4899
4900 if let (Some(i), Some(j)) = (source_idx, target_idx)
4901 && !used_deleted.contains(&i)
4902 && !used_created.contains(&j)
4903 {
4904 matches.push((deleted[i].clone(), created[j].clone(), weight));
4905 used_deleted.insert(i);
4906 used_created.insert(j);
4907 }
4908 }
4909
4910 matches
4911 }
4912
4913 fn detect_added_indexes(&self, changes: &mut DetectedChanges) {
4918 for ((app_label, model_name), to_model) in &self.to_state.models {
4919 if let Some(from_model) =
4920 self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4921 {
4922 for to_index in &to_model.indexes {
4923 if !from_model
4925 .indexes
4926 .iter()
4927 .any(|idx| idx.name == to_index.name)
4928 {
4929 changes.added_indexes.push((
4930 app_label.clone(),
4931 model_name.clone(),
4932 to_index.clone(),
4933 ));
4934 }
4935 }
4936 }
4937 }
4938 }
4939
4940 fn detect_removed_indexes(&self, changes: &mut DetectedChanges) {
4945 for ((app_label, model_name), from_model) in &self.from_state.models {
4946 if let Some(to_model) =
4947 self.matching_to_model_for_from_model(app_label, model_name, from_model, changes)
4948 {
4949 for from_index in &from_model.indexes {
4950 if !to_model
4952 .indexes
4953 .iter()
4954 .any(|idx| idx.name == from_index.name)
4955 {
4956 changes.removed_indexes.push((
4957 app_label.clone(),
4958 model_name.clone(),
4959 from_index.name.clone(),
4960 ));
4961 }
4962 }
4963 }
4964 }
4965 }
4966
4967 fn detect_added_constraints(&self, changes: &mut DetectedChanges) {
4992 for ((app_label, model_name), to_model) in &self.to_state.models {
4993 if let Some(from_model) =
4994 self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4995 {
4996 for to_constraint in &to_model.constraints {
4997 if from_model
4998 .constraints
4999 .iter()
5000 .any(|c| c.name == to_constraint.name)
5001 {
5002 continue;
5003 }
5004 if Self::single_field_unique_already_present(to_constraint, from_model) {
5005 continue;
5006 }
5007 if Self::added_single_field_unique_preserved_by_rename(
5008 changes,
5009 app_label,
5010 model_name,
5011 to_constraint,
5012 from_model,
5013 ) {
5014 continue;
5015 }
5016 changes.added_constraints.push((
5017 app_label.clone(),
5018 model_name.clone(),
5019 to_constraint.clone(),
5020 ));
5021 }
5022 }
5023 }
5024 }
5025
5026 fn detect_removed_constraints(&self, changes: &mut DetectedChanges) {
5042 for ((app_label, model_name), from_model) in &self.from_state.models {
5043 if let Some(to_model) =
5044 self.matching_to_model_for_from_model(app_label, model_name, from_model, changes)
5045 {
5046 for from_constraint in &from_model.constraints {
5047 if to_model
5048 .constraints
5049 .iter()
5050 .any(|c| c.name == from_constraint.name)
5051 {
5052 continue;
5053 }
5054 if Self::single_field_unique_already_present(from_constraint, to_model) {
5055 continue;
5056 }
5057 if Self::removed_single_field_unique_preserved_by_rename(
5058 changes,
5059 app_label,
5060 model_name,
5061 to_model,
5062 from_constraint,
5063 ) {
5064 continue;
5065 }
5066 changes.removed_constraints.push((
5067 app_label.clone(),
5068 model_name.clone(),
5069 from_constraint.name.clone(),
5070 ));
5071 }
5072 }
5073 }
5074 }
5075
5076 fn single_field_unique_already_present(
5087 candidate: &ConstraintDefinition,
5088 other_side: &ModelState,
5089 ) -> bool {
5090 if !is_single_field_unique(candidate) {
5091 return false;
5092 }
5093 let column = &candidate.fields[0];
5094 Self::single_field_unique_column_already_present(other_side, column)
5095 }
5096
5097 fn single_field_unique_column_already_present(model: &ModelState, column: &str) -> bool {
5098 let covered_by_constraint = model
5099 .constraints
5100 .iter()
5101 .any(|c| is_single_field_unique(c) && c.fields[0] == column);
5102 if covered_by_constraint {
5103 return true;
5104 }
5105 model
5106 .fields
5107 .get(column)
5108 .and_then(|f| f.params.get("unique"))
5109 .map(String::as_str)
5110 == Some("true")
5111 }
5112
5113 fn added_single_field_unique_preserved_by_rename(
5114 changes: &DetectedChanges,
5115 app_label: &str,
5116 model_name: &str,
5117 to_constraint: &ConstraintDefinition,
5118 from_model: &ModelState,
5119 ) -> bool {
5120 if !is_single_field_unique(to_constraint) {
5121 return false;
5122 }
5123 let new_column = &to_constraint.fields[0];
5124 changes.renamed_fields.iter().any(|(app, model, old, new)| {
5125 app == app_label
5126 && model == model_name
5127 && new == new_column
5128 && Self::single_field_unique_column_already_present(from_model, old)
5129 })
5130 }
5131
5132 fn removed_single_field_unique_preserved_by_rename(
5133 changes: &DetectedChanges,
5134 app_label: &str,
5135 from_model_name: &str,
5136 to_model: &ModelState,
5137 from_constraint: &ConstraintDefinition,
5138 ) -> bool {
5139 if !is_single_field_unique(from_constraint) {
5140 return false;
5141 }
5142 let old_column = &from_constraint.fields[0];
5143 changes.renamed_fields.iter().any(|(app, model, old, new)| {
5144 app == app_label
5145 && (model == from_model_name || model == &to_model.name)
5146 && old == old_column
5147 && Self::single_field_unique_column_already_present(to_model, new)
5148 })
5149 }
5150
5151 fn dedup_redundant_unique_add_constraints(
5179 by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
5180 ) {
5181 use std::collections::HashSet;
5182
5183 for operations in by_app.values_mut() {
5184 let mut covered: HashSet<(String, String)> = HashSet::new();
5186 let mut keep = Vec::with_capacity(operations.len());
5187 for op in operations.drain(..) {
5188 match &op {
5189 super::Operation::CreateTable {
5190 name,
5191 columns,
5192 constraints,
5193 ..
5194 } => {
5195 for col in columns {
5196 if col.unique {
5197 covered.insert((name.clone(), col.name.clone()));
5198 }
5199 }
5200 for c in constraints {
5201 if let super::operations::Constraint::Unique { columns, .. } = c
5202 && columns.len() == 1
5203 {
5204 covered.insert((name.clone(), columns[0].clone()));
5205 }
5206 }
5207 keep.push(op);
5208 }
5209 super::Operation::AddColumn { table, column, .. } => {
5210 if column.unique {
5211 covered.insert((table.clone(), column.name.clone()));
5212 }
5213 keep.push(op);
5214 }
5215 super::Operation::AddConstraint {
5216 table,
5217 constraint_sql,
5218 } => {
5219 if let Some(col) = parse_single_column_unique(constraint_sql) {
5220 let key = (table.clone(), col.to_string());
5221 if covered.contains(&key) {
5222 continue;
5224 }
5225 covered.insert(key);
5226 }
5227 keep.push(op);
5228 }
5229 _ => keep.push(op),
5230 }
5231 }
5232 *operations = keep;
5233 }
5234 }
5235
5236 fn detect_composite_pk_changes(&self, changes: &mut DetectedChanges) {
5246 for ((app_label, model_name), to_model) in &self.to_state.models {
5247 let from_model = self
5248 .from_state
5249 .get_model_by_table_name(app_label, &to_model.table_name);
5250 for constraint in &to_model.constraints {
5251 if constraint.constraint_type != "primary_key" || constraint.fields.len() < 2 {
5252 continue;
5253 }
5254 let from_pk = from_model
5255 .and_then(|m| m.constraints.iter().find(|c| c.name == constraint.name));
5256 match from_pk {
5257 Some(existing) if existing.fields == constraint.fields => {
5258 }
5260 Some(_) => {
5261 changes.removed_composite_primary_keys.push((
5263 app_label.clone(),
5264 model_name.clone(),
5265 constraint.name.clone(),
5266 ));
5267 changes.added_composite_primary_keys.push((
5268 app_label.clone(),
5269 model_name.clone(),
5270 constraint.clone(),
5271 ));
5272 }
5273 None => {
5274 changes.added_composite_primary_keys.push((
5276 app_label.clone(),
5277 model_name.clone(),
5278 constraint.clone(),
5279 ));
5280 }
5281 }
5282 }
5283 }
5284 }
5285
5286 fn detect_auto_increment_resets(&self, changes: &mut DetectedChanges) {
5291 for ((app_label, model_name), to_model) in &self.to_state.models {
5292 let Some(value_str) = to_model.options.get("sequence_reset") else {
5293 continue;
5294 };
5295 let from_value = self
5296 .from_state
5297 .get_model(app_label, model_name)
5298 .and_then(|m| m.options.get("sequence_reset"))
5299 .map(String::as_str);
5300 if from_value == Some(value_str.as_str()) {
5301 continue;
5302 }
5303 let Ok(value) = value_str.parse::<i64>() else {
5304 eprintln!(
5305 "Invalid sequence_reset value for {}.{}: {:?}. Expected an integer.",
5306 app_label, model_name, value_str
5307 );
5308 continue;
5309 };
5310 let Some(column) = to_model
5311 .fields
5312 .iter()
5313 .find(|(_, f)| f.params.get("auto_increment").is_some_and(|v| v == "true"))
5314 .map(|(name, _)| name.clone())
5315 else {
5316 continue;
5317 };
5318 changes.auto_increment_resets.push((
5319 app_label.clone(),
5320 model_name.clone(),
5321 column,
5322 value,
5323 ));
5324 }
5325 }
5326
5327 fn generate_intermediate_table(
5345 &self,
5346 app_label: &str,
5347 model_name: &str,
5348 field_name: &str,
5349 to_model: &str,
5350 through_table: &Option<String>,
5351 ) -> Option<super::Operation> {
5352 let source_table = self
5356 .to_state
5357 .get_model(app_label, model_name)
5358 .map(|m| m.table_name.clone())
5359 .unwrap_or_else(|| {
5360 format!("{}_{}", to_snake_case(app_label), to_snake_case(model_name))
5361 });
5362
5363 let (target_app, target_model) = self.parse_model_reference(to_model, app_label)?;
5365 let target_table = self
5366 .to_state
5367 .get_model(&target_app, &target_model)
5368 .map(|m| m.table_name.clone())
5369 .or_else(|| {
5370 super::model_registry::global_registry()
5371 .get_models()
5372 .iter()
5373 .find(|m| m.app_label == target_app && m.model_name == target_model)
5374 .map(|m| m.table_name.clone())
5375 })
5376 .unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
5377
5378 let table_name = if let Some(custom_name) = through_table {
5384 custom_name.clone()
5385 } else {
5386 format!(
5387 "{}_{}",
5388 source_table.to_lowercase(),
5389 to_snake_case(field_name)
5390 )
5391 };
5392
5393 let source_table_lower = source_table.to_lowercase();
5399 let target_table_lower = target_table.to_lowercase();
5400 let (source_column, target_column) = if source_table_lower == target_table_lower {
5401 (
5402 format!("from_{}_id", source_table_lower),
5403 format!("to_{}_id", target_table_lower),
5404 )
5405 } else {
5406 (
5407 format!("{}_id", source_table_lower),
5408 format!("{}_id", target_table_lower),
5409 )
5410 };
5411
5412 let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
5417 let target_pk_type = self
5418 .to_state
5419 .get_primary_key_type(&target_app, &target_model);
5420
5421 let columns = vec![
5423 super::ColumnDefinition {
5425 name: "id".to_string(),
5426 type_definition: super::FieldType::BigInteger,
5427 not_null: true,
5428 unique: false,
5429 primary_key: true,
5430 auto_increment: true,
5431 default: None,
5432 },
5433 super::ColumnDefinition {
5435 name: source_column.clone(),
5436 type_definition: source_pk_type,
5437 not_null: true,
5438 unique: false,
5439 primary_key: false,
5440 auto_increment: false,
5441 default: None,
5442 },
5443 super::ColumnDefinition {
5445 name: target_column.clone(),
5446 type_definition: target_pk_type,
5447 not_null: true,
5448 unique: false,
5449 primary_key: false,
5450 auto_increment: false,
5451 default: None,
5452 },
5453 ];
5454
5455 let constraints = vec![
5457 super::Constraint::ForeignKey {
5459 name: format!("fk_{}_{}", table_name, source_column),
5460 columns: vec![source_column.clone()],
5461 referenced_table: source_table.clone(),
5462 referenced_columns: vec!["id".to_string()],
5463 on_delete: super::ForeignKeyAction::Cascade,
5464 on_update: super::ForeignKeyAction::Cascade,
5465 deferrable: None,
5466 },
5467 super::Constraint::ForeignKey {
5469 name: format!("fk_{}_{}", table_name, target_column),
5470 columns: vec![target_column.clone()],
5471 referenced_table: target_table.clone(),
5472 referenced_columns: vec!["id".to_string()],
5473 on_delete: super::ForeignKeyAction::Cascade,
5474 on_update: super::ForeignKeyAction::Cascade,
5475 deferrable: None,
5476 },
5477 super::Constraint::Unique {
5479 name: format!(
5480 "uq_{}_{}_{}",
5481 table_name,
5482 source_column.replace("_id", ""),
5483 target_column.replace("_id", "")
5484 ),
5485 columns: vec![source_column, target_column],
5486 },
5487 ];
5488
5489 Some(super::Operation::CreateTable {
5490 name: table_name,
5491 columns,
5492 constraints,
5493 without_rowid: None,
5494 interleave_in_parent: None,
5495 partition: None,
5496 })
5497 }
5498
5499 fn sort_operations_by_dependency(
5546 &self,
5547 mut operations: Vec<super::Operation>,
5548 ) -> Vec<super::Operation> {
5549 let mut sorted = Vec::new();
5550
5551 let create_tables: Vec<_> = operations
5553 .iter()
5554 .filter(|op| matches!(op, super::Operation::CreateTable { .. }))
5555 .cloned()
5556 .collect();
5557 operations.retain(|op| !matches!(op, super::Operation::CreateTable { .. }));
5558
5559 let field_ops: Vec<_> = operations
5561 .iter()
5562 .filter(|op| {
5563 matches!(
5564 op,
5565 super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
5566 )
5567 })
5568 .cloned()
5569 .collect();
5570 operations.retain(|op| {
5571 !matches!(
5572 op,
5573 super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
5574 )
5575 });
5576
5577 sorted.extend(create_tables);
5579 sorted.extend(field_ops);
5580 sorted.extend(operations); sorted
5583 }
5584
5585 fn operation_targets_table(operation: &super::Operation, table_name: &str) -> bool {
5586 match operation {
5587 super::Operation::AddColumn { table, .. }
5588 | super::Operation::AlterColumn { table, .. }
5589 | super::Operation::RenameColumn { table, .. }
5590 | super::Operation::AddConstraint { table, .. }
5591 | super::Operation::DropConstraint { table, .. }
5592 | super::Operation::CreateIndex { table, .. }
5593 | super::Operation::DropIndex { table, .. }
5594 | super::Operation::CreateCompositePrimaryKey { table, .. }
5595 | super::Operation::SetAutoIncrementValue { table, .. } => table == table_name,
5596 super::Operation::CreateTable { name, .. } | super::Operation::DropTable { name } => {
5597 name == table_name
5598 }
5599 super::Operation::RenameTable { old_name, new_name } => {
5600 old_name == table_name || new_name == table_name
5601 }
5602 _ => false,
5603 }
5604 }
5605
5606 fn constraint_references_table(constraint: &super::Constraint, table_name: &str) -> bool {
5607 match constraint {
5608 super::Constraint::ForeignKey {
5609 referenced_table, ..
5610 }
5611 | super::Constraint::OneToOne {
5612 referenced_table, ..
5613 } => referenced_table == table_name,
5614 super::Constraint::ManyToMany {
5615 target_table,
5616 through_table,
5617 ..
5618 } => target_table == table_name || through_table == table_name,
5619 super::Constraint::PrimaryKey { .. }
5620 | super::Constraint::Unique { .. }
5621 | super::Constraint::Check { .. }
5622 | super::Constraint::Exclude { .. } => false,
5623 }
5624 }
5625
5626 fn reference_tail_starts_with_table(tail: &str, table_name: &str) -> bool {
5627 let tail = tail.trim_start();
5628 let Some(first_char) = tail.chars().next() else {
5629 return false;
5630 };
5631
5632 let (referenced_table, rest) = if first_char == '"' {
5633 let Some(end_quote) = tail[1..].find('"') else {
5634 return false;
5635 };
5636 (&tail[1..=end_quote], &tail[end_quote + 2..])
5637 } else {
5638 let end = tail
5639 .find(|ch: char| ch == '(' || ch.is_whitespace())
5640 .unwrap_or(tail.len());
5641 (&tail[..end], &tail[end..])
5642 };
5643
5644 referenced_table == table_name
5645 && (rest.is_empty()
5646 || rest.starts_with('(')
5647 || rest.chars().next().is_some_and(char::is_whitespace))
5648 }
5649
5650 fn constraint_sql_references_table(constraint_sql: &str, table_name: &str) -> bool {
5651 let mut rest = constraint_sql;
5652 while let Some(index) = rest.find("REFERENCES ") {
5653 let tail = &rest[index + "REFERENCES ".len()..];
5654 if Self::reference_tail_starts_with_table(tail, table_name) {
5655 return true;
5656 }
5657 rest = tail;
5658 }
5659 false
5660 }
5661
5662 fn operation_references_table(operation: &super::Operation, table_name: &str) -> bool {
5663 match operation {
5664 super::Operation::CreateTable { constraints, .. } => constraints
5665 .iter()
5666 .any(|constraint| Self::constraint_references_table(constraint, table_name)),
5667 super::Operation::AddConstraint { constraint_sql, .. } => {
5668 Self::constraint_sql_references_table(constraint_sql, table_name)
5669 }
5670 _ => false,
5671 }
5672 }
5673
5674 fn operation_needs_table_after_rename(
5675 operation: &super::Operation,
5676 new_table_name: &str,
5677 ) -> bool {
5678 Self::operation_targets_table(operation, new_table_name)
5679 || Self::operation_references_table(operation, new_table_name)
5680 }
5681
5682 fn table_rename_names(operation: &super::Operation) -> Option<(String, String)> {
5683 match operation {
5684 super::Operation::RenameTable { old_name, new_name } => {
5685 Some((old_name.clone(), new_name.clone()))
5686 }
5687 super::Operation::MoveModel {
5688 rename_table: true,
5689 old_table_name: Some(old_name),
5690 new_table_name: Some(new_name),
5691 ..
5692 } => Some((old_name.clone(), new_name.clone())),
5693 _ => None,
5694 }
5695 }
5696
5697 fn order_renamed_table_operations(operations: &mut Vec<super::Operation>) {
5698 let mut index = 0;
5699 while index < operations.len() {
5700 let (old_name, new_name) = match Self::table_rename_names(&operations[index]) {
5701 Some(names) => names,
5702 _ => {
5703 index += 1;
5704 continue;
5705 }
5706 };
5707
5708 let rename_operation = operations.remove(index);
5709 let mut before_rename = Vec::new();
5710 let mut after_rename = Vec::new();
5711
5712 for (candidate_index, operation) in std::mem::take(operations).into_iter().enumerate() {
5713 if Self::operation_targets_table(&operation, &old_name) {
5714 before_rename.push(operation);
5715 } else if Self::operation_needs_table_after_rename(&operation, &new_name) {
5716 after_rename.push(operation);
5717 } else if candidate_index < index {
5718 before_rename.push(operation);
5719 } else {
5720 after_rename.push(operation);
5721 }
5722 }
5723
5724 let next_index = before_rename.len() + 1;
5725 before_rename.push(rename_operation);
5726 before_rename.append(&mut after_rename);
5727 *operations = before_rename;
5728 index = next_index;
5729 }
5730 }
5731
5732 pub fn generate_operations(&self) -> Vec<super::Operation> {
5734 let changes = self.detect_changes();
5735 self.generate_operations_from_changes(&changes)
5736 }
5737
5738 pub fn try_generate_operations(&self) -> super::Result<Vec<super::Operation>> {
5740 let changes = self.try_detect_changes()?;
5741 Ok(self.generate_operations_from_changes(&changes))
5742 }
5743
5744 fn generate_operations_from_changes(&self, changes: &DetectedChanges) -> Vec<super::Operation> {
5745 let mut by_app: BTreeMap<String, Vec<super::Operation>> = BTreeMap::new();
5746
5747 self.emit_shared_per_app_operations(changes, &mut by_app);
5752
5753 for (app_label, model_name) in &changes.created_models {
5759 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5760 for (field_name, field_state) in &model.fields {
5761 if let super::FieldType::ManyToMany { to, through } = &field_state.field_type
5762 && let Some(operation) = self.generate_intermediate_table(
5763 app_label, model_name, field_name, to, through,
5764 ) {
5765 by_app.entry(app_label.clone()).or_default().push(operation);
5766 }
5767 }
5768 }
5769 }
5770 for (app_label, model_name, field_name) in &changes.added_fields {
5771 if let Some(model) = self.to_state.get_model(app_label, model_name)
5772 && let Some(field) = model.get_field(field_name)
5773 && let super::FieldType::ManyToMany { to, through } = &field.field_type
5774 && let Some(operation) =
5775 self.generate_intermediate_table(app_label, model_name, field_name, to, through)
5776 {
5777 by_app.entry(app_label.clone()).or_default().push(operation);
5778 }
5779 }
5780
5781 Self::dedup_redundant_unique_add_constraints(&mut by_app);
5793
5794 let operations: Vec<super::Operation> = by_app.into_values().flatten().collect();
5796 self.sort_operations_by_dependency(operations)
5797 }
5798
5799 fn emit_shared_per_app_operations(
5814 &self,
5815 changes: &DetectedChanges,
5816 by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
5817 ) {
5818 for (app_label, model_name) in &changes.created_models {
5820 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5821 let mut columns = Vec::new();
5822 for (field_name, field_state) in &model.fields {
5823 columns.push(super::ColumnDefinition::from_field_state(
5824 field_name.clone(),
5825 field_state,
5826 ));
5827 }
5828
5829 let constraints: Vec<super::operations::Constraint> = model
5830 .constraints
5831 .iter()
5832 .map(|c| c.to_constraint())
5833 .collect();
5834
5835 by_app
5836 .entry(app_label.clone())
5837 .or_default()
5838 .push(super::Operation::CreateTable {
5839 name: model.table_name.clone(),
5840 columns,
5841 constraints,
5842 without_rowid: None,
5843 interleave_in_parent: None,
5844 partition: None,
5845 });
5846 }
5847 }
5848
5849 for (app_label, model_name, field_name) in &changes.added_fields {
5857 if let Some(model) = self.to_state.get_model(app_label, model_name)
5858 && let Some(field) = model.get_field(field_name)
5859 {
5860 by_app
5861 .entry(app_label.clone())
5862 .or_default()
5863 .push(super::Operation::AddColumn {
5864 table: model.table_name.clone(),
5865 column: super::ColumnDefinition::from_field_state(
5866 field_name.clone(),
5867 field,
5868 ),
5869 mysql_options: None,
5870 });
5871 }
5872 }
5873
5874 for (app_label, model_name, old_name, new_name) in &changes.renamed_fields {
5876 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5877 by_app
5878 .entry(app_label.clone())
5879 .or_default()
5880 .push(super::Operation::RenameColumn {
5881 table: model.table_name.clone(),
5882 old_name: old_name.clone(),
5883 new_name: new_name.clone(),
5884 });
5885 }
5886 }
5887
5888 for (app_label, model_name, field_name) in &changes.altered_fields {
5890 if let Some(model) = self.to_state.get_model(app_label, model_name)
5891 && let Some(field) = model.get_field(field_name)
5892 {
5893 let old_definition = self
5894 .from_state
5895 .get_model(app_label, model_name)
5896 .and_then(|from_model| from_model.get_field(field_name))
5897 .map(|from_field| {
5898 super::ColumnDefinition::from_field_state(field_name.clone(), from_field)
5899 });
5900 by_app
5901 .entry(app_label.clone())
5902 .or_default()
5903 .push(super::Operation::AlterColumn {
5904 table: model.table_name.clone(),
5905 old_definition,
5906 column: field_name.clone(),
5907 new_definition: super::ColumnDefinition::from_field_state(
5908 field_name.clone(),
5909 field,
5910 ),
5911 mysql_options: None,
5912 });
5913 }
5914 }
5915
5916 for (app_label, model_name, field_name) in &changes.removed_fields {
5918 if let Some(model) = self.from_state.get_model(app_label, model_name) {
5919 by_app
5920 .entry(app_label.clone())
5921 .or_default()
5922 .push(super::Operation::DropColumn {
5923 table: model.table_name.clone(),
5924 column: field_name.clone(),
5925 });
5926 }
5927 }
5928
5929 for (app_label, model_name) in &changes.deleted_models {
5931 if let Some(model) = self.from_state.get_model(app_label, model_name) {
5932 by_app
5933 .entry(app_label.clone())
5934 .or_default()
5935 .push(super::Operation::DropTable {
5936 name: model.table_name.clone(),
5937 });
5938 }
5939 }
5940
5941 for (app_label, model_name, constraint_name) in &changes.removed_composite_primary_keys {
5943 if let Some(model) = self.from_state.get_model(app_label, model_name) {
5944 by_app.entry(app_label.clone()).or_default().push(
5945 super::Operation::DropConstraint {
5946 table: model.table_name.clone(),
5947 constraint_name: constraint_name.clone(),
5948 },
5949 );
5950 }
5951 }
5952
5953 for (app_label, model_name, constraint) in &changes.added_composite_primary_keys {
5955 if let Some(model) = self.to_state.get_model(app_label, model_name) {
5956 by_app.entry(app_label.clone()).or_default().push(
5957 super::Operation::CreateCompositePrimaryKey {
5958 table: model.table_name.clone(),
5959 columns: constraint.fields.clone(),
5960 constraint_name: Some(constraint.name.clone()),
5961 },
5962 );
5963 }
5964 }
5965
5966 for (app_label, model_name, constraint_name) in &changes.removed_constraints {
5972 let Some(from_model) = self.from_state.get_model(app_label, model_name) else {
5973 continue;
5974 };
5975 let is_composite_pk = from_model
5976 .constraints
5977 .iter()
5978 .find(|c| &c.name == constraint_name)
5979 .is_some_and(|c| c.constraint_type == "primary_key" && c.fields.len() >= 2);
5980 if is_composite_pk {
5981 continue;
5982 }
5983 by_app
5984 .entry(app_label.clone())
5985 .or_default()
5986 .push(super::Operation::DropConstraint {
5987 table: from_model.table_name.clone(),
5988 constraint_name: constraint_name.clone(),
5989 });
5990 }
5991
5992 for (app_label, model_name, constraint) in &changes.added_constraints {
6006 if constraint.constraint_type == "primary_key" && constraint.fields.len() >= 2 {
6007 continue;
6008 }
6009 let Some(to_model) = self.to_state.get_model(app_label, model_name) else {
6010 continue;
6011 };
6012 let constraint_sql = constraint.to_constraint().to_string();
6013 by_app
6014 .entry(app_label.clone())
6015 .or_default()
6016 .push(super::Operation::AddConstraint {
6017 table: to_model.table_name.clone(),
6018 constraint_sql,
6019 });
6020 }
6021
6022 for (app_label, model_name, column, value) in &changes.auto_increment_resets {
6024 if let Some(model) = self.to_state.get_model(app_label, model_name) {
6025 by_app.entry(app_label.clone()).or_default().push(
6026 super::Operation::SetAutoIncrementValue {
6027 table: model.table_name.clone(),
6028 column: column.clone(),
6029 value: *value,
6030 },
6031 );
6032 }
6033 }
6034 }
6035
6036 pub fn generate_migrations(&self) -> Vec<super::Migration> {
6078 let changes = self.detect_changes();
6079 self.generate_migrations_from_changes(&changes)
6080 }
6081
6082 pub fn try_generate_migrations(&self) -> super::Result<Vec<super::Migration>> {
6084 let changes = self.try_detect_changes()?;
6085 Ok(self.generate_migrations_from_changes(&changes))
6086 }
6087
6088 fn generate_migrations_from_changes(&self, changes: &DetectedChanges) -> Vec<super::Migration> {
6089 let mut migrations_by_app: BTreeMap<String, Vec<super::Operation>> = BTreeMap::new();
6090
6091 self.emit_shared_per_app_operations(changes, &mut migrations_by_app);
6096
6097 for (app_label, model_name, through_table, m2m) in &changes.created_many_to_many {
6099 let source_table = self
6104 .to_state
6105 .get_model(app_label, model_name)
6106 .map(|m| m.table_name.clone())
6107 .unwrap_or_else(|| format!("{}_{}", app_label, model_name.to_lowercase()));
6108
6109 let (parsed_target_app, parsed_target_model) =
6117 self.resolve_model_reference(&m2m.to_model, app_label);
6118
6119 let target_table = self
6127 .to_state
6128 .get_model(&parsed_target_app, &parsed_target_model)
6129 .map(|model| model.table_name.clone())
6130 .or_else(|| {
6131 super::model_registry::global_registry()
6132 .get_models()
6133 .iter()
6134 .find(|m| {
6135 m.app_label == parsed_target_app && m.model_name == parsed_target_model
6136 })
6137 .map(|m| m.table_name.clone())
6138 })
6139 .unwrap_or_else(|| {
6140 format!(
6141 "{}_{}",
6142 parsed_target_app,
6143 parsed_target_model.to_lowercase()
6144 )
6145 });
6146
6147 let (default_source_col, default_target_col) =
6153 crate::m2m_naming::default_m2m_columns(&source_table, &target_table);
6154 let source_column = m2m.source_field.clone().unwrap_or(default_source_col);
6155 let target_column = m2m.target_field.clone().unwrap_or(default_target_col);
6156
6157 let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
6159
6160 let target_pk_type = self
6164 .to_state
6165 .get_primary_key_type(&parsed_target_app, &parsed_target_model);
6166
6167 let columns = vec![
6169 super::ColumnDefinition {
6170 name: "id".to_string(),
6171 type_definition: super::FieldType::Integer,
6172 not_null: true,
6173 unique: false,
6174 primary_key: true,
6175 auto_increment: true,
6176 default: None,
6177 },
6178 super::ColumnDefinition {
6179 name: source_column.clone(),
6180 type_definition: source_pk_type.clone(),
6181 not_null: true,
6182 unique: false,
6183 primary_key: false,
6184 auto_increment: false,
6185 default: None,
6186 },
6187 super::ColumnDefinition {
6188 name: target_column.clone(),
6189 type_definition: target_pk_type,
6190 not_null: true,
6191 unique: false,
6192 primary_key: false,
6193 auto_increment: false,
6194 default: None,
6195 },
6196 ];
6197
6198 let constraints = vec![
6200 super::operations::Constraint::ForeignKey {
6201 name: format!("fk_{}_{}", through_table, source_column),
6202 columns: vec![source_column.clone()],
6203 referenced_table: source_table.clone(),
6204 referenced_columns: vec!["id".to_string()],
6205 on_delete: ForeignKeyAction::Cascade,
6206 on_update: ForeignKeyAction::Cascade,
6207 deferrable: None,
6208 },
6209 super::operations::Constraint::ForeignKey {
6210 name: format!("fk_{}_{}", through_table, target_column),
6211 columns: vec![target_column.clone()],
6212 referenced_table: target_table,
6213 referenced_columns: vec!["id".to_string()],
6214 on_delete: ForeignKeyAction::Cascade,
6215 on_update: ForeignKeyAction::Cascade,
6216 deferrable: None,
6217 },
6218 super::operations::Constraint::Unique {
6220 name: format!("{}_unique", through_table),
6221 columns: vec![source_column, target_column],
6222 },
6223 ];
6224
6225 migrations_by_app
6226 .entry(app_label.clone())
6227 .or_default()
6228 .push(super::Operation::CreateTable {
6229 name: through_table.clone(),
6230 columns,
6231 constraints,
6232 without_rowid: None,
6233 interleave_in_parent: None,
6234 partition: None,
6235 });
6236 }
6237
6238 for (app_label, old_name, new_name) in &changes.renamed_models {
6240 if let Some(model) = self.to_state.get_model(app_label, new_name) {
6241 let old_table_name = self
6243 .from_state
6244 .get_model(app_label, old_name)
6245 .map(|m| m.table_name.clone())
6246 .unwrap_or_else(|| format!("{}_{}", app_label, old_name.to_lowercase()));
6247
6248 if old_table_name != model.table_name {
6250 migrations_by_app
6251 .entry(app_label.clone())
6252 .or_default()
6253 .push(super::Operation::RenameTable {
6254 old_name: old_table_name,
6255 new_name: model.table_name.clone(),
6256 });
6257 }
6258 }
6259 }
6260
6261 for (
6264 from_app,
6265 from_model_name,
6266 to_app,
6267 to_model_name,
6268 rename_table,
6269 old_table,
6270 new_table,
6271 ) in &changes.moved_models
6272 {
6273 let old_table_name = old_table.clone().unwrap_or_else(|| {
6275 self.from_state
6276 .get_model(from_app, from_model_name)
6277 .map(|m| m.table_name.clone())
6278 .unwrap_or_else(|| format!("{}_{}", from_app, from_model_name.to_lowercase()))
6279 });
6280
6281 let new_table_name = new_table.clone().unwrap_or_else(|| {
6282 self.to_state
6283 .get_model(to_app, to_model_name)
6284 .map(|m| m.table_name.clone())
6285 .unwrap_or_else(|| format!("{}_{}", to_app, to_model_name.to_lowercase()))
6286 });
6287
6288 migrations_by_app.entry(to_app.clone()).or_default().push(
6290 super::Operation::MoveModel {
6291 model_name: from_model_name.clone(),
6292 from_app: from_app.clone(),
6293 to_app: to_app.clone(),
6294 rename_table: *rename_table,
6295 old_table_name: if *rename_table {
6296 Some(old_table_name)
6297 } else {
6298 None
6299 },
6300 new_table_name: if *rename_table {
6301 Some(new_table_name)
6302 } else {
6303 None
6304 },
6305 },
6306 );
6307 }
6308
6309 Self::dedup_redundant_unique_add_constraints(&mut migrations_by_app);
6316 for operations in migrations_by_app.values_mut() {
6317 Self::order_renamed_table_operations(operations);
6318 }
6319
6320 let mut migrations = Vec::new();
6322 for (app_label, operations) in migrations_by_app {
6323 let migration_name = "autodetected".to_string();
6326
6327 let mut migration = super::Migration::new(&migration_name, &app_label);
6328 for operation in operations {
6329 migration = migration.add_operation(operation);
6330 }
6331 migrations.push(migration);
6332 }
6333
6334 migrations
6335 }
6336
6337 fn detect_created_many_to_many(&self, changes: &mut DetectedChanges) {
6380 for ((app_label, model_name), model_state) in &self.to_state.models {
6381 for m2m in &model_state.many_to_many_fields {
6382 let through_table = m2m.through.clone().unwrap_or_else(|| {
6399 crate::m2m_naming::default_through_table(
6400 &model_state.table_name,
6401 &m2m.field_name,
6402 )
6403 });
6404
6405 let exists_in_from = self
6415 .from_state
6416 .find_model_by_table(&through_table)
6417 .is_some();
6418 let exists_in_to = self.to_state.find_model_by_table(&through_table).is_some();
6419
6420 if !exists_in_from && !exists_in_to {
6421 changes.created_many_to_many.push((
6423 app_label.clone(),
6424 model_name.clone(),
6425 through_table.clone(),
6426 m2m.clone(),
6427 ));
6428
6429 let target_app = self
6432 .find_model_app(&m2m.to_model)
6433 .unwrap_or_else(|| app_label.clone());
6434
6435 changes
6436 .model_dependencies
6437 .entry((app_label.clone(), through_table))
6438 .or_default()
6439 .extend(vec![
6440 (app_label.clone(), model_name.clone()),
6441 (target_app, m2m.to_model.clone()),
6442 ]);
6443 }
6444 }
6445 }
6446 }
6447
6448 fn find_model_app(&self, model_name: &str) -> Option<String> {
6453 for (app_label, name) in self.to_state.models.keys() {
6455 if name == model_name {
6456 return Some(app_label.clone());
6457 }
6458 }
6459
6460 for model_meta in super::model_registry::global_registry().get_models() {
6463 if model_meta.model_name == model_name {
6464 return Some(model_meta.app_label.clone());
6465 }
6466 }
6467
6468 None
6469 }
6470
6471 fn detect_model_dependencies(&self, changes: &mut DetectedChanges) {
6509 for ((app_label, model_name), model) in &self.to_state.models {
6511 let mut dependencies = Vec::new();
6512
6513 for field in model.fields.values() {
6515 match &field.field_type {
6516 super::FieldType::ForeignKey { to_table, .. } => {
6518 if let Some(dep) = self.find_model_by_table_name(to_table) {
6520 if dep != (app_label.clone(), model_name.clone()) {
6522 dependencies.push(dep);
6523 }
6524 }
6525 }
6526 super::FieldType::OneToOne { to, .. } => {
6528 if let Some(dep) = self.parse_model_reference(to, app_label)
6530 && dep != (app_label.clone(), model_name.clone())
6531 {
6532 dependencies.push(dep);
6533 }
6534 }
6535 super::FieldType::ManyToMany { to, .. } => {
6537 if let Some(dep) = self.parse_model_reference(to, app_label)
6539 && dep != (app_label.clone(), model_name.clone())
6540 {
6541 dependencies.push(dep);
6542 }
6543 }
6544 super::FieldType::Custom(s) => {
6546 if let Some(referenced_model) = self.extract_related_model(s, app_label)
6547 && referenced_model != (app_label.clone(), model_name.clone())
6548 {
6549 dependencies.push(referenced_model);
6550 }
6551 }
6552 _ => {}
6554 }
6555 }
6556
6557 if !dependencies.is_empty() {
6559 changes
6560 .model_dependencies
6561 .insert((app_label.clone(), model_name.clone()), dependencies);
6562 }
6563 }
6564 }
6565
6566 fn extract_related_model(
6582 &self,
6583 field_type: &str,
6584 current_app: &str,
6585 ) -> Option<(String, String)> {
6586 if let Some(inner) = field_type
6588 .strip_prefix("ForeignKey(")
6589 .and_then(|s| s.strip_suffix(")"))
6590 {
6591 return self.parse_model_reference(inner, current_app);
6592 }
6593
6594 if let Some(inner) = field_type
6596 .strip_prefix("ManyToManyField(")
6597 .and_then(|s| s.strip_suffix(")"))
6598 {
6599 return self.parse_model_reference(inner, current_app);
6600 }
6601
6602 if let Some(inner) = field_type
6604 .strip_prefix("OneToOneField(")
6605 .and_then(|s| s.strip_suffix(")"))
6606 {
6607 return self.parse_model_reference(inner, current_app);
6608 }
6609
6610 None
6611 }
6612
6613 fn parse_model_reference(
6627 &self,
6628 reference: &str,
6629 current_app: &str,
6630 ) -> Option<(String, String)> {
6631 let parts: Vec<&str> = reference.split('.').collect();
6632 match parts.as_slice() {
6633 [app, model] => Some((app.to_string(), model.to_string())),
6635 [model] => {
6637 Some((current_app.to_string(), model.to_string()))
6639 }
6640 _ => None,
6642 }
6643 }
6644
6645 fn resolve_model_reference(&self, reference: &str, current_app: &str) -> (String, String) {
6646 let parts: Vec<&str> = reference.split('.').collect();
6647 match parts.as_slice() {
6648 [app, model] => (app.to_string(), model.to_string()),
6649 [model] => {
6650 let model = model.to_string();
6651 if self.to_state.get_model(current_app, &model).is_some() {
6652 (current_app.to_string(), model)
6653 } else {
6654 (
6655 self.find_model_app(&model)
6656 .unwrap_or_else(|| current_app.to_string()),
6657 model,
6658 )
6659 }
6660 }
6661 _ => (current_app.to_string(), reference.to_string()),
6662 }
6663 }
6664
6665 fn find_model_by_table_name(&self, table_name: &str) -> Option<(String, String)> {
6681 for (app_label, model_name) in self.to_state.models.keys() {
6683 let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
6685 if django_table == table_name {
6686 return Some((app_label.clone(), model_name.clone()));
6687 }
6688
6689 if model_name.to_lowercase() == table_name {
6691 return Some((app_label.clone(), model_name.clone()));
6692 }
6693 }
6694
6695 for (app_label, model_name) in self.from_state.models.keys() {
6697 let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
6698 if django_table == table_name {
6699 return Some((app_label.clone(), model_name.clone()));
6700 }
6701
6702 if model_name.to_lowercase() == table_name {
6703 return Some((app_label.clone(), model_name.clone()));
6704 }
6705 }
6706
6707 None
6708 }
6709}
6710
6711impl ModelState {
6712 pub fn remove_field(&mut self, name: &str) {
6728 self.fields.remove(name);
6729 }
6730
6731 pub fn alter_field(&mut self, name: &str, new_field: FieldState) {
6750 self.fields.insert(name.to_string(), new_field);
6751 }
6752}
6753
6754#[cfg(test)]
6755mod tests {
6756 use super::*;
6757 use rstest::rstest;
6758
6759 fn build_project_state(models: Vec<((String, String), ModelState)>) -> ProjectState {
6761 let mut state = ProjectState::new();
6762 for (key, model) in models {
6763 state.models.insert(key, model);
6764 }
6765 state
6766 }
6767
6768 fn build_model_state(
6770 app_label: &str,
6771 name: &str,
6772 fields: Vec<FieldState>,
6773 indexes: Vec<IndexDefinition>,
6774 constraints: Vec<ConstraintDefinition>,
6775 ) -> ModelState {
6776 let mut field_map = std::collections::BTreeMap::new();
6777 for f in fields {
6778 field_map.insert(f.name.clone(), f);
6779 }
6780 ModelState {
6781 app_label: app_label.to_string(),
6782 name: name.to_string(),
6783 table_name: format!("{}_{}", app_label, name.to_lowercase()),
6784 fields: field_map,
6785 options: std::collections::HashMap::new(),
6786 base_model: None,
6787 inheritance_type: None,
6788 discriminator_column: None,
6789 indexes,
6790 constraints,
6791 many_to_many_fields: Vec::new(),
6792 }
6793 }
6794
6795 #[rstest]
6796 fn apply_migration_operations_replays_foreign_key_add_constraint() {
6797 let create_posts = super::super::Operation::CreateTable {
6799 name: "blog_posts".to_string(),
6800 columns: vec![
6801 super::super::ColumnDefinition {
6802 name: "id".to_string(),
6803 type_definition: super::super::FieldType::BigInteger,
6804 not_null: true,
6805 unique: false,
6806 primary_key: true,
6807 auto_increment: true,
6808 default: None,
6809 },
6810 super::super::ColumnDefinition {
6811 name: "user_id".to_string(),
6812 type_definition: super::super::FieldType::BigInteger,
6813 not_null: true,
6814 unique: false,
6815 primary_key: false,
6816 auto_increment: false,
6817 default: None,
6818 },
6819 ],
6820 constraints: vec![],
6821 without_rowid: None,
6822 interleave_in_parent: None,
6823 partition: None,
6824 };
6825 let add_user_fk = super::super::Operation::AddConstraint {
6826 table: "blog_posts".to_string(),
6827 constraint_sql: "CONSTRAINT blog_posts_user_id_fk FOREIGN KEY (user_id) REFERENCES auth_users(id) ON DELETE CASCADE ON UPDATE NO ACTION".to_string(),
6828 };
6829 let mut state = ProjectState::new();
6830
6831 state.apply_migration_operations(&[create_posts, add_user_fk], "blog");
6833
6834 let model = state
6836 .find_model_by_table("blog_posts")
6837 .expect("blog_posts model should be reconstructed");
6838 let constraint = model
6839 .constraints
6840 .iter()
6841 .find(|constraint| constraint.name == "blog_posts_user_id_fk")
6842 .expect("foreign key constraint should be reconstructed");
6843 assert_eq!(constraint.constraint_type, "foreign_key");
6844 assert_eq!(constraint.fields, vec!["user_id".to_string()]);
6845 let fk_info = constraint
6846 .foreign_key_info
6847 .as_ref()
6848 .expect("foreign key metadata should be reconstructed");
6849 assert_eq!(fk_info.referenced_table, "auth_users");
6850 assert_eq!(fk_info.referenced_columns, vec!["id".to_string()]);
6851 assert_eq!(fk_info.on_delete, ForeignKeyAction::Cascade);
6852 assert_eq!(fk_info.on_update, ForeignKeyAction::NoAction);
6853 }
6854
6855 #[rstest]
6856 fn apply_migration_operations_replays_omitted_foreign_key_actions_as_no_action() {
6857 let create_posts = super::super::Operation::CreateTable {
6859 name: "blog_posts".to_string(),
6860 columns: vec![
6861 super::super::ColumnDefinition {
6862 name: "id".to_string(),
6863 type_definition: super::super::FieldType::BigInteger,
6864 not_null: true,
6865 unique: false,
6866 primary_key: true,
6867 auto_increment: true,
6868 default: None,
6869 },
6870 super::super::ColumnDefinition {
6871 name: "user_id".to_string(),
6872 type_definition: super::super::FieldType::BigInteger,
6873 not_null: true,
6874 unique: false,
6875 primary_key: false,
6876 auto_increment: false,
6877 default: None,
6878 },
6879 ],
6880 constraints: vec![],
6881 without_rowid: None,
6882 interleave_in_parent: None,
6883 partition: None,
6884 };
6885 let add_user_fk = super::super::Operation::AddConstraint {
6886 table: "blog_posts".to_string(),
6887 constraint_sql:
6888 "CONSTRAINT blog_posts_user_id_fk FOREIGN KEY (user_id) REFERENCES auth_users(id)"
6889 .to_string(),
6890 };
6891 let mut state = ProjectState::new();
6892
6893 state.apply_migration_operations(&[create_posts, add_user_fk], "blog");
6895
6896 let model = state
6898 .find_model_by_table("blog_posts")
6899 .expect("blog_posts model should be reconstructed");
6900 let fk_info = model
6901 .constraints
6902 .iter()
6903 .find(|constraint| constraint.name == "blog_posts_user_id_fk")
6904 .and_then(|constraint| constraint.foreign_key_info.as_ref())
6905 .expect("foreign key metadata should be reconstructed");
6906 assert_eq!(fk_info.on_delete, ForeignKeyAction::NoAction);
6907 assert_eq!(fk_info.on_update, ForeignKeyAction::NoAction);
6908 }
6909
6910 #[rstest]
6911 fn generate_operations_emits_rename_column_for_unambiguous_field_rename() {
6912 let from_model = build_model_state(
6913 "deployments",
6914 "Deployment",
6915 vec![
6916 FieldState::new("id", super::super::FieldType::Integer, false),
6917 FieldState::new("app_name", super::super::FieldType::VarChar(255), false),
6918 ],
6919 Vec::new(),
6920 Vec::new(),
6921 );
6922 let to_model = build_model_state(
6923 "deployments",
6924 "Deployment",
6925 vec![
6926 FieldState::new("id", super::super::FieldType::Integer, false),
6927 FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
6928 ],
6929 Vec::new(),
6930 Vec::new(),
6931 );
6932 let detector = MigrationAutodetector::new(
6933 build_project_state(vec![(
6934 ("deployments".to_string(), "Deployment".to_string()),
6935 from_model,
6936 )]),
6937 build_project_state(vec![(
6938 ("deployments".to_string(), "Deployment".to_string()),
6939 to_model,
6940 )]),
6941 );
6942
6943 let operations = detector
6944 .try_generate_operations()
6945 .expect("unambiguous rename should generate operations");
6946
6947 assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
6948 assert!(matches!(
6949 &operations[0],
6950 super::super::Operation::RenameColumn {
6951 table,
6952 old_name,
6953 new_name
6954 } if table == "deployments_deployment"
6955 && old_name == "app_name"
6956 && new_name == "project_name"
6957 ));
6958 }
6959
6960 #[rstest]
6961 fn generate_operations_renames_unique_column_without_constraint_churn() {
6962 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6963 let old_slug_field =
6964 FieldState::new("old_slug", super::super::FieldType::VarChar(255), false);
6965 let new_slug_field = FieldState::new("slug", super::super::FieldType::VarChar(255), false);
6966 let from_unique = ConstraintDefinition {
6967 name: "deployments_deployment_old_slug_uniq".to_string(),
6968 constraint_type: "unique".to_string(),
6969 fields: vec!["old_slug".to_string()],
6970 expression: None,
6971 foreign_key_info: None,
6972 };
6973 let to_unique = ConstraintDefinition {
6974 name: "deployments_deployment_slug_uniq".to_string(),
6975 constraint_type: "unique".to_string(),
6976 fields: vec!["slug".to_string()],
6977 expression: None,
6978 foreign_key_info: None,
6979 };
6980 let from_model = build_model_state(
6981 "deployments",
6982 "Deployment",
6983 vec![id_field.clone(), old_slug_field],
6984 Vec::new(),
6985 vec![from_unique],
6986 );
6987 let to_model = build_model_state(
6988 "deployments",
6989 "Deployment",
6990 vec![id_field, new_slug_field],
6991 Vec::new(),
6992 vec![to_unique],
6993 );
6994 let detector = MigrationAutodetector::new(
6995 build_project_state(vec![(
6996 ("deployments".to_string(), "Deployment".to_string()),
6997 from_model,
6998 )]),
6999 build_project_state(vec![(
7000 ("deployments".to_string(), "Deployment".to_string()),
7001 to_model,
7002 )]),
7003 );
7004
7005 let operations = detector
7006 .try_generate_operations()
7007 .expect("unique column rename should generate operations");
7008
7009 assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
7010 assert!(matches!(
7011 &operations[0],
7012 super::super::Operation::RenameColumn {
7013 table,
7014 old_name,
7015 new_name
7016 } if table == "deployments_deployment"
7017 && old_name == "old_slug"
7018 && new_name == "slug"
7019 ));
7020 assert!(
7021 operations.iter().all(|op| {
7022 !matches!(
7023 op,
7024 super::super::Operation::AddConstraint { .. }
7025 | super::super::Operation::DropConstraint { .. }
7026 )
7027 }),
7028 "expected no AddConstraint/DropConstraint for unique rename, got: {operations:?}"
7029 );
7030 }
7031
7032 #[rstest]
7033 fn generate_operations_detects_field_rename_from_offline_table_keyed_state() {
7034 let mut from_model = build_model_state(
7035 "deployments",
7036 "DeploymentsDeployment",
7037 vec![
7038 FieldState::new("id", super::super::FieldType::Integer, false),
7039 FieldState::new("reinhardt_app_yaml", super::super::FieldType::Text, false),
7040 ],
7041 Vec::new(),
7042 Vec::new(),
7043 );
7044 from_model.table_name = "deployments_deployment".to_string();
7045 let to_model = build_model_state(
7046 "deployments",
7047 "Deployment",
7048 vec![
7049 FieldState::new("id", super::super::FieldType::Integer, false),
7050 FieldState::new("project_yaml", super::super::FieldType::Text, false),
7051 ],
7052 Vec::new(),
7053 Vec::new(),
7054 );
7055 let detector = MigrationAutodetector::new(
7056 build_project_state(vec![(
7057 (
7058 "deployments".to_string(),
7059 "DeploymentsDeployment".to_string(),
7060 ),
7061 from_model,
7062 )]),
7063 build_project_state(vec![(
7064 ("deployments".to_string(), "Deployment".to_string()),
7065 to_model,
7066 )]),
7067 );
7068
7069 let migrations = detector
7070 .try_generate_migrations()
7071 .expect("table-name matched state should detect rename");
7072 let operations: Vec<_> = migrations
7073 .iter()
7074 .flat_map(|migration| migration.operations.iter())
7075 .collect();
7076
7077 assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
7078 assert!(matches!(
7079 operations[0],
7080 super::super::Operation::RenameColumn {
7081 table,
7082 old_name,
7083 new_name
7084 } if table == "deployments_deployment"
7085 && old_name == "reinhardt_app_yaml"
7086 && new_name == "project_yaml"
7087 ));
7088 }
7089
7090 #[rstest]
7091 fn generate_migrations_renames_field_with_renamed_model() {
7092 let from_model = build_model_state(
7093 "deployments",
7094 "Deployment",
7095 vec![
7096 FieldState::new("id", super::super::FieldType::Integer, false),
7097 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7098 FieldState::new("app_name", super::super::FieldType::VarChar(255), false),
7099 ],
7100 Vec::new(),
7101 Vec::new(),
7102 );
7103 let to_model = build_model_state(
7104 "deployments",
7105 "Project",
7106 vec![
7107 FieldState::new("id", super::super::FieldType::Integer, false),
7108 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7109 FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
7110 ],
7111 Vec::new(),
7112 Vec::new(),
7113 );
7114 let detector = MigrationAutodetector::new(
7115 build_project_state(vec![(
7116 ("deployments".to_string(), "Deployment".to_string()),
7117 from_model,
7118 )]),
7119 build_project_state(vec![(
7120 ("deployments".to_string(), "Project".to_string()),
7121 to_model,
7122 )]),
7123 );
7124
7125 let migrations = detector
7126 .try_generate_migrations()
7127 .expect("model and field rename should generate migrations");
7128 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
7129 let operations = &migrations[0].operations;
7130
7131 assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
7132 assert!(
7133 matches!(
7134 &operations[0],
7135 super::super::Operation::RenameTable { old_name, new_name }
7136 if old_name == "deployments_deployment"
7137 && new_name == "deployments_project"
7138 ),
7139 "RenameTable must precede new-table field operations: {operations:?}"
7140 );
7141 assert!(matches!(
7142 &operations[1],
7143 super::super::Operation::RenameColumn {
7144 table,
7145 old_name,
7146 new_name
7147 } if table == "deployments_project"
7148 && old_name == "app_name"
7149 && new_name == "project_name"
7150 ));
7151 assert!(
7152 operations.iter().all(|operation| {
7153 !matches!(
7154 operation,
7155 super::super::Operation::AddColumn { .. }
7156 | super::super::Operation::DropColumn { .. }
7157 )
7158 }),
7159 "field rename on a renamed model must not degrade to AddColumn/DropColumn: {operations:?}"
7160 );
7161 }
7162
7163 #[rstest]
7164 fn generate_migrations_adds_field_after_renaming_model_table() {
7165 let from_model = build_model_state(
7166 "deployments",
7167 "Deployment",
7168 vec![
7169 FieldState::new("id", super::super::FieldType::Integer, false),
7170 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7171 FieldState::new("updated_at", super::super::FieldType::DateTime, false),
7172 ],
7173 Vec::new(),
7174 Vec::new(),
7175 );
7176 let to_model = build_model_state(
7177 "deployments",
7178 "Project",
7179 vec![
7180 FieldState::new("id", super::super::FieldType::Integer, false),
7181 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7182 FieldState::new("updated_at", super::super::FieldType::DateTime, false),
7183 FieldState::new("project_name", super::super::FieldType::VarChar(255), true),
7184 ],
7185 Vec::new(),
7186 Vec::new(),
7187 );
7188 let detector = MigrationAutodetector::new(
7189 build_project_state(vec![(
7190 ("deployments".to_string(), "Deployment".to_string()),
7191 from_model,
7192 )]),
7193 build_project_state(vec![(
7194 ("deployments".to_string(), "Project".to_string()),
7195 to_model,
7196 )]),
7197 );
7198
7199 let migrations = detector
7200 .try_generate_migrations()
7201 .expect("model rename with added field should generate migrations");
7202 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
7203 let operations = &migrations[0].operations;
7204
7205 assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
7206 assert!(
7207 matches!(
7208 &operations[0],
7209 super::super::Operation::RenameTable { old_name, new_name }
7210 if old_name == "deployments_deployment"
7211 && new_name == "deployments_project"
7212 ),
7213 "RenameTable must precede new-table field operations: {operations:?}"
7214 );
7215 assert!(matches!(
7216 &operations[1],
7217 super::super::Operation::AddColumn { table, column, .. }
7218 if table == "deployments_project" && column.name == "project_name"
7219 ));
7220 }
7221
7222 #[rstest]
7223 fn generate_migrations_keeps_old_table_drop_before_renaming_model_table() {
7224 let from_model = build_model_state(
7225 "deployments",
7226 "Deployment",
7227 vec![
7228 FieldState::new("id", super::super::FieldType::Integer, false),
7229 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7230 FieldState::new("updated_at", super::super::FieldType::DateTime, false),
7231 FieldState::new("legacy_payload", super::super::FieldType::Text, true),
7232 ],
7233 Vec::new(),
7234 Vec::new(),
7235 );
7236 let to_model = build_model_state(
7237 "deployments",
7238 "Project",
7239 vec![
7240 FieldState::new("id", super::super::FieldType::Integer, false),
7241 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7242 FieldState::new("updated_at", super::super::FieldType::DateTime, false),
7243 FieldState::new("retry_count", super::super::FieldType::Integer, false),
7244 ],
7245 Vec::new(),
7246 Vec::new(),
7247 );
7248 let detector = MigrationAutodetector::new(
7249 build_project_state(vec![(
7250 ("deployments".to_string(), "Deployment".to_string()),
7251 from_model,
7252 )]),
7253 build_project_state(vec![(
7254 ("deployments".to_string(), "Project".to_string()),
7255 to_model,
7256 )]),
7257 );
7258
7259 let migrations = detector
7260 .try_generate_migrations()
7261 .expect("model rename with old-table drop should generate migrations");
7262 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
7263 let operations = &migrations[0].operations;
7264
7265 assert_eq!(operations.len(), 3, "unexpected operations: {operations:?}");
7266 assert!(matches!(
7267 &operations[0],
7268 super::super::Operation::DropColumn { table, column }
7269 if table == "deployments_deployment" && column == "legacy_payload"
7270 ));
7271 assert!(matches!(
7272 &operations[1],
7273 super::super::Operation::RenameTable { old_name, new_name }
7274 if old_name == "deployments_deployment" && new_name == "deployments_project"
7275 ));
7276 assert!(matches!(
7277 &operations[2],
7278 super::super::Operation::AddColumn { table, column, .. }
7279 if table == "deployments_project" && column.name == "retry_count"
7280 ));
7281 }
7282
7283 #[rstest]
7284 fn generate_migrations_adds_constraint_after_renaming_model_table() {
7285 let from_model = build_model_state(
7286 "deployments",
7287 "Deployment",
7288 vec![
7289 FieldState::new("id", super::super::FieldType::Integer, false),
7290 FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
7291 ],
7292 Vec::new(),
7293 Vec::new(),
7294 );
7295 let to_model = build_model_state(
7296 "deployments",
7297 "Project",
7298 vec![
7299 FieldState::new("id", super::super::FieldType::Integer, false),
7300 FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
7301 ],
7302 Vec::new(),
7303 vec![ConstraintDefinition {
7304 name: "deployments_project_project_name_not_empty".to_string(),
7305 constraint_type: "check".to_string(),
7306 fields: vec!["project_name".to_string()],
7307 expression: Some("project_name <> ''".to_string()),
7308 foreign_key_info: None,
7309 }],
7310 );
7311 let detector = MigrationAutodetector::new(
7312 build_project_state(vec![(
7313 ("deployments".to_string(), "Deployment".to_string()),
7314 from_model,
7315 )]),
7316 build_project_state(vec![(
7317 ("deployments".to_string(), "Project".to_string()),
7318 to_model,
7319 )]),
7320 );
7321
7322 let migrations = detector
7323 .try_generate_migrations()
7324 .expect("model rename with added constraint should generate migrations");
7325 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
7326 let operations = &migrations[0].operations;
7327
7328 assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
7329 assert!(matches!(
7330 &operations[0],
7331 super::super::Operation::RenameTable { old_name, new_name }
7332 if old_name == "deployments_deployment" && new_name == "deployments_project"
7333 ));
7334 assert!(matches!(
7335 &operations[1],
7336 super::super::Operation::AddConstraint {
7337 table,
7338 constraint_sql
7339 } if table == "deployments_project"
7340 && constraint_sql.contains("deployments_project_project_name_not_empty")
7341 && constraint_sql.contains("CHECK")
7342 && constraint_sql.contains("project_name")
7343 ));
7344 }
7345
7346 #[rstest]
7347 fn generate_migrations_preserves_field_changes_for_cross_app_move() {
7348 let from_model = build_model_state(
7349 "legacy",
7350 "Deployment",
7351 vec![
7352 FieldState::new("id", super::super::FieldType::Integer, false),
7353 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7354 FieldState::new("updated_at", super::super::FieldType::DateTime, false),
7355 FieldState::new("status", super::super::FieldType::VarChar(32), false),
7356 ],
7357 Vec::new(),
7358 Vec::new(),
7359 );
7360 let to_model = build_model_state(
7361 "deployments",
7362 "Project",
7363 vec![
7364 FieldState::new("id", super::super::FieldType::Integer, false),
7365 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7366 FieldState::new("updated_at", super::super::FieldType::DateTime, false),
7367 FieldState::new("status", super::super::FieldType::VarChar(32), false),
7368 FieldState::new("project_name", super::super::FieldType::VarChar(255), true),
7369 ],
7370 Vec::new(),
7371 Vec::new(),
7372 );
7373 let detector = MigrationAutodetector::new(
7374 build_project_state(vec![(
7375 ("legacy".to_string(), "Deployment".to_string()),
7376 from_model,
7377 )]),
7378 build_project_state(vec![(
7379 ("deployments".to_string(), "Project".to_string()),
7380 to_model,
7381 )]),
7382 );
7383
7384 let migrations = detector
7385 .try_generate_migrations()
7386 .expect("cross-app model move with added field should generate migrations");
7387 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
7388 assert_eq!(migrations[0].app_label, "deployments");
7389 let operations = &migrations[0].operations;
7390
7391 assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
7392 assert!(matches!(
7393 &operations[0],
7394 super::super::Operation::MoveModel {
7395 model_name,
7396 from_app,
7397 to_app,
7398 rename_table: true,
7399 old_table_name: Some(old_table),
7400 new_table_name: Some(new_table)
7401 } if model_name == "Deployment"
7402 && from_app == "legacy"
7403 && to_app == "deployments"
7404 && old_table == "legacy_deployment"
7405 && new_table == "deployments_project"
7406 ));
7407 assert!(matches!(
7408 &operations[1],
7409 super::super::Operation::AddColumn { table, column, .. }
7410 if table == "deployments_project" && column.name == "project_name"
7411 ));
7412 }
7413
7414 #[rstest]
7415 fn generate_migrations_orders_referenced_table_constraint_after_rename() {
7416 let from_account = build_model_state(
7417 "crm",
7418 "User",
7419 vec![
7420 FieldState::new("id", super::super::FieldType::Integer, false),
7421 FieldState::new("email", super::super::FieldType::VarChar(255), false),
7422 ],
7423 Vec::new(),
7424 Vec::new(),
7425 );
7426 let from_profile = build_model_state(
7427 "crm",
7428 "Profile",
7429 vec![
7430 FieldState::new("id", super::super::FieldType::Integer, false),
7431 FieldState::new("account_id", super::super::FieldType::Integer, false),
7432 ],
7433 Vec::new(),
7434 Vec::new(),
7435 );
7436 let to_account = build_model_state(
7437 "crm",
7438 "Account",
7439 vec![
7440 FieldState::new("id", super::super::FieldType::Integer, false),
7441 FieldState::new("email", super::super::FieldType::VarChar(255), false),
7442 ],
7443 Vec::new(),
7444 Vec::new(),
7445 );
7446 let to_profile = build_model_state(
7447 "crm",
7448 "Profile",
7449 vec![
7450 FieldState::new("id", super::super::FieldType::Integer, false),
7451 FieldState::new("account_id", super::super::FieldType::Integer, false),
7452 ],
7453 Vec::new(),
7454 vec![ConstraintDefinition {
7455 name: "crm_profile_account_id_fk".to_string(),
7456 constraint_type: "foreign_key".to_string(),
7457 fields: vec!["account_id".to_string()],
7458 expression: None,
7459 foreign_key_info: Some(ForeignKeyConstraintInfo {
7460 referenced_table: "crm_account".to_string(),
7461 referenced_columns: vec!["id".to_string()],
7462 on_delete: ForeignKeyAction::Cascade,
7463 on_update: ForeignKeyAction::Cascade,
7464 }),
7465 }],
7466 );
7467 let detector = MigrationAutodetector::new(
7468 build_project_state(vec![
7469 (("crm".to_string(), "User".to_string()), from_account),
7470 (("crm".to_string(), "Profile".to_string()), from_profile),
7471 ]),
7472 build_project_state(vec![
7473 (("crm".to_string(), "Account".to_string()), to_account),
7474 (("crm".to_string(), "Profile".to_string()), to_profile),
7475 ]),
7476 );
7477
7478 let migrations = detector
7479 .try_generate_migrations()
7480 .expect("referenced table rename with added FK should generate migrations");
7481 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
7482 let operations = &migrations[0].operations;
7483
7484 assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
7485 assert!(matches!(
7486 &operations[0],
7487 super::super::Operation::RenameTable { old_name, new_name }
7488 if old_name == "crm_user" && new_name == "crm_account"
7489 ));
7490 assert!(matches!(
7491 &operations[1],
7492 super::super::Operation::AddConstraint {
7493 table,
7494 constraint_sql
7495 } if table == "crm_profile"
7496 && constraint_sql.contains("crm_profile_account_id_fk")
7497 && constraint_sql.contains("REFERENCES crm_account")
7498 ));
7499 }
7500
7501 #[rstest]
7502 fn try_generate_operations_rejects_ambiguous_field_rename_candidates() {
7503 let from_model = build_model_state(
7504 "projects",
7505 "Project",
7506 vec![
7507 FieldState::new("old_code", super::super::FieldType::VarChar(255), false),
7508 FieldState::new("legacy_code", super::super::FieldType::VarChar(255), false),
7509 ],
7510 Vec::new(),
7511 Vec::new(),
7512 );
7513 let to_model = build_model_state(
7514 "projects",
7515 "Project",
7516 vec![FieldState::new(
7517 "project_code",
7518 super::super::FieldType::VarChar(255),
7519 false,
7520 )],
7521 Vec::new(),
7522 Vec::new(),
7523 );
7524 let detector = MigrationAutodetector::new(
7525 build_project_state(vec![(
7526 ("projects".to_string(), "Project".to_string()),
7527 from_model,
7528 )]),
7529 build_project_state(vec![(
7530 ("projects".to_string(), "Project".to_string()),
7531 to_model,
7532 )]),
7533 );
7534
7535 let error = detector
7536 .try_generate_operations()
7537 .expect_err("ambiguous rename candidates must fail");
7538 let message = error.to_string();
7539
7540 assert!(
7541 message.contains("Ambiguous field rename candidates"),
7542 "unexpected error: {message}"
7543 );
7544 assert!(
7545 message.contains("legacy_code")
7546 && message.contains("old_code")
7547 && message.contains("project_code"),
7548 "error should name candidate fields: {message}"
7549 );
7550 }
7551
7552 #[rstest]
7553 fn try_generate_operations_preserves_unrelated_add_and_drop() {
7554 let from_model = build_model_state(
7555 "projects",
7556 "Project",
7557 vec![FieldState::new(
7558 "legacy_payload",
7559 super::super::FieldType::Text,
7560 true,
7561 )],
7562 Vec::new(),
7563 Vec::new(),
7564 );
7565 let to_model = build_model_state(
7566 "projects",
7567 "Project",
7568 vec![FieldState::new(
7569 "retry_count",
7570 super::super::FieldType::Integer,
7571 false,
7572 )],
7573 Vec::new(),
7574 Vec::new(),
7575 );
7576 let detector = MigrationAutodetector::new(
7577 build_project_state(vec![(
7578 ("projects".to_string(), "Project".to_string()),
7579 from_model,
7580 )]),
7581 build_project_state(vec![(
7582 ("projects".to_string(), "Project".to_string()),
7583 to_model,
7584 )]),
7585 );
7586
7587 let operations = detector
7588 .try_generate_operations()
7589 .expect("unrelated add/drop should remain valid");
7590
7591 assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
7592 assert!(
7593 operations.iter().any(|op| matches!(
7594 op,
7595 super::super::Operation::AddColumn { column, .. } if column.name == "retry_count"
7596 )),
7597 "expected AddColumn, got: {operations:?}"
7598 );
7599 assert!(
7600 operations.iter().any(|op| matches!(
7601 op,
7602 super::super::Operation::DropColumn { column, .. } if column == "legacy_payload"
7603 )),
7604 "expected DropColumn, got: {operations:?}"
7605 );
7606 assert!(
7607 operations
7608 .iter()
7609 .all(|op| !matches!(op, super::super::Operation::RenameColumn { .. })),
7610 "unrelated add/drop must not be collapsed into RenameColumn: {operations:?}"
7611 );
7612 }
7613
7614 #[rstest]
7615 fn to_database_schema_uses_app_prefixed_table_key() {
7616 let model = build_model_state(
7618 "blog",
7619 "Post",
7620 vec![FieldState::new(
7621 "id",
7622 super::super::FieldType::Integer,
7623 false,
7624 )],
7625 Vec::new(),
7626 Vec::new(),
7627 );
7628 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
7629
7630 let schema = state.to_database_schema();
7632
7633 assert_eq!(schema.tables.len(), 1);
7635 assert!(
7636 schema.tables.contains_key("blog_post"),
7637 "table key should be app_label + '_' + lowercase model name"
7638 );
7639 let table = &schema.tables["blog_post"];
7640 assert_eq!(table.name, "blog_post");
7641 }
7642
7643 #[rstest]
7644 fn to_database_schema_prevents_cross_app_collision() {
7645 let blog_user = build_model_state(
7648 "blog",
7649 "User",
7650 vec![FieldState::new(
7651 "id",
7652 super::super::FieldType::Integer,
7653 false,
7654 )],
7655 Vec::new(),
7656 Vec::new(),
7657 );
7658 let auth_user = build_model_state(
7659 "auth",
7660 "User",
7661 vec![FieldState::new(
7662 "id",
7663 super::super::FieldType::Integer,
7664 false,
7665 )],
7666 Vec::new(),
7667 Vec::new(),
7668 );
7669 let state = build_project_state(vec![
7670 (("blog".to_string(), "User".to_string()), blog_user),
7671 (("auth".to_string(), "User".to_string()), auth_user),
7672 ]);
7673
7674 let schema = state.to_database_schema();
7676
7677 assert_eq!(schema.tables.len(), 2);
7679 assert!(schema.tables.contains_key("blog_user"));
7680 assert!(schema.tables.contains_key("auth_user"));
7681 }
7682
7683 #[rstest]
7684 fn to_database_schema_propagates_indexes() {
7685 let indexes = vec![
7687 IndexDefinition {
7688 name: "idx_title".to_string(),
7689 fields: vec!["title".to_string()],
7690 unique: false,
7691 },
7692 IndexDefinition {
7693 name: "idx_slug_unique".to_string(),
7694 fields: vec!["slug".to_string()],
7695 unique: true,
7696 },
7697 ];
7698 let model = build_model_state(
7699 "blog",
7700 "Post",
7701 vec![
7702 FieldState::new("title", super::super::FieldType::VarChar(255), false),
7703 FieldState::new("slug", super::super::FieldType::VarChar(100), false),
7704 ],
7705 indexes,
7706 Vec::new(),
7707 );
7708 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
7709
7710 let schema = state.to_database_schema();
7712
7713 let table = &schema.tables["blog_post"];
7715 assert_eq!(table.indexes.len(), 2);
7716 assert_eq!(table.indexes[0].name, "idx_title");
7717 assert_eq!(table.indexes[0].columns, vec!["title".to_string()]);
7718 assert!(!table.indexes[0].unique);
7719 assert_eq!(table.indexes[1].name, "idx_slug_unique");
7720 assert!(table.indexes[1].unique);
7721 }
7722
7723 #[rstest]
7724 fn to_database_schema_propagates_constraints() {
7725 let constraints = vec![ConstraintDefinition {
7727 name: "uq_email".to_string(),
7728 constraint_type: "unique".to_string(),
7729 fields: vec!["email".to_string()],
7730 expression: None,
7731 foreign_key_info: None,
7732 }];
7733 let model = build_model_state(
7734 "auth",
7735 "Account",
7736 vec![FieldState::new(
7737 "email",
7738 super::super::FieldType::VarChar(255),
7739 false,
7740 )],
7741 Vec::new(),
7742 constraints,
7743 );
7744 let state = build_project_state(vec![(("auth".to_string(), "Account".to_string()), model)]);
7745
7746 let schema = state.to_database_schema();
7748
7749 let table = &schema.tables["auth_account"];
7751 assert_eq!(table.constraints.len(), 1);
7752 assert_eq!(table.constraints[0].name, "uq_email");
7753 assert_eq!(table.constraints[0].constraint_type, "unique");
7754 assert_eq!(table.constraints[0].definition, "email");
7755 }
7756
7757 #[rstest]
7758 fn to_database_schema_maps_field_params() {
7759 let mut field = FieldState::new("id", super::super::FieldType::Integer, false);
7761 field
7762 .params
7763 .insert("primary_key".to_string(), "true".to_string());
7764 field
7765 .params
7766 .insert("auto_increment".to_string(), "true".to_string());
7767 field.params.insert("default".to_string(), "0".to_string());
7768
7769 let mut nullable_field = FieldState::new("bio", super::super::FieldType::Text, true);
7770 nullable_field
7771 .params
7772 .insert("default".to_string(), "''".to_string());
7773
7774 let model = build_model_state(
7775 "users",
7776 "Profile",
7777 vec![field, nullable_field],
7778 Vec::new(),
7779 Vec::new(),
7780 );
7781 let state =
7782 build_project_state(vec![(("users".to_string(), "Profile".to_string()), model)]);
7783
7784 let schema = state.to_database_schema();
7786
7787 let table = &schema.tables["users_profile"];
7789 let id_col = &table.columns["id"];
7790 assert!(id_col.primary_key);
7791 assert!(id_col.auto_increment);
7792 assert_eq!(id_col.default, Some("0".to_string()));
7793 assert!(!id_col.nullable);
7794
7795 let bio_col = &table.columns["bio"];
7796 assert!(!bio_col.primary_key);
7797 assert!(!bio_col.auto_increment);
7798 assert!(bio_col.nullable);
7799 assert_eq!(bio_col.default, Some("''".to_string()));
7800 }
7801
7802 #[rstest]
7803 fn to_database_schema_for_app_filters_by_app_label() {
7804 let blog_post = build_model_state(
7806 "blog",
7807 "Post",
7808 vec![FieldState::new(
7809 "id",
7810 super::super::FieldType::Integer,
7811 false,
7812 )],
7813 Vec::new(),
7814 Vec::new(),
7815 );
7816 let auth_user = build_model_state(
7817 "auth",
7818 "User",
7819 vec![FieldState::new(
7820 "id",
7821 super::super::FieldType::Integer,
7822 false,
7823 )],
7824 Vec::new(),
7825 Vec::new(),
7826 );
7827 let state = build_project_state(vec![
7828 (("blog".to_string(), "Post".to_string()), blog_post),
7829 (("auth".to_string(), "User".to_string()), auth_user),
7830 ]);
7831
7832 let blog_schema = state.to_database_schema_for_app("blog");
7834 let auth_schema = state.to_database_schema_for_app("auth");
7835 let empty_schema = state.to_database_schema_for_app("nonexistent");
7836
7837 assert_eq!(blog_schema.tables.len(), 1);
7839 assert!(blog_schema.tables.contains_key("blog_post"));
7840
7841 assert_eq!(auth_schema.tables.len(), 1);
7842 assert!(auth_schema.tables.contains_key("auth_user"));
7843
7844 assert_eq!(empty_schema.tables.len(), 0);
7845 }
7846
7847 #[rstest]
7848 fn to_database_schema_for_app_propagates_indexes_and_constraints() {
7849 let indexes = vec![IndexDefinition {
7851 name: "idx_created".to_string(),
7852 fields: vec!["created_at".to_string()],
7853 unique: false,
7854 }];
7855 let constraints = vec![ConstraintDefinition {
7856 name: "ck_status".to_string(),
7857 constraint_type: "check".to_string(),
7858 fields: vec!["status".to_string()],
7859 expression: Some("status IN ('draft', 'published')".to_string()),
7860 foreign_key_info: None,
7861 }];
7862 let model = build_model_state(
7863 "blog",
7864 "Post",
7865 vec![
7866 FieldState::new("created_at", super::super::FieldType::DateTime, false),
7867 FieldState::new("status", super::super::FieldType::VarChar(20), false),
7868 ],
7869 indexes,
7870 constraints,
7871 );
7872 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
7873
7874 let schema = state.to_database_schema_for_app("blog");
7876
7877 let table = &schema.tables["blog_post"];
7879 assert_eq!(table.indexes.len(), 1);
7880 assert_eq!(table.indexes[0].name, "idx_created");
7881 assert_eq!(table.indexes[0].columns, vec!["created_at".to_string()]);
7882
7883 assert_eq!(table.constraints.len(), 1);
7884 assert_eq!(table.constraints[0].name, "ck_status");
7885 assert_eq!(table.constraints[0].constraint_type, "check");
7886 assert_eq!(table.constraints[0].definition, "status");
7887 }
7888
7889 fn build_model_state_with_table_name(
7891 app_label: &str,
7892 name: &str,
7893 table_name: &str,
7894 fields: Vec<FieldState>,
7895 ) -> ModelState {
7896 let mut field_map = std::collections::BTreeMap::new();
7897 for f in fields {
7898 field_map.insert(f.name.clone(), f);
7899 }
7900 ModelState {
7901 app_label: app_label.to_string(),
7902 name: name.to_string(),
7903 table_name: table_name.to_string(),
7904 fields: field_map,
7905 options: std::collections::HashMap::new(),
7906 base_model: None,
7907 inheritance_type: None,
7908 discriminator_column: None,
7909 indexes: Vec::new(),
7910 constraints: Vec::new(),
7911 many_to_many_fields: Vec::new(),
7912 }
7913 }
7914
7915 #[rstest]
7916 fn to_database_schema_respects_custom_table_name() {
7917 let model = build_model_state_with_table_name(
7919 "blog",
7920 "Post",
7921 "custom_posts_table",
7922 vec![FieldState::new(
7923 "id",
7924 super::super::FieldType::Integer,
7925 false,
7926 )],
7927 );
7928 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
7929
7930 let schema = state.to_database_schema();
7932
7933 assert!(schema.tables.contains_key("blog_post"));
7936 let table = &schema.tables["blog_post"];
7938 assert_eq!(table.name, "custom_posts_table");
7939 }
7940
7941 #[rstest]
7942 fn to_database_schema_for_app_respects_custom_table_name() {
7943 let model = build_model_state_with_table_name(
7945 "blog",
7946 "Post",
7947 "custom_posts_table",
7948 vec![FieldState::new(
7949 "id",
7950 super::super::FieldType::Integer,
7951 false,
7952 )],
7953 );
7954 let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
7955
7956 let schema = state.to_database_schema_for_app("blog");
7958
7959 assert!(schema.tables.contains_key("blog_post"));
7961 let table = &schema.tables["blog_post"];
7962 assert_eq!(table.name, "custom_posts_table");
7963 }
7964
7965 fn sample_fields() -> Vec<FieldState> {
7969 vec![
7970 FieldState::new("id", super::super::FieldType::Integer, false),
7971 FieldState::new("name", super::super::FieldType::VarChar(255), false),
7972 ]
7973 }
7974
7975 #[rstest]
7991 fn detect_created_many_to_many_recognises_existing_through_table_by_table_name() {
7992 use super::super::model_registry::ManyToManyMetadata;
7993
7994 let from_room = build_model_state_with_table_name("dm", "Room", "dm_room", sample_fields());
7999 let from_through = build_model_state_with_table_name(
8000 "dm",
8001 "RoomMembers",
8002 "dm_room_members",
8003 sample_fields(),
8004 );
8005 let from_state = build_project_state(vec![
8006 (("dm".to_string(), "Room".to_string()), from_room),
8007 (("dm".to_string(), "RoomMembers".to_string()), from_through),
8008 ]);
8009
8010 let mut to_room =
8015 build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
8016 to_room
8017 .many_to_many_fields
8018 .push(ManyToManyMetadata::new("members", "User"));
8019 let to_through = build_model_state_with_table_name(
8020 "dm",
8021 "DMRoomMembers",
8022 "dm_room_members",
8023 sample_fields(),
8024 );
8025 let to_state = build_project_state(vec![
8026 (("dm".to_string(), "DMRoom".to_string()), to_room),
8027 (("dm".to_string(), "DMRoomMembers".to_string()), to_through),
8028 ]);
8029
8030 let detector = MigrationAutodetector::new(from_state, to_state);
8031
8032 let changes = detector.detect_changes();
8034
8035 assert!(
8038 changes.created_many_to_many.is_empty(),
8039 "M2M through table already exists in from_state; expected no \
8040 created_many_to_many, got {:?}",
8041 changes.created_many_to_many
8042 );
8043 }
8044
8045 #[rstest]
8046 fn generate_migrations_resolves_unqualified_many_to_many_target_across_apps() {
8047 use super::super::Operation;
8048 use super::super::model_registry::ManyToManyMetadata;
8049 use super::super::operations::Constraint;
8050
8051 let auth_user =
8052 build_model_state_with_table_name("auth", "User", "auth_user", sample_fields());
8053 let mut dm_room =
8054 build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
8055 dm_room
8056 .many_to_many_fields
8057 .push(ManyToManyMetadata::new("members", "User"));
8058 let to_state = build_project_state(vec![
8059 (("auth".to_string(), "User".to_string()), auth_user),
8060 (("dm".to_string(), "DMRoom".to_string()), dm_room),
8061 ]);
8062 let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
8063
8064 let migrations = detector.generate_migrations();
8065 let dm_migration = migrations
8066 .iter()
8067 .find(|migration| migration.app_label == "dm")
8068 .expect("dm migration should be generated");
8069 let Operation::CreateTable {
8070 name, constraints, ..
8071 } = dm_migration
8072 .operations
8073 .iter()
8074 .find(|operation| {
8075 matches!(
8076 operation,
8077 Operation::CreateTable { name, .. } if name == "dm_room_members"
8078 )
8079 })
8080 .expect("dm_room_members through table should be generated")
8081 else {
8082 panic!("expected CreateTable operation");
8083 };
8084 assert_eq!(name, "dm_room_members");
8085
8086 let target_fk = constraints
8087 .iter()
8088 .find_map(|constraint| match constraint {
8089 Constraint::ForeignKey {
8090 columns,
8091 referenced_table,
8092 ..
8093 } if columns == &vec!["auth_user_id".to_string()] => Some(referenced_table),
8094 _ => None,
8095 })
8096 .expect("auth_user_id foreign key should be generated");
8097 assert_eq!(target_fk, "auth_user");
8098 }
8099
8100 #[rstest]
8101 fn detect_created_many_to_many_skips_existing_to_state_through_table() {
8102 use super::super::model_registry::ManyToManyMetadata;
8103
8104 let auth_user =
8105 build_model_state_with_table_name("auth", "User", "auth_user", sample_fields());
8106 let mut dm_room =
8107 build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
8108 dm_room
8109 .many_to_many_fields
8110 .push(ManyToManyMetadata::new("members", "User"));
8111 let dm_room_members = build_model_state_with_table_name(
8112 "dm",
8113 "DMRoomMembers",
8114 "dm_room_members",
8115 sample_fields(),
8116 );
8117 let to_state = build_project_state(vec![
8118 (("auth".to_string(), "User".to_string()), auth_user),
8119 (("dm".to_string(), "DMRoom".to_string()), dm_room),
8120 (
8121 ("dm".to_string(), "DMRoomMembers".to_string()),
8122 dm_room_members,
8123 ),
8124 ]);
8125 let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
8126
8127 let changes = detector.detect_changes();
8128
8129 assert!(
8130 changes.created_many_to_many.is_empty(),
8131 "to_state already contains the through table model; expected no \
8132 synthetic created_many_to_many, got {:?}",
8133 changes.created_many_to_many
8134 );
8135 }
8136
8137 #[rstest]
8138 fn detect_renamed_models_skips_struct_only_rename_with_same_table_name() {
8139 let from_model =
8141 build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
8142 let to_model =
8143 build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
8144
8145 let from_state = build_project_state(vec![(
8146 ("myapp".to_string(), "Clusters".to_string()),
8147 from_model,
8148 )]);
8149 let to_state = build_project_state(vec![(
8150 ("myapp".to_string(), "Cluster".to_string()),
8151 to_model,
8152 )]);
8153
8154 let detector = MigrationAutodetector::new(from_state, to_state);
8155
8156 let changes = detector.detect_changes();
8158
8159 assert!(
8161 changes.renamed_models.is_empty(),
8162 "struct-only rename with same table name should not produce renamed_models"
8163 );
8164 }
8165
8166 #[rstest]
8167 fn detect_renamed_models_detects_actual_table_rename() {
8168 let from_model =
8170 build_model_state_with_table_name("myapp", "OldModel", "old_table", sample_fields());
8171 let to_model =
8172 build_model_state_with_table_name("myapp", "NewModel", "new_table", sample_fields());
8173
8174 let from_state = build_project_state(vec![(
8175 ("myapp".to_string(), "OldModel".to_string()),
8176 from_model,
8177 )]);
8178 let to_state = build_project_state(vec![(
8179 ("myapp".to_string(), "NewModel".to_string()),
8180 to_model,
8181 )]);
8182
8183 let detector = MigrationAutodetector::new(from_state, to_state);
8184
8185 let changes = detector.detect_changes();
8187 let migrations = detector.generate_migrations();
8188
8189 assert_eq!(
8192 changes.renamed_models.len(),
8193 1,
8194 "actual table rename should be detected"
8195 );
8196 assert_eq!(changes.renamed_models[0].1, "OldModel");
8197 assert_eq!(changes.renamed_models[0].2, "NewModel");
8198 assert!(
8199 changes.created_models.is_empty(),
8200 "confirmed model rename must not leave created_models noise: {:?}",
8201 changes.created_models
8202 );
8203 assert!(
8204 changes.deleted_models.is_empty(),
8205 "confirmed model rename must not leave deleted_models noise: {:?}",
8206 changes.deleted_models
8207 );
8208 assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
8209 assert_eq!(migrations[0].app_label, "myapp");
8210 let operations = &migrations[0].operations;
8211 assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
8212 assert!(matches!(
8213 &operations[0],
8214 super::super::Operation::RenameTable { old_name, new_name }
8215 if old_name == "old_table" && new_name == "new_table"
8216 ));
8217 }
8218
8219 #[rstest]
8220 fn has_field_changed_ignores_non_schema_params() {
8221 let from_field = FieldState {
8223 name: "email".to_string(),
8224 field_type: super::super::FieldType::VarChar(255),
8225 nullable: false,
8226 params: std::collections::HashMap::new(),
8227 foreign_key: None,
8228 };
8229 let mut to_params = std::collections::HashMap::new();
8230 to_params.insert("max_length".to_string(), "255".to_string());
8231 to_params.insert("null".to_string(), "false".to_string());
8232 to_params.insert("blank".to_string(), "false".to_string());
8233 let to_field = FieldState {
8234 name: "email".to_string(),
8235 field_type: super::super::FieldType::VarChar(255),
8236 nullable: false,
8237 params: to_params,
8238 foreign_key: None,
8239 };
8240
8241 let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
8242
8243 let changed =
8245 detector.has_field_changed_with_unique("email", &from_field, &to_field, None, None);
8246
8247 assert!(
8249 !changed,
8250 "fields with identical schema but different non-schema params should not be detected as changed"
8251 );
8252 }
8253
8254 #[rstest]
8255 fn has_field_changed_detects_database_default_changes() {
8256 let from_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
8258 let mut to_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
8259 to_field
8260 .params
8261 .insert("default".to_string(), "true".to_string());
8262 let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
8263
8264 let changed =
8266 detector.has_field_changed_with_unique("is_active", &from_field, &to_field, None, None);
8267
8268 assert!(
8270 changed,
8271 "database default changes must be detected as schema-affecting field changes"
8272 );
8273 }
8274
8275 #[rstest]
8276 fn generate_operations_carries_old_definition_for_database_default_changes() {
8277 let mut from_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
8279 from_field
8280 .params
8281 .insert("default".to_string(), "true".to_string());
8282 let to_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
8283 let from_model =
8284 build_model_state("accounts", "User", vec![from_field], Vec::new(), Vec::new());
8285 let to_model =
8286 build_model_state("accounts", "User", vec![to_field], Vec::new(), Vec::new());
8287 let detector = MigrationAutodetector::new(
8288 build_project_state(vec![(
8289 ("accounts".to_string(), "User".to_string()),
8290 from_model,
8291 )]),
8292 build_project_state(vec![(
8293 ("accounts".to_string(), "User".to_string()),
8294 to_model,
8295 )]),
8296 );
8297
8298 let operations = detector.generate_operations();
8300
8301 let operation = operations
8303 .iter()
8304 .find(|operation| {
8305 matches!(
8306 operation,
8307 super::super::Operation::AlterColumn { column, .. } if column == "is_active"
8308 )
8309 })
8310 .expect("default removal should emit AlterColumn");
8311 let super::super::Operation::AlterColumn {
8312 old_definition,
8313 new_definition,
8314 ..
8315 } = operation
8316 else {
8317 unreachable!("matched AlterColumn above");
8318 };
8319 assert_eq!(
8320 old_definition
8321 .as_ref()
8322 .and_then(|definition| definition.default.as_deref()),
8323 Some("true")
8324 );
8325 assert_eq!(new_definition.default, None);
8326 }
8327
8328 #[rstest]
8329 fn generate_operations_empty_for_struct_only_rename() {
8330 let from_model =
8332 build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
8333 let to_model =
8334 build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
8335
8336 let from_state = build_project_state(vec![(
8337 ("myapp".to_string(), "Clusters".to_string()),
8338 from_model,
8339 )]);
8340 let to_state = build_project_state(vec![(
8341 ("myapp".to_string(), "Cluster".to_string()),
8342 to_model,
8343 )]);
8344
8345 let detector = MigrationAutodetector::new(from_state, to_state);
8346
8347 let operations = detector.generate_operations();
8349
8350 assert!(
8352 operations.is_empty(),
8353 "struct-only rename with same table name and identical fields should produce no operations, got: {:?}",
8354 operations
8355 );
8356 }
8357
8358 #[rstest]
8359 fn detect_composite_pk_added_emits_create_composite_primary_key() {
8360 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8362 let tenant_id_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
8363
8364 let from_model = build_model_state(
8365 "billing",
8366 "Invoice",
8367 vec![id_field.clone(), tenant_id_field.clone()],
8368 Vec::new(),
8369 Vec::new(),
8370 );
8371 let composite_pk = ConstraintDefinition {
8372 name: "billing_invoice_pkey".to_string(),
8373 constraint_type: "primary_key".to_string(),
8374 fields: vec!["id".to_string(), "tenant_id".to_string()],
8375 expression: None,
8376 foreign_key_info: None,
8377 };
8378 let to_model = build_model_state(
8379 "billing",
8380 "Invoice",
8381 vec![id_field, tenant_id_field],
8382 Vec::new(),
8383 vec![composite_pk],
8384 );
8385
8386 let from_state = build_project_state(vec![(
8387 ("billing".to_string(), "Invoice".to_string()),
8388 from_model,
8389 )]);
8390 let to_state = build_project_state(vec![(
8391 ("billing".to_string(), "Invoice".to_string()),
8392 to_model,
8393 )]);
8394 let detector = MigrationAutodetector::new(from_state, to_state);
8395
8396 let operations = detector.generate_operations();
8398
8399 assert_eq!(operations.len(), 1);
8401 assert!(
8402 matches!(
8403 &operations[0],
8404 super::super::Operation::CreateCompositePrimaryKey {
8405 table,
8406 columns,
8407 ..
8408 } if table == "billing_invoice"
8409 && columns == &["id".to_string(), "tenant_id".to_string()]
8410 ),
8411 "expected CreateCompositePrimaryKey, got: {:?}",
8412 operations
8413 );
8414 }
8415
8416 #[rstest]
8417 fn detect_composite_pk_unchanged_emits_no_operations() {
8418 let composite_pk = ConstraintDefinition {
8420 name: "billing_invoice_pkey".to_string(),
8421 constraint_type: "primary_key".to_string(),
8422 fields: vec!["id".to_string(), "tenant_id".to_string()],
8423 expression: None,
8424 foreign_key_info: None,
8425 };
8426 let from_model = build_model_state(
8427 "billing",
8428 "Invoice",
8429 vec![
8430 FieldState::new("id", super::super::FieldType::Integer, false),
8431 FieldState::new("tenant_id", super::super::FieldType::Integer, false),
8432 ],
8433 Vec::new(),
8434 vec![composite_pk.clone()],
8435 );
8436 let to_model = build_model_state(
8437 "billing",
8438 "Invoice",
8439 vec![
8440 FieldState::new("id", super::super::FieldType::Integer, false),
8441 FieldState::new("tenant_id", super::super::FieldType::Integer, false),
8442 ],
8443 Vec::new(),
8444 vec![composite_pk],
8445 );
8446
8447 let from_state = build_project_state(vec![(
8448 ("billing".to_string(), "Invoice".to_string()),
8449 from_model,
8450 )]);
8451 let to_state = build_project_state(vec![(
8452 ("billing".to_string(), "Invoice".to_string()),
8453 to_model,
8454 )]);
8455 let detector = MigrationAutodetector::new(from_state, to_state);
8456
8457 let operations = detector.generate_operations();
8459
8460 assert!(
8462 operations.is_empty(),
8463 "unchanged composite PK should produce no operations, got: {:?}",
8464 operations
8465 );
8466 }
8467
8468 #[rstest]
8469 fn detect_composite_pk_changed_fields_emits_drop_and_create() {
8470 let composite_pk_from = ConstraintDefinition {
8472 name: "billing_invoice_pkey".to_string(),
8473 constraint_type: "primary_key".to_string(),
8474 fields: vec!["id".to_string(), "tenant_id".to_string()],
8475 expression: None,
8476 foreign_key_info: None,
8477 };
8478 let composite_pk_to = ConstraintDefinition {
8479 name: "billing_invoice_pkey".to_string(),
8480 constraint_type: "primary_key".to_string(),
8481 fields: vec!["id".to_string(), "org_id".to_string()],
8482 expression: None,
8483 foreign_key_info: None,
8484 };
8485 let from_model = build_model_state(
8486 "billing",
8487 "Invoice",
8488 vec![
8489 FieldState::new("id", super::super::FieldType::Integer, false),
8490 FieldState::new("tenant_id", super::super::FieldType::Integer, false),
8491 ],
8492 Vec::new(),
8493 vec![composite_pk_from],
8494 );
8495 let to_model = build_model_state(
8496 "billing",
8497 "Invoice",
8498 vec![
8499 FieldState::new("id", super::super::FieldType::Integer, false),
8500 FieldState::new("org_id", super::super::FieldType::Integer, false),
8501 ],
8502 Vec::new(),
8503 vec![composite_pk_to],
8504 );
8505 let from_state = build_project_state(vec![(
8506 ("billing".to_string(), "Invoice".to_string()),
8507 from_model,
8508 )]);
8509 let to_state = build_project_state(vec![(
8510 ("billing".to_string(), "Invoice".to_string()),
8511 to_model,
8512 )]);
8513 let detector = MigrationAutodetector::new(from_state, to_state);
8514
8515 let operations = detector.generate_operations();
8517
8518 let drop_op = operations.iter().find(|op| {
8520 matches!(op, super::super::Operation::DropConstraint { constraint_name, .. }
8521 if constraint_name == "billing_invoice_pkey")
8522 });
8523 let create_op = operations.iter().find(|op| {
8524 matches!(op, super::super::Operation::CreateCompositePrimaryKey { columns, .. }
8525 if columns == &["id".to_string(), "org_id".to_string()])
8526 });
8527 assert!(
8528 drop_op.is_some(),
8529 "expected DropConstraint for modified composite PK, got: {:?}",
8530 operations
8531 );
8532 assert!(
8533 create_op.is_some(),
8534 "expected CreateCompositePrimaryKey with new fields, got: {:?}",
8535 operations
8536 );
8537 }
8538
8539 #[rstest]
8540 fn detect_sequence_reset_emits_set_auto_increment_value() {
8541 let mut id_field = FieldState::new("id", super::super::FieldType::BigInteger, false);
8543 id_field
8544 .params
8545 .insert("auto_increment".to_string(), "true".to_string());
8546
8547 let from_model = build_model_state(
8548 "shop",
8549 "Order",
8550 vec![id_field.clone()],
8551 Vec::new(),
8552 Vec::new(),
8553 );
8554 let mut to_model =
8555 build_model_state("shop", "Order", vec![id_field], Vec::new(), Vec::new());
8556 to_model
8557 .options
8558 .insert("sequence_reset".to_string(), "1000".to_string());
8559
8560 let from_state = build_project_state(vec![(
8561 ("shop".to_string(), "Order".to_string()),
8562 from_model,
8563 )]);
8564 let to_state =
8565 build_project_state(vec![(("shop".to_string(), "Order".to_string()), to_model)]);
8566 let detector = MigrationAutodetector::new(from_state, to_state);
8567
8568 let operations = detector.generate_operations();
8570
8571 assert_eq!(operations.len(), 1);
8573 assert!(
8574 matches!(
8575 &operations[0],
8576 super::super::Operation::SetAutoIncrementValue {
8577 table,
8578 column,
8579 value,
8580 } if table == "shop_order" && column == "id" && *value == 1000
8581 ),
8582 "expected SetAutoIncrementValue, got: {:?}",
8583 operations
8584 );
8585 }
8586
8587 #[rstest]
8588 fn detect_added_unique_together_emits_add_constraint() {
8589 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8593 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
8594 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
8595
8596 let from_model = build_model_state(
8597 "clusters",
8598 "Cluster",
8599 vec![id_field.clone(), org_field.clone(), name_field.clone()],
8600 Vec::new(),
8601 Vec::new(),
8602 );
8603 let unique_constraint = ConstraintDefinition {
8604 name: "clusters_cluster_organization_id_name_uniq".to_string(),
8605 constraint_type: "unique".to_string(),
8606 fields: vec!["organization_id".to_string(), "name".to_string()],
8607 expression: None,
8608 foreign_key_info: None,
8609 };
8610 let to_model = build_model_state(
8611 "clusters",
8612 "Cluster",
8613 vec![id_field, org_field, name_field],
8614 Vec::new(),
8615 vec![unique_constraint],
8616 );
8617
8618 let from_state = build_project_state(vec![(
8619 ("clusters".to_string(), "Cluster".to_string()),
8620 from_model,
8621 )]);
8622 let to_state = build_project_state(vec![(
8623 ("clusters".to_string(), "Cluster".to_string()),
8624 to_model,
8625 )]);
8626 let detector = MigrationAutodetector::new(from_state, to_state);
8627
8628 let operations = detector.generate_operations();
8630
8631 assert_eq!(
8634 operations.len(),
8635 1,
8636 "expected exactly one AddConstraint operation, got: {:?}",
8637 operations
8638 );
8639 let super::super::Operation::AddConstraint {
8640 table,
8641 constraint_sql,
8642 } = &operations[0]
8643 else {
8644 panic!(
8645 "expected Operation::AddConstraint, got: {:?}",
8646 operations[0]
8647 );
8648 };
8649 assert_eq!(table, "clusters_cluster");
8650 assert!(
8651 constraint_sql.contains("UNIQUE"),
8652 "constraint SQL should declare UNIQUE, got: {}",
8653 constraint_sql
8654 );
8655 assert!(
8656 constraint_sql.contains("organization_id"),
8657 "constraint SQL should reference organization_id, got: {}",
8658 constraint_sql
8659 );
8660 assert!(
8661 constraint_sql.contains("name"),
8662 "constraint SQL should reference name, got: {}",
8663 constraint_sql
8664 );
8665 assert!(
8666 constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
8667 "constraint SQL should carry the constraint name, got: {}",
8668 constraint_sql
8669 );
8670 }
8671
8672 #[rstest]
8673 fn detect_removed_unique_together_emits_drop_constraint() {
8674 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8678 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
8679 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
8680
8681 let unique_constraint = ConstraintDefinition {
8682 name: "clusters_cluster_organization_id_name_uniq".to_string(),
8683 constraint_type: "unique".to_string(),
8684 fields: vec!["organization_id".to_string(), "name".to_string()],
8685 expression: None,
8686 foreign_key_info: None,
8687 };
8688 let from_model = build_model_state(
8689 "clusters",
8690 "Cluster",
8691 vec![id_field.clone(), org_field.clone(), name_field.clone()],
8692 Vec::new(),
8693 vec![unique_constraint],
8694 );
8695 let to_model = build_model_state(
8696 "clusters",
8697 "Cluster",
8698 vec![id_field, org_field, name_field],
8699 Vec::new(),
8700 Vec::new(),
8701 );
8702
8703 let from_state = build_project_state(vec![(
8704 ("clusters".to_string(), "Cluster".to_string()),
8705 from_model,
8706 )]);
8707 let to_state = build_project_state(vec![(
8708 ("clusters".to_string(), "Cluster".to_string()),
8709 to_model,
8710 )]);
8711 let detector = MigrationAutodetector::new(from_state, to_state);
8712
8713 let operations = detector.generate_operations();
8715
8716 assert_eq!(
8718 operations.len(),
8719 1,
8720 "expected exactly one DropConstraint operation, got: {:?}",
8721 operations
8722 );
8723 let super::super::Operation::DropConstraint {
8724 table,
8725 constraint_name,
8726 } = &operations[0]
8727 else {
8728 panic!(
8729 "expected Operation::DropConstraint, got: {:?}",
8730 operations[0]
8731 );
8732 };
8733 assert_eq!(table, "clusters_cluster");
8734 assert_eq!(
8735 constraint_name,
8736 "clusters_cluster_organization_id_name_uniq"
8737 );
8738 }
8739
8740 #[rstest]
8741 fn detect_added_unique_together_via_offline_reconstructed_from_state() {
8742 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8755 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
8756 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
8757
8758 let mut from_model = build_model_state(
8760 "clusters",
8761 "Clusters",
8762 vec![id_field.clone(), org_field.clone(), name_field.clone()],
8763 Vec::new(),
8764 Vec::new(),
8765 );
8766 from_model.table_name = "clusters_cluster".to_string();
8767
8768 let unique_constraint = ConstraintDefinition {
8770 name: "clusters_cluster_organization_id_name_uniq".to_string(),
8771 constraint_type: "unique".to_string(),
8772 fields: vec!["organization_id".to_string(), "name".to_string()],
8773 expression: None,
8774 foreign_key_info: None,
8775 };
8776 let to_model = build_model_state(
8777 "clusters",
8778 "Cluster",
8779 vec![id_field, org_field, name_field],
8780 Vec::new(),
8781 vec![unique_constraint],
8782 );
8783
8784 let from_state = build_project_state(vec![(
8785 ("clusters".to_string(), "Clusters".to_string()),
8786 from_model,
8787 )]);
8788 let to_state = build_project_state(vec![(
8789 ("clusters".to_string(), "Cluster".to_string()),
8790 to_model,
8791 )]);
8792 let detector = MigrationAutodetector::new(from_state, to_state);
8793
8794 let operations = detector.generate_operations();
8796
8797 assert_eq!(
8801 operations.len(),
8802 1,
8803 "expected exactly one AddConstraint operation, got: {:?}",
8804 operations
8805 );
8806 let super::super::Operation::AddConstraint {
8807 table,
8808 constraint_sql,
8809 } = &operations[0]
8810 else {
8811 panic!(
8812 "expected Operation::AddConstraint, got: {:?}",
8813 operations[0]
8814 );
8815 };
8816 assert_eq!(table, "clusters_cluster");
8817 assert!(
8818 constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
8819 "constraint SQL should carry the constraint name, got: {}",
8820 constraint_sql
8821 );
8822 }
8823
8824 #[rstest]
8825 fn detect_removed_unique_together_via_offline_reconstructed_from_state() {
8826 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8832 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
8833 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
8834
8835 let unique_constraint = ConstraintDefinition {
8836 name: "clusters_cluster_organization_id_name_uniq".to_string(),
8837 constraint_type: "unique".to_string(),
8838 fields: vec!["organization_id".to_string(), "name".to_string()],
8839 expression: None,
8840 foreign_key_info: None,
8841 };
8842 let mut from_model = build_model_state(
8843 "clusters",
8844 "Clusters",
8845 vec![id_field.clone(), org_field.clone(), name_field.clone()],
8846 Vec::new(),
8847 vec![unique_constraint],
8848 );
8849 from_model.table_name = "clusters_cluster".to_string();
8850
8851 let to_model = build_model_state(
8852 "clusters",
8853 "Cluster",
8854 vec![id_field, org_field, name_field],
8855 Vec::new(),
8856 Vec::new(),
8857 );
8858
8859 let from_state = build_project_state(vec![(
8860 ("clusters".to_string(), "Clusters".to_string()),
8861 from_model,
8862 )]);
8863 let to_state = build_project_state(vec![(
8864 ("clusters".to_string(), "Cluster".to_string()),
8865 to_model,
8866 )]);
8867 let detector = MigrationAutodetector::new(from_state, to_state);
8868
8869 let operations = detector.generate_operations();
8871
8872 assert_eq!(
8874 operations.len(),
8875 1,
8876 "expected exactly one DropConstraint operation, got: {:?}",
8877 operations
8878 );
8879 let super::super::Operation::DropConstraint {
8880 table,
8881 constraint_name,
8882 } = &operations[0]
8883 else {
8884 panic!(
8885 "expected Operation::DropConstraint, got: {:?}",
8886 operations[0]
8887 );
8888 };
8889 assert_eq!(table, "clusters_cluster");
8890 assert_eq!(
8891 constraint_name,
8892 "clusters_cluster_organization_id_name_uniq"
8893 );
8894 }
8895
8896 #[rstest]
8897 fn has_field_changed_ignores_param_population_skew() {
8898 let mut from_params = std::collections::HashMap::new();
8911 from_params.insert("primary_key".to_string(), "true".to_string());
8912 from_params.insert("auto_increment".to_string(), "true".to_string());
8913 let from_field = FieldState {
8914 name: "id".to_string(),
8915 field_type: super::super::FieldType::BigInteger,
8916 nullable: false,
8917 params: from_params,
8918 foreign_key: None,
8919 };
8920
8921 let mut to_params = std::collections::HashMap::new();
8929 to_params.insert("primary_key".to_string(), "true".to_string());
8930 to_params.insert("auto_increment".to_string(), "true".to_string());
8931 to_params.insert("not_null".to_string(), "true".to_string());
8932 to_params.insert("null".to_string(), "false".to_string());
8933 to_params.insert("unique".to_string(), "false".to_string());
8934 let to_field = FieldState {
8935 name: "id".to_string(),
8936 field_type: super::super::FieldType::BigInteger,
8937 nullable: false,
8938 params: to_params,
8939 foreign_key: None,
8940 };
8941
8942 let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
8943
8944 let changed =
8946 detector.has_field_changed_with_unique("id", &from_field, &to_field, None, None);
8947
8948 assert!(
8951 !changed,
8952 "identical schema with asymmetric param populations between migration replay and macro registry must not be detected as changed"
8953 );
8954 }
8955
8956 #[rstest]
8957 fn generate_operations_no_spurious_altercolumn_for_pk_via_offline_reconstructed_state() {
8958 let mut from_id_params = std::collections::HashMap::new();
8972 from_id_params.insert("primary_key".to_string(), "true".to_string());
8973 from_id_params.insert("auto_increment".to_string(), "true".to_string());
8974 let from_id_field = FieldState {
8975 name: "id".to_string(),
8976 field_type: super::super::FieldType::BigInteger,
8977 nullable: false,
8978 params: from_id_params,
8979 foreign_key: None,
8980 };
8981 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
8982 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
8983
8984 let mut from_model = build_model_state(
8987 "clusters",
8988 "Clusters",
8989 vec![from_id_field, org_field.clone(), name_field.clone()],
8990 Vec::new(),
8991 Vec::new(),
8992 );
8993 from_model.table_name = "clusters_cluster".to_string();
8994
8995 let mut to_id_params = std::collections::HashMap::new();
9002 to_id_params.insert("primary_key".to_string(), "true".to_string());
9003 to_id_params.insert("auto_increment".to_string(), "true".to_string());
9004 to_id_params.insert("not_null".to_string(), "true".to_string());
9005 to_id_params.insert("null".to_string(), "false".to_string());
9006 to_id_params.insert("unique".to_string(), "false".to_string());
9007 let to_id_field = FieldState {
9008 name: "id".to_string(),
9009 field_type: super::super::FieldType::BigInteger,
9010 nullable: false,
9011 params: to_id_params,
9012 foreign_key: None,
9013 };
9014 let unique_constraint = ConstraintDefinition {
9015 name: "clusters_cluster_organization_id_name_uniq".to_string(),
9016 constraint_type: "unique".to_string(),
9017 fields: vec!["organization_id".to_string(), "name".to_string()],
9018 expression: None,
9019 foreign_key_info: None,
9020 };
9021 let to_model = build_model_state(
9022 "clusters",
9023 "Cluster",
9024 vec![to_id_field, org_field, name_field],
9025 Vec::new(),
9026 vec![unique_constraint],
9027 );
9028
9029 let from_state = build_project_state(vec![(
9030 ("clusters".to_string(), "Clusters".to_string()),
9031 from_model,
9032 )]);
9033 let to_state = build_project_state(vec![(
9034 ("clusters".to_string(), "Cluster".to_string()),
9035 to_model,
9036 )]);
9037 let detector = MigrationAutodetector::new(from_state, to_state);
9038
9039 let operations = detector.generate_operations();
9041
9042 assert!(
9046 !operations
9047 .iter()
9048 .any(|op| matches!(op, super::super::Operation::AlterColumn { .. })),
9049 "no AlterColumn must be emitted for unchanged PK under offline state reconstruction, got: {:?}",
9050 operations
9051 );
9052 assert_eq!(
9053 operations.len(),
9054 1,
9055 "expected exactly one AddConstraint operation, got: {:?}",
9056 operations
9057 );
9058 assert!(
9059 matches!(
9060 &operations[0],
9061 super::super::Operation::AddConstraint { .. }
9062 ),
9063 "expected the single operation to be AddConstraint, got: {:?}",
9064 operations[0]
9065 );
9066 }
9067
9068 #[rstest]
9069 fn generate_operations_no_spurious_altercolumn_for_option_pk_via_apply_migration_operations() {
9070 let mut id_meta =
9101 super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger);
9102 id_meta = id_meta
9103 .with_param("primary_key", "true")
9104 .with_param("auto_increment", "true")
9105 .with_param("not_null", "true")
9106 .with_nullable(false);
9107 let mut name_meta =
9108 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255));
9109 name_meta = name_meta
9110 .with_param("max_length", "255")
9111 .with_param("not_null", "true")
9112 .with_nullable(false);
9113
9114 let mut metadata =
9115 super::super::model_registry::ModelMetadata::new("clusters", "Cluster", "clusters");
9116 metadata.add_field("id".to_string(), id_meta);
9117 metadata.add_field("name".to_string(), name_meta);
9118
9119 let to_model = metadata.to_model_state();
9120 let to_id = to_model.fields.get("id").expect("id field present");
9124 assert!(
9125 !to_id.nullable,
9126 "to_state PK FieldState.nullable must be false; got nullable=true \
9127 with params={:?}. Did the #[model] macro regress to emitting \
9128 null=\"true\" for Option<T> PKs?",
9129 to_id.params
9130 );
9131
9132 let to_state = build_project_state(vec![(
9133 ("clusters".to_string(), "Cluster".to_string()),
9134 to_model,
9135 )]);
9136
9137 let create_clusters = super::super::Operation::CreateTable {
9142 name: "clusters".to_string(),
9143 columns: vec![
9144 super::super::ColumnDefinition {
9145 name: "id".to_string(),
9146 type_definition: super::super::FieldType::BigInteger,
9147 not_null: true,
9148 unique: false,
9149 primary_key: true,
9150 auto_increment: true,
9151 default: None,
9152 },
9153 super::super::ColumnDefinition {
9154 name: "name".to_string(),
9155 type_definition: super::super::FieldType::VarChar(255),
9156 not_null: true,
9157 unique: false,
9158 primary_key: false,
9159 auto_increment: false,
9160 default: None,
9161 },
9162 ],
9163 constraints: vec![],
9164 without_rowid: None,
9165 interleave_in_parent: None,
9166 partition: None,
9167 };
9168 let mut from_state = ProjectState::new();
9169 from_state.apply_migration_operations(&[create_clusters], "clusters");
9170
9171 let from_clusters = from_state
9174 .find_model_by_table("clusters")
9175 .expect("clusters model present in from_state");
9176 assert!(
9177 !from_clusters
9178 .fields
9179 .get("id")
9180 .expect("id field in from_state")
9181 .nullable,
9182 "from_state PK FieldState.nullable must be false (column_def_to_field_state derives \
9183 from not_null); got nullable=true"
9184 );
9185
9186 let detector = MigrationAutodetector::new(from_state, to_state);
9187
9188 let direct_ops = detector.generate_operations();
9192 let migrations = detector.generate_migrations();
9193 let migration_ops: Vec<&super::super::Operation> = migrations
9194 .iter()
9195 .flat_map(|m| m.operations.iter())
9196 .collect();
9197
9198 assert!(
9201 !direct_ops.iter().any(|op| matches!(
9202 op,
9203 super::super::Operation::AlterColumn { column, .. } if column == "id"
9204 )),
9205 "generate_operations() emitted spurious AlterColumn for unchanged `id` PK \
9206 under apply_migration_operations from_state. ops={:?}",
9207 direct_ops
9208 );
9209 assert!(
9210 !migration_ops.iter().any(|op| matches!(
9211 op,
9212 super::super::Operation::AlterColumn { column, .. } if column == "id"
9213 )),
9214 "generate_migrations() emitted spurious AlterColumn for unchanged `id` PK \
9215 under apply_migration_operations from_state. ops={:?}",
9216 migration_ops
9217 );
9218 }
9219
9220 #[rstest]
9221 fn generate_operations_no_spurious_altercolumn_for_replayed_foreign_key_column() {
9222 let mut target_metadata = super::super::model_registry::ModelMetadata::new(
9232 "fk_drift_target_app",
9233 "FkDriftTarget",
9234 "fk_drift_targets",
9235 );
9236 target_metadata.add_field(
9237 "id".to_string(),
9238 super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger)
9239 .with_param("primary_key", "true")
9240 .with_param("auto_increment", "true")
9241 .with_param("not_null", "true")
9242 .with_nullable(false),
9243 );
9244 super::super::model_registry::global_registry().register_model(target_metadata);
9245
9246 let create_sources = super::super::Operation::CreateTable {
9247 name: "fk_drift_sources".to_string(),
9248 columns: vec![
9249 super::super::ColumnDefinition {
9250 name: "id".to_string(),
9251 type_definition: super::super::FieldType::BigInteger,
9252 not_null: true,
9253 unique: false,
9254 primary_key: true,
9255 auto_increment: true,
9256 default: None,
9257 },
9258 super::super::ColumnDefinition {
9259 name: "target_id".to_string(),
9260 type_definition: super::super::FieldType::BigInteger,
9261 not_null: true,
9262 unique: false,
9263 primary_key: false,
9264 auto_increment: false,
9265 default: None,
9266 },
9267 ],
9268 constraints: vec![],
9269 without_rowid: None,
9270 interleave_in_parent: None,
9271 partition: None,
9272 };
9273 let mut from_state = ProjectState::new();
9274 from_state.apply_migration_operations(&[create_sources], "fk_drift_source_app");
9275
9276 let mut source_metadata = super::super::model_registry::ModelMetadata::new(
9277 "fk_drift_source_app",
9278 "FkDriftSource",
9279 "fk_drift_sources",
9280 );
9281 source_metadata.add_field(
9282 "id".to_string(),
9283 super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger)
9284 .with_param("primary_key", "true")
9285 .with_param("auto_increment", "true")
9286 .with_param("not_null", "true")
9287 .with_nullable(false),
9288 );
9289 source_metadata.add_field(
9290 "target_id".to_string(),
9291 super::super::model_registry::FieldMetadata::new(super::super::FieldType::Uuid)
9292 .with_param("fk_target", "FkDriftTarget")
9293 .with_param("fk_target_app", "fk_drift_target_app")
9294 .with_param("not_null", "true")
9295 .with_nullable(false),
9296 );
9297 let to_state = build_project_state(vec![(
9298 (
9299 "fk_drift_source_app".to_string(),
9300 "FkDriftSource".to_string(),
9301 ),
9302 source_metadata.to_model_state(),
9303 )]);
9304 let detector = MigrationAutodetector::new(from_state, to_state);
9305
9306 let operations = detector.generate_operations();
9308
9309 assert!(
9311 !operations.iter().any(|op| matches!(
9312 op,
9313 super::super::Operation::AlterColumn { column, .. } if column == "target_id"
9314 )),
9315 "unchanged FK _id column must not emit no-op AlterColumn, got: {:?}",
9316 operations
9317 );
9318 assert!(
9319 operations.is_empty(),
9320 "replayed FK column should be in sync with registry state, got: {:?}",
9321 operations
9322 );
9323 }
9324
9325 #[rstest]
9326 fn generate_operations_no_spurious_drift_for_replayed_auth_schema() {
9327 let create_auth_users = super::super::Operation::CreateTable {
9337 name: "auth_users".to_string(),
9338 columns: vec![
9339 super::super::ColumnDefinition {
9340 name: "id".to_string(),
9341 type_definition: super::super::FieldType::Uuid,
9342 not_null: true,
9343 unique: false,
9344 primary_key: true,
9345 auto_increment: false,
9346 default: None,
9347 },
9348 super::super::ColumnDefinition {
9349 name: "username".to_string(),
9350 type_definition: super::super::FieldType::VarChar(150),
9351 not_null: true,
9352 unique: true,
9353 primary_key: false,
9354 auto_increment: false,
9355 default: None,
9356 },
9357 super::super::ColumnDefinition {
9358 name: "email".to_string(),
9359 type_definition: super::super::FieldType::VarChar(254),
9360 not_null: true,
9361 unique: false,
9362 primary_key: false,
9363 auto_increment: false,
9364 default: None,
9365 },
9366 super::super::ColumnDefinition {
9367 name: "first_name".to_string(),
9368 type_definition: super::super::FieldType::VarChar(150),
9369 not_null: true,
9370 unique: false,
9371 primary_key: false,
9372 auto_increment: false,
9373 default: Some("''".to_string()),
9374 },
9375 super::super::ColumnDefinition {
9376 name: "last_name".to_string(),
9377 type_definition: super::super::FieldType::VarChar(150),
9378 not_null: true,
9379 unique: false,
9380 primary_key: false,
9381 auto_increment: false,
9382 default: Some("''".to_string()),
9383 },
9384 super::super::ColumnDefinition {
9385 name: "is_active".to_string(),
9386 type_definition: super::super::FieldType::Boolean,
9387 not_null: true,
9388 unique: false,
9389 primary_key: false,
9390 auto_increment: false,
9391 default: Some("true".to_string()),
9392 },
9393 super::super::ColumnDefinition {
9394 name: "is_staff".to_string(),
9395 type_definition: super::super::FieldType::Boolean,
9396 not_null: true,
9397 unique: false,
9398 primary_key: false,
9399 auto_increment: false,
9400 default: Some("false".to_string()),
9401 },
9402 super::super::ColumnDefinition {
9403 name: "is_superuser".to_string(),
9404 type_definition: super::super::FieldType::Boolean,
9405 not_null: true,
9406 unique: false,
9407 primary_key: false,
9408 auto_increment: false,
9409 default: Some("false".to_string()),
9410 },
9411 ],
9412 constraints: vec![super::super::operations::Constraint::Unique {
9413 name: "auth_user_username_uniq".to_string(),
9414 columns: vec!["username".to_string()],
9415 }],
9416 without_rowid: None,
9417 interleave_in_parent: None,
9418 partition: None,
9419 };
9420 let add_email_unique = super::super::Operation::AddConstraint {
9421 table: "auth_users".to_string(),
9422 constraint_sql: "CONSTRAINT auth_user_email_uniq UNIQUE (email)".to_string(),
9423 };
9424 let create_auth_permission = super::super::Operation::CreateTable {
9425 name: "auth_permission".to_string(),
9426 columns: vec![
9427 super::super::ColumnDefinition {
9428 name: "id".to_string(),
9429 type_definition: super::super::FieldType::Uuid,
9430 not_null: true,
9431 unique: false,
9432 primary_key: true,
9433 auto_increment: true,
9434 default: None,
9435 },
9436 super::super::ColumnDefinition {
9437 name: "name".to_string(),
9438 type_definition: super::super::FieldType::VarChar(255),
9439 not_null: true,
9440 unique: false,
9441 primary_key: false,
9442 auto_increment: false,
9443 default: None,
9444 },
9445 ],
9446 constraints: vec![],
9447 without_rowid: None,
9448 interleave_in_parent: None,
9449 partition: None,
9450 };
9451
9452 let mut from_state = ProjectState::new();
9453 from_state.apply_migration_operations(
9454 &[create_auth_users, add_email_unique, create_auth_permission],
9455 "auth",
9456 );
9457
9458 let mut user_metadata =
9459 super::super::model_registry::ModelMetadata::new("auth", "User", "auth_users");
9460 user_metadata.add_field(
9461 "id".to_string(),
9462 super::super::model_registry::FieldMetadata::new(super::super::FieldType::Uuid)
9463 .with_param("primary_key", "true")
9464 .with_param("not_null", "true")
9465 .with_nullable(false),
9466 );
9467 user_metadata.add_field(
9468 "username".to_string(),
9469 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(150))
9470 .with_param("max_length", "150")
9471 .with_param("unique", "true")
9472 .with_param("not_null", "true")
9473 .with_nullable(false),
9474 );
9475 user_metadata.add_field(
9476 "email".to_string(),
9477 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(254))
9478 .with_param("max_length", "254")
9479 .with_param("unique", "true")
9480 .with_param("not_null", "true")
9481 .with_nullable(false),
9482 );
9483 user_metadata.add_field(
9484 "first_name".to_string(),
9485 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(150))
9486 .with_param("max_length", "150")
9487 .with_param("default", "''")
9488 .with_param("not_null", "true")
9489 .with_nullable(false),
9490 );
9491 user_metadata.add_field(
9492 "last_name".to_string(),
9493 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(150))
9494 .with_param("max_length", "150")
9495 .with_param("default", "''")
9496 .with_param("not_null", "true")
9497 .with_nullable(false),
9498 );
9499 user_metadata.add_field(
9500 "is_active".to_string(),
9501 super::super::model_registry::FieldMetadata::new(super::super::FieldType::Boolean)
9502 .with_param("default", "true")
9503 .with_param("not_null", "true")
9504 .with_nullable(false),
9505 );
9506 user_metadata.add_field(
9507 "is_staff".to_string(),
9508 super::super::model_registry::FieldMetadata::new(super::super::FieldType::Boolean)
9509 .with_param("default", "false")
9510 .with_param("not_null", "true")
9511 .with_nullable(false),
9512 );
9513 user_metadata.add_field(
9514 "is_superuser".to_string(),
9515 super::super::model_registry::FieldMetadata::new(super::super::FieldType::Boolean)
9516 .with_param("default", "false")
9517 .with_param("not_null", "true")
9518 .with_nullable(false),
9519 );
9520
9521 let mut permission_metadata = super::super::model_registry::ModelMetadata::new(
9522 "auth",
9523 "AuthPermission",
9524 "auth_permission",
9525 );
9526 permission_metadata.add_field(
9527 "id".to_string(),
9528 super::super::model_registry::FieldMetadata::new(super::super::FieldType::Uuid)
9529 .with_param("primary_key", "true")
9530 .with_param("not_null", "true")
9531 .with_nullable(false),
9532 );
9533 permission_metadata.add_field(
9534 "name".to_string(),
9535 super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255))
9536 .with_param("max_length", "255")
9537 .with_param("not_null", "true")
9538 .with_nullable(false),
9539 );
9540
9541 let to_state = build_project_state(vec![
9542 (
9543 ("auth".to_string(), "User".to_string()),
9544 user_metadata.to_model_state(),
9545 ),
9546 (
9547 ("auth".to_string(), "AuthPermission".to_string()),
9548 permission_metadata.to_model_state(),
9549 ),
9550 ]);
9551 let detector = MigrationAutodetector::new(from_state, to_state);
9552
9553 let operations = detector.generate_operations();
9555
9556 assert!(
9558 operations.is_empty(),
9559 "replayed auth schema should be in sync with registry state, got: {:?}",
9560 operations
9561 );
9562 }
9563
9564 #[rstest]
9565 fn generate_migrations_emits_add_constraint_for_added_unique_together() {
9566 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
9579 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
9580 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
9581
9582 let mut from_model = build_model_state(
9586 "clusters",
9587 "Cluster",
9588 vec![id_field.clone(), org_field.clone(), name_field.clone()],
9589 Vec::new(),
9590 Vec::new(),
9591 );
9592 from_model.table_name = "clusters_cluster".to_string();
9593
9594 let unique_constraint = ConstraintDefinition {
9597 name: "clusters_cluster_organization_id_name_uniq".to_string(),
9598 constraint_type: "unique".to_string(),
9599 fields: vec!["organization_id".to_string(), "name".to_string()],
9600 expression: None,
9601 foreign_key_info: None,
9602 };
9603 let mut to_model = build_model_state(
9604 "clusters",
9605 "Cluster",
9606 vec![id_field, org_field, name_field],
9607 Vec::new(),
9608 vec![unique_constraint],
9609 );
9610 to_model.table_name = "clusters_cluster".to_string();
9611
9612 let from_state = build_project_state(vec![(
9613 ("clusters".to_string(), "Cluster".to_string()),
9614 from_model,
9615 )]);
9616 let to_state = build_project_state(vec![(
9617 ("clusters".to_string(), "Cluster".to_string()),
9618 to_model,
9619 )]);
9620 let detector = MigrationAutodetector::new(from_state, to_state);
9621
9622 let migrations = detector.generate_migrations();
9624
9625 assert_eq!(
9628 migrations.len(),
9629 1,
9630 "expected exactly one Migration, got: {:?}",
9631 migrations
9632 );
9633 assert_eq!(migrations[0].app_label, "clusters");
9634 assert_eq!(
9635 migrations[0].operations.len(),
9636 1,
9637 "expected exactly one operation in the migration, got: {:?}",
9638 migrations[0].operations
9639 );
9640 let super::super::Operation::AddConstraint {
9641 table,
9642 constraint_sql,
9643 } = &migrations[0].operations[0]
9644 else {
9645 panic!(
9646 "expected Operation::AddConstraint, got: {:?}",
9647 migrations[0].operations[0]
9648 );
9649 };
9650 assert_eq!(table, "clusters_cluster");
9651 assert!(
9652 constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
9653 "constraint SQL should carry the constraint name, got: {}",
9654 constraint_sql
9655 );
9656 }
9657
9658 #[rstest]
9659 fn generate_migrations_emits_drop_constraint_for_removed_unique_together() {
9660 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
9665 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
9666 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
9667
9668 let unique_constraint = ConstraintDefinition {
9669 name: "clusters_cluster_organization_id_name_uniq".to_string(),
9670 constraint_type: "unique".to_string(),
9671 fields: vec!["organization_id".to_string(), "name".to_string()],
9672 expression: None,
9673 foreign_key_info: None,
9674 };
9675 let mut from_model = build_model_state(
9676 "clusters",
9677 "Cluster",
9678 vec![id_field.clone(), org_field.clone(), name_field.clone()],
9679 Vec::new(),
9680 vec![unique_constraint],
9681 );
9682 from_model.table_name = "clusters_cluster".to_string();
9683
9684 let mut to_model = build_model_state(
9685 "clusters",
9686 "Cluster",
9687 vec![id_field, org_field, name_field],
9688 Vec::new(),
9689 Vec::new(),
9690 );
9691 to_model.table_name = "clusters_cluster".to_string();
9692
9693 let from_state = build_project_state(vec![(
9694 ("clusters".to_string(), "Cluster".to_string()),
9695 from_model,
9696 )]);
9697 let to_state = build_project_state(vec![(
9698 ("clusters".to_string(), "Cluster".to_string()),
9699 to_model,
9700 )]);
9701 let detector = MigrationAutodetector::new(from_state, to_state);
9702
9703 let migrations = detector.generate_migrations();
9705
9706 assert_eq!(
9708 migrations.len(),
9709 1,
9710 "expected exactly one Migration, got: {:?}",
9711 migrations
9712 );
9713 assert_eq!(migrations[0].app_label, "clusters");
9714 assert_eq!(
9715 migrations[0].operations.len(),
9716 1,
9717 "expected exactly one operation in the migration, got: {:?}",
9718 migrations[0].operations
9719 );
9720 let super::super::Operation::DropConstraint {
9721 table,
9722 constraint_name,
9723 } = &migrations[0].operations[0]
9724 else {
9725 panic!(
9726 "expected Operation::DropConstraint, got: {:?}",
9727 migrations[0].operations[0]
9728 );
9729 };
9730 assert_eq!(table, "clusters_cluster");
9731 assert_eq!(
9732 constraint_name,
9733 "clusters_cluster_organization_id_name_uniq"
9734 );
9735 }
9736
9737 #[rstest]
9738 fn shared_per_app_emissions_are_consistent_between_generate_paths() {
9739 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
9755 let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
9756 let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
9757 let new_col = FieldState::new("region", super::super::FieldType::VarChar(64), false);
9758
9759 let mut from_model = build_model_state(
9760 "clusters",
9761 "Cluster",
9762 vec![id_field.clone(), org_field.clone(), name_field.clone()],
9763 Vec::new(),
9764 Vec::new(),
9765 );
9766 from_model.table_name = "clusters_cluster".to_string();
9767
9768 let unique_constraint = ConstraintDefinition {
9769 name: "clusters_cluster_organization_id_name_uniq".to_string(),
9770 constraint_type: "unique".to_string(),
9771 fields: vec!["organization_id".to_string(), "name".to_string()],
9772 expression: None,
9773 foreign_key_info: None,
9774 };
9775 let mut to_model = build_model_state(
9776 "clusters",
9777 "Cluster",
9778 vec![id_field, org_field, name_field, new_col],
9779 Vec::new(),
9780 vec![unique_constraint],
9781 );
9782 to_model.table_name = "clusters_cluster".to_string();
9783
9784 let from_state = build_project_state(vec![(
9785 ("clusters".to_string(), "Cluster".to_string()),
9786 from_model,
9787 )]);
9788 let to_state = build_project_state(vec![(
9789 ("clusters".to_string(), "Cluster".to_string()),
9790 to_model,
9791 )]);
9792 let detector = MigrationAutodetector::new(from_state, to_state);
9793
9794 let ops = detector.generate_operations();
9796 let migrations = detector.generate_migrations();
9797
9798 let mig_ops: Vec<&super::super::Operation> = migrations
9803 .iter()
9804 .flat_map(|m| m.operations.iter())
9805 .collect();
9806
9807 assert_eq!(
9808 ops.len(),
9809 mig_ops.len(),
9810 "shared per-app emissions diverged between generate_operations() ({:?}) and generate_migrations() ({:?})",
9811 ops,
9812 mig_ops
9813 );
9814 for op in &ops {
9817 assert!(
9818 mig_ops.iter().any(|m| *m == op),
9819 "generate_operations() produced {:?} but generate_migrations() did not",
9820 op
9821 );
9822 }
9823 for op in &mig_ops {
9825 assert!(
9826 ops.iter().any(|o| o == *op),
9827 "generate_migrations() produced {:?} but generate_operations() did not",
9828 op
9829 );
9830 }
9831 }
9832
9833 #[rstest]
9834 fn detect_added_composite_pk_does_not_double_emit_add_constraint() {
9835 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
9840 let tenant_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
9841
9842 let from_model = build_model_state(
9843 "billing",
9844 "Invoice",
9845 vec![id_field.clone(), tenant_field.clone()],
9846 Vec::new(),
9847 Vec::new(),
9848 );
9849 let composite_pk = ConstraintDefinition {
9850 name: "billing_invoice_pkey".to_string(),
9851 constraint_type: "primary_key".to_string(),
9852 fields: vec!["id".to_string(), "tenant_id".to_string()],
9853 expression: None,
9854 foreign_key_info: None,
9855 };
9856 let to_model = build_model_state(
9857 "billing",
9858 "Invoice",
9859 vec![id_field, tenant_field],
9860 Vec::new(),
9861 vec![composite_pk],
9862 );
9863 let from_state = build_project_state(vec![(
9864 ("billing".to_string(), "Invoice".to_string()),
9865 from_model,
9866 )]);
9867 let to_state = build_project_state(vec![(
9868 ("billing".to_string(), "Invoice".to_string()),
9869 to_model,
9870 )]);
9871 let detector = MigrationAutodetector::new(from_state, to_state);
9872
9873 let operations = detector.generate_operations();
9875
9876 assert_eq!(operations.len(), 1, "got: {:?}", operations);
9879 assert!(
9880 matches!(
9881 &operations[0],
9882 super::super::Operation::CreateCompositePrimaryKey { columns, .. }
9883 if columns == &["id".to_string(), "tenant_id".to_string()]
9884 ),
9885 "expected only CreateCompositePrimaryKey, got: {:?}",
9886 operations
9887 );
9888 }
9889
9890 #[rstest]
9898 fn inline_unique_param_on_from_side_does_not_emit_redundant_add_constraint() {
9899 let mut username_field =
9903 FieldState::new("username", super::super::FieldType::VarChar(150), false);
9904 username_field
9905 .params
9906 .insert("unique".to_string(), "true".to_string());
9907 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
9908 let from_model = build_model_state(
9909 "users",
9910 "User",
9911 vec![id_field.clone(), username_field.clone()],
9912 Vec::new(),
9913 Vec::new(),
9914 );
9915
9916 let synthesised = ConstraintDefinition {
9921 name: "users_user_username_uniq".to_string(),
9922 constraint_type: "unique".to_string(),
9923 fields: vec!["username".to_string()],
9924 expression: None,
9925 foreign_key_info: None,
9926 };
9927 let to_model = build_model_state(
9928 "users",
9929 "User",
9930 vec![id_field, username_field],
9931 Vec::new(),
9932 vec![synthesised],
9933 );
9934
9935 let from_state = build_project_state(vec![(
9936 ("users".to_string(), "User".to_string()),
9937 from_model,
9938 )]);
9939 let to_state =
9940 build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
9941 let detector = MigrationAutodetector::new(from_state, to_state);
9942
9943 let operations = detector.generate_operations();
9945
9946 assert!(
9949 operations
9950 .iter()
9951 .all(|op| !matches!(op, super::super::Operation::AddConstraint { .. })),
9952 "expected NO Operation::AddConstraint, got: {:?}",
9953 operations
9954 );
9955 }
9956
9957 #[rstest]
9965 fn single_field_unique_constraint_renames_do_not_emit_redundant_add_constraint() {
9966 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
9968 let username_field =
9969 FieldState::new("username", super::super::FieldType::VarChar(150), false);
9970 let auto_named = ConstraintDefinition {
9971 name: "sqlite_autoindex_users_1".to_string(),
9972 constraint_type: "unique".to_string(),
9973 fields: vec!["username".to_string()],
9974 expression: None,
9975 foreign_key_info: None,
9976 };
9977 let model_named = ConstraintDefinition {
9978 name: "users_user_username_uniq".to_string(),
9979 constraint_type: "unique".to_string(),
9980 fields: vec!["username".to_string()],
9981 expression: None,
9982 foreign_key_info: None,
9983 };
9984 let from_model = build_model_state(
9985 "users",
9986 "User",
9987 vec![id_field.clone(), username_field.clone()],
9988 Vec::new(),
9989 vec![auto_named],
9990 );
9991 let to_model = build_model_state(
9992 "users",
9993 "User",
9994 vec![id_field, username_field],
9995 Vec::new(),
9996 vec![model_named],
9997 );
9998 let from_state = build_project_state(vec![(
9999 ("users".to_string(), "User".to_string()),
10000 from_model,
10001 )]);
10002 let to_state =
10003 build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
10004 let detector = MigrationAutodetector::new(from_state, to_state);
10005
10006 let operations = detector.generate_operations();
10008
10009 let constraint_ops: Vec<_> = operations
10015 .iter()
10016 .filter(|op| {
10017 matches!(
10018 op,
10019 super::super::Operation::AddConstraint { .. }
10020 | super::super::Operation::DropConstraint { .. }
10021 )
10022 })
10023 .collect();
10024 assert!(
10025 constraint_ops.is_empty(),
10026 "expected no Add/DropConstraint ops, got: {:?}",
10027 constraint_ops
10028 );
10029 }
10030
10031 #[rstest]
10038 fn from_side_unique_constraint_matched_by_inline_unique_on_to_side_emits_no_drop() {
10039 let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
10041 let mut username_field =
10042 FieldState::new("username", super::super::FieldType::VarChar(150), false);
10043 username_field
10044 .params
10045 .insert("unique".to_string(), "true".to_string());
10046 let unique_constraint = ConstraintDefinition {
10047 name: "users_user_username_uniq".to_string(),
10048 constraint_type: "unique".to_string(),
10049 fields: vec!["username".to_string()],
10050 expression: None,
10051 foreign_key_info: None,
10052 };
10053 let bare_username =
10055 FieldState::new("username", super::super::FieldType::VarChar(150), false);
10056 let from_model = build_model_state(
10057 "users",
10058 "User",
10059 vec![id_field.clone(), bare_username],
10060 Vec::new(),
10061 vec![unique_constraint],
10062 );
10063 let to_model = build_model_state(
10065 "users",
10066 "User",
10067 vec![id_field, username_field],
10068 Vec::new(),
10069 Vec::new(),
10070 );
10071 let from_state = build_project_state(vec![(
10072 ("users".to_string(), "User".to_string()),
10073 from_model,
10074 )]);
10075 let to_state =
10076 build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
10077 let detector = MigrationAutodetector::new(from_state, to_state);
10078
10079 let operations = detector.generate_operations();
10081
10082 assert!(
10085 operations
10086 .iter()
10087 .all(|op| !matches!(op, super::super::Operation::DropConstraint { .. })),
10088 "expected NO Operation::DropConstraint, got: {:?}",
10089 operations
10090 );
10091 }
10092
10093 #[rstest]
10102 fn dedup_pass_drops_add_constraint_redundant_with_unique_add_column() {
10103 let ops = vec![
10105 super::super::Operation::AddColumn {
10106 table: "users".to_string(),
10107 column: super::super::ColumnDefinition {
10108 name: "username".to_string(),
10109 type_definition: super::super::FieldType::VarChar(150),
10110 not_null: true,
10111 unique: true,
10112 primary_key: false,
10113 auto_increment: false,
10114 default: None,
10115 },
10116 mysql_options: None,
10117 },
10118 super::super::Operation::AddConstraint {
10119 table: "users".to_string(),
10120 constraint_sql: "CONSTRAINT users_user_username_uniq UNIQUE (username)".to_string(),
10121 },
10122 ];
10123 let mut by_app: std::collections::BTreeMap<String, Vec<super::super::Operation>> =
10124 std::collections::BTreeMap::new();
10125 by_app.insert("users".to_string(), ops);
10126
10127 MigrationAutodetector::dedup_redundant_unique_add_constraints(&mut by_app);
10129
10130 let remaining = &by_app["users"];
10132 assert_eq!(
10133 remaining.len(),
10134 1,
10135 "expected one operation after dedup, got: {:?}",
10136 remaining
10137 );
10138 assert!(
10139 matches!(remaining[0], super::super::Operation::AddColumn { .. }),
10140 "expected the surviving op to be AddColumn, got: {:?}",
10141 remaining[0]
10142 );
10143 }
10144}