Skip to main content

reinhardt_db/migrations/
autodetector.rs

1//! Migration autodetector
2
3use 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/// ForeignKey action for ON DELETE and ON UPDATE clauses
13#[derive(
14	Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
15)]
16pub enum ForeignKeyAction {
17	/// Restricts deletion/update (default)
18	Restrict,
19	/// Cascades deletion/update to dependent rows
20	Cascade,
21	/// Sets foreign key to NULL
22	SetNull,
23	/// No action (similar to Restrict but deferred)
24	NoAction,
25	/// Sets foreign key to default value
26	SetDefault,
27}
28
29impl ForeignKeyAction {
30	/// Convert to SQL keyword for use in constraint definitions
31	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			// reinhardt-query's ForeignKeyAction is non-exhaustive, so we need a catch-all
63			_ => ForeignKeyAction::NoAction,
64		}
65	}
66}
67
68/// Convert a name to snake_case
69///
70/// Handles:
71/// - Acronyms: inserts underscores at acronym-word boundaries
72/// - Multiple separators: collapses consecutive `_`, `-`, ` `, `.` to single `_`
73/// - Mixed case: properly handles camelCase and PascalCase
74///
75/// # Examples
76///
77/// ```rust,ignore
78/// # use reinhardt_db::migrations::to_snake_case;
79/// assert_eq!(to_snake_case("User"), "user");
80/// assert_eq!(to_snake_case("BlogPost"), "blog_post");
81/// assert_eq!(to_snake_case("HTTPResponse"), "http_response");
82/// assert_eq!(to_snake_case("APIKey"), "api_key");
83/// assert_eq!(to_snake_case("XMLParser"), "xml_parser");
84/// assert_eq!(to_snake_case("User__Profile"), "user_profile");
85/// assert_eq!(to_snake_case("public.users"), "public_users");
86/// ```
87pub use crate::naming::to_snake_case;
88
89/// Convert a snake_case name to PascalCase
90///
91/// Handles multiple separators: `_`, `.`, `-`, space
92///
93/// # Examples
94///
95/// ```rust,ignore
96/// use reinhardt_db::migrations::autodetector::to_pascal_case;
97///
98/// assert_eq!(to_pascal_case("user"), "User");
99/// assert_eq!(to_pascal_case("blog_post"), "BlogPost");
100/// assert_eq!(to_pascal_case("http_response"), "HttpResponse");
101/// assert_eq!(to_pascal_case("following"), "Following");
102/// assert_eq!(to_pascal_case("blocked_users"), "BlockedUsers");
103/// assert_eq!(to_pascal_case("public.users"), "PublicUsers");
104/// ```
105pub 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/// ForeignKey reference information
119#[derive(Debug, Clone, PartialEq)]
120pub struct ForeignKeyInfo {
121	/// Referenced table name
122	pub referenced_table: String,
123	/// Referenced column name (usually "id")
124	pub referenced_column: String,
125	/// ON DELETE action (Cascade, SetNull, Restrict, NoAction, SetDefault)
126	pub on_delete: ForeignKeyAction,
127	/// ON UPDATE action (Cascade, SetNull, Restrict, NoAction, SetDefault)
128	pub on_update: ForeignKeyAction,
129}
130
131/// Field state for migration detection
132#[derive(Debug, Clone)]
133pub struct FieldState {
134	/// The name.
135	pub name: String,
136	/// The field type.
137	pub field_type: super::FieldType,
138	/// The nullable.
139	pub nullable: bool,
140	/// The params.
141	pub params: std::collections::HashMap<String, String>,
142	/// ForeignKey information if this field is a foreign key
143	pub foreign_key: Option<ForeignKeyInfo>,
144}
145
146impl FieldState {
147	/// Creates a new instance.
148	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	/// Create a new FieldState with ForeignKey information
159	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/// Model state for migration detection
176///
177/// Django equivalent: `ModelState` in django/db/migrations/state.py
178#[derive(Debug, Clone)]
179pub struct ModelState {
180	/// Application label (e.g., "auth", "blog")
181	pub app_label: String,
182	/// Model name (e.g., "User", "Post")
183	pub name: String,
184	/// Database table name (e.g., "users", "blog_posts")
185	pub table_name: String,
186	/// Fields: field_name -> FieldState
187	pub fields: std::collections::BTreeMap<String, FieldState>,
188	/// Model options (db_table, ordering, etc.)
189	pub options: std::collections::HashMap<String, String>,
190	/// Base model for inheritance
191	pub base_model: Option<String>,
192	/// Inheritance type: "single_table" or "joined_table"
193	pub inheritance_type: Option<String>,
194	/// Discriminator column for single table inheritance
195	pub discriminator_column: Option<String>,
196	/// Indexes: index_name -> IndexDefinition
197	pub indexes: Vec<IndexDefinition>,
198	/// Constraints: constraint_name -> ConstraintDefinition
199	pub constraints: Vec<ConstraintDefinition>,
200	/// ManyToMany relationships
201	pub many_to_many_fields: Vec<ManyToManyMetadata>,
202}
203
204/// Index definition for a model
205#[derive(Debug, Clone, PartialEq)]
206pub struct IndexDefinition {
207	/// Index name
208	pub name: String,
209	/// Fields to index (in order)
210	pub fields: Vec<String>,
211	/// Whether this is a unique index
212	pub unique: bool,
213}
214
215/// Constraint definition for a model
216#[derive(Debug, Clone, PartialEq)]
217pub struct ConstraintDefinition {
218	/// Constraint name
219	pub name: String,
220	/// Constraint type (e.g., "check", "unique", "foreign_key")
221	pub constraint_type: String,
222	/// Fields involved in the constraint
223	pub fields: Vec<String>,
224	/// Additional constraint expression (e.g., CHECK condition)
225	pub expression: Option<String>,
226	/// ForeignKey-specific information (only for foreign_key type)
227	pub foreign_key_info: Option<ForeignKeyConstraintInfo>,
228}
229
230/// ForeignKey constraint information
231#[derive(Debug, Clone, PartialEq)]
232pub struct ForeignKeyConstraintInfo {
233	/// Referenced table name
234	pub referenced_table: String,
235	/// Referenced columns (usually ["id"])
236	pub referenced_columns: Vec<String>,
237	/// ON DELETE action
238	pub on_delete: ForeignKeyAction,
239	/// ON UPDATE action
240	pub on_update: ForeignKeyAction,
241}
242
243/// Returns true when `c` is a single-column UNIQUE constraint.
244///
245/// "Single-column" means exactly one entry in `fields`; `constraint_type` is
246/// compared case-insensitively against `"unique"` because the codebase has
247/// historically mixed `"unique"` (model registry / autodetector) and
248/// `"UNIQUE"` (`schema_diff::ConstraintSchema`) spellings for the same
249/// concept. The two diff codepaths converge here, so accept either.
250fn is_single_field_unique(c: &ConstraintDefinition) -> bool {
251	c.constraint_type.eq_ignore_ascii_case("unique") && c.fields.len() == 1
252}
253
254/// Extracts the single column name from a constraint SQL of the form
255/// `CONSTRAINT <name> UNIQUE (<column>)` and returns it when the body has no
256/// comma (i.e. it really is a single-column UNIQUE).
257///
258/// Used by `MigrationAutodetector::dedup_redundant_unique_add_constraints`
259/// to identify which `Operation::AddConstraint` operations are eligible for
260/// the redundant-emission check (multi-column UNIQUE / non-UNIQUE
261/// constraints are deliberately ignored).
262fn parse_single_column_unique(constraint_sql: &str) -> Option<&str> {
263	// Uppercase-only match: every emitter in this crate writes `UNIQUE` in
264	// upper case (see `operations::Constraint`'s `Display` impl and
265	// `schema_diff::constraint_schema_to_sql`).
266	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	/// Convert ConstraintDefinition to operations::Constraint
277	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					// Fallback if foreign_key_info is missing
300					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					// Fallback
328					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				// Default to Check constraint with empty expression
341				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	/// Create a new ModelState with app_label and name
352	///
353	/// # Examples
354	///
355	/// ```rust,ignore
356	/// use reinhardt_db::migrations::ModelState;
357	///
358	/// let model = ModelState::new("myapp", "User");
359	/// assert_eq!(model.app_label, "myapp");
360	/// assert_eq!(model.name, "User");
361	/// assert_eq!(model.table_name, "user");
362	/// assert_eq!(model.fields.len(), 0);
363	/// ```
364	pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Self {
365		let name_str = name.into();
366		// Convert model name to table name (e.g., "User" -> "user", "BlogPost" -> "blog_post")
367		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	/// Add a field to this model
385	///
386	/// # Examples
387	///
388	/// ```rust,ignore
389	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
390	///
391	/// let mut model = ModelState::new("myapp", "User");
392	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
393	/// model.add_field(field);
394	/// assert_eq!(model.fields.len(), 1);
395	/// assert!(model.has_field("email"));
396	/// ```
397	pub fn add_field(&mut self, field: FieldState) {
398		self.fields.insert(field.name.clone(), field);
399	}
400
401	/// Get a field by name
402	///
403	/// # Examples
404	///
405	/// ```rust,ignore
406	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
407	///
408	/// let mut model = ModelState::new("myapp", "User");
409	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
410	/// model.add_field(field);
411	///
412	/// let retrieved = model.get_field("email");
413	/// assert!(retrieved.is_some());
414	/// assert_eq!(retrieved.unwrap().field_type, FieldType::VarChar(255));
415	/// ```
416	pub fn get_field(&self, name: &str) -> Option<&FieldState> {
417		self.fields.get(name)
418	}
419
420	/// Check if a field exists
421	///
422	/// # Examples
423	///
424	/// ```rust,ignore
425	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
426	///
427	/// let mut model = ModelState::new("myapp", "User");
428	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
429	/// model.add_field(field);
430	///
431	/// assert!(model.has_field("email"));
432	/// assert!(!model.has_field("username"));
433	/// ```
434	pub fn has_field(&self, name: &str) -> bool {
435		self.fields.contains_key(name)
436	}
437
438	/// Rename a field
439	///
440	/// # Examples
441	///
442	/// ```rust,ignore
443	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
444	///
445	/// let mut model = ModelState::new("myapp", "User");
446	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
447	/// model.add_field(field);
448	///
449	/// model.rename_field("email", "email_address".to_string());
450	/// assert!(!model.has_field("email"));
451	/// assert!(model.has_field("email_address"));
452	/// ```
453	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	/// Add a constraint to this model
461	///
462	/// # Examples
463	///
464	/// ```rust,ignore
465	/// use reinhardt_db::migrations::{ModelState, ConstraintDefinition};
466	///
467	/// let mut model = ModelState::new("myapp", "User");
468	/// let constraint = ConstraintDefinition {
469	///     name: "unique_email".to_string(),
470	///     constraint_type: "unique".to_string(),
471	///     fields: vec!["email".to_string()],
472	///     expression: None,
473	///     foreign_key_info: None,
474	/// };
475	/// model.add_constraint(constraint);
476	/// assert_eq!(model.constraints.len(), 1);
477	/// ```
478	pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
479		self.constraints.push(constraint);
480	}
481
482	/// Add a ForeignKey constraint from field information
483	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/// Project state for migration detection
505///
506/// Django equivalent: `ProjectState` in django/db/migrations/state.py
507///
508/// # Examples
509///
510/// ```rust,ignore
511/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
512///
513/// let mut state = ProjectState::new();
514/// let mut model = ModelState::new("myapp", "User");
515/// model.add_field(FieldState::new("id", FieldType::Integer, false));
516/// state.add_model(model);
517///
518/// assert!(state.get_model("myapp", "User").is_some());
519/// ```
520#[derive(Debug, Clone)]
521pub struct ProjectState {
522	/// Models: (app_label, model_name) -> ModelState
523	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	/// Converts to database schema.
534	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				// FieldType enum already contains all type information including length
541				// (e.g., VarChar(255), Decimal { precision, scale }). Direct mapping is correct.
542				// Database-specific SQL generation is handled by ColumnTypeDefinition::to_sql_for_dialect.
543				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			// Convert constraints from ModelState to ConstraintSchema
568			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			// Convert indexes from ModelState to IndexSchema
580			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			// Use app_label + model_name as table key to prevent collisions
591			// across apps (Django convention: app_label_modelname)
592			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	/// Convert ProjectState to DatabaseSchema for a specific app
608	///
609	/// This method filters models by app_label before converting to DatabaseSchema,
610	/// allowing per-app migration generation.
611	///
612	/// # Examples
613	///
614	/// ```rust,ignore
615	/// use reinhardt_db::migrations::ProjectState;
616	///
617	/// let state = ProjectState::from_global_registry();
618	/// let schema = state.to_database_schema_for_app("users");
619	/// // schema contains only tables for the "users" app
620	/// ```
621	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			// Filter by app_label
629			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				// Convert constraints from ModelState to ConstraintSchema
658				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				// Convert indexes from ModelState to IndexSchema
670				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				// Use app_label + model_name as table key to prevent collisions
681				// across apps (Django convention: app_label_modelname)
682				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	/// Create a new empty ProjectState
699	///
700	/// # Examples
701	///
702	/// ```rust,ignore
703	/// use reinhardt_db::migrations::ProjectState;
704	///
705	/// let state = ProjectState::new();
706	/// assert_eq!(state.models.len(), 0);
707	/// ```
708	pub fn new() -> Self {
709		Self {
710			models: std::collections::BTreeMap::new(),
711		}
712	}
713
714	/// Add a model to this project state
715	///
716	/// # Examples
717	///
718	/// ```rust,ignore
719	/// use reinhardt_db::migrations::{ProjectState, ModelState};
720	///
721	/// let mut state = ProjectState::new();
722	/// let model = ModelState::new("myapp", "User");
723	/// state.add_model(model);
724	///
725	/// assert_eq!(state.models.len(), 1);
726	/// assert!(state.get_model("myapp", "User").is_some());
727	/// ```
728	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	/// Get a model by app_label and model_name
734	///
735	/// # Examples
736	///
737	/// ```rust,ignore
738	/// use reinhardt_db::migrations::{ProjectState, ModelState};
739	///
740	/// let mut state = ProjectState::new();
741	/// let model = ModelState::new("myapp", "User");
742	/// state.add_model(model);
743	///
744	/// let retrieved = state.get_model("myapp", "User");
745	/// assert!(retrieved.is_some());
746	/// assert_eq!(retrieved.unwrap().name, "User");
747	/// ```
748	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	/// Get a mutable reference to a model
754	///
755	/// # Examples
756	///
757	/// ```rust,ignore
758	/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
759	///
760	/// let mut state = ProjectState::new();
761	/// let model = ModelState::new("myapp", "User");
762	/// state.add_model(model);
763	///
764	/// if let Some(model) = state.get_model_mut("myapp", "User") {
765	///     let field = FieldState::new("email", FieldType::VarChar(255), false);
766	///     model.add_field(field);
767	/// }
768	///
769	/// assert!(state.get_model("myapp", "User").unwrap().has_field("email"));
770	/// ```
771	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	/// Get primary key field type for a model
777	///
778	/// Returns the field type of the primary key, defaulting to Uuid if not found
779	/// or if the model is not in the state.
780	///
781	/// # Examples
782	///
783	/// ```ignore
784	/// # // This method is private and cannot be called from external code
785	/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
786	///
787	/// let mut state = ProjectState::new();
788	/// let mut model = ModelState::new("myapp", "User");
789	/// model.add_field(FieldState::new("id", FieldType::Integer, false));
790	/// state.add_model(model);
791	///
792	/// let pk_type = state.get_primary_key_type("myapp", "User");
793	/// assert_eq!(pk_type, FieldType::Integer);
794	/// ```
795	fn get_primary_key_type(&self, app_label: &str, model_name: &str) -> super::FieldType {
796		// JSON update
797		if let Some(model_state) = self.get_model(app_label, model_name) {
798			// Search the “id” field (by default primary key name)
799			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			// Search fields with the primary_key parameter
808			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 not found in to_state, search the global registry
818		if let Some(model_meta) =
819			super::model_registry::global_registry().get_model(app_label, model_name)
820		{
821			// Search the “id” fields
822			if let Some(id_field) = model_meta.fields.get("id") {
823				return id_field.field_type.clone();
824			}
825
826			// Search fields with the primary_key parameter
827			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		// The default is UUID (current hardcoded value)
835		super::FieldType::Uuid
836	}
837
838	/// Get a model by table name
839	///
840	/// # Examples
841	///
842	/// ```rust,ignore
843	/// use reinhardt_db::migrations::{ProjectState, ModelState};
844	///
845	/// let mut state = ProjectState::new();
846	/// let mut model = ModelState::new("myapp", "User");
847	/// model.table_name = "myapp_user".to_string();
848	/// state.add_model(model);
849	///
850	/// let retrieved = state.get_model_by_table_name("myapp", "myapp_user");
851	/// assert!(retrieved.is_some());
852	/// assert_eq!(retrieved.unwrap().name, "User");
853	/// ```
854	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	/// Filter models by app_label and return a new ProjectState containing only those models
865	///
866	/// This method is used to create app-specific ProjectState for per-app migration generation.
867	///
868	/// # Examples
869	///
870	/// ```rust,ignore
871	/// use reinhardt_db::migrations::{ProjectState, ModelState};
872	///
873	/// let mut state = ProjectState::new();
874	/// state.add_model(ModelState::new("users", "User"));
875	/// state.add_model(ModelState::new("users", "Profile"));
876	/// state.add_model(ModelState::new("posts", "Post"));
877	///
878	/// let users_state = state.filter_by_app("users");
879	/// assert_eq!(users_state.models.len(), 2);
880	/// assert!(users_state.get_model("users", "User").is_some());
881	/// assert!(users_state.get_model("users", "Profile").is_some());
882	/// assert!(users_state.get_model("posts", "Post").is_none());
883	/// ```
884	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	/// Remove a model from this project state
895	///
896	/// # Examples
897	///
898	/// ```rust,ignore
899	/// use reinhardt_db::migrations::{ProjectState, ModelState};
900	///
901	/// let mut state = ProjectState::new();
902	/// let model = ModelState::new("myapp", "User");
903	/// state.add_model(model);
904	///
905	/// state.remove_model("myapp", "User");
906	/// assert!(state.get_model("myapp", "User").is_none());
907	/// ```
908	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	/// Rename a model
914	///
915	/// # Examples
916	///
917	/// ```rust,ignore
918	/// use reinhardt_db::migrations::{ProjectState, ModelState};
919	///
920	/// let mut state = ProjectState::new();
921	/// let model = ModelState::new("myapp", "User");
922	/// state.add_model(model);
923	///
924	/// state.rename_model("myapp", "User", "Account".to_string());
925	/// assert!(state.get_model("myapp", "User").is_none());
926	/// assert!(state.get_model("myapp", "Account").is_some());
927	/// ```
928	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	/// Load ProjectState from the global model registry
939	///
940	/// Django equivalent: `ProjectState.from_apps()` in django/db/migrations/state.py:594-600
941	///
942	/// # Examples
943	///
944	/// ```rust,ignore
945	/// use reinhardt_db::migrations::ProjectState;
946	///
947	/// let state = ProjectState::from_global_registry();
948	/// // state will contain all models registered in the global registry
949	/// ```
950	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		// First, add all regular models
960		for metadata in &models_metadata {
961			let model_state = metadata.to_model_state();
962			state.add_model(model_state);
963		}
964
965		// Then, generate intermediate tables for ManyToMany relationships
966		for metadata in &models_metadata {
967			for m2m in &metadata.many_to_many_fields {
968				// Generate intermediate table for this ManyToMany relationship
969				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		// Add all intermediate tables to state
980		for table in intermediate_tables {
981			state.add_model(table);
982		}
983
984		state
985	}
986
987	/// Create an intermediate table ModelState for a ManyToMany relationship
988	///
989	/// This generates a ModelState representing the intermediate/junction table
990	/// for a ManyToMany relationship.
991	///
992	/// # Arguments
993	///
994	/// * `source_app_label` - The app label of the source model (e.g., "auth")
995	/// * `source_model_name` - The name of the source model (e.g., "User")
996	/// * `source_table_name` - The table name of the source model (e.g., "auth_user")
997	/// * `m2m` - ManyToMany relationship metadata
998	///
999	/// # Returns
1000	///
1001	/// A `ModelState` representing the intermediate table with:
1002	/// - Auto-increment primary key `id`
1003	/// - Foreign key to source model: `{source_table}_id` (or
1004	///   `from_{source_table}_id` for self-referencing M2M)
1005	/// - Foreign key to target model: `{target_table}_id` (or
1006	///   `to_{target_table}_id` for self-referencing M2M)
1007	/// - Foreign key constraints with CASCADE
1008	/// - Unique constraint on (source_id, target_id)
1009	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		// Default through-table and column names come from the canonical
1017		// convention in `crate::m2m_naming` (also re-exported as
1018		// `crate::migrations::naming`) so this site cannot drift from
1019		// `detect_created_many_to_many` (lookup) or `ManyToManyAccessor`
1020		// (runtime). See issue #4665.
1021		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		// Generate model name: PascalCase version of field_name
1026		// Example: "following" -> "UserFollowing"
1027		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		// Add primary key field: id
1033		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		// Determine the primary key type for the source and target from the registry
1043		let source_pk_type = self.get_primary_key_type(source_app_label, source_model_name);
1044		// Extract target app_label from to_model (may be in "app.Model" format)
1045		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		// Resolve the target table name from ProjectState, falling back to the
1051		// `app_label`-prefixed snake_case form as a last-resort heuristic.
1052		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		// Default FK column names come from the canonical convention in
1058		// `crate::m2m_naming::default_m2m_columns` (single source of truth,
1059		// see issue #4665): `{table}_id` for non-self-referential relations,
1060		// and `from_/to_` prefixes when source and target tables match.
1061		// Keying off the *actual* table names (not the struct identifiers)
1062		// matches the ORM accessor fallback in
1063		// `crates/reinhardt-db/src/orm/many_to_many_accessor.rs` and the
1064		// on-disk schema produced by initial migrations (#4659).
1065		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		// Add foreign key to source model
1071		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		// Add foreign key to target model
1085		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		// Add foreign key constraints
1098		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		// Add unique constraint on (from_id, to_id)
1102		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	/// Load ProjectState from a list of migrations
1145	///
1146	/// This method constructs a ProjectState by applying all operations
1147	/// from the provided migrations in order. This is useful for determining
1148	/// what the database schema should look like after applying all migrations.
1149	///
1150	/// # Examples
1151	///
1152	/// ```rust,ignore
1153	/// use reinhardt_db::migrations::{ProjectState, Migration};
1154	///
1155	/// let migrations = vec![/* ... */];
1156	/// let state = ProjectState::from_migrations(&migrations);
1157	/// // state will contain all models as they would exist after applying all migrations
1158	/// ```
1159	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	/// Apply migration operations to this project state
1168	///
1169	/// This method processes each operation and updates the ProjectState accordingly.
1170	/// It handles:
1171	/// - CreateTable: Creates a new model
1172	/// - DropTable: Removes a model
1173	/// - AddColumn: Adds a field to a model
1174	/// - DropColumn: Removes a field from a model
1175	/// - AlterColumn: Modifies a field
1176	/// - RenameTable: Renames a model's table
1177	/// - RenameColumn: Renames a field
1178	/// - Other operations are logged but not applied to state
1179	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					// Create a new model from the table definition
1195					// Use the provided app_label instead of hardcoding "auto"
1196					// Convert table name to model name (PascalCase)
1197					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					// Convert columns to fields
1202					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					// Find and remove the model with this table name
1216					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					// Find the model with this table name and add the field
1229					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					// Find the model and remove the field
1236					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					// Find the model and update the field
1250					let new_field = self.column_def_to_field_state(new_definition);
1251					// Keep the old field name but update everything else
1252					let mut updated_field = new_field;
1253					updated_field.name = column.to_string();
1254
1255					// If model exists, update the field
1256					if let Some(model) = self.find_model_by_table_mut(table) {
1257						model.fields.insert(column.to_string(), updated_field);
1258					} else {
1259						// If model doesn't exist, create it and add the field
1260						// This handles the case where AlterColumn is used in initial migrations
1261						// before CreateTable (which shouldn't happen, but does in some legacy migrations)
1262						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					// Find the model with old table name and update it
1271					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					// Find the model and rename the field
1281					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				// Other operations don't affect the schema state in ways we track.
1315				_ => {
1316					// Operations like CreateIndex, DropIndex, RunSQL, etc. are not
1317					// currently tracked in ProjectState.
1318				}
1319			}
1320		}
1321	}
1322
1323	/// Helper: Find a model by table name (immutable)
1324	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	/// Helper: Find a model by table name (mutable)
1331	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	/// Helper: Convert table name to model name (PascalCase)
1338	///
1339	/// Examples:
1340	/// - `auth_user` → `User` (with app_label="auth")
1341	/// - `auth_password_reset_token` → `PasswordResetToken`
1342	/// - `dm_message` → `DMMessage`
1343	/// - `dm_room` → `DMRoom`
1344	/// - `profile_profile` → `Profile`
1345	fn table_name_to_model_name(table_name: &str, app_label: &str) -> String {
1346		// Remove app_label prefix if present (e.g., "auth_user" → "user")
1347		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		// Convert snake_case to PascalCase
1355		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	/// Helper: Convert ColumnDefinition to FieldState
1584	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/// Configuration for similarity threshold calculation
1611///
1612/// This struct controls how aggressive the autodetector is when matching
1613/// models and fields across apps for rename/move detection.
1614///
1615/// Uses a hybrid similarity metric combining:
1616/// - Jaro-Winkler distance: Best for detecting prefix similarities (e.g., "UserModel" vs "UserProfile")
1617/// - Levenshtein distance: Best for detecting edit operations (e.g., "User" vs "Users")
1618///
1619/// # Examples
1620///
1621/// ```rust,ignore
1622/// use reinhardt_db::migrations::SimilarityConfig;
1623///
1624/// // Default configuration (70% threshold for models, 80% for fields)
1625/// let config = SimilarityConfig::default();
1626/// assert_eq!(config.model_threshold(), 0.7);
1627///
1628/// // Custom conservative configuration (higher threshold = fewer matches)
1629/// let config = SimilarityConfig::new(0.85, 0.90).unwrap();
1630///
1631/// // Liberal configuration (lower threshold = more matches, but more false positives)
1632/// let config = SimilarityConfig::new(0.60, 0.70).unwrap();
1633///
1634/// // Custom with specific algorithm weights
1635/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.6, 0.4).unwrap();
1636/// ```
1637#[non_exhaustive]
1638#[derive(Debug, Clone)]
1639pub struct SimilarityConfig {
1640	/// Threshold for model similarity (0.45 - 0.95)
1641	/// Higher values mean stricter matching (fewer false positives)
1642	model_threshold: f64,
1643	/// Threshold for field similarity (0.45 - 0.95)
1644	/// Higher values mean stricter matching
1645	field_threshold: f64,
1646	/// Weight for Jaro-Winkler component (0.0 - 1.0, default 0.7)
1647	/// Higher values prioritize prefix matching
1648	jaro_winkler_weight: f64,
1649	/// Weight for Levenshtein component (0.0 - 1.0, default 0.3)
1650	/// Higher values prioritize edit distance
1651	/// Note: jaro_winkler_weight + levenshtein_weight should equal 1.0
1652	levenshtein_weight: f64,
1653}
1654
1655impl SimilarityConfig {
1656	/// Create a new SimilarityConfig with custom thresholds
1657	///
1658	/// # Arguments
1659	///
1660	/// * `model_threshold` - Similarity threshold for model matching (0.45 - 0.95)
1661	/// * `field_threshold` - Similarity threshold for field matching (0.45 - 0.95)
1662	///
1663	/// # Errors
1664	///
1665	/// Returns an error if thresholds are outside the valid range (0.45 - 0.95).
1666	/// Values below 0.45 would produce too many false positives.
1667	/// Values above 0.95 would make matching nearly impossible.
1668	///
1669	/// # Examples
1670	///
1671	/// ```rust,ignore
1672	/// use reinhardt_db::migrations::SimilarityConfig;
1673	///
1674	/// let config = SimilarityConfig::new(0.75, 0.85).unwrap();
1675	/// assert_eq!(config.model_threshold(), 0.75);
1676	/// assert_eq!(config.field_threshold(), 0.85);
1677	///
1678	/// // Invalid threshold (too low)
1679	/// assert!(SimilarityConfig::new(0.4, 0.8).is_err());
1680	///
1681	/// // Invalid threshold (too high)
1682	/// assert!(SimilarityConfig::new(0.96, 0.8).is_err());
1683	/// ```
1684	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	/// Create a new SimilarityConfig with custom thresholds and algorithm weights
1689	///
1690	/// # Arguments
1691	///
1692	/// * `model_threshold` - Similarity threshold for model matching (0.45 - 0.95)
1693	/// * `field_threshold` - Similarity threshold for field matching (0.45 - 0.95)
1694	/// * `jaro_winkler_weight` - Weight for Jaro-Winkler component (0.0 - 1.0)
1695	/// * `levenshtein_weight` - Weight for Levenshtein component (0.0 - 1.0)
1696	///
1697	/// # Errors
1698	///
1699	/// Returns an error if:
1700	/// - Thresholds are outside the valid range (0.45 - 0.95)
1701	/// - Weights are outside the valid range (0.0 - 1.0)
1702	/// - Weights don't sum to approximately 1.0 (within 0.01 tolerance)
1703	///
1704	/// # Examples
1705	///
1706	/// ```rust,ignore
1707	/// use reinhardt_db::migrations::SimilarityConfig;
1708	///
1709	/// // Prefer Jaro-Winkler for prefix matching
1710	/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.8, 0.2).unwrap();
1711	///
1712	/// // Prefer Levenshtein for edit distance
1713	/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.3, 0.7).unwrap();
1714	///
1715	/// // Invalid: weights don't sum to 1.0
1716	/// assert!(SimilarityConfig::with_weights(0.75, 0.85, 0.5, 0.3).is_err());
1717	/// ```
1718	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		// Validate thresholds are in reasonable range
1725		// Minimum 0.45: below this produces too many false positives
1726		// Maximum 0.95: above this makes matching nearly impossible
1727		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		// Validate weights are in valid range
1741		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		// Validate weights sum to approximately 1.0 (allow small floating point errors)
1755		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	/// Get the model similarity threshold
1772	pub fn model_threshold(&self) -> f64 {
1773		self.model_threshold
1774	}
1775
1776	/// Get the field similarity threshold
1777	pub fn field_threshold(&self) -> f64 {
1778		self.field_threshold
1779	}
1780}
1781
1782impl Default for SimilarityConfig {
1783	/// Default configuration with balanced thresholds and weights
1784	///
1785	/// - Model threshold: 0.7 (70% similarity required)
1786	/// - Field threshold: 0.8 (80% similarity required)
1787	/// - Jaro-Winkler weight: 0.7 (70% weight for prefix matching)
1788	/// - Levenshtein weight: 0.3 (30% weight for edit distance)
1789	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
1799/// Migration autodetector
1800///
1801/// Django equivalent: `MigrationAutodetector` in django/db/migrations/autodetector.py
1802///
1803/// Detects schema changes between two ProjectStates and generates migrations.
1804///
1805/// # Examples
1806///
1807/// ```rust,ignore
1808/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
1809///
1810/// let mut from_state = ProjectState::new();
1811/// let mut to_state = ProjectState::new();
1812///
1813/// // Add a new model to to_state
1814/// let mut model = ModelState::new("myapp", "User");
1815/// model.add_field(FieldState::new("id", FieldType::Integer, false));
1816/// to_state.add_model(model);
1817///
1818/// let detector = MigrationAutodetector::new(from_state, to_state);
1819/// let changes = detector.detect_changes();
1820///
1821/// // Should detect the new model creation
1822/// assert_eq!(changes.created_models.len(), 1);
1823/// ```
1824pub struct MigrationAutodetector {
1825	from_state: ProjectState,
1826	to_state: ProjectState,
1827	similarity_config: SimilarityConfig,
1828}
1829
1830/// Type alias for moved model information:
1831/// (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
1832type MovedModelInfo = (
1833	String,
1834	String,
1835	String,
1836	String,
1837	bool,
1838	Option<String>,
1839	Option<String>,
1840);
1841
1842/// Type alias for model match result: ((deleted_app, deleted_model), (created_app, created_model), similarity_score)
1843type ModelMatchResult = ((String, String), (String, String), f64);
1844
1845/// Detected changes between two project states
1846#[derive(Debug, Clone, Default)]
1847pub struct DetectedChanges {
1848	/// Models that were created: (app_label, model_name)
1849	pub created_models: Vec<(String, String)>,
1850	/// Models that were deleted: (app_label, model_name)
1851	pub deleted_models: Vec<(String, String)>,
1852	/// Fields that were added: (app_label, model_name, field_name)
1853	pub added_fields: Vec<(String, String, String)>,
1854	/// Fields that were removed: (app_label, model_name, field_name)
1855	pub removed_fields: Vec<(String, String, String)>,
1856	/// Fields that were altered: (app_label, model_name, field_name)
1857	pub altered_fields: Vec<(String, String, String)>,
1858	/// Models that were renamed: (app_label, old_name, new_name)
1859	pub renamed_models: Vec<(String, String, String)>,
1860	/// Models that were moved between apps: (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
1861	pub moved_models: Vec<MovedModelInfo>,
1862	/// Fields that were renamed: (app_label, model_name, old_name, new_name)
1863	pub renamed_fields: Vec<(String, String, String, String)>,
1864	/// Indexes that were added: (app_label, model_name, IndexDefinition)
1865	pub added_indexes: Vec<(String, String, IndexDefinition)>,
1866	/// Indexes that were removed: (app_label, model_name, index_name)
1867	pub removed_indexes: Vec<(String, String, String)>,
1868	/// Constraints that were added: (app_label, model_name, ConstraintDefinition)
1869	pub added_constraints: Vec<(String, String, ConstraintDefinition)>,
1870	/// Constraints that were removed: (app_label, model_name, constraint_name)
1871	pub removed_constraints: Vec<(String, String, String)>,
1872	/// Composite primary keys added: (app_label, model_name, ConstraintDefinition)
1873	pub added_composite_primary_keys: Vec<(String, String, ConstraintDefinition)>,
1874	/// Composite primary keys removed due to modification (same name, different fields): (app_label, model_name, constraint_name)
1875	pub removed_composite_primary_keys: Vec<(String, String, String)>,
1876	/// Auto-increment sequence resets: (app_label, model_name, column_name, value)
1877	pub auto_increment_resets: Vec<(String, String, String, i64)>,
1878	/// Model dependencies for ordering operations
1879	/// Maps (app_label, model_name) -> `Vec<(dependent_app, dependent_model)>`
1880	/// A model depends on another if it has ForeignKey or ManyToMany fields pointing to it
1881	pub model_dependencies: std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
1882	/// ManyToMany intermediate tables that were created
1883	/// Contains (app_label, source_model, through_table, ManyToManyMetadata)
1884	pub created_many_to_many: Vec<(String, String, String, ManyToManyMetadata)>,
1885}
1886
1887impl DetectedChanges {
1888	/// Order models for migration operations based on dependencies
1889	///
1890	/// Uses topological sort (Kahn's algorithm) to determine the correct order
1891	/// for creating or moving models. This ensures that referenced models are
1892	/// processed before models that reference them.
1893	///
1894	/// # Algorithm: Kahn's Algorithm (Topological Sort)
1895	/// - Time Complexity: O(V + E) where V is models, E is dependencies
1896	/// - Detects circular dependencies and handles them gracefully
1897	/// - Returns models in dependency order (bottom-up)
1898	///
1899	/// # Returns
1900	/// A vector of (app_label, model_name) tuples in dependency order.
1901	/// Models with no dependencies come first, models depending on others come last.
1902	///
1903	/// # Examples
1904	///
1905	/// ```rust,ignore
1906	/// use reinhardt_db::migrations::{DetectedChanges};
1907	/// use std::collections::BTreeMap;
1908	///
1909	/// let mut changes = DetectedChanges::default();
1910	/// changes.created_models.push(("accounts".to_string(), "User".to_string()));
1911	/// changes.created_models.push(("blog".to_string(), "Post".to_string()));
1912	///
1913	/// // Post depends on User
1914	/// let mut deps = BTreeMap::new();
1915	/// deps.insert(
1916	///     ("blog".to_string(), "Post".to_string()),
1917	///     vec![("accounts".to_string(), "User".to_string())],
1918	/// );
1919	/// changes.model_dependencies = deps;
1920	///
1921	/// let ordered = changes.order_models_by_dependency();
1922	/// // User comes before Post
1923	/// assert_eq!(ordered[0], ("accounts".to_string(), "User".to_string()));
1924	/// assert_eq!(ordered[1], ("blog".to_string(), "Post".to_string()));
1925	/// ```
1926	pub fn order_models_by_dependency(&self) -> Vec<(String, String)> {
1927		use std::collections::{HashMap, HashSet, VecDeque};
1928
1929		// Build in-degree map (count of incoming edges)
1930		let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
1931		let mut all_models: HashSet<(String, String)> = HashSet::new();
1932
1933		// Collect all models (both created and dependencies)
1934		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()); // (to_app, to_model_name)
1941			all_models.insert(model_key.clone());
1942			in_degree.entry(model_key).or_insert(0);
1943		}
1944
1945		// Build in-degree counts from dependencies
1946		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		// Kahn's algorithm: Start with models that have no dependencies
1955		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			// Reduce in-degree for models that depend on this model
1968			// model_dependencies maps dependent -> dependencies
1969			// So we need to find all models that have `model` in their dependencies
1970			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 not all models are ordered, there's a circular dependency
1983		if ordered.len() < all_models.len() {
1984			// Fall back to original order with a warning
1985			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	/// Check for circular dependencies in model relationships
2006	///
2007	/// Detects cycles in the dependency graph using depth-first search.
2008	///
2009	/// # Returns
2010	/// - `Ok(())` if no circular dependencies exist
2011	/// - `Err(Vec<(String, String)>)` with the cycle path if found
2012	///
2013	/// # Examples
2014	///
2015	/// ```rust,ignore
2016	/// use reinhardt_db::migrations::{DetectedChanges};
2017	/// use std::collections::BTreeMap;
2018	///
2019	/// let mut changes = DetectedChanges::default();
2020	///
2021	/// // Create circular dependency: A -> B -> C -> A
2022	/// let mut deps = BTreeMap::new();
2023	/// deps.insert(
2024	///     ("app".to_string(), "A".to_string()),
2025	///     vec![("app".to_string(), "B".to_string())],
2026	/// );
2027	/// deps.insert(
2028	///     ("app".to_string(), "B".to_string()),
2029	///     vec![("app".to_string(), "C".to_string())],
2030	/// );
2031	/// deps.insert(
2032	///     ("app".to_string(), "C".to_string()),
2033	///     vec![("app".to_string(), "A".to_string())],
2034	/// );
2035	/// changes.model_dependencies = deps;
2036	///
2037	/// assert!(changes.check_circular_dependencies().is_err());
2038	/// ```
2039	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						// Found cycle
2065						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	/// Remove operations from DetectedChanges based on OperationRef list
2093	///
2094	/// This method is called when a user rejects an inferred intent during
2095	/// interactive migration detection. It removes the specific operations
2096	/// that the rejected intent was tracking, preventing them from being
2097	/// included in the generated migration.
2098	///
2099	/// # Arguments
2100	///
2101	/// * `refs` - Slice of OperationRef indicating which operations to remove
2102	///
2103	/// # Examples
2104	///
2105	/// ```rust,ignore
2106	/// use reinhardt_db::migrations::{DetectedChanges, OperationRef};
2107	///
2108	/// let mut changes = DetectedChanges::default();
2109	/// changes.renamed_models.push((
2110	///     "blog".to_string(),
2111	///     "Post".to_string(),
2112	///     "BlogPost".to_string(),
2113	/// ));
2114	/// changes.added_fields.push((
2115	///     "blog".to_string(),
2116	///     "BlogPost".to_string(),
2117	///     "slug".to_string(),
2118	/// ));
2119	///
2120	/// // Remove the renamed model operation
2121	/// changes.remove_operations(&[OperationRef::RenamedModel {
2122	///     app_label: "blog".to_string(),
2123	///     old_name: "Post".to_string(),
2124	///     new_name: "BlogPost".to_string(),
2125	/// }]);
2126	///
2127	/// assert!(changes.renamed_models.is_empty());
2128	/// // added_fields is not affected
2129	/// assert_eq!(changes.added_fields.len(), 1);
2130	/// ```
2131	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					// MovedModelInfo is (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
2149					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// ============================================================================
2213// Advanced Change Inference System
2214// ============================================================================
2215
2216/// Change history entry for temporal pattern analysis
2217///
2218/// Tracks individual changes with timestamps to identify patterns over time.
2219/// This enables the autodetector to learn from past migrations and make
2220/// better predictions about future changes.
2221///
2222/// # Examples
2223///
2224/// ```rust,ignore
2225/// use reinhardt_db::migrations::autodetector::ChangeHistoryEntry;
2226/// use std::time::SystemTime;
2227///
2228/// let entry = ChangeHistoryEntry {
2229///     timestamp: SystemTime::now(),
2230///     change_type: "RenameModel".to_string(),
2231///     app_label: "blog".to_string(),
2232///     model_name: "Post".to_string(),
2233///     field_name: None,
2234///     old_value: Some("BlogPost".to_string()),
2235///     new_value: Some("Post".to_string()),
2236/// };
2237/// ```
2238#[derive(Debug, Clone)]
2239pub struct ChangeHistoryEntry {
2240	/// When this change occurred
2241	pub timestamp: std::time::SystemTime,
2242	/// Type of change (e.g., "RenameModel", "AddField", "MoveModel")
2243	pub change_type: String,
2244	/// App label of the affected model
2245	pub app_label: String,
2246	/// Model name
2247	pub model_name: String,
2248	/// Field name (if field-level change)
2249	pub field_name: Option<String>,
2250	/// Old value (for renames/alterations)
2251	pub old_value: Option<String>,
2252	/// New value (for renames/alterations)
2253	pub new_value: Option<String>,
2254}
2255
2256/// Pattern frequency for learning from historical changes
2257///
2258/// Tracks how often certain patterns appear to predict future changes.
2259/// For example, if "User -> Account" rename happened 5 times in history,
2260/// similar patterns will get higher confidence scores.
2261#[derive(Debug, Clone)]
2262pub struct PatternFrequency {
2263	/// The pattern being tracked (e.g., "RenameModel:User->Account")
2264	pub pattern: String,
2265	/// Number of times this pattern occurred
2266	pub frequency: usize,
2267	/// Last time this pattern was seen
2268	pub last_seen: std::time::SystemTime,
2269	/// Contexts where this pattern appeared
2270	pub contexts: Vec<String>,
2271}
2272
2273/// Change tracker for temporal pattern analysis
2274///
2275/// Maintains a history of schema changes and analyzes patterns over time
2276/// to improve autodetection accuracy. This implements Django's concept of
2277/// "migration squashing" intelligence - learning which changes commonly
2278/// occur together.
2279///
2280/// # Algorithm: Temporal Pattern Mining
2281/// - Time Complexity: O(n) for insertion, O(n log n) for pattern analysis
2282/// - Space Complexity: O(h) where h is history size
2283/// - Uses sliding window for recent changes (last 100 by default)
2284///
2285/// # Examples
2286///
2287/// ```rust,ignore
2288/// use reinhardt_db::migrations::ChangeTracker;
2289///
2290/// let mut tracker = ChangeTracker::new();
2291///
2292/// // Track a model rename
2293/// tracker.record_model_rename("blog", "BlogPost", "Post");
2294///
2295/// // Track a field addition
2296/// tracker.record_field_addition("blog", "Post", "slug");
2297///
2298/// // Get pattern frequency
2299/// let patterns = tracker.get_frequent_patterns(2); // Min frequency: 2
2300/// ```
2301#[derive(Debug, Clone)]
2302pub struct ChangeTracker {
2303	/// Complete history of changes
2304	history: Vec<ChangeHistoryEntry>,
2305	/// Pattern frequency map
2306	patterns: HashMap<String, PatternFrequency>,
2307	/// Maximum history size (for memory efficiency)
2308	max_history_size: usize,
2309}
2310
2311impl ChangeTracker {
2312	/// Create a new change tracker with default settings
2313	///
2314	/// Default max history size: 1000 entries
2315	pub fn new() -> Self {
2316		Self {
2317			history: Vec::new(),
2318			patterns: HashMap::new(),
2319			max_history_size: 1000,
2320		}
2321	}
2322
2323	/// Create a change tracker with custom history size
2324	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	/// Record a model rename in the history
2333	///
2334	/// # Arguments
2335	/// * `app_label` - App containing the model
2336	/// * `old_name` - Original model name
2337	/// * `new_name` - New model name
2338	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	/// Record a model move between apps
2357	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	/// Record a field addition
2376	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	/// Record a field rename
2395	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	/// Add an entry to history with size management
2420	fn add_entry(&mut self, entry: ChangeHistoryEntry) {
2421		self.history.push(entry);
2422
2423		// Maintain max history size
2424		if self.history.len() > self.max_history_size {
2425			self.history.remove(0);
2426		}
2427	}
2428
2429	/// Update pattern frequency
2430	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	/// Get patterns that occur at least `min_frequency` times
2449	///
2450	/// Returns patterns sorted by frequency (descending)
2451	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	/// Get recent changes within the specified duration
2464	///
2465	/// # Arguments
2466	/// * `duration` - Time window (e.g., Duration::from_secs(3600) for last hour)
2467	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	/// Analyze co-occurring patterns
2480	///
2481	/// Returns pairs of patterns that frequently appear together
2482	/// within a time window (default: 1 hour)
2483	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	/// Clear all history (useful for testing)
2518	pub fn clear(&mut self) {
2519		self.history.clear();
2520		self.patterns.clear();
2521	}
2522
2523	/// Get total number of changes tracked
2524	pub fn len(&self) -> usize {
2525		self.history.len()
2526	}
2527
2528	/// Check if history is empty
2529	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/// Pattern match result
2541///
2542/// Represents a single match found by the PatternMatcher.
2543#[derive(Debug, Clone)]
2544pub struct PatternMatch {
2545	/// The pattern that matched
2546	pub pattern: String,
2547	/// Starting position in the text
2548	pub start: usize,
2549	/// Ending position in the text
2550	pub end: usize,
2551	/// The matched text
2552	pub matched_text: String,
2553}
2554
2555/// Pattern matcher using Aho-Corasick algorithm
2556///
2557/// Efficiently searches for multiple patterns simultaneously in model/field names.
2558/// This is useful for detecting common naming patterns like:
2559/// - "User" -> "Account" conversions
2560/// - "created_at" -> "timestamp" renames
2561/// - Common prefix/suffix patterns
2562///
2563/// # Algorithm: Aho-Corasick
2564/// - Time Complexity: O(n + m + z) where n=text length, m=total pattern length, z=matches
2565/// - Space Complexity: O(m) for the automaton
2566/// - Advantage: Simultaneous multi-pattern matching in linear time
2567///
2568/// # Examples
2569///
2570/// ```rust,ignore
2571/// use reinhardt_db::migrations::PatternMatcher;
2572///
2573/// let mut matcher = PatternMatcher::new();
2574/// matcher.add_pattern("User");
2575/// matcher.add_pattern("Post");
2576/// matcher.build();
2577///
2578/// let matches = matcher.find_all("User has many Posts");
2579/// assert_eq!(matches.len(), 2);
2580/// ```
2581#[derive(Debug, Clone)]
2582pub struct PatternMatcher {
2583	/// Patterns to search for
2584	patterns: Vec<String>,
2585	/// Aho-Corasick automaton (built lazily)
2586	automaton: Option<aho_corasick::AhoCorasick>,
2587}
2588
2589impl PatternMatcher {
2590	/// Create a new empty pattern matcher
2591	pub fn new() -> Self {
2592		Self {
2593			patterns: Vec::new(),
2594			automaton: None,
2595		}
2596	}
2597
2598	/// Add a pattern to search for
2599	///
2600	/// Patterns are case-sensitive by default.
2601	/// Call `build()` after adding all patterns.
2602	pub fn add_pattern(&mut self, pattern: &str) {
2603		self.patterns.push(pattern.to_string());
2604		// Invalidate automaton - needs rebuild
2605		self.automaton = None;
2606	}
2607
2608	/// Add multiple patterns at once
2609	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	/// Build the Aho-Corasick automaton
2621	///
2622	/// Must be called after adding patterns and before searching.
2623	/// Returns Err if patterns is empty or build fails.
2624	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	/// Find all pattern matches in the given text
2638	///
2639	/// Returns empty vector if no matches found or automaton not built.
2640	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	/// Check if any pattern matches the text
2657	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	/// Find the first match in the text
2665	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	/// Replace all pattern matches with replacements
2678	///
2679	/// # Arguments
2680	/// * `text` - The text to search in
2681	/// * `replacements` - Map from pattern to replacement string
2682	///
2683	/// # Returns
2684	/// Modified text with all patterns replaced
2685	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			// Add text before match
2695			result.push_str(&text[last_end..mat.start()]);
2696
2697			// Add replacement or original if no replacement found
2698			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		// Add remaining text
2709		result.push_str(&text[last_end..]);
2710		result
2711	}
2712
2713	/// Get all patterns currently registered
2714	pub fn patterns(&self) -> &[String] {
2715		&self.patterns
2716	}
2717
2718	/// Clear all patterns
2719	pub fn clear(&mut self) {
2720		self.patterns.clear();
2721		self.automaton = None;
2722	}
2723
2724	/// Check if automaton is built and ready
2725	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// ============================================================================
2737// Inference Types
2738// ============================================================================
2739
2740/// Condition for an inference rule
2741#[derive(Debug, Clone, PartialEq)]
2742pub enum RuleCondition {
2743	/// Model rename pattern
2744	ModelRename {
2745		/// The source model name pattern.
2746		from_pattern: String,
2747		/// The target model name pattern.
2748		to_pattern: String,
2749	},
2750	/// Model move pattern
2751	ModelMove {
2752		/// The application label pattern.
2753		app_pattern: String,
2754	},
2755	/// Field addition pattern
2756	FieldAddition {
2757		/// The field name pattern.
2758		field_name_pattern: String,
2759	},
2760	/// Field rename pattern
2761	FieldRename {
2762		/// The source field name pattern.
2763		from_pattern: String,
2764		/// The target field name pattern.
2765		to_pattern: String,
2766	},
2767	/// Multiple model renames
2768	MultipleModelRenames {
2769		/// The minimum count of renames.
2770		min_count: usize,
2771	},
2772	/// Multiple field additions
2773	MultipleFieldAdditions {
2774		/// The model name pattern.
2775		model_pattern: String,
2776		/// The minimum count of additions.
2777		min_count: usize,
2778	},
2779}
2780
2781/// Reference to a specific operation in DetectedChanges
2782///
2783/// Used to track which operations an inferred intent relates to,
2784/// enabling removal of operations when the user rejects an intent.
2785#[derive(Debug, Clone, PartialEq)]
2786pub enum OperationRef {
2787	/// Reference to a renamed model: (app_label, old_name, new_name)
2788	RenamedModel {
2789		/// The app label.
2790		app_label: String,
2791		/// The old name.
2792		old_name: String,
2793		/// The new name.
2794		new_name: String,
2795	},
2796	/// Reference to a moved model: (from_app, to_app, model_name)
2797	MovedModel {
2798		/// The from app.
2799		from_app: String,
2800		/// The to app.
2801		to_app: String,
2802		/// The model name.
2803		model_name: String,
2804	},
2805	/// Reference to an added field: (app_label, model_name, field_name)
2806	AddedField {
2807		/// The app label.
2808		app_label: String,
2809		/// The model name.
2810		model_name: String,
2811		/// The field name.
2812		field_name: String,
2813	},
2814	/// Reference to a renamed field: (app_label, model_name, old_name, new_name)
2815	RenamedField {
2816		/// The app label.
2817		app_label: String,
2818		/// The model name.
2819		model_name: String,
2820		/// The old name.
2821		old_name: String,
2822		/// The new name.
2823		new_name: String,
2824	},
2825	/// Reference to a removed field: (app_label, model_name, field_name)
2826	RemovedField {
2827		/// The app label.
2828		app_label: String,
2829		/// The model name.
2830		model_name: String,
2831		/// The field name.
2832		field_name: String,
2833	},
2834	/// Reference to an altered field: (app_label, model_name, field_name)
2835	AlteredField {
2836		/// The app label.
2837		app_label: String,
2838		/// The model name.
2839		model_name: String,
2840		/// The field name.
2841		field_name: String,
2842	},
2843	/// Reference to a created model: (app_label, model_name)
2844	CreatedModel {
2845		/// The app label.
2846		app_label: String,
2847		/// The model name.
2848		model_name: String,
2849	},
2850	/// Reference to a deleted model: (app_label, model_name)
2851	DeletedModel {
2852		/// The app label.
2853		app_label: String,
2854		/// The model name.
2855		model_name: String,
2856	},
2857}
2858
2859/// Inferred intent from detected changes
2860#[derive(Debug, Clone, PartialEq)]
2861pub struct InferredIntent {
2862	/// Type of intent (e.g., "Refactoring", "Add timestamp tracking")
2863	pub intent_type: String,
2864	/// Confidence score (0.0 - 1.0)
2865	pub confidence: f64,
2866	/// Human-readable description
2867	pub description: String,
2868	/// Evidence supporting this intent
2869	pub evidence: Vec<String>,
2870	/// References to operations in DetectedChanges that this intent relates to
2871	///
2872	/// When the user rejects this intent, these operations will be removed
2873	/// from DetectedChanges to prevent migration generation.
2874	pub related_operations: Vec<OperationRef>,
2875}
2876
2877/// Rule for inferring intent from change patterns
2878#[derive(Debug, Clone)]
2879pub struct InferenceRule {
2880	/// Rule name
2881	pub name: String,
2882	/// Required conditions (all must match)
2883	pub conditions: Vec<RuleCondition>,
2884	/// Optional conditions (boost confidence if matched)
2885	pub optional_conditions: Vec<RuleCondition>,
2886	/// Intent type to infer
2887	pub intent_type: String,
2888	/// Base confidence (0.0 - 1.0)
2889	pub base_confidence: f64,
2890	/// Confidence boost per matched optional condition
2891	pub confidence_boost_per_optional: f64,
2892}
2893
2894/// Inference engine for detecting composite change intents
2895///
2896/// Analyzes multiple detected changes to infer high-level intentions.
2897/// For example:
2898/// - AddIndex + AlterField(to larger type) → Performance optimization
2899/// - RenameModel + AddForeignKey → Relationship refactoring
2900/// - AddField + RemoveField → Data migration
2901///
2902/// # Algorithm: Rule-Based Inference
2903/// - Matches detected changes against predefined rules
2904/// - Calculates confidence scores based on pattern matching
2905/// - Returns ranked list of possible intents
2906///
2907/// # Examples
2908///
2909/// ```rust,no_run
2910/// use reinhardt_db::migrations::InferenceEngine;
2911///
2912/// let mut engine = InferenceEngine::new();
2913/// engine.add_default_rules();
2914///
2915/// // Analyze changes with proper arguments
2916/// let model_renames = vec![];
2917/// let model_moves = vec![];
2918/// let field_additions = vec![
2919///     ("users".to_string(), "User".to_string(), "email".to_string())
2920/// ];
2921/// let field_renames = vec![];
2922///
2923/// let intents = engine.infer_intents(
2924///     &model_renames,
2925///     &model_moves,
2926///     &field_additions,
2927///     &field_renames
2928/// );
2929/// ```
2930#[derive(Debug, Clone)]
2931pub struct InferenceEngine {
2932	/// Inference rules
2933	rules: Vec<InferenceRule>,
2934	/// Change history for contextual analysis
2935	///
2936	/// The change tracker maintains a history of schema changes and can be used
2937	/// to improve inference accuracy by analyzing temporal patterns. To use:
2938	///
2939	/// 1. Record changes via `record_model_rename()`, `record_field_addition()`, etc.
2940	/// 2. Query patterns via `get_frequent_patterns()` or `analyze_cooccurrence()`
2941	/// 3. Use pattern analysis to boost confidence scores in inference rules
2942	///
2943	/// Example:
2944	/// ```rust,ignore
2945	/// use reinhardt_db::migrations::autodetector::InferenceEngine;
2946	/// let mut engine = InferenceEngine::new();
2947	/// // Record rename and field addition history
2948	/// engine.record_model_rename("blog", "BlogPost", "Post");
2949	/// engine.record_field_addition("blog", "Post", "slug");
2950	/// // Analyze co-occurrence within a 60-second window
2951	/// let _cooccurrences = engine.analyze_cooccurrence(std::time::Duration::from_secs(60));
2952	/// ```
2953	change_tracker: ChangeTracker,
2954}
2955
2956impl Default for InferenceEngine {
2957	fn default() -> Self {
2958		Self::new()
2959	}
2960}
2961
2962impl InferenceEngine {
2963	/// Create a new inference engine
2964	pub fn new() -> Self {
2965		Self {
2966			rules: Vec::new(),
2967			change_tracker: ChangeTracker::new(),
2968		}
2969	}
2970
2971	/// Add a rule to the engine
2972	pub fn add_rule(&mut self, rule: InferenceRule) {
2973		self.rules.push(rule);
2974	}
2975
2976	/// Add default inference rules
2977	pub fn add_default_rules(&mut self) {
2978		// Rule 1: Model refactoring (rename)
2979		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		// Rule 2: Timestamp tracking
2992		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		// Rule 3: Cross-app model move
3006		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		// Rule 4: Field refactoring (rename)
3018		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		// Rule 5: Model normalization
3034		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	/// Match string against a pattern (supports regex)
3048	///
3049	/// Patterns can be:
3050	/// - Literal strings (exact match)
3051	/// - ".*" wildcard (matches anything)
3052	/// - Regular expressions (e.g., "User.*" matches "User", "UserProfile", etc.)
3053	fn matches_pattern(value: &str, pattern: &str) -> bool {
3054		// Wildcard pattern matches everything
3055		if pattern == ".*" {
3056			return true;
3057		}
3058
3059		// Try exact match first
3060		if value == pattern {
3061			return true;
3062		}
3063
3064		// Try regex match
3065		if let Ok(re) = Regex::new(pattern) {
3066			re.is_match(value)
3067		} else {
3068			// If regex is invalid, fall back to exact match
3069			false
3070		}
3071	}
3072
3073	/// Get all rules
3074	pub fn rules(&self) -> &[InferenceRule] {
3075		&self.rules
3076	}
3077
3078	/// Infer intents from detected changes
3079	pub fn infer_intents(
3080		&self,
3081		model_renames: &[(String, String, String, String)], // (from_app, from_model, to_app, to_model)
3082		model_moves: &[(String, String, String, String)],   // (from_app, from_model, to_app, to_model)
3083		field_additions: &[(String, String, String)],       // (app, model, field)
3084		field_renames: &[(String, String, String, String)], // (app, model, from_field, to_field)
3085	) -> 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			// Check required conditions
3094			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						// Check if any model rename matches the patterns
3106						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						// Check if any model move matches the app pattern
3135						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						// Check if any field rename matches the patterns
3182						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			// Check optional conditions
3234			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			// Calculate confidence
3256			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		// Sort by confidence (highest first)
3270		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	/// Infer intents from DetectedChanges
3280	///
3281	/// Extracts change operations from DetectedChanges and runs inference rules on them.
3282	///
3283	/// # Arguments
3284	/// * `changes` - Detected changes between two project states
3285	///
3286	/// # Returns
3287	/// Inferred intents sorted by confidence (highest first)
3288	pub fn infer_from_detected_changes(&self, changes: &DetectedChanges) -> Vec<InferredIntent> {
3289		// Extract model renames: (from_app, from_model, to_app, to_model)
3290		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		// Extract model moves: (from_app, from_model, to_app, to_model)
3299		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		// Extract field additions: (app, model, field)
3313		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		// Extract field renames: (app, model, from_field, to_field)
3320		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		// Run inference on extracted changes
3334		let mut intents = self.infer_intents(
3335			&model_renames,
3336			&model_moves,
3337			&field_additions,
3338			&field_renames,
3339		);
3340
3341		// Post-process: populate related_operations for each intent based on evidence
3342		for intent in &mut intents {
3343			// Parse evidence to determine which operations are related
3344			// Evidence strings contain information about which changes triggered the intent
3345			for evidence_str in &intent.evidence {
3346				// Model rename evidence: "Model renamed: app.old → app.new ..."
3347				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				// Model move evidence: "Model moved: from_app.model → to_app.model ..."
3357				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				// Field added evidence: "Field added: app.model.field ..."
3368				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				// Field renamed evidence: "Field renamed: app.model.old → new ..."
3378				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				// Multiple model renames evidence
3389				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				// Multiple field additions or optional field added evidence
3399				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			// Deduplicate related_operations
3413			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	/// Record a model rename in the change tracker
3423	///
3424	/// This enables contextual analysis for future migrations by tracking patterns.
3425	///
3426	/// # Arguments
3427	/// * `app_label` - App containing the model
3428	/// * `old_name` - Original model name
3429	/// * `new_name` - New model name
3430	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	/// Record a model move between apps
3436	///
3437	/// # Arguments
3438	/// * `from_app` - Source app label
3439	/// * `to_app` - Target app label
3440	/// * `model_name` - Name of the model being moved
3441	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	/// Record a field addition
3447	///
3448	/// # Arguments
3449	/// * `app_label` - App containing the model
3450	/// * `model_name` - Name of the model
3451	/// * `field_name` - Name of the field being added
3452	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	/// Record a field rename
3458	///
3459	/// # Arguments
3460	/// * `app_label` - App containing the model
3461	/// * `model_name` - Name of the model
3462	/// * `old_name` - Original field name
3463	/// * `new_name` - New field name
3464	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	/// Get frequent patterns from change history
3476	///
3477	/// Returns patterns that occur at least `min_frequency` times.
3478	/// This can be used to improve confidence scores for similar patterns.
3479	///
3480	/// # Arguments
3481	/// * `min_frequency` - Minimum number of occurrences to be considered frequent
3482	pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
3483		self.change_tracker.get_frequent_patterns(min_frequency)
3484	}
3485
3486	/// Get recent changes within the specified duration
3487	///
3488	/// # Arguments
3489	/// * `duration` - Time window for recent changes (e.g., last hour)
3490	pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
3491		self.change_tracker.get_recent_changes(duration)
3492	}
3493
3494	/// Analyze co-occurring patterns in change history
3495	///
3496	/// Returns pairs of patterns that frequently appear together
3497	/// within a time window.
3498	///
3499	/// # Arguments
3500	/// * `window` - Time window for co-occurrence analysis (default: 1 hour)
3501	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
3509// ============================================================================
3510// Interactive UI for User Confirmation
3511// ============================================================================
3512
3513/// Interactive prompt system for user confirmation of ambiguous changes
3514///
3515/// This module provides CLI-based prompts for:
3516/// - Ambiguous model/field renames
3517/// - Cross-app model moves
3518/// - Multiple possible intents with different confidence scores
3519///
3520/// Uses the `dialoguer` crate for rich terminal interactions.
3521pub struct MigrationPrompt {
3522	/// Minimum confidence threshold for auto-acceptance (0.0 - 1.0)
3523	/// Changes above this threshold are accepted without prompting
3524	auto_accept_threshold: f64,
3525
3526	/// Theme for terminal styling
3527	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	/// Create a new prompt system with default settings
3541	pub fn new() -> Self {
3542		Self {
3543			auto_accept_threshold: 0.85,
3544			theme: dialoguer::theme::ColorfulTheme::default(),
3545		}
3546	}
3547
3548	/// Create with custom auto-accept threshold
3549	pub fn with_threshold(threshold: f64) -> Self {
3550		Self {
3551			auto_accept_threshold: threshold,
3552			theme: dialoguer::theme::ColorfulTheme::default(),
3553		}
3554	}
3555
3556	/// Get the auto-accept threshold
3557	pub fn auto_accept_threshold(&self) -> f64 {
3558		self.auto_accept_threshold
3559	}
3560
3561	/// Confirm a single intent with the user
3562	///
3563	/// Returns true if the user confirms, false if they reject
3564	pub fn confirm_intent(
3565		&self,
3566		intent: &InferredIntent,
3567	) -> Result<bool, Box<dyn std::error::Error>> {
3568		// Auto-accept high-confidence changes
3569		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		// Build prompt message
3579		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		// Show evidence
3587		if !intent.evidence.is_empty() {
3588			println!("\nEvidence:");
3589			for evidence in &intent.evidence {
3590				println!("  • {}", evidence);
3591			}
3592		}
3593
3594		// Prompt user
3595		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	/// Select one intent from multiple alternatives
3603	///
3604	/// Returns the index of the selected intent, or None if user cancels
3605	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		// Single alternative - just confirm
3615		if alternatives.len() == 1 {
3616			let confirmed = self.confirm_intent(&alternatives[0])?;
3617			return Ok(if confirmed { Some(0) } else { None });
3618		}
3619
3620		// Build selection items
3621		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		// Show prompt
3634		println!("\n{}", prompt);
3635		println!("Multiple possibilities detected:\n");
3636
3637		// Add "None of the above" option
3638		let mut items_with_none = items.clone();
3639		items_with_none.push("None of the above / Skip".to_string());
3640
3641		// Prompt user
3642		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		// Return None if user selected "None of the above"
3649		if selection >= items.len() {
3650			Ok(None)
3651		} else {
3652			Ok(Some(selection))
3653		}
3654	}
3655
3656	/// Multi-select intents from a list
3657	///
3658	/// Returns indices of selected intents
3659	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		// Build selection items
3669		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		// Show prompt
3682		println!("\n{}", prompt);
3683		println!("Select all that apply:\n");
3684
3685		// Prompt user with multi-select
3686		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	/// Confirm a model rename with details
3695	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		// Auto-accept high-confidence changes
3704		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	/// Confirm a field rename with details
3733	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		// Auto-accept high-confidence changes
3741		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	/// Show progress indicator for long operations
3769	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
3800/// Extension trait for MigrationAutodetector with interactive prompts
3801pub trait InteractiveAutodetector {
3802	/// Detect changes with user prompts for ambiguous cases
3803	fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>>;
3804
3805	/// Apply inferred intents with user confirmation
3806	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		// Build inference engine
3819		let mut engine = InferenceEngine::new();
3820		engine.add_default_rules();
3821
3822		// Infer intents from detected changes
3823		let intents = engine.infer_from_detected_changes(&changes);
3824
3825		// Filter high-confidence intents
3826		let ambiguous_intents: Vec<_> = intents
3827			.into_iter()
3828			.filter(|intent| intent.confidence < prompt.auto_accept_threshold)
3829			.collect();
3830
3831		// Prompt for ambiguous changes
3832		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					// Remove the related operations from DetectedChanges
3844					// This prevents rejected intents from generating migration operations
3845					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		// Detect and order dependencies
3857		self.detect_model_dependencies(&mut changes);
3858
3859		// Check for circular dependencies
3860		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		// Group intents by confidence
3884		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		// Auto-apply high-confidence intents
3899		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		// Prompt for medium-confidence intents
3912		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		// Prompt for low-confidence intents with multi-select
3929		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	/// Create a new migration autodetector with default similarity config
3946	///
3947	/// # Examples
3948	///
3949	/// ```rust,ignore
3950	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState};
3951	///
3952	/// let from_state = ProjectState::new();
3953	/// let to_state = ProjectState::new();
3954	///
3955	/// let detector = MigrationAutodetector::new(from_state, to_state);
3956	/// ```
3957	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	/// Create a new migration autodetector with custom similarity config
3966	///
3967	/// # Examples
3968	///
3969	/// ```rust,ignore
3970	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, SimilarityConfig};
3971	///
3972	/// let from_state = ProjectState::new();
3973	/// let to_state = ProjectState::new();
3974	/// let config = SimilarityConfig::new(0.75, 0.85).unwrap();
3975	///
3976	/// let detector = MigrationAutodetector::with_config(from_state, to_state, config);
3977	/// ```
3978	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	/// Detect all changes between from_state and to_state
3991	///
3992	/// Django equivalent: `_detect_changes()` in django/db/migrations/autodetector.py
3993	///
3994	/// # Examples
3995	///
3996	/// ```rust,ignore
3997	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState};
3998	///
3999	/// let from_state = ProjectState::new();
4000	/// let mut to_state = ProjectState::new();
4001	///
4002	/// // Add a new model
4003	/// let model = ModelState::new("myapp", "User");
4004	/// to_state.add_model(model);
4005	///
4006	/// let detector = MigrationAutodetector::new(from_state, to_state);
4007	/// let changes = detector.detect_changes();
4008	///
4009	/// assert_eq!(changes.created_models.len(), 1);
4010	/// ```
4011	pub fn detect_changes(&self) -> DetectedChanges {
4012		self.detect_changes_internal(false)
4013			.expect("non-strict autodetection must not fail")
4014	}
4015
4016	/// Detect changes and fail when a compatible field rename is ambiguous.
4017	///
4018	/// This is the safer entry point for `makemigrations`: when the
4019	/// autodetector sees add/drop candidates that could be field renames, it
4020	/// must either emit `RenameColumn` for one-to-one compatible pairs or stop
4021	/// instead of silently generating destructive add/drop operations.
4022	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		// Detect model-level changes
4033		self.detect_created_models(&mut changes);
4034		self.detect_deleted_models(&mut changes);
4035		self.detect_renamed_models(&mut changes);
4036
4037		// Detect field-level changes (only for models that exist in both states)
4038		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		// Detect index and constraint changes
4044		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		// Detect ManyToMany intermediate tables
4052		self.detect_created_many_to_many(&mut changes);
4053
4054		// Detect model dependencies for operation ordering
4055		self.detect_model_dependencies(&mut changes);
4056
4057		// Sort all changes to ensure deterministic ordering
4058		// This guarantees that the same model set always produces the same migration order
4059		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		// Sort by (app_label, model_name) for index and constraint changes
4068		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	/// Detect newly created models
4089	///
4090	/// Django reference: `generate_created_models()` in django/db/migrations/autodetector.py:800
4091	fn detect_created_models(&self, changes: &mut DetectedChanges) {
4092		for ((app_label, model_name), to_model) in &self.to_state.models {
4093			// Check if the model exists in from_state by table name
4094			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	/// Detect deleted models
4107	///
4108	/// Django reference: `generate_deleted_models()` in django/db/migrations/autodetector.py:900
4109	fn detect_deleted_models(&self, changes: &mut DetectedChanges) {
4110		for ((app_label, model_name), from_model) in &self.from_state.models {
4111			// Check if the model exists in to_state by table name
4112			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	/// Detect added fields
4125	///
4126	/// Django reference: `generate_added_fields()` in django/db/migrations/autodetector.py:1000
4127	fn detect_added_fields(&self, changes: &mut DetectedChanges) {
4128		for ((app_label, model_name), to_model) in &self.to_state.models {
4129			// Only check models that exist in both states. Model renames are
4130			// resolved through `changes.renamed_models` so simultaneous
4131			// RenameTable + AddColumn cases are preserved.
4132			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	/// Detect removed fields
4149	///
4150	/// Django reference: `generate_removed_fields()` in django/db/migrations/autodetector.py:1100
4151	fn detect_removed_fields(&self, changes: &mut DetectedChanges) {
4152		for ((app_label, model_name), from_model) in &self.from_state.models {
4153			// Only check models that exist in both states. Model renames are
4154			// resolved through `changes.renamed_models` so simultaneous
4155			// RenameTable + DropColumn cases are preserved.
4156			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	/// Detect altered fields
4173	///
4174	/// Django reference: `generate_altered_fields()` in django/db/migrations/autodetector.py:1200
4175	fn detect_altered_fields(&self, changes: &mut DetectedChanges) {
4176		for ((app_label, model_name), to_model) in &self.to_state.models {
4177			// Only check models that exist in both states. Model renames are
4178			// resolved through `changes.renamed_models` so simultaneous
4179			// RenameTable + AlterColumn cases are preserved.
4180			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						// Check if field definition has changed
4186						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		// Schema-affecting bits are compared via the canonical
4295		// `ColumnDefinition` form to absorb asymmetric param populations.
4296		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	/// Detect renamed models
4329	///
4330	/// This method attempts to detect model renames by comparing deleted and created models.
4331	/// It uses field similarity to determine if a model was renamed rather than deleted/created.
4332	///
4333	/// # Django Reference
4334	/// From: django/db/migrations/autodetector.py:620-750
4335	/// ```python
4336	/// def generate_renamed_models(self):
4337	///     # Find models that were deleted and created with similar fields
4338	///     for (app_label, old_model_name) in self.old_model_keys - self.new_model_keys:
4339	///         for (app_label, new_model_name) in self.new_model_keys - self.old_model_keys:
4340	///             if self._is_renamed_model(old_model_name, new_model_name):
4341	///                 self.add_operation(
4342	///                     app_label,
4343	///                     operations.RenameModel(
4344	///                         old_name=old_model_name,
4345	///                         new_name=new_model_name,
4346	///                     ),
4347	///                 )
4348	/// ```rust,ignore
4349	///
4350	/// # Examples
4351	///
4352	/// ```rust,ignore
4353	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4354	///
4355	/// let mut from_state = ProjectState::new();
4356	/// let mut old_model = ModelState::new("myapp", "OldUser");
4357	/// old_model.add_field(FieldState::new("id", FieldType::Integer, false));
4358	/// old_model.add_field(FieldState::new("name", FieldType::VarChar(255), false));
4359	/// from_state.add_model(old_model);
4360	///
4361	/// let mut to_state = ProjectState::new();
4362	/// let mut new_model = ModelState::new("myapp", "NewUser");
4363	/// new_model.add_field(FieldState::new("id", FieldType::Integer, false));
4364	/// new_model.add_field(FieldState::new("name", FieldType::VarChar(255), false));
4365	/// to_state.add_model(new_model);
4366	///
4367	/// let detector = MigrationAutodetector::new(from_state, to_state);
4368	/// let changes = detector.detect_changes();
4369	///
4370	/// // With high field similarity, should detect as rename
4371	/// assert!(changes.renamed_models.len() <= 1);
4372	/// ```
4373	fn detect_renamed_models(&self, changes: &mut DetectedChanges) {
4374		// Get deleted and created models
4375		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		// Use bipartite matching to find optimal model pairs
4390		// This supports both same-app renames and cross-app moves
4391		let matches = self.find_optimal_model_matches(&deleted, &created);
4392
4393		for (deleted_key, created_key, _similarity) in matches {
4394			// Check if this is a cross-app move or same-app rename
4395			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				// Same app: check if table names actually differ
4400				// Struct-only renames (same table name) are not schema changes
4401				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				// Different apps: this is a move operation
4429				// Determine if table needs to be renamed
4430				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	/// Detect renamed fields
4464	///
4465	/// This method attempts to detect field renames by comparing removed and added fields.
4466	///
4467	/// # Django Reference
4468	/// From: django/db/migrations/autodetector.py:1300-1400
4469	/// ```python
4470	/// def generate_renamed_fields(self):
4471	///     for app_label, model_name in sorted(self.kept_model_keys):
4472	///         old_model_state = self.from_state.models[app_label, model_name]
4473	///         new_model_state = self.to_state.models[app_label, model_name]
4474	///
4475	///         # Find fields that were removed and added with same type
4476	///         for old_field_name, old_field in old_model_state.fields:
4477	///             for new_field_name, new_field in new_model_state.fields:
4478	///                 if self._is_renamed_field(old_field, new_field):
4479	///                     self.add_operation(...)
4480	/// ```rust,ignore
4481	///
4482	/// # Examples
4483	///
4484	/// ```rust,ignore
4485	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4486	///
4487	/// let mut from_state = ProjectState::new();
4488	/// let mut old_model = ModelState::new("myapp", "User");
4489	/// old_model.add_field(FieldState::new("old_email", FieldType::VarChar(255), false));
4490	/// from_state.add_model(old_model);
4491	///
4492	/// let mut to_state = ProjectState::new();
4493	/// let mut new_model = ModelState::new("myapp", "User");
4494	/// new_model.add_field(FieldState::new("new_email", FieldType::VarChar(255), false));
4495	/// to_state.add_model(new_model);
4496	///
4497	/// let detector = MigrationAutodetector::new(from_state, to_state);
4498	/// let changes = detector.detect_changes();
4499	///
4500	/// // With matching type, might detect as rename
4501	/// assert!(changes.renamed_fields.len() <= 1);
4502	/// ```
4503	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	/// Calculate similarity between two models using advanced field matching
4665	///
4666	/// # Algorithm: Weighted Bipartite Matching for Fields
4667	/// - Uses Jaro-Winkler for field name similarity
4668	/// - Time Complexity: O(n*m) where n,m are number of fields
4669	/// - Considers both exact matches and fuzzy matches
4670	///
4671	/// # Scoring:
4672	/// - Exact field name + type match: 1.0
4673	/// - Fuzzy field name + type match: Jaro-Winkler score (0.0-1.0)
4674	/// - No type match: 0.0
4675	///
4676	/// Returns a value between 0.0 and 1.0, where 1.0 means identical field sets.
4677	///
4678	/// # Examples
4679	///
4680	/// ```rust,ignore
4681	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4682	///
4683	/// let mut from_state = ProjectState::new();
4684	/// let mut from_model = ModelState::new("myapp", "User");
4685	/// from_model.add_field(FieldState::new("user_id", FieldType::Integer, false));
4686	/// from_model.add_field(FieldState::new("user_email", FieldType::VarChar(255), false));
4687	/// from_state.add_model(from_model);
4688	///
4689	/// let mut to_state = ProjectState::new();
4690	/// let mut to_model = ModelState::new("auth", "User");
4691	/// to_model.add_field(FieldState::new("id", FieldType::Integer, false));
4692	/// to_model.add_field(FieldState::new("email", FieldType::VarChar(255), false));
4693	/// to_state.add_model(to_model);
4694	///
4695	/// let detector = MigrationAutodetector::new(from_state, to_state);
4696	/// // Similarity would be high due to fuzzy field name matching
4697	/// ```
4698	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		// Use Hungarian algorithm concept: find best matching between fields
4711		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			// Find best matching field in to_model
4718			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	/// Calculate field-level similarity using hybrid algorithm
4746	///
4747	/// This method combines Jaro-Winkler and Levenshtein distance to measure
4748	/// similarity between field names, providing better detection than either alone.
4749	///
4750	/// # Hybrid Algorithm
4751	/// - **Jaro-Winkler**: Best for prefix similarities (e.g., "UserEmail" vs "UserAddress")
4752	///   - Time Complexity: O(n)
4753	///   - Range: 0.0 to 1.0
4754	///   - Default weight: 70%
4755	/// - **Levenshtein**: Best for edit distance (e.g., "User" vs "Users")
4756	///   - Time Complexity: O(n*m)
4757	///   - Normalized to 0.0-1.0 range
4758	///   - Default weight: 30%
4759	///
4760	/// # Examples
4761	///
4762	/// ```rust,ignore
4763	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4764	///
4765	/// let from_state = ProjectState::new();
4766	/// let to_state = ProjectState::new();
4767	/// let detector = MigrationAutodetector::new(from_state, to_state);
4768	///
4769	/// let from_field = FieldState::new("user_email", FieldType::VarChar(255), false);
4770	/// let to_field = FieldState::new("email", FieldType::VarChar(255), false);
4771	///
4772	/// // High similarity (field name is similar and type matches)
4773	/// // Jaro-Winkler ≈ 0.81, Levenshtein normalized ≈ 0.45
4774	/// // Hybrid (0.7 * 0.81 + 0.3 * 0.45) ≈ 0.70
4775	/// ```
4776	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 types don't match, similarity is 0
4784		if from_field.field_type != to_field.field_type {
4785			return 0.0;
4786		}
4787
4788		// Calculate Jaro-Winkler similarity (0.0 - 1.0)
4789		let jaro_winkler_sim = jaro_winkler(from_field_name, to_field_name);
4790
4791		// Calculate Levenshtein distance and normalize to 0.0-1.0
4792		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 // Both strings are empty
4798		};
4799
4800		// Combine using configured weights
4801		let name_similarity = self.similarity_config.jaro_winkler_weight * jaro_winkler_sim
4802			+ self.similarity_config.levenshtein_weight * levenshtein_sim;
4803
4804		// Boost similarity if nullability also matches
4805		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	/// Perform bipartite matching between deleted and created models
4815	///
4816	/// # Algorithm: Maximum Weight Bipartite Matching
4817	/// - Based on Hopcroft-Karp algorithm concept: O(n*m*√(n+m))
4818	/// - Uses petgraph for graph construction
4819	/// - Finds optimal matching considering all possible pairs
4820	///
4821	/// # Implementation Note
4822	/// This implementation uses a greedy approach with weighted edges sorted by
4823	/// similarity score. While not a full Hopcroft-Karp implementation, it provides
4824	/// good results with O(E log E) complexity where E = number of edges.
4825	///
4826	/// # Returns
4827	/// Vector of matches: (deleted_key, created_key, similarity_score)
4828	///
4829	/// # Examples
4830	///
4831	/// ```rust,ignore
4832	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4833	///
4834	/// let mut from_state = ProjectState::new();
4835	/// let mut old_model = ModelState::new("myapp", "User");
4836	/// old_model.add_field(FieldState::new("id", FieldType::Integer, false));
4837	/// from_state.add_model(old_model);
4838	///
4839	/// let mut to_state = ProjectState::new();
4840	/// let mut new_model = ModelState::new("auth", "User");
4841	/// new_model.add_field(FieldState::new("id", FieldType::Integer, false));
4842	/// to_state.add_model(new_model);
4843	///
4844	/// let detector = MigrationAutodetector::new(from_state, to_state);
4845	/// // Would detect cross-app model move from myapp.User to auth.User
4846	/// ```
4847	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		// Create nodes for deleted models (left side of bipartite graph)
4857		for _ in deleted {
4858			deleted_nodes.push(graph.add_node(()));
4859		}
4860
4861		// Create nodes for created models (right side of bipartite graph)
4862		for _ in created {
4863			created_nodes.push(graph.add_node(()));
4864		}
4865
4866		// Add edges with similarity weights
4867		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						// Only add edge if similarity exceeds threshold
4874						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		// Find maximum weight matching using greedy algorithm
4883		// (Full Hopcroft-Karp would require additional implementation)
4884		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		// Sort edges by weight (similarity) in descending order
4889		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		// Greedy matching: pick highest weight edges first
4896		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	/// Detect added indexes
4914	///
4915	/// # Django Reference
4916	/// From: django/db/migrations/autodetector.py:1500-1600
4917	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					// Check if this index exists in from_model
4924					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	/// Detect removed indexes
4941	///
4942	/// # Django Reference
4943	/// From: django/db/migrations/autodetector.py:1600-1700
4944	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					// Check if this index still exists in to_model
4951					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	/// Detect added constraints
4968	///
4969	/// A single-field UNIQUE constraint on the to-side is treated as
4970	/// already-present on the from-side when any of the following is true:
4971	///
4972	/// 1. From-state has a constraint with the same name (legacy behaviour).
4973	/// 2. From-state has any single-field UNIQUE constraint covering the same
4974	///    column — same semantics, different name. This handles the
4975	///    DB-introspection case where SQLite auto-generates names like
4976	///    `sqlite_autoindex_users_1`, which never match the to-state's
4977	///    `{app}_{model}_{field}_uniq`.
4978	/// 3. From-state has a `FieldState` for that column with
4979	///    `params["unique"] == "true"`. This handles the file-based
4980	///    reconstruction path: `apply_migration_operations` translates
4981	///    `ColumnDefinition.unique = true` into an inline field param but
4982	///    never synthesises a peer `ConstraintDefinition`, while
4983	///    `ModelMetadata::to_model_state()` on the to-side does synthesise
4984	///    one. Without this branch the autodetector keeps emitting a
4985	///    redundant `AddConstraint` every time `makemigrations` runs against
4986	///    a model whose `#[field(unique = true)]` column already shipped in
4987	///    `0001_initial.rs` (see reinhardt-web#4448).
4988	///
4989	/// # Django Reference
4990	/// From: django/db/migrations/autodetector.py:1700-1800
4991	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	/// Detect removed constraints
5027	///
5028	/// Symmetric to [`Self::detect_added_constraints`]: a from-side
5029	/// single-field UNIQUE constraint is NOT reported as removed when the
5030	/// to-side carries an equivalent shape — either another single-field
5031	/// UNIQUE constraint over the same column, or a `FieldState` for that
5032	/// column with `params["unique"] == "true"`. Without this guard the
5033	/// asymmetric shape-match in `detect_added_constraints` would simply move
5034	/// the redundancy from `AddConstraint` into a spurious `DropConstraint`
5035	/// when the column was originally introduced as a separately-named
5036	/// UNIQUE constraint but is now declared via inline `#[field(unique =
5037	/// true)]` (or vice versa). See reinhardt-web#4448.
5038	///
5039	/// # Django Reference
5040	/// From: django/db/migrations/autodetector.py:1800-1900
5041	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	/// Returns true when `candidate` is a single-field UNIQUE constraint and
5077	/// the same column on `other_side` is already covered by either:
5078	/// - any single-field UNIQUE constraint over the same column, or
5079	/// - a field whose `params["unique"] == "true"`.
5080	///
5081	/// Used by both `detect_added_constraints` and
5082	/// `detect_removed_constraints` to recognise inline `column.unique = true`
5083	/// and a separately-named single-field `UNIQUE` constraint as
5084	/// semantically identical, so a name mismatch alone does not trigger a
5085	/// redundant `AddConstraint` / `DropConstraint` (reinhardt-web#4448).
5086	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	/// Final-pass dedup: drop redundant single-column `AddConstraint UNIQUE`
5152	/// operations whose column is already declared unique elsewhere in the
5153	/// same migration.
5154	///
5155	/// This is the second of two layers that protect against the bug in
5156	/// reinhardt-web#4448. The primary fix is `detect_added_constraints`'s
5157	/// shape-match, which compares from-state and to-state. This pass
5158	/// inspects the *generated* operation list and is the safety net for
5159	/// any future codepath that produces both an `AddColumn { column.unique
5160	/// = true }` and a peer `AddConstraint` for the same single column —
5161	/// for example, a column being added in the same migration as the
5162	/// model registry synthesises its `{app}_{model}_{field}_uniq`
5163	/// constraint.
5164	///
5165	/// Coverage rules (per `(table, column)`):
5166	/// - `Operation::CreateTable { name, columns, constraints }` —
5167	///   any column with `unique = true` or any `Constraint::Unique` over a
5168	///   single column counts the column as already unique.
5169	/// - `Operation::AddColumn { table, column }` — `column.unique = true`
5170	///   counts.
5171	/// - A previously-emitted `Operation::AddConstraint` whose SQL is a
5172	///   single-column UNIQUE on the same column also counts, so duplicate
5173	///   `AddConstraint`s for the same column in the same op list collapse
5174	///   to one.
5175	///
5176	/// Multi-column UNIQUE (`unique_together`) is intentionally not touched
5177	/// — its semantics differ from a single-column UNIQUE.
5178	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			// (table, column) pairs already known to be UNIQUE in this migration.
5185			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								// Redundant — drop it.
5223								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	/// Detect added and modified composite primary keys (2+ columns).
5237	///
5238	/// A composite PK is represented as a `ConstraintDefinition` with
5239	/// `constraint_type == "primary_key"` and `fields.len() >= 2`.
5240	///
5241	/// Three cases are handled:
5242	/// - Added: no constraint with the same name existed in from_state → emit CreateCompositePrimaryKey
5243	/// - Modified: same constraint name exists but fields differ → emit DropConstraint + CreateCompositePrimaryKey
5244	/// - Unchanged: same constraint name and identical fields → no operation
5245	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						// Unchanged — no operation needed
5259					}
5260					Some(_) => {
5261						// Modified (same name, different fields) — drop old then create new
5262						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						// Added — create new
5275						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	/// Detect auto-increment sequence resets driven by `sequence_reset` model option.
5287	///
5288	/// When `ModelState.options["sequence_reset"]` is added or changed, emit a
5289	/// `SetAutoIncrementValue` operation targeting the model's auto-increment column.
5290	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	/// Generate intermediate table operation for ManyToMany field
5328	///
5329	/// Creates a through table for ManyToMany relationships with:
5330	/// - id: BigInteger primary key with auto_increment
5331	/// - {source}_id: BigInteger foreign key to source model
5332	/// - {target}_id: BigInteger foreign key to target model
5333	/// - Unique constraint on (source_id, target_id)
5334	///
5335	/// # Arguments
5336	/// * `app_label` - The app label of the source model
5337	/// * `model_name` - The source model name
5338	/// * `field_name` - The ManyToMany field name
5339	/// * `to_model` - The target model reference (e.g., "app.Model")
5340	/// * `through_table` - Optional custom through table name
5341	///
5342	/// # Returns
5343	/// Optional CreateTable operation for the intermediate table
5344	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		// Resolve the source table name from to_state. The source model is
5353		// guaranteed to be in to_state because this function is called from
5354		// `generate_operations` while iterating `to_state.models`.
5355		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		// Parse target model to get its app and table name
5364		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		// Generate through-table name: prefer explicit `through`, otherwise
5379		// derive from the source table name (matches
5380		// `create_intermediate_table_for_m2m` and the ORM accessor's
5381		// `default_through_table`, which both lowercase the source table
5382		// before composition — see #4659).
5383		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		// Derive column names from the resolved *table* names so the
5394		// autodetector matches the ORM accessor convention
5395		// (`format!("{}_id", T::table_name().to_lowercase())` in
5396		// `crates/reinhardt-db/src/orm/many_to_many_accessor.rs`). Compare by
5397		// table identity for self-reference (#4659 follow-up).
5398		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		// Resolve real PK types on both sides so the junction table matches
5413		// what `create_intermediate_table_for_m2m` / `generate_migrations`
5414		// produce. Without this, FK columns would hard-code `BigInteger`
5415		// even when either side uses a different PK type.
5416		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		// Create columns
5422		let columns = vec![
5423			// id column
5424			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			// source_id column
5434			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			// target_id column
5444			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		// Create constraints (use the resolved table names)
5456		let constraints = vec![
5457			// Foreign key to source table
5458			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			// Foreign key to target table
5468			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			// Unique constraint on (source_id, target_id)
5478			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	/// Generate operations from detected changes
5500	///
5501	/// Converts DetectedChanges into a list of Operation objects that can be
5502	/// executed to migrate the database schema.
5503	///
5504	/// # Django Reference
5505	/// From: django/db/migrations/autodetector.py:1063-1164
5506	/// ```python
5507	/// def generate_created_models(self):
5508	///     for app_label, model_name in sorted(self.new_model_keys):
5509	///         model_state = self.to_state.models[app_label, model_name]
5510	///         self.add_operation(
5511	///             app_label,
5512	///             operations.CreateModel(
5513	///                 name=model_name,
5514	///                 fields=model_state.fields,
5515	///                 options=model_state.options,
5516	///                 bases=model_state.bases,
5517	///             ),
5518	///         )
5519	/// ```rust,ignore
5520	///
5521	/// # Examples
5522	///
5523	/// ```rust,ignore
5524	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
5525	///
5526	/// let mut from_state = ProjectState::new();
5527	/// let mut to_state = ProjectState::new();
5528	///
5529	/// // Add a new model to the target state
5530	/// let mut model = ModelState::new("myapp", "User");
5531	/// model.add_field(FieldState::new("id", FieldType::Integer, false));
5532	/// to_state.add_model(model);
5533	///
5534	/// let detector = MigrationAutodetector::new(from_state, to_state);
5535	/// let operations = detector.generate_operations();
5536	///
5537	/// assert!(!operations.is_empty());
5538	/// ```rust,ignore
5539	/// Sort operations by their dependencies to ensure correct execution order
5540	///
5541	/// This method reorders operations to prevent execution errors:
5542	/// 1. CreateTable operations first (tables must exist before modification)
5543	/// 2. AddColumn/AlterColumn operations next (field modifications)
5544	/// 3. Other operations last (indexes, constraints, etc.)
5545	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		// Extract CreateTable operations (must be first)
5552		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		// Extract field operations (must be after CreateTable)
5560		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		// Assemble in correct order
5578		sorted.extend(create_tables);
5579		sorted.extend(field_ops);
5580		sorted.extend(operations); // Remaining operations
5581
5582		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	/// Performs the generate operations operation.
5733	pub fn generate_operations(&self) -> Vec<super::Operation> {
5734		let changes = self.detect_changes();
5735		self.generate_operations_from_changes(&changes)
5736	}
5737
5738	/// Performs operation generation and fails on ambiguous rename-like changes.
5739	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		// Shared per-app emissions (CreateTable, column ops, constraint ops,
5748		// auto-increment resets). This is the single source of truth shared
5749		// with `generate_migrations()` so the two paths cannot diverge again
5750		// (issue #4040).
5751		self.emit_shared_per_app_operations(changes, &mut by_app);
5752
5753		// `generate_operations()`-specific extra: walk ManyToMany fields on
5754		// new and added models and emit intermediate `CreateTable`s via
5755		// `generate_intermediate_table`. This complements the shared
5756		// emissions above. (`generate_migrations()` covers the same
5757		// ground via `created_many_to_many` with PK-type-resolved logic.)
5758		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		// Note: MoveModel and RenameTable operations are intentionally only
5782		// emitted by `generate_migrations()` (not here). Direct callers of
5783		// `generate_operations()` historically did not see them; preserve
5784		// that contract to avoid behavioral surprises.
5785
5786		// Second-line defence against redundant single-column `AddConstraint
5787		// UNIQUE` operations. The primary fix lives in
5788		// `detect_added_constraints` (shape-match), but this pass also catches
5789		// cases where the column is being added in the same migration with
5790		// `column.unique = true` *and* a peer `AddConstraint` is emitted for
5791		// it. See reinhardt-web#4448.
5792		Self::dedup_redundant_unique_add_constraints(&mut by_app);
5793
5794		// Flatten and sort by dependency to ensure correct execution order.
5795		let operations: Vec<super::Operation> = by_app.into_values().flatten().collect();
5796		self.sort_operations_by_dependency(operations)
5797	}
5798
5799	/// Emit per-app operations shared by `generate_operations()` and
5800	/// `generate_migrations()`.
5801	///
5802	/// This is the single source of truth for emissions that previously had
5803	/// to be duplicated between the two methods. Issue #4040 was caused by
5804	/// PR #3998 updating only `generate_operations()` while
5805	/// `generate_migrations()` (the CLI entry point) was left silently
5806	/// divergent. Centralizing the shared emissions here makes that class of
5807	/// drift impossible.
5808	///
5809	/// Method-specific extras (M2M field-walking for `generate_operations()`;
5810	/// `created_many_to_many` / `renamed_models` / `moved_models` for
5811	/// `generate_migrations()`) are added by the callers after this helper
5812	/// returns.
5813	fn emit_shared_per_app_operations(
5814		&self,
5815		changes: &DetectedChanges,
5816		by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
5817	) {
5818		// CreateTable for new models.
5819		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		// AddColumn for new fields.
5850		//
5851		// Use `model.table_name` (not `model.name`) so the executor's
5852		// `find_model_by_table_mut(table)` path resolves correctly. The
5853		// previous `generate_operations()` body used `model.name` here, which
5854		// was a latent bug that did not surface because that path was rarely
5855		// exercised against table-name-keyed state.
5856		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		// RenameColumn for confirmed field renames.
5875		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		// AlterColumn for changed fields.
5889		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		// DropColumn for removed fields.
5917		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		// DropTable for deleted models.
5930		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		// DropConstraint for modified composite PKs (drop before recreate).
5942		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		// CreateCompositePrimaryKey for composite PK additions.
5954		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		// DropConstraint for non-PK constraints removed from existing tables.
5967		//
5968		// Composite primary keys (constraint_type == "primary_key" with 2+
5969		// fields) are handled by `removed_composite_primary_keys` above and
5970		// must be skipped here to avoid emitting a duplicate DropConstraint.
5971		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		// AddConstraint for non-PK constraints added to existing tables.
5993		//
5994		// Covers `unique_together`, `Check`, `ForeignKey`, and `OneToOne`
5995		// constraints declared on a model that already exists in
5996		// `from_state`. Composite primary keys are emitted via
5997		// `added_composite_primary_keys` using `CreateCompositePrimaryKey`
5998		// and must be skipped here to avoid duplicate emission. The
5999		// constraint SQL is rendered through the existing
6000		// `ConstraintDefinition::to_constraint()` -> `Constraint: Display`
6001		// path, which mirrors the SQL produced for the same constraint when
6002		// emitted as part of a `CreateTable` operation, so the on-disk
6003		// schema for a "create + add later" sequence stays equivalent to a
6004		// "create with constraint" sequence.
6005		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		// SetAutoIncrementValue for detected sequence resets.
6023		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	/// Generate migrations from detected changes
6037	///
6038	/// Groups operations by app_label and creates Migration objects for each app.
6039	/// This is the final step in the migration autodetection process.
6040	///
6041	/// # Django Reference
6042	/// From: django/db/migrations/autodetector.py:95-141
6043	/// ```python
6044	/// def changes(self, graph, trim_to_apps=None, convert_apps=None, migration_name=None):
6045	///     # Generate operations
6046	///     self._generate_through_model_map()
6047	///     self.generate_renamed_models()
6048	///     # ... all other generate_* methods
6049	///
6050	///     # Group operations by app
6051	///     self.arrange_for_graph(changes, graph, trim_to_apps)
6052	///
6053	///     # Create Migration objects
6054	///     return changes
6055	/// ```rust,ignore
6056	///
6057	/// # Examples
6058	///
6059	/// ```rust,ignore
6060	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
6061	///
6062	/// let mut from_state = ProjectState::new();
6063	/// let mut to_state = ProjectState::new();
6064	///
6065	/// // Add a new model
6066	/// let mut model = ModelState::new("blog", "Post");
6067	/// model.add_field(FieldState::new("title", FieldType::VarChar(255), false));
6068	/// to_state.add_model(model);
6069	///
6070	/// let detector = MigrationAutodetector::new(from_state, to_state);
6071	/// let migrations = detector.generate_migrations();
6072	///
6073	/// assert_eq!(migrations.len(), 1);
6074	/// assert_eq!(migrations[0].app_label, "blog");
6075	/// assert!(!migrations[0].operations.is_empty());
6076	/// ```
6077	pub fn generate_migrations(&self) -> Vec<super::Migration> {
6078		let changes = self.detect_changes();
6079		self.generate_migrations_from_changes(&changes)
6080	}
6081
6082	/// Generate migrations and fail on ambiguous rename-like changes.
6083	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		// Shared per-app emissions (CreateTable, column ops, constraint ops,
6092		// auto-increment resets). Single source of truth shared with
6093		// `generate_operations()` — see `emit_shared_per_app_operations` and
6094		// issue #4040.
6095		self.emit_shared_per_app_operations(changes, &mut migrations_by_app);
6096
6097		// Generate intermediate tables for ManyToMany relationships
6098		for (app_label, model_name, through_table, m2m) in &changes.created_many_to_many {
6099			// Resolve source table name from the to_state model. The
6100			// `table_name` (user-set via `#[model(table_name = "...")]` or
6101			// derived by the macro) is the canonical identifier — never
6102			// the `struct` identifier (see #4659).
6103			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			// Parse the target reference up-front so qualified names like
6110			// "app.Model" resolve correctly throughout the rest of this
6111			// block (table lookup, PK type lookup, and the lowercase
6112			// fallback). Without this, lookups would use the literal
6113			// "app.Model" string as the model name, miss every
6114			// to_state/registry entry, and produce defaults like
6115			// "app.model_id".
6116			let (parsed_target_app, parsed_target_model) =
6117				self.resolve_model_reference(&m2m.to_model, app_label);
6118
6119			// Resolve target table name: prefer to_state, then global registry,
6120			// finally fall back to the canonical `{app}_{model_lower}` form
6121			// (mirroring the source-table fallback above). The fallback must
6122			// include the parsed app label — emitting only the lowercased
6123			// model name would lose the app prefix that `#[model]` writes
6124			// into the real `table_name`, so FK constraints would point at a
6125			// table that does not exist.
6126			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			// Default FK column names come from `crate::m2m_naming::default_m2m_columns`,
6148			// the single source of truth shared with `create_intermediate_table_for_m2m`
6149			// and the ORM accessor (issue #4665). The helper keys off the
6150			// *actual* table names (not struct identifiers) and applies
6151			// `from_/to_` prefixes only for self-referential M2M (#4659).
6152			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			// Get source model's primary key type
6158			let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
6159
6160			// Get target model's primary key type. Reuse the values parsed
6161			// from `m2m.to_model` at the top of this block so the lookup
6162			// agrees with how `target_table` was resolved (#4659 follow-up).
6163			let target_pk_type = self
6164				.to_state
6165				.get_primary_key_type(&parsed_target_app, &parsed_target_model);
6166
6167			// Create intermediate table columns
6168			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			// Create FK constraints for the intermediate table
6199			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				// Add unique constraint on the combination of both FK columns
6219				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		// Handle model renames (same app)
6239		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				// Get the old table name from from_state
6242				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				// Defense-in-depth: skip no-op renames where table name is unchanged
6249				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		// Handle cross-app model moves
6262		// MovedModelInfo: (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
6263		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			// Get table names
6274			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			// Add MoveModel operation to the target app's migrations
6289			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		// Second-line defence against redundant single-column `AddConstraint
6310		// UNIQUE` operations. The primary fix lives in
6311		// `detect_added_constraints` (shape-match); this pass also catches
6312		// cases where the column is being added in the same migration with
6313		// `column.unique = true` *and* a peer `AddConstraint` is emitted for
6314		// it. See reinhardt-web#4448.
6315		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		// Create Migration objects for each app
6321		let mut migrations = Vec::new();
6322		for (app_label, operations) in migrations_by_app {
6323			// Placeholder name; the final migration name is generated by
6324			// MakeMigrationsCommand using MigrationNamer::generate_name().
6325			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	/// Detect newly created ManyToMany relationships
6338	///
6339	/// This method compares ManyToMany fields between from_state and to_state
6340	/// to detect new relationships that require intermediate table creation.
6341	///
6342	/// # Detection Logic
6343	/// 1. Iterate through all models in to_state
6344	/// 2. For each ManyToMany field, check if it exists in from_state
6345	/// 3. If not, mark it as a newly created ManyToMany relationship
6346	///
6347	/// # Intermediate Table Naming
6348	/// Uses Django naming convention: `{app}_{model}_{field}`
6349	/// Custom through table names are supported via `through` option.
6350	///
6351	/// # Examples
6352	///
6353	/// ```rust,ignore
6354	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, ManyToManyMetadata};
6355	///
6356	/// let from_state = ProjectState::new();
6357	/// let mut to_state = ProjectState::new();
6358	///
6359	/// // Create User model with ManyToMany to Group
6360	/// let mut user = ModelState::new("auth", "User");
6361	/// user.many_to_many_fields.push(ManyToManyMetadata {
6362	///     field_name: "groups".to_string(),
6363	///     to_model: "Group".to_string(),
6364	///     related_name: Some("users".to_string()),
6365	///     through: None,
6366	///     source_field: None,
6367	///     target_field: None,
6368	///     db_constraint_prefix: None,
6369	/// });
6370	/// to_state.add_model(user);
6371	///
6372	/// let detector = MigrationAutodetector::new(from_state, to_state);
6373	/// let changes = detector.detect_changes();
6374	///
6375	/// // Should detect created ManyToMany relationship
6376	/// assert_eq!(changes.created_many_to_many.len(), 1);
6377	/// assert_eq!(changes.created_many_to_many[0].2, "auth_user_groups");
6378	/// ```
6379	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				// Generate the canonical through-table name from the source
6383				// model's actual `table_name`. Using `table_name` (not the
6384				// struct identifier) is required for two reasons:
6385				//   1. The ORM accessor's fallback derives the through-table
6386				//      and the source column from `S::table_name()` (see
6387				//      `crates/reinhardt-db/src/orm/many_to_many_accessor.rs`).
6388				//      The autodetector must agree on the same naming so that
6389				//      generated migrations match runtime expectations.
6390				//   2. `from_state` reconstructed from on-disk migrations only
6391				//      preserves `table_name`s (the original struct identifiers
6392				//      are lost — see #4659), so any subsequent existence check
6393				//      must use a `table_name`-derived key.
6394				// Route through `crate::m2m_naming::default_through_table`
6395				// so this existence-check key cannot drift from the
6396				// migration-emitting site (`create_intermediate_table_for_m2m`)
6397				// or the ORM accessor's fallback (#4659, #4665).
6398				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				// Check whether the through-table already exists in
6406				// `from_state`. The previous implementation looked at
6407				// `from_state.get_model(app_label, model_name).many_to_many_fields`,
6408				// but the M2M metadata is not stored in on-disk
6409				// `Operation::CreateTable`, so reconstructed `from_state`
6410				// always reported `many_to_many_fields.is_empty()` and
6411				// `exists_in_from` was always `false`. As a result, the
6412				// intermediate `CreateTable` was re-emitted on every
6413				// incremental `makemigrations` run (#4659).
6414				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					// Add to created_many_to_many
6422					changes.created_many_to_many.push((
6423						app_label.clone(),
6424						model_name.clone(),
6425						through_table.clone(),
6426						m2m.clone(),
6427					));
6428
6429					// Add model dependencies
6430					// The intermediate table depends on both source and target models
6431					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	/// Find the app_label for a given model name
6449	///
6450	/// Searches through to_state models to find the app that contains the model.
6451	/// If not found in to_state, falls back to the global registry for cross-app references.
6452	fn find_model_app(&self, model_name: &str) -> Option<String> {
6453		// First, search in to_state
6454		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		// If not found, search in global registry for cross-app references
6461		// This is needed when generating migrations for one app that references models in another app
6462		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	/// Detect model dependencies for proper migration ordering
6472	///
6473	/// This method analyzes ForeignKey relationships between models to ensure
6474	/// migrations are generated in the correct order. A model that references
6475	/// another model via ForeignKey depends on that model being created first.
6476	///
6477	/// # Django Reference
6478	/// Django's dependency detection is in `django/db/migrations/autodetector.py:1400`
6479	/// Function: `_generate_through_model_map` and dependency tracking
6480	///
6481	/// # Examples
6482	///
6483	/// ```rust,ignore
6484	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
6485	///
6486	/// let mut from_state = ProjectState::new();
6487	/// let mut to_state = ProjectState::new();
6488	///
6489	/// // Create User model
6490	/// let mut user = ModelState::new("accounts", "User");
6491	/// user.add_field(FieldState::new("id", FieldType::Integer, false));
6492	/// to_state.add_model(user);
6493	///
6494	/// // Create Post model that references User
6495	/// let mut post = ModelState::new("blog", "Post");
6496	/// post.add_field(FieldState::new("id", FieldType::Integer, false));
6497	/// post.add_field(FieldState::new("author_id", FieldType::Custom("ForeignKey(accounts.User)".into()), false));
6498	/// to_state.add_model(post);
6499	///
6500	/// let detector = MigrationAutodetector::new(from_state, to_state);
6501	/// let changes = detector.detect_changes();
6502	///
6503	/// // blog.Post depends on accounts.User
6504	/// let post_deps = changes.model_dependencies.get(&("blog".to_string(), "Post".to_string()));
6505	/// assert!(post_deps.is_some());
6506	/// assert!(post_deps.unwrap().contains(&("accounts".to_string(), "User".to_string())));
6507	/// ```
6508	fn detect_model_dependencies(&self, changes: &mut DetectedChanges) {
6509		// Analyze all models in the final state
6510		for ((app_label, model_name), model) in &self.to_state.models {
6511			let mut dependencies = Vec::new();
6512
6513			// Check each field for foreign key relationships
6514			for field in model.fields.values() {
6515				match &field.field_type {
6516					// Handle structured ForeignKey variant
6517					super::FieldType::ForeignKey { to_table, .. } => {
6518						// Find model by table name in the project state
6519						if let Some(dep) = self.find_model_by_table_name(to_table) {
6520							// Avoid self-reference unless intentional
6521							if dep != (app_label.clone(), model_name.clone()) {
6522								dependencies.push(dep);
6523							}
6524						}
6525					}
6526					// Handle structured OneToOne variant
6527					super::FieldType::OneToOne { to, .. } => {
6528						// Format: "app.Model" or "Model"
6529						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					// Handle structured ManyToMany variant
6536					super::FieldType::ManyToMany { to, .. } => {
6537						// Format: "app.Model" or "Model"
6538						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					// Handle legacy Custom string format
6545					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					// Skip other field types
6553					_ => {}
6554				}
6555			}
6556
6557			// Only store if there are actual dependencies
6558			if !dependencies.is_empty() {
6559				changes
6560					.model_dependencies
6561					.insert((app_label.clone(), model_name.clone()), dependencies);
6562			}
6563		}
6564	}
6565
6566	/// Extract related model from field type string
6567	///
6568	/// Parses field type strings like:
6569	/// - "ForeignKey(app.Model)" -> Some(("app", "Model"))
6570	/// - "ManyToManyField(app.Model)" -> Some(("app", "Model"))
6571	/// - "ForeignKey(Model)" -> Some((current_app, "Model"))
6572	/// - "CharField" -> None
6573	///
6574	/// # Arguments
6575	/// * `field_type` - Field type string (e.g., "ForeignKey(accounts.User)")
6576	/// * `current_app` - Current app label for resolving unqualified references
6577	///
6578	/// # Returns
6579	/// * `Some((app_label, model_name))` if field is a relation
6580	/// * `None` if field is not a relation
6581	fn extract_related_model(
6582		&self,
6583		field_type: &str,
6584		current_app: &str,
6585	) -> Option<(String, String)> {
6586		// Check for ForeignKey pattern
6587		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		// Check for ManyToManyField pattern
6595		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		// Check for OneToOneField pattern
6603		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	/// Parse model reference string into (app_label, model_name)
6614	///
6615	/// Supports formats:
6616	/// - "app.Model" -> ("app", "Model")
6617	/// - "Model" -> (current_app, "Model") - Uses current app for unqualified references
6618	///
6619	/// # Arguments
6620	/// * `reference` - Model reference string (e.g., "accounts.User" or "User")
6621	/// * `current_app` - Current app label for resolving unqualified references
6622	///
6623	/// # Returns
6624	/// * `Some((app_label, model_name))` if parseable
6625	/// * `None` if format is invalid
6626	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			// Fully qualified reference: "app.Model"
6634			[app, model] => Some((app.to_string(), model.to_string())),
6635			// Unqualified reference: "Model" - assume same app
6636			[model] => {
6637				// Use current app for same-app references
6638				Some((current_app.to_string(), model.to_string()))
6639			}
6640			// Invalid format
6641			_ => 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	/// Find a model in the project state by its table name
6666	///
6667	/// This method searches through all models in both from_state and to_state
6668	/// to find a model whose table name matches the given table name.
6669	///
6670	/// Table name matching supports:
6671	/// - Django-style table names: "app_modelname" (e.g., "auth_user")
6672	/// - Simple model name match: "modelname" (lowercase, e.g., "user")
6673	///
6674	/// # Arguments
6675	/// * `table_name` - The table name to search for
6676	///
6677	/// # Returns
6678	/// * `Some((app_label, model_name))` if found
6679	/// * `None` if no matching model is found
6680	fn find_model_by_table_name(&self, table_name: &str) -> Option<(String, String)> {
6681		// Search in to_state (target state has priority)
6682		for (app_label, model_name) in self.to_state.models.keys() {
6683			// Check Django-style table name: app_modelname
6684			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			// Check simple lowercase model name
6690			if model_name.to_lowercase() == table_name {
6691				return Some((app_label.clone(), model_name.clone()));
6692			}
6693		}
6694
6695		// Fallback: search in from_state
6696		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	/// Remove a field from this model
6713	///
6714	/// # Examples
6715	///
6716	/// ```rust,ignore
6717	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
6718	///
6719	/// let mut model = ModelState::new("myapp", "User");
6720	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
6721	/// model.add_field(field);
6722	/// assert!(model.has_field("email"));
6723	///
6724	/// model.remove_field("email");
6725	/// assert!(!model.has_field("email"));
6726	/// ```
6727	pub fn remove_field(&mut self, name: &str) {
6728		self.fields.remove(name);
6729	}
6730
6731	/// Alter a field definition
6732	///
6733	/// # Examples
6734	///
6735	/// ```rust,ignore
6736	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
6737	///
6738	/// let mut model = ModelState::new("myapp", "User");
6739	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
6740	/// model.add_field(field);
6741	///
6742	/// let new_field = FieldState::new("email", FieldType::Text, true);
6743	/// model.alter_field("email", new_field);
6744	///
6745	/// let altered = model.get_field("email").unwrap();
6746	/// assert_eq!(altered.field_type, FieldType::Text);
6747	/// assert!(altered.nullable);
6748	/// ```
6749	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	/// Helper to build a ProjectState with given models
6760	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	/// Helper to build a minimal ModelState
6769	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		// Arrange
6798		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		// Act
6832		state.apply_migration_operations(&[create_posts, add_user_fk], "blog");
6833
6834		// Assert
6835		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		// Arrange
6858		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		// Act
6894		state.apply_migration_operations(&[create_posts, add_user_fk], "blog");
6895
6896		// Assert
6897		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		// Arrange
7617		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		// Act
7631		let schema = state.to_database_schema();
7632
7633		// Assert
7634		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		// Arrange
7646		// Two different apps with identically named models
7647		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		// Act
7675		let schema = state.to_database_schema();
7676
7677		// Assert
7678		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		// Arrange
7686		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		// Act
7711		let schema = state.to_database_schema();
7712
7713		// Assert
7714		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		// Arrange
7726		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		// Act
7747		let schema = state.to_database_schema();
7748
7749		// Assert
7750		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		// Arrange
7760		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		// Act
7785		let schema = state.to_database_schema();
7786
7787		// Assert
7788		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		// Arrange
7805		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		// Act
7833		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
7838		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		// Arrange
7850		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		// Act
7875		let schema = state.to_database_schema_for_app("blog");
7876
7877		// Assert
7878		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	/// Helper to build a ModelState with a custom table name
7890	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		// Arrange
7918		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		// Act
7931		let schema = state.to_database_schema();
7932
7933		// Assert
7934		// The HashMap key should still be the auto-generated key
7935		assert!(schema.tables.contains_key("blog_post"));
7936		// But the TableSchema.name should use the custom table name
7937		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		// Arrange
7944		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		// Act
7957		let schema = state.to_database_schema_for_app("blog");
7958
7959		// Assert
7960		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	// --- Tests for #3204: no-op migration detection ---
7966
7967	/// Build fields commonly used in #3204 tests
7968	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	/// Regression test for issue #4659.
7976	///
7977	/// Scenario: a model struct is renamed (`Room` -> `DMRoom`) but the
7978	/// `table_name` stays the same (`dm_room`), and an M2M through-table
7979	/// (`dm_room_members`) already exists on disk. The state reconstructed
7980	/// from on-disk migrations loses the original struct identifier (it
7981	/// derives a `RoomMembers`-style approximation via
7982	/// `table_name_to_model_name`) and the M2M metadata is gone — only the
7983	/// raw through-table survives.
7984	///
7985	/// Before the fix, `detect_created_many_to_many` keyed its existence
7986	/// check on `(app_label, model_name)` and inspected
7987	/// `many_to_many_fields`, which is always empty on reconstructed
7988	/// states, so the through-table was spuriously re-created on every
7989	/// incremental `makemigrations` run.
7990	#[rstest]
7991	fn detect_created_many_to_many_recognises_existing_through_table_by_table_name() {
7992		use super::super::model_registry::ManyToManyMetadata;
7993
7994		// Arrange: from_state mimics the on-disk reconstruction.
7995		// `table_name_to_model_name` produces `Room` / `RoomMembers` for
7996		// tables `dm_room` / `dm_room_members`. The through table appears
7997		// as an ordinary model with no M2M metadata attached.
7998		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		// to_state: the renamed struct (`DMRoom`) re-declares the same
8011		// M2M field. The synthetic through-table model (`DMRoomMembers`)
8012		// the macro emits keeps the canonical `dm_room_members` table
8013		// name.
8014		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		// Act
8033		let changes = detector.detect_changes();
8034
8035		// Assert: the through table is already present in from_state, so
8036		// the autodetector must not re-create it (#4659).
8037		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		// Arrange: struct name changed (Clusters -> Cluster) but table name is the same
8140		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		// Act
8157		let changes = detector.detect_changes();
8158
8159		// Assert: no rename should be detected
8160		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		// Arrange: struct name changed AND table name changed
8169		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		// Act
8186		let changes = detector.detect_changes();
8187		let migrations = detector.generate_migrations();
8188
8189		// Assert: rename should be detected and emitted as a single table
8190		// rename, matching Django's RenameModel-vs-create/drop contract.
8191		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		// Arrange: same schema, but to_field has extra non-schema params
8222		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		// Act
8244		let changed =
8245			detector.has_field_changed_with_unique("email", &from_field, &to_field, None, None);
8246
8247		// Assert: should NOT be detected as changed
8248		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		// Arrange
8257		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		// Act
8265		let changed =
8266			detector.has_field_changed_with_unique("is_active", &from_field, &to_field, None, None);
8267
8268		// Assert
8269		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		// Arrange
8278		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		// Act
8299		let operations = detector.generate_operations();
8300
8301		// Assert
8302		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		// Arrange: struct name changed but table name and fields are the same
8331		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		// Act
8348		let operations = detector.generate_operations();
8349
8350		// Assert: no operations should be generated
8351		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		// Arrange
8361		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		// Act
8397		let operations = detector.generate_operations();
8398
8399		// Assert
8400		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		// Arrange — same composite PK in both states should produce no operations
8419		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		// Act
8458		let operations = detector.generate_operations();
8459
8460		// Assert
8461		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		// Arrange — same constraint name but different field set
8471		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		// Act
8516		let operations = detector.generate_operations();
8517
8518		// Assert — expect DropConstraint followed by CreateCompositePrimaryKey
8519		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		// Arrange
8542		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		// Act
8569		let operations = detector.generate_operations();
8570
8571		// Assert
8572		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		// Arrange — same model in both states, but to_state adds a UNIQUE
8590		// constraint over (organization_id, name). This mirrors the
8591		// `unique_together = ("organization_id", "name")` macro form.
8592		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		// Act
8629		let operations = detector.generate_operations();
8630
8631		// Assert — exactly one AddConstraint targeting the cluster table
8632		// with SQL referencing both columns of the composite UNIQUE.
8633		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		// Arrange — symmetric reverse: from_state has the UNIQUE, to_state
8675		// drops it. The autodetector must emit a DropConstraint so the DB
8676		// is brought back in sync.
8677		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		// Act
8714		let operations = detector.generate_operations();
8715
8716		// Assert
8717		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		// Arrange — regression for issue #4032.
8743		//
8744		// When `makemigrations` falls back to file-based state reconstruction
8745		// (no DB available), `from_state` is rebuilt from migration
8746		// `Operation::CreateTable` entries and keyed by the PascalCase form
8747		// of the table name (e.g. table `"clusters"` -> key `"Clusters"`),
8748		// while `to_state` is keyed by the registered struct name
8749		// (e.g. `"Cluster"`). Both share the same `table_name`.
8750		//
8751		// Constraint diffing must locate the corresponding model by
8752		// `table_name` rather than by struct-name key, otherwise added
8753		// `unique_together` constraints are silently dropped.
8754		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		// from_state: keyed by table-derived name "Clusters", no constraints.
8759		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		// to_state: keyed by struct name "Cluster", carries the unique constraint.
8769		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		// Act
8795		let operations = detector.generate_operations();
8796
8797		// Assert — exactly one AddConstraint, targeted at the shared table.
8798		// No spurious operations (in particular no AlterColumn/RenameModel)
8799		// must leak through from the model-name mismatch.
8800		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		// Arrange — symmetric regression for issue #4032 covering the
8827		// removal direction: offline-reconstructed `from_state` retains a
8828		// `unique_together` constraint, and the registered model in
8829		// `to_state` no longer declares it. The diff must emit a
8830		// `DropConstraint` despite the model-name key mismatch.
8831		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		// Act
8870		let operations = detector.generate_operations();
8871
8872		// Assert
8873		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		// Arrange — regression for issue #4049.
8899		//
8900		// `from_state` is rebuilt from migration files via
8901		// `column_def_to_field_state`, which only inserts schema-affecting
8902		// params (`primary_key`, `auto_increment`, `unique`, `default`) when
8903		// their value is true/Some. `to_state` is rebuilt from the macro
8904		// registry, which inserts explicit "true"/"false" strings for
8905		// `not_null`, `null`, etc. on every field.
8906		//
8907		// The two `FieldState` HashMaps are therefore asymmetric even when
8908		// the underlying schema is identical, and `has_field_changed` must
8909		// not treat that asymmetry as a real change.
8910		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		// to_field carries the macro-registry-style asymmetric params. In
8922		// particular, schema-affecting keys like `unique` and `default` may be
8923		// populated explicitly with their false/empty value on the to side
8924		// while the migration-replay from side simply omits the key. The raw
8925		// HashMap comparison previously surfaced this as a difference; the
8926		// canonical ColumnDefinition collapses None and "false" to the same
8927		// `false`, so the field is correctly seen as unchanged.
8928		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		// Act
8945		let changed =
8946			detector.has_field_changed_with_unique("id", &from_field, &to_field, None, None);
8947
8948		// Assert — schema is identical (BigInteger PK NOT NULL auto_increment,
8949		// not unique). Asymmetric param maps must not surface as a change.
8950		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		// Arrange — regression for issue #4049.
8959		//
8960		// When `makemigrations` runs offline (no live DB), `from_state` is
8961		// reconstructed from migration files and keyed by the PascalCase form
8962		// of the table name (e.g. table `"clusters"` -> key `"Clusters"`),
8963		// while `to_state` is keyed by the registered struct name (`"Cluster"`).
8964		// On top of that, the two states populate `FieldState.params`
8965		// asymmetrically: migration replay only inserts schema-affecting params
8966		// when their value is true/Some, whereas the macro registry inserts
8967		// explicit "true"/"false" strings for `not_null`, `null`, etc.
8968		//
8969		// The diff must NOT emit a no-op `Operation::AlterColumn` for the
8970		// unchanged `id` primary key, even though the params HashMap differs.
8971		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		// from_state: keyed by table-derived name "Clusters", no constraints,
8985		// migration-replay-style sparse params on the PK column.
8986		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		// to_state: keyed by struct name "Cluster", carries an added
8996		// unique_together constraint, macro-registry-style dense params on the
8997		// PK column (`not_null`, `null`, `unique` explicitly populated even
8998		// when their value is the default false). The from side omits these
8999		// keys entirely, which previously surfaced as a fictitious
9000		// difference in `has_field_changed`'s raw HashMap comparison.
9001		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		// Act
9040		let operations = detector.generate_operations();
9041
9042		// Assert — exactly one AddConstraint and no spurious AlterColumn for
9043		// the unchanged PK. The asymmetric param populations must collapse to
9044		// the same canonical `ColumnDefinition` and never surface as a diff.
9045		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		// Arrange — regression for issue #4052 (residual after #4050).
9071		//
9072		// Reproduces the production CLI path that #4050's regression test
9073		// missed: from_state is built by feeding a synthetic
9074		// `Operation::CreateTable` (modeled on `0001_initial.rs`) through
9075		// `ProjectState::apply_migration_operations`, so its `id` FieldState
9076		// flows through `column_def_to_field_state`. to_state mirrors the
9077		// `#[model]` macro's output for `id: Option<i64>` with
9078		// `#[field(primary_key = true)]` AFTER the macro fix that suppresses
9079		// `null = "true"` for primary keys (the Option<T> wrapper for PKs
9080		// reflects "id is None until DB assigns it on insert", not DB-level
9081		// nullability).
9082		//
9083		// Pre-fix, the macro emitted `null = "true"` for any Option<T>
9084		// field, including PKs. `to_model_state` then set
9085		// `FieldState.nullable = true` while `column_def_to_field_state`
9086		// produced `nullable = false`. `has_field_changed`'s direct
9087		// `nullable != nullable` short-circuit (added by #4050 to keep the
9088		// authoritative NOT NULL bit on the canonical comparison path)
9089		// returned true before the canonical `ColumnDefinition::from_field_state`
9090		// folding could absorb the asymmetry, surfacing as a no-op
9091		// `Operation::AlterColumn { old_definition: None, .. }` for the
9092		// unchanged PK.
9093
9094		// Build to_state via the model registry layer that the macro feeds.
9095		// Mirror the FIXED macro params for `id: Option<i64>` with
9096		// `#[field(primary_key = true)]`: `null = "false"` (forced by the
9097		// fix), `not_null = "true"`, `primary_key = "true"`,
9098		// `auto_increment = "true"`. The dense param population is exactly
9099		// what `ModelMetadata::to_model_state` consumes.
9100		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		// Sanity: the FIXED macro contract must fold `null = "false"` into
9121		// `FieldState.nullable = false` for the PK, matching the migration
9122		// replay side.
9123		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		// Build from_state via the production CLI path: feed a CreateTable
9138		// (modeled on 0001_initial.rs) through apply_migration_operations.
9139		// This populates from_state via column_def_to_field_state, which
9140		// derives nullability from `not_null` (sparse params).
9141		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		// Sanity: the migration-replay path must produce nullable=false for
9172		// the PK.
9173		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		// Act — call BOTH lower-level generate_operations() and the CLI
9189		// entry generate_migrations(), since #4052's reproducer asserts
9190		// against both.
9191		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 — neither path may emit AlterColumn for the unchanged `id`
9199		// PK. Pre-fix, both emitted exactly such an AlterColumn.
9200		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		// Arrange — regression for the basis tutorial migration check.
9223		//
9224		// The replayed migration state only knows the physical `_id` column
9225		// type (`BigInteger`). The macro registry state keeps the
9226		// `ForeignKeyField<T>` placeholder type (`Uuid`) plus `fk_target`
9227		// params, and `ColumnDefinition::from_field_state` resolves that
9228		// placeholder to the referenced model's PK type. The autodetector must
9229		// compare the resolved column definitions, not the raw logical
9230		// `FieldState.field_type`, or it emits a no-op AlterColumn.
9231		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		// Act
9307		let operations = detector.generate_operations();
9308
9309		// Assert
9310		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		// Arrange - regression for issue #5367.
9328		//
9329		// The file-based replay path reconstructs old migrations from
9330		// `ColumnDefinition` values. That state can differ from today's
9331		// registry state even when the applied schema is equivalent:
9332		// - old UUID PK migrations may carry `auto_increment = true`;
9333		// - field-level UNIQUE may have been added through `AddConstraint`;
9334		// - DB defaults must round-trip through both replayed migrations and
9335		//   registry metadata.
9336		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		// Act
9554		let operations = detector.generate_operations();
9555
9556		// Assert
9557		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		// Arrange — regression for issue #4040.
9567		//
9568		// `generate_migrations()` is the entry point used by the
9569		// `makemigrations` CLI, in contrast to `generate_operations()`
9570		// which is used by tests / direct callers. PR #3998 added
9571		// `added_constraints` handling to `generate_operations()` but the
9572		// symmetric loop was missing from `generate_migrations()`, so the
9573		// CLI silently dropped `Operation::AddConstraint` for non-PK
9574		// constraints (e.g. `unique_together`) added to existing models.
9575		//
9576		// This test exercises the CLI path directly and asserts the
9577		// migration carries an `Operation::AddConstraint`.
9578		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		// from_state mirrors the CLI's offline-reconstructed state with no
9583		// constraints declared on the existing model (matching what the
9584		// `0001_initial` migration produces before the constraint is added).
9585		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		// to_state carries the unique_together constraint declared via the
9595		// macro on the registered struct.
9596		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		// Act
9623		let migrations = detector.generate_migrations();
9624
9625		// Assert — exactly one Migration for app "clusters" with exactly
9626		// one AddConstraint operation, targeted at the shared table.
9627		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		// Arrange — symmetric regression for issue #4040 covering the
9661		// removal direction: the existing model declares a
9662		// `unique_together` constraint, the new struct has dropped it, and
9663		// the CLI path must emit `Operation::DropConstraint`.
9664		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		// Act
9704		let migrations = detector.generate_migrations();
9705
9706		// Assert
9707		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		// Arrange — regression for issue #4040 (structural).
9740		//
9741		// Issue #4040 was caused by `generate_operations()` and
9742		// `generate_migrations()` carrying parallel-but-divergent
9743		// per-change-set emission loops; PR #3998 updated only the former.
9744		// After the structural fix both methods route through
9745		// `emit_shared_per_app_operations()`, so the shared subset of ops
9746		// (CreateTable / column ops / constraint ops / auto-increment
9747		// resets) MUST always agree.
9748		//
9749		// This test exercises a representative scenario: an existing model
9750		// gains a unique constraint AND a new column. Both emissions must
9751		// appear identically in both methods. M2M / rename / move
9752		// divergences are intentionally not exercised here because they
9753		// remain method-specific by design.
9754		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		// Act
9795		let ops = detector.generate_operations();
9796		let migrations = detector.generate_migrations();
9797
9798		// Assert — flatten migrations into the same shape as `ops` and
9799		// compare as multisets (order is determined by per-app dependency
9800		// sort, which is the same algorithm but called with a different
9801		// scope, so we compare unordered).
9802		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		// Every op produced by generate_operations() must also appear in
9815		// generate_migrations() output.
9816		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		// And vice versa.
9824		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		// Arrange — adding a composite PK should be emitted by the
9836		// `CreateCompositePrimaryKey` path only. The new
9837		// `added_constraints` emitter must skip composite PKs to avoid
9838		// emitting a redundant AddConstraint alongside it.
9839		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		// Act
9874		let operations = detector.generate_operations();
9875
9876		// Assert — exactly one operation, and it must be the composite PK
9877		// path, not a duplicate AddConstraint.
9878		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	/// Reproduces reinhardt-web#4448: when the file-based `from_state`
9891	/// reconstruction stores a column's uniqueness as `params["unique"] =
9892	/// "true"` (the path through `ProjectState::apply_migration_operations`
9893	/// → `column_def_to_field_state`), and the live model registry's
9894	/// `to_state` materialises the same column as a synthesised
9895	/// single-field `ConstraintDefinition`, the autodetector must NOT emit
9896	/// an `Operation::AddConstraint` for the redundant UNIQUE.
9897	#[rstest]
9898	fn inline_unique_param_on_from_side_does_not_emit_redundant_add_constraint() {
9899		// Arrange — `from_state` mimics what `apply_migration_operations`
9900		// produces for `0001_initial.rs`: the `username` column carries
9901		// `params["unique"] = "true"` and the model has NO peer constraint.
9902		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		// `to_state` mimics what `ModelMetadata::to_model_state()` produces:
9917		// the same inline-unique param PLUS a synthesised single-field
9918		// UNIQUE `ConstraintDefinition` named per the
9919		// `{app}_{model.to_lowercase()}_{field}_uniq` convention.
9920		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		// Act
9944		let operations = detector.generate_operations();
9945
9946		// Assert — no AddConstraint is emitted, because the column is
9947		// already covered by inline `params["unique"]` in `from_state`.
9948		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	/// Reproduces the DB-introspection variant of reinhardt-web#4448:
9958	/// `from_state` carries a single-field UNIQUE constraint with a
9959	/// dialect-specific auto-name (e.g. SQLite's
9960	/// `sqlite_autoindex_users_1`), and `to_state` declares the same
9961	/// column's UNIQUE with the model-derived name
9962	/// (`users_user_username_uniq`). The names differ but the semantics
9963	/// are identical — no `AddConstraint` must be emitted.
9964	#[rstest]
9965	fn single_field_unique_constraint_renames_do_not_emit_redundant_add_constraint() {
9966		// Arrange
9967		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		// Act
10007		let operations = detector.generate_operations();
10008
10009		// Assert — neither AddConstraint nor DropConstraint is emitted.
10010		// A pure rename of an internal constraint name on a single-column
10011		// UNIQUE is treated as a no-op; emitting either would be invalid on
10012		// SQLite (no ALTER TABLE ADD CONSTRAINT) and would leave duplicate
10013		// constraints on dialects that recreate the table.
10014		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	/// Symmetric guard for reinhardt-web#4448: when `from_state` has a
10032	/// single-field UNIQUE constraint and `to_state` represents the same
10033	/// uniqueness inline via `params["unique"] = "true"` only, no
10034	/// `DropConstraint` must be emitted. Without the symmetric check in
10035	/// `detect_removed_constraints` the fix would shift the redundancy
10036	/// from `AddConstraint` into `DropConstraint`.
10037	#[rstest]
10038	fn from_side_unique_constraint_matched_by_inline_unique_on_to_side_emits_no_drop() {
10039		// Arrange
10040		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		// from_state: constraint present, field has NO inline unique param.
10054		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		// to_state: no peer constraint, inline param only.
10064		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		// Act
10080		let operations = detector.generate_operations();
10081
10082		// Assert — no DropConstraint emitted; the inline `params["unique"]`
10083		// on the to-side already covers the column.
10084		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	/// Direct unit test for the dedup pass
10094	/// `MigrationAutodetector::dedup_redundant_unique_add_constraints`.
10095	/// Manually constructs a per-app operation list where an
10096	/// `Operation::AddColumn { column.unique = true }` is followed by a
10097	/// peer `Operation::AddConstraint` that ascribes a UNIQUE to the same
10098	/// column. The dedup pass must drop the redundant `AddConstraint`,
10099	/// regardless of how it got there. Second safety net for
10100	/// reinhardt-web#4448.
10101	#[rstest]
10102	fn dedup_pass_drops_add_constraint_redundant_with_unique_add_column() {
10103		// Arrange
10104		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		// Act
10128		MigrationAutodetector::dedup_redundant_unique_add_constraints(&mut by_app);
10129
10130		// Assert — only AddColumn survives; the AddConstraint is dropped.
10131		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}