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	/// Partial index condition.
214	pub where_clause: Option<String>,
215	/// Index method.
216	pub index_type: Option<super::operations::IndexType>,
217	/// Expression-index definitions.
218	pub expressions: Option<Vec<String>>,
219	/// Whether the index was created concurrently.
220	pub concurrently: bool,
221	/// MySQL index options.
222	pub mysql_options: Option<super::operations::AlterTableOptions>,
223	/// PostgreSQL operator class.
224	pub operator_class: Option<String>,
225}
226
227impl IndexDefinition {
228	fn create_operation(&self, table: &str) -> super::Operation {
229		super::Operation::CreateIndexRepair {
230			table: table.to_string(),
231			name: Some(self.name.clone()),
232			columns: self.fields.clone(),
233			unique: self.unique,
234			index_type: self.index_type,
235			where_clause: self.where_clause.clone(),
236			concurrently: self.concurrently,
237			expressions: self.expressions.clone(),
238			mysql_options: self.mysql_options,
239			operator_class: self.operator_class.clone(),
240		}
241	}
242
243	fn drop_operation(&self, table: &str) -> super::Operation {
244		super::Operation::DropNamedIndex {
245			table: table.to_string(),
246			name: self.name.clone(),
247			columns: self.fields.clone(),
248			unique: self.unique,
249			index_type: self.index_type,
250			where_clause: self.where_clause.clone(),
251			concurrently: self.concurrently,
252			expressions: self.expressions.clone(),
253			mysql_options: self.mysql_options,
254			operator_class: self.operator_class.clone(),
255		}
256	}
257}
258
259const ADVANCED_INDEX_OPTION_PREFIX: &str = "__reinhardt_advanced_index__:";
260
261fn advanced_index_option_key(name: &str) -> String {
262	format!("{ADVANCED_INDEX_OPTION_PREFIX}{name}")
263}
264
265fn model_index_is_advanced(_model: &ModelState, index: &IndexDefinition) -> bool {
266	index.where_clause.is_some()
267		|| index.index_type.is_some()
268		|| index.expressions.is_some()
269		|| index.operator_class.is_some()
270}
271
272fn model_index_definitions_equivalent(
273	left_model: &ModelState,
274	left: &IndexDefinition,
275	right_model: &ModelState,
276	right: &IndexDefinition,
277) -> bool {
278	index_definitions_equivalent(left, right)
279		&& (left_model.table_name != right_model.table_name || left.name == right.name)
280		&& model_index_is_advanced(left_model, left) == model_index_is_advanced(right_model, right)
281}
282
283/// Build the physical index name used by `Operation::CreateIndex`.
284pub(crate) fn default_index_name(table: &str, fields: &[String]) -> String {
285	super::operations::generated_index_name(table, fields, None)
286}
287
288/// Compare indexes by schema semantics rather than generated names.
289pub(crate) fn index_definitions_equivalent(
290	left: &IndexDefinition,
291	right: &IndexDefinition,
292) -> bool {
293	left.fields == right.fields
294		&& left.unique == right.unique
295		&& left.where_clause == right.where_clause
296		&& left.index_type == right.index_type
297		&& left.expressions == right.expressions
298		&& left.operator_class == right.operator_class
299}
300
301/// Constraint definition for a model
302#[derive(Debug, Clone, PartialEq)]
303pub struct ConstraintDefinition {
304	/// Constraint name
305	pub name: String,
306	/// Constraint type (e.g., "check", "unique", "foreign_key")
307	pub constraint_type: String,
308	/// Fields involved in the constraint
309	pub fields: Vec<String>,
310	/// Additional constraint expression (e.g., CHECK condition)
311	pub expression: Option<String>,
312	/// ForeignKey-specific information (only for foreign_key type)
313	pub foreign_key_info: Option<ForeignKeyConstraintInfo>,
314}
315
316/// ForeignKey constraint information
317#[derive(Debug, Clone, PartialEq)]
318pub struct ForeignKeyConstraintInfo {
319	/// Referenced table name
320	pub referenced_table: String,
321	/// Referenced columns (usually ["id"])
322	pub referenced_columns: Vec<String>,
323	/// ON DELETE action
324	pub on_delete: ForeignKeyAction,
325	/// ON UPDATE action
326	pub on_update: ForeignKeyAction,
327}
328
329/// Returns true when `c` is a single-column UNIQUE constraint.
330///
331/// "Single-column" means exactly one entry in `fields`; `constraint_type` is
332/// compared case-insensitively against `"unique"` because the codebase has
333/// historically mixed `"unique"` (model registry / autodetector) and
334/// `"UNIQUE"` (`schema_diff::ConstraintSchema`) spellings for the same
335/// concept. The two diff codepaths converge here, so accept either.
336fn is_single_field_unique(c: &ConstraintDefinition) -> bool {
337	c.constraint_type.eq_ignore_ascii_case("unique") && c.fields.len() == 1
338}
339
340/// Extracts the single column name from a constraint SQL of the form
341/// `CONSTRAINT <name> UNIQUE (<column>)` and returns it when the body has no
342/// comma (i.e. it really is a single-column UNIQUE).
343///
344/// Used by `MigrationAutodetector::dedup_redundant_unique_add_constraints`
345/// to identify which `Operation::AddConstraint` operations are eligible for
346/// the redundant-emission check (multi-column UNIQUE / non-UNIQUE
347/// constraints are deliberately ignored).
348fn parse_single_column_unique(constraint_sql: &str) -> Option<&str> {
349	// Uppercase-only match: every emitter in this crate writes `UNIQUE` in
350	// upper case (see `operations::Constraint`'s `Display` impl and
351	// `schema_diff::constraint_schema_to_sql`).
352	let after_unique = constraint_sql.split(" UNIQUE (").nth(1)?;
353	let close = after_unique.find(')')?;
354	let body = after_unique[..close].trim();
355	if body.contains(',') || body.is_empty() {
356		return None;
357	}
358	Some(
359		body.strip_prefix('"')
360			.and_then(|body| body.strip_suffix('"'))
361			.unwrap_or(body),
362	)
363}
364
365impl ConstraintDefinition {
366	/// Convert ConstraintDefinition to operations::Constraint
367	pub fn to_constraint(&self) -> super::operations::Constraint {
368		match self.constraint_type.as_str() {
369			unique if unique.eq_ignore_ascii_case("unique") => {
370				super::operations::Constraint::Unique {
371					name: self.name.clone(),
372					columns: self.fields.clone(),
373				}
374			}
375			"check" => super::operations::Constraint::Check {
376				name: self.name.clone(),
377				expression: self.expression.clone().unwrap_or_default(),
378			},
379			"foreign_key" => {
380				if let Some(fk_info) = &self.foreign_key_info {
381					super::operations::Constraint::ForeignKey {
382						name: self.name.clone(),
383						columns: self.fields.clone(),
384						referenced_table: fk_info.referenced_table.clone(),
385						referenced_columns: fk_info.referenced_columns.clone(),
386						on_delete: fk_info.on_delete,
387						on_update: fk_info.on_update,
388						deferrable: None,
389					}
390				} else {
391					// Fallback if foreign_key_info is missing
392					super::operations::Constraint::ForeignKey {
393						name: self.name.clone(),
394						columns: self.fields.clone(),
395						referenced_table: String::new(),
396						referenced_columns: vec!["id".to_string()],
397						on_delete: ForeignKeyAction::Cascade,
398						on_update: ForeignKeyAction::Cascade,
399						deferrable: None,
400					}
401				}
402			}
403			"one_to_one" => {
404				if let Some(fk_info) = &self.foreign_key_info {
405					super::operations::Constraint::OneToOne {
406						name: self.name.clone(),
407						column: self.fields.first().cloned().unwrap_or_default(),
408						referenced_table: fk_info.referenced_table.clone(),
409						referenced_column: fk_info
410							.referenced_columns
411							.first()
412							.cloned()
413							.unwrap_or_else(|| "id".to_string()),
414						on_delete: fk_info.on_delete,
415						on_update: fk_info.on_update,
416						deferrable: None,
417					}
418				} else {
419					// Fallback
420					super::operations::Constraint::OneToOne {
421						name: self.name.clone(),
422						column: self.fields.first().cloned().unwrap_or_default(),
423						referenced_table: String::new(),
424						referenced_column: "id".to_string(),
425						on_delete: ForeignKeyAction::Cascade,
426						on_update: ForeignKeyAction::Cascade,
427						deferrable: None,
428					}
429				}
430			}
431			_ => {
432				// Default to Check constraint with empty expression
433				super::operations::Constraint::Check {
434					name: self.name.clone(),
435					expression: self.expression.clone().unwrap_or_default(),
436				}
437			}
438		}
439	}
440}
441
442impl ModelState {
443	/// Create a new ModelState with app_label and name
444	///
445	/// # Examples
446	///
447	/// ```rust,ignore
448	/// use reinhardt_db::migrations::ModelState;
449	///
450	/// let model = ModelState::new("myapp", "User");
451	/// assert_eq!(model.app_label, "myapp");
452	/// assert_eq!(model.name, "User");
453	/// assert_eq!(model.table_name, "user");
454	/// assert_eq!(model.fields.len(), 0);
455	/// ```
456	pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Self {
457		let name_str = name.into();
458		// Convert model name to table name (e.g., "User" -> "user", "BlogPost" -> "blog_post")
459		let table_name = to_snake_case(&name_str);
460
461		Self {
462			app_label: app_label.into(),
463			name: name_str,
464			table_name,
465			fields: std::collections::BTreeMap::new(),
466			options: std::collections::HashMap::new(),
467			base_model: None,
468			inheritance_type: None,
469			discriminator_column: None,
470			indexes: Vec::new(),
471			constraints: Vec::new(),
472			many_to_many_fields: Vec::new(),
473		}
474	}
475
476	/// Add a field to this model
477	///
478	/// # Examples
479	///
480	/// ```rust,ignore
481	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
482	///
483	/// let mut model = ModelState::new("myapp", "User");
484	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
485	/// model.add_field(field);
486	/// assert_eq!(model.fields.len(), 1);
487	/// assert!(model.has_field("email"));
488	/// ```
489	pub fn add_field(&mut self, field: FieldState) {
490		self.fields.insert(field.name.clone(), field);
491	}
492
493	/// Get a field by name
494	///
495	/// # Examples
496	///
497	/// ```rust,ignore
498	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
499	///
500	/// let mut model = ModelState::new("myapp", "User");
501	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
502	/// model.add_field(field);
503	///
504	/// let retrieved = model.get_field("email");
505	/// assert!(retrieved.is_some());
506	/// assert_eq!(retrieved.unwrap().field_type, FieldType::VarChar(255));
507	/// ```
508	pub fn get_field(&self, name: &str) -> Option<&FieldState> {
509		self.fields.get(name)
510	}
511
512	/// Check if a field exists
513	///
514	/// # Examples
515	///
516	/// ```rust,ignore
517	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
518	///
519	/// let mut model = ModelState::new("myapp", "User");
520	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
521	/// model.add_field(field);
522	///
523	/// assert!(model.has_field("email"));
524	/// assert!(!model.has_field("username"));
525	/// ```
526	pub fn has_field(&self, name: &str) -> bool {
527		self.fields.contains_key(name)
528	}
529
530	/// Rename a field
531	///
532	/// # Examples
533	///
534	/// ```rust,ignore
535	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
536	///
537	/// let mut model = ModelState::new("myapp", "User");
538	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
539	/// model.add_field(field);
540	///
541	/// model.rename_field("email", "email_address".to_string());
542	/// assert!(!model.has_field("email"));
543	/// assert!(model.has_field("email_address"));
544	/// ```
545	pub fn rename_field(&mut self, old_name: &str, new_name: String) {
546		if let Some(mut field) = self.fields.remove(old_name) {
547			field.name = new_name.clone();
548			self.fields.insert(new_name, field);
549		}
550	}
551
552	/// Add a constraint to this model
553	///
554	/// # Examples
555	///
556	/// ```rust,ignore
557	/// use reinhardt_db::migrations::{ModelState, ConstraintDefinition};
558	///
559	/// let mut model = ModelState::new("myapp", "User");
560	/// let constraint = ConstraintDefinition {
561	///     name: "unique_email".to_string(),
562	///     constraint_type: "unique".to_string(),
563	///     fields: vec!["email".to_string()],
564	///     expression: None,
565	///     foreign_key_info: None,
566	/// };
567	/// model.add_constraint(constraint);
568	/// assert_eq!(model.constraints.len(), 1);
569	/// ```
570	pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
571		self.constraints.push(constraint);
572	}
573
574	/// Add a ForeignKey constraint from field information
575	pub fn add_foreign_key_constraint_from_field(&mut self, field_name: &str) {
576		if let Some(field) = self.fields.get(field_name)
577			&& let Some(ref fk_info) = field.foreign_key
578		{
579			let constraint = ConstraintDefinition {
580				name: format!("fk_{}_{}", self.table_name, field_name),
581				constraint_type: "foreign_key".to_string(),
582				fields: vec![field_name.to_string()],
583				expression: None,
584				foreign_key_info: Some(ForeignKeyConstraintInfo {
585					referenced_table: fk_info.referenced_table.clone(),
586					referenced_columns: vec![fk_info.referenced_column.clone()],
587					on_delete: fk_info.on_delete,
588					on_update: fk_info.on_update,
589				}),
590			};
591			self.add_constraint(constraint);
592		}
593	}
594}
595
596/// Project state for migration detection
597///
598/// Django equivalent: `ProjectState` in django/db/migrations/state.py
599///
600/// # Examples
601///
602/// ```rust,ignore
603/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
604///
605/// let mut state = ProjectState::new();
606/// let mut model = ModelState::new("myapp", "User");
607/// model.add_field(FieldState::new("id", FieldType::Integer, false));
608/// state.add_model(model);
609///
610/// assert!(state.get_model("myapp", "User").is_some());
611/// ```
612#[derive(Debug, Clone)]
613pub struct ProjectState {
614	/// Models: (app_label, model_name) -> ModelState
615	pub models: std::collections::BTreeMap<(String, String), ModelState>,
616}
617
618impl Default for ProjectState {
619	fn default() -> Self {
620		Self::new()
621	}
622}
623
624impl ProjectState {
625	/// Converts to database schema.
626	pub fn to_database_schema(&self) -> super::schema_diff::DatabaseSchema {
627		let mut tables = BTreeMap::new();
628
629		for ((app_label, model_name), model_state) in &self.models {
630			let mut columns = BTreeMap::new();
631			for (field_name, field_state) in &model_state.fields {
632				// FieldType enum already contains all type information including length
633				// (e.g., VarChar(255), Decimal { precision, scale }). Direct mapping is correct.
634				// Database-specific SQL generation is handled by ColumnTypeDefinition::to_sql_for_dialect.
635				let data_type = field_state.field_type.clone();
636				let nullable = field_state.nullable;
637				let primary_key = field_state
638					.params
639					.get("primary_key")
640					.is_some_and(|s| s == "true");
641				let auto_increment = field_state
642					.params
643					.get("auto_increment")
644					.is_some_and(|s| s == "true");
645				let default = field_state.params.get("default").cloned();
646
647				columns.insert(
648					field_name.clone(),
649					super::schema_diff::ColumnSchema {
650						name: field_name.clone(),
651						data_type,
652						nullable,
653						default,
654						primary_key,
655						auto_increment,
656					},
657				);
658			}
659			// Convert constraints from ModelState to ConstraintSchema
660			let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
661				.constraints
662				.iter()
663				.map(|c| super::schema_diff::ConstraintSchema {
664					name: c.name.clone(),
665					constraint_type: c.constraint_type.clone(),
666					definition: c.fields.join(", "),
667					foreign_key_info: None,
668				})
669				.collect();
670
671			// Convert indexes from ModelState to IndexSchema
672			let indexes: Vec<super::schema_diff::IndexSchema> = model_state
673				.indexes
674				.iter()
675				.map(|idx| super::schema_diff::IndexSchema {
676					name: idx.name.clone(),
677					columns: idx.fields.clone(),
678					unique: idx.unique,
679				})
680				.collect();
681
682			// Use app_label + model_name as table key to prevent collisions
683			// across apps (Django convention: app_label_modelname)
684			let table_key = format!("{}_{}", app_label, model_name.to_lowercase());
685			tables.insert(
686				table_key,
687				super::schema_diff::TableSchema {
688					name: model_state.table_name.clone(),
689					columns,
690					indexes,
691					constraints,
692				},
693			);
694		}
695
696		super::schema_diff::DatabaseSchema { tables }
697	}
698
699	/// Convert ProjectState to DatabaseSchema for a specific app
700	///
701	/// This method filters models by app_label before converting to DatabaseSchema,
702	/// allowing per-app migration generation.
703	///
704	/// # Examples
705	///
706	/// ```rust,ignore
707	/// use reinhardt_db::migrations::ProjectState;
708	///
709	/// let state = ProjectState::from_global_registry();
710	/// let schema = state.to_database_schema_for_app("users");
711	/// // schema contains only tables for the "users" app
712	/// ```
713	pub fn to_database_schema_for_app(
714		&self,
715		app_label: &str,
716	) -> super::schema_diff::DatabaseSchema {
717		let mut tables = BTreeMap::new();
718
719		for ((this_app_label, model_name), model_state) in &self.models {
720			// Filter by app_label
721			if this_app_label == app_label {
722				let mut columns = BTreeMap::new();
723				for (field_name, field_state) in &model_state.fields {
724					let data_type = field_state.field_type.clone();
725					let nullable = field_state.nullable;
726					let primary_key = field_state
727						.params
728						.get("primary_key")
729						.is_some_and(|s| s == "true");
730					let auto_increment = field_state
731						.params
732						.get("auto_increment")
733						.is_some_and(|s| s == "true");
734					let default = field_state.params.get("default").cloned();
735
736					columns.insert(
737						field_name.clone(),
738						super::schema_diff::ColumnSchema {
739							name: field_name.clone(),
740							data_type,
741							nullable,
742							default,
743							primary_key,
744							auto_increment,
745						},
746					);
747				}
748
749				// Convert constraints from ModelState to ConstraintSchema
750				let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
751					.constraints
752					.iter()
753					.map(|c| super::schema_diff::ConstraintSchema {
754						name: c.name.clone(),
755						constraint_type: c.constraint_type.clone(),
756						definition: c.fields.join(", "),
757						foreign_key_info: None,
758					})
759					.collect();
760
761				// Convert indexes from ModelState to IndexSchema
762				let indexes: Vec<super::schema_diff::IndexSchema> = model_state
763					.indexes
764					.iter()
765					.map(|idx| super::schema_diff::IndexSchema {
766						name: idx.name.clone(),
767						columns: idx.fields.clone(),
768						unique: idx.unique,
769					})
770					.collect();
771
772				// Use app_label + model_name as table key to prevent collisions
773				// across apps (Django convention: app_label_modelname)
774				let table_key = format!("{}_{}", this_app_label, model_name.to_lowercase());
775				tables.insert(
776					table_key,
777					super::schema_diff::TableSchema {
778						name: model_state.table_name.clone(),
779						columns,
780						indexes,
781						constraints,
782					},
783				);
784			}
785		}
786
787		super::schema_diff::DatabaseSchema { tables }
788	}
789
790	/// Create a new empty ProjectState
791	///
792	/// # Examples
793	///
794	/// ```rust,ignore
795	/// use reinhardt_db::migrations::ProjectState;
796	///
797	/// let state = ProjectState::new();
798	/// assert_eq!(state.models.len(), 0);
799	/// ```
800	pub fn new() -> Self {
801		Self {
802			models: std::collections::BTreeMap::new(),
803		}
804	}
805
806	/// Add a model to this project state
807	///
808	/// # Examples
809	///
810	/// ```rust,ignore
811	/// use reinhardt_db::migrations::{ProjectState, ModelState};
812	///
813	/// let mut state = ProjectState::new();
814	/// let model = ModelState::new("myapp", "User");
815	/// state.add_model(model);
816	///
817	/// assert_eq!(state.models.len(), 1);
818	/// assert!(state.get_model("myapp", "User").is_some());
819	/// ```
820	pub fn add_model(&mut self, model: ModelState) {
821		let key = (model.app_label.clone(), model.name.clone());
822		self.models.insert(key, model);
823	}
824
825	/// Get a model by app_label and model_name
826	///
827	/// # Examples
828	///
829	/// ```rust,ignore
830	/// use reinhardt_db::migrations::{ProjectState, ModelState};
831	///
832	/// let mut state = ProjectState::new();
833	/// let model = ModelState::new("myapp", "User");
834	/// state.add_model(model);
835	///
836	/// let retrieved = state.get_model("myapp", "User");
837	/// assert!(retrieved.is_some());
838	/// assert_eq!(retrieved.unwrap().name, "User");
839	/// ```
840	pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<&ModelState> {
841		self.models
842			.get(&(app_label.to_string(), model_name.to_string()))
843	}
844
845	/// Get a mutable reference to a model
846	///
847	/// # Examples
848	///
849	/// ```rust,ignore
850	/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
851	///
852	/// let mut state = ProjectState::new();
853	/// let model = ModelState::new("myapp", "User");
854	/// state.add_model(model);
855	///
856	/// if let Some(model) = state.get_model_mut("myapp", "User") {
857	///     let field = FieldState::new("email", FieldType::VarChar(255), false);
858	///     model.add_field(field);
859	/// }
860	///
861	/// assert!(state.get_model("myapp", "User").unwrap().has_field("email"));
862	/// ```
863	pub fn get_model_mut(&mut self, app_label: &str, model_name: &str) -> Option<&mut ModelState> {
864		self.models
865			.get_mut(&(app_label.to_string(), model_name.to_string()))
866	}
867
868	/// Get primary key field type for a model
869	///
870	/// Returns the field type of the primary key, defaulting to Uuid if not found
871	/// or if the model is not in the state.
872	///
873	/// # Examples
874	///
875	/// ```ignore
876	/// # // This method is private and cannot be called from external code
877	/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
878	///
879	/// let mut state = ProjectState::new();
880	/// let mut model = ModelState::new("myapp", "User");
881	/// model.add_field(FieldState::new("id", FieldType::Integer, false));
882	/// state.add_model(model);
883	///
884	/// let pk_type = state.get_primary_key_type("myapp", "User");
885	/// assert_eq!(pk_type, FieldType::Integer);
886	/// ```
887	fn get_primary_key_type(&self, app_label: &str, model_name: &str) -> super::FieldType {
888		// JSON update
889		if let Some(model_state) = self.get_model(app_label, model_name) {
890			// Search the “id” field (by default primary key name)
891			if let Some((_, id_field)) = model_state
892				.fields
893				.iter()
894				.find(|(name, _)| name.as_str() == "id")
895			{
896				return id_field.field_type.clone();
897			}
898
899			// Search fields with the primary_key parameter
900			if let Some((_, pk_field)) = model_state
901				.fields
902				.iter()
903				.find(|(_, f)| f.params.get("primary_key").map(String::as_str) == Some("true"))
904			{
905				return pk_field.field_type.clone();
906			}
907		}
908
909		// If not found in to_state, search the global registry
910		if let Some(model_meta) =
911			super::model_registry::global_registry().get_model(app_label, model_name)
912		{
913			// Search the “id” fields
914			if let Some(id_field) = model_meta.fields.get("id") {
915				return id_field.field_type.clone();
916			}
917
918			// Search fields with the primary_key parameter
919			for field_meta in model_meta.fields.values() {
920				if field_meta.params.get("primary_key").map(String::as_str) == Some("true") {
921					return field_meta.field_type.clone();
922				}
923			}
924		}
925
926		// The default is UUID (current hardcoded value)
927		super::FieldType::Uuid
928	}
929
930	/// Get a model by table name
931	///
932	/// # Examples
933	///
934	/// ```rust,ignore
935	/// use reinhardt_db::migrations::{ProjectState, ModelState};
936	///
937	/// let mut state = ProjectState::new();
938	/// let mut model = ModelState::new("myapp", "User");
939	/// model.table_name = "myapp_user".to_string();
940	/// state.add_model(model);
941	///
942	/// let retrieved = state.get_model_by_table_name("myapp", "myapp_user");
943	/// assert!(retrieved.is_some());
944	/// assert_eq!(retrieved.unwrap().name, "User");
945	/// ```
946	pub fn get_model_by_table_name(
947		&self,
948		app_label: &str,
949		table_name: &str,
950	) -> Option<&ModelState> {
951		self.models
952			.values()
953			.find(|model| model.app_label == app_label && model.table_name == table_name)
954	}
955
956	/// Filter models by app_label and return a new ProjectState containing only those models
957	///
958	/// This method is used to create app-specific ProjectState for per-app migration generation.
959	///
960	/// # Examples
961	///
962	/// ```rust,ignore
963	/// use reinhardt_db::migrations::{ProjectState, ModelState};
964	///
965	/// let mut state = ProjectState::new();
966	/// state.add_model(ModelState::new("users", "User"));
967	/// state.add_model(ModelState::new("users", "Profile"));
968	/// state.add_model(ModelState::new("posts", "Post"));
969	///
970	/// let users_state = state.filter_by_app("users");
971	/// assert_eq!(users_state.models.len(), 2);
972	/// assert!(users_state.get_model("users", "User").is_some());
973	/// assert!(users_state.get_model("users", "Profile").is_some());
974	/// assert!(users_state.get_model("posts", "Post").is_none());
975	/// ```
976	pub fn filter_by_app(&self, app_label: &str) -> Self {
977		let mut filtered = Self::new();
978		for ((app, _model_name), model_state) in &self.models {
979			if app == app_label {
980				filtered.add_model(model_state.clone());
981			}
982		}
983		filtered
984	}
985
986	/// Remove a model from this project state
987	///
988	/// # Examples
989	///
990	/// ```rust,ignore
991	/// use reinhardt_db::migrations::{ProjectState, ModelState};
992	///
993	/// let mut state = ProjectState::new();
994	/// let model = ModelState::new("myapp", "User");
995	/// state.add_model(model);
996	///
997	/// state.remove_model("myapp", "User");
998	/// assert!(state.get_model("myapp", "User").is_none());
999	/// ```
1000	pub fn remove_model(&mut self, app_label: &str, model_name: &str) -> Option<ModelState> {
1001		self.models
1002			.remove(&(app_label.to_string(), model_name.to_string()))
1003	}
1004
1005	/// Rename a model
1006	///
1007	/// # Examples
1008	///
1009	/// ```rust,ignore
1010	/// use reinhardt_db::migrations::{ProjectState, ModelState};
1011	///
1012	/// let mut state = ProjectState::new();
1013	/// let model = ModelState::new("myapp", "User");
1014	/// state.add_model(model);
1015	///
1016	/// state.rename_model("myapp", "User", "Account".to_string());
1017	/// assert!(state.get_model("myapp", "User").is_none());
1018	/// assert!(state.get_model("myapp", "Account").is_some());
1019	/// ```
1020	pub fn rename_model(&mut self, app_label: &str, old_name: &str, new_name: String) {
1021		if let Some(mut model) = self
1022			.models
1023			.remove(&(app_label.to_string(), old_name.to_string()))
1024		{
1025			model.name = new_name.clone();
1026			self.models.insert((app_label.to_string(), new_name), model);
1027		}
1028	}
1029
1030	/// Load ProjectState from the global model registry
1031	///
1032	/// Django equivalent: `ProjectState.from_apps()` in django/db/migrations/state.py:594-600
1033	///
1034	/// # Examples
1035	///
1036	/// ```rust,ignore
1037	/// use reinhardt_db::migrations::ProjectState;
1038	///
1039	/// let state = ProjectState::from_global_registry();
1040	/// // state will contain all models registered in the global registry
1041	/// ```
1042	pub fn from_global_registry() -> Self {
1043		use super::model_registry::global_registry;
1044
1045		let registry = global_registry();
1046		let models_metadata = registry.get_models();
1047
1048		let mut state = ProjectState::new();
1049		let mut intermediate_tables = Vec::new();
1050
1051		// First, add all regular models
1052		for metadata in &models_metadata {
1053			let model_state = metadata.to_model_state();
1054			state.add_model(model_state);
1055		}
1056
1057		// Then, generate intermediate tables for ManyToMany relationships
1058		for metadata in &models_metadata {
1059			for m2m in &metadata.many_to_many_fields {
1060				// Generate intermediate table for this ManyToMany relationship
1061				let intermediate_table = state.create_intermediate_table_for_m2m(
1062					&metadata.app_label,
1063					&metadata.model_name,
1064					&metadata.table_name,
1065					m2m,
1066				);
1067				intermediate_tables.push(intermediate_table);
1068			}
1069		}
1070
1071		// Add all intermediate tables to state
1072		for table in intermediate_tables {
1073			state.add_model(table);
1074		}
1075
1076		state
1077	}
1078
1079	/// Create an intermediate table ModelState for a ManyToMany relationship
1080	///
1081	/// This generates a ModelState representing the intermediate/junction table
1082	/// for a ManyToMany relationship.
1083	///
1084	/// # Arguments
1085	///
1086	/// * `source_app_label` - The app label of the source model (e.g., "auth")
1087	/// * `source_model_name` - The name of the source model (e.g., "User")
1088	/// * `source_table_name` - The table name of the source model (e.g., "auth_user")
1089	/// * `m2m` - ManyToMany relationship metadata
1090	///
1091	/// # Returns
1092	///
1093	/// A `ModelState` representing the intermediate table with:
1094	/// - Auto-increment primary key `id`
1095	/// - Foreign key to source model: `{source_table}_id` (or
1096	///   `from_{source_table}_id` for self-referencing M2M)
1097	/// - Foreign key to target model: `{target_table}_id` (or
1098	///   `to_{target_table}_id` for self-referencing M2M)
1099	/// - Foreign key constraints with CASCADE
1100	/// - Unique constraint on (source_id, target_id)
1101	fn create_intermediate_table_for_m2m(
1102		&self,
1103		source_app_label: &str,
1104		source_model_name: &str,
1105		source_table_name: &str,
1106		m2m: &super::model_registry::ManyToManyMetadata,
1107	) -> ModelState {
1108		// Default through-table and column names come from the canonical
1109		// convention in `crate::m2m_naming` (also re-exported as
1110		// `crate::migrations::naming`) so this site cannot drift from
1111		// `detect_created_many_to_many` (lookup) or `ManyToManyAccessor`
1112		// (runtime). See issue #4665.
1113		let table_name = m2m.through.clone().unwrap_or_else(|| {
1114			crate::m2m_naming::default_through_table(source_table_name, &m2m.field_name)
1115		});
1116
1117		// Generate model name: PascalCase version of field_name
1118		// Example: "following" -> "UserFollowing"
1119		let model_name = format!("{}{}", source_model_name, to_pascal_case(&m2m.field_name));
1120
1121		let mut model_state = ModelState::new(source_app_label, &model_name);
1122		model_state.table_name = table_name.clone();
1123
1124		// Add primary key field: id
1125		let mut id_field = FieldState::new("id".to_string(), super::FieldType::Integer, false);
1126		id_field
1127			.params
1128			.insert("primary_key".to_string(), "true".to_string());
1129		id_field
1130			.params
1131			.insert("auto_increment".to_string(), "true".to_string());
1132		model_state.add_field(id_field);
1133
1134		// Determine the primary key type for the source and target from the registry
1135		let source_pk_type = self.get_primary_key_type(source_app_label, source_model_name);
1136		// Extract target app_label from to_model (may be in "app.Model" format)
1137		let (target_app, target_model) =
1138			self.resolve_model_reference(&m2m.to_model, source_app_label);
1139
1140		let target_pk_type = self.get_primary_key_type(&target_app, &target_model);
1141
1142		// Resolve the target table name from ProjectState, falling back to the
1143		// `app_label`-prefixed snake_case form as a last-resort heuristic.
1144		let target_table_name = self
1145			.get_model(&target_app, &target_model)
1146			.map(|m| m.table_name.clone())
1147			.unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
1148
1149		// Default FK column names come from the canonical convention in
1150		// `crate::m2m_naming::default_m2m_columns` (single source of truth,
1151		// see issue #4665): `{table}_id` for non-self-referential relations,
1152		// and `from_/to_` prefixes when source and target tables match.
1153		// Keying off the *actual* table names (not the struct identifiers)
1154		// matches the ORM accessor fallback in
1155		// `crates/reinhardt-db/src/orm/many_to_many_accessor.rs` and the
1156		// on-disk schema produced by initial migrations (#4659).
1157		let (default_source_col, default_target_col) =
1158			crate::m2m_naming::default_m2m_columns(source_table_name, &target_table_name);
1159		let source_field_name = m2m.source_field.clone().unwrap_or(default_source_col);
1160		let target_field_name = m2m.target_field.clone().unwrap_or(default_target_col);
1161
1162		// Add foreign key to source model
1163		let mut from_field =
1164			FieldState::new(source_field_name.clone(), source_pk_type.clone(), false);
1165		from_field
1166			.params
1167			.insert("not_null".to_string(), "true".to_string());
1168		from_field.foreign_key = Some(ForeignKeyInfo {
1169			referenced_table: source_table_name.to_string(),
1170			referenced_column: "id".to_string(),
1171			on_delete: ForeignKeyAction::Cascade,
1172			on_update: ForeignKeyAction::Cascade,
1173		});
1174		model_state.add_field(from_field);
1175
1176		// Add foreign key to target model
1177		let mut to_field = FieldState::new(target_field_name.clone(), target_pk_type, false);
1178		to_field
1179			.params
1180			.insert("not_null".to_string(), "true".to_string());
1181		to_field.foreign_key = Some(ForeignKeyInfo {
1182			referenced_table: target_table_name,
1183			referenced_column: "id".to_string(),
1184			on_delete: ForeignKeyAction::Cascade,
1185			on_update: ForeignKeyAction::Cascade,
1186		});
1187		model_state.add_field(to_field);
1188
1189		// Add foreign key constraints
1190		model_state.add_foreign_key_constraint_from_field(&source_field_name);
1191		model_state.add_foreign_key_constraint_from_field(&target_field_name);
1192
1193		// Add unique constraint on (from_id, to_id)
1194		let unique_constraint = ConstraintDefinition {
1195			name: format!("{}_unique", table_name),
1196			constraint_type: "unique".to_string(),
1197			fields: vec![source_field_name, target_field_name],
1198			expression: None,
1199			foreign_key_info: None,
1200		};
1201		model_state.constraints.push(unique_constraint);
1202
1203		model_state
1204	}
1205
1206	fn resolve_model_reference(&self, reference: &str, current_app: &str) -> (String, String) {
1207		let parts: Vec<&str> = reference.split('.').collect();
1208		match parts.as_slice() {
1209			[app, model] => (app.to_string(), model.to_string()),
1210			[model] => {
1211				let model = model.to_string();
1212				if self.get_model(current_app, &model).is_some() {
1213					(current_app.to_string(), model)
1214				} else {
1215					let app = self
1216						.models
1217						.keys()
1218						.find_map(|(app_label, model_name)| {
1219							(model_name == &model).then(|| app_label.clone())
1220						})
1221						.or_else(|| {
1222							super::model_registry::global_registry()
1223								.get_models()
1224								.iter()
1225								.find(|metadata| metadata.model_name == model)
1226								.map(|metadata| metadata.app_label.clone())
1227						})
1228						.unwrap_or_else(|| current_app.to_string());
1229					(app, model)
1230				}
1231			}
1232			_ => (current_app.to_string(), reference.to_string()),
1233		}
1234	}
1235
1236	/// Load ProjectState from a list of migrations
1237	///
1238	/// This method constructs a ProjectState by applying all operations
1239	/// from the provided migrations in order. This is useful for determining
1240	/// what the database schema should look like after applying all migrations.
1241	///
1242	/// # Examples
1243	///
1244	/// ```rust,ignore
1245	/// use reinhardt_db::migrations::{ProjectState, Migration};
1246	///
1247	/// let migrations = vec![/* ... */];
1248	/// let state = ProjectState::from_migrations(&migrations);
1249	/// // state will contain all models as they would exist after applying all migrations
1250	/// ```
1251	pub fn from_migrations(migrations: &[super::migration::Migration]) -> Self {
1252		let mut state = Self::new();
1253		for migration in migrations {
1254			state.apply_migration_operations(&migration.operations, &migration.app_label);
1255		}
1256		state
1257	}
1258
1259	/// Apply migration operations to this project state
1260	///
1261	/// This method processes each operation and updates the ProjectState accordingly.
1262	/// It handles:
1263	/// - CreateTable: Creates a new model
1264	/// - DropTable: Removes a model
1265	/// - AddColumn: Adds a field to a model
1266	/// - DropColumn: Removes a field from a model
1267	/// - AlterColumn: Modifies a field
1268	/// - RenameTable: Renames a model's table
1269	/// - RenameColumn: Renames a field
1270	/// - CreateIndex/DropIndex: Tracks model indexes
1271	/// - Other operations are logged but not applied to state
1272	pub fn apply_migration_operations(
1273		&mut self,
1274		operations: &[super::operations::Operation],
1275		app_label: &str,
1276	) {
1277		use super::operations::Operation;
1278
1279		for op in operations {
1280			match op {
1281				Operation::CreateTable {
1282					name,
1283					columns,
1284					constraints,
1285					..
1286				} => {
1287					// Create a new model from the table definition
1288					// Use the provided app_label instead of hardcoding "auto"
1289					// Convert table name to model name (PascalCase)
1290					let model_name = Self::table_name_to_model_name(name, app_label);
1291					let mut model = ModelState::new(app_label, model_name);
1292					model.table_name = name.to_string();
1293
1294					// Convert columns to fields
1295					for col in columns {
1296						let field = self.column_def_to_field_state(col);
1297						model.add_field(field);
1298					}
1299					for constraint in constraints {
1300						model
1301							.constraints
1302							.push(Self::constraint_to_definition(constraint));
1303					}
1304
1305					self.add_model(model);
1306				}
1307				Operation::CreateIndex {
1308					table,
1309					columns,
1310					unique,
1311					index_type,
1312					where_clause,
1313					concurrently,
1314					expressions,
1315					mysql_options,
1316					operator_class,
1317				} => {
1318					let name = super::operations::generated_index_name(
1319						table,
1320						columns,
1321						expressions.as_deref(),
1322					);
1323					let index = IndexDefinition {
1324						name,
1325						fields: columns.clone(),
1326						unique: *unique,
1327						where_clause: where_clause.clone(),
1328						index_type: *index_type,
1329						expressions: expressions.clone(),
1330						concurrently: *concurrently,
1331						mysql_options: *mysql_options,
1332						operator_class: operator_class.clone(),
1333					};
1334					let is_advanced = where_clause.is_some()
1335						|| index_type.is_some()
1336						|| expressions.is_some()
1337						|| operator_class.is_some();
1338					if let Some(model) = self.find_model_by_table_mut(table)
1339						&& !model.indexes.iter().any(|existing| {
1340							index_definitions_equivalent(existing, &index)
1341								&& model_index_is_advanced(model, existing) == is_advanced
1342						}) {
1343						model.indexes.push(index.clone());
1344						if is_advanced {
1345							model
1346								.options
1347								.insert(advanced_index_option_key(&index.name), "true".to_string());
1348						}
1349					}
1350				}
1351				Operation::CreateIndexRepair {
1352					table,
1353					name,
1354					columns,
1355					unique,
1356					index_type,
1357					where_clause,
1358					concurrently,
1359					expressions,
1360					mysql_options,
1361					operator_class,
1362				} => {
1363					let name = name.clone().unwrap_or_else(|| {
1364						super::operations::generated_index_name(
1365							table,
1366							columns,
1367							expressions.as_deref(),
1368						)
1369					});
1370					let index = IndexDefinition {
1371						name,
1372						fields: columns.clone(),
1373						unique: *unique,
1374						where_clause: where_clause.clone(),
1375						index_type: *index_type,
1376						expressions: expressions.clone(),
1377						concurrently: *concurrently,
1378						mysql_options: *mysql_options,
1379						operator_class: operator_class.clone(),
1380					};
1381					if let Some(model) = self.find_model_by_table_mut(table)
1382						&& !model
1383							.indexes
1384							.iter()
1385							.any(|existing| index_definitions_equivalent(existing, &index))
1386					{
1387						model.indexes.push(index);
1388					}
1389				}
1390				Operation::DropIndex { table, columns } => {
1391					if let Some(model) = self.find_model_by_table_mut(table) {
1392						let generated_name =
1393							super::operations::generated_index_name(table, columns, None);
1394						model.indexes.retain(|index| index.name != generated_name);
1395						model
1396							.options
1397							.remove(&advanced_index_option_key(&generated_name));
1398					}
1399				}
1400				Operation::DropNamedIndex { table, name, .. } => {
1401					if let Some(model) = self.find_model_by_table_mut(table) {
1402						model.indexes.retain(|index| index.name != *name);
1403						model.options.remove(&advanced_index_option_key(name));
1404					}
1405				}
1406				Operation::DropTable { name } => {
1407					// Find and remove the model with this table name
1408					let keys_to_remove: Vec<_> = self
1409						.models
1410						.iter()
1411						.filter(|(_, model)| model.table_name == *name)
1412						.map(|(key, _)| key.clone())
1413						.collect();
1414
1415					for key in keys_to_remove {
1416						self.models.remove(&key);
1417					}
1418				}
1419				Operation::AddColumn { table, column, .. } => {
1420					// Find the model with this table name and add the field
1421					let field = self.column_def_to_field_state(column);
1422					if let Some(model) = self.find_model_by_table_mut(table) {
1423						model.add_field(field);
1424					}
1425				}
1426				Operation::DropColumn { table, column } => {
1427					// Find the model and remove the field
1428					if let Some(model) = self.find_model_by_table_mut(table) {
1429						let removed_names: Vec<_> = model
1430							.indexes
1431							.iter()
1432							.filter(|index| Self::index_definition_references_column(index, column))
1433							.map(|index| index.name.clone())
1434							.collect();
1435						model.fields.remove(column);
1436						model.indexes.retain(|index| {
1437							!Self::index_definition_references_column(index, column)
1438						});
1439						for name in removed_names {
1440							model.options.remove(&advanced_index_option_key(&name));
1441						}
1442						model.constraints.retain(|constraint| {
1443							!constraint.fields.iter().any(|field| field == column)
1444						});
1445					}
1446				}
1447				Operation::AlterColumn {
1448					table,
1449					column,
1450					new_definition,
1451					..
1452				} => {
1453					// Find the model and update the field
1454					let new_field = self.column_def_to_field_state(new_definition);
1455					// Keep the old field name but update everything else
1456					let mut updated_field = new_field;
1457					updated_field.name = column.to_string();
1458
1459					// If model exists, update the field
1460					if let Some(model) = self.find_model_by_table_mut(table) {
1461						model.fields.insert(column.to_string(), updated_field);
1462					} else {
1463						// If model doesn't exist, create it and add the field
1464						// This handles the case where AlterColumn is used in initial migrations
1465						// before CreateTable (which shouldn't happen, but does in some legacy migrations)
1466						let model_name = Self::table_name_to_model_name(table, app_label);
1467						let mut model = ModelState::new(app_label, model_name);
1468						model.table_name = table.to_string();
1469						model.add_field(updated_field);
1470						self.add_model(model);
1471					}
1472				}
1473				Operation::RenameTable { old_name, new_name } => {
1474					// Find the model with old table name and update it
1475					if let Some(model) = self.find_model_by_table_mut(old_name) {
1476						let advanced_index_renames: Vec<_> = model
1477							.indexes
1478							.iter()
1479							.filter(|index| model_index_is_advanced(model, index))
1480							.map(|index| {
1481								let old_default = default_index_name(old_name, &index.fields);
1482								let old_legacy =
1483									format!("{}_{}_idx", old_name, index.fields.join("_"));
1484								let renamed_index_name =
1485									if index.name == old_default || index.name == old_legacy {
1486										default_index_name(new_name, &index.fields)
1487									} else {
1488										index.name.clone()
1489									};
1490								(index.name.clone(), renamed_index_name)
1491							})
1492							.collect();
1493						for index in &mut model.indexes {
1494							let old_default = default_index_name(old_name, &index.fields);
1495							let old_legacy = format!("{}_{}_idx", old_name, index.fields.join("_"));
1496							if index.name == old_default || index.name == old_legacy {
1497								index.name = default_index_name(new_name, &index.fields);
1498							}
1499						}
1500						for (old_index_name, new_index_name) in advanced_index_renames {
1501							model
1502								.options
1503								.remove(&advanced_index_option_key(&old_index_name));
1504							model.options.insert(
1505								advanced_index_option_key(&new_index_name),
1506								"true".to_string(),
1507							);
1508						}
1509						model.table_name = new_name.to_string();
1510					}
1511				}
1512				Operation::RenameColumn {
1513					table,
1514					old_name,
1515					new_name,
1516				} => {
1517					// Find the model and rename the field
1518					if let Some(model) = self.find_model_by_table_mut(table) {
1519						let advanced_index_renames: Vec<_> = model
1520							.indexes
1521							.iter()
1522							.filter(|index| model_index_is_advanced(model, index))
1523							.map(|index| {
1524								let old_fields = index.fields.clone();
1525								let old_default = default_index_name(table, &old_fields);
1526								let old_legacy = format!("{}_{}_idx", table, old_fields.join("_"));
1527								let mut new_fields = old_fields.clone();
1528								for field in &mut new_fields {
1529									if field == old_name {
1530										*field = new_name.clone();
1531									}
1532								}
1533								let new_index_name =
1534									if index.name == old_default || index.name == old_legacy {
1535										default_index_name(table, &new_fields)
1536									} else {
1537										index.name.clone()
1538									};
1539								(index.name.clone(), new_index_name)
1540							})
1541							.collect();
1542						model.rename_field(old_name, new_name.to_string());
1543						for index in &mut model.indexes {
1544							if !index.fields.iter().any(|field| field == old_name) {
1545								continue;
1546							}
1547							let old_fields = index.fields.clone();
1548							let old_default = default_index_name(table, &old_fields);
1549							let old_legacy = format!("{}_{}_idx", table, old_fields.join("_"));
1550							for field in &mut index.fields {
1551								if field == old_name {
1552									*field = new_name.to_string();
1553								}
1554							}
1555							if index.name == old_default || index.name == old_legacy {
1556								index.name = default_index_name(table, &index.fields);
1557							}
1558						}
1559						for (old_index_name, new_index_name) in advanced_index_renames {
1560							model
1561								.options
1562								.remove(&advanced_index_option_key(&old_index_name));
1563							model.options.insert(
1564								advanced_index_option_key(&new_index_name),
1565								"true".to_string(),
1566							);
1567						}
1568						for constraint in &mut model.constraints {
1569							for field in &mut constraint.fields {
1570								if field == old_name {
1571									*field = new_name.to_string();
1572								}
1573							}
1574						}
1575					}
1576				}
1577				Operation::AddConstraint {
1578					table,
1579					constraint_sql,
1580				} => {
1581					if let Some(model) = self.find_model_by_table_mut(table)
1582						&& let Some(constraint) =
1583							Self::constraint_definition_from_sql(constraint_sql)
1584						&& !model.constraints.iter().any(|c| c.name == constraint.name)
1585					{
1586						model.constraints.push(constraint);
1587					}
1588				}
1589				Operation::DropConstraint {
1590					table,
1591					constraint_name,
1592				} => {
1593					if let Some(model) = self.find_model_by_table_mut(table) {
1594						model
1595							.constraints
1596							.retain(|constraint| constraint.name != *constraint_name);
1597					}
1598				}
1599				// Other operations don't affect the schema state in ways we track.
1600				_ => {
1601					// Operations like RunSQL and other backend-only operations are not
1602					// currently tracked in ProjectState.
1603				}
1604			}
1605		}
1606	}
1607
1608	fn index_definition_references_column(index: &IndexDefinition, column: &str) -> bool {
1609		index.fields.iter().any(|field| field == column)
1610			|| index.expressions.as_deref().is_some_and(|expressions| {
1611				expressions
1612					.iter()
1613					.any(|expression| Self::expression_references_column(expression, column))
1614			}) || index
1615			.where_clause
1616			.as_deref()
1617			.is_some_and(|where_clause| Self::expression_references_column(where_clause, column))
1618	}
1619
1620	fn expression_references_column(expression: &str, column: &str) -> bool {
1621		let mut token = String::new();
1622		let mut in_string = false;
1623		let mut characters = expression.chars().peekable();
1624
1625		while let Some(character) = characters.next() {
1626			if character == '\'' {
1627				in_string = !in_string;
1628				if !in_string {
1629					token.clear();
1630				}
1631				continue;
1632			}
1633			if in_string {
1634				continue;
1635			}
1636			if character.is_ascii_alphanumeric() || character == '_' {
1637				token.push(character);
1638			} else if !token.is_empty() {
1639				let is_function_call = character == '('
1640					|| (character.is_ascii_whitespace()
1641						&& characters
1642							.clone()
1643							.find(|next| !next.is_ascii_whitespace())
1644							.is_some_and(|next| next == '('));
1645				if !is_function_call && token.eq_ignore_ascii_case(column) {
1646					return true;
1647				}
1648				token.clear();
1649			}
1650		}
1651
1652		!token.is_empty() && token.eq_ignore_ascii_case(column)
1653	}
1654
1655	/// Helper: Find a model by table name (immutable)
1656	pub fn find_model_by_table(&self, table_name: &str) -> Option<&ModelState> {
1657		self.models
1658			.values()
1659			.find(|model| model.table_name == table_name)
1660	}
1661
1662	/// Helper: Find a model by table name (mutable)
1663	pub fn find_model_by_table_mut(&mut self, table_name: &str) -> Option<&mut ModelState> {
1664		self.models
1665			.values_mut()
1666			.find(|model| model.table_name == table_name)
1667	}
1668
1669	/// Helper: Convert table name to model name (PascalCase)
1670	///
1671	/// Examples:
1672	/// - `auth_user` → `User` (with app_label="auth")
1673	/// - `auth_password_reset_token` → `PasswordResetToken`
1674	/// - `dm_message` → `DMMessage`
1675	/// - `dm_room` → `DMRoom`
1676	/// - `profile_profile` → `Profile`
1677	fn table_name_to_model_name(table_name: &str, app_label: &str) -> String {
1678		// Remove app_label prefix if present (e.g., "auth_user" → "user")
1679		let prefix = format!("{}_", app_label);
1680		let name_without_prefix = if table_name.starts_with(&prefix) {
1681			&table_name[prefix.len()..]
1682		} else {
1683			table_name
1684		};
1685
1686		// Convert snake_case to PascalCase
1687		name_without_prefix
1688			.split('_')
1689			.map(|word| {
1690				let mut chars = word.chars();
1691				match chars.next() {
1692					Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1693					None => String::new(),
1694				}
1695			})
1696			.collect()
1697	}
1698
1699	fn constraint_to_definition(
1700		constraint: &super::operations::Constraint,
1701	) -> ConstraintDefinition {
1702		match constraint {
1703			super::operations::Constraint::PrimaryKey { name, columns } => ConstraintDefinition {
1704				name: name.clone(),
1705				constraint_type: "primary_key".to_string(),
1706				fields: columns.clone(),
1707				expression: None,
1708				foreign_key_info: None,
1709			},
1710			super::operations::Constraint::ForeignKey {
1711				name,
1712				columns,
1713				referenced_table,
1714				referenced_columns,
1715				on_delete,
1716				on_update,
1717				..
1718			} => ConstraintDefinition {
1719				name: name.clone(),
1720				constraint_type: "foreign_key".to_string(),
1721				fields: columns.clone(),
1722				expression: None,
1723				foreign_key_info: Some(ForeignKeyConstraintInfo {
1724					referenced_table: referenced_table.clone(),
1725					referenced_columns: referenced_columns.clone(),
1726					on_delete: *on_delete,
1727					on_update: *on_update,
1728				}),
1729			},
1730			super::operations::Constraint::Unique { name, columns } => ConstraintDefinition {
1731				name: name.clone(),
1732				constraint_type: "unique".to_string(),
1733				fields: columns.clone(),
1734				expression: None,
1735				foreign_key_info: None,
1736			},
1737			super::operations::Constraint::Check { name, expression } => ConstraintDefinition {
1738				name: name.clone(),
1739				constraint_type: "check".to_string(),
1740				fields: Vec::new(),
1741				expression: Some(expression.clone()),
1742				foreign_key_info: None,
1743			},
1744			super::operations::Constraint::OneToOne {
1745				name,
1746				column,
1747				referenced_table,
1748				referenced_column,
1749				on_delete,
1750				on_update,
1751				..
1752			} => ConstraintDefinition {
1753				name: name.clone(),
1754				constraint_type: "one_to_one".to_string(),
1755				fields: vec![column.clone()],
1756				expression: None,
1757				foreign_key_info: Some(ForeignKeyConstraintInfo {
1758					referenced_table: referenced_table.clone(),
1759					referenced_columns: vec![referenced_column.clone()],
1760					on_delete: *on_delete,
1761					on_update: *on_update,
1762				}),
1763			},
1764			super::operations::Constraint::ManyToMany {
1765				name,
1766				source_column,
1767				target_column,
1768				..
1769			} => ConstraintDefinition {
1770				name: name.clone(),
1771				constraint_type: "many_to_many".to_string(),
1772				fields: vec![source_column.clone(), target_column.clone()],
1773				expression: None,
1774				foreign_key_info: None,
1775			},
1776			super::operations::Constraint::Exclude { name, elements, .. } => ConstraintDefinition {
1777				name: name.clone(),
1778				constraint_type: "exclude".to_string(),
1779				fields: elements.iter().map(|(field, _)| field.clone()).collect(),
1780				expression: None,
1781				foreign_key_info: None,
1782			},
1783		}
1784	}
1785
1786	fn trim_sql_identifier(identifier: &str) -> String {
1787		let trimmed = identifier.trim();
1788		let Some(quote) = trimmed.chars().next() else {
1789			return String::new();
1790		};
1791		let stripped = match quote {
1792			'"' => trimmed
1793				.strip_prefix('"')
1794				.and_then(|value| value.strip_suffix('"')),
1795			'`' => trimmed
1796				.strip_prefix('`')
1797				.and_then(|value| value.strip_suffix('`')),
1798			'\'' => trimmed
1799				.strip_prefix('\'')
1800				.and_then(|value| value.strip_suffix('\'')),
1801			_ => None,
1802		};
1803		if let Some(stripped) = stripped {
1804			stripped.replace(&format!("{quote}{quote}"), &quote.to_string())
1805		} else {
1806			trimmed.to_string()
1807		}
1808	}
1809
1810	fn parse_constraint_identifier_list(identifier_list: &str) -> Vec<String> {
1811		let mut identifiers = Vec::new();
1812		let mut current = String::new();
1813		let mut quote = None;
1814		let mut depth = 0usize;
1815		let mut chars = identifier_list.chars().peekable();
1816
1817		while let Some(character) = chars.next() {
1818			if let Some(quote_char) = quote {
1819				current.push(character);
1820				if character == quote_char {
1821					if chars.peek() == Some(&quote_char) {
1822						current.push(chars.next().expect("peeked quote must exist"));
1823					} else {
1824						quote = None;
1825					}
1826				}
1827				continue;
1828			}
1829
1830			match character {
1831				'\'' | '"' | '`' => {
1832					quote = Some(character);
1833					current.push(character);
1834				}
1835				'(' => {
1836					depth += 1;
1837					current.push(character);
1838				}
1839				')' => {
1840					depth = depth.saturating_sub(1);
1841					current.push(character);
1842				}
1843				',' if depth == 0 => {
1844					let identifier = Self::trim_sql_identifier(&current);
1845					if !identifier.is_empty() {
1846						identifiers.push(identifier);
1847					}
1848					current.clear();
1849				}
1850				_ => current.push(character),
1851			}
1852		}
1853
1854		let identifier = Self::trim_sql_identifier(&current);
1855		if !identifier.is_empty() {
1856			identifiers.push(identifier);
1857		}
1858		identifiers
1859	}
1860
1861	fn extract_sql_parenthesized_expression(sql: &str, open: usize) -> Option<(&str, usize)> {
1862		if !sql.is_char_boundary(open) || sql[open..].chars().next()? != '(' {
1863			return None;
1864		}
1865
1866		let mut depth = 0usize;
1867		let mut quote = None;
1868		let mut chars = sql[open..].char_indices().peekable();
1869		while let Some((relative_index, character)) = chars.next() {
1870			let index = open + relative_index;
1871			if let Some(quote_char) = quote {
1872				if character == quote_char {
1873					if chars.peek().is_some_and(|(_, next)| *next == quote_char) {
1874						chars.next();
1875					} else {
1876						quote = None;
1877					}
1878				}
1879				continue;
1880			}
1881
1882			match character {
1883				'\'' | '"' | '`' => quote = Some(character),
1884				'(' => depth += 1,
1885				')' => {
1886					depth = depth.checked_sub(1)?;
1887					if depth == 0 {
1888						return Some((&sql[open + 1..index], index));
1889					}
1890				}
1891				_ => {}
1892			}
1893		}
1894
1895		None
1896	}
1897
1898	fn foreign_key_action_from_clause(clauses: &str, clause: &str) -> Option<ForeignKeyAction> {
1899		let upper_clauses = clauses.to_ascii_uppercase();
1900		let tail = upper_clauses.split_once(clause)?.1.trim_start();
1901		if tail.starts_with("SET NULL") {
1902			Some(ForeignKeyAction::SetNull)
1903		} else if tail.starts_with("SET DEFAULT") {
1904			Some(ForeignKeyAction::SetDefault)
1905		} else if tail.starts_with("NO ACTION") {
1906			Some(ForeignKeyAction::NoAction)
1907		} else if tail.starts_with("CASCADE") {
1908			Some(ForeignKeyAction::Cascade)
1909		} else if tail.starts_with("RESTRICT") {
1910			Some(ForeignKeyAction::Restrict)
1911		} else {
1912			None
1913		}
1914	}
1915
1916	fn constraint_definition_from_sql(constraint_sql: &str) -> Option<ConstraintDefinition> {
1917		let rest = constraint_sql.trim().strip_prefix("CONSTRAINT ")?;
1918		let (name, body) = rest.split_once(' ')?;
1919		let body = body.trim();
1920		let upper_body = body.to_ascii_uppercase();
1921		if upper_body.starts_with("UNIQUE (") {
1922			let open = body.find('(')?;
1923			let (identifier_list, _) = Self::extract_sql_parenthesized_expression(body, open)?;
1924			let fields = Self::parse_constraint_identifier_list(identifier_list);
1925			if fields.is_empty() {
1926				return None;
1927			}
1928			return Some(ConstraintDefinition {
1929				name: name.trim_matches('"').to_string(),
1930				constraint_type: "unique".to_string(),
1931				fields,
1932				expression: None,
1933				foreign_key_info: None,
1934			});
1935		}
1936		if upper_body.starts_with("CHECK (") {
1937			let open = body.find('(')?;
1938			let close = body.rfind(')')?;
1939			return Some(ConstraintDefinition {
1940				name: name.trim_matches('"').to_string(),
1941				constraint_type: "check".to_string(),
1942				fields: Vec::new(),
1943				expression: Some(body[open + 1..close].trim().to_string()),
1944				foreign_key_info: None,
1945			});
1946		}
1947		if upper_body.starts_with("FOREIGN KEY") {
1948			let open = body.find('(')?;
1949			let (identifier_list, close) = Self::extract_sql_parenthesized_expression(body, open)?;
1950			let fields = Self::parse_constraint_identifier_list(identifier_list);
1951			if fields.is_empty() {
1952				return None;
1953			}
1954
1955			let after_fields = body[close + 1..].trim_start();
1956			if !after_fields.to_ascii_uppercase().starts_with("REFERENCES") {
1957				return None;
1958			}
1959			let after_references = after_fields["REFERENCES".len()..].trim_start();
1960			let referenced_open = after_references.find('(')?;
1961			let referenced_table = Self::trim_sql_identifier(&after_references[..referenced_open]);
1962			if referenced_table.is_empty() {
1963				return None;
1964			}
1965
1966			let (referenced_identifier_list, referenced_close) =
1967				Self::extract_sql_parenthesized_expression(after_references, referenced_open)?;
1968			let referenced_columns =
1969				Self::parse_constraint_identifier_list(referenced_identifier_list);
1970			if referenced_columns.is_empty() {
1971				return None;
1972			}
1973
1974			let clauses = &after_references[referenced_close + 1..];
1975			return Some(ConstraintDefinition {
1976				name: name.trim_matches('"').to_string(),
1977				constraint_type: "foreign_key".to_string(),
1978				fields,
1979				expression: None,
1980				foreign_key_info: Some(ForeignKeyConstraintInfo {
1981					referenced_table,
1982					referenced_columns,
1983					on_delete: Self::foreign_key_action_from_clause(clauses, "ON DELETE")
1984						.unwrap_or(ForeignKeyAction::NoAction),
1985					on_update: Self::foreign_key_action_from_clause(clauses, "ON UPDATE")
1986						.unwrap_or(ForeignKeyAction::NoAction),
1987				}),
1988			});
1989		}
1990		None
1991	}
1992
1993	/// Helper: Convert ColumnDefinition to FieldState
1994	fn column_def_to_field_state(&self, col: &super::operations::ColumnDefinition) -> FieldState {
1995		let mut params = std::collections::HashMap::new();
1996
1997		if col.primary_key {
1998			params.insert("primary_key".to_string(), "true".to_string());
1999		}
2000		if col.auto_increment {
2001			params.insert("auto_increment".to_string(), "true".to_string());
2002		}
2003		if col.unique {
2004			params.insert("unique".to_string(), "true".to_string());
2005		}
2006		if let Some(default) = &col.default {
2007			params.insert("default".to_string(), default.to_string());
2008		}
2009
2010		FieldState {
2011			name: col.name.to_string(),
2012			field_type: col.type_definition.clone(),
2013			nullable: !col.not_null,
2014			params,
2015			foreign_key: None,
2016		}
2017	}
2018}
2019
2020/// Configuration for similarity threshold calculation
2021///
2022/// This struct controls how aggressive the autodetector is when matching
2023/// models and fields across apps for rename/move detection.
2024///
2025/// Uses a hybrid similarity metric combining:
2026/// - Jaro-Winkler distance: Best for detecting prefix similarities (e.g., "UserModel" vs "UserProfile")
2027/// - Levenshtein distance: Best for detecting edit operations (e.g., "User" vs "Users")
2028///
2029/// # Examples
2030///
2031/// ```rust,ignore
2032/// use reinhardt_db::migrations::SimilarityConfig;
2033///
2034/// // Default configuration (70% threshold for models, 80% for fields)
2035/// let config = SimilarityConfig::default();
2036/// assert_eq!(config.model_threshold(), 0.7);
2037///
2038/// // Custom conservative configuration (higher threshold = fewer matches)
2039/// let config = SimilarityConfig::new(0.85, 0.90).unwrap();
2040///
2041/// // Liberal configuration (lower threshold = more matches, but more false positives)
2042/// let config = SimilarityConfig::new(0.60, 0.70).unwrap();
2043///
2044/// // Custom with specific algorithm weights
2045/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.6, 0.4).unwrap();
2046/// ```
2047#[non_exhaustive]
2048#[derive(Debug, Clone)]
2049pub struct SimilarityConfig {
2050	/// Threshold for model similarity (0.45 - 0.95)
2051	/// Higher values mean stricter matching (fewer false positives)
2052	model_threshold: f64,
2053	/// Threshold for field similarity (0.45 - 0.95)
2054	/// Higher values mean stricter matching
2055	field_threshold: f64,
2056	/// Weight for Jaro-Winkler component (0.0 - 1.0, default 0.7)
2057	/// Higher values prioritize prefix matching
2058	jaro_winkler_weight: f64,
2059	/// Weight for Levenshtein component (0.0 - 1.0, default 0.3)
2060	/// Higher values prioritize edit distance
2061	/// Note: jaro_winkler_weight + levenshtein_weight should equal 1.0
2062	levenshtein_weight: f64,
2063}
2064
2065impl SimilarityConfig {
2066	/// Create a new SimilarityConfig with custom thresholds
2067	///
2068	/// # Arguments
2069	///
2070	/// * `model_threshold` - Similarity threshold for model matching (0.45 - 0.95)
2071	/// * `field_threshold` - Similarity threshold for field matching (0.45 - 0.95)
2072	///
2073	/// # Errors
2074	///
2075	/// Returns an error if thresholds are outside the valid range (0.45 - 0.95).
2076	/// Values below 0.45 would produce too many false positives.
2077	/// Values above 0.95 would make matching nearly impossible.
2078	///
2079	/// # Examples
2080	///
2081	/// ```rust,ignore
2082	/// use reinhardt_db::migrations::SimilarityConfig;
2083	///
2084	/// let config = SimilarityConfig::new(0.75, 0.85).unwrap();
2085	/// assert_eq!(config.model_threshold(), 0.75);
2086	/// assert_eq!(config.field_threshold(), 0.85);
2087	///
2088	/// // Invalid threshold (too low)
2089	/// assert!(SimilarityConfig::new(0.4, 0.8).is_err());
2090	///
2091	/// // Invalid threshold (too high)
2092	/// assert!(SimilarityConfig::new(0.96, 0.8).is_err());
2093	/// ```
2094	pub fn new(model_threshold: f64, field_threshold: f64) -> Result<Self, String> {
2095		Self::with_weights(model_threshold, field_threshold, 0.7, 0.3)
2096	}
2097
2098	/// Create a new SimilarityConfig with custom thresholds and algorithm weights
2099	///
2100	/// # Arguments
2101	///
2102	/// * `model_threshold` - Similarity threshold for model matching (0.45 - 0.95)
2103	/// * `field_threshold` - Similarity threshold for field matching (0.45 - 0.95)
2104	/// * `jaro_winkler_weight` - Weight for Jaro-Winkler component (0.0 - 1.0)
2105	/// * `levenshtein_weight` - Weight for Levenshtein component (0.0 - 1.0)
2106	///
2107	/// # Errors
2108	///
2109	/// Returns an error if:
2110	/// - Thresholds are outside the valid range (0.45 - 0.95)
2111	/// - Weights are outside the valid range (0.0 - 1.0)
2112	/// - Weights don't sum to approximately 1.0 (within 0.01 tolerance)
2113	///
2114	/// # Examples
2115	///
2116	/// ```rust,ignore
2117	/// use reinhardt_db::migrations::SimilarityConfig;
2118	///
2119	/// // Prefer Jaro-Winkler for prefix matching
2120	/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.8, 0.2).unwrap();
2121	///
2122	/// // Prefer Levenshtein for edit distance
2123	/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.3, 0.7).unwrap();
2124	///
2125	/// // Invalid: weights don't sum to 1.0
2126	/// assert!(SimilarityConfig::with_weights(0.75, 0.85, 0.5, 0.3).is_err());
2127	/// ```
2128	pub fn with_weights(
2129		model_threshold: f64,
2130		field_threshold: f64,
2131		jaro_winkler_weight: f64,
2132		levenshtein_weight: f64,
2133	) -> Result<Self, String> {
2134		// Validate thresholds are in reasonable range
2135		// Minimum 0.45: below this produces too many false positives
2136		// Maximum 0.95: above this makes matching nearly impossible
2137		if !(0.45..=0.95).contains(&model_threshold) {
2138			return Err(format!(
2139				"model_threshold must be between 0.45 and 0.95, got {}",
2140				model_threshold
2141			));
2142		}
2143		if !(0.45..=0.95).contains(&field_threshold) {
2144			return Err(format!(
2145				"field_threshold must be between 0.45 and 0.95, got {}",
2146				field_threshold
2147			));
2148		}
2149
2150		// Validate weights are in valid range
2151		if !(0.0..=1.0).contains(&jaro_winkler_weight) {
2152			return Err(format!(
2153				"jaro_winkler_weight must be between 0.0 and 1.0, got {}",
2154				jaro_winkler_weight
2155			));
2156		}
2157		if !(0.0..=1.0).contains(&levenshtein_weight) {
2158			return Err(format!(
2159				"levenshtein_weight must be between 0.0 and 1.0, got {}",
2160				levenshtein_weight
2161			));
2162		}
2163
2164		// Validate weights sum to approximately 1.0 (allow small floating point errors)
2165		let weight_sum = jaro_winkler_weight + levenshtein_weight;
2166		if (weight_sum - 1.0).abs() > 0.01 {
2167			return Err(format!(
2168				"jaro_winkler_weight + levenshtein_weight must sum to 1.0, got {} + {} = {}",
2169				jaro_winkler_weight, levenshtein_weight, weight_sum
2170			));
2171		}
2172
2173		Ok(Self {
2174			model_threshold,
2175			field_threshold,
2176			jaro_winkler_weight,
2177			levenshtein_weight,
2178		})
2179	}
2180
2181	/// Get the model similarity threshold
2182	pub fn model_threshold(&self) -> f64 {
2183		self.model_threshold
2184	}
2185
2186	/// Get the field similarity threshold
2187	pub fn field_threshold(&self) -> f64 {
2188		self.field_threshold
2189	}
2190}
2191
2192impl Default for SimilarityConfig {
2193	/// Default configuration with balanced thresholds and weights
2194	///
2195	/// - Model threshold: 0.7 (70% similarity required)
2196	/// - Field threshold: 0.8 (80% similarity required)
2197	/// - Jaro-Winkler weight: 0.7 (70% weight for prefix matching)
2198	/// - Levenshtein weight: 0.3 (30% weight for edit distance)
2199	fn default() -> Self {
2200		Self {
2201			model_threshold: 0.7,
2202			field_threshold: 0.8,
2203			jaro_winkler_weight: 0.7,
2204			levenshtein_weight: 0.3,
2205		}
2206	}
2207}
2208
2209/// Migration autodetector
2210///
2211/// Django equivalent: `MigrationAutodetector` in django/db/migrations/autodetector.py
2212///
2213/// Detects schema changes between two ProjectStates and generates migrations.
2214///
2215/// # Examples
2216///
2217/// ```rust,ignore
2218/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
2219///
2220/// let mut from_state = ProjectState::new();
2221/// let mut to_state = ProjectState::new();
2222///
2223/// // Add a new model to to_state
2224/// let mut model = ModelState::new("myapp", "User");
2225/// model.add_field(FieldState::new("id", FieldType::Integer, false));
2226/// to_state.add_model(model);
2227///
2228/// let detector = MigrationAutodetector::new(from_state, to_state);
2229/// let changes = detector.detect_changes();
2230///
2231/// // Should detect the new model creation
2232/// assert_eq!(changes.created_models.len(), 1);
2233/// ```
2234pub struct MigrationAutodetector {
2235	from_state: ProjectState,
2236	to_state: ProjectState,
2237	similarity_config: SimilarityConfig,
2238}
2239
2240/// Type alias for moved model information:
2241/// (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
2242type MovedModelInfo = (
2243	String,
2244	String,
2245	String,
2246	String,
2247	bool,
2248	Option<String>,
2249	Option<String>,
2250);
2251
2252/// Type alias for model match result: ((deleted_app, deleted_model), (created_app, created_model), similarity_score)
2253type ModelMatchResult = ((String, String), (String, String), f64);
2254
2255/// Detected changes between two project states
2256#[derive(Debug, Clone, Default)]
2257pub struct DetectedChanges {
2258	/// Models that were created: (app_label, model_name)
2259	pub created_models: Vec<(String, String)>,
2260	/// Models that were deleted: (app_label, model_name)
2261	pub deleted_models: Vec<(String, String)>,
2262	/// Fields that were added: (app_label, model_name, field_name)
2263	pub added_fields: Vec<(String, String, String)>,
2264	/// Fields that were removed: (app_label, model_name, field_name)
2265	pub removed_fields: Vec<(String, String, String)>,
2266	/// Fields that were altered: (app_label, model_name, field_name)
2267	pub altered_fields: Vec<(String, String, String)>,
2268	/// Models that were renamed: (app_label, old_name, new_name)
2269	pub renamed_models: Vec<(String, String, String)>,
2270	/// Models that were moved between apps: (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
2271	pub moved_models: Vec<MovedModelInfo>,
2272	/// Fields that were renamed: (app_label, model_name, old_name, new_name)
2273	pub renamed_fields: Vec<(String, String, String, String)>,
2274	/// Indexes that were added: (app_label, model_name, IndexDefinition)
2275	pub added_indexes: Vec<(String, String, IndexDefinition)>,
2276	/// Indexes that were removed: (app_label, model_name, index_name)
2277	pub removed_indexes: Vec<(String, String, String)>,
2278	/// Constraints that were added: (app_label, model_name, ConstraintDefinition)
2279	pub added_constraints: Vec<(String, String, ConstraintDefinition)>,
2280	/// Constraints that were removed: (app_label, model_name, constraint_name)
2281	pub removed_constraints: Vec<(String, String, String)>,
2282	/// Composite primary keys added: (app_label, model_name, ConstraintDefinition)
2283	pub added_composite_primary_keys: Vec<(String, String, ConstraintDefinition)>,
2284	/// Composite primary keys removed due to modification (same name, different fields): (app_label, model_name, constraint_name)
2285	pub removed_composite_primary_keys: Vec<(String, String, String)>,
2286	/// Auto-increment sequence resets: (app_label, model_name, column_name, value)
2287	pub auto_increment_resets: Vec<(String, String, String, i64)>,
2288	/// Model dependencies for ordering operations
2289	/// Maps (app_label, model_name) -> `Vec<(dependent_app, dependent_model)>`
2290	/// A model depends on another if it has ForeignKey or ManyToMany fields pointing to it
2291	pub model_dependencies: std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
2292	/// ManyToMany intermediate tables that were created
2293	/// Contains (app_label, source_model, through_table, ManyToManyMetadata)
2294	pub created_many_to_many: Vec<(String, String, String, ManyToManyMetadata)>,
2295}
2296
2297/// Deterministic topological sort of `(app_label, model_name)` keys.
2298///
2299/// Only `nodes` participate in the graph. Edges that point at models outside
2300/// that set (already-existing tables, or models belonging to another
2301/// migration) are ignored so they cannot inject extra CreateTable operations
2302/// or leave a created model with a non-zero in-degree that never drains.
2303///
2304/// Ready nodes are stored in a `BTreeSet` so equal-in-degree ties break
2305/// lexicographically. Cycles fall back to lexicographic order of the
2306/// remaining nodes after a warning, rather than `HashSet` iteration order.
2307fn topological_sort_model_keys(
2308	nodes: &[(String, String)],
2309	dependencies: &std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
2310) -> Vec<(String, String)> {
2311	use std::collections::{BTreeMap, BTreeSet};
2312
2313	let node_set: BTreeSet<(String, String)> = nodes.iter().cloned().collect();
2314	let mut in_degree: BTreeMap<(String, String), usize> =
2315		node_set.iter().cloned().map(|node| (node, 0)).collect();
2316	let mut dependents: BTreeMap<(String, String), BTreeSet<(String, String)>> = BTreeMap::new();
2317
2318	for (dependent, deps) in dependencies {
2319		if !node_set.contains(dependent) {
2320			continue;
2321		}
2322		for dependency in deps {
2323			if dependent == dependency || !node_set.contains(dependency) {
2324				continue;
2325			}
2326			*in_degree.entry(dependent.clone()).or_insert(0) += 1;
2327			dependents
2328				.entry(dependency.clone())
2329				.or_default()
2330				.insert(dependent.clone());
2331		}
2332	}
2333
2334	let mut ready: BTreeSet<(String, String)> = in_degree
2335		.iter()
2336		.filter(|(_, degree)| **degree == 0)
2337		.map(|(node, _)| node.clone())
2338		.collect();
2339	let mut ordered = Vec::with_capacity(node_set.len());
2340
2341	while let Some(node) = ready.iter().next().cloned() {
2342		ready.remove(&node);
2343		ordered.push(node.clone());
2344		if let Some(children) = dependents.get(&node) {
2345			for child in children {
2346				if let Some(degree) = in_degree.get_mut(child) {
2347					*degree = degree.saturating_sub(1);
2348					if *degree == 0 {
2349						ready.insert(child.clone());
2350					}
2351				}
2352			}
2353		}
2354	}
2355
2356	if ordered.len() < node_set.len() {
2357		let mut remaining: Vec<(String, String)> = node_set
2358			.into_iter()
2359			.filter(|node| !ordered.contains(node))
2360			.collect();
2361		remaining.sort();
2362		eprintln!(
2363			"⚠️  Warning: Circular dependency detected in models: [{}]",
2364			remaining
2365				.iter()
2366				.map(|(app, name)| format!("{app}.{name}"))
2367				.collect::<Vec<_>>()
2368				.join(", ")
2369		);
2370		eprintln!(
2371			"    Falling back to lexicographic order for remaining models. Migration operations may need manual reordering."
2372		);
2373		ordered.extend(remaining);
2374	}
2375
2376	ordered
2377}
2378
2379impl DetectedChanges {
2380	/// Order models for migration operations based on dependencies
2381	///
2382	/// Uses topological sort (Kahn's algorithm) to determine the correct order
2383	/// for creating or moving models. This ensures that referenced models are
2384	/// processed before models that reference them.
2385	///
2386	/// # Algorithm: Kahn's Algorithm (Topological Sort)
2387	/// - Time Complexity: O(V + E) where V is models, E is dependencies
2388	/// - Detects circular dependencies and handles them gracefully
2389	/// - Returns models in dependency order (bottom-up)
2390	///
2391	/// # Returns
2392	/// A vector of (app_label, model_name) tuples in dependency order.
2393	/// Models with no dependencies come first, models depending on others come last.
2394	///
2395	/// # Examples
2396	///
2397	/// ```rust,ignore
2398	/// use reinhardt_db::migrations::{DetectedChanges};
2399	/// use std::collections::BTreeMap;
2400	///
2401	/// let mut changes = DetectedChanges::default();
2402	/// changes.created_models.push(("accounts".to_string(), "User".to_string()));
2403	/// changes.created_models.push(("blog".to_string(), "Post".to_string()));
2404	///
2405	/// // Post depends on User
2406	/// let mut deps = BTreeMap::new();
2407	/// deps.insert(
2408	///     ("blog".to_string(), "Post".to_string()),
2409	///     vec![("accounts".to_string(), "User".to_string())],
2410	/// );
2411	/// changes.model_dependencies = deps;
2412	///
2413	/// let ordered = changes.order_models_by_dependency();
2414	/// // User comes before Post
2415	/// assert_eq!(ordered[0], ("accounts".to_string(), "User".to_string()));
2416	/// assert_eq!(ordered[1], ("blog".to_string(), "Post".to_string()));
2417	/// ```
2418	pub fn order_models_by_dependency(&self) -> Vec<(String, String)> {
2419		let mut nodes = self.created_models.clone();
2420		for moved in &self.moved_models {
2421			nodes.push((moved.2.clone(), moved.3.clone()));
2422		}
2423		topological_sort_model_keys(&nodes, &self.model_dependencies)
2424	}
2425
2426	/// Order `created_models` by foreign-key dependencies.
2427	///
2428	/// Only models that are themselves being created participate in the graph.
2429	/// References to already-existing (or cross-app) tables do not inject extra
2430	/// CreateTable operations and do not delay the dependent model.
2431	pub fn order_created_models_by_dependency(&self) -> Vec<(String, String)> {
2432		topological_sort_model_keys(&self.created_models, &self.model_dependencies)
2433	}
2434
2435	/// Check for circular dependencies in model relationships
2436	///
2437	/// Detects cycles in the dependency graph using depth-first search.
2438	///
2439	/// # Returns
2440	/// - `Ok(())` if no circular dependencies exist
2441	/// - `Err(Vec<(String, String)>)` with the cycle path if found
2442	///
2443	/// # Examples
2444	///
2445	/// ```rust,ignore
2446	/// use reinhardt_db::migrations::{DetectedChanges};
2447	/// use std::collections::BTreeMap;
2448	///
2449	/// let mut changes = DetectedChanges::default();
2450	///
2451	/// // Create circular dependency: A -> B -> C -> A
2452	/// let mut deps = BTreeMap::new();
2453	/// deps.insert(
2454	///     ("app".to_string(), "A".to_string()),
2455	///     vec![("app".to_string(), "B".to_string())],
2456	/// );
2457	/// deps.insert(
2458	///     ("app".to_string(), "B".to_string()),
2459	///     vec![("app".to_string(), "C".to_string())],
2460	/// );
2461	/// deps.insert(
2462	///     ("app".to_string(), "C".to_string()),
2463	///     vec![("app".to_string(), "A".to_string())],
2464	/// );
2465	/// changes.model_dependencies = deps;
2466	///
2467	/// assert!(changes.check_circular_dependencies().is_err());
2468	/// ```
2469	pub fn check_circular_dependencies(&self) -> Result<(), Vec<(String, String)>> {
2470		use std::collections::HashSet;
2471
2472		let mut visited: HashSet<(String, String)> = HashSet::new();
2473		let mut rec_stack: HashSet<(String, String)> = HashSet::new();
2474		let mut path: Vec<(String, String)> = Vec::new();
2475
2476		fn dfs(
2477			model: &(String, String),
2478			deps: &BTreeMap<(String, String), Vec<(String, String)>>,
2479			visited: &mut HashSet<(String, String)>,
2480			rec_stack: &mut HashSet<(String, String)>,
2481			path: &mut Vec<(String, String)>,
2482		) -> Option<Vec<(String, String)>> {
2483			visited.insert(model.clone());
2484			rec_stack.insert(model.clone());
2485			path.push(model.clone());
2486
2487			if let Some(dependencies) = deps.get(model) {
2488				for dep in dependencies {
2489					if !visited.contains(dep) {
2490						if let Some(cycle) = dfs(dep, deps, visited, rec_stack, path) {
2491							return Some(cycle);
2492						}
2493					} else if rec_stack.contains(dep) {
2494						// Found cycle
2495						let cycle_start = path.iter().position(|m| m == dep).unwrap();
2496						return Some(path[cycle_start..].to_vec());
2497					}
2498				}
2499			}
2500
2501			path.pop();
2502			rec_stack.remove(model);
2503			None
2504		}
2505
2506		for model in self.model_dependencies.keys() {
2507			if !visited.contains(model)
2508				&& let Some(cycle) = dfs(
2509					model,
2510					&self.model_dependencies,
2511					&mut visited,
2512					&mut rec_stack,
2513					&mut path,
2514				) {
2515				return Err(cycle);
2516			}
2517		}
2518
2519		Ok(())
2520	}
2521
2522	/// Remove operations from DetectedChanges based on OperationRef list
2523	///
2524	/// This method is called when a user rejects an inferred intent during
2525	/// interactive migration detection. It removes the specific operations
2526	/// that the rejected intent was tracking, preventing them from being
2527	/// included in the generated migration.
2528	///
2529	/// # Arguments
2530	///
2531	/// * `refs` - Slice of OperationRef indicating which operations to remove
2532	///
2533	/// # Examples
2534	///
2535	/// ```rust,ignore
2536	/// use reinhardt_db::migrations::{DetectedChanges, OperationRef};
2537	///
2538	/// let mut changes = DetectedChanges::default();
2539	/// changes.renamed_models.push((
2540	///     "blog".to_string(),
2541	///     "Post".to_string(),
2542	///     "BlogPost".to_string(),
2543	/// ));
2544	/// changes.added_fields.push((
2545	///     "blog".to_string(),
2546	///     "BlogPost".to_string(),
2547	///     "slug".to_string(),
2548	/// ));
2549	///
2550	/// // Remove the renamed model operation
2551	/// changes.remove_operations(&[OperationRef::RenamedModel {
2552	///     app_label: "blog".to_string(),
2553	///     old_name: "Post".to_string(),
2554	///     new_name: "BlogPost".to_string(),
2555	/// }]);
2556	///
2557	/// assert!(changes.renamed_models.is_empty());
2558	/// // added_fields is not affected
2559	/// assert_eq!(changes.added_fields.len(), 1);
2560	/// ```
2561	pub fn remove_operations(&mut self, refs: &[OperationRef]) {
2562		for op_ref in refs {
2563			match op_ref {
2564				OperationRef::RenamedModel {
2565					app_label,
2566					old_name,
2567					new_name,
2568				} => {
2569					self.renamed_models.retain(|(app, old, new)| {
2570						!(app == app_label && old == old_name && new == new_name)
2571					});
2572				}
2573				OperationRef::MovedModel {
2574					from_app,
2575					to_app,
2576					model_name,
2577				} => {
2578					// MovedModelInfo is (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
2579					self.moved_models.retain(|info| {
2580						!(&info.0 == from_app
2581							&& &info.2 == to_app && (&info.1 == model_name || &info.3 == model_name))
2582					});
2583				}
2584				OperationRef::AddedField {
2585					app_label,
2586					model_name,
2587					field_name,
2588				} => {
2589					self.added_fields.retain(|(app, model, field)| {
2590						!(app == app_label && model == model_name && field == field_name)
2591					});
2592				}
2593				OperationRef::RenamedField {
2594					app_label,
2595					model_name,
2596					old_name,
2597					new_name,
2598				} => {
2599					self.renamed_fields.retain(|(app, model, old, new)| {
2600						!(app == app_label
2601							&& model == model_name
2602							&& old == old_name && new == new_name)
2603					});
2604				}
2605				OperationRef::RemovedField {
2606					app_label,
2607					model_name,
2608					field_name,
2609				} => {
2610					self.removed_fields.retain(|(app, model, field)| {
2611						!(app == app_label && model == model_name && field == field_name)
2612					});
2613				}
2614				OperationRef::AlteredField {
2615					app_label,
2616					model_name,
2617					field_name,
2618				} => {
2619					self.altered_fields.retain(|(app, model, field)| {
2620						!(app == app_label && model == model_name && field == field_name)
2621					});
2622				}
2623				OperationRef::CreatedModel {
2624					app_label,
2625					model_name,
2626				} => {
2627					self.created_models
2628						.retain(|(app, model)| !(app == app_label && model == model_name));
2629				}
2630				OperationRef::DeletedModel {
2631					app_label,
2632					model_name,
2633				} => {
2634					self.deleted_models
2635						.retain(|(app, model)| !(app == app_label && model == model_name));
2636				}
2637			}
2638		}
2639	}
2640}
2641
2642// ============================================================================
2643// Advanced Change Inference System
2644// ============================================================================
2645
2646/// Change history entry for temporal pattern analysis
2647///
2648/// Tracks individual changes with timestamps to identify patterns over time.
2649/// This enables the autodetector to learn from past migrations and make
2650/// better predictions about future changes.
2651///
2652/// # Examples
2653///
2654/// ```rust,ignore
2655/// use reinhardt_db::migrations::autodetector::ChangeHistoryEntry;
2656/// use std::time::SystemTime;
2657///
2658/// let entry = ChangeHistoryEntry {
2659///     timestamp: SystemTime::now(),
2660///     change_type: "RenameModel".to_string(),
2661///     app_label: "blog".to_string(),
2662///     model_name: "Post".to_string(),
2663///     field_name: None,
2664///     old_value: Some("BlogPost".to_string()),
2665///     new_value: Some("Post".to_string()),
2666/// };
2667/// ```
2668#[derive(Debug, Clone)]
2669pub struct ChangeHistoryEntry {
2670	/// When this change occurred
2671	pub timestamp: std::time::SystemTime,
2672	/// Type of change (e.g., "RenameModel", "AddField", "MoveModel")
2673	pub change_type: String,
2674	/// App label of the affected model
2675	pub app_label: String,
2676	/// Model name
2677	pub model_name: String,
2678	/// Field name (if field-level change)
2679	pub field_name: Option<String>,
2680	/// Old value (for renames/alterations)
2681	pub old_value: Option<String>,
2682	/// New value (for renames/alterations)
2683	pub new_value: Option<String>,
2684}
2685
2686/// Pattern frequency for learning from historical changes
2687///
2688/// Tracks how often certain patterns appear to predict future changes.
2689/// For example, if "User -> Account" rename happened 5 times in history,
2690/// similar patterns will get higher confidence scores.
2691#[derive(Debug, Clone)]
2692pub struct PatternFrequency {
2693	/// The pattern being tracked (e.g., "RenameModel:User->Account")
2694	pub pattern: String,
2695	/// Number of times this pattern occurred
2696	pub frequency: usize,
2697	/// Last time this pattern was seen
2698	pub last_seen: std::time::SystemTime,
2699	/// Contexts where this pattern appeared
2700	pub contexts: Vec<String>,
2701}
2702
2703/// Change tracker for temporal pattern analysis
2704///
2705/// Maintains a history of schema changes and analyzes patterns over time
2706/// to improve autodetection accuracy. This implements Django's concept of
2707/// "migration squashing" intelligence - learning which changes commonly
2708/// occur together.
2709///
2710/// # Algorithm: Temporal Pattern Mining
2711/// - Time Complexity: O(n) for insertion, O(n log n) for pattern analysis
2712/// - Space Complexity: O(h) where h is history size
2713/// - Uses sliding window for recent changes (last 100 by default)
2714///
2715/// # Examples
2716///
2717/// ```rust,ignore
2718/// use reinhardt_db::migrations::ChangeTracker;
2719///
2720/// let mut tracker = ChangeTracker::new();
2721///
2722/// // Track a model rename
2723/// tracker.record_model_rename("blog", "BlogPost", "Post");
2724///
2725/// // Track a field addition
2726/// tracker.record_field_addition("blog", "Post", "slug");
2727///
2728/// // Get pattern frequency
2729/// let patterns = tracker.get_frequent_patterns(2); // Min frequency: 2
2730/// ```
2731#[derive(Debug, Clone)]
2732pub struct ChangeTracker {
2733	/// Complete history of changes
2734	history: Vec<ChangeHistoryEntry>,
2735	/// Pattern frequency map
2736	patterns: HashMap<String, PatternFrequency>,
2737	/// Maximum history size (for memory efficiency)
2738	max_history_size: usize,
2739}
2740
2741impl ChangeTracker {
2742	/// Create a new change tracker with default settings
2743	///
2744	/// Default max history size: 1000 entries
2745	pub fn new() -> Self {
2746		Self {
2747			history: Vec::new(),
2748			patterns: HashMap::new(),
2749			max_history_size: 1000,
2750		}
2751	}
2752
2753	/// Create a change tracker with custom history size
2754	pub fn with_capacity(max_size: usize) -> Self {
2755		Self {
2756			history: Vec::with_capacity(max_size),
2757			patterns: HashMap::new(),
2758			max_history_size: max_size,
2759		}
2760	}
2761
2762	/// Record a model rename in the history
2763	///
2764	/// # Arguments
2765	/// * `app_label` - App containing the model
2766	/// * `old_name` - Original model name
2767	/// * `new_name` - New model name
2768	pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
2769		let entry = ChangeHistoryEntry {
2770			timestamp: std::time::SystemTime::now(),
2771			change_type: "RenameModel".to_string(),
2772			app_label: app_label.to_string(),
2773			model_name: new_name.to_string(),
2774			field_name: None,
2775			old_value: Some(old_name.to_string()),
2776			new_value: Some(new_name.to_string()),
2777		};
2778
2779		self.add_entry(entry);
2780		self.update_pattern(
2781			&format!("RenameModel:{}->{}", old_name, new_name),
2782			app_label,
2783		);
2784	}
2785
2786	/// Record a model move between apps
2787	pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
2788		let entry = ChangeHistoryEntry {
2789			timestamp: std::time::SystemTime::now(),
2790			change_type: "MoveModel".to_string(),
2791			app_label: to_app.to_string(),
2792			model_name: model_name.to_string(),
2793			field_name: None,
2794			old_value: Some(from_app.to_string()),
2795			new_value: Some(to_app.to_string()),
2796		};
2797
2798		self.add_entry(entry);
2799		self.update_pattern(
2800			&format!("MoveModel:{}->{}:{}", from_app, to_app, model_name),
2801			to_app,
2802		);
2803	}
2804
2805	/// Record a field addition
2806	pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
2807		let entry = ChangeHistoryEntry {
2808			timestamp: std::time::SystemTime::now(),
2809			change_type: "AddField".to_string(),
2810			app_label: app_label.to_string(),
2811			model_name: model_name.to_string(),
2812			field_name: Some(field_name.to_string()),
2813			old_value: None,
2814			new_value: Some(field_name.to_string()),
2815		};
2816
2817		self.add_entry(entry);
2818		self.update_pattern(
2819			&format!("AddField:{}:{}", model_name, field_name),
2820			app_label,
2821		);
2822	}
2823
2824	/// Record a field rename
2825	pub fn record_field_rename(
2826		&mut self,
2827		app_label: &str,
2828		model_name: &str,
2829		old_name: &str,
2830		new_name: &str,
2831	) {
2832		let entry = ChangeHistoryEntry {
2833			timestamp: std::time::SystemTime::now(),
2834			change_type: "RenameField".to_string(),
2835			app_label: app_label.to_string(),
2836			model_name: model_name.to_string(),
2837			field_name: Some(new_name.to_string()),
2838			old_value: Some(old_name.to_string()),
2839			new_value: Some(new_name.to_string()),
2840		};
2841
2842		self.add_entry(entry);
2843		self.update_pattern(
2844			&format!("RenameField:{}:{}->{}", model_name, old_name, new_name),
2845			app_label,
2846		);
2847	}
2848
2849	/// Add an entry to history with size management
2850	fn add_entry(&mut self, entry: ChangeHistoryEntry) {
2851		self.history.push(entry);
2852
2853		// Maintain max history size
2854		if self.history.len() > self.max_history_size {
2855			self.history.remove(0);
2856		}
2857	}
2858
2859	/// Update pattern frequency
2860	fn update_pattern(&mut self, pattern: &str, context: &str) {
2861		self.patterns
2862			.entry(pattern.to_string())
2863			.and_modify(|pf| {
2864				pf.frequency += 1;
2865				pf.last_seen = std::time::SystemTime::now();
2866				if !pf.contexts.contains(&context.to_string()) {
2867					pf.contexts.push(context.to_string());
2868				}
2869			})
2870			.or_insert(PatternFrequency {
2871				pattern: pattern.to_string(),
2872				frequency: 1,
2873				last_seen: std::time::SystemTime::now(),
2874				contexts: vec![context.to_string()],
2875			});
2876	}
2877
2878	/// Get patterns that occur at least `min_frequency` times
2879	///
2880	/// Returns patterns sorted by frequency (descending)
2881	pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
2882		let mut patterns: Vec<_> = self
2883			.patterns
2884			.values()
2885			.filter(|p| p.frequency >= min_frequency)
2886			.cloned()
2887			.collect();
2888
2889		patterns.sort_by_key(|pattern| std::cmp::Reverse(pattern.frequency));
2890		patterns
2891	}
2892
2893	/// Get recent changes within the specified duration
2894	///
2895	/// # Arguments
2896	/// * `duration` - Time window (e.g., Duration::from_secs(3600) for last hour)
2897	pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
2898		let now = std::time::SystemTime::now();
2899		self.history
2900			.iter()
2901			.filter(|entry| {
2902				now.duration_since(entry.timestamp)
2903					.map(|d| d < duration)
2904					.unwrap_or(false)
2905			})
2906			.collect()
2907	}
2908
2909	/// Analyze co-occurring patterns
2910	///
2911	/// Returns pairs of patterns that frequently appear together
2912	/// within a time window (default: 1 hour)
2913	pub fn analyze_cooccurrence(
2914		&self,
2915		window: std::time::Duration,
2916	) -> HashMap<(String, String), usize> {
2917		let mut cooccurrences = HashMap::new();
2918
2919		for i in 0..self.history.len() {
2920			for j in (i + 1)..self.history.len() {
2921				if let Ok(diff) = self.history[j]
2922					.timestamp
2923					.duration_since(self.history[i].timestamp)
2924					&& diff <= window
2925				{
2926					let pattern1 = format!(
2927						"{}:{}",
2928						self.history[i].change_type, self.history[i].model_name
2929					);
2930					let pattern2 = format!(
2931						"{}:{}",
2932						self.history[j].change_type, self.history[j].model_name
2933					);
2934					let key = if pattern1 < pattern2 {
2935						(pattern1, pattern2)
2936					} else {
2937						(pattern2, pattern1)
2938					};
2939					*cooccurrences.entry(key).or_insert(0) += 1;
2940				}
2941			}
2942		}
2943
2944		cooccurrences
2945	}
2946
2947	/// Clear all history (useful for testing)
2948	pub fn clear(&mut self) {
2949		self.history.clear();
2950		self.patterns.clear();
2951	}
2952
2953	/// Get total number of changes tracked
2954	pub fn len(&self) -> usize {
2955		self.history.len()
2956	}
2957
2958	/// Check if history is empty
2959	pub fn is_empty(&self) -> bool {
2960		self.history.is_empty()
2961	}
2962}
2963
2964impl Default for ChangeTracker {
2965	fn default() -> Self {
2966		Self::new()
2967	}
2968}
2969
2970/// Pattern match result
2971///
2972/// Represents a single match found by the PatternMatcher.
2973#[derive(Debug, Clone)]
2974pub struct PatternMatch {
2975	/// The pattern that matched
2976	pub pattern: String,
2977	/// Starting position in the text
2978	pub start: usize,
2979	/// Ending position in the text
2980	pub end: usize,
2981	/// The matched text
2982	pub matched_text: String,
2983}
2984
2985/// Pattern matcher using Aho-Corasick algorithm
2986///
2987/// Efficiently searches for multiple patterns simultaneously in model/field names.
2988/// This is useful for detecting common naming patterns like:
2989/// - "User" -> "Account" conversions
2990/// - "created_at" -> "timestamp" renames
2991/// - Common prefix/suffix patterns
2992///
2993/// # Algorithm: Aho-Corasick
2994/// - Time Complexity: O(n + m + z) where n=text length, m=total pattern length, z=matches
2995/// - Space Complexity: O(m) for the automaton
2996/// - Advantage: Simultaneous multi-pattern matching in linear time
2997///
2998/// # Examples
2999///
3000/// ```rust,ignore
3001/// use reinhardt_db::migrations::PatternMatcher;
3002///
3003/// let mut matcher = PatternMatcher::new();
3004/// matcher.add_pattern("User");
3005/// matcher.add_pattern("Post");
3006/// matcher.build();
3007///
3008/// let matches = matcher.find_all("User has many Posts");
3009/// assert_eq!(matches.len(), 2);
3010/// ```
3011#[derive(Debug, Clone)]
3012pub struct PatternMatcher {
3013	/// Patterns to search for
3014	patterns: Vec<String>,
3015	/// Aho-Corasick automaton (built lazily)
3016	automaton: Option<aho_corasick::AhoCorasick>,
3017}
3018
3019impl PatternMatcher {
3020	/// Create a new empty pattern matcher
3021	pub fn new() -> Self {
3022		Self {
3023			patterns: Vec::new(),
3024			automaton: None,
3025		}
3026	}
3027
3028	/// Add a pattern to search for
3029	///
3030	/// Patterns are case-sensitive by default.
3031	/// Call `build()` after adding all patterns.
3032	pub fn add_pattern(&mut self, pattern: &str) {
3033		self.patterns.push(pattern.to_string());
3034		// Invalidate automaton - needs rebuild
3035		self.automaton = None;
3036	}
3037
3038	/// Add multiple patterns at once
3039	pub fn add_patterns<I, S>(&mut self, patterns: I)
3040	where
3041		I: IntoIterator<Item = S>,
3042		S: AsRef<str>,
3043	{
3044		for pattern in patterns {
3045			self.patterns.push(pattern.as_ref().to_string());
3046		}
3047		self.automaton = None;
3048	}
3049
3050	/// Build the Aho-Corasick automaton
3051	///
3052	/// Must be called after adding patterns and before searching.
3053	/// Returns Err if patterns is empty or build fails.
3054	pub fn build(&mut self) -> Result<(), String> {
3055		if self.patterns.is_empty() {
3056			return Err("No patterns to build automaton".to_string());
3057		}
3058
3059		self.automaton = Some(
3060			aho_corasick::AhoCorasick::new(&self.patterns)
3061				.map_err(|e| format!("Failed to build Aho-Corasick automaton: {}", e))?,
3062		);
3063
3064		Ok(())
3065	}
3066
3067	/// Find all pattern matches in the given text
3068	///
3069	/// Returns empty vector if no matches found or automaton not built.
3070	pub fn find_all(&self, text: &str) -> Vec<PatternMatch> {
3071		let Some(ref automaton) = self.automaton else {
3072			return Vec::new();
3073		};
3074
3075		automaton
3076			.find_iter(text)
3077			.map(|mat| PatternMatch {
3078				pattern: self.patterns[mat.pattern().as_usize()].clone(),
3079				start: mat.start(),
3080				end: mat.end(),
3081				matched_text: text[mat.start()..mat.end()].to_string(),
3082			})
3083			.collect()
3084	}
3085
3086	/// Check if any pattern matches the text
3087	pub fn contains_any(&self, text: &str) -> bool {
3088		self.automaton
3089			.as_ref()
3090			.map(|ac| ac.is_match(text))
3091			.unwrap_or(false)
3092	}
3093
3094	/// Find the first match in the text
3095	pub fn find_first(&self, text: &str) -> Option<PatternMatch> {
3096		let automaton = self.automaton.as_ref()?;
3097		let mat = automaton.find(text)?;
3098
3099		Some(PatternMatch {
3100			pattern: self.patterns[mat.pattern().as_usize()].clone(),
3101			start: mat.start(),
3102			end: mat.end(),
3103			matched_text: text[mat.start()..mat.end()].to_string(),
3104		})
3105	}
3106
3107	/// Replace all pattern matches with replacements
3108	///
3109	/// # Arguments
3110	/// * `text` - The text to search in
3111	/// * `replacements` - Map from pattern to replacement string
3112	///
3113	/// # Returns
3114	/// Modified text with all patterns replaced
3115	pub fn replace_all(&self, text: &str, replacements: &HashMap<String, String>) -> String {
3116		let Some(ref automaton) = self.automaton else {
3117			return text.to_string();
3118		};
3119
3120		let mut result = String::new();
3121		let mut last_end = 0;
3122
3123		for mat in automaton.find_iter(text) {
3124			// Add text before match
3125			result.push_str(&text[last_end..mat.start()]);
3126
3127			// Add replacement or original if no replacement found
3128			let pattern = &self.patterns[mat.pattern().as_usize()];
3129			if let Some(replacement) = replacements.get(pattern) {
3130				result.push_str(replacement);
3131			} else {
3132				result.push_str(&text[mat.start()..mat.end()]);
3133			}
3134
3135			last_end = mat.end();
3136		}
3137
3138		// Add remaining text
3139		result.push_str(&text[last_end..]);
3140		result
3141	}
3142
3143	/// Get all patterns currently registered
3144	pub fn patterns(&self) -> &[String] {
3145		&self.patterns
3146	}
3147
3148	/// Clear all patterns
3149	pub fn clear(&mut self) {
3150		self.patterns.clear();
3151		self.automaton = None;
3152	}
3153
3154	/// Check if automaton is built and ready
3155	pub fn is_built(&self) -> bool {
3156		self.automaton.is_some()
3157	}
3158}
3159
3160impl Default for PatternMatcher {
3161	fn default() -> Self {
3162		Self::new()
3163	}
3164}
3165
3166// ============================================================================
3167// Inference Types
3168// ============================================================================
3169
3170/// Condition for an inference rule
3171#[derive(Debug, Clone, PartialEq)]
3172pub enum RuleCondition {
3173	/// Model rename pattern
3174	ModelRename {
3175		/// The source model name pattern.
3176		from_pattern: String,
3177		/// The target model name pattern.
3178		to_pattern: String,
3179	},
3180	/// Model move pattern
3181	ModelMove {
3182		/// The application label pattern.
3183		app_pattern: String,
3184	},
3185	/// Field addition pattern
3186	FieldAddition {
3187		/// The field name pattern.
3188		field_name_pattern: String,
3189	},
3190	/// Field rename pattern
3191	FieldRename {
3192		/// The source field name pattern.
3193		from_pattern: String,
3194		/// The target field name pattern.
3195		to_pattern: String,
3196	},
3197	/// Multiple model renames
3198	MultipleModelRenames {
3199		/// The minimum count of renames.
3200		min_count: usize,
3201	},
3202	/// Multiple field additions
3203	MultipleFieldAdditions {
3204		/// The model name pattern.
3205		model_pattern: String,
3206		/// The minimum count of additions.
3207		min_count: usize,
3208	},
3209}
3210
3211/// Reference to a specific operation in DetectedChanges
3212///
3213/// Used to track which operations an inferred intent relates to,
3214/// enabling removal of operations when the user rejects an intent.
3215#[derive(Debug, Clone, PartialEq)]
3216pub enum OperationRef {
3217	/// Reference to a renamed model: (app_label, old_name, new_name)
3218	RenamedModel {
3219		/// The app label.
3220		app_label: String,
3221		/// The old name.
3222		old_name: String,
3223		/// The new name.
3224		new_name: String,
3225	},
3226	/// Reference to a moved model: (from_app, to_app, model_name)
3227	MovedModel {
3228		/// The from app.
3229		from_app: String,
3230		/// The to app.
3231		to_app: String,
3232		/// The model name.
3233		model_name: String,
3234	},
3235	/// Reference to an added field: (app_label, model_name, field_name)
3236	AddedField {
3237		/// The app label.
3238		app_label: String,
3239		/// The model name.
3240		model_name: String,
3241		/// The field name.
3242		field_name: String,
3243	},
3244	/// Reference to a renamed field: (app_label, model_name, old_name, new_name)
3245	RenamedField {
3246		/// The app label.
3247		app_label: String,
3248		/// The model name.
3249		model_name: String,
3250		/// The old name.
3251		old_name: String,
3252		/// The new name.
3253		new_name: String,
3254	},
3255	/// Reference to a removed field: (app_label, model_name, field_name)
3256	RemovedField {
3257		/// The app label.
3258		app_label: String,
3259		/// The model name.
3260		model_name: String,
3261		/// The field name.
3262		field_name: String,
3263	},
3264	/// Reference to an altered field: (app_label, model_name, field_name)
3265	AlteredField {
3266		/// The app label.
3267		app_label: String,
3268		/// The model name.
3269		model_name: String,
3270		/// The field name.
3271		field_name: String,
3272	},
3273	/// Reference to a created model: (app_label, model_name)
3274	CreatedModel {
3275		/// The app label.
3276		app_label: String,
3277		/// The model name.
3278		model_name: String,
3279	},
3280	/// Reference to a deleted model: (app_label, model_name)
3281	DeletedModel {
3282		/// The app label.
3283		app_label: String,
3284		/// The model name.
3285		model_name: String,
3286	},
3287}
3288
3289/// Inferred intent from detected changes
3290#[derive(Debug, Clone, PartialEq)]
3291pub struct InferredIntent {
3292	/// Type of intent (e.g., "Refactoring", "Add timestamp tracking")
3293	pub intent_type: String,
3294	/// Confidence score (0.0 - 1.0)
3295	pub confidence: f64,
3296	/// Human-readable description
3297	pub description: String,
3298	/// Evidence supporting this intent
3299	pub evidence: Vec<String>,
3300	/// References to operations in DetectedChanges that this intent relates to
3301	///
3302	/// When the user rejects this intent, these operations will be removed
3303	/// from DetectedChanges to prevent migration generation.
3304	pub related_operations: Vec<OperationRef>,
3305}
3306
3307/// Rule for inferring intent from change patterns
3308#[derive(Debug, Clone)]
3309pub struct InferenceRule {
3310	/// Rule name
3311	pub name: String,
3312	/// Required conditions (all must match)
3313	pub conditions: Vec<RuleCondition>,
3314	/// Optional conditions (boost confidence if matched)
3315	pub optional_conditions: Vec<RuleCondition>,
3316	/// Intent type to infer
3317	pub intent_type: String,
3318	/// Base confidence (0.0 - 1.0)
3319	pub base_confidence: f64,
3320	/// Confidence boost per matched optional condition
3321	pub confidence_boost_per_optional: f64,
3322}
3323
3324/// Inference engine for detecting composite change intents
3325///
3326/// Analyzes multiple detected changes to infer high-level intentions.
3327/// For example:
3328/// - AddIndex + AlterField(to larger type) → Performance optimization
3329/// - RenameModel + AddForeignKey → Relationship refactoring
3330/// - AddField + RemoveField → Data migration
3331///
3332/// # Algorithm: Rule-Based Inference
3333/// - Matches detected changes against predefined rules
3334/// - Calculates confidence scores based on pattern matching
3335/// - Returns ranked list of possible intents
3336///
3337/// # Examples
3338///
3339/// ```rust,no_run
3340/// use reinhardt_db::migrations::InferenceEngine;
3341///
3342/// let mut engine = InferenceEngine::new();
3343/// engine.add_default_rules();
3344///
3345/// // Analyze changes with proper arguments
3346/// let model_renames = vec![];
3347/// let model_moves = vec![];
3348/// let field_additions = vec![
3349///     ("users".to_string(), "User".to_string(), "email".to_string())
3350/// ];
3351/// let field_renames = vec![];
3352///
3353/// let intents = engine.infer_intents(
3354///     &model_renames,
3355///     &model_moves,
3356///     &field_additions,
3357///     &field_renames
3358/// );
3359/// ```
3360#[derive(Debug, Clone)]
3361pub struct InferenceEngine {
3362	/// Inference rules
3363	rules: Vec<InferenceRule>,
3364	/// Change history for contextual analysis
3365	///
3366	/// The change tracker maintains a history of schema changes and can be used
3367	/// to improve inference accuracy by analyzing temporal patterns. To use:
3368	///
3369	/// 1. Record changes via `record_model_rename()`, `record_field_addition()`, etc.
3370	/// 2. Query patterns via `get_frequent_patterns()` or `analyze_cooccurrence()`
3371	/// 3. Use pattern analysis to boost confidence scores in inference rules
3372	///
3373	/// Example:
3374	/// ```rust,ignore
3375	/// use reinhardt_db::migrations::autodetector::InferenceEngine;
3376	/// let mut engine = InferenceEngine::new();
3377	/// // Record rename and field addition history
3378	/// engine.record_model_rename("blog", "BlogPost", "Post");
3379	/// engine.record_field_addition("blog", "Post", "slug");
3380	/// // Analyze co-occurrence within a 60-second window
3381	/// let _cooccurrences = engine.analyze_cooccurrence(std::time::Duration::from_secs(60));
3382	/// ```
3383	change_tracker: ChangeTracker,
3384}
3385
3386impl Default for InferenceEngine {
3387	fn default() -> Self {
3388		Self::new()
3389	}
3390}
3391
3392impl InferenceEngine {
3393	/// Create a new inference engine
3394	pub fn new() -> Self {
3395		Self {
3396			rules: Vec::new(),
3397			change_tracker: ChangeTracker::new(),
3398		}
3399	}
3400
3401	/// Add a rule to the engine
3402	pub fn add_rule(&mut self, rule: InferenceRule) {
3403		self.rules.push(rule);
3404	}
3405
3406	/// Add default inference rules
3407	pub fn add_default_rules(&mut self) {
3408		// Rule 1: Model refactoring (rename)
3409		self.add_rule(InferenceRule {
3410			name: "model_refactoring".to_string(),
3411			conditions: vec![RuleCondition::ModelRename {
3412				from_pattern: ".*".to_string(),
3413				to_pattern: ".*".to_string(),
3414			}],
3415			optional_conditions: vec![RuleCondition::MultipleModelRenames { min_count: 2 }],
3416			intent_type: "Refactoring: Model rename".to_string(),
3417			base_confidence: 0.7,
3418			confidence_boost_per_optional: 0.1,
3419		});
3420
3421		// Rule 2: Timestamp tracking
3422		self.add_rule(InferenceRule {
3423			name: "add_timestamp_tracking".to_string(),
3424			conditions: vec![RuleCondition::FieldAddition {
3425				field_name_pattern: "created_at".to_string(),
3426			}],
3427			optional_conditions: vec![RuleCondition::FieldAddition {
3428				field_name_pattern: "updated_at".to_string(),
3429			}],
3430			intent_type: "Add timestamp tracking".to_string(),
3431			base_confidence: 0.8,
3432			confidence_boost_per_optional: 0.15,
3433		});
3434
3435		// Rule 3: Cross-app model move
3436		self.add_rule(InferenceRule {
3437			name: "cross_app_move".to_string(),
3438			conditions: vec![RuleCondition::ModelMove {
3439				app_pattern: ".*".to_string(),
3440			}],
3441			optional_conditions: vec![],
3442			intent_type: "Cross-app model organization".to_string(),
3443			base_confidence: 0.75,
3444			confidence_boost_per_optional: 0.0,
3445		});
3446
3447		// Rule 4: Field refactoring (rename)
3448		self.add_rule(InferenceRule {
3449			name: "field_refactoring".to_string(),
3450			conditions: vec![RuleCondition::FieldRename {
3451				from_pattern: ".*".to_string(),
3452				to_pattern: ".*".to_string(),
3453			}],
3454			optional_conditions: vec![RuleCondition::MultipleFieldAdditions {
3455				model_pattern: ".*".to_string(),
3456				min_count: 2,
3457			}],
3458			intent_type: "Refactoring: Field rename".to_string(),
3459			base_confidence: 0.65,
3460			confidence_boost_per_optional: 0.1,
3461		});
3462
3463		// Rule 5: Model normalization
3464		self.add_rule(InferenceRule {
3465			name: "model_normalization".to_string(),
3466			conditions: vec![RuleCondition::MultipleFieldAdditions {
3467				model_pattern: ".*".to_string(),
3468				min_count: 3,
3469			}],
3470			optional_conditions: vec![],
3471			intent_type: "Schema normalization".to_string(),
3472			base_confidence: 0.6,
3473			confidence_boost_per_optional: 0.0,
3474		});
3475	}
3476
3477	/// Match string against a pattern (supports regex)
3478	///
3479	/// Patterns can be:
3480	/// - Literal strings (exact match)
3481	/// - ".*" wildcard (matches anything)
3482	/// - Regular expressions (e.g., "User.*" matches "User", "UserProfile", etc.)
3483	fn matches_pattern(value: &str, pattern: &str) -> bool {
3484		// Wildcard pattern matches everything
3485		if pattern == ".*" {
3486			return true;
3487		}
3488
3489		// Try exact match first
3490		if value == pattern {
3491			return true;
3492		}
3493
3494		// Try regex match
3495		if let Ok(re) = Regex::new(pattern) {
3496			re.is_match(value)
3497		} else {
3498			// If regex is invalid, fall back to exact match
3499			false
3500		}
3501	}
3502
3503	/// Get all rules
3504	pub fn rules(&self) -> &[InferenceRule] {
3505		&self.rules
3506	}
3507
3508	/// Infer intents from detected changes
3509	pub fn infer_intents(
3510		&self,
3511		model_renames: &[(String, String, String, String)], // (from_app, from_model, to_app, to_model)
3512		model_moves: &[(String, String, String, String)],   // (from_app, from_model, to_app, to_model)
3513		field_additions: &[(String, String, String)],       // (app, model, field)
3514		field_renames: &[(String, String, String, String)], // (app, model, from_field, to_field)
3515	) -> Vec<InferredIntent> {
3516		let mut intents = Vec::new();
3517
3518		for rule in &self.rules {
3519			let mut matches_required = true;
3520			let mut optional_matches = 0;
3521			let mut evidence = Vec::new();
3522
3523			// Check required conditions
3524			for condition in &rule.conditions {
3525				match condition {
3526					RuleCondition::ModelRename {
3527						from_pattern,
3528						to_pattern,
3529					} => {
3530						if model_renames.is_empty() {
3531							matches_required = false;
3532							break;
3533						}
3534
3535						// Check if any model rename matches the patterns
3536						let mut matched = false;
3537						for (from_app, from_model, to_app, to_model) in model_renames {
3538							let from_name = format!("{}.{}", from_app, from_model);
3539							let to_name = format!("{}.{}", to_app, to_model);
3540
3541							if Self::matches_pattern(&from_name, from_pattern)
3542								&& Self::matches_pattern(&to_name, to_pattern)
3543							{
3544								evidence.push(format!(
3545									"Model renamed: {} → {} (pattern: {} → {})",
3546									from_name, to_name, from_pattern, to_pattern
3547								));
3548								matched = true;
3549								break;
3550							}
3551						}
3552
3553						if !matched {
3554							matches_required = false;
3555							break;
3556						}
3557					}
3558					RuleCondition::ModelMove { app_pattern } => {
3559						if model_moves.is_empty() {
3560							matches_required = false;
3561							break;
3562						}
3563
3564						// Check if any model move matches the app pattern
3565						let mut matched = false;
3566						for (from_app, from_model, to_app, to_model) in model_moves {
3567							if Self::matches_pattern(to_app, app_pattern) {
3568								evidence.push(format!(
3569									"Model moved: {}.{} → {}.{} (app pattern: {})",
3570									from_app, from_model, to_app, to_model, app_pattern
3571								));
3572								matched = true;
3573								break;
3574							}
3575						}
3576
3577						if !matched {
3578							matches_required = false;
3579							break;
3580						}
3581					}
3582					RuleCondition::FieldAddition { field_name_pattern } => {
3583						let matching_fields: Vec<_> = field_additions
3584							.iter()
3585							.filter(|(_, _, field)| {
3586								Self::matches_pattern(field, field_name_pattern)
3587							})
3588							.collect();
3589
3590						if matching_fields.is_empty() {
3591							matches_required = false;
3592							break;
3593						}
3594						evidence.push(format!(
3595							"Field added: {}.{}.{} (pattern: {})",
3596							matching_fields[0].0,
3597							matching_fields[0].1,
3598							matching_fields[0].2,
3599							field_name_pattern
3600						));
3601					}
3602					RuleCondition::FieldRename {
3603						from_pattern,
3604						to_pattern,
3605					} => {
3606						if field_renames.is_empty() {
3607							matches_required = false;
3608							break;
3609						}
3610
3611						// Check if any field rename matches the patterns
3612						let mut matched = false;
3613						for (app, model, from_field, to_field) in field_renames {
3614							if Self::matches_pattern(from_field, from_pattern)
3615								&& Self::matches_pattern(to_field, to_pattern)
3616							{
3617								evidence.push(format!(
3618									"Field renamed: {}.{}.{} → {} (pattern: {} → {})",
3619									app, model, from_field, to_field, from_pattern, to_pattern
3620								));
3621								matched = true;
3622								break;
3623							}
3624						}
3625
3626						if !matched {
3627							matches_required = false;
3628							break;
3629						}
3630					}
3631					RuleCondition::MultipleModelRenames { min_count } => {
3632						if model_renames.len() < *min_count {
3633							matches_required = false;
3634							break;
3635						}
3636						evidence.push(format!("Multiple model renames: {}", model_renames.len()));
3637					}
3638					RuleCondition::MultipleFieldAdditions {
3639						model_pattern,
3640						min_count,
3641					} => {
3642						let count = field_additions
3643							.iter()
3644							.filter(|(_, model, _)| Self::matches_pattern(model, model_pattern))
3645							.count();
3646
3647						if count < *min_count {
3648							matches_required = false;
3649							break;
3650						}
3651						evidence.push(format!(
3652							"Multiple field additions: {} (pattern: {}, min: {})",
3653							count, model_pattern, min_count
3654						));
3655					}
3656				}
3657			}
3658
3659			if !matches_required {
3660				continue;
3661			}
3662
3663			// Check optional conditions
3664			for condition in &rule.optional_conditions {
3665				match condition {
3666					RuleCondition::FieldAddition { field_name_pattern } => {
3667						if field_additions
3668							.iter()
3669							.any(|(_, _, field)| field.contains(field_name_pattern.as_str()))
3670						{
3671							optional_matches += 1;
3672							evidence.push(format!("Optional field added: {}", field_name_pattern));
3673						}
3674					}
3675					RuleCondition::MultipleModelRenames { min_count }
3676						if model_renames.len() >= *min_count =>
3677					{
3678						optional_matches += 1;
3679						evidence.push(format!("Multiple renames: {}", model_renames.len()));
3680					}
3681					_ => {}
3682				}
3683			}
3684
3685			// Calculate confidence
3686			let confidence = rule.base_confidence
3687				+ (optional_matches as f64 * rule.confidence_boost_per_optional);
3688			let confidence = confidence.min(1.0);
3689
3690			intents.push(InferredIntent {
3691				intent_type: rule.intent_type.clone(),
3692				confidence,
3693				description: format!("Detected: {}", rule.name),
3694				evidence,
3695				related_operations: Vec::new(),
3696			});
3697		}
3698
3699		// Sort by confidence (highest first)
3700		intents.sort_by(|a, b| {
3701			b.confidence
3702				.partial_cmp(&a.confidence)
3703				.unwrap_or(std::cmp::Ordering::Equal)
3704		});
3705
3706		intents
3707	}
3708
3709	/// Infer intents from DetectedChanges
3710	///
3711	/// Extracts change operations from DetectedChanges and runs inference rules on them.
3712	///
3713	/// # Arguments
3714	/// * `changes` - Detected changes between two project states
3715	///
3716	/// # Returns
3717	/// Inferred intents sorted by confidence (highest first)
3718	pub fn infer_from_detected_changes(&self, changes: &DetectedChanges) -> Vec<InferredIntent> {
3719		// Extract model renames: (from_app, from_model, to_app, to_model)
3720		let model_renames: Vec<(String, String, String, String)> = changes
3721			.renamed_models
3722			.iter()
3723			.map(|(app, old_name, new_name)| {
3724				(app.clone(), old_name.clone(), app.clone(), new_name.clone())
3725			})
3726			.collect();
3727
3728		// Extract model moves: (from_app, from_model, to_app, to_model)
3729		let model_moves: Vec<(String, String, String, String)> = changes
3730			.moved_models
3731			.iter()
3732			.map(|(from_app, from_model, to_app, to_model, _, _, _)| {
3733				(
3734					from_app.clone(),
3735					from_model.clone(),
3736					to_app.clone(),
3737					to_model.clone(),
3738				)
3739			})
3740			.collect();
3741
3742		// Extract field additions: (app, model, field)
3743		let field_additions: Vec<(String, String, String)> = changes
3744			.added_fields
3745			.iter()
3746			.map(|(app, model, field)| (app.clone(), model.clone(), field.clone()))
3747			.collect();
3748
3749		// Extract field renames: (app, model, from_field, to_field)
3750		let field_renames: Vec<(String, String, String, String)> = changes
3751			.renamed_fields
3752			.iter()
3753			.map(|(app, model, old_name, new_name)| {
3754				(
3755					app.clone(),
3756					model.clone(),
3757					old_name.clone(),
3758					new_name.clone(),
3759				)
3760			})
3761			.collect();
3762
3763		// Run inference on extracted changes
3764		let mut intents = self.infer_intents(
3765			&model_renames,
3766			&model_moves,
3767			&field_additions,
3768			&field_renames,
3769		);
3770
3771		// Post-process: populate related_operations for each intent based on evidence
3772		for intent in &mut intents {
3773			// Parse evidence to determine which operations are related
3774			// Evidence strings contain information about which changes triggered the intent
3775			for evidence_str in &intent.evidence {
3776				// Model rename evidence: "Model renamed: app.old → app.new ..."
3777				if evidence_str.starts_with("Model renamed:") {
3778					for (app, old_name, new_name) in &changes.renamed_models {
3779						intent.related_operations.push(OperationRef::RenamedModel {
3780							app_label: app.clone(),
3781							old_name: old_name.clone(),
3782							new_name: new_name.clone(),
3783						});
3784					}
3785				}
3786				// Model move evidence: "Model moved: from_app.model → to_app.model ..."
3787				else if evidence_str.starts_with("Model moved:") {
3788					for (from_app, _from_model, to_app, to_model, _, _, _) in &changes.moved_models
3789					{
3790						intent.related_operations.push(OperationRef::MovedModel {
3791							from_app: from_app.clone(),
3792							to_app: to_app.clone(),
3793							model_name: to_model.clone(),
3794						});
3795					}
3796				}
3797				// Field added evidence: "Field added: app.model.field ..."
3798				else if evidence_str.starts_with("Field added:") {
3799					for (app, model, field) in &changes.added_fields {
3800						intent.related_operations.push(OperationRef::AddedField {
3801							app_label: app.clone(),
3802							model_name: model.clone(),
3803							field_name: field.clone(),
3804						});
3805					}
3806				}
3807				// Field renamed evidence: "Field renamed: app.model.old → new ..."
3808				else if evidence_str.starts_with("Field renamed:") {
3809					for (app, model, old_name, new_name) in &changes.renamed_fields {
3810						intent.related_operations.push(OperationRef::RenamedField {
3811							app_label: app.clone(),
3812							model_name: model.clone(),
3813							old_name: old_name.clone(),
3814							new_name: new_name.clone(),
3815						});
3816					}
3817				}
3818				// Multiple model renames evidence
3819				else if evidence_str.starts_with("Multiple model renames:") {
3820					for (app, old_name, new_name) in &changes.renamed_models {
3821						intent.related_operations.push(OperationRef::RenamedModel {
3822							app_label: app.clone(),
3823							old_name: old_name.clone(),
3824							new_name: new_name.clone(),
3825						});
3826					}
3827				}
3828				// Multiple field additions or optional field added evidence
3829				else if evidence_str.starts_with("Multiple field additions:")
3830					|| evidence_str.starts_with("Optional field added:")
3831				{
3832					for (app, model, field) in &changes.added_fields {
3833						intent.related_operations.push(OperationRef::AddedField {
3834							app_label: app.clone(),
3835							model_name: model.clone(),
3836							field_name: field.clone(),
3837						});
3838					}
3839				}
3840			}
3841
3842			// Deduplicate related_operations
3843			intent
3844				.related_operations
3845				.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
3846			intent.related_operations.dedup();
3847		}
3848
3849		intents
3850	}
3851
3852	/// Record a model rename in the change tracker
3853	///
3854	/// This enables contextual analysis for future migrations by tracking patterns.
3855	///
3856	/// # Arguments
3857	/// * `app_label` - App containing the model
3858	/// * `old_name` - Original model name
3859	/// * `new_name` - New model name
3860	pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
3861		self.change_tracker
3862			.record_model_rename(app_label, old_name, new_name);
3863	}
3864
3865	/// Record a model move between apps
3866	///
3867	/// # Arguments
3868	/// * `from_app` - Source app label
3869	/// * `to_app` - Target app label
3870	/// * `model_name` - Name of the model being moved
3871	pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
3872		self.change_tracker
3873			.record_model_move(from_app, to_app, model_name);
3874	}
3875
3876	/// Record a field addition
3877	///
3878	/// # Arguments
3879	/// * `app_label` - App containing the model
3880	/// * `model_name` - Name of the model
3881	/// * `field_name` - Name of the field being added
3882	pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
3883		self.change_tracker
3884			.record_field_addition(app_label, model_name, field_name);
3885	}
3886
3887	/// Record a field rename
3888	///
3889	/// # Arguments
3890	/// * `app_label` - App containing the model
3891	/// * `model_name` - Name of the model
3892	/// * `old_name` - Original field name
3893	/// * `new_name` - New field name
3894	pub fn record_field_rename(
3895		&mut self,
3896		app_label: &str,
3897		model_name: &str,
3898		old_name: &str,
3899		new_name: &str,
3900	) {
3901		self.change_tracker
3902			.record_field_rename(app_label, model_name, old_name, new_name);
3903	}
3904
3905	/// Get frequent patterns from change history
3906	///
3907	/// Returns patterns that occur at least `min_frequency` times.
3908	/// This can be used to improve confidence scores for similar patterns.
3909	///
3910	/// # Arguments
3911	/// * `min_frequency` - Minimum number of occurrences to be considered frequent
3912	pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
3913		self.change_tracker.get_frequent_patterns(min_frequency)
3914	}
3915
3916	/// Get recent changes within the specified duration
3917	///
3918	/// # Arguments
3919	/// * `duration` - Time window for recent changes (e.g., last hour)
3920	pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
3921		self.change_tracker.get_recent_changes(duration)
3922	}
3923
3924	/// Analyze co-occurring patterns in change history
3925	///
3926	/// Returns pairs of patterns that frequently appear together
3927	/// within a time window.
3928	///
3929	/// # Arguments
3930	/// * `window` - Time window for co-occurrence analysis (default: 1 hour)
3931	pub fn analyze_cooccurrence(
3932		&self,
3933		window: std::time::Duration,
3934	) -> HashMap<(String, String), usize> {
3935		self.change_tracker.analyze_cooccurrence(window)
3936	}
3937}
3938
3939// ============================================================================
3940// Interactive UI for User Confirmation
3941// ============================================================================
3942
3943/// Interactive prompt system for user confirmation of ambiguous changes
3944///
3945/// This module provides CLI-based prompts for:
3946/// - Ambiguous model/field renames
3947/// - Cross-app model moves
3948/// - Multiple possible intents with different confidence scores
3949///
3950/// Uses the `dialoguer` crate for rich terminal interactions.
3951pub struct MigrationPrompt {
3952	/// Minimum confidence threshold for auto-acceptance (0.0 - 1.0)
3953	/// Changes above this threshold are accepted without prompting
3954	auto_accept_threshold: f64,
3955
3956	/// Theme for terminal styling
3957	theme: dialoguer::theme::ColorfulTheme,
3958}
3959
3960impl std::fmt::Debug for MigrationPrompt {
3961	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3962		f.debug_struct("MigrationPrompt")
3963			.field("auto_accept_threshold", &self.auto_accept_threshold)
3964			.field("theme", &"ColorfulTheme")
3965			.finish()
3966	}
3967}
3968
3969impl MigrationPrompt {
3970	/// Create a new prompt system with default settings
3971	pub fn new() -> Self {
3972		Self {
3973			auto_accept_threshold: 0.85,
3974			theme: dialoguer::theme::ColorfulTheme::default(),
3975		}
3976	}
3977
3978	/// Create with custom auto-accept threshold
3979	pub fn with_threshold(threshold: f64) -> Self {
3980		Self {
3981			auto_accept_threshold: threshold,
3982			theme: dialoguer::theme::ColorfulTheme::default(),
3983		}
3984	}
3985
3986	/// Get the auto-accept threshold
3987	pub fn auto_accept_threshold(&self) -> f64 {
3988		self.auto_accept_threshold
3989	}
3990
3991	/// Confirm a single intent with the user
3992	///
3993	/// Returns true if the user confirms, false if they reject
3994	pub fn confirm_intent(
3995		&self,
3996		intent: &InferredIntent,
3997	) -> Result<bool, Box<dyn std::error::Error>> {
3998		// Auto-accept high-confidence changes
3999		if intent.confidence >= self.auto_accept_threshold {
4000			println!(
4001				"✓ Auto-accepting (confidence: {:.1}%): {}",
4002				intent.confidence * 100.0,
4003				intent.intent_type
4004			);
4005			return Ok(true);
4006		}
4007
4008		// Build prompt message
4009		let message = format!(
4010			"Detected: {} (confidence: {:.1}%)\nDetails: {}\n\nAccept this change?",
4011			intent.intent_type,
4012			intent.confidence * 100.0,
4013			intent.description
4014		);
4015
4016		// Show evidence
4017		if !intent.evidence.is_empty() {
4018			println!("\nEvidence:");
4019			for evidence in &intent.evidence {
4020				println!("  • {}", evidence);
4021			}
4022		}
4023
4024		// Prompt user
4025		dialoguer::Confirm::with_theme(&self.theme)
4026			.with_prompt(message)
4027			.default(true)
4028			.interact()
4029			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
4030	}
4031
4032	/// Select one intent from multiple alternatives
4033	///
4034	/// Returns the index of the selected intent, or None if user cancels
4035	pub fn select_intent(
4036		&self,
4037		alternatives: &[InferredIntent],
4038		prompt: &str,
4039	) -> Result<Option<usize>, Box<dyn std::error::Error>> {
4040		if alternatives.is_empty() {
4041			return Ok(None);
4042		}
4043
4044		// Single alternative - just confirm
4045		if alternatives.len() == 1 {
4046			let confirmed = self.confirm_intent(&alternatives[0])?;
4047			return Ok(if confirmed { Some(0) } else { None });
4048		}
4049
4050		// Build selection items
4051		let items: Vec<String> = alternatives
4052			.iter()
4053			.map(|intent| {
4054				format!(
4055					"{} (confidence: {:.1}%) - {}",
4056					intent.intent_type,
4057					intent.confidence * 100.0,
4058					intent.description
4059				)
4060			})
4061			.collect();
4062
4063		// Show prompt
4064		println!("\n{}", prompt);
4065		println!("Multiple possibilities detected:\n");
4066
4067		// Add "None of the above" option
4068		let mut items_with_none = items.clone();
4069		items_with_none.push("None of the above / Skip".to_string());
4070
4071		// Prompt user
4072		let selection = dialoguer::Select::with_theme(&self.theme)
4073			.items(&items_with_none)
4074			.default(0)
4075			.interact()
4076			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
4077
4078		// Return None if user selected "None of the above"
4079		if selection >= items.len() {
4080			Ok(None)
4081		} else {
4082			Ok(Some(selection))
4083		}
4084	}
4085
4086	/// Multi-select intents from a list
4087	///
4088	/// Returns indices of selected intents
4089	pub fn multi_select_intents(
4090		&self,
4091		alternatives: &[InferredIntent],
4092		prompt: &str,
4093	) -> Result<Vec<usize>, Box<dyn std::error::Error>> {
4094		if alternatives.is_empty() {
4095			return Ok(Vec::new());
4096		}
4097
4098		// Build selection items
4099		let items: Vec<String> = alternatives
4100			.iter()
4101			.map(|intent| {
4102				format!(
4103					"{} (confidence: {:.1}%) - {}",
4104					intent.intent_type,
4105					intent.confidence * 100.0,
4106					intent.description
4107				)
4108			})
4109			.collect();
4110
4111		// Show prompt
4112		println!("\n{}", prompt);
4113		println!("Select all that apply:\n");
4114
4115		// Prompt user with multi-select
4116		let selections = dialoguer::MultiSelect::with_theme(&self.theme)
4117			.items(&items)
4118			.interact()
4119			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
4120
4121		Ok(selections)
4122	}
4123
4124	/// Confirm a model rename with details
4125	pub fn confirm_model_rename(
4126		&self,
4127		from_app: &str,
4128		from_model: &str,
4129		to_app: &str,
4130		to_model: &str,
4131		confidence: f64,
4132	) -> Result<bool, Box<dyn std::error::Error>> {
4133		// Auto-accept high-confidence changes
4134		if confidence >= self.auto_accept_threshold {
4135			println!(
4136				"✓ Auto-accepting model rename (confidence: {:.1}%): {}.{} → {}.{}",
4137				confidence * 100.0,
4138				from_app,
4139				from_model,
4140				to_app,
4141				to_model
4142			);
4143			return Ok(true);
4144		}
4145
4146		let message = format!(
4147			"Rename model from {}.{} to {}.{}?\n(confidence: {:.1}%)",
4148			from_app,
4149			from_model,
4150			to_app,
4151			to_model,
4152			confidence * 100.0
4153		);
4154
4155		dialoguer::Confirm::with_theme(&self.theme)
4156			.with_prompt(message)
4157			.default(true)
4158			.interact()
4159			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
4160	}
4161
4162	/// Confirm a field rename with details
4163	pub fn confirm_field_rename(
4164		&self,
4165		model: &str,
4166		from_field: &str,
4167		to_field: &str,
4168		confidence: f64,
4169	) -> Result<bool, Box<dyn std::error::Error>> {
4170		// Auto-accept high-confidence changes
4171		if confidence >= self.auto_accept_threshold {
4172			println!(
4173				"✓ Auto-accepting field rename (confidence: {:.1}%): {}.{} → {}.{}",
4174				confidence * 100.0,
4175				model,
4176				from_field,
4177				model,
4178				to_field
4179			);
4180			return Ok(true);
4181		}
4182
4183		let message = format!(
4184			"Rename field in model {}:\n  {} → {}?\n(confidence: {:.1}%)",
4185			model,
4186			from_field,
4187			to_field,
4188			confidence * 100.0
4189		);
4190
4191		dialoguer::Confirm::with_theme(&self.theme)
4192			.with_prompt(message)
4193			.default(true)
4194			.interact()
4195			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
4196	}
4197
4198	/// Show progress indicator for long operations
4199	pub fn with_progress<F, T>(
4200		&self,
4201		message: &str,
4202		total: u64,
4203		operation: F,
4204	) -> Result<T, Box<dyn std::error::Error>>
4205	where
4206		F: FnOnce(&indicatif::ProgressBar) -> Result<T, Box<dyn std::error::Error>>,
4207	{
4208		let pb = indicatif::ProgressBar::new(total);
4209		pb.set_style(
4210			indicatif::ProgressStyle::default_bar()
4211				.template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
4212				.expect("Failed to create progress bar template")
4213				.progress_chars("#>-"),
4214		);
4215		pb.set_message(message.to_string());
4216
4217		let result = operation(&pb)?;
4218
4219		pb.finish_with_message("Done");
4220		Ok(result)
4221	}
4222}
4223
4224impl Default for MigrationPrompt {
4225	fn default() -> Self {
4226		Self::new()
4227	}
4228}
4229
4230/// Extension trait for MigrationAutodetector with interactive prompts
4231pub trait InteractiveAutodetector {
4232	/// Detect changes with user prompts for ambiguous cases
4233	fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>>;
4234
4235	/// Apply inferred intents with user confirmation
4236	fn apply_intents_interactive(
4237		&self,
4238		intents: Vec<InferredIntent>,
4239		changes: &mut DetectedChanges,
4240	) -> Result<(), Box<dyn std::error::Error>>;
4241}
4242
4243impl InteractiveAutodetector for MigrationAutodetector {
4244	fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>> {
4245		let prompt = MigrationPrompt::new();
4246		let mut changes = self.detect_changes();
4247
4248		// Build inference engine
4249		let mut engine = InferenceEngine::new();
4250		engine.add_default_rules();
4251
4252		// Infer intents from detected changes
4253		let intents = engine.infer_from_detected_changes(&changes);
4254
4255		// Filter high-confidence intents
4256		let ambiguous_intents: Vec<_> = intents
4257			.into_iter()
4258			.filter(|intent| intent.confidence < prompt.auto_accept_threshold)
4259			.collect();
4260
4261		// Prompt for ambiguous changes
4262		if !ambiguous_intents.is_empty() {
4263			println!(
4264				"\n⚠️  Found {} ambiguous change(s) requiring confirmation:",
4265				ambiguous_intents.len()
4266			);
4267
4268			for intent in &ambiguous_intents {
4269				let confirmed = prompt.confirm_intent(intent)?;
4270
4271				if !confirmed {
4272					println!("✗ Skipped: {}", intent.description);
4273					// Remove the related operations from DetectedChanges
4274					// This prevents rejected intents from generating migration operations
4275					if !intent.related_operations.is_empty() {
4276						changes.remove_operations(&intent.related_operations);
4277						println!(
4278							"  → Removed {} related operation(s) from migration",
4279							intent.related_operations.len()
4280						);
4281					}
4282				}
4283			}
4284		}
4285
4286		// Detect and order dependencies
4287		self.detect_model_dependencies(&mut changes);
4288
4289		// Check for circular dependencies
4290		if let Err(cycle) = changes.check_circular_dependencies() {
4291			println!("\n⚠️  Warning: Circular dependency detected: {:?}", cycle);
4292
4293			let should_continue = dialoguer::Confirm::new()
4294				.with_prompt("Continue anyway? (may require manual intervention)")
4295				.default(false)
4296				.interact()?;
4297
4298			if !should_continue {
4299				return Err("Aborted due to circular dependency".into());
4300			}
4301		}
4302
4303		Ok(changes)
4304	}
4305
4306	fn apply_intents_interactive(
4307		&self,
4308		intents: Vec<InferredIntent>,
4309		_changes: &mut DetectedChanges,
4310	) -> Result<(), Box<dyn std::error::Error>> {
4311		let prompt = MigrationPrompt::new();
4312
4313		// Group intents by confidence
4314		let mut high_confidence = Vec::new();
4315		let mut medium_confidence = Vec::new();
4316		let mut low_confidence = Vec::new();
4317
4318		for intent in intents {
4319			if intent.confidence >= 0.85 {
4320				high_confidence.push(intent);
4321			} else if intent.confidence >= 0.65 {
4322				medium_confidence.push(intent);
4323			} else {
4324				low_confidence.push(intent);
4325			}
4326		}
4327
4328		// Auto-apply high-confidence intents
4329		println!(
4330			"\n✓ Auto-applying {} high-confidence change(s):",
4331			high_confidence.len()
4332		);
4333		for intent in &high_confidence {
4334			println!(
4335				"  • {} (confidence: {:.1}%)",
4336				intent.description,
4337				intent.confidence * 100.0
4338			);
4339		}
4340
4341		// Prompt for medium-confidence intents
4342		if !medium_confidence.is_empty() {
4343			println!(
4344				"\n⚠️  Review {} medium-confidence change(s):",
4345				medium_confidence.len()
4346			);
4347
4348			for intent in &medium_confidence {
4349				let confirmed = prompt.confirm_intent(intent)?;
4350				if confirmed {
4351					println!("  ✓ Accepted: {}", intent.description);
4352				} else {
4353					println!("  ✗ Rejected: {}", intent.description);
4354				}
4355			}
4356		}
4357
4358		// Prompt for low-confidence intents with multi-select
4359		if !low_confidence.is_empty() {
4360			let selections = prompt.multi_select_intents(
4361				&low_confidence,
4362				"⚠️  Select low-confidence changes to apply:",
4363			)?;
4364
4365			for idx in selections {
4366				println!("  ✓ Accepted: {}", low_confidence[idx].description);
4367			}
4368		}
4369
4370		Ok(())
4371	}
4372}
4373
4374impl MigrationAutodetector {
4375	/// Create a new migration autodetector with default similarity config
4376	///
4377	/// # Examples
4378	///
4379	/// ```rust,ignore
4380	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState};
4381	///
4382	/// let from_state = ProjectState::new();
4383	/// let to_state = ProjectState::new();
4384	///
4385	/// let detector = MigrationAutodetector::new(from_state, to_state);
4386	/// ```
4387	pub fn new(from_state: ProjectState, to_state: ProjectState) -> Self {
4388		Self {
4389			from_state,
4390			to_state,
4391			similarity_config: SimilarityConfig::default(),
4392		}
4393	}
4394
4395	/// Create a new migration autodetector with custom similarity config
4396	///
4397	/// # Examples
4398	///
4399	/// ```rust,ignore
4400	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, SimilarityConfig};
4401	///
4402	/// let from_state = ProjectState::new();
4403	/// let to_state = ProjectState::new();
4404	/// let config = SimilarityConfig::new(0.75, 0.85).unwrap();
4405	///
4406	/// let detector = MigrationAutodetector::with_config(from_state, to_state, config);
4407	/// ```
4408	pub fn with_config(
4409		from_state: ProjectState,
4410		to_state: ProjectState,
4411		similarity_config: SimilarityConfig,
4412	) -> Self {
4413		Self {
4414			from_state,
4415			to_state,
4416			similarity_config,
4417		}
4418	}
4419
4420	/// Detect all changes between from_state and to_state
4421	///
4422	/// Django equivalent: `_detect_changes()` in django/db/migrations/autodetector.py
4423	///
4424	/// # Examples
4425	///
4426	/// ```rust,ignore
4427	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState};
4428	///
4429	/// let from_state = ProjectState::new();
4430	/// let mut to_state = ProjectState::new();
4431	///
4432	/// // Add a new model
4433	/// let model = ModelState::new("myapp", "User");
4434	/// to_state.add_model(model);
4435	///
4436	/// let detector = MigrationAutodetector::new(from_state, to_state);
4437	/// let changes = detector.detect_changes();
4438	///
4439	/// assert_eq!(changes.created_models.len(), 1);
4440	/// ```
4441	pub fn detect_changes(&self) -> DetectedChanges {
4442		self.detect_changes_internal(false)
4443			.expect("non-strict autodetection must not fail")
4444	}
4445
4446	/// Detect changes and fail when a compatible field rename is ambiguous.
4447	///
4448	/// This is the safer entry point for `makemigrations`: when the
4449	/// autodetector sees add/drop candidates that could be field renames, it
4450	/// must either emit `RenameColumn` for one-to-one compatible pairs or stop
4451	/// instead of silently generating destructive add/drop operations.
4452	pub fn try_detect_changes(&self) -> super::Result<DetectedChanges> {
4453		self.detect_changes_internal(true)
4454	}
4455
4456	fn detect_changes_internal(
4457		&self,
4458		strict_rename_ambiguity: bool,
4459	) -> super::Result<DetectedChanges> {
4460		let mut changes = DetectedChanges::default();
4461
4462		// Detect model-level changes
4463		self.detect_created_models(&mut changes);
4464		self.detect_deleted_models(&mut changes);
4465		self.detect_renamed_models(&mut changes);
4466
4467		// Detect field-level changes (only for models that exist in both states)
4468		self.detect_added_fields(&mut changes);
4469		self.detect_removed_fields(&mut changes);
4470		self.detect_altered_fields(&mut changes);
4471		self.detect_renamed_fields(&mut changes, strict_rename_ambiguity)?;
4472
4473		// Detect index and constraint changes
4474		self.detect_added_indexes(&mut changes);
4475		self.detect_removed_indexes(&mut changes);
4476		self.detect_added_constraints(&mut changes);
4477		self.detect_removed_constraints(&mut changes);
4478		self.detect_composite_pk_changes(&mut changes);
4479		self.detect_auto_increment_resets(&mut changes);
4480
4481		// Detect ManyToMany intermediate tables
4482		self.detect_created_many_to_many(&mut changes);
4483
4484		// Detect model dependencies for operation ordering
4485		self.detect_model_dependencies(&mut changes);
4486
4487		// Order newly created models by foreign-key dependencies so CreateTable
4488		// operations are emitted with referenced tables first. Lexicographic
4489		// sort is the wrong default here: `auth_api_keys` sorts before
4490		// `auth_users` even when the former has an inline FK to the latter.
4491		let created_set: std::collections::BTreeSet<_> =
4492			changes.created_models.iter().cloned().collect();
4493		changes.created_models = changes
4494			.order_created_models_by_dependency()
4495			.into_iter()
4496			.filter(|model| created_set.contains(model))
4497			.collect();
4498
4499		// Sort remaining change lists for deterministic output
4500		changes.deleted_models.sort();
4501		changes.added_fields.sort();
4502		changes.removed_fields.sort();
4503		changes.altered_fields.sort();
4504		changes.renamed_models.sort();
4505		changes.renamed_fields.sort();
4506
4507		// Sort by (app_label, model_name) for index and constraint changes
4508		changes
4509			.added_indexes
4510			.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
4511		changes.removed_indexes.sort();
4512		changes
4513			.added_constraints
4514			.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
4515		changes.removed_constraints.sort();
4516		changes
4517			.added_composite_primary_keys
4518			.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
4519		changes.removed_composite_primary_keys.sort();
4520		changes.auto_increment_resets.sort();
4521		changes
4522			.created_many_to_many
4523			.sort_by(|a, b| (&a.0, &a.1, &a.2).cmp(&(&b.0, &b.1, &b.2)));
4524
4525		Ok(changes)
4526	}
4527
4528	/// Detect newly created models
4529	///
4530	/// Django reference: `generate_created_models()` in django/db/migrations/autodetector.py:800
4531	fn detect_created_models(&self, changes: &mut DetectedChanges) {
4532		for ((app_label, model_name), to_model) in &self.to_state.models {
4533			// Check if the model exists in from_state by table name
4534			if self
4535				.from_state
4536				.get_model_by_table_name(app_label, &to_model.table_name)
4537				.is_none()
4538			{
4539				changes
4540					.created_models
4541					.push((app_label.clone(), model_name.clone()));
4542			}
4543		}
4544	}
4545
4546	/// Detect deleted models
4547	///
4548	/// Django reference: `generate_deleted_models()` in django/db/migrations/autodetector.py:900
4549	fn detect_deleted_models(&self, changes: &mut DetectedChanges) {
4550		for ((app_label, model_name), from_model) in &self.from_state.models {
4551			// Check if the model exists in to_state by table name
4552			if self
4553				.to_state
4554				.get_model_by_table_name(app_label, &from_model.table_name)
4555				.is_none()
4556			{
4557				changes
4558					.deleted_models
4559					.push((app_label.clone(), model_name.clone()));
4560			}
4561		}
4562	}
4563
4564	/// Detect added fields
4565	///
4566	/// Django reference: `generate_added_fields()` in django/db/migrations/autodetector.py:1000
4567	fn detect_added_fields(&self, changes: &mut DetectedChanges) {
4568		for ((app_label, model_name), to_model) in &self.to_state.models {
4569			// Only check models that exist in both states. Model renames are
4570			// resolved through `changes.renamed_models` so simultaneous
4571			// RenameTable + AddColumn cases are preserved.
4572			if let Some(from_model) =
4573				self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4574			{
4575				for field_name in to_model.fields.keys() {
4576					if !from_model.fields.contains_key(field_name) {
4577						changes.added_fields.push((
4578							app_label.clone(),
4579							model_name.clone(),
4580							field_name.clone(),
4581						));
4582					}
4583				}
4584			}
4585		}
4586	}
4587
4588	/// Detect removed fields
4589	///
4590	/// Django reference: `generate_removed_fields()` in django/db/migrations/autodetector.py:1100
4591	fn detect_removed_fields(&self, changes: &mut DetectedChanges) {
4592		for ((app_label, model_name), from_model) in &self.from_state.models {
4593			// Only check models that exist in both states. Model renames are
4594			// resolved through `changes.renamed_models` so simultaneous
4595			// RenameTable + DropColumn cases are preserved.
4596			if let Some(to_model) =
4597				self.matching_to_model_for_from_model(app_label, model_name, from_model, changes)
4598			{
4599				for field_name in from_model.fields.keys() {
4600					if !to_model.fields.contains_key(field_name) {
4601						changes.removed_fields.push((
4602							app_label.clone(),
4603							model_name.clone(),
4604							field_name.clone(),
4605						));
4606					}
4607				}
4608			}
4609		}
4610	}
4611
4612	/// Detect altered fields
4613	///
4614	/// Django reference: `generate_altered_fields()` in django/db/migrations/autodetector.py:1200
4615	fn detect_altered_fields(&self, changes: &mut DetectedChanges) {
4616		for ((app_label, model_name), to_model) in &self.to_state.models {
4617			// Only check models that exist in both states. Model renames are
4618			// resolved through `changes.renamed_models` so simultaneous
4619			// RenameTable + AlterColumn cases are preserved.
4620			if let Some(from_model) =
4621				self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4622			{
4623				for (field_name, to_field) in &to_model.fields {
4624					if let Some(from_field) = from_model.fields.get(field_name) {
4625						// Check if field definition has changed
4626						if self.has_field_changed_in_model_context(
4627							field_name, from_model, to_model, from_field, to_field,
4628						) {
4629							changes.altered_fields.push((
4630								app_label.clone(),
4631								model_name.clone(),
4632								field_name.clone(),
4633							));
4634						}
4635					}
4636				}
4637			}
4638		}
4639	}
4640
4641	fn matching_from_model_for_to_model<'a>(
4642		&'a self,
4643		app_label: &str,
4644		to_model_name: &str,
4645		to_model: &ModelState,
4646		changes: &DetectedChanges,
4647	) -> Option<&'a ModelState> {
4648		self.from_state
4649			.get_model_by_table_name(app_label, &to_model.table_name)
4650			.or_else(|| {
4651				changes
4652					.renamed_models
4653					.iter()
4654					.find(|(app, _old_name, new_name)| {
4655						app == app_label && new_name == to_model_name
4656					})
4657					.and_then(|(_app, old_name, _new_name)| {
4658						self.from_state.get_model(app_label, old_name)
4659					})
4660			})
4661			.or_else(|| {
4662				changes
4663					.moved_models
4664					.iter()
4665					.find(|(_from_app, _from_model, to_app, to_model, _, _, _)| {
4666						to_app == app_label && to_model == to_model_name
4667					})
4668					.and_then(|(from_app, from_model, _to_app, _to_model, _, _, _)| {
4669						self.from_state.get_model(from_app, from_model)
4670					})
4671			})
4672	}
4673
4674	fn matching_to_model_for_from_model<'a>(
4675		&'a self,
4676		app_label: &str,
4677		from_model_name: &str,
4678		from_model: &ModelState,
4679		changes: &DetectedChanges,
4680	) -> Option<&'a ModelState> {
4681		self.to_state
4682			.get_model_by_table_name(app_label, &from_model.table_name)
4683			.or_else(|| {
4684				changes
4685					.renamed_models
4686					.iter()
4687					.find(|(app, old_name, _new_name)| {
4688						app == app_label && old_name == from_model_name
4689					})
4690					.and_then(|(_app, _old_name, new_name)| {
4691						self.to_state.get_model(app_label, new_name)
4692					})
4693			})
4694			.or_else(|| {
4695				changes
4696					.moved_models
4697					.iter()
4698					.find(|(from_app, from_model, _to_app, _to_model, _, _, _)| {
4699						from_app == app_label && from_model == from_model_name
4700					})
4701					.and_then(|(_from_app, _from_model, to_app, to_model, _, _, _)| {
4702						self.to_state.get_model(to_app, to_model)
4703					})
4704			})
4705	}
4706
4707	fn has_field_changed_in_model_context(
4708		&self,
4709		field_name: &str,
4710		from_model: &ModelState,
4711		to_model: &ModelState,
4712		from_field: &FieldState,
4713		to_field: &FieldState,
4714	) -> bool {
4715		let from_constraint_managed =
4716			Self::single_field_unique_constraint_present(from_model, field_name);
4717		let to_constraint_managed =
4718			Self::single_field_unique_constraint_present(to_model, field_name);
4719		let constraint_managed = from_constraint_managed || to_constraint_managed;
4720		let from_inline_unique = Self::field_has_inline_unique(from_model, field_name);
4721		let to_inline_unique = Self::field_has_inline_unique(to_model, field_name);
4722		let from_unique = Some(if constraint_managed {
4723			from_inline_unique && !to_constraint_managed
4724		} else {
4725			Self::single_field_unique_column_already_present(from_model, field_name)
4726		});
4727		let to_unique = Some(if constraint_managed {
4728			to_inline_unique && !from_constraint_managed
4729		} else {
4730			Self::single_field_unique_column_already_present(to_model, field_name)
4731		});
4732		self.has_field_changed_with_unique(field_name, from_field, to_field, from_unique, to_unique)
4733	}
4734
4735	fn field_has_inline_unique(model: &ModelState, field_name: &str) -> bool {
4736		model
4737			.fields
4738			.get(field_name)
4739			.and_then(|field| field.params.get("unique"))
4740			.map(String::as_str)
4741			== Some("true")
4742	}
4743
4744	fn has_field_changed_with_unique(
4745		&self,
4746		field_name: &str,
4747		from_field: &FieldState,
4748		to_field: &FieldState,
4749		from_unique: Option<bool>,
4750		to_unique: Option<bool>,
4751	) -> bool {
4752		// Schema-affecting bits are compared via the canonical
4753		// `ColumnDefinition` form to absorb asymmetric param populations.
4754		let mut from_def = super::ColumnDefinition::from_field_state(field_name, from_field);
4755		let mut to_def = super::ColumnDefinition::from_field_state(field_name, to_field);
4756		from_def.auto_increment =
4757			Self::canonical_auto_increment(&from_def.type_definition, from_def.auto_increment);
4758		to_def.auto_increment =
4759			Self::canonical_auto_increment(&to_def.type_definition, to_def.auto_increment);
4760		if let Some(unique) = from_unique {
4761			from_def.unique = unique;
4762		}
4763		if let Some(unique) = to_unique {
4764			to_def.unique = unique;
4765		}
4766		from_def.type_definition != to_def.type_definition
4767			|| from_def.not_null != to_def.not_null
4768			|| from_def.primary_key != to_def.primary_key
4769			|| from_def.auto_increment != to_def.auto_increment
4770			|| from_def.unique != to_def.unique
4771			|| from_def.default != to_def.default
4772	}
4773
4774	fn canonical_auto_increment(field_type: &super::FieldType, auto_increment: bool) -> bool {
4775		auto_increment
4776			&& matches!(
4777				field_type,
4778				super::FieldType::BigInteger
4779					| super::FieldType::Integer
4780					| super::FieldType::SmallInteger
4781					| super::FieldType::TinyInt
4782					| super::FieldType::MediumInt
4783			)
4784	}
4785
4786	/// Detect renamed models
4787	///
4788	/// This method attempts to detect model renames by comparing deleted and created models.
4789	/// It uses field similarity to determine if a model was renamed rather than deleted/created.
4790	///
4791	/// # Django Reference
4792	/// From: django/db/migrations/autodetector.py:620-750
4793	/// ```python
4794	/// def generate_renamed_models(self):
4795	///     # Find models that were deleted and created with similar fields
4796	///     for (app_label, old_model_name) in self.old_model_keys - self.new_model_keys:
4797	///         for (app_label, new_model_name) in self.new_model_keys - self.old_model_keys:
4798	///             if self._is_renamed_model(old_model_name, new_model_name):
4799	///                 self.add_operation(
4800	///                     app_label,
4801	///                     operations.RenameModel(
4802	///                         old_name=old_model_name,
4803	///                         new_name=new_model_name,
4804	///                     ),
4805	///                 )
4806	/// ```rust,ignore
4807	///
4808	/// # Examples
4809	///
4810	/// ```rust,ignore
4811	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4812	///
4813	/// let mut from_state = ProjectState::new();
4814	/// let mut old_model = ModelState::new("myapp", "OldUser");
4815	/// old_model.add_field(FieldState::new("id", FieldType::Integer, false));
4816	/// old_model.add_field(FieldState::new("name", FieldType::VarChar(255), false));
4817	/// from_state.add_model(old_model);
4818	///
4819	/// let mut to_state = ProjectState::new();
4820	/// let mut new_model = ModelState::new("myapp", "NewUser");
4821	/// new_model.add_field(FieldState::new("id", FieldType::Integer, false));
4822	/// new_model.add_field(FieldState::new("name", FieldType::VarChar(255), false));
4823	/// to_state.add_model(new_model);
4824	///
4825	/// let detector = MigrationAutodetector::new(from_state, to_state);
4826	/// let changes = detector.detect_changes();
4827	///
4828	/// // With high field similarity, should detect as rename
4829	/// assert!(changes.renamed_models.len() <= 1);
4830	/// ```
4831	fn detect_renamed_models(&self, changes: &mut DetectedChanges) {
4832		// Get deleted and created models
4833		let deleted: Vec<_> = self
4834			.from_state
4835			.models
4836			.keys()
4837			.filter(|k| !self.to_state.models.contains_key(k))
4838			.collect();
4839
4840		let created: Vec<_> = self
4841			.to_state
4842			.models
4843			.keys()
4844			.filter(|k| !self.from_state.models.contains_key(k))
4845			.collect();
4846
4847		// Use bipartite matching to find optimal model pairs
4848		// This supports both same-app renames and cross-app moves
4849		let matches = self.find_optimal_model_matches(&deleted, &created);
4850
4851		for (deleted_key, created_key, _similarity) in matches {
4852			// Check if this is a cross-app move or same-app rename
4853			if deleted_key.0 == created_key.0 {
4854				let app_label = deleted_key.0.clone();
4855				let old_model_name = deleted_key.1.clone();
4856				let new_model_name = created_key.1.clone();
4857				// Same app: check if table names actually differ
4858				// Struct-only renames (same table name) are not schema changes
4859				let old_table = self
4860					.from_state
4861					.get_model(&app_label, &old_model_name)
4862					.map(|m| m.table_name.as_str());
4863				let new_table = self
4864					.to_state
4865					.get_model(&app_label, &new_model_name)
4866					.map(|m| m.table_name.as_str());
4867
4868				if old_table != new_table {
4869					changes.renamed_models.push((
4870						app_label.clone(),
4871						old_model_name.clone(),
4872						new_model_name.clone(),
4873					));
4874					changes
4875						.created_models
4876						.retain(|(app, model)| !(app == &app_label && model == &new_model_name));
4877					changes
4878						.deleted_models
4879						.retain(|(app, model)| !(app == &app_label && model == &old_model_name));
4880				}
4881			} else {
4882				let from_app = deleted_key.0.clone();
4883				let to_app = created_key.0.clone();
4884				let model_name = created_key.1.clone();
4885				let deleted_model_name = deleted_key.1.clone();
4886				// Different apps: this is a move operation
4887				// Determine if table needs to be renamed
4888				let old_table = self
4889					.from_state
4890					.get_model(&from_app, &deleted_model_name)
4891					.map(|model| model.table_name.clone())
4892					.unwrap_or_else(|| {
4893						format!("{}_{}", from_app, deleted_model_name.to_lowercase())
4894					});
4895				let new_table = self
4896					.to_state
4897					.get_model(&to_app, &model_name)
4898					.map(|model| model.table_name.clone())
4899					.unwrap_or_else(|| format!("{}_{}", to_app, model_name.to_lowercase()));
4900				let rename_table = old_table != new_table;
4901
4902				changes.moved_models.push((
4903					from_app.clone(),
4904					deleted_model_name.clone(),
4905					to_app.clone(),
4906					model_name.clone(),
4907					rename_table,
4908					if rename_table { Some(old_table) } else { None },
4909					if rename_table { Some(new_table) } else { None },
4910				));
4911				changes
4912					.created_models
4913					.retain(|(app, model)| !(app == &to_app && model == &model_name));
4914				changes
4915					.deleted_models
4916					.retain(|(app, model)| !(app == &from_app && model == &deleted_model_name));
4917			}
4918		}
4919	}
4920
4921	/// Detect renamed fields
4922	///
4923	/// This method attempts to detect field renames by comparing removed and added fields.
4924	///
4925	/// # Django Reference
4926	/// From: django/db/migrations/autodetector.py:1300-1400
4927	/// ```python
4928	/// def generate_renamed_fields(self):
4929	///     for app_label, model_name in sorted(self.kept_model_keys):
4930	///         old_model_state = self.from_state.models[app_label, model_name]
4931	///         new_model_state = self.to_state.models[app_label, model_name]
4932	///
4933	///         # Find fields that were removed and added with same type
4934	///         for old_field_name, old_field in old_model_state.fields:
4935	///             for new_field_name, new_field in new_model_state.fields:
4936	///                 if self._is_renamed_field(old_field, new_field):
4937	///                     self.add_operation(...)
4938	/// ```rust,ignore
4939	///
4940	/// # Examples
4941	///
4942	/// ```rust,ignore
4943	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4944	///
4945	/// let mut from_state = ProjectState::new();
4946	/// let mut old_model = ModelState::new("myapp", "User");
4947	/// old_model.add_field(FieldState::new("old_email", FieldType::VarChar(255), false));
4948	/// from_state.add_model(old_model);
4949	///
4950	/// let mut to_state = ProjectState::new();
4951	/// let mut new_model = ModelState::new("myapp", "User");
4952	/// new_model.add_field(FieldState::new("new_email", FieldType::VarChar(255), false));
4953	/// to_state.add_model(new_model);
4954	///
4955	/// let detector = MigrationAutodetector::new(from_state, to_state);
4956	/// let changes = detector.detect_changes();
4957	///
4958	/// // With matching type, might detect as rename
4959	/// assert!(changes.renamed_fields.len() <= 1);
4960	/// ```
4961	fn detect_renamed_fields(
4962		&self,
4963		changes: &mut DetectedChanges,
4964		strict_rename_ambiguity: bool,
4965	) -> super::Result<()> {
4966		let mut confirmed_renames = Vec::new();
4967		let mut ambiguous_groups = Vec::new();
4968
4969		for ((app_label, model_name), to_model) in &self.to_state.models {
4970			let Some(from_model) =
4971				self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
4972			else {
4973				continue;
4974			};
4975
4976			let removed_fields: Vec<_> = from_model
4977				.fields
4978				.iter()
4979				.filter(|(name, _)| !to_model.fields.contains_key(*name))
4980				.collect();
4981			let added_fields: Vec<_> = to_model
4982				.fields
4983				.iter()
4984				.filter(|(name, _)| !from_model.fields.contains_key(*name))
4985				.collect();
4986
4987			if removed_fields.is_empty() || added_fields.is_empty() {
4988				continue;
4989			}
4990
4991			let mut old_to_new: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4992			let mut new_to_old: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4993
4994			for (removed_name, removed_field) in &removed_fields {
4995				for (added_name, added_field) in &added_fields {
4996					if Self::field_definitions_match_for_rename(
4997						removed_name,
4998						removed_field,
4999						added_name,
5000						added_field,
5001						Self::single_field_unique_column_already_present(from_model, removed_name),
5002						Self::single_field_unique_column_already_present(to_model, added_name),
5003					) {
5004						old_to_new
5005							.entry((*removed_name).clone())
5006							.or_default()
5007							.insert((*added_name).clone());
5008						new_to_old
5009							.entry((*added_name).clone())
5010							.or_default()
5011							.insert((*removed_name).clone());
5012					}
5013				}
5014			}
5015
5016			if old_to_new.is_empty() {
5017				continue;
5018			}
5019
5020			for (old_name, new_names) in &old_to_new {
5021				if new_names.len() == 1 {
5022					let new_name = new_names.iter().next().expect("one candidate");
5023					if new_to_old
5024						.get(new_name)
5025						.is_some_and(|old_names| old_names.len() == 1)
5026					{
5027						confirmed_renames.push((
5028							app_label.clone(),
5029							model_name.clone(),
5030							from_model.name.clone(),
5031							to_model.table_name.clone(),
5032							old_name.clone(),
5033							new_name.clone(),
5034						));
5035						continue;
5036					}
5037				}
5038
5039				ambiguous_groups.push(format!(
5040					"{}.{} (table {}): old [{}] -> new [{}]",
5041					app_label,
5042					model_name,
5043					to_model.table_name,
5044					old_name,
5045					new_names.iter().cloned().collect::<Vec<_>>().join(", ")
5046				));
5047			}
5048
5049			for (new_name, old_names) in &new_to_old {
5050				if old_names.len() > 1 {
5051					ambiguous_groups.push(format!(
5052						"{}.{} (table {}): old [{}] -> new [{}]",
5053						app_label,
5054						model_name,
5055						to_model.table_name,
5056						old_names.iter().cloned().collect::<Vec<_>>().join(", "),
5057						new_name
5058					));
5059				}
5060			}
5061		}
5062
5063		ambiguous_groups.sort();
5064		ambiguous_groups.dedup();
5065		if strict_rename_ambiguity && !ambiguous_groups.is_empty() {
5066			return Err(super::MigrationError::InvalidMigration(format!(
5067				"Ambiguous field rename candidates detected. \
5068				 Reinhardt will not emit destructive AddColumn + DropColumn operations for \
5069				 rename-like changes. Split the change or make the rename intent explicit. \
5070				 Candidates: {}",
5071				ambiguous_groups.join("; ")
5072			)));
5073		}
5074
5075		for (app_label, model_name, from_model_name, table_name, old_name, new_name) in
5076			confirmed_renames
5077		{
5078			changes.renamed_fields.push((
5079				app_label.clone(),
5080				model_name.clone(),
5081				old_name.clone(),
5082				new_name.clone(),
5083			));
5084			changes.added_fields.retain(|(app, model, field)| {
5085				!(app == &app_label && model == &model_name && field == &new_name)
5086			});
5087			changes.removed_fields.retain(|(app, model, field)| {
5088				!(app == &app_label
5089					&& field == &old_name
5090					&& (model == &from_model_name
5091						|| self
5092							.from_state
5093							.get_model(app, model)
5094							.is_some_and(|from_model| from_model.table_name == table_name)))
5095			});
5096			changes.altered_fields.retain(|(app, model, field)| {
5097				!(app == &app_label && model == &model_name && field == &new_name)
5098			});
5099		}
5100
5101		Ok(())
5102	}
5103
5104	fn field_definitions_match_for_rename(
5105		from_name: &str,
5106		from_field: &FieldState,
5107		to_name: &str,
5108		to_field: &FieldState,
5109		from_unique: bool,
5110		to_unique: bool,
5111	) -> bool {
5112		if from_field.field_type != to_field.field_type
5113			|| from_field.nullable != to_field.nullable
5114			|| from_field.foreign_key != to_field.foreign_key
5115		{
5116			return false;
5117		}
5118
5119		let mut from_def = super::ColumnDefinition::from_field_state(from_name, from_field);
5120		let mut to_def = super::ColumnDefinition::from_field_state(to_name, to_field);
5121		from_def.name = "__renamed_field__".to_string();
5122		to_def.name = "__renamed_field__".to_string();
5123		from_def.unique = from_unique;
5124		to_def.unique = to_unique;
5125		from_def == to_def
5126	}
5127
5128	/// Calculate similarity between two models using advanced field matching
5129	///
5130	/// # Algorithm: Weighted Bipartite Matching for Fields
5131	/// - Uses Jaro-Winkler for field name similarity
5132	/// - Time Complexity: O(n*m) where n,m are number of fields
5133	/// - Considers both exact matches and fuzzy matches
5134	///
5135	/// # Scoring:
5136	/// - Exact field name + type match: 1.0
5137	/// - Fuzzy field name + type match: Jaro-Winkler score (0.0-1.0)
5138	/// - No type match: 0.0
5139	///
5140	/// Returns a value between 0.0 and 1.0, where 1.0 means identical field sets.
5141	///
5142	/// # Examples
5143	///
5144	/// ```rust,ignore
5145	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
5146	///
5147	/// let mut from_state = ProjectState::new();
5148	/// let mut from_model = ModelState::new("myapp", "User");
5149	/// from_model.add_field(FieldState::new("user_id", FieldType::Integer, false));
5150	/// from_model.add_field(FieldState::new("user_email", FieldType::VarChar(255), false));
5151	/// from_state.add_model(from_model);
5152	///
5153	/// let mut to_state = ProjectState::new();
5154	/// let mut to_model = ModelState::new("auth", "User");
5155	/// to_model.add_field(FieldState::new("id", FieldType::Integer, false));
5156	/// to_model.add_field(FieldState::new("email", FieldType::VarChar(255), false));
5157	/// to_state.add_model(to_model);
5158	///
5159	/// let detector = MigrationAutodetector::new(from_state, to_state);
5160	/// // Similarity would be high due to fuzzy field name matching
5161	/// ```
5162	fn calculate_model_similarity(&self, from_model: &ModelState, to_model: &ModelState) -> f64 {
5163		if from_model.fields.is_empty() && to_model.fields.is_empty() {
5164			return 1.0;
5165		}
5166
5167		if from_model.fields.is_empty() || to_model.fields.is_empty() {
5168			return 0.0;
5169		}
5170
5171		let mut total_similarity = 0.0;
5172		let total_fields = from_model.fields.len().max(to_model.fields.len());
5173
5174		// Use Hungarian algorithm concept: find best matching between fields
5175		let mut matched_to_fields = std::collections::HashSet::new();
5176
5177		for (from_field_name, from_field) in &from_model.fields {
5178			let mut best_match_score = 0.0;
5179			let mut best_match_name = None;
5180
5181			// Find best matching field in to_model
5182			for (to_field_name, to_field) in &to_model.fields {
5183				if matched_to_fields.contains(to_field_name) {
5184					continue;
5185				}
5186
5187				let similarity = self.calculate_field_similarity(
5188					from_field_name,
5189					to_field_name,
5190					from_field,
5191					to_field,
5192				);
5193
5194				if similarity > best_match_score {
5195					best_match_score = similarity;
5196					best_match_name = Some(to_field_name.clone());
5197				}
5198			}
5199
5200			if let Some(matched_name) = best_match_name {
5201				matched_to_fields.insert(matched_name);
5202				total_similarity += best_match_score;
5203			}
5204		}
5205
5206		total_similarity / total_fields as f64
5207	}
5208
5209	/// Calculate field-level similarity using hybrid algorithm
5210	///
5211	/// This method combines Jaro-Winkler and Levenshtein distance to measure
5212	/// similarity between field names, providing better detection than either alone.
5213	///
5214	/// # Hybrid Algorithm
5215	/// - **Jaro-Winkler**: Best for prefix similarities (e.g., "UserEmail" vs "UserAddress")
5216	///   - Time Complexity: O(n)
5217	///   - Range: 0.0 to 1.0
5218	///   - Default weight: 70%
5219	/// - **Levenshtein**: Best for edit distance (e.g., "User" vs "Users")
5220	///   - Time Complexity: O(n*m)
5221	///   - Normalized to 0.0-1.0 range
5222	///   - Default weight: 30%
5223	///
5224	/// # Examples
5225	///
5226	/// ```rust,ignore
5227	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
5228	///
5229	/// let from_state = ProjectState::new();
5230	/// let to_state = ProjectState::new();
5231	/// let detector = MigrationAutodetector::new(from_state, to_state);
5232	///
5233	/// let from_field = FieldState::new("user_email", FieldType::VarChar(255), false);
5234	/// let to_field = FieldState::new("email", FieldType::VarChar(255), false);
5235	///
5236	/// // High similarity (field name is similar and type matches)
5237	/// // Jaro-Winkler ≈ 0.81, Levenshtein normalized ≈ 0.45
5238	/// // Hybrid (0.7 * 0.81 + 0.3 * 0.45) ≈ 0.70
5239	/// ```
5240	fn calculate_field_similarity(
5241		&self,
5242		from_field_name: &str,
5243		to_field_name: &str,
5244		from_field: &FieldState,
5245		to_field: &FieldState,
5246	) -> f64 {
5247		// If types don't match, similarity is 0
5248		if from_field.field_type != to_field.field_type {
5249			return 0.0;
5250		}
5251
5252		// Calculate Jaro-Winkler similarity (0.0 - 1.0)
5253		let jaro_winkler_sim = jaro_winkler(from_field_name, to_field_name);
5254
5255		// Calculate Levenshtein distance and normalize to 0.0-1.0
5256		let lev_distance = levenshtein(from_field_name, to_field_name);
5257		let max_len = from_field_name.len().max(to_field_name.len()) as f64;
5258		let levenshtein_sim = if max_len > 0.0 {
5259			1.0 - (lev_distance as f64 / max_len)
5260		} else {
5261			1.0 // Both strings are empty
5262		};
5263
5264		// Combine using configured weights
5265		let name_similarity = self.similarity_config.jaro_winkler_weight * jaro_winkler_sim
5266			+ self.similarity_config.levenshtein_weight * levenshtein_sim;
5267
5268		// Boost similarity if nullability also matches
5269		let nullable_boost = if from_field.nullable == to_field.nullable {
5270			0.1
5271		} else {
5272			0.0
5273		};
5274
5275		(name_similarity + nullable_boost).min(1.0)
5276	}
5277
5278	/// Perform bipartite matching between deleted and created models
5279	///
5280	/// # Algorithm: Maximum Weight Bipartite Matching
5281	/// - Based on Hopcroft-Karp algorithm concept: O(n*m*√(n+m))
5282	/// - Uses petgraph for graph construction
5283	/// - Finds optimal matching considering all possible pairs
5284	///
5285	/// # Implementation Note
5286	/// This implementation uses a greedy approach with weighted edges sorted by
5287	/// similarity score. While not a full Hopcroft-Karp implementation, it provides
5288	/// good results with O(E log E) complexity where E = number of edges.
5289	///
5290	/// # Returns
5291	/// Vector of matches: (deleted_key, created_key, similarity_score)
5292	///
5293	/// # Examples
5294	///
5295	/// ```rust,ignore
5296	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
5297	///
5298	/// let mut from_state = ProjectState::new();
5299	/// let mut old_model = ModelState::new("myapp", "User");
5300	/// old_model.add_field(FieldState::new("id", FieldType::Integer, false));
5301	/// from_state.add_model(old_model);
5302	///
5303	/// let mut to_state = ProjectState::new();
5304	/// let mut new_model = ModelState::new("auth", "User");
5305	/// new_model.add_field(FieldState::new("id", FieldType::Integer, false));
5306	/// to_state.add_model(new_model);
5307	///
5308	/// let detector = MigrationAutodetector::new(from_state, to_state);
5309	/// // Would detect cross-app model move from myapp.User to auth.User
5310	/// ```
5311	fn find_optimal_model_matches(
5312		&self,
5313		deleted: &[&(String, String)],
5314		created: &[&(String, String)],
5315	) -> Vec<ModelMatchResult> {
5316		let mut graph = Graph::<(), f64, Undirected>::new_undirected();
5317		let mut deleted_nodes = Vec::new();
5318		let mut created_nodes = Vec::new();
5319
5320		// Create nodes for deleted models (left side of bipartite graph)
5321		for _ in deleted {
5322			deleted_nodes.push(graph.add_node(()));
5323		}
5324
5325		// Create nodes for created models (right side of bipartite graph)
5326		for _ in created {
5327			created_nodes.push(graph.add_node(()));
5328		}
5329
5330		// Add edges with similarity weights
5331		for (i, deleted_key) in deleted.iter().enumerate() {
5332			if let Some(from_model) = self.from_state.models.get(*deleted_key) {
5333				for (j, created_key) in created.iter().enumerate() {
5334					if let Some(to_model) = self.to_state.models.get(*created_key) {
5335						let similarity = self.calculate_model_similarity(from_model, to_model);
5336
5337						// Only add edge if similarity exceeds threshold
5338						if similarity >= self.similarity_config.model_threshold() {
5339							graph.add_edge(deleted_nodes[i], created_nodes[j], similarity);
5340						}
5341					}
5342				}
5343			}
5344		}
5345
5346		// Find maximum weight matching using greedy algorithm
5347		// (Full Hopcroft-Karp would require additional implementation)
5348		let mut matches = Vec::new();
5349		let mut used_deleted = std::collections::HashSet::new();
5350		let mut used_created = std::collections::HashSet::new();
5351
5352		// Sort edges by weight (similarity) in descending order
5353		let mut weighted_edges: Vec<_> = graph
5354			.edge_references()
5355			.map(|e| (e.source(), e.target(), *e.weight()))
5356			.collect();
5357		weighted_edges.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
5358
5359		// Greedy matching: pick highest weight edges first
5360		for (source, target, weight) in weighted_edges {
5361			let source_idx = deleted_nodes.iter().position(|&n| n == source);
5362			let target_idx = created_nodes.iter().position(|&n| n == target);
5363
5364			if let (Some(i), Some(j)) = (source_idx, target_idx)
5365				&& !used_deleted.contains(&i)
5366				&& !used_created.contains(&j)
5367			{
5368				matches.push((deleted[i].clone(), created[j].clone(), weight));
5369				used_deleted.insert(i);
5370				used_created.insert(j);
5371			}
5372		}
5373
5374		matches
5375	}
5376
5377	/// Detect added indexes
5378	///
5379	/// # Django Reference
5380	/// From: django/db/migrations/autodetector.py:1500-1600
5381	fn detect_added_indexes(&self, changes: &mut DetectedChanges) {
5382		for ((app_label, model_name), to_model) in &self.to_state.models {
5383			if let Some(from_model) =
5384				self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
5385			{
5386				for to_index in &to_model.indexes {
5387					// Check if this index exists in from_model
5388					if !from_model.indexes.iter().any(|idx| {
5389						model_index_definitions_equivalent(from_model, idx, to_model, to_index)
5390					}) {
5391						changes.added_indexes.push((
5392							app_label.clone(),
5393							model_name.clone(),
5394							to_index.clone(),
5395						));
5396					}
5397				}
5398			}
5399		}
5400	}
5401
5402	/// Detect removed indexes
5403	///
5404	/// # Django Reference
5405	/// From: django/db/migrations/autodetector.py:1600-1700
5406	fn detect_removed_indexes(&self, changes: &mut DetectedChanges) {
5407		for ((app_label, model_name), from_model) in &self.from_state.models {
5408			if let Some(to_model) =
5409				self.matching_to_model_for_from_model(app_label, model_name, from_model, changes)
5410			{
5411				for from_index in &from_model.indexes {
5412					// Check if this index still exists in to_model
5413					if !to_model.indexes.iter().any(|idx| {
5414						model_index_definitions_equivalent(from_model, from_index, to_model, idx)
5415					}) {
5416						changes.removed_indexes.push((
5417							app_label.clone(),
5418							model_name.clone(),
5419							from_index.name.clone(),
5420						));
5421					}
5422				}
5423			}
5424		}
5425	}
5426
5427	/// Detect added constraints
5428	///
5429	/// A single-field UNIQUE constraint on the to-side is treated as
5430	/// already-present on the from-side when any of the following is true:
5431	///
5432	/// 1. From-state has a constraint with the same name (legacy behaviour).
5433	/// 2. From-state has any single-field UNIQUE constraint covering the same
5434	///    column — same semantics, different name. This handles the
5435	///    DB-introspection case where SQLite auto-generates names like
5436	///    `sqlite_autoindex_users_1`, which never match the to-state's
5437	///    `{table}_{field}_uniq`.
5438	/// 3. From-state has a `FieldState` for that column with
5439	///    `params["unique"] == "true"`. This handles the file-based
5440	///    reconstruction path: `apply_migration_operations` translates
5441	///    `ColumnDefinition.unique = true` into an inline field param but
5442	///    never synthesises a peer `ConstraintDefinition`, while
5443	///    `ModelMetadata::to_model_state()` on the to-side does synthesise
5444	///    one. Without this branch the autodetector keeps emitting a
5445	///    redundant `AddConstraint` every time `makemigrations` runs against
5446	///    a model whose `#[field(unique = true)]` column already shipped in
5447	///    `0001_initial.rs` (see reinhardt-web#4448).
5448	///
5449	/// # Django Reference
5450	/// From: django/db/migrations/autodetector.py:1700-1800
5451	fn detect_added_constraints(&self, changes: &mut DetectedChanges) {
5452		for ((app_label, model_name), to_model) in &self.to_state.models {
5453			if let Some(from_model) =
5454				self.matching_from_model_for_to_model(app_label, model_name, to_model, changes)
5455			{
5456				for to_constraint in &to_model.constraints {
5457					if from_model.constraints.iter().any(|c| {
5458						c.name == to_constraint.name
5459							&& Self::constraint_definitions_match(c, to_constraint)
5460					}) {
5461						continue;
5462					}
5463					if Self::single_field_unique_already_present(to_constraint, from_model) {
5464						continue;
5465					}
5466					if Self::added_single_field_unique_preserved_by_rename(
5467						changes,
5468						app_label,
5469						model_name,
5470						to_constraint,
5471						from_model,
5472					) {
5473						continue;
5474					}
5475					changes.added_constraints.push((
5476						app_label.clone(),
5477						model_name.clone(),
5478						to_constraint.clone(),
5479					));
5480				}
5481			}
5482		}
5483	}
5484
5485	/// Detect removed constraints
5486	///
5487	/// Symmetric to [`Self::detect_added_constraints`]: a from-side
5488	/// single-field UNIQUE constraint is NOT reported as removed when the
5489	/// to-side carries an equivalent shape — either another single-field
5490	/// UNIQUE constraint over the same column, or a `FieldState` for that
5491	/// column with `params["unique"] == "true"`. Without this guard the
5492	/// asymmetric shape-match in `detect_added_constraints` would simply move
5493	/// the redundancy from `AddConstraint` into a spurious `DropConstraint`
5494	/// when the column was originally introduced as a separately-named
5495	/// UNIQUE constraint but is now declared via inline `#[field(unique =
5496	/// true)]` (or vice versa). See reinhardt-web#4448.
5497	///
5498	/// # Django Reference
5499	/// From: django/db/migrations/autodetector.py:1800-1900
5500	fn detect_removed_constraints(&self, changes: &mut DetectedChanges) {
5501		for ((app_label, model_name), from_model) in &self.from_state.models {
5502			if let Some(to_model) =
5503				self.matching_to_model_for_from_model(app_label, model_name, from_model, changes)
5504			{
5505				for from_constraint in &from_model.constraints {
5506					if to_model.constraints.iter().any(|c| {
5507						c.name == from_constraint.name
5508							&& Self::constraint_definitions_match(c, from_constraint)
5509					}) {
5510						continue;
5511					}
5512					if Self::single_field_unique_already_present(from_constraint, to_model) {
5513						continue;
5514					}
5515					if Self::removed_single_field_unique_preserved_by_rename(
5516						changes,
5517						app_label,
5518						model_name,
5519						to_model,
5520						from_constraint,
5521					) {
5522						continue;
5523					}
5524					changes.removed_constraints.push((
5525						app_label.clone(),
5526						model_name.clone(),
5527						from_constraint.name.clone(),
5528					));
5529				}
5530			}
5531		}
5532	}
5533
5534	fn constraint_definitions_match(
5535		left: &ConstraintDefinition,
5536		right: &ConstraintDefinition,
5537	) -> bool {
5538		left.constraint_type
5539			.eq_ignore_ascii_case(&right.constraint_type)
5540			&& left.fields == right.fields
5541			&& left.expression == right.expression
5542			&& left.foreign_key_info == right.foreign_key_info
5543	}
5544
5545	fn renamed_single_field_unique_constraints(
5546		from_model: &ModelState,
5547		to_model: &ModelState,
5548	) -> Vec<(ConstraintDefinition, ConstraintDefinition)> {
5549		from_model
5550			.constraints
5551			.iter()
5552			.filter(|from_constraint| is_single_field_unique(from_constraint))
5553			.filter_map(|from_constraint| {
5554				to_model
5555					.constraints
5556					.iter()
5557					.find(|to_constraint| {
5558						to_constraint.name != from_constraint.name
5559							&& is_single_field_unique(to_constraint)
5560							&& Self::constraint_definitions_match(from_constraint, to_constraint)
5561					})
5562					.map(|to_constraint| (from_constraint.clone(), to_constraint.clone()))
5563			})
5564			.collect()
5565	}
5566
5567	/// Returns true when `candidate` is a single-field UNIQUE constraint and
5568	/// the same column on `other_side` is already covered by either:
5569	/// - any single-field UNIQUE constraint over the same column, or
5570	/// - a field whose `params["unique"] == "true"`.
5571	///
5572	/// Used by both `detect_added_constraints` and
5573	/// `detect_removed_constraints` to recognise inline `column.unique = true`
5574	/// and a separately-named single-field `UNIQUE` constraint as
5575	/// semantically identical, so a name mismatch alone does not trigger a
5576	/// redundant `AddConstraint` / `DropConstraint` (reinhardt-web#4448).
5577	fn single_field_unique_already_present(
5578		candidate: &ConstraintDefinition,
5579		other_side: &ModelState,
5580	) -> bool {
5581		if !is_single_field_unique(candidate) {
5582			return false;
5583		}
5584		let column = &candidate.fields[0];
5585		Self::single_field_unique_column_already_present(other_side, column)
5586	}
5587
5588	fn single_field_unique_column_already_present(model: &ModelState, column: &str) -> bool {
5589		if Self::single_field_unique_constraint_present(model, column) {
5590			return true;
5591		}
5592		model
5593			.fields
5594			.get(column)
5595			.and_then(|f| f.params.get("unique"))
5596			.map(String::as_str)
5597			== Some("true")
5598	}
5599
5600	fn single_field_unique_constraint_present(model: &ModelState, column: &str) -> bool {
5601		model
5602			.constraints
5603			.iter()
5604			.any(|constraint| is_single_field_unique(constraint) && constraint.fields[0] == column)
5605	}
5606
5607	fn added_single_field_unique_preserved_by_rename(
5608		changes: &DetectedChanges,
5609		app_label: &str,
5610		model_name: &str,
5611		to_constraint: &ConstraintDefinition,
5612		from_model: &ModelState,
5613	) -> bool {
5614		if !is_single_field_unique(to_constraint) {
5615			return false;
5616		}
5617		let new_column = &to_constraint.fields[0];
5618		changes.renamed_fields.iter().any(|(app, model, old, new)| {
5619			app == app_label
5620				&& model == model_name
5621				&& new == new_column
5622				&& !Self::single_field_unique_constraint_present(from_model, old)
5623				&& Self::single_field_unique_column_already_present(from_model, old)
5624		})
5625	}
5626
5627	fn removed_single_field_unique_preserved_by_rename(
5628		changes: &DetectedChanges,
5629		app_label: &str,
5630		from_model_name: &str,
5631		to_model: &ModelState,
5632		from_constraint: &ConstraintDefinition,
5633	) -> bool {
5634		if !is_single_field_unique(from_constraint) {
5635			return false;
5636		}
5637		let old_column = &from_constraint.fields[0];
5638		changes.renamed_fields.iter().any(|(app, model, old, new)| {
5639			app == app_label
5640				&& (model == from_model_name || model == &to_model.name)
5641				&& old == old_column
5642				&& !Self::single_field_unique_constraint_present(to_model, new)
5643				&& Self::single_field_unique_column_already_present(to_model, new)
5644		})
5645	}
5646
5647	/// Final-pass dedup: drop redundant single-column `AddConstraint UNIQUE`
5648	/// operations whose column is already declared unique elsewhere in the
5649	/// same migration.
5650	///
5651	/// This is the second of two layers that protect against the bug in
5652	/// reinhardt-web#4448. The primary fix is `detect_added_constraints`'s
5653	/// shape-match, which compares from-state and to-state. This pass
5654	/// inspects the *generated* operation list and is the safety net for
5655	/// any future codepath that produces both an `AddColumn { column.unique
5656	/// = true }` and a peer `AddConstraint` for the same single column —
5657	/// for example, a column being added in the same migration as the
5658	/// model registry synthesises its `{table}_{field}_uniq`
5659	/// constraint.
5660	///
5661	/// Coverage rules (per `(table, column)`):
5662	/// - `Operation::CreateTable { name, columns, constraints }` —
5663	///   any column with `unique = true` or any `Constraint::Unique` over a
5664	///   single column counts the column as already unique.
5665	/// - `Operation::AddColumn { table, column }` — `column.unique = true`
5666	///   counts.
5667	/// - A previously-emitted `Operation::AddConstraint` whose SQL is a
5668	///   single-column UNIQUE on the same column also counts, so duplicate
5669	///   `AddConstraint`s for the same column in the same op list collapse
5670	///   to one.
5671	///
5672	/// Multi-column UNIQUE (`unique_together`) is intentionally not touched
5673	/// — its semantics differ from a single-column UNIQUE.
5674	fn dedup_redundant_unique_add_constraints(
5675		by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
5676	) {
5677		use std::collections::HashSet;
5678
5679		for operations in by_app.values_mut() {
5680			// (table, column) pairs already known to be UNIQUE in this migration.
5681			let mut covered: HashSet<(String, String)> = HashSet::new();
5682			let mut keep = Vec::with_capacity(operations.len());
5683			for op in operations.drain(..) {
5684				match &op {
5685					super::Operation::CreateTable {
5686						name,
5687						columns,
5688						constraints,
5689						..
5690					} => {
5691						for col in columns {
5692							if col.unique {
5693								covered.insert((name.clone(), col.name.clone()));
5694							}
5695						}
5696						for c in constraints {
5697							if let super::operations::Constraint::Unique { columns, .. } = c
5698								&& columns.len() == 1
5699							{
5700								covered.insert((name.clone(), columns[0].clone()));
5701							}
5702						}
5703						keep.push(op);
5704					}
5705					super::Operation::AddColumn { table, column, .. } => {
5706						if column.unique {
5707							covered.insert((table.clone(), column.name.clone()));
5708						}
5709						keep.push(op);
5710					}
5711					super::Operation::AddConstraint {
5712						table,
5713						constraint_sql,
5714					} => {
5715						if let Some(col) = parse_single_column_unique(constraint_sql) {
5716							let key = (table.clone(), col.to_string());
5717							if covered.contains(&key) {
5718								// Redundant — drop it.
5719								continue;
5720							}
5721							covered.insert(key);
5722						}
5723						keep.push(op);
5724					}
5725					_ => keep.push(op),
5726				}
5727			}
5728			*operations = keep;
5729		}
5730	}
5731
5732	/// Detect added and modified composite primary keys (2+ columns).
5733	///
5734	/// A composite PK is represented as a `ConstraintDefinition` with
5735	/// `constraint_type == "primary_key"` and `fields.len() >= 2`.
5736	///
5737	/// Three cases are handled:
5738	/// - Added: no constraint with the same name existed in from_state → emit CreateCompositePrimaryKey
5739	/// - Modified: same constraint name exists but fields differ → emit DropConstraint + CreateCompositePrimaryKey
5740	/// - Unchanged: same constraint name and identical fields → no operation
5741	fn detect_composite_pk_changes(&self, changes: &mut DetectedChanges) {
5742		for ((app_label, model_name), to_model) in &self.to_state.models {
5743			let from_model = self
5744				.from_state
5745				.get_model_by_table_name(app_label, &to_model.table_name);
5746			for constraint in &to_model.constraints {
5747				if constraint.constraint_type != "primary_key" || constraint.fields.len() < 2 {
5748					continue;
5749				}
5750				let from_pk = from_model
5751					.and_then(|m| m.constraints.iter().find(|c| c.name == constraint.name));
5752				match from_pk {
5753					Some(existing) if existing.fields == constraint.fields => {
5754						// Unchanged — no operation needed
5755					}
5756					Some(_) => {
5757						// Modified (same name, different fields) — drop old then create new
5758						changes.removed_composite_primary_keys.push((
5759							app_label.clone(),
5760							model_name.clone(),
5761							constraint.name.clone(),
5762						));
5763						changes.added_composite_primary_keys.push((
5764							app_label.clone(),
5765							model_name.clone(),
5766							constraint.clone(),
5767						));
5768					}
5769					None => {
5770						// Added — create new
5771						changes.added_composite_primary_keys.push((
5772							app_label.clone(),
5773							model_name.clone(),
5774							constraint.clone(),
5775						));
5776					}
5777				}
5778			}
5779		}
5780	}
5781
5782	/// Detect auto-increment sequence resets driven by `sequence_reset` model option.
5783	///
5784	/// When `ModelState.options["sequence_reset"]` is added or changed, emit a
5785	/// `SetAutoIncrementValue` operation targeting the model's auto-increment column.
5786	fn detect_auto_increment_resets(&self, changes: &mut DetectedChanges) {
5787		for ((app_label, model_name), to_model) in &self.to_state.models {
5788			let Some(value_str) = to_model.options.get("sequence_reset") else {
5789				continue;
5790			};
5791			let from_value = self
5792				.from_state
5793				.get_model(app_label, model_name)
5794				.and_then(|m| m.options.get("sequence_reset"))
5795				.map(String::as_str);
5796			if from_value == Some(value_str.as_str()) {
5797				continue;
5798			}
5799			let Ok(value) = value_str.parse::<i64>() else {
5800				eprintln!(
5801					"Invalid sequence_reset value for {}.{}: {:?}. Expected an integer.",
5802					app_label, model_name, value_str
5803				);
5804				continue;
5805			};
5806			let Some(column) = to_model
5807				.fields
5808				.iter()
5809				.find(|(_, f)| f.params.get("auto_increment").is_some_and(|v| v == "true"))
5810				.map(|(name, _)| name.clone())
5811			else {
5812				continue;
5813			};
5814			changes.auto_increment_resets.push((
5815				app_label.clone(),
5816				model_name.clone(),
5817				column,
5818				value,
5819			));
5820		}
5821	}
5822
5823	/// Generate intermediate table operation for ManyToMany field
5824	///
5825	/// Creates a through table for ManyToMany relationships with:
5826	/// - id: BigInteger primary key with auto_increment
5827	/// - {source}_id: BigInteger foreign key to source model
5828	/// - {target}_id: BigInteger foreign key to target model
5829	/// - Unique constraint on (source_id, target_id)
5830	///
5831	/// # Arguments
5832	/// * `app_label` - The app label of the source model
5833	/// * `model_name` - The source model name
5834	/// * `field_name` - The ManyToMany field name
5835	/// * `to_model` - The target model reference (e.g., "app.Model")
5836	/// * `through_table` - Optional custom through table name
5837	///
5838	/// # Returns
5839	/// Optional CreateTable operation for the intermediate table
5840	fn generate_intermediate_table(
5841		&self,
5842		app_label: &str,
5843		model_name: &str,
5844		field_name: &str,
5845		to_model: &str,
5846		through_table: &Option<String>,
5847	) -> Option<super::Operation> {
5848		// Resolve the source table name from to_state. The source model is
5849		// guaranteed to be in to_state because this function is called from
5850		// `generate_operations` while iterating `to_state.models`.
5851		let source_table = self
5852			.to_state
5853			.get_model(app_label, model_name)
5854			.map(|m| m.table_name.clone())
5855			.unwrap_or_else(|| {
5856				format!("{}_{}", to_snake_case(app_label), to_snake_case(model_name))
5857			});
5858
5859		// Parse target model to get its app and table name
5860		let (target_app, target_model) = self.parse_model_reference(to_model, app_label)?;
5861		let target_table = self
5862			.to_state
5863			.get_model(&target_app, &target_model)
5864			.map(|m| m.table_name.clone())
5865			.or_else(|| {
5866				super::model_registry::global_registry()
5867					.get_models()
5868					.iter()
5869					.find(|m| m.app_label == target_app && m.model_name == target_model)
5870					.map(|m| m.table_name.clone())
5871			})
5872			.unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
5873
5874		// Generate through-table name: prefer explicit `through`, otherwise
5875		// derive from the source table name (matches
5876		// `create_intermediate_table_for_m2m` and the ORM accessor's
5877		// `default_through_table`, which both lowercase the source table
5878		// before composition — see #4659).
5879		let table_name = if let Some(custom_name) = through_table {
5880			custom_name.clone()
5881		} else {
5882			format!(
5883				"{}_{}",
5884				source_table.to_lowercase(),
5885				to_snake_case(field_name)
5886			)
5887		};
5888
5889		// Derive column names from the resolved *table* names so the
5890		// autodetector matches the ORM accessor convention
5891		// (`format!("{}_id", T::table_name().to_lowercase())` in
5892		// `crates/reinhardt-db/src/orm/many_to_many_accessor.rs`). Compare by
5893		// table identity for self-reference (#4659 follow-up).
5894		let source_table_lower = source_table.to_lowercase();
5895		let target_table_lower = target_table.to_lowercase();
5896		let (source_column, target_column) = if source_table_lower == target_table_lower {
5897			(
5898				format!("from_{}_id", source_table_lower),
5899				format!("to_{}_id", target_table_lower),
5900			)
5901		} else {
5902			(
5903				format!("{}_id", source_table_lower),
5904				format!("{}_id", target_table_lower),
5905			)
5906		};
5907
5908		// Resolve real PK types on both sides so the junction table matches
5909		// what `create_intermediate_table_for_m2m` / `generate_migrations`
5910		// produce. Without this, FK columns would hard-code `BigInteger`
5911		// even when either side uses a different PK type.
5912		let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
5913		let target_pk_type = self
5914			.to_state
5915			.get_primary_key_type(&target_app, &target_model);
5916
5917		// Create columns
5918		let columns = vec![
5919			// id column
5920			super::ColumnDefinition {
5921				name: "id".to_string(),
5922				type_definition: super::FieldType::BigInteger,
5923				not_null: true,
5924				unique: false,
5925				primary_key: true,
5926				auto_increment: true,
5927				default: None,
5928			},
5929			// source_id column
5930			super::ColumnDefinition {
5931				name: source_column.clone(),
5932				type_definition: source_pk_type,
5933				not_null: true,
5934				unique: false,
5935				primary_key: false,
5936				auto_increment: false,
5937				default: None,
5938			},
5939			// target_id column
5940			super::ColumnDefinition {
5941				name: target_column.clone(),
5942				type_definition: target_pk_type,
5943				not_null: true,
5944				unique: false,
5945				primary_key: false,
5946				auto_increment: false,
5947				default: None,
5948			},
5949		];
5950
5951		// Create constraints (use the resolved table names)
5952		let constraints = vec![
5953			// Foreign key to source table
5954			super::Constraint::ForeignKey {
5955				name: format!("fk_{}_{}", table_name, source_column),
5956				columns: vec![source_column.clone()],
5957				referenced_table: source_table.clone(),
5958				referenced_columns: vec!["id".to_string()],
5959				on_delete: super::ForeignKeyAction::Cascade,
5960				on_update: super::ForeignKeyAction::Cascade,
5961				deferrable: None,
5962			},
5963			// Foreign key to target table
5964			super::Constraint::ForeignKey {
5965				name: format!("fk_{}_{}", table_name, target_column),
5966				columns: vec![target_column.clone()],
5967				referenced_table: target_table.clone(),
5968				referenced_columns: vec!["id".to_string()],
5969				on_delete: super::ForeignKeyAction::Cascade,
5970				on_update: super::ForeignKeyAction::Cascade,
5971				deferrable: None,
5972			},
5973			// Unique constraint on (source_id, target_id)
5974			super::Constraint::Unique {
5975				name: format!(
5976					"uq_{}_{}_{}",
5977					table_name,
5978					source_column.replace("_id", ""),
5979					target_column.replace("_id", "")
5980				),
5981				columns: vec![source_column, target_column],
5982			},
5983		];
5984
5985		Some(super::Operation::CreateTable {
5986			name: table_name,
5987			columns,
5988			constraints,
5989			without_rowid: None,
5990			interleave_in_parent: None,
5991			partition: None,
5992		})
5993	}
5994
5995	/// Generate operations from detected changes
5996	///
5997	/// Converts DetectedChanges into a list of Operation objects that can be
5998	/// executed to migrate the database schema.
5999	///
6000	/// # Django Reference
6001	/// From: django/db/migrations/autodetector.py:1063-1164
6002	/// ```python
6003	/// def generate_created_models(self):
6004	///     for app_label, model_name in sorted(self.new_model_keys):
6005	///         model_state = self.to_state.models[app_label, model_name]
6006	///         self.add_operation(
6007	///             app_label,
6008	///             operations.CreateModel(
6009	///                 name=model_name,
6010	///                 fields=model_state.fields,
6011	///                 options=model_state.options,
6012	///                 bases=model_state.bases,
6013	///             ),
6014	///         )
6015	/// ```rust,ignore
6016	///
6017	/// # Examples
6018	///
6019	/// ```rust,ignore
6020	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
6021	///
6022	/// let mut from_state = ProjectState::new();
6023	/// let mut to_state = ProjectState::new();
6024	///
6025	/// // Add a new model to the target state
6026	/// let mut model = ModelState::new("myapp", "User");
6027	/// model.add_field(FieldState::new("id", FieldType::Integer, false));
6028	/// to_state.add_model(model);
6029	///
6030	/// let detector = MigrationAutodetector::new(from_state, to_state);
6031	/// let operations = detector.generate_operations();
6032	///
6033	/// assert!(!operations.is_empty());
6034	/// ```rust,ignore
6035	/// Sort operations by their dependencies to ensure correct execution order
6036	///
6037	/// This method reorders operations to prevent execution errors:
6038	/// 1. CreateTable operations first (tables must exist before modification)
6039	/// 2. AddColumn/AlterColumn operations next (field modifications)
6040	/// 3. Other operations last (indexes, constraints, etc.)
6041	fn sort_operations_by_dependency(
6042		&self,
6043		mut operations: Vec<super::Operation>,
6044	) -> Vec<super::Operation> {
6045		let mut sorted = Vec::new();
6046
6047		// Extract CreateTable operations (must be first)
6048		let create_tables: Vec<_> = operations
6049			.iter()
6050			.filter(|op| matches!(op, super::Operation::CreateTable { .. }))
6051			.cloned()
6052			.collect();
6053		operations.retain(|op| !matches!(op, super::Operation::CreateTable { .. }));
6054
6055		// Extract field operations (must be after CreateTable)
6056		let field_ops: Vec<_> = operations
6057			.iter()
6058			.filter(|op| {
6059				matches!(
6060					op,
6061					super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
6062				)
6063			})
6064			.cloned()
6065			.collect();
6066		operations.retain(|op| {
6067			!matches!(
6068				op,
6069				super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
6070			)
6071		});
6072
6073		// Assemble in correct order. CreateTable ops are topologically ordered
6074		// so inline foreign keys do not reference tables created later in the
6075		// same migration.
6076		sorted.extend(Self::topological_sort_create_tables(create_tables));
6077		sorted.extend(field_ops);
6078		sorted.extend(operations); // Remaining operations
6079
6080		sorted
6081	}
6082
6083	fn operation_targets_table(operation: &super::Operation, table_name: &str) -> bool {
6084		match operation {
6085			super::Operation::AddColumn { table, .. }
6086			| super::Operation::AlterColumn { table, .. }
6087			| super::Operation::RenameColumn { table, .. }
6088			| super::Operation::AddConstraint { table, .. }
6089			| super::Operation::DropConstraint { table, .. }
6090			| super::Operation::CreateIndex { table, .. }
6091			| super::Operation::CreateIndexRepair { table, .. }
6092			| super::Operation::DropIndex { table, .. }
6093			| super::Operation::DropNamedIndex { table, .. }
6094			| super::Operation::CreateCompositePrimaryKey { table, .. }
6095			| super::Operation::SetAutoIncrementValue { table, .. } => table == table_name,
6096			super::Operation::CreateTable { name, .. } | super::Operation::DropTable { name } => {
6097				name == table_name
6098			}
6099			super::Operation::RenameTable { old_name, new_name } => {
6100				old_name == table_name || new_name == table_name
6101			}
6102			_ => false,
6103		}
6104	}
6105
6106	fn constraint_references_table(constraint: &super::Constraint, table_name: &str) -> bool {
6107		match constraint {
6108			super::Constraint::ForeignKey {
6109				referenced_table, ..
6110			}
6111			| super::Constraint::OneToOne {
6112				referenced_table, ..
6113			} => referenced_table == table_name,
6114			super::Constraint::ManyToMany {
6115				target_table,
6116				through_table,
6117				..
6118			} => target_table == table_name || through_table == table_name,
6119			super::Constraint::PrimaryKey { .. }
6120			| super::Constraint::Unique { .. }
6121			| super::Constraint::Check { .. }
6122			| super::Constraint::Exclude { .. } => false,
6123		}
6124	}
6125
6126	fn reference_tail_starts_with_table(tail: &str, table_name: &str) -> bool {
6127		let tail = tail.trim_start();
6128		let Some(first_char) = tail.chars().next() else {
6129			return false;
6130		};
6131
6132		let (referenced_table, rest) = if first_char == '"' {
6133			let Some(end_quote) = tail[1..].find('"') else {
6134				return false;
6135			};
6136			(&tail[1..=end_quote], &tail[end_quote + 2..])
6137		} else {
6138			let end = tail
6139				.find(|ch: char| ch == '(' || ch.is_whitespace())
6140				.unwrap_or(tail.len());
6141			(&tail[..end], &tail[end..])
6142		};
6143
6144		referenced_table == table_name
6145			&& (rest.is_empty()
6146				|| rest.starts_with('(')
6147				|| rest.chars().next().is_some_and(char::is_whitespace))
6148	}
6149
6150	fn constraint_sql_references_table(constraint_sql: &str, table_name: &str) -> bool {
6151		let mut rest = constraint_sql;
6152		while let Some(index) = rest.find("REFERENCES ") {
6153			let tail = &rest[index + "REFERENCES ".len()..];
6154			if Self::reference_tail_starts_with_table(tail, table_name) {
6155				return true;
6156			}
6157			rest = tail;
6158		}
6159		false
6160	}
6161
6162	fn operation_references_table(operation: &super::Operation, table_name: &str) -> bool {
6163		match operation {
6164			super::Operation::CreateTable { constraints, .. } => constraints
6165				.iter()
6166				.any(|constraint| Self::constraint_references_table(constraint, table_name)),
6167			super::Operation::AddConstraint { constraint_sql, .. } => {
6168				Self::constraint_sql_references_table(constraint_sql, table_name)
6169			}
6170			_ => false,
6171		}
6172	}
6173
6174	fn operation_needs_table_after_rename(
6175		operation: &super::Operation,
6176		new_table_name: &str,
6177	) -> bool {
6178		Self::operation_targets_table(operation, new_table_name)
6179			|| Self::operation_references_table(operation, new_table_name)
6180	}
6181
6182	fn table_rename_names(operation: &super::Operation) -> Option<(String, String)> {
6183		match operation {
6184			super::Operation::RenameTable { old_name, new_name } => {
6185				Some((old_name.clone(), new_name.clone()))
6186			}
6187			super::Operation::MoveModel {
6188				rename_table: true,
6189				old_table_name: Some(old_name),
6190				new_table_name: Some(new_name),
6191				..
6192			} => Some((old_name.clone(), new_name.clone())),
6193			_ => None,
6194		}
6195	}
6196
6197	fn order_renamed_table_operations(operations: &mut Vec<super::Operation>) {
6198		let mut index = 0;
6199		while index < operations.len() {
6200			let (old_name, new_name) = match Self::table_rename_names(&operations[index]) {
6201				Some(names) => names,
6202				_ => {
6203					index += 1;
6204					continue;
6205				}
6206			};
6207
6208			let rename_operation = operations.remove(index);
6209			let mut before_rename = Vec::new();
6210			let mut after_rename = Vec::new();
6211
6212			for (candidate_index, operation) in std::mem::take(operations).into_iter().enumerate() {
6213				if Self::operation_targets_table(&operation, &old_name) {
6214					before_rename.push(operation);
6215				} else if Self::operation_needs_table_after_rename(&operation, &new_name) {
6216					after_rename.push(operation);
6217				} else if candidate_index < index {
6218					before_rename.push(operation);
6219				} else {
6220					after_rename.push(operation);
6221				}
6222			}
6223
6224			let next_index = before_rename.len() + 1;
6225			before_rename.push(rename_operation);
6226			before_rename.append(&mut after_rename);
6227			*operations = before_rename;
6228			index = next_index;
6229		}
6230	}
6231
6232	fn operation_index_references_column(
6233		operation: &super::Operation,
6234		table_name: &str,
6235		column: &str,
6236	) -> bool {
6237		match operation {
6238			super::Operation::CreateIndex {
6239				table,
6240				columns,
6241				expressions,
6242				where_clause,
6243				..
6244			}
6245			| super::Operation::CreateIndexRepair {
6246				table,
6247				columns,
6248				expressions,
6249				where_clause,
6250				..
6251			}
6252			| super::Operation::DropNamedIndex {
6253				table,
6254				columns,
6255				expressions,
6256				where_clause,
6257				..
6258			} => {
6259				table == table_name
6260					&& (columns.iter().any(|field| field == column)
6261						|| expressions.as_deref().is_some_and(|expressions| {
6262							expressions.iter().any(|expression| {
6263								ProjectState::expression_references_column(expression, column)
6264							})
6265						}) || where_clause.as_deref().is_some_and(|where_clause| {
6266						ProjectState::expression_references_column(where_clause, column)
6267					}))
6268			}
6269			super::Operation::DropIndex { table, columns } => {
6270				table == table_name && columns.iter().any(|field| field == column)
6271			}
6272			_ => false,
6273		}
6274	}
6275
6276	fn order_renamed_column_operations(operations: &mut Vec<super::Operation>) {
6277		let mut index = 0;
6278		while index < operations.len() {
6279			let (table, old_name, new_name) = match &operations[index] {
6280				super::Operation::RenameColumn {
6281					table,
6282					old_name,
6283					new_name,
6284				} => (table.clone(), old_name.clone(), new_name.clone()),
6285				_ => {
6286					index += 1;
6287					continue;
6288				}
6289			};
6290
6291			let mut remaining = std::mem::take(operations);
6292			let rename_operation = remaining.remove(index);
6293			let prefix = remaining.drain(..index).collect::<Vec<_>>();
6294			let mut before_rename = Vec::new();
6295			let mut after_rename = Vec::new();
6296			let mut prefix_without_recreated_indexes = Vec::new();
6297			for operation in prefix {
6298				match &operation {
6299					super::Operation::CreateIndex { .. }
6300					| super::Operation::CreateIndexRepair { .. }
6301						if Self::operation_index_references_column(
6302							&operation, &table, &new_name,
6303						) =>
6304					{
6305						after_rename.push(operation);
6306					}
6307					_ => prefix_without_recreated_indexes.push(operation),
6308				}
6309			}
6310			for operation in remaining {
6311				match &operation {
6312					super::Operation::DropIndex { .. }
6313					| super::Operation::DropNamedIndex { .. }
6314						if Self::operation_index_references_column(
6315							&operation, &table, &old_name,
6316						) =>
6317					{
6318						before_rename.push(operation);
6319					}
6320					super::Operation::CreateIndex { .. }
6321					| super::Operation::CreateIndexRepair { .. }
6322						if Self::operation_index_references_column(
6323							&operation, &table, &new_name,
6324						) =>
6325					{
6326						after_rename.push(operation);
6327					}
6328					_ => after_rename.push(operation),
6329				}
6330			}
6331
6332			let rename_position = prefix_without_recreated_indexes.len() + before_rename.len();
6333			let mut reordered = prefix_without_recreated_indexes;
6334			reordered.extend(before_rename);
6335			reordered.push(rename_operation);
6336			reordered.extend(after_rename);
6337			*operations = reordered;
6338			index = rename_position + 1;
6339		}
6340	}
6341
6342	/// Performs the generate operations operation.
6343	pub fn generate_operations(&self) -> Vec<super::Operation> {
6344		let changes = self.detect_changes();
6345		self.generate_operations_from_changes(&changes)
6346	}
6347
6348	/// Performs operation generation and fails on ambiguous rename-like changes.
6349	pub fn try_generate_operations(&self) -> super::Result<Vec<super::Operation>> {
6350		let changes = self.try_detect_changes()?;
6351		Ok(self.generate_operations_from_changes(&changes))
6352	}
6353
6354	fn generate_operations_from_changes(&self, changes: &DetectedChanges) -> Vec<super::Operation> {
6355		let mut by_app: BTreeMap<String, Vec<super::Operation>> = BTreeMap::new();
6356
6357		// Shared per-app emissions (CreateTable, column ops, constraint ops,
6358		// auto-increment resets). This is the single source of truth shared
6359		// with `generate_migrations()` so the two paths cannot diverge again
6360		// (issue #4040).
6361		self.emit_shared_per_app_operations(changes, &mut by_app);
6362
6363		// `generate_operations()`-specific extra: walk ManyToMany fields on
6364		// new and added models and emit intermediate `CreateTable`s via
6365		// `generate_intermediate_table`. This complements the shared
6366		// emissions above. (`generate_migrations()` covers the same
6367		// ground via `created_many_to_many` with PK-type-resolved logic.)
6368		for (app_label, model_name) in &changes.created_models {
6369			if let Some(model) = self.to_state.get_model(app_label, model_name) {
6370				for (field_name, field_state) in &model.fields {
6371					if let super::FieldType::ManyToMany { to, through } = &field_state.field_type
6372						&& let Some(operation) = self.generate_intermediate_table(
6373							app_label, model_name, field_name, to, through,
6374						) {
6375						by_app.entry(app_label.clone()).or_default().push(operation);
6376					}
6377				}
6378			}
6379		}
6380		for (app_label, model_name, field_name) in &changes.added_fields {
6381			if let Some(model) = self.to_state.get_model(app_label, model_name)
6382				&& let Some(field) = model.get_field(field_name)
6383				&& let super::FieldType::ManyToMany { to, through } = &field.field_type
6384				&& let Some(operation) =
6385					self.generate_intermediate_table(app_label, model_name, field_name, to, through)
6386			{
6387				by_app.entry(app_label.clone()).or_default().push(operation);
6388			}
6389		}
6390
6391		// Note: MoveModel and RenameTable operations are intentionally only
6392		// emitted by `generate_migrations()` (not here). Direct callers of
6393		// `generate_operations()` historically did not see them; preserve
6394		// that contract to avoid behavioral surprises.
6395
6396		// Second-line defence against redundant single-column `AddConstraint
6397		// UNIQUE` operations. The primary fix lives in
6398		// `detect_added_constraints` (shape-match), but this pass also catches
6399		// cases where the column is being added in the same migration with
6400		// `column.unique = true` *and* a peer `AddConstraint` is emitted for
6401		// it. See reinhardt-web#4448.
6402		Self::dedup_redundant_unique_add_constraints(&mut by_app);
6403		for operations in by_app.values_mut() {
6404			Self::order_renamed_column_operations(operations);
6405		}
6406
6407		// Flatten and sort by dependency to ensure correct execution order.
6408		let operations: Vec<super::Operation> = by_app.into_values().flatten().collect();
6409		self.sort_operations_by_dependency(operations)
6410	}
6411
6412	/// Emit per-app operations shared by `generate_operations()` and
6413	/// `generate_migrations()`.
6414	///
6415	/// This is the single source of truth for emissions that previously had
6416	/// to be duplicated between the two methods. Issue #4040 was caused by
6417	/// PR #3998 updating only `generate_operations()` while
6418	/// `generate_migrations()` (the CLI entry point) was left silently
6419	/// divergent. Centralizing the shared emissions here makes that class of
6420	/// drift impossible.
6421	///
6422	/// Method-specific extras (M2M field-walking for `generate_operations()`;
6423	/// `created_many_to_many` / `renamed_models` / `moved_models` for
6424	/// `generate_migrations()`) are added by the callers after this helper
6425	/// returns.
6426	fn emit_shared_per_app_operations(
6427		&self,
6428		changes: &DetectedChanges,
6429		by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
6430	) {
6431		// CreateTable for new models.
6432		for (app_label, model_name) in &changes.created_models {
6433			if let Some(model) = self.to_state.get_model(app_label, model_name) {
6434				let mut columns = Vec::new();
6435				for (field_name, field_state) in &model.fields {
6436					columns.push(super::ColumnDefinition::from_field_state(
6437						field_name.clone(),
6438						field_state,
6439					));
6440				}
6441
6442				let constraints: Vec<super::operations::Constraint> = model
6443					.constraints
6444					.iter()
6445					.map(|c| c.to_constraint())
6446					.collect();
6447
6448				by_app
6449					.entry(app_label.clone())
6450					.or_default()
6451					.push(super::Operation::CreateTable {
6452						name: model.table_name.clone(),
6453						columns,
6454						constraints,
6455						without_rowid: None,
6456						interleave_in_parent: None,
6457						partition: None,
6458					});
6459
6460				for index in &model.indexes {
6461					by_app
6462						.entry(app_label.clone())
6463						.or_default()
6464						.push(index.create_operation(&model.table_name));
6465				}
6466			}
6467		}
6468
6469		// AddColumn for new fields.
6470		//
6471		// Use `model.table_name` (not `model.name`) so the executor's
6472		// `find_model_by_table_mut(table)` path resolves correctly. The
6473		// previous `generate_operations()` body used `model.name` here, which
6474		// was a latent bug that did not surface because that path was rarely
6475		// exercised against table-name-keyed state.
6476		for (app_label, model_name, field_name) in &changes.added_fields {
6477			if let Some(model) = self.to_state.get_model(app_label, model_name)
6478				&& let Some(field) = model.get_field(field_name)
6479			{
6480				by_app
6481					.entry(app_label.clone())
6482					.or_default()
6483					.push(super::Operation::AddColumn {
6484						table: model.table_name.clone(),
6485						column: super::ColumnDefinition::from_field_state(
6486							field_name.clone(),
6487							field,
6488						),
6489						mysql_options: None,
6490					});
6491			}
6492		}
6493
6494		// RenameColumn for confirmed field renames.
6495		for (app_label, model_name, old_name, new_name) in &changes.renamed_fields {
6496			if let Some(model) = self.to_state.get_model(app_label, model_name) {
6497				by_app
6498					.entry(app_label.clone())
6499					.or_default()
6500					.push(super::Operation::RenameColumn {
6501						table: model.table_name.clone(),
6502						old_name: old_name.clone(),
6503						new_name: new_name.clone(),
6504					});
6505			}
6506		}
6507
6508		// AlterColumn for changed fields.
6509		for (app_label, model_name, field_name) in &changes.altered_fields {
6510			if let Some(model) = self.to_state.get_model(app_label, model_name)
6511				&& let Some(field) = model.get_field(field_name)
6512			{
6513				let old_definition = self
6514					.from_state
6515					.get_model(app_label, model_name)
6516					.and_then(|from_model| from_model.get_field(field_name))
6517					.map(|from_field| {
6518						super::ColumnDefinition::from_field_state(field_name.clone(), from_field)
6519					});
6520				by_app
6521					.entry(app_label.clone())
6522					.or_default()
6523					.push(super::Operation::AlterColumn {
6524						table: model.table_name.clone(),
6525						old_definition,
6526						column: field_name.clone(),
6527						new_definition: super::ColumnDefinition::from_field_state(
6528							field_name.clone(),
6529							field,
6530						),
6531						mysql_options: None,
6532					});
6533			}
6534		}
6535
6536		// DropConstraint for non-PK constraints removed from existing tables.
6537		//
6538		// Emit these before DropColumn: databases remove a column's dependent
6539		// UNIQUE constraint together with the column, so dropping the constraint
6540		// afterward would target an object that no longer exists.
6541		for (app_label, model_name, constraint_name) in &changes.removed_constraints {
6542			let Some(from_model) = self.from_state.get_model(app_label, model_name) else {
6543				continue;
6544			};
6545			let is_composite_pk = from_model
6546				.constraints
6547				.iter()
6548				.find(|c| &c.name == constraint_name)
6549				.is_some_and(|c| c.constraint_type == "primary_key" && c.fields.len() >= 2);
6550			if is_composite_pk {
6551				continue;
6552			}
6553			by_app
6554				.entry(app_label.clone())
6555				.or_default()
6556				.push(super::Operation::DropConstraint {
6557					table: from_model.table_name.clone(),
6558					constraint_name: constraint_name.clone(),
6559				});
6560		}
6561
6562		// DropNamedIndex before recreating indexes or dropping columns so the generated
6563		// SQL remains valid when an index changes only its uniqueness.
6564		for (app_label, model_name, index_name) in &changes.removed_indexes {
6565			let Some(model) = self.from_state.get_model(app_label, model_name) else {
6566				continue;
6567			};
6568			let Some(index) = model.indexes.iter().find(|index| &index.name == index_name) else {
6569				continue;
6570			};
6571			by_app
6572				.entry(app_label.clone())
6573				.or_default()
6574				.push(index.drop_operation(&model.table_name));
6575		}
6576
6577		// CreateIndex for indexes added to existing models.
6578		for (app_label, model_name, index) in &changes.added_indexes {
6579			if let Some(model) = self.to_state.get_model(app_label, model_name) {
6580				by_app
6581					.entry(app_label.clone())
6582					.or_default()
6583					.push(index.create_operation(&model.table_name));
6584			}
6585		}
6586
6587		// DropColumn for removed fields.
6588		for (app_label, model_name, field_name) in &changes.removed_fields {
6589			if let Some(model) = self.from_state.get_model(app_label, model_name) {
6590				by_app
6591					.entry(app_label.clone())
6592					.or_default()
6593					.push(super::Operation::DropColumn {
6594						table: model.table_name.clone(),
6595						column: field_name.clone(),
6596					});
6597			}
6598		}
6599
6600		// DropTable for deleted models.
6601		for (app_label, model_name) in &changes.deleted_models {
6602			if let Some(model) = self.from_state.get_model(app_label, model_name) {
6603				by_app
6604					.entry(app_label.clone())
6605					.or_default()
6606					.push(super::Operation::DropTable {
6607						name: model.table_name.clone(),
6608					});
6609			}
6610		}
6611
6612		// DropConstraint for modified composite PKs (drop before recreate).
6613		for (app_label, model_name, constraint_name) in &changes.removed_composite_primary_keys {
6614			if let Some(model) = self.from_state.get_model(app_label, model_name) {
6615				by_app.entry(app_label.clone()).or_default().push(
6616					super::Operation::DropConstraint {
6617						table: model.table_name.clone(),
6618						constraint_name: constraint_name.clone(),
6619					},
6620				);
6621			}
6622		}
6623
6624		// CreateCompositePrimaryKey for composite PK additions.
6625		for (app_label, model_name, constraint) in &changes.added_composite_primary_keys {
6626			if let Some(model) = self.to_state.get_model(app_label, model_name) {
6627				by_app.entry(app_label.clone()).or_default().push(
6628					super::Operation::CreateCompositePrimaryKey {
6629						table: model.table_name.clone(),
6630						columns: constraint.fields.clone(),
6631						constraint_name: Some(constraint.name.clone()),
6632					},
6633				);
6634			}
6635		}
6636
6637		// AddConstraint for non-PK constraints added to existing tables.
6638		//
6639		// Covers `unique_together`, `Check`, `ForeignKey`, and `OneToOne`
6640		// constraints declared on a model that already exists in
6641		// `from_state`. Composite primary keys are emitted via
6642		// `added_composite_primary_keys` using `CreateCompositePrimaryKey`
6643		// and must be skipped here to avoid duplicate emission. The
6644		// constraint SQL is rendered through the existing
6645		// `ConstraintDefinition::to_constraint()` -> `Constraint: Display`
6646		// path, which mirrors the SQL produced for the same constraint when
6647		// emitted as part of a `CreateTable` operation, so the on-disk
6648		// schema for a "create + add later" sequence stays equivalent to a
6649		// "create with constraint" sequence.
6650		for (app_label, model_name, constraint) in &changes.added_constraints {
6651			if constraint.constraint_type == "primary_key" && constraint.fields.len() >= 2 {
6652				continue;
6653			}
6654			let Some(to_model) = self.to_state.get_model(app_label, model_name) else {
6655				continue;
6656			};
6657			let constraint_sql = constraint.to_constraint().to_string();
6658			by_app
6659				.entry(app_label.clone())
6660				.or_default()
6661				.push(super::Operation::AddConstraint {
6662					table: to_model.table_name.clone(),
6663					constraint_sql,
6664				});
6665		}
6666
6667		// SetAutoIncrementValue for detected sequence resets.
6668		for (app_label, model_name, column, value) in &changes.auto_increment_resets {
6669			if let Some(model) = self.to_state.get_model(app_label, model_name) {
6670				by_app.entry(app_label.clone()).or_default().push(
6671					super::Operation::SetAutoIncrementValue {
6672						table: model.table_name.clone(),
6673						column: column.clone(),
6674						value: *value,
6675					},
6676				);
6677			}
6678		}
6679	}
6680
6681	/// Generate migrations from detected changes
6682	///
6683	/// Groups operations by app_label and creates Migration objects for each app.
6684	/// This is the final step in the migration autodetection process.
6685	///
6686	/// # Django Reference
6687	/// From: django/db/migrations/autodetector.py:95-141
6688	/// ```python
6689	/// def changes(self, graph, trim_to_apps=None, convert_apps=None, migration_name=None):
6690	///     # Generate operations
6691	///     self._generate_through_model_map()
6692	///     self.generate_renamed_models()
6693	///     # ... all other generate_* methods
6694	///
6695	///     # Group operations by app
6696	///     self.arrange_for_graph(changes, graph, trim_to_apps)
6697	///
6698	///     # Create Migration objects
6699	///     return changes
6700	/// ```rust,ignore
6701	///
6702	/// # Examples
6703	///
6704	/// ```rust,ignore
6705	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
6706	///
6707	/// let mut from_state = ProjectState::new();
6708	/// let mut to_state = ProjectState::new();
6709	///
6710	/// // Add a new model
6711	/// let mut model = ModelState::new("blog", "Post");
6712	/// model.add_field(FieldState::new("title", FieldType::VarChar(255), false));
6713	/// to_state.add_model(model);
6714	///
6715	/// let detector = MigrationAutodetector::new(from_state, to_state);
6716	/// let migrations = detector.generate_migrations();
6717	///
6718	/// assert_eq!(migrations.len(), 1);
6719	/// assert_eq!(migrations[0].app_label, "blog");
6720	/// assert!(!migrations[0].operations.is_empty());
6721	/// ```
6722	pub fn generate_migrations(&self) -> Vec<super::Migration> {
6723		let changes = self.detect_changes();
6724		self.generate_migrations_from_changes(&changes)
6725	}
6726
6727	/// Generate migrations and fail on ambiguous rename-like changes.
6728	pub fn try_generate_migrations(&self) -> super::Result<Vec<super::Migration>> {
6729		let changes = self.try_detect_changes()?;
6730		Ok(self.generate_migrations_from_changes(&changes))
6731	}
6732
6733	fn generate_migrations_from_changes(&self, changes: &DetectedChanges) -> Vec<super::Migration> {
6734		let mut migrations_by_app: BTreeMap<String, Vec<super::Operation>> = BTreeMap::new();
6735
6736		// Shared per-app emissions (CreateTable, column ops, constraint ops,
6737		// auto-increment resets). Single source of truth shared with
6738		// `generate_operations()` — see `emit_shared_per_app_operations` and
6739		// issue #4040.
6740		self.emit_shared_per_app_operations(changes, &mut migrations_by_app);
6741
6742		// Generate intermediate tables for ManyToMany relationships
6743		for (app_label, model_name, through_table, m2m) in &changes.created_many_to_many {
6744			// Resolve source table name from the to_state model. The
6745			// `table_name` (user-set via `#[model(table_name = "...")]` or
6746			// derived by the macro) is the canonical identifier — never
6747			// the `struct` identifier (see #4659).
6748			let source_table = self
6749				.to_state
6750				.get_model(app_label, model_name)
6751				.map(|m| m.table_name.clone())
6752				.unwrap_or_else(|| format!("{}_{}", app_label, model_name.to_lowercase()));
6753
6754			// Parse the target reference up-front so qualified names like
6755			// "app.Model" resolve correctly throughout the rest of this
6756			// block (table lookup, PK type lookup, and the lowercase
6757			// fallback). Without this, lookups would use the literal
6758			// "app.Model" string as the model name, miss every
6759			// to_state/registry entry, and produce defaults like
6760			// "app.model_id".
6761			let (parsed_target_app, parsed_target_model) =
6762				self.resolve_model_reference(&m2m.to_model, app_label);
6763
6764			// Resolve target table name: prefer to_state, then global registry,
6765			// finally fall back to the canonical `{app}_{model_lower}` form
6766			// (mirroring the source-table fallback above). The fallback must
6767			// include the parsed app label — emitting only the lowercased
6768			// model name would lose the app prefix that `#[model]` writes
6769			// into the real `table_name`, so FK constraints would point at a
6770			// table that does not exist.
6771			let target_table = self
6772				.to_state
6773				.get_model(&parsed_target_app, &parsed_target_model)
6774				.map(|model| model.table_name.clone())
6775				.or_else(|| {
6776					super::model_registry::global_registry()
6777						.get_models()
6778						.iter()
6779						.find(|m| {
6780							m.app_label == parsed_target_app && m.model_name == parsed_target_model
6781						})
6782						.map(|m| m.table_name.clone())
6783				})
6784				.unwrap_or_else(|| {
6785					format!(
6786						"{}_{}",
6787						parsed_target_app,
6788						parsed_target_model.to_lowercase()
6789					)
6790				});
6791
6792			// Default FK column names come from `crate::m2m_naming::default_m2m_columns`,
6793			// the single source of truth shared with `create_intermediate_table_for_m2m`
6794			// and the ORM accessor (issue #4665). The helper keys off the
6795			// *actual* table names (not struct identifiers) and applies
6796			// `from_/to_` prefixes only for self-referential M2M (#4659).
6797			let (default_source_col, default_target_col) =
6798				crate::m2m_naming::default_m2m_columns(&source_table, &target_table);
6799			let source_column = m2m.source_field.clone().unwrap_or(default_source_col);
6800			let target_column = m2m.target_field.clone().unwrap_or(default_target_col);
6801
6802			// Get source model's primary key type
6803			let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
6804
6805			// Get target model's primary key type. Reuse the values parsed
6806			// from `m2m.to_model` at the top of this block so the lookup
6807			// agrees with how `target_table` was resolved (#4659 follow-up).
6808			let target_pk_type = self
6809				.to_state
6810				.get_primary_key_type(&parsed_target_app, &parsed_target_model);
6811
6812			// Create intermediate table columns
6813			let columns = vec![
6814				super::ColumnDefinition {
6815					name: "id".to_string(),
6816					type_definition: super::FieldType::Integer,
6817					not_null: true,
6818					unique: false,
6819					primary_key: true,
6820					auto_increment: true,
6821					default: None,
6822				},
6823				super::ColumnDefinition {
6824					name: source_column.clone(),
6825					type_definition: source_pk_type.clone(),
6826					not_null: true,
6827					unique: false,
6828					primary_key: false,
6829					auto_increment: false,
6830					default: None,
6831				},
6832				super::ColumnDefinition {
6833					name: target_column.clone(),
6834					type_definition: target_pk_type,
6835					not_null: true,
6836					unique: false,
6837					primary_key: false,
6838					auto_increment: false,
6839					default: None,
6840				},
6841			];
6842
6843			// Create FK constraints for the intermediate table
6844			let constraints = vec![
6845				super::operations::Constraint::ForeignKey {
6846					name: format!("fk_{}_{}", through_table, source_column),
6847					columns: vec![source_column.clone()],
6848					referenced_table: source_table.clone(),
6849					referenced_columns: vec!["id".to_string()],
6850					on_delete: ForeignKeyAction::Cascade,
6851					on_update: ForeignKeyAction::Cascade,
6852					deferrable: None,
6853				},
6854				super::operations::Constraint::ForeignKey {
6855					name: format!("fk_{}_{}", through_table, target_column),
6856					columns: vec![target_column.clone()],
6857					referenced_table: target_table,
6858					referenced_columns: vec!["id".to_string()],
6859					on_delete: ForeignKeyAction::Cascade,
6860					on_update: ForeignKeyAction::Cascade,
6861					deferrable: None,
6862				},
6863				// Add unique constraint on the combination of both FK columns
6864				super::operations::Constraint::Unique {
6865					name: format!("{}_unique", through_table),
6866					columns: vec![source_column, target_column],
6867				},
6868			];
6869
6870			migrations_by_app
6871				.entry(app_label.clone())
6872				.or_default()
6873				.push(super::Operation::CreateTable {
6874					name: through_table.clone(),
6875					columns,
6876					constraints,
6877					without_rowid: None,
6878					interleave_in_parent: None,
6879					partition: None,
6880				});
6881		}
6882
6883		// Handle model renames (same app)
6884		for (app_label, old_name, new_name) in &changes.renamed_models {
6885			if let Some(model) = self.to_state.get_model(app_label, new_name) {
6886				// Get the old table name from from_state
6887				let Some(old_model) = self.from_state.get_model(app_label, old_name) else {
6888					continue;
6889				};
6890				let old_table_name = old_model.table_name.clone();
6891
6892				// Defense-in-depth: skip no-op renames where table name is unchanged
6893				if old_table_name != model.table_name {
6894					let renamed_constraints =
6895						Self::renamed_single_field_unique_constraints(old_model, model);
6896					let renamed_indexes = old_model
6897						.indexes
6898						.iter()
6899						.filter_map(|old_index| {
6900							model
6901								.indexes
6902								.iter()
6903								.find(|new_index| {
6904									model_index_definitions_equivalent(
6905										old_model, old_index, model, new_index,
6906									)
6907								})
6908								.filter(|new_index| old_index.name != new_index.name)
6909								.map(|new_index| (old_index.clone(), new_index.clone()))
6910						})
6911						.collect::<Vec<_>>();
6912					let operations = migrations_by_app.entry(app_label.clone()).or_default();
6913					for (old_constraint, _) in &renamed_constraints {
6914						operations.push(super::Operation::DropConstraint {
6915							table: old_table_name.clone(),
6916							constraint_name: old_constraint.name.clone(),
6917						});
6918					}
6919					for (old_index, _) in &renamed_indexes {
6920						operations.push(old_index.drop_operation(&old_table_name));
6921					}
6922					operations.push(super::Operation::RenameTable {
6923						old_name: old_table_name,
6924						new_name: model.table_name.clone(),
6925					});
6926					for (_, new_constraint) in renamed_constraints {
6927						operations.push(super::Operation::AddConstraint {
6928							table: model.table_name.clone(),
6929							constraint_sql: new_constraint.to_constraint().to_string(),
6930						});
6931					}
6932					for (_, new_index) in renamed_indexes {
6933						operations.push(new_index.create_operation(&model.table_name));
6934					}
6935				}
6936			}
6937		}
6938
6939		// Handle cross-app model moves
6940		// MovedModelInfo: (from_app, from_model, to_app, to_model, rename_table, old_table, new_table)
6941		for (
6942			from_app,
6943			from_model_name,
6944			to_app,
6945			to_model_name,
6946			rename_table,
6947			old_table,
6948			new_table,
6949		) in &changes.moved_models
6950		{
6951			// Get table names
6952			let old_table_name = old_table.clone().unwrap_or_else(|| {
6953				self.from_state
6954					.get_model(from_app, from_model_name)
6955					.map(|m| m.table_name.clone())
6956					.unwrap_or_else(|| format!("{}_{}", from_app, from_model_name.to_lowercase()))
6957			});
6958
6959			let new_table_name = new_table.clone().unwrap_or_else(|| {
6960				self.to_state
6961					.get_model(to_app, to_model_name)
6962					.map(|m| m.table_name.clone())
6963					.unwrap_or_else(|| format!("{}_{}", to_app, to_model_name.to_lowercase()))
6964			});
6965
6966			let (renamed_constraints, renamed_indexes) = if *rename_table {
6967				match (
6968					self.from_state.get_model(from_app, from_model_name),
6969					self.to_state.get_model(to_app, to_model_name),
6970				) {
6971					(Some(old_model), Some(new_model)) => (
6972						Self::renamed_single_field_unique_constraints(old_model, new_model),
6973						old_model
6974							.indexes
6975							.iter()
6976							.filter_map(|old_index| {
6977								new_model
6978									.indexes
6979									.iter()
6980									.find(|new_index| {
6981										model_index_definitions_equivalent(
6982											old_model, old_index, new_model, new_index,
6983										)
6984									})
6985									.filter(|new_index| old_index.name != new_index.name)
6986									.map(|new_index| (old_index.clone(), new_index.clone()))
6987							})
6988							.collect::<Vec<_>>(),
6989					),
6990					_ => (Vec::new(), Vec::new()),
6991				}
6992			} else {
6993				(Vec::new(), Vec::new())
6994			};
6995			let operations = migrations_by_app.entry(to_app.clone()).or_default();
6996			for (old_constraint, _) in &renamed_constraints {
6997				operations.push(super::Operation::DropConstraint {
6998					table: old_table_name.clone(),
6999					constraint_name: old_constraint.name.clone(),
7000				});
7001			}
7002			for (old_index, _) in &renamed_indexes {
7003				operations.push(old_index.drop_operation(&old_table_name));
7004			}
7005			// Add MoveModel operation to the target app's migrations
7006			operations.push(super::Operation::MoveModel {
7007				model_name: from_model_name.clone(),
7008				from_app: from_app.clone(),
7009				to_app: to_app.clone(),
7010				rename_table: *rename_table,
7011				old_table_name: if *rename_table {
7012					Some(old_table_name)
7013				} else {
7014					None
7015				},
7016				new_table_name: if *rename_table {
7017					Some(new_table_name.clone())
7018				} else {
7019					None
7020				},
7021			});
7022			for (_, new_constraint) in renamed_constraints {
7023				operations.push(super::Operation::AddConstraint {
7024					table: new_table_name.clone(),
7025					constraint_sql: new_constraint.to_constraint().to_string(),
7026				});
7027			}
7028			for (_, new_index) in renamed_indexes {
7029				operations.push(new_index.create_operation(&new_table_name));
7030			}
7031		}
7032
7033		// Second-line defence against redundant single-column `AddConstraint
7034		// UNIQUE` operations. The primary fix lives in
7035		// `detect_added_constraints` (shape-match); this pass also catches
7036		// cases where the column is being added in the same migration with
7037		// `column.unique = true` *and* a peer `AddConstraint` is emitted for
7038		// it. See reinhardt-web#4448.
7039		Self::dedup_redundant_unique_add_constraints(&mut migrations_by_app);
7040		for operations in migrations_by_app.values_mut() {
7041			Self::order_create_tables_by_foreign_keys(operations);
7042			Self::order_renamed_table_operations(operations);
7043			Self::order_renamed_column_operations(operations);
7044		}
7045
7046		// Create Migration objects for each app
7047		let mut migrations = Vec::new();
7048		for (app_label, operations) in migrations_by_app {
7049			// Placeholder name; the final migration name is generated by
7050			// MakeMigrationsCommand using MigrationNamer::generate_name().
7051			let migration_name = "autodetected".to_string();
7052
7053			let mut migration = super::Migration::new(&migration_name, &app_label);
7054			for operation in operations {
7055				migration = migration.add_operation(operation);
7056			}
7057			migrations.push(migration);
7058		}
7059
7060		migrations
7061	}
7062
7063	/// Detect newly created ManyToMany relationships
7064	///
7065	/// This method compares ManyToMany fields between from_state and to_state
7066	/// to detect new relationships that require intermediate table creation.
7067	///
7068	/// # Detection Logic
7069	/// 1. Iterate through all models in to_state
7070	/// 2. For each ManyToMany field, check if it exists in from_state
7071	/// 3. If not, mark it as a newly created ManyToMany relationship
7072	///
7073	/// # Intermediate Table Naming
7074	/// Uses Django naming convention: `{app}_{model}_{field}`
7075	/// Custom through table names are supported via `through` option.
7076	///
7077	/// # Examples
7078	///
7079	/// ```rust,ignore
7080	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, ManyToManyMetadata};
7081	///
7082	/// let from_state = ProjectState::new();
7083	/// let mut to_state = ProjectState::new();
7084	///
7085	/// // Create User model with ManyToMany to Group
7086	/// let mut user = ModelState::new("auth", "User");
7087	/// user.many_to_many_fields.push(ManyToManyMetadata {
7088	///     field_name: "groups".to_string(),
7089	///     to_model: "Group".to_string(),
7090	///     related_name: Some("users".to_string()),
7091	///     through: None,
7092	///     source_field: None,
7093	///     target_field: None,
7094	///     db_constraint_prefix: None,
7095	/// });
7096	/// to_state.add_model(user);
7097	///
7098	/// let detector = MigrationAutodetector::new(from_state, to_state);
7099	/// let changes = detector.detect_changes();
7100	///
7101	/// // Should detect created ManyToMany relationship
7102	/// assert_eq!(changes.created_many_to_many.len(), 1);
7103	/// assert_eq!(changes.created_many_to_many[0].2, "auth_user_groups");
7104	/// ```
7105	fn detect_created_many_to_many(&self, changes: &mut DetectedChanges) {
7106		for ((app_label, model_name), model_state) in &self.to_state.models {
7107			for m2m in &model_state.many_to_many_fields {
7108				// Generate the canonical through-table name from the source
7109				// model's actual `table_name`. Using `table_name` (not the
7110				// struct identifier) is required for two reasons:
7111				//   1. The ORM accessor's fallback derives the through-table
7112				//      and the source column from `S::table_name()` (see
7113				//      `crates/reinhardt-db/src/orm/many_to_many_accessor.rs`).
7114				//      The autodetector must agree on the same naming so that
7115				//      generated migrations match runtime expectations.
7116				//   2. `from_state` reconstructed from on-disk migrations only
7117				//      preserves `table_name`s (the original struct identifiers
7118				//      are lost — see #4659), so any subsequent existence check
7119				//      must use a `table_name`-derived key.
7120				// Route through `crate::m2m_naming::default_through_table`
7121				// so this existence-check key cannot drift from the
7122				// migration-emitting site (`create_intermediate_table_for_m2m`)
7123				// or the ORM accessor's fallback (#4659, #4665).
7124				let through_table = m2m.through.clone().unwrap_or_else(|| {
7125					crate::m2m_naming::default_through_table(
7126						&model_state.table_name,
7127						&m2m.field_name,
7128					)
7129				});
7130
7131				// Check whether the through-table already exists in
7132				// `from_state`. The previous implementation looked at
7133				// `from_state.get_model(app_label, model_name).many_to_many_fields`,
7134				// but the M2M metadata is not stored in on-disk
7135				// `Operation::CreateTable`, so reconstructed `from_state`
7136				// always reported `many_to_many_fields.is_empty()` and
7137				// `exists_in_from` was always `false`. As a result, the
7138				// intermediate `CreateTable` was re-emitted on every
7139				// incremental `makemigrations` run (#4659).
7140				let exists_in_from = self
7141					.from_state
7142					.find_model_by_table(&through_table)
7143					.is_some();
7144				let exists_in_to = self.to_state.find_model_by_table(&through_table).is_some();
7145
7146				if !exists_in_from && !exists_in_to {
7147					// Add to created_many_to_many
7148					changes.created_many_to_many.push((
7149						app_label.clone(),
7150						model_name.clone(),
7151						through_table.clone(),
7152						m2m.clone(),
7153					));
7154
7155					// Add model dependencies
7156					// The intermediate table depends on both source and target models
7157					let target_app = self
7158						.find_model_app(&m2m.to_model)
7159						.unwrap_or_else(|| app_label.clone());
7160
7161					changes
7162						.model_dependencies
7163						.entry((app_label.clone(), through_table))
7164						.or_default()
7165						.extend(vec![
7166							(app_label.clone(), model_name.clone()),
7167							(target_app, m2m.to_model.clone()),
7168						]);
7169				}
7170			}
7171		}
7172	}
7173
7174	/// Find the app_label for a given model name
7175	///
7176	/// Searches through to_state models to find the app that contains the model.
7177	/// If not found in to_state, falls back to the global registry for cross-app references.
7178	fn find_model_app(&self, model_name: &str) -> Option<String> {
7179		// First, search in to_state
7180		for (app_label, name) in self.to_state.models.keys() {
7181			if name == model_name {
7182				return Some(app_label.clone());
7183			}
7184		}
7185
7186		// If not found, search in global registry for cross-app references
7187		// This is needed when generating migrations for one app that references models in another app
7188		for model_meta in super::model_registry::global_registry().get_models() {
7189			if model_meta.model_name == model_name {
7190				return Some(model_meta.app_label.clone());
7191			}
7192		}
7193
7194		None
7195	}
7196
7197	/// Detect model dependencies for proper migration ordering
7198	///
7199	/// This method analyzes ForeignKey relationships between models to ensure
7200	/// migrations are generated in the correct order. A model that references
7201	/// another model via ForeignKey depends on that model being created first.
7202	///
7203	/// # Django Reference
7204	/// Django's dependency detection is in `django/db/migrations/autodetector.py:1400`
7205	/// Function: `_generate_through_model_map` and dependency tracking
7206	///
7207	/// # Examples
7208	///
7209	/// ```rust,ignore
7210	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
7211	///
7212	/// let mut from_state = ProjectState::new();
7213	/// let mut to_state = ProjectState::new();
7214	///
7215	/// // Create User model
7216	/// let mut user = ModelState::new("accounts", "User");
7217	/// user.add_field(FieldState::new("id", FieldType::Integer, false));
7218	/// to_state.add_model(user);
7219	///
7220	/// // Create Post model that references User
7221	/// let mut post = ModelState::new("blog", "Post");
7222	/// post.add_field(FieldState::new("id", FieldType::Integer, false));
7223	/// post.add_field(FieldState::new("author_id", FieldType::Custom("ForeignKey(accounts.User)".into()), false));
7224	/// to_state.add_model(post);
7225	///
7226	/// let detector = MigrationAutodetector::new(from_state, to_state);
7227	/// let changes = detector.detect_changes();
7228	///
7229	/// // blog.Post depends on accounts.User
7230	/// let post_deps = changes.model_dependencies.get(&("blog".to_string(), "Post".to_string()));
7231	/// assert!(post_deps.is_some());
7232	/// assert!(post_deps.unwrap().contains(&("accounts".to_string(), "User".to_string())));
7233	/// ```
7234	/// Detect foreign-key and relation dependencies between models.
7235	///
7236	/// Registered foreign-key ID columns typically keep a scalar `field_type`
7237	/// (for example `FieldType::Integer`) and store the relationship on
7238	/// `FieldState.foreign_key`. Detecting only relationship-shaped
7239	/// `FieldType` variants therefore leaves `model_dependencies` empty for
7240	/// the common inline-FK case. This method also inspects
7241	/// `FieldState.foreign_key` and `ModelState.constraints`, and resolves
7242	/// referenced tables through `ProjectState::find_model_by_table` so
7243	/// custom `table_name` values such as `auth_users` are found.
7244	fn detect_model_dependencies(&self, changes: &mut DetectedChanges) {
7245		use std::collections::BTreeSet;
7246
7247		for ((app_label, model_name), model) in &self.to_state.models {
7248			let current = (app_label.clone(), model_name.clone());
7249			let mut dependencies = Vec::new();
7250			let mut seen = BTreeSet::new();
7251
7252			for field in model.fields.values() {
7253				match &field.field_type {
7254					super::FieldType::ForeignKey { to_table, .. } => {
7255						self.record_table_dependency(
7256							to_table,
7257							&current,
7258							&mut dependencies,
7259							&mut seen,
7260						);
7261					}
7262					super::FieldType::OneToOne { to, .. } => {
7263						if let Some(dep) = self.parse_model_reference(to, app_label) {
7264							self.record_model_dependency(
7265								dep,
7266								&current,
7267								&mut dependencies,
7268								&mut seen,
7269							);
7270						}
7271					}
7272					super::FieldType::ManyToMany { to, .. } => {
7273						if let Some(dep) = self.parse_model_reference(to, app_label) {
7274							self.record_model_dependency(
7275								dep,
7276								&current,
7277								&mut dependencies,
7278								&mut seen,
7279							);
7280						}
7281					}
7282					super::FieldType::Custom(s) => {
7283						if let Some(dep) = self.extract_related_model(s, app_label) {
7284							self.record_model_dependency(
7285								dep,
7286								&current,
7287								&mut dependencies,
7288								&mut seen,
7289							);
7290						}
7291					}
7292					_ => {}
7293				}
7294
7295				if let Some(fk) = &field.foreign_key {
7296					self.record_table_dependency(
7297						&fk.referenced_table,
7298						&current,
7299						&mut dependencies,
7300						&mut seen,
7301					);
7302				}
7303			}
7304
7305			for constraint in &model.constraints {
7306				if (constraint
7307					.constraint_type
7308					.eq_ignore_ascii_case("foreign_key")
7309					|| constraint
7310						.constraint_type
7311						.eq_ignore_ascii_case("one_to_one"))
7312					&& let Some(fk_info) = &constraint.foreign_key_info
7313				{
7314					self.record_table_dependency(
7315						&fk_info.referenced_table,
7316						&current,
7317						&mut dependencies,
7318						&mut seen,
7319					);
7320				}
7321			}
7322
7323			if !dependencies.is_empty() {
7324				changes.model_dependencies.insert(current, dependencies);
7325			}
7326		}
7327	}
7328
7329	fn record_table_dependency(
7330		&self,
7331		table_name: &str,
7332		current: &(String, String),
7333		dependencies: &mut Vec<(String, String)>,
7334		seen: &mut std::collections::BTreeSet<(String, String)>,
7335	) {
7336		if let Some(dep) = self.resolve_model_by_table(table_name) {
7337			self.record_model_dependency(dep, current, dependencies, seen);
7338		}
7339	}
7340
7341	fn record_model_dependency(
7342		&self,
7343		dep: (String, String),
7344		current: &(String, String),
7345		dependencies: &mut Vec<(String, String)>,
7346		seen: &mut std::collections::BTreeSet<(String, String)>,
7347	) {
7348		if dep != *current && seen.insert(dep.clone()) {
7349			dependencies.push(dep);
7350		}
7351	}
7352
7353	fn resolve_model_by_table(&self, table_name: &str) -> Option<(String, String)> {
7354		if let Some(model) = self.to_state.find_model_by_table(table_name) {
7355			return Some((model.app_label.clone(), model.name.clone()));
7356		}
7357		if let Some(model) = self.from_state.find_model_by_table(table_name) {
7358			return Some((model.app_label.clone(), model.name.clone()));
7359		}
7360		self.find_model_by_table_name(table_name)
7361	}
7362
7363	/// Extract related model from field type string
7364	///
7365	/// Parses field type strings like:
7366	/// - "ForeignKey(app.Model)" -> Some(("app", "Model"))
7367	/// - "ManyToManyField(app.Model)" -> Some(("app", "Model"))
7368	/// - "ForeignKey(Model)" -> Some((current_app, "Model"))
7369	/// - "CharField" -> None
7370	///
7371	/// # Arguments
7372	/// * `field_type` - Field type string (e.g., "ForeignKey(accounts.User)")
7373	/// * `current_app` - Current app label for resolving unqualified references
7374	///
7375	/// # Returns
7376	/// * `Some((app_label, model_name))` if field is a relation
7377	/// * `None` if field is not a relation
7378	fn extract_related_model(
7379		&self,
7380		field_type: &str,
7381		current_app: &str,
7382	) -> Option<(String, String)> {
7383		// Check for ForeignKey pattern
7384		if let Some(inner) = field_type
7385			.strip_prefix("ForeignKey(")
7386			.and_then(|s| s.strip_suffix(")"))
7387		{
7388			return self.parse_model_reference(inner, current_app);
7389		}
7390
7391		// Check for ManyToManyField pattern
7392		if let Some(inner) = field_type
7393			.strip_prefix("ManyToManyField(")
7394			.and_then(|s| s.strip_suffix(")"))
7395		{
7396			return self.parse_model_reference(inner, current_app);
7397		}
7398
7399		// Check for OneToOneField pattern
7400		if let Some(inner) = field_type
7401			.strip_prefix("OneToOneField(")
7402			.and_then(|s| s.strip_suffix(")"))
7403		{
7404			return self.parse_model_reference(inner, current_app);
7405		}
7406
7407		None
7408	}
7409
7410	/// Parse model reference string into (app_label, model_name)
7411	///
7412	/// Supports formats:
7413	/// - "app.Model" -> ("app", "Model")
7414	/// - "Model" -> (current_app, "Model") - Uses current app for unqualified references
7415	///
7416	/// # Arguments
7417	/// * `reference` - Model reference string (e.g., "accounts.User" or "User")
7418	/// * `current_app` - Current app label for resolving unqualified references
7419	///
7420	/// # Returns
7421	/// * `Some((app_label, model_name))` if parseable
7422	/// * `None` if format is invalid
7423	fn parse_model_reference(
7424		&self,
7425		reference: &str,
7426		current_app: &str,
7427	) -> Option<(String, String)> {
7428		let parts: Vec<&str> = reference.split('.').collect();
7429		match parts.as_slice() {
7430			// Fully qualified reference: "app.Model"
7431			[app, model] => Some((app.to_string(), model.to_string())),
7432			// Unqualified reference: "Model" - assume same app
7433			[model] => {
7434				// Use current app for same-app references
7435				Some((current_app.to_string(), model.to_string()))
7436			}
7437			// Invalid format
7438			_ => None,
7439		}
7440	}
7441
7442	fn resolve_model_reference(&self, reference: &str, current_app: &str) -> (String, String) {
7443		let parts: Vec<&str> = reference.split('.').collect();
7444		match parts.as_slice() {
7445			[app, model] => (app.to_string(), model.to_string()),
7446			[model] => {
7447				let model = model.to_string();
7448				if self.to_state.get_model(current_app, &model).is_some() {
7449					(current_app.to_string(), model)
7450				} else {
7451					(
7452						self.find_model_app(&model)
7453							.unwrap_or_else(|| current_app.to_string()),
7454						model,
7455					)
7456				}
7457			}
7458			_ => (current_app.to_string(), reference.to_string()),
7459		}
7460	}
7461
7462	/// Apps whose tables are referenced by foreign keys in `operations`.
7463	///
7464	/// Used by `makemigrations` to attach cross-app migration dependencies so
7465	/// a fresh database applies provider-app initials before dependents.
7466	pub fn foreign_key_provider_apps(
7467		to_state: &ProjectState,
7468		operations: &[super::Operation],
7469		current_app: &str,
7470	) -> Vec<String> {
7471		use std::collections::BTreeSet;
7472
7473		let mut apps = BTreeSet::new();
7474		for operation in operations {
7475			for table in Self::referenced_tables_in_operation(operation) {
7476				if let Some(model) = to_state.find_model_by_table(&table)
7477					&& model.app_label != current_app
7478				{
7479					apps.insert(model.app_label.clone());
7480				}
7481			}
7482			if let super::Operation::MoveModel { from_app, .. } = operation
7483				&& from_app != current_app
7484			{
7485				apps.insert(from_app.clone());
7486			}
7487		}
7488		apps.into_iter().collect()
7489	}
7490
7491	fn referenced_tables_in_operation(operation: &super::Operation) -> Vec<String> {
7492		match operation {
7493			super::Operation::CreateTable { constraints, .. } => {
7494				Self::referenced_tables_from_constraints(constraints)
7495			}
7496			super::Operation::AddConstraint { constraint_sql, .. } => {
7497				Self::referenced_tables_from_constraint_sql(constraint_sql)
7498			}
7499			_ => Vec::new(),
7500		}
7501	}
7502
7503	fn referenced_tables_from_constraints(
7504		constraints: &[super::operations::Constraint],
7505	) -> Vec<String> {
7506		let mut tables = Vec::new();
7507		for constraint in constraints {
7508			match constraint {
7509				super::operations::Constraint::ForeignKey {
7510					referenced_table, ..
7511				}
7512				| super::operations::Constraint::OneToOne {
7513					referenced_table, ..
7514				} => {
7515					tables.push(referenced_table.clone());
7516				}
7517				super::operations::Constraint::ManyToMany { target_table, .. } => {
7518					tables.push(target_table.clone());
7519				}
7520				_ => {}
7521			}
7522		}
7523		tables
7524	}
7525
7526	fn referenced_tables_from_constraint_sql(constraint_sql: &str) -> Vec<String> {
7527		let mut tables = Vec::new();
7528		let mut rest = constraint_sql;
7529		while let Some(index) = rest.find("REFERENCES ") {
7530			let tail = rest[index + "REFERENCES ".len()..].trim_start();
7531			if tail.is_empty() {
7532				break;
7533			}
7534			let (table, remaining) = if let Some(stripped) = tail.strip_prefix('"') {
7535				match stripped.find('"') {
7536					Some(end) => (&stripped[..end], &stripped[end + 1..]),
7537					None => break,
7538				}
7539			} else {
7540				let end = tail
7541					.find(|ch: char| ch == '(' || ch.is_whitespace())
7542					.unwrap_or(tail.len());
7543				(&tail[..end], &tail[end..])
7544			};
7545			if !table.is_empty() {
7546				tables.push(table.to_string());
7547			}
7548			rest = remaining;
7549		}
7550		tables
7551	}
7552
7553	fn order_create_tables_by_foreign_keys(operations: &mut [super::Operation]) {
7554		let create_indices: Vec<usize> = operations
7555			.iter()
7556			.enumerate()
7557			.filter(|(_, op)| matches!(op, super::Operation::CreateTable { .. }))
7558			.map(|(i, _)| i)
7559			.collect();
7560		if create_indices.len() <= 1 {
7561			return;
7562		}
7563		let create_ops: Vec<super::Operation> = create_indices
7564			.iter()
7565			.map(|&i| operations[i].clone())
7566			.collect();
7567		let sorted = Self::topological_sort_create_tables(create_ops);
7568		for (slot, op) in create_indices.into_iter().zip(sorted) {
7569			operations[slot] = op;
7570		}
7571	}
7572
7573	fn topological_sort_create_tables(
7574		create_tables: Vec<super::Operation>,
7575	) -> Vec<super::Operation> {
7576		use std::collections::{BTreeMap, BTreeSet};
7577
7578		if create_tables.len() <= 1 {
7579			return create_tables;
7580		}
7581
7582		let names: Vec<String> = create_tables
7583			.iter()
7584			.filter_map(|op| match op {
7585				super::Operation::CreateTable { name, .. } => Some(name.clone()),
7586				_ => None,
7587			})
7588			.collect();
7589		let name_set: BTreeSet<String> = names.iter().cloned().collect();
7590		let mut in_degree: BTreeMap<String, usize> =
7591			names.iter().cloned().map(|name| (name, 0)).collect();
7592		let mut dependents: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7593
7594		for op in &create_tables {
7595			let super::Operation::CreateTable {
7596				name, constraints, ..
7597			} = op
7598			else {
7599				continue;
7600			};
7601			for referenced in Self::referenced_tables_from_constraints(constraints) {
7602				if referenced == *name || !name_set.contains(&referenced) {
7603					continue;
7604				}
7605				*in_degree.entry(name.clone()).or_insert(0) += 1;
7606				dependents
7607					.entry(referenced)
7608					.or_default()
7609					.insert(name.clone());
7610			}
7611		}
7612
7613		let mut ready: BTreeSet<String> = in_degree
7614			.iter()
7615			.filter(|(_, degree)| **degree == 0)
7616			.map(|(name, _)| name.clone())
7617			.collect();
7618		let mut ordered_names = Vec::with_capacity(names.len());
7619		while let Some(name) = ready.iter().next().cloned() {
7620			ready.remove(&name);
7621			ordered_names.push(name.clone());
7622			if let Some(children) = dependents.get(&name) {
7623				for child in children {
7624					if let Some(degree) = in_degree.get_mut(child) {
7625						*degree = degree.saturating_sub(1);
7626						if *degree == 0 {
7627							ready.insert(child.clone());
7628						}
7629					}
7630				}
7631			}
7632		}
7633
7634		if ordered_names.len() < names.len() {
7635			let mut remaining: Vec<String> = names
7636				.iter()
7637				.filter(|name| !ordered_names.contains(name))
7638				.cloned()
7639				.collect();
7640			remaining.sort();
7641			eprintln!(
7642				"⚠️  Warning: Circular foreign-key dependency detected among CreateTable operations: [{}]",
7643				remaining.join(", ")
7644			);
7645			ordered_names.extend(remaining);
7646		}
7647
7648		let mut by_name: BTreeMap<String, super::Operation> = BTreeMap::new();
7649		let mut extras = Vec::new();
7650		for op in create_tables {
7651			match &op {
7652				super::Operation::CreateTable { name, .. } => {
7653					by_name.insert(name.clone(), op);
7654				}
7655				_ => extras.push(op),
7656			}
7657		}
7658
7659		let mut sorted: Vec<super::Operation> = ordered_names
7660			.into_iter()
7661			.filter_map(|name| by_name.remove(&name))
7662			.collect();
7663		sorted.extend(by_name.into_values());
7664		sorted.extend(extras);
7665		sorted
7666	}
7667
7668	/// Find a model in the project state by its table name
7669	///
7670	/// Resolves against the model's actual `table_name` first. Django-style
7671	/// `{app}_{model}` and lowercase model-name heuristics remain as fallbacks
7672	/// for callers that pass a relation target without a registered table.
7673	fn find_model_by_table_name(&self, table_name: &str) -> Option<(String, String)> {
7674		if let Some(model) = self.to_state.find_model_by_table(table_name) {
7675			return Some((model.app_label.clone(), model.name.clone()));
7676		}
7677		if let Some(model) = self.from_state.find_model_by_table(table_name) {
7678			return Some((model.app_label.clone(), model.name.clone()));
7679		}
7680
7681		// Search in to_state (target state has priority)
7682		for (app_label, model_name) in self.to_state.models.keys() {
7683			// Check Django-style table name: app_modelname
7684			let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
7685			if django_table == table_name {
7686				return Some((app_label.clone(), model_name.clone()));
7687			}
7688
7689			// Check simple lowercase model name
7690			if model_name.to_lowercase() == table_name {
7691				return Some((app_label.clone(), model_name.clone()));
7692			}
7693		}
7694
7695		// Fallback: search in from_state
7696		for (app_label, model_name) in self.from_state.models.keys() {
7697			let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
7698			if django_table == table_name {
7699				return Some((app_label.clone(), model_name.clone()));
7700			}
7701
7702			if model_name.to_lowercase() == table_name {
7703				return Some((app_label.clone(), model_name.clone()));
7704			}
7705		}
7706
7707		None
7708	}
7709}
7710
7711impl ModelState {
7712	/// Remove a field from this model
7713	///
7714	/// # Examples
7715	///
7716	/// ```rust,ignore
7717	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
7718	///
7719	/// let mut model = ModelState::new("myapp", "User");
7720	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
7721	/// model.add_field(field);
7722	/// assert!(model.has_field("email"));
7723	///
7724	/// model.remove_field("email");
7725	/// assert!(!model.has_field("email"));
7726	/// ```
7727	pub fn remove_field(&mut self, name: &str) {
7728		self.fields.remove(name);
7729	}
7730
7731	/// Alter a field definition
7732	///
7733	/// # Examples
7734	///
7735	/// ```rust,ignore
7736	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
7737	///
7738	/// let mut model = ModelState::new("myapp", "User");
7739	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
7740	/// model.add_field(field);
7741	///
7742	/// let new_field = FieldState::new("email", FieldType::Text, true);
7743	/// model.alter_field("email", new_field);
7744	///
7745	/// let altered = model.get_field("email").unwrap();
7746	/// assert_eq!(altered.field_type, FieldType::Text);
7747	/// assert!(altered.nullable);
7748	/// ```
7749	pub fn alter_field(&mut self, name: &str, new_field: FieldState) {
7750		self.fields.insert(name.to_string(), new_field);
7751	}
7752}
7753
7754#[cfg(test)]
7755mod tests {
7756	use super::*;
7757	use crate::migrations::FieldType;
7758	use rstest::rstest;
7759
7760	/// Helper to build a ProjectState with given models
7761	fn build_project_state(models: Vec<((String, String), ModelState)>) -> ProjectState {
7762		let mut state = ProjectState::new();
7763		for (key, model) in models {
7764			state.models.insert(key, model);
7765		}
7766		state
7767	}
7768
7769	/// Helper to build a minimal ModelState
7770	fn build_model_state(
7771		app_label: &str,
7772		name: &str,
7773		fields: Vec<FieldState>,
7774		indexes: Vec<IndexDefinition>,
7775		constraints: Vec<ConstraintDefinition>,
7776	) -> ModelState {
7777		let mut field_map = std::collections::BTreeMap::new();
7778		for f in fields {
7779			field_map.insert(f.name.clone(), f);
7780		}
7781		ModelState {
7782			app_label: app_label.to_string(),
7783			name: name.to_string(),
7784			table_name: format!("{}_{}", app_label, name.to_lowercase()),
7785			fields: field_map,
7786			options: std::collections::HashMap::new(),
7787			base_model: None,
7788			inheritance_type: None,
7789			discriminator_column: None,
7790			indexes,
7791			constraints,
7792			many_to_many_fields: Vec::new(),
7793		}
7794	}
7795
7796	#[rstest]
7797	fn apply_migration_operations_replays_foreign_key_add_constraint() {
7798		// Arrange
7799		let create_posts = super::super::Operation::CreateTable {
7800			name: "blog_posts".to_string(),
7801			columns: vec![
7802				super::super::ColumnDefinition {
7803					name: "id".to_string(),
7804					type_definition: super::super::FieldType::BigInteger,
7805					not_null: true,
7806					unique: false,
7807					primary_key: true,
7808					auto_increment: true,
7809					default: None,
7810				},
7811				super::super::ColumnDefinition {
7812					name: "user_id".to_string(),
7813					type_definition: super::super::FieldType::BigInteger,
7814					not_null: true,
7815					unique: false,
7816					primary_key: false,
7817					auto_increment: false,
7818					default: None,
7819				},
7820			],
7821			constraints: vec![],
7822			without_rowid: None,
7823			interleave_in_parent: None,
7824			partition: None,
7825		};
7826		let add_user_fk = super::super::Operation::AddConstraint {
7827				table: "blog_posts".to_string(),
7828				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(),
7829			};
7830		let mut state = ProjectState::new();
7831
7832		// Act
7833		state.apply_migration_operations(&[create_posts, add_user_fk], "blog");
7834
7835		// Assert
7836		let model = state
7837			.find_model_by_table("blog_posts")
7838			.expect("blog_posts model should be reconstructed");
7839		let constraint = model
7840			.constraints
7841			.iter()
7842			.find(|constraint| constraint.name == "blog_posts_user_id_fk")
7843			.expect("foreign key constraint should be reconstructed");
7844		assert_eq!(constraint.constraint_type, "foreign_key");
7845		assert_eq!(constraint.fields, vec!["user_id".to_string()]);
7846		let fk_info = constraint
7847			.foreign_key_info
7848			.as_ref()
7849			.expect("foreign key metadata should be reconstructed");
7850		assert_eq!(fk_info.referenced_table, "auth_users");
7851		assert_eq!(fk_info.referenced_columns, vec!["id".to_string()]);
7852		assert_eq!(fk_info.on_delete, ForeignKeyAction::Cascade);
7853		assert_eq!(fk_info.on_update, ForeignKeyAction::NoAction);
7854	}
7855
7856	#[test]
7857	fn generated_indexes_are_replayed_without_second_migration() {
7858		// Arrange
7859		let mut target = ProjectState::new();
7860		let mut post = ModelState::new("blog", "Post");
7861		post.table_name = "blog_posts".to_string();
7862		post.add_field(FieldState::new("id", FieldType::Integer, false));
7863		post.add_field(FieldState::new("author_id", FieldType::Uuid, false));
7864		post.indexes.push(IndexDefinition {
7865			name: "idx_blog_posts_author_id".to_string(),
7866			fields: vec!["author_id".to_string()],
7867			unique: false,
7868			where_clause: None,
7869			index_type: None,
7870			expressions: None,
7871			concurrently: false,
7872			mysql_options: None,
7873			operator_class: None,
7874		});
7875		target.add_model(post);
7876
7877		// Act
7878		let operations =
7879			MigrationAutodetector::new(ProjectState::new(), target.clone()).generate_operations();
7880		let mut replayed = ProjectState::new();
7881		replayed.apply_migration_operations(&operations, "blog");
7882		let second_run = MigrationAutodetector::new(replayed.clone(), target).generate_operations();
7883
7884		// Assert
7885		assert_eq!(
7886			operations
7887				.iter()
7888				.filter(|operation| {
7889					matches!(
7890						operation,
7891						super::super::Operation::CreateIndex { .. }
7892							| super::super::Operation::CreateIndexRepair { .. }
7893					)
7894				})
7895				.count(),
7896			1
7897		);
7898		assert_eq!(
7899			replayed
7900				.find_model_by_table("blog_posts")
7901				.unwrap()
7902				.indexes
7903				.len(),
7904			1
7905		);
7906		assert!(
7907			second_run.is_empty(),
7908			"unexpected second migration: {second_run:?}"
7909		);
7910	}
7911
7912	#[test]
7913	fn replays_advanced_indexes_for_replacement_and_removal() {
7914		// Arrange
7915		let create_advanced_index = super::super::Operation::CreateIndex {
7916			table: "blog_posts".to_string(),
7917			columns: vec!["slug".to_string()],
7918			unique: false,
7919			index_type: None,
7920			where_clause: Some("published = TRUE".to_string()),
7921			concurrently: false,
7922			expressions: None,
7923			mysql_options: None,
7924			operator_class: None,
7925		};
7926		let mut replayed = ProjectState::new();
7927		let mut old_model = ModelState::new("blog", "Post");
7928		old_model.table_name = "blog_posts".to_string();
7929		old_model.add_field(FieldState::new("slug", FieldType::VarChar(255), false));
7930		replayed.add_model(old_model);
7931		replayed.apply_migration_operations(&[create_advanced_index], "blog");
7932		let mut replacement_model = ModelState::new("blog", "Post");
7933		replacement_model.table_name = "blog_posts".to_string();
7934		replacement_model.add_field(FieldState::new("slug", FieldType::VarChar(255), false));
7935		replacement_model.indexes.push(IndexDefinition {
7936			name: "idx_blog_posts_slug".to_string(),
7937			fields: vec!["slug".to_string()],
7938			unique: false,
7939			where_clause: None,
7940			index_type: None,
7941			expressions: None,
7942			concurrently: false,
7943			mysql_options: None,
7944			operator_class: None,
7945		});
7946		let mut replacement_target = ProjectState::new();
7947		replacement_target.add_model(replacement_model);
7948
7949		// Act
7950		let replacement_operations =
7951			MigrationAutodetector::new(replayed.clone(), replacement_target).generate_operations();
7952		let mut removal_target = ProjectState::new();
7953		let mut removal_model = ModelState::new("blog", "Post");
7954		removal_model.table_name = "blog_posts".to_string();
7955		removal_model.add_field(FieldState::new("slug", FieldType::VarChar(255), false));
7956		removal_target.add_model(removal_model);
7957		let removal_operations =
7958			MigrationAutodetector::new(replayed, removal_target).generate_operations();
7959
7960		// Assert
7961		let drop_position = replacement_operations
7962			.iter()
7963			.position(|operation| {
7964				matches!(operation, super::super::Operation::DropNamedIndex { .. })
7965			})
7966			.expect("advanced index replacement should drop the old index");
7967		let create_position = replacement_operations
7968			.iter()
7969			.position(|operation| {
7970				matches!(
7971					operation,
7972					super::super::Operation::CreateIndex { .. }
7973						| super::super::Operation::CreateIndexRepair { .. }
7974				)
7975			})
7976			.expect("advanced index replacement should create the ordinary index");
7977		assert!(drop_position < create_position);
7978		assert_eq!(
7979			removal_operations
7980				.iter()
7981				.filter(|operation| {
7982					matches!(operation, super::super::Operation::DropNamedIndex { .. })
7983				})
7984				.count(),
7985			1
7986		);
7987	}
7988
7989	#[test]
7990	fn replays_expression_index_name_and_definition_for_removal() {
7991		// Arrange
7992		let create_expression_index = super::super::Operation::CreateIndex {
7993			table: "blog_posts".to_string(),
7994			columns: vec!["slug".to_string()],
7995			unique: true,
7996			index_type: Some(super::super::operations::IndexType::BTree),
7997			where_clause: Some("published = TRUE".to_string()),
7998			concurrently: false,
7999			expressions: Some(vec!["LOWER(slug)".to_string()]),
8000			mysql_options: None,
8001			operator_class: None,
8002		};
8003		let mut replayed = ProjectState::new();
8004		let mut old_model = ModelState::new("blog", "Post");
8005		old_model.table_name = "blog_posts".to_string();
8006		old_model.add_field(FieldState::new("slug", FieldType::VarChar(255), false));
8007		replayed.add_model(old_model);
8008		replayed.apply_migration_operations(&[create_expression_index], "blog");
8009		let replayed_index = &replayed
8010			.find_model_by_table("blog_posts")
8011			.expect("replayed model")
8012			.indexes[0];
8013		assert_eq!(replayed_index.name, "idx_blog_posts_expr");
8014		assert_eq!(
8015			replayed_index.expressions,
8016			Some(vec!["LOWER(slug)".to_string()])
8017		);
8018
8019		let mut removal_target = ProjectState::new();
8020		let mut target_model = ModelState::new("blog", "Post");
8021		target_model.table_name = "blog_posts".to_string();
8022		target_model.add_field(FieldState::new("slug", FieldType::VarChar(255), false));
8023		removal_target.add_model(target_model);
8024
8025		// Act
8026		let removal_operations =
8027			MigrationAutodetector::new(replayed, removal_target).generate_operations();
8028
8029		// Assert
8030		assert!(matches!(
8031			removal_operations.as_slice(),
8032			[super::super::Operation::DropNamedIndex {
8033				name,
8034				unique: true,
8035				where_clause: Some(predicate),
8036				expressions: Some(expressions),
8037				..
8038			}] if name == "idx_blog_posts_expr"
8039				&& predicate == "published = TRUE"
8040				&& expressions == &["LOWER(slug)".to_string()]
8041		));
8042	}
8043
8044	#[test]
8045	fn detects_same_table_index_name_changes() {
8046		// Arrange
8047		let index = |name: &str| IndexDefinition {
8048			name: name.to_string(),
8049			fields: vec!["email".to_string()],
8050			unique: false,
8051			where_clause: None,
8052			index_type: None,
8053			expressions: None,
8054			concurrently: false,
8055			mysql_options: None,
8056			operator_class: None,
8057		};
8058		let from_model = build_model_state(
8059			"blog",
8060			"Post",
8061			vec![FieldState::new("email", FieldType::VarChar(255), false)],
8062			vec![index("old_email_idx")],
8063			Vec::new(),
8064		);
8065		let to_model = build_model_state(
8066			"blog",
8067			"Post",
8068			vec![FieldState::new("email", FieldType::VarChar(255), false)],
8069			vec![index("new_email_idx")],
8070			Vec::new(),
8071		);
8072		let detector = MigrationAutodetector::new(
8073			build_project_state(vec![(("blog".to_string(), "Post".to_string()), from_model)]),
8074			build_project_state(vec![(("blog".to_string(), "Post".to_string()), to_model)]),
8075		);
8076
8077		// Act
8078		let operations = detector.generate_operations();
8079
8080		// Assert
8081		assert!(matches!(
8082			operations.as_slice(),
8083			[
8084				super::super::Operation::DropNamedIndex { name: old, .. },
8085				super::super::Operation::CreateIndexRepair { name: Some(new), .. },
8086			] if old == "old_email_idx" && new == "new_email_idx"
8087		));
8088	}
8089
8090	#[test]
8091	fn replays_drop_index_only_removes_generated_name() {
8092		// Arrange
8093		let mut model = ModelState::new("blog", "Post");
8094		model.table_name = "blog_posts".to_string();
8095		model.add_field(FieldState::new("email", FieldType::VarChar(255), false));
8096		model.indexes = vec![
8097			IndexDefinition {
8098				name: "idx_blog_posts_email".to_string(),
8099				fields: vec!["email".to_string()],
8100				unique: false,
8101				where_clause: None,
8102				index_type: None,
8103				expressions: None,
8104				concurrently: false,
8105				mysql_options: None,
8106				operator_class: None,
8107			},
8108			IndexDefinition {
8109				name: "custom_email_idx".to_string(),
8110				fields: vec!["email".to_string()],
8111				unique: true,
8112				where_clause: None,
8113				index_type: None,
8114				expressions: None,
8115				concurrently: false,
8116				mysql_options: None,
8117				operator_class: None,
8118			},
8119		];
8120		let mut state = ProjectState::new();
8121		state.add_model(model);
8122		let drop = super::super::Operation::DropIndex {
8123			table: "blog_posts".to_string(),
8124			columns: vec!["email".to_string()],
8125		};
8126
8127		// Act
8128		state.apply_migration_operations(&[drop], "blog");
8129
8130		// Assert
8131		let indexes = &state
8132			.find_model_by_table("blog_posts")
8133			.expect("replayed model")
8134			.indexes;
8135		assert_eq!(indexes.len(), 1);
8136		assert_eq!(indexes[0].name, "custom_email_idx");
8137	}
8138
8139	#[test]
8140	fn replays_drop_column_predicate_and_ignores_function_name() {
8141		// Arrange
8142		assert!(!ProjectState::expression_references_column(
8143			"LOWER(email)",
8144			"lower"
8145		));
8146		assert!(ProjectState::expression_references_column(
8147			"LOWER(email)",
8148			"email"
8149		));
8150		let mut model = ModelState::new("blog", "Post");
8151		model.table_name = "blog_posts".to_string();
8152		model.add_field(FieldState::new("email", FieldType::VarChar(255), false));
8153		model.add_field(FieldState::new("active", FieldType::Boolean, false));
8154		let mut state = ProjectState::new();
8155		state.add_model(model);
8156		let create = super::super::Operation::CreateIndexRepair {
8157			table: "blog_posts".to_string(),
8158			name: Some("active_email_idx".to_string()),
8159			columns: vec!["email".to_string()],
8160			unique: false,
8161			index_type: None,
8162			where_clause: Some("active = TRUE".to_string()),
8163			concurrently: false,
8164			expressions: Some(vec!["LOWER(email)".to_string()]),
8165			mysql_options: None,
8166			operator_class: None,
8167		};
8168
8169		// Act
8170		state.apply_migration_operations(&[create], "blog");
8171		state.apply_migration_operations(
8172			&[super::super::Operation::DropColumn {
8173				table: "blog_posts".to_string(),
8174				column: "active".to_string(),
8175			}],
8176			"blog",
8177		);
8178
8179		// Assert
8180		assert!(
8181			state
8182				.find_model_by_table("blog_posts")
8183				.expect("replayed model")
8184				.indexes
8185				.is_empty()
8186		);
8187	}
8188
8189	#[test]
8190	fn generate_migrations_recreates_generated_indexes_around_cross_app_move() {
8191		// Arrange
8192		let old_index = IndexDefinition {
8193			name: "idx_legacy_user_email".to_string(),
8194			fields: vec!["email".to_string()],
8195			unique: true,
8196			where_clause: None,
8197			index_type: None,
8198			expressions: None,
8199			concurrently: false,
8200			mysql_options: None,
8201			operator_class: None,
8202		};
8203		let new_index = IndexDefinition {
8204			name: "idx_accounts_user_email".to_string(),
8205			..old_index.clone()
8206		};
8207		let old_model = build_model_state(
8208			"legacy",
8209			"User",
8210			vec![
8211				FieldState::new("id", FieldType::Integer, false),
8212				FieldState::new("email", FieldType::VarChar(255), false),
8213			],
8214			vec![old_index],
8215			Vec::new(),
8216		);
8217		let new_model = build_model_state(
8218			"accounts",
8219			"User",
8220			vec![
8221				FieldState::new("id", FieldType::Integer, false),
8222				FieldState::new("email", FieldType::VarChar(255), false),
8223			],
8224			vec![new_index],
8225			Vec::new(),
8226		);
8227		let detector = MigrationAutodetector::new(
8228			build_project_state(vec![(
8229				("legacy".to_string(), "User".to_string()),
8230				old_model,
8231			)]),
8232			build_project_state(vec![(
8233				("accounts".to_string(), "User".to_string()),
8234				new_model,
8235			)]),
8236		);
8237
8238		// Act
8239		let migrations = detector
8240			.try_generate_migrations()
8241			.expect("cross-app move should generate a migration");
8242
8243		// Assert
8244		let operations = &migrations[0].operations;
8245		assert!(matches!(
8246			operations.as_slice(),
8247			[
8248				super::super::Operation::DropNamedIndex { name, .. },
8249				super::super::Operation::MoveModel { .. },
8250				create,
8251			] if name == "idx_legacy_user_email"
8252				&& matches!(
8253					create,
8254					super::super::Operation::CreateIndex {
8255						table,
8256						columns,
8257						unique: true,
8258						..
8259					} | super::super::Operation::CreateIndexRepair {
8260						table,
8261						columns,
8262						unique: true,
8263						..
8264					}
8265					if table == "accounts_user" && columns == &["email".to_string()]
8266				)
8267		));
8268	}
8269
8270	#[test]
8271	fn reorders_index_recreation_around_column_rename() {
8272		// Arrange
8273		let create_index = |columns: &[&str]| super::super::Operation::CreateIndex {
8274			table: "blog_posts".to_string(),
8275			columns: columns.iter().map(|column| (*column).to_string()).collect(),
8276			unique: false,
8277			index_type: None,
8278			where_clause: None,
8279			concurrently: false,
8280			expressions: None,
8281			mysql_options: None,
8282			operator_class: None,
8283		};
8284		let mut operations = vec![
8285			create_index(&["slug_new"]),
8286			super::super::Operation::RenameColumn {
8287				table: "blog_posts".to_string(),
8288				old_name: "slug".to_string(),
8289				new_name: "slug_new".to_string(),
8290			},
8291			super::super::Operation::DropIndex {
8292				table: "blog_posts".to_string(),
8293				columns: vec!["slug".to_string()],
8294			},
8295		];
8296
8297		// Act
8298		MigrationAutodetector::order_renamed_column_operations(&mut operations);
8299
8300		// Assert
8301		assert!(matches!(
8302			&operations[..],
8303			[
8304				super::super::Operation::DropIndex { .. },
8305				super::super::Operation::RenameColumn { .. },
8306				super::super::Operation::CreateIndex { .. },
8307			]
8308		));
8309	}
8310
8311	#[test]
8312	fn reorders_predicate_index_drop_before_column_rename() {
8313		// Arrange
8314		let mut operations = vec![
8315			super::super::Operation::RenameColumn {
8316				table: "blog_posts".to_string(),
8317				old_name: "active_old".to_string(),
8318				new_name: "active_new".to_string(),
8319			},
8320			super::super::Operation::DropNamedIndex {
8321				table: "blog_posts".to_string(),
8322				name: "active_email_idx".to_string(),
8323				columns: vec!["email".to_string()],
8324				unique: false,
8325				index_type: None,
8326				where_clause: Some("active_old = TRUE".to_string()),
8327				concurrently: false,
8328				expressions: None,
8329				mysql_options: None,
8330				operator_class: None,
8331			},
8332		];
8333
8334		// Act
8335		MigrationAutodetector::order_renamed_column_operations(&mut operations);
8336
8337		// Assert
8338		assert!(matches!(
8339			&operations[..],
8340			[
8341				super::super::Operation::DropNamedIndex { .. },
8342				super::super::Operation::RenameColumn { .. },
8343			]
8344		));
8345	}
8346
8347	#[test]
8348	fn drops_replaced_index_before_creating_new_definition() {
8349		// Arrange
8350		let old_model = build_model_state(
8351			"blog",
8352			"Post",
8353			vec![FieldState::new("email", FieldType::VarChar(255), false)],
8354			vec![IndexDefinition {
8355				name: "idx_blog_post_email".to_string(),
8356				fields: vec!["email".to_string()],
8357				unique: false,
8358				where_clause: None,
8359				index_type: None,
8360				expressions: None,
8361				concurrently: false,
8362				mysql_options: None,
8363				operator_class: None,
8364			}],
8365			Vec::new(),
8366		);
8367		let new_model = build_model_state(
8368			"blog",
8369			"Post",
8370			vec![FieldState::new("email", FieldType::VarChar(255), false)],
8371			vec![IndexDefinition {
8372				name: "idx_blog_post_email".to_string(),
8373				fields: vec!["email".to_string()],
8374				unique: true,
8375				where_clause: None,
8376				index_type: None,
8377				expressions: None,
8378				concurrently: false,
8379				mysql_options: None,
8380				operator_class: None,
8381			}],
8382			Vec::new(),
8383		);
8384		let from_state =
8385			build_project_state(vec![(("blog".to_string(), "Post".to_string()), old_model)]);
8386		let to_state =
8387			build_project_state(vec![(("blog".to_string(), "Post".to_string()), new_model)]);
8388
8389		// Act
8390		let operations = MigrationAutodetector::new(from_state, to_state).generate_operations();
8391
8392		// Assert
8393		let drop_position = operations
8394			.iter()
8395			.position(|operation| {
8396				matches!(operation, super::super::Operation::DropNamedIndex { .. })
8397			})
8398			.expect("replacing an index should drop the previous definition");
8399		let create_position = operations
8400			.iter()
8401			.position(|operation| {
8402				matches!(
8403					operation,
8404					super::super::Operation::CreateIndex { .. }
8405						| super::super::Operation::CreateIndexRepair { .. }
8406				)
8407			})
8408			.expect("replacing an index should create the new definition");
8409		assert!(drop_position < create_position);
8410	}
8411
8412	#[rstest]
8413	fn apply_migration_operations_replays_omitted_foreign_key_actions_as_no_action() {
8414		// Arrange
8415		let create_posts = super::super::Operation::CreateTable {
8416			name: "blog_posts".to_string(),
8417			columns: vec![
8418				super::super::ColumnDefinition {
8419					name: "id".to_string(),
8420					type_definition: super::super::FieldType::BigInteger,
8421					not_null: true,
8422					unique: false,
8423					primary_key: true,
8424					auto_increment: true,
8425					default: None,
8426				},
8427				super::super::ColumnDefinition {
8428					name: "user_id".to_string(),
8429					type_definition: super::super::FieldType::BigInteger,
8430					not_null: true,
8431					unique: false,
8432					primary_key: false,
8433					auto_increment: false,
8434					default: None,
8435				},
8436			],
8437			constraints: vec![],
8438			without_rowid: None,
8439			interleave_in_parent: None,
8440			partition: None,
8441		};
8442		let add_user_fk = super::super::Operation::AddConstraint {
8443			table: "blog_posts".to_string(),
8444			constraint_sql:
8445				"CONSTRAINT blog_posts_user_id_fk FOREIGN KEY (user_id) REFERENCES auth_users(id)"
8446					.to_string(),
8447		};
8448		let mut state = ProjectState::new();
8449
8450		// Act
8451		state.apply_migration_operations(&[create_posts, add_user_fk], "blog");
8452
8453		// Assert
8454		let model = state
8455			.find_model_by_table("blog_posts")
8456			.expect("blog_posts model should be reconstructed");
8457		let fk_info = model
8458			.constraints
8459			.iter()
8460			.find(|constraint| constraint.name == "blog_posts_user_id_fk")
8461			.and_then(|constraint| constraint.foreign_key_info.as_ref())
8462			.expect("foreign key metadata should be reconstructed");
8463		assert_eq!(fk_info.on_delete, ForeignKeyAction::NoAction);
8464		assert_eq!(fk_info.on_update, ForeignKeyAction::NoAction);
8465	}
8466
8467	#[rstest]
8468	fn generate_operations_emits_rename_column_for_unambiguous_field_rename() {
8469		let from_model = build_model_state(
8470			"deployments",
8471			"Deployment",
8472			vec![
8473				FieldState::new("id", super::super::FieldType::Integer, false),
8474				FieldState::new("app_name", super::super::FieldType::VarChar(255), false),
8475			],
8476			Vec::new(),
8477			Vec::new(),
8478		);
8479		let to_model = build_model_state(
8480			"deployments",
8481			"Deployment",
8482			vec![
8483				FieldState::new("id", super::super::FieldType::Integer, false),
8484				FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
8485			],
8486			Vec::new(),
8487			Vec::new(),
8488		);
8489		let detector = MigrationAutodetector::new(
8490			build_project_state(vec![(
8491				("deployments".to_string(), "Deployment".to_string()),
8492				from_model,
8493			)]),
8494			build_project_state(vec![(
8495				("deployments".to_string(), "Deployment".to_string()),
8496				to_model,
8497			)]),
8498		);
8499
8500		let operations = detector
8501			.try_generate_operations()
8502			.expect("unambiguous rename should generate operations");
8503
8504		assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
8505		assert!(matches!(
8506			&operations[0],
8507			super::super::Operation::RenameColumn {
8508				table,
8509				old_name,
8510				new_name
8511			} if table == "deployments_deployment"
8512				&& old_name == "app_name"
8513				&& new_name == "project_name"
8514		));
8515	}
8516
8517	#[rstest]
8518	fn generate_operations_renames_unique_column_with_constraint_name_change() {
8519		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8520		let old_slug_field =
8521			FieldState::new("old_slug", super::super::FieldType::VarChar(255), false);
8522		let new_slug_field = FieldState::new("slug", super::super::FieldType::VarChar(255), false);
8523		let from_unique = ConstraintDefinition {
8524			name: "deployments_deployment_old_slug_uniq".to_string(),
8525			constraint_type: "unique".to_string(),
8526			fields: vec!["old_slug".to_string()],
8527			expression: None,
8528			foreign_key_info: None,
8529		};
8530		let to_unique = ConstraintDefinition {
8531			name: "deployments_deployment_slug_uniq".to_string(),
8532			constraint_type: "unique".to_string(),
8533			fields: vec!["slug".to_string()],
8534			expression: None,
8535			foreign_key_info: None,
8536		};
8537		let from_model = build_model_state(
8538			"deployments",
8539			"Deployment",
8540			vec![id_field.clone(), old_slug_field],
8541			Vec::new(),
8542			vec![from_unique],
8543		);
8544		let to_model = build_model_state(
8545			"deployments",
8546			"Deployment",
8547			vec![id_field, new_slug_field],
8548			Vec::new(),
8549			vec![to_unique],
8550		);
8551		let detector = MigrationAutodetector::new(
8552			build_project_state(vec![(
8553				("deployments".to_string(), "Deployment".to_string()),
8554				from_model,
8555			)]),
8556			build_project_state(vec![(
8557				("deployments".to_string(), "Deployment".to_string()),
8558				to_model,
8559			)]),
8560		);
8561
8562		let operations = detector
8563			.try_generate_operations()
8564			.expect("unique column rename should generate operations");
8565
8566		assert_eq!(operations.len(), 3, "unexpected operations: {operations:?}");
8567		assert!(matches!(
8568			&operations[0],
8569			super::super::Operation::RenameColumn {
8570				table,
8571				old_name,
8572				new_name
8573			} if table == "deployments_deployment"
8574				&& old_name == "old_slug"
8575				&& new_name == "slug"
8576		));
8577		assert!(matches!(
8578			&operations[1],
8579			super::super::Operation::DropConstraint { constraint_name, .. }
8580				if constraint_name == "deployments_deployment_old_slug_uniq"
8581		));
8582		assert!(matches!(
8583			&operations[2],
8584			super::super::Operation::AddConstraint { constraint_sql, .. }
8585				if constraint_sql == "CONSTRAINT deployments_deployment_slug_uniq UNIQUE (slug)"
8586		));
8587	}
8588
8589	#[rstest]
8590	fn generate_operations_renames_unique_column_from_inline_to_model_constraint() {
8591		// Arrange
8592		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
8593		let mut old_email_field =
8594			FieldState::new("old_email", super::super::FieldType::VarChar(255), false);
8595		old_email_field
8596			.params
8597			.insert("unique".to_string(), "true".to_string());
8598		let new_email_field =
8599			FieldState::new("email", super::super::FieldType::VarChar(255), false);
8600		let new_unique = ConstraintDefinition {
8601			name: "accounts_account_email_uniq".to_string(),
8602			constraint_type: "unique".to_string(),
8603			fields: vec!["email".to_string()],
8604			expression: None,
8605			foreign_key_info: None,
8606		};
8607		let from_model = build_model_state(
8608			"accounts",
8609			"Account",
8610			vec![id_field.clone(), old_email_field],
8611			Vec::new(),
8612			Vec::new(),
8613		);
8614		let to_model = build_model_state(
8615			"accounts",
8616			"Account",
8617			vec![id_field, new_email_field],
8618			Vec::new(),
8619			vec![new_unique],
8620		);
8621		let detector = MigrationAutodetector::new(
8622			build_project_state(vec![(
8623				("accounts".to_string(), "Account".to_string()),
8624				from_model,
8625			)]),
8626			build_project_state(vec![(
8627				("accounts".to_string(), "Account".to_string()),
8628				to_model,
8629			)]),
8630		);
8631
8632		// Act
8633		let operations = detector
8634			.try_generate_operations()
8635			.expect("unique field rename should generate operations");
8636
8637		// Assert
8638		assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
8639		assert!(matches!(
8640			&operations[0],
8641			super::super::Operation::RenameColumn {
8642				table,
8643				old_name,
8644				new_name
8645			} if table == "accounts_account"
8646				&& old_name == "old_email"
8647				&& new_name == "email"
8648		));
8649	}
8650
8651	#[rstest]
8652	fn generate_operations_detects_field_rename_from_offline_table_keyed_state() {
8653		let mut from_model = build_model_state(
8654			"deployments",
8655			"DeploymentsDeployment",
8656			vec![
8657				FieldState::new("id", super::super::FieldType::Integer, false),
8658				FieldState::new("reinhardt_app_yaml", super::super::FieldType::Text, false),
8659			],
8660			Vec::new(),
8661			Vec::new(),
8662		);
8663		from_model.table_name = "deployments_deployment".to_string();
8664		let to_model = build_model_state(
8665			"deployments",
8666			"Deployment",
8667			vec![
8668				FieldState::new("id", super::super::FieldType::Integer, false),
8669				FieldState::new("project_yaml", super::super::FieldType::Text, false),
8670			],
8671			Vec::new(),
8672			Vec::new(),
8673		);
8674		let detector = MigrationAutodetector::new(
8675			build_project_state(vec![(
8676				(
8677					"deployments".to_string(),
8678					"DeploymentsDeployment".to_string(),
8679				),
8680				from_model,
8681			)]),
8682			build_project_state(vec![(
8683				("deployments".to_string(), "Deployment".to_string()),
8684				to_model,
8685			)]),
8686		);
8687
8688		let migrations = detector
8689			.try_generate_migrations()
8690			.expect("table-name matched state should detect rename");
8691		let operations: Vec<_> = migrations
8692			.iter()
8693			.flat_map(|migration| migration.operations.iter())
8694			.collect();
8695
8696		assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
8697		assert!(matches!(
8698			operations[0],
8699			super::super::Operation::RenameColumn {
8700				table,
8701				old_name,
8702				new_name
8703			} if table == "deployments_deployment"
8704				&& old_name == "reinhardt_app_yaml"
8705				&& new_name == "project_yaml"
8706		));
8707	}
8708
8709	#[rstest]
8710	fn generate_migrations_renames_field_with_renamed_model() {
8711		let from_model = build_model_state(
8712			"deployments",
8713			"Deployment",
8714			vec![
8715				FieldState::new("id", super::super::FieldType::Integer, false),
8716				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8717				FieldState::new("app_name", super::super::FieldType::VarChar(255), false),
8718			],
8719			Vec::new(),
8720			Vec::new(),
8721		);
8722		let to_model = build_model_state(
8723			"deployments",
8724			"Project",
8725			vec![
8726				FieldState::new("id", super::super::FieldType::Integer, false),
8727				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8728				FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
8729			],
8730			Vec::new(),
8731			Vec::new(),
8732		);
8733		let detector = MigrationAutodetector::new(
8734			build_project_state(vec![(
8735				("deployments".to_string(), "Deployment".to_string()),
8736				from_model,
8737			)]),
8738			build_project_state(vec![(
8739				("deployments".to_string(), "Project".to_string()),
8740				to_model,
8741			)]),
8742		);
8743
8744		let migrations = detector
8745			.try_generate_migrations()
8746			.expect("model and field rename should generate migrations");
8747		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
8748		let operations = &migrations[0].operations;
8749
8750		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
8751		assert!(
8752			matches!(
8753				&operations[0],
8754				super::super::Operation::RenameTable { old_name, new_name }
8755					if old_name == "deployments_deployment"
8756						&& new_name == "deployments_project"
8757			),
8758			"RenameTable must precede new-table field operations: {operations:?}"
8759		);
8760		assert!(matches!(
8761			&operations[1],
8762			super::super::Operation::RenameColumn {
8763				table,
8764				old_name,
8765				new_name
8766			} if table == "deployments_project"
8767				&& old_name == "app_name"
8768				&& new_name == "project_name"
8769		));
8770		assert!(
8771			operations.iter().all(|operation| {
8772				!matches!(
8773					operation,
8774					super::super::Operation::AddColumn { .. }
8775						| super::super::Operation::DropColumn { .. }
8776				)
8777			}),
8778			"field rename on a renamed model must not degrade to AddColumn/DropColumn: {operations:?}"
8779		);
8780	}
8781
8782	#[rstest]
8783	fn generate_migrations_adds_field_after_renaming_model_table() {
8784		let from_model = build_model_state(
8785			"deployments",
8786			"Deployment",
8787			vec![
8788				FieldState::new("id", super::super::FieldType::Integer, false),
8789				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8790				FieldState::new("updated_at", super::super::FieldType::DateTime, false),
8791			],
8792			Vec::new(),
8793			Vec::new(),
8794		);
8795		let to_model = build_model_state(
8796			"deployments",
8797			"Project",
8798			vec![
8799				FieldState::new("id", super::super::FieldType::Integer, false),
8800				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8801				FieldState::new("updated_at", super::super::FieldType::DateTime, false),
8802				FieldState::new("project_name", super::super::FieldType::VarChar(255), true),
8803			],
8804			Vec::new(),
8805			Vec::new(),
8806		);
8807		let detector = MigrationAutodetector::new(
8808			build_project_state(vec![(
8809				("deployments".to_string(), "Deployment".to_string()),
8810				from_model,
8811			)]),
8812			build_project_state(vec![(
8813				("deployments".to_string(), "Project".to_string()),
8814				to_model,
8815			)]),
8816		);
8817
8818		let migrations = detector
8819			.try_generate_migrations()
8820			.expect("model rename with added field should generate migrations");
8821		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
8822		let operations = &migrations[0].operations;
8823
8824		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
8825		assert!(
8826			matches!(
8827				&operations[0],
8828				super::super::Operation::RenameTable { old_name, new_name }
8829					if old_name == "deployments_deployment"
8830						&& new_name == "deployments_project"
8831			),
8832			"RenameTable must precede new-table field operations: {operations:?}"
8833		);
8834		assert!(matches!(
8835			&operations[1],
8836			super::super::Operation::AddColumn { table, column, .. }
8837				if table == "deployments_project" && column.name == "project_name"
8838		));
8839	}
8840
8841	#[rstest]
8842	fn generate_migrations_keeps_old_table_drop_before_renaming_model_table() {
8843		let from_model = build_model_state(
8844			"deployments",
8845			"Deployment",
8846			vec![
8847				FieldState::new("id", super::super::FieldType::Integer, false),
8848				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8849				FieldState::new("updated_at", super::super::FieldType::DateTime, false),
8850				FieldState::new("legacy_payload", super::super::FieldType::Text, true),
8851			],
8852			Vec::new(),
8853			Vec::new(),
8854		);
8855		let to_model = build_model_state(
8856			"deployments",
8857			"Project",
8858			vec![
8859				FieldState::new("id", super::super::FieldType::Integer, false),
8860				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8861				FieldState::new("updated_at", super::super::FieldType::DateTime, false),
8862				FieldState::new("retry_count", super::super::FieldType::Integer, false),
8863			],
8864			Vec::new(),
8865			Vec::new(),
8866		);
8867		let detector = MigrationAutodetector::new(
8868			build_project_state(vec![(
8869				("deployments".to_string(), "Deployment".to_string()),
8870				from_model,
8871			)]),
8872			build_project_state(vec![(
8873				("deployments".to_string(), "Project".to_string()),
8874				to_model,
8875			)]),
8876		);
8877
8878		let migrations = detector
8879			.try_generate_migrations()
8880			.expect("model rename with old-table drop should generate migrations");
8881		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
8882		let operations = &migrations[0].operations;
8883
8884		assert_eq!(operations.len(), 3, "unexpected operations: {operations:?}");
8885		assert!(matches!(
8886			&operations[0],
8887			super::super::Operation::DropColumn { table, column }
8888				if table == "deployments_deployment" && column == "legacy_payload"
8889		));
8890		assert!(matches!(
8891			&operations[1],
8892			super::super::Operation::RenameTable { old_name, new_name }
8893				if old_name == "deployments_deployment" && new_name == "deployments_project"
8894		));
8895		assert!(matches!(
8896			&operations[2],
8897			super::super::Operation::AddColumn { table, column, .. }
8898				if table == "deployments_project" && column.name == "retry_count"
8899		));
8900	}
8901
8902	#[rstest]
8903	fn generate_migrations_adds_constraint_after_renaming_model_table() {
8904		let from_model = build_model_state(
8905			"deployments",
8906			"Deployment",
8907			vec![
8908				FieldState::new("id", super::super::FieldType::Integer, false),
8909				FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
8910			],
8911			Vec::new(),
8912			Vec::new(),
8913		);
8914		let to_model = build_model_state(
8915			"deployments",
8916			"Project",
8917			vec![
8918				FieldState::new("id", super::super::FieldType::Integer, false),
8919				FieldState::new("project_name", super::super::FieldType::VarChar(255), false),
8920			],
8921			Vec::new(),
8922			vec![ConstraintDefinition {
8923				name: "deployments_project_project_name_not_empty".to_string(),
8924				constraint_type: "check".to_string(),
8925				fields: vec!["project_name".to_string()],
8926				expression: Some("project_name <> ''".to_string()),
8927				foreign_key_info: None,
8928			}],
8929		);
8930		let detector = MigrationAutodetector::new(
8931			build_project_state(vec![(
8932				("deployments".to_string(), "Deployment".to_string()),
8933				from_model,
8934			)]),
8935			build_project_state(vec![(
8936				("deployments".to_string(), "Project".to_string()),
8937				to_model,
8938			)]),
8939		);
8940
8941		let migrations = detector
8942			.try_generate_migrations()
8943			.expect("model rename with added constraint should generate migrations");
8944		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
8945		let operations = &migrations[0].operations;
8946
8947		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
8948		assert!(matches!(
8949			&operations[0],
8950			super::super::Operation::RenameTable { old_name, new_name }
8951				if old_name == "deployments_deployment" && new_name == "deployments_project"
8952		));
8953		assert!(matches!(
8954			&operations[1],
8955			super::super::Operation::AddConstraint {
8956				table,
8957				constraint_sql
8958			} if table == "deployments_project"
8959				&& constraint_sql.contains("deployments_project_project_name_not_empty")
8960				&& constraint_sql.contains("CHECK")
8961				&& constraint_sql.contains("project_name")
8962		));
8963	}
8964
8965	#[rstest]
8966	fn generate_migrations_preserves_field_changes_for_cross_app_move() {
8967		let from_model = build_model_state(
8968			"legacy",
8969			"Deployment",
8970			vec![
8971				FieldState::new("id", super::super::FieldType::Integer, false),
8972				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8973				FieldState::new("updated_at", super::super::FieldType::DateTime, false),
8974				FieldState::new("status", super::super::FieldType::VarChar(32), false),
8975			],
8976			Vec::new(),
8977			Vec::new(),
8978		);
8979		let to_model = build_model_state(
8980			"deployments",
8981			"Project",
8982			vec![
8983				FieldState::new("id", super::super::FieldType::Integer, false),
8984				FieldState::new("created_at", super::super::FieldType::DateTime, false),
8985				FieldState::new("updated_at", super::super::FieldType::DateTime, false),
8986				FieldState::new("status", super::super::FieldType::VarChar(32), false),
8987				FieldState::new("project_name", super::super::FieldType::VarChar(255), true),
8988			],
8989			Vec::new(),
8990			Vec::new(),
8991		);
8992		let detector = MigrationAutodetector::new(
8993			build_project_state(vec![(
8994				("legacy".to_string(), "Deployment".to_string()),
8995				from_model,
8996			)]),
8997			build_project_state(vec![(
8998				("deployments".to_string(), "Project".to_string()),
8999				to_model,
9000			)]),
9001		);
9002
9003		let migrations = detector
9004			.try_generate_migrations()
9005			.expect("cross-app model move with added field should generate migrations");
9006		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
9007		assert_eq!(migrations[0].app_label, "deployments");
9008		let operations = &migrations[0].operations;
9009
9010		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
9011		assert!(matches!(
9012			&operations[0],
9013			super::super::Operation::MoveModel {
9014				model_name,
9015				from_app,
9016				to_app,
9017				rename_table: true,
9018				old_table_name: Some(old_table),
9019				new_table_name: Some(new_table)
9020			} if model_name == "Deployment"
9021				&& from_app == "legacy"
9022				&& to_app == "deployments"
9023				&& old_table == "legacy_deployment"
9024				&& new_table == "deployments_project"
9025		));
9026		assert!(matches!(
9027			&operations[1],
9028			super::super::Operation::AddColumn { table, column, .. }
9029				if table == "deployments_project" && column.name == "project_name"
9030		));
9031	}
9032
9033	#[rstest]
9034	fn generate_migrations_orders_referenced_table_constraint_after_rename() {
9035		let from_account = build_model_state(
9036			"crm",
9037			"User",
9038			vec![
9039				FieldState::new("id", super::super::FieldType::Integer, false),
9040				FieldState::new("email", super::super::FieldType::VarChar(255), false),
9041			],
9042			Vec::new(),
9043			Vec::new(),
9044		);
9045		let from_profile = build_model_state(
9046			"crm",
9047			"Profile",
9048			vec![
9049				FieldState::new("id", super::super::FieldType::Integer, false),
9050				FieldState::new("account_id", super::super::FieldType::Integer, false),
9051			],
9052			Vec::new(),
9053			Vec::new(),
9054		);
9055		let to_account = build_model_state(
9056			"crm",
9057			"Account",
9058			vec![
9059				FieldState::new("id", super::super::FieldType::Integer, false),
9060				FieldState::new("email", super::super::FieldType::VarChar(255), false),
9061			],
9062			Vec::new(),
9063			Vec::new(),
9064		);
9065		let to_profile = build_model_state(
9066			"crm",
9067			"Profile",
9068			vec![
9069				FieldState::new("id", super::super::FieldType::Integer, false),
9070				FieldState::new("account_id", super::super::FieldType::Integer, false),
9071			],
9072			Vec::new(),
9073			vec![ConstraintDefinition {
9074				name: "crm_profile_account_id_fk".to_string(),
9075				constraint_type: "foreign_key".to_string(),
9076				fields: vec!["account_id".to_string()],
9077				expression: None,
9078				foreign_key_info: Some(ForeignKeyConstraintInfo {
9079					referenced_table: "crm_account".to_string(),
9080					referenced_columns: vec!["id".to_string()],
9081					on_delete: ForeignKeyAction::Cascade,
9082					on_update: ForeignKeyAction::Cascade,
9083				}),
9084			}],
9085		);
9086		let detector = MigrationAutodetector::new(
9087			build_project_state(vec![
9088				(("crm".to_string(), "User".to_string()), from_account),
9089				(("crm".to_string(), "Profile".to_string()), from_profile),
9090			]),
9091			build_project_state(vec![
9092				(("crm".to_string(), "Account".to_string()), to_account),
9093				(("crm".to_string(), "Profile".to_string()), to_profile),
9094			]),
9095		);
9096
9097		let migrations = detector
9098			.try_generate_migrations()
9099			.expect("referenced table rename with added FK should generate migrations");
9100		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
9101		let operations = &migrations[0].operations;
9102
9103		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
9104		assert!(matches!(
9105			&operations[0],
9106			super::super::Operation::RenameTable { old_name, new_name }
9107				if old_name == "crm_user" && new_name == "crm_account"
9108		));
9109		assert!(matches!(
9110			&operations[1],
9111			super::super::Operation::AddConstraint {
9112				table,
9113				constraint_sql
9114			} if table == "crm_profile"
9115				&& constraint_sql.contains("crm_profile_account_id_fk")
9116				&& constraint_sql.contains("REFERENCES crm_account")
9117		));
9118	}
9119
9120	#[rstest]
9121	fn try_generate_operations_rejects_ambiguous_field_rename_candidates() {
9122		let from_model = build_model_state(
9123			"projects",
9124			"Project",
9125			vec![
9126				FieldState::new("old_code", super::super::FieldType::VarChar(255), false),
9127				FieldState::new("legacy_code", super::super::FieldType::VarChar(255), false),
9128			],
9129			Vec::new(),
9130			Vec::new(),
9131		);
9132		let to_model = build_model_state(
9133			"projects",
9134			"Project",
9135			vec![FieldState::new(
9136				"project_code",
9137				super::super::FieldType::VarChar(255),
9138				false,
9139			)],
9140			Vec::new(),
9141			Vec::new(),
9142		);
9143		let detector = MigrationAutodetector::new(
9144			build_project_state(vec![(
9145				("projects".to_string(), "Project".to_string()),
9146				from_model,
9147			)]),
9148			build_project_state(vec![(
9149				("projects".to_string(), "Project".to_string()),
9150				to_model,
9151			)]),
9152		);
9153
9154		let error = detector
9155			.try_generate_operations()
9156			.expect_err("ambiguous rename candidates must fail");
9157		let message = error.to_string();
9158
9159		assert!(
9160			message.contains("Ambiguous field rename candidates"),
9161			"unexpected error: {message}"
9162		);
9163		assert!(
9164			message.contains("legacy_code")
9165				&& message.contains("old_code")
9166				&& message.contains("project_code"),
9167			"error should name candidate fields: {message}"
9168		);
9169	}
9170
9171	#[rstest]
9172	fn try_generate_operations_preserves_unrelated_add_and_drop() {
9173		let from_model = build_model_state(
9174			"projects",
9175			"Project",
9176			vec![FieldState::new(
9177				"legacy_payload",
9178				super::super::FieldType::Text,
9179				true,
9180			)],
9181			Vec::new(),
9182			Vec::new(),
9183		);
9184		let to_model = build_model_state(
9185			"projects",
9186			"Project",
9187			vec![FieldState::new(
9188				"retry_count",
9189				super::super::FieldType::Integer,
9190				false,
9191			)],
9192			Vec::new(),
9193			Vec::new(),
9194		);
9195		let detector = MigrationAutodetector::new(
9196			build_project_state(vec![(
9197				("projects".to_string(), "Project".to_string()),
9198				from_model,
9199			)]),
9200			build_project_state(vec![(
9201				("projects".to_string(), "Project".to_string()),
9202				to_model,
9203			)]),
9204		);
9205
9206		let operations = detector
9207			.try_generate_operations()
9208			.expect("unrelated add/drop should remain valid");
9209
9210		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
9211		assert!(
9212			operations.iter().any(|op| matches!(
9213				op,
9214				super::super::Operation::AddColumn { column, .. } if column.name == "retry_count"
9215			)),
9216			"expected AddColumn, got: {operations:?}"
9217		);
9218		assert!(
9219			operations.iter().any(|op| matches!(
9220				op,
9221				super::super::Operation::DropColumn { column, .. } if column == "legacy_payload"
9222			)),
9223			"expected DropColumn, got: {operations:?}"
9224		);
9225		assert!(
9226			operations
9227				.iter()
9228				.all(|op| !matches!(op, super::super::Operation::RenameColumn { .. })),
9229			"unrelated add/drop must not be collapsed into RenameColumn: {operations:?}"
9230		);
9231	}
9232
9233	#[rstest]
9234	fn to_database_schema_uses_app_prefixed_table_key() {
9235		// Arrange
9236		let model = build_model_state(
9237			"blog",
9238			"Post",
9239			vec![FieldState::new(
9240				"id",
9241				super::super::FieldType::Integer,
9242				false,
9243			)],
9244			Vec::new(),
9245			Vec::new(),
9246		);
9247		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
9248
9249		// Act
9250		let schema = state.to_database_schema();
9251
9252		// Assert
9253		assert_eq!(schema.tables.len(), 1);
9254		assert!(
9255			schema.tables.contains_key("blog_post"),
9256			"table key should be app_label + '_' + lowercase model name"
9257		);
9258		let table = &schema.tables["blog_post"];
9259		assert_eq!(table.name, "blog_post");
9260	}
9261
9262	#[rstest]
9263	fn to_database_schema_prevents_cross_app_collision() {
9264		// Arrange
9265		// Two different apps with identically named models
9266		let blog_user = build_model_state(
9267			"blog",
9268			"User",
9269			vec![FieldState::new(
9270				"id",
9271				super::super::FieldType::Integer,
9272				false,
9273			)],
9274			Vec::new(),
9275			Vec::new(),
9276		);
9277		let auth_user = build_model_state(
9278			"auth",
9279			"User",
9280			vec![FieldState::new(
9281				"id",
9282				super::super::FieldType::Integer,
9283				false,
9284			)],
9285			Vec::new(),
9286			Vec::new(),
9287		);
9288		let state = build_project_state(vec![
9289			(("blog".to_string(), "User".to_string()), blog_user),
9290			(("auth".to_string(), "User".to_string()), auth_user),
9291		]);
9292
9293		// Act
9294		let schema = state.to_database_schema();
9295
9296		// Assert
9297		assert_eq!(schema.tables.len(), 2);
9298		assert!(schema.tables.contains_key("blog_user"));
9299		assert!(schema.tables.contains_key("auth_user"));
9300	}
9301
9302	#[rstest]
9303	fn to_database_schema_propagates_indexes() {
9304		// Arrange
9305		let indexes = vec![
9306			IndexDefinition {
9307				name: "idx_title".to_string(),
9308				fields: vec!["title".to_string()],
9309				unique: false,
9310				where_clause: None,
9311				index_type: None,
9312				expressions: None,
9313				concurrently: false,
9314				mysql_options: None,
9315				operator_class: None,
9316			},
9317			IndexDefinition {
9318				name: "idx_slug_unique".to_string(),
9319				fields: vec!["slug".to_string()],
9320				unique: true,
9321				where_clause: None,
9322				index_type: None,
9323				expressions: None,
9324				concurrently: false,
9325				mysql_options: None,
9326				operator_class: None,
9327			},
9328		];
9329		let model = build_model_state(
9330			"blog",
9331			"Post",
9332			vec![
9333				FieldState::new("title", super::super::FieldType::VarChar(255), false),
9334				FieldState::new("slug", super::super::FieldType::VarChar(100), false),
9335			],
9336			indexes,
9337			Vec::new(),
9338		);
9339		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
9340
9341		// Act
9342		let schema = state.to_database_schema();
9343
9344		// Assert
9345		let table = &schema.tables["blog_post"];
9346		assert_eq!(table.indexes.len(), 2);
9347		assert_eq!(table.indexes[0].name, "idx_title");
9348		assert_eq!(table.indexes[0].columns, vec!["title".to_string()]);
9349		assert!(!table.indexes[0].unique);
9350		assert_eq!(table.indexes[1].name, "idx_slug_unique");
9351		assert!(table.indexes[1].unique);
9352	}
9353
9354	#[rstest]
9355	fn to_database_schema_propagates_constraints() {
9356		// Arrange
9357		let constraints = vec![ConstraintDefinition {
9358			name: "uq_email".to_string(),
9359			constraint_type: "unique".to_string(),
9360			fields: vec!["email".to_string()],
9361			expression: None,
9362			foreign_key_info: None,
9363		}];
9364		let model = build_model_state(
9365			"auth",
9366			"Account",
9367			vec![FieldState::new(
9368				"email",
9369				super::super::FieldType::VarChar(255),
9370				false,
9371			)],
9372			Vec::new(),
9373			constraints,
9374		);
9375		let state = build_project_state(vec![(("auth".to_string(), "Account".to_string()), model)]);
9376
9377		// Act
9378		let schema = state.to_database_schema();
9379
9380		// Assert
9381		let table = &schema.tables["auth_account"];
9382		assert_eq!(table.constraints.len(), 1);
9383		assert_eq!(table.constraints[0].name, "uq_email");
9384		assert_eq!(table.constraints[0].constraint_type, "unique");
9385		assert_eq!(table.constraints[0].definition, "email");
9386	}
9387
9388	#[rstest]
9389	fn to_database_schema_maps_field_params() {
9390		// Arrange
9391		let mut field = FieldState::new("id", super::super::FieldType::Integer, false);
9392		field
9393			.params
9394			.insert("primary_key".to_string(), "true".to_string());
9395		field
9396			.params
9397			.insert("auto_increment".to_string(), "true".to_string());
9398		field.params.insert("default".to_string(), "0".to_string());
9399
9400		let mut nullable_field = FieldState::new("bio", super::super::FieldType::Text, true);
9401		nullable_field
9402			.params
9403			.insert("default".to_string(), "''".to_string());
9404
9405		let model = build_model_state(
9406			"users",
9407			"Profile",
9408			vec![field, nullable_field],
9409			Vec::new(),
9410			Vec::new(),
9411		);
9412		let state =
9413			build_project_state(vec![(("users".to_string(), "Profile".to_string()), model)]);
9414
9415		// Act
9416		let schema = state.to_database_schema();
9417
9418		// Assert
9419		let table = &schema.tables["users_profile"];
9420		let id_col = &table.columns["id"];
9421		assert!(id_col.primary_key);
9422		assert!(id_col.auto_increment);
9423		assert_eq!(id_col.default, Some("0".to_string()));
9424		assert!(!id_col.nullable);
9425
9426		let bio_col = &table.columns["bio"];
9427		assert!(!bio_col.primary_key);
9428		assert!(!bio_col.auto_increment);
9429		assert!(bio_col.nullable);
9430		assert_eq!(bio_col.default, Some("''".to_string()));
9431	}
9432
9433	#[rstest]
9434	fn to_database_schema_for_app_filters_by_app_label() {
9435		// Arrange
9436		let blog_post = build_model_state(
9437			"blog",
9438			"Post",
9439			vec![FieldState::new(
9440				"id",
9441				super::super::FieldType::Integer,
9442				false,
9443			)],
9444			Vec::new(),
9445			Vec::new(),
9446		);
9447		let auth_user = build_model_state(
9448			"auth",
9449			"User",
9450			vec![FieldState::new(
9451				"id",
9452				super::super::FieldType::Integer,
9453				false,
9454			)],
9455			Vec::new(),
9456			Vec::new(),
9457		);
9458		let state = build_project_state(vec![
9459			(("blog".to_string(), "Post".to_string()), blog_post),
9460			(("auth".to_string(), "User".to_string()), auth_user),
9461		]);
9462
9463		// Act
9464		let blog_schema = state.to_database_schema_for_app("blog");
9465		let auth_schema = state.to_database_schema_for_app("auth");
9466		let empty_schema = state.to_database_schema_for_app("nonexistent");
9467
9468		// Assert
9469		assert_eq!(blog_schema.tables.len(), 1);
9470		assert!(blog_schema.tables.contains_key("blog_post"));
9471
9472		assert_eq!(auth_schema.tables.len(), 1);
9473		assert!(auth_schema.tables.contains_key("auth_user"));
9474
9475		assert_eq!(empty_schema.tables.len(), 0);
9476	}
9477
9478	#[rstest]
9479	fn to_database_schema_for_app_propagates_indexes_and_constraints() {
9480		// Arrange
9481		let indexes = vec![IndexDefinition {
9482			name: "idx_created".to_string(),
9483			fields: vec!["created_at".to_string()],
9484			unique: false,
9485			where_clause: None,
9486			index_type: None,
9487			expressions: None,
9488			concurrently: false,
9489			mysql_options: None,
9490			operator_class: None,
9491		}];
9492		let constraints = vec![ConstraintDefinition {
9493			name: "ck_status".to_string(),
9494			constraint_type: "check".to_string(),
9495			fields: vec!["status".to_string()],
9496			expression: Some("status IN ('draft', 'published')".to_string()),
9497			foreign_key_info: None,
9498		}];
9499		let model = build_model_state(
9500			"blog",
9501			"Post",
9502			vec![
9503				FieldState::new("created_at", super::super::FieldType::DateTime, false),
9504				FieldState::new("status", super::super::FieldType::VarChar(20), false),
9505			],
9506			indexes,
9507			constraints,
9508		);
9509		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
9510
9511		// Act
9512		let schema = state.to_database_schema_for_app("blog");
9513
9514		// Assert
9515		let table = &schema.tables["blog_post"];
9516		assert_eq!(table.indexes.len(), 1);
9517		assert_eq!(table.indexes[0].name, "idx_created");
9518		assert_eq!(table.indexes[0].columns, vec!["created_at".to_string()]);
9519
9520		assert_eq!(table.constraints.len(), 1);
9521		assert_eq!(table.constraints[0].name, "ck_status");
9522		assert_eq!(table.constraints[0].constraint_type, "check");
9523		assert_eq!(table.constraints[0].definition, "status");
9524	}
9525
9526	/// Helper to build a ModelState with a custom table name
9527	fn build_model_state_with_table_name(
9528		app_label: &str,
9529		name: &str,
9530		table_name: &str,
9531		fields: Vec<FieldState>,
9532	) -> ModelState {
9533		let mut field_map = std::collections::BTreeMap::new();
9534		for f in fields {
9535			field_map.insert(f.name.clone(), f);
9536		}
9537		ModelState {
9538			app_label: app_label.to_string(),
9539			name: name.to_string(),
9540			table_name: table_name.to_string(),
9541			fields: field_map,
9542			options: std::collections::HashMap::new(),
9543			base_model: None,
9544			inheritance_type: None,
9545			discriminator_column: None,
9546			indexes: Vec::new(),
9547			constraints: Vec::new(),
9548			many_to_many_fields: Vec::new(),
9549		}
9550	}
9551
9552	#[rstest]
9553	fn to_database_schema_respects_custom_table_name() {
9554		// Arrange
9555		let model = build_model_state_with_table_name(
9556			"blog",
9557			"Post",
9558			"custom_posts_table",
9559			vec![FieldState::new(
9560				"id",
9561				super::super::FieldType::Integer,
9562				false,
9563			)],
9564		);
9565		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
9566
9567		// Act
9568		let schema = state.to_database_schema();
9569
9570		// Assert
9571		// The HashMap key should still be the auto-generated key
9572		assert!(schema.tables.contains_key("blog_post"));
9573		// But the TableSchema.name should use the custom table name
9574		let table = &schema.tables["blog_post"];
9575		assert_eq!(table.name, "custom_posts_table");
9576	}
9577
9578	#[rstest]
9579	fn to_database_schema_for_app_respects_custom_table_name() {
9580		// Arrange
9581		let model = build_model_state_with_table_name(
9582			"blog",
9583			"Post",
9584			"custom_posts_table",
9585			vec![FieldState::new(
9586				"id",
9587				super::super::FieldType::Integer,
9588				false,
9589			)],
9590		);
9591		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
9592
9593		// Act
9594		let schema = state.to_database_schema_for_app("blog");
9595
9596		// Assert
9597		assert!(schema.tables.contains_key("blog_post"));
9598		let table = &schema.tables["blog_post"];
9599		assert_eq!(table.name, "custom_posts_table");
9600	}
9601
9602	// --- Tests for #3204: no-op migration detection ---
9603
9604	/// Build fields commonly used in #3204 tests
9605	fn sample_fields() -> Vec<FieldState> {
9606		vec![
9607			FieldState::new("id", super::super::FieldType::Integer, false),
9608			FieldState::new("name", super::super::FieldType::VarChar(255), false),
9609		]
9610	}
9611
9612	/// Regression test for issue #4659.
9613	///
9614	/// Scenario: a model struct is renamed (`Room` -> `DMRoom`) but the
9615	/// `table_name` stays the same (`dm_room`), and an M2M through-table
9616	/// (`dm_room_members`) already exists on disk. The state reconstructed
9617	/// from on-disk migrations loses the original struct identifier (it
9618	/// derives a `RoomMembers`-style approximation via
9619	/// `table_name_to_model_name`) and the M2M metadata is gone — only the
9620	/// raw through-table survives.
9621	///
9622	/// Before the fix, `detect_created_many_to_many` keyed its existence
9623	/// check on `(app_label, model_name)` and inspected
9624	/// `many_to_many_fields`, which is always empty on reconstructed
9625	/// states, so the through-table was spuriously re-created on every
9626	/// incremental `makemigrations` run.
9627	#[rstest]
9628	fn detect_created_many_to_many_recognises_existing_through_table_by_table_name() {
9629		use super::super::model_registry::ManyToManyMetadata;
9630
9631		// Arrange: from_state mimics the on-disk reconstruction.
9632		// `table_name_to_model_name` produces `Room` / `RoomMembers` for
9633		// tables `dm_room` / `dm_room_members`. The through table appears
9634		// as an ordinary model with no M2M metadata attached.
9635		let from_room = build_model_state_with_table_name("dm", "Room", "dm_room", sample_fields());
9636		let from_through = build_model_state_with_table_name(
9637			"dm",
9638			"RoomMembers",
9639			"dm_room_members",
9640			sample_fields(),
9641		);
9642		let from_state = build_project_state(vec![
9643			(("dm".to_string(), "Room".to_string()), from_room),
9644			(("dm".to_string(), "RoomMembers".to_string()), from_through),
9645		]);
9646
9647		// to_state: the renamed struct (`DMRoom`) re-declares the same
9648		// M2M field. The synthetic through-table model (`DMRoomMembers`)
9649		// the macro emits keeps the canonical `dm_room_members` table
9650		// name.
9651		let mut to_room =
9652			build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
9653		to_room
9654			.many_to_many_fields
9655			.push(ManyToManyMetadata::new("members", "User"));
9656		let to_through = build_model_state_with_table_name(
9657			"dm",
9658			"DMRoomMembers",
9659			"dm_room_members",
9660			sample_fields(),
9661		);
9662		let to_state = build_project_state(vec![
9663			(("dm".to_string(), "DMRoom".to_string()), to_room),
9664			(("dm".to_string(), "DMRoomMembers".to_string()), to_through),
9665		]);
9666
9667		let detector = MigrationAutodetector::new(from_state, to_state);
9668
9669		// Act
9670		let changes = detector.detect_changes();
9671
9672		// Assert: the through table is already present in from_state, so
9673		// the autodetector must not re-create it (#4659).
9674		assert!(
9675			changes.created_many_to_many.is_empty(),
9676			"M2M through table already exists in from_state; expected no \
9677			 created_many_to_many, got {:?}",
9678			changes.created_many_to_many
9679		);
9680	}
9681
9682	#[rstest]
9683	fn generate_migrations_resolves_unqualified_many_to_many_target_across_apps() {
9684		use super::super::Operation;
9685		use super::super::model_registry::ManyToManyMetadata;
9686		use super::super::operations::Constraint;
9687
9688		let auth_user =
9689			build_model_state_with_table_name("auth", "User", "auth_user", sample_fields());
9690		let mut dm_room =
9691			build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
9692		dm_room
9693			.many_to_many_fields
9694			.push(ManyToManyMetadata::new("members", "User"));
9695		let to_state = build_project_state(vec![
9696			(("auth".to_string(), "User".to_string()), auth_user),
9697			(("dm".to_string(), "DMRoom".to_string()), dm_room),
9698		]);
9699		let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
9700
9701		let migrations = detector.generate_migrations();
9702		let dm_migration = migrations
9703			.iter()
9704			.find(|migration| migration.app_label == "dm")
9705			.expect("dm migration should be generated");
9706		let Operation::CreateTable {
9707			name, constraints, ..
9708		} = dm_migration
9709			.operations
9710			.iter()
9711			.find(|operation| {
9712				matches!(
9713					operation,
9714					Operation::CreateTable { name, .. } if name == "dm_room_members"
9715				)
9716			})
9717			.expect("dm_room_members through table should be generated")
9718		else {
9719			panic!("expected CreateTable operation");
9720		};
9721		assert_eq!(name, "dm_room_members");
9722
9723		let target_fk = constraints
9724			.iter()
9725			.find_map(|constraint| match constraint {
9726				Constraint::ForeignKey {
9727					columns,
9728					referenced_table,
9729					..
9730				} if columns == &vec!["auth_user_id".to_string()] => Some(referenced_table),
9731				_ => None,
9732			})
9733			.expect("auth_user_id foreign key should be generated");
9734		assert_eq!(target_fk, "auth_user");
9735	}
9736
9737	#[rstest]
9738	fn detect_created_many_to_many_skips_existing_to_state_through_table() {
9739		use super::super::model_registry::ManyToManyMetadata;
9740
9741		let auth_user =
9742			build_model_state_with_table_name("auth", "User", "auth_user", sample_fields());
9743		let mut dm_room =
9744			build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
9745		dm_room
9746			.many_to_many_fields
9747			.push(ManyToManyMetadata::new("members", "User"));
9748		let dm_room_members = build_model_state_with_table_name(
9749			"dm",
9750			"DMRoomMembers",
9751			"dm_room_members",
9752			sample_fields(),
9753		);
9754		let to_state = build_project_state(vec![
9755			(("auth".to_string(), "User".to_string()), auth_user),
9756			(("dm".to_string(), "DMRoom".to_string()), dm_room),
9757			(
9758				("dm".to_string(), "DMRoomMembers".to_string()),
9759				dm_room_members,
9760			),
9761		]);
9762		let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
9763
9764		let changes = detector.detect_changes();
9765
9766		assert!(
9767			changes.created_many_to_many.is_empty(),
9768			"to_state already contains the through table model; expected no \
9769			 synthetic created_many_to_many, got {:?}",
9770			changes.created_many_to_many
9771		);
9772	}
9773
9774	#[rstest]
9775	fn detect_renamed_models_skips_struct_only_rename_with_same_table_name() {
9776		// Arrange: struct name changed (Clusters -> Cluster) but table name is the same
9777		let from_model =
9778			build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
9779		let to_model =
9780			build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
9781
9782		let from_state = build_project_state(vec![(
9783			("myapp".to_string(), "Clusters".to_string()),
9784			from_model,
9785		)]);
9786		let to_state = build_project_state(vec![(
9787			("myapp".to_string(), "Cluster".to_string()),
9788			to_model,
9789		)]);
9790
9791		let detector = MigrationAutodetector::new(from_state, to_state);
9792
9793		// Act
9794		let changes = detector.detect_changes();
9795
9796		// Assert: no rename should be detected
9797		assert!(
9798			changes.renamed_models.is_empty(),
9799			"struct-only rename with same table name should not produce renamed_models"
9800		);
9801	}
9802
9803	#[rstest]
9804	fn detect_renamed_models_detects_actual_table_rename() {
9805		// Arrange: struct name changed AND table name changed
9806		let from_model =
9807			build_model_state_with_table_name("myapp", "OldModel", "old_table", sample_fields());
9808		let to_model =
9809			build_model_state_with_table_name("myapp", "NewModel", "new_table", sample_fields());
9810
9811		let from_state = build_project_state(vec![(
9812			("myapp".to_string(), "OldModel".to_string()),
9813			from_model,
9814		)]);
9815		let to_state = build_project_state(vec![(
9816			("myapp".to_string(), "NewModel".to_string()),
9817			to_model,
9818		)]);
9819
9820		let detector = MigrationAutodetector::new(from_state, to_state);
9821
9822		// Act
9823		let changes = detector.detect_changes();
9824		let migrations = detector.generate_migrations();
9825
9826		// Assert: rename should be detected and emitted as a single table
9827		// rename, matching Django's RenameModel-vs-create/drop contract.
9828		assert_eq!(
9829			changes.renamed_models.len(),
9830			1,
9831			"actual table rename should be detected"
9832		);
9833		assert_eq!(changes.renamed_models[0].1, "OldModel");
9834		assert_eq!(changes.renamed_models[0].2, "NewModel");
9835		assert!(
9836			changes.created_models.is_empty(),
9837			"confirmed model rename must not leave created_models noise: {:?}",
9838			changes.created_models
9839		);
9840		assert!(
9841			changes.deleted_models.is_empty(),
9842			"confirmed model rename must not leave deleted_models noise: {:?}",
9843			changes.deleted_models
9844		);
9845		assert_eq!(migrations.len(), 1, "unexpected migrations: {migrations:?}");
9846		assert_eq!(migrations[0].app_label, "myapp");
9847		let operations = &migrations[0].operations;
9848		assert_eq!(operations.len(), 1, "unexpected operations: {operations:?}");
9849		assert!(matches!(
9850			&operations[0],
9851			super::super::Operation::RenameTable { old_name, new_name }
9852				if old_name == "old_table" && new_name == "new_table"
9853		));
9854	}
9855
9856	#[rstest]
9857	fn table_rename_recreates_single_field_unique_constraint_with_new_name() {
9858		// Arrange
9859		let email = FieldState::new("email", super::super::FieldType::VarChar(255), false);
9860		let old_constraint = ConstraintDefinition {
9861			name: "old_table_email_uniq".to_string(),
9862			constraint_type: "unique".to_string(),
9863			fields: vec!["email".to_string()],
9864			expression: None,
9865			foreign_key_info: None,
9866		};
9867		let new_constraint = ConstraintDefinition {
9868			name: "new_table_email_uniq".to_string(),
9869			..old_constraint.clone()
9870		};
9871		let mut from_model = build_model_state_with_table_name(
9872			"myapp",
9873			"OldModel",
9874			"old_table",
9875			vec![email.clone()],
9876		);
9877		from_model.constraints.push(old_constraint);
9878		let mut to_model =
9879			build_model_state_with_table_name("myapp", "NewModel", "new_table", vec![email]);
9880		to_model.constraints.push(new_constraint.clone());
9881		let detector = MigrationAutodetector::new(
9882			build_project_state(vec![(
9883				("myapp".to_string(), "OldModel".to_string()),
9884				from_model,
9885			)]),
9886			build_project_state(vec![(
9887				("myapp".to_string(), "NewModel".to_string()),
9888				to_model,
9889			)]),
9890		);
9891
9892		// Act
9893		let migrations = detector.generate_migrations();
9894
9895		// Assert
9896		assert_eq!(migrations.len(), 1);
9897		assert_eq!(
9898			migrations[0].operations,
9899			vec![
9900				super::super::Operation::DropConstraint {
9901					table: "old_table".to_string(),
9902					constraint_name: "old_table_email_uniq".to_string(),
9903				},
9904				super::super::Operation::RenameTable {
9905					old_name: "old_table".to_string(),
9906					new_name: "new_table".to_string(),
9907				},
9908				super::super::Operation::AddConstraint {
9909					table: "new_table".to_string(),
9910					constraint_sql: new_constraint.to_constraint().to_string(),
9911				},
9912			]
9913		);
9914	}
9915
9916	#[rstest]
9917	fn has_field_changed_ignores_non_schema_params() {
9918		// Arrange: same schema, but to_field has extra non-schema params
9919		let from_field = FieldState {
9920			name: "email".to_string(),
9921			field_type: super::super::FieldType::VarChar(255),
9922			nullable: false,
9923			params: std::collections::HashMap::new(),
9924			foreign_key: None,
9925		};
9926		let mut to_params = std::collections::HashMap::new();
9927		to_params.insert("max_length".to_string(), "255".to_string());
9928		to_params.insert("null".to_string(), "false".to_string());
9929		to_params.insert("blank".to_string(), "false".to_string());
9930		let to_field = FieldState {
9931			name: "email".to_string(),
9932			field_type: super::super::FieldType::VarChar(255),
9933			nullable: false,
9934			params: to_params,
9935			foreign_key: None,
9936		};
9937
9938		let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
9939
9940		// Act
9941		let changed =
9942			detector.has_field_changed_with_unique("email", &from_field, &to_field, None, None);
9943
9944		// Assert: should NOT be detected as changed
9945		assert!(
9946			!changed,
9947			"fields with identical schema but different non-schema params should not be detected as changed"
9948		);
9949	}
9950
9951	#[rstest]
9952	fn has_field_changed_detects_database_default_changes() {
9953		// Arrange
9954		let from_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
9955		let mut to_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
9956		to_field
9957			.params
9958			.insert("default".to_string(), "true".to_string());
9959		let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
9960
9961		// Act
9962		let changed =
9963			detector.has_field_changed_with_unique("is_active", &from_field, &to_field, None, None);
9964
9965		// Assert
9966		assert!(
9967			changed,
9968			"database default changes must be detected as schema-affecting field changes"
9969		);
9970	}
9971
9972	#[rstest]
9973	fn generate_operations_carries_old_definition_for_database_default_changes() {
9974		// Arrange
9975		let mut from_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
9976		from_field
9977			.params
9978			.insert("default".to_string(), "true".to_string());
9979		let to_field = FieldState::new("is_active", super::super::FieldType::Boolean, false);
9980		let from_model =
9981			build_model_state("accounts", "User", vec![from_field], Vec::new(), Vec::new());
9982		let to_model =
9983			build_model_state("accounts", "User", vec![to_field], Vec::new(), Vec::new());
9984		let detector = MigrationAutodetector::new(
9985			build_project_state(vec![(
9986				("accounts".to_string(), "User".to_string()),
9987				from_model,
9988			)]),
9989			build_project_state(vec![(
9990				("accounts".to_string(), "User".to_string()),
9991				to_model,
9992			)]),
9993		);
9994
9995		// Act
9996		let operations = detector.generate_operations();
9997
9998		// Assert
9999		let operation = operations
10000			.iter()
10001			.find(|operation| {
10002				matches!(
10003					operation,
10004					super::super::Operation::AlterColumn { column, .. } if column == "is_active"
10005				)
10006			})
10007			.expect("default removal should emit AlterColumn");
10008		let super::super::Operation::AlterColumn {
10009			old_definition,
10010			new_definition,
10011			..
10012		} = operation
10013		else {
10014			unreachable!("matched AlterColumn above");
10015		};
10016		assert_eq!(
10017			old_definition
10018				.as_ref()
10019				.and_then(|definition| definition.default.as_deref()),
10020			Some("true")
10021		);
10022		assert_eq!(new_definition.default, None);
10023	}
10024
10025	#[rstest]
10026	fn generate_operations_empty_for_struct_only_rename() {
10027		// Arrange: struct name changed but table name and fields are the same
10028		let from_model =
10029			build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
10030		let to_model =
10031			build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
10032
10033		let from_state = build_project_state(vec![(
10034			("myapp".to_string(), "Clusters".to_string()),
10035			from_model,
10036		)]);
10037		let to_state = build_project_state(vec![(
10038			("myapp".to_string(), "Cluster".to_string()),
10039			to_model,
10040		)]);
10041
10042		let detector = MigrationAutodetector::new(from_state, to_state);
10043
10044		// Act
10045		let operations = detector.generate_operations();
10046
10047		// Assert: no operations should be generated
10048		assert!(
10049			operations.is_empty(),
10050			"struct-only rename with same table name and identical fields should produce no operations, got: {:?}",
10051			operations
10052		);
10053	}
10054
10055	#[rstest]
10056	fn detect_composite_pk_added_emits_create_composite_primary_key() {
10057		// Arrange
10058		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
10059		let tenant_id_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
10060
10061		let from_model = build_model_state(
10062			"billing",
10063			"Invoice",
10064			vec![id_field.clone(), tenant_id_field.clone()],
10065			Vec::new(),
10066			Vec::new(),
10067		);
10068		let composite_pk = ConstraintDefinition {
10069			name: "billing_invoice_pkey".to_string(),
10070			constraint_type: "primary_key".to_string(),
10071			fields: vec!["id".to_string(), "tenant_id".to_string()],
10072			expression: None,
10073			foreign_key_info: None,
10074		};
10075		let to_model = build_model_state(
10076			"billing",
10077			"Invoice",
10078			vec![id_field, tenant_id_field],
10079			Vec::new(),
10080			vec![composite_pk],
10081		);
10082
10083		let from_state = build_project_state(vec![(
10084			("billing".to_string(), "Invoice".to_string()),
10085			from_model,
10086		)]);
10087		let to_state = build_project_state(vec![(
10088			("billing".to_string(), "Invoice".to_string()),
10089			to_model,
10090		)]);
10091		let detector = MigrationAutodetector::new(from_state, to_state);
10092
10093		// Act
10094		let operations = detector.generate_operations();
10095
10096		// Assert
10097		assert_eq!(operations.len(), 1);
10098		assert!(
10099			matches!(
10100				&operations[0],
10101				super::super::Operation::CreateCompositePrimaryKey {
10102					table,
10103					columns,
10104					..
10105				} if table == "billing_invoice"
10106					&& columns == &["id".to_string(), "tenant_id".to_string()]
10107			),
10108			"expected CreateCompositePrimaryKey, got: {:?}",
10109			operations
10110		);
10111	}
10112
10113	#[rstest]
10114	fn detect_composite_pk_unchanged_emits_no_operations() {
10115		// Arrange — same composite PK in both states should produce no operations
10116		let composite_pk = ConstraintDefinition {
10117			name: "billing_invoice_pkey".to_string(),
10118			constraint_type: "primary_key".to_string(),
10119			fields: vec!["id".to_string(), "tenant_id".to_string()],
10120			expression: None,
10121			foreign_key_info: None,
10122		};
10123		let from_model = build_model_state(
10124			"billing",
10125			"Invoice",
10126			vec![
10127				FieldState::new("id", super::super::FieldType::Integer, false),
10128				FieldState::new("tenant_id", super::super::FieldType::Integer, false),
10129			],
10130			Vec::new(),
10131			vec![composite_pk.clone()],
10132		);
10133		let to_model = build_model_state(
10134			"billing",
10135			"Invoice",
10136			vec![
10137				FieldState::new("id", super::super::FieldType::Integer, false),
10138				FieldState::new("tenant_id", super::super::FieldType::Integer, false),
10139			],
10140			Vec::new(),
10141			vec![composite_pk],
10142		);
10143
10144		let from_state = build_project_state(vec![(
10145			("billing".to_string(), "Invoice".to_string()),
10146			from_model,
10147		)]);
10148		let to_state = build_project_state(vec![(
10149			("billing".to_string(), "Invoice".to_string()),
10150			to_model,
10151		)]);
10152		let detector = MigrationAutodetector::new(from_state, to_state);
10153
10154		// Act
10155		let operations = detector.generate_operations();
10156
10157		// Assert
10158		assert!(
10159			operations.is_empty(),
10160			"unchanged composite PK should produce no operations, got: {:?}",
10161			operations
10162		);
10163	}
10164
10165	#[rstest]
10166	fn detect_composite_pk_changed_fields_emits_drop_and_create() {
10167		// Arrange — same constraint name but different field set
10168		let composite_pk_from = ConstraintDefinition {
10169			name: "billing_invoice_pkey".to_string(),
10170			constraint_type: "primary_key".to_string(),
10171			fields: vec!["id".to_string(), "tenant_id".to_string()],
10172			expression: None,
10173			foreign_key_info: None,
10174		};
10175		let composite_pk_to = ConstraintDefinition {
10176			name: "billing_invoice_pkey".to_string(),
10177			constraint_type: "primary_key".to_string(),
10178			fields: vec!["id".to_string(), "org_id".to_string()],
10179			expression: None,
10180			foreign_key_info: None,
10181		};
10182		let from_model = build_model_state(
10183			"billing",
10184			"Invoice",
10185			vec![
10186				FieldState::new("id", super::super::FieldType::Integer, false),
10187				FieldState::new("tenant_id", super::super::FieldType::Integer, false),
10188			],
10189			Vec::new(),
10190			vec![composite_pk_from],
10191		);
10192		let to_model = build_model_state(
10193			"billing",
10194			"Invoice",
10195			vec![
10196				FieldState::new("id", super::super::FieldType::Integer, false),
10197				FieldState::new("org_id", super::super::FieldType::Integer, false),
10198			],
10199			Vec::new(),
10200			vec![composite_pk_to],
10201		);
10202		let from_state = build_project_state(vec![(
10203			("billing".to_string(), "Invoice".to_string()),
10204			from_model,
10205		)]);
10206		let to_state = build_project_state(vec![(
10207			("billing".to_string(), "Invoice".to_string()),
10208			to_model,
10209		)]);
10210		let detector = MigrationAutodetector::new(from_state, to_state);
10211
10212		// Act
10213		let operations = detector.generate_operations();
10214
10215		// Assert — expect DropConstraint followed by CreateCompositePrimaryKey
10216		let drop_op = operations.iter().find(|op| {
10217			matches!(op, super::super::Operation::DropConstraint { constraint_name, .. }
10218				if constraint_name == "billing_invoice_pkey")
10219		});
10220		let create_op = operations.iter().find(|op| {
10221			matches!(op, super::super::Operation::CreateCompositePrimaryKey { columns, .. }
10222				if columns == &["id".to_string(), "org_id".to_string()])
10223		});
10224		assert!(
10225			drop_op.is_some(),
10226			"expected DropConstraint for modified composite PK, got: {:?}",
10227			operations
10228		);
10229		assert!(
10230			create_op.is_some(),
10231			"expected CreateCompositePrimaryKey with new fields, got: {:?}",
10232			operations
10233		);
10234	}
10235
10236	#[rstest]
10237	fn detect_sequence_reset_emits_set_auto_increment_value() {
10238		// Arrange
10239		let mut id_field = FieldState::new("id", super::super::FieldType::BigInteger, false);
10240		id_field
10241			.params
10242			.insert("auto_increment".to_string(), "true".to_string());
10243
10244		let from_model = build_model_state(
10245			"shop",
10246			"Order",
10247			vec![id_field.clone()],
10248			Vec::new(),
10249			Vec::new(),
10250		);
10251		let mut to_model =
10252			build_model_state("shop", "Order", vec![id_field], Vec::new(), Vec::new());
10253		to_model
10254			.options
10255			.insert("sequence_reset".to_string(), "1000".to_string());
10256
10257		let from_state = build_project_state(vec![(
10258			("shop".to_string(), "Order".to_string()),
10259			from_model,
10260		)]);
10261		let to_state =
10262			build_project_state(vec![(("shop".to_string(), "Order".to_string()), to_model)]);
10263		let detector = MigrationAutodetector::new(from_state, to_state);
10264
10265		// Act
10266		let operations = detector.generate_operations();
10267
10268		// Assert
10269		assert_eq!(operations.len(), 1);
10270		assert!(
10271			matches!(
10272				&operations[0],
10273				super::super::Operation::SetAutoIncrementValue {
10274					table,
10275					column,
10276					value,
10277				} if table == "shop_order" && column == "id" && *value == 1000
10278			),
10279			"expected SetAutoIncrementValue, got: {:?}",
10280			operations
10281		);
10282	}
10283
10284	#[rstest]
10285	fn detect_added_unique_together_emits_add_constraint() {
10286		// Arrange — same model in both states, but to_state adds a UNIQUE
10287		// constraint over (organization_id, name). This mirrors the
10288		// `unique_together = ("organization_id", "name")` macro form.
10289		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
10290		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
10291		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
10292
10293		let from_model = build_model_state(
10294			"clusters",
10295			"Cluster",
10296			vec![id_field.clone(), org_field.clone(), name_field.clone()],
10297			Vec::new(),
10298			Vec::new(),
10299		);
10300		let unique_constraint = ConstraintDefinition {
10301			name: "clusters_cluster_organization_id_name_uniq".to_string(),
10302			constraint_type: "unique".to_string(),
10303			fields: vec!["organization_id".to_string(), "name".to_string()],
10304			expression: None,
10305			foreign_key_info: None,
10306		};
10307		let to_model = build_model_state(
10308			"clusters",
10309			"Cluster",
10310			vec![id_field, org_field, name_field],
10311			Vec::new(),
10312			vec![unique_constraint],
10313		);
10314
10315		let from_state = build_project_state(vec![(
10316			("clusters".to_string(), "Cluster".to_string()),
10317			from_model,
10318		)]);
10319		let to_state = build_project_state(vec![(
10320			("clusters".to_string(), "Cluster".to_string()),
10321			to_model,
10322		)]);
10323		let detector = MigrationAutodetector::new(from_state, to_state);
10324
10325		// Act
10326		let operations = detector.generate_operations();
10327
10328		// Assert — exactly one AddConstraint targeting the cluster table
10329		// with SQL referencing both columns of the composite UNIQUE.
10330		assert_eq!(
10331			operations.len(),
10332			1,
10333			"expected exactly one AddConstraint operation, got: {:?}",
10334			operations
10335		);
10336		let super::super::Operation::AddConstraint {
10337			table,
10338			constraint_sql,
10339		} = &operations[0]
10340		else {
10341			panic!(
10342				"expected Operation::AddConstraint, got: {:?}",
10343				operations[0]
10344			);
10345		};
10346		assert_eq!(table, "clusters_cluster");
10347		assert!(
10348			constraint_sql.contains("UNIQUE"),
10349			"constraint SQL should declare UNIQUE, got: {}",
10350			constraint_sql
10351		);
10352		assert!(
10353			constraint_sql.contains("organization_id"),
10354			"constraint SQL should reference organization_id, got: {}",
10355			constraint_sql
10356		);
10357		assert!(
10358			constraint_sql.contains("name"),
10359			"constraint SQL should reference name, got: {}",
10360			constraint_sql
10361		);
10362		assert!(
10363			constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
10364			"constraint SQL should carry the constraint name, got: {}",
10365			constraint_sql
10366		);
10367	}
10368
10369	#[rstest]
10370	fn detect_removed_unique_together_emits_drop_constraint() {
10371		// Arrange — symmetric reverse: from_state has the UNIQUE, to_state
10372		// drops it. The autodetector must emit a DropConstraint so the DB
10373		// is brought back in sync.
10374		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
10375		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
10376		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
10377
10378		let unique_constraint = ConstraintDefinition {
10379			name: "clusters_cluster_organization_id_name_uniq".to_string(),
10380			constraint_type: "unique".to_string(),
10381			fields: vec!["organization_id".to_string(), "name".to_string()],
10382			expression: None,
10383			foreign_key_info: None,
10384		};
10385		let from_model = build_model_state(
10386			"clusters",
10387			"Cluster",
10388			vec![id_field.clone(), org_field.clone(), name_field.clone()],
10389			Vec::new(),
10390			vec![unique_constraint],
10391		);
10392		let to_model = build_model_state(
10393			"clusters",
10394			"Cluster",
10395			vec![id_field, org_field, name_field],
10396			Vec::new(),
10397			Vec::new(),
10398		);
10399
10400		let from_state = build_project_state(vec![(
10401			("clusters".to_string(), "Cluster".to_string()),
10402			from_model,
10403		)]);
10404		let to_state = build_project_state(vec![(
10405			("clusters".to_string(), "Cluster".to_string()),
10406			to_model,
10407		)]);
10408		let detector = MigrationAutodetector::new(from_state, to_state);
10409
10410		// Act
10411		let operations = detector.generate_operations();
10412
10413		// Assert
10414		assert_eq!(
10415			operations.len(),
10416			1,
10417			"expected exactly one DropConstraint operation, got: {:?}",
10418			operations
10419		);
10420		let super::super::Operation::DropConstraint {
10421			table,
10422			constraint_name,
10423		} = &operations[0]
10424		else {
10425			panic!(
10426				"expected Operation::DropConstraint, got: {:?}",
10427				operations[0]
10428			);
10429		};
10430		assert_eq!(table, "clusters_cluster");
10431		assert_eq!(
10432			constraint_name,
10433			"clusters_cluster_organization_id_name_uniq"
10434		);
10435	}
10436
10437	#[rstest]
10438	fn detect_added_unique_together_via_offline_reconstructed_from_state() {
10439		// Arrange — regression for issue #4032.
10440		//
10441		// When `makemigrations` falls back to file-based state reconstruction
10442		// (no DB available), `from_state` is rebuilt from migration
10443		// `Operation::CreateTable` entries and keyed by the PascalCase form
10444		// of the table name (e.g. table `"clusters"` -> key `"Clusters"`),
10445		// while `to_state` is keyed by the registered struct name
10446		// (e.g. `"Cluster"`). Both share the same `table_name`.
10447		//
10448		// Constraint diffing must locate the corresponding model by
10449		// `table_name` rather than by struct-name key, otherwise added
10450		// `unique_together` constraints are silently dropped.
10451		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
10452		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
10453		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
10454
10455		// from_state: keyed by table-derived name "Clusters", no constraints.
10456		let mut from_model = build_model_state(
10457			"clusters",
10458			"Clusters",
10459			vec![id_field.clone(), org_field.clone(), name_field.clone()],
10460			Vec::new(),
10461			Vec::new(),
10462		);
10463		from_model.table_name = "clusters_cluster".to_string();
10464
10465		// to_state: keyed by struct name "Cluster", carries the unique constraint.
10466		let unique_constraint = ConstraintDefinition {
10467			name: "clusters_cluster_organization_id_name_uniq".to_string(),
10468			constraint_type: "unique".to_string(),
10469			fields: vec!["organization_id".to_string(), "name".to_string()],
10470			expression: None,
10471			foreign_key_info: None,
10472		};
10473		let to_model = build_model_state(
10474			"clusters",
10475			"Cluster",
10476			vec![id_field, org_field, name_field],
10477			Vec::new(),
10478			vec![unique_constraint],
10479		);
10480
10481		let from_state = build_project_state(vec![(
10482			("clusters".to_string(), "Clusters".to_string()),
10483			from_model,
10484		)]);
10485		let to_state = build_project_state(vec![(
10486			("clusters".to_string(), "Cluster".to_string()),
10487			to_model,
10488		)]);
10489		let detector = MigrationAutodetector::new(from_state, to_state);
10490
10491		// Act
10492		let operations = detector.generate_operations();
10493
10494		// Assert — exactly one AddConstraint, targeted at the shared table.
10495		// No spurious operations (in particular no AlterColumn/RenameModel)
10496		// must leak through from the model-name mismatch.
10497		assert_eq!(
10498			operations.len(),
10499			1,
10500			"expected exactly one AddConstraint operation, got: {:?}",
10501			operations
10502		);
10503		let super::super::Operation::AddConstraint {
10504			table,
10505			constraint_sql,
10506		} = &operations[0]
10507		else {
10508			panic!(
10509				"expected Operation::AddConstraint, got: {:?}",
10510				operations[0]
10511			);
10512		};
10513		assert_eq!(table, "clusters_cluster");
10514		assert!(
10515			constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
10516			"constraint SQL should carry the constraint name, got: {}",
10517			constraint_sql
10518		);
10519	}
10520
10521	#[rstest]
10522	fn detect_removed_unique_together_via_offline_reconstructed_from_state() {
10523		// Arrange — symmetric regression for issue #4032 covering the
10524		// removal direction: offline-reconstructed `from_state` retains a
10525		// `unique_together` constraint, and the registered model in
10526		// `to_state` no longer declares it. The diff must emit a
10527		// `DropConstraint` despite the model-name key mismatch.
10528		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
10529		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
10530		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
10531
10532		let unique_constraint = ConstraintDefinition {
10533			name: "clusters_cluster_organization_id_name_uniq".to_string(),
10534			constraint_type: "unique".to_string(),
10535			fields: vec!["organization_id".to_string(), "name".to_string()],
10536			expression: None,
10537			foreign_key_info: None,
10538		};
10539		let mut from_model = build_model_state(
10540			"clusters",
10541			"Clusters",
10542			vec![id_field.clone(), org_field.clone(), name_field.clone()],
10543			Vec::new(),
10544			vec![unique_constraint],
10545		);
10546		from_model.table_name = "clusters_cluster".to_string();
10547
10548		let to_model = build_model_state(
10549			"clusters",
10550			"Cluster",
10551			vec![id_field, org_field, name_field],
10552			Vec::new(),
10553			Vec::new(),
10554		);
10555
10556		let from_state = build_project_state(vec![(
10557			("clusters".to_string(), "Clusters".to_string()),
10558			from_model,
10559		)]);
10560		let to_state = build_project_state(vec![(
10561			("clusters".to_string(), "Cluster".to_string()),
10562			to_model,
10563		)]);
10564		let detector = MigrationAutodetector::new(from_state, to_state);
10565
10566		// Act
10567		let operations = detector.generate_operations();
10568
10569		// Assert
10570		assert_eq!(
10571			operations.len(),
10572			1,
10573			"expected exactly one DropConstraint operation, got: {:?}",
10574			operations
10575		);
10576		let super::super::Operation::DropConstraint {
10577			table,
10578			constraint_name,
10579		} = &operations[0]
10580		else {
10581			panic!(
10582				"expected Operation::DropConstraint, got: {:?}",
10583				operations[0]
10584			);
10585		};
10586		assert_eq!(table, "clusters_cluster");
10587		assert_eq!(
10588			constraint_name,
10589			"clusters_cluster_organization_id_name_uniq"
10590		);
10591	}
10592
10593	#[rstest]
10594	fn has_field_changed_ignores_param_population_skew() {
10595		// Arrange — regression for issue #4049.
10596		//
10597		// `from_state` is rebuilt from migration files via
10598		// `column_def_to_field_state`, which only inserts schema-affecting
10599		// params (`primary_key`, `auto_increment`, `unique`, `default`) when
10600		// their value is true/Some. `to_state` is rebuilt from the macro
10601		// registry, which inserts explicit "true"/"false" strings for
10602		// `not_null`, `null`, etc. on every field.
10603		//
10604		// The two `FieldState` HashMaps are therefore asymmetric even when
10605		// the underlying schema is identical, and `has_field_changed` must
10606		// not treat that asymmetry as a real change.
10607		let mut from_params = std::collections::HashMap::new();
10608		from_params.insert("primary_key".to_string(), "true".to_string());
10609		from_params.insert("auto_increment".to_string(), "true".to_string());
10610		let from_field = FieldState {
10611			name: "id".to_string(),
10612			field_type: super::super::FieldType::BigInteger,
10613			nullable: false,
10614			params: from_params,
10615			foreign_key: None,
10616		};
10617
10618		// to_field carries the macro-registry-style asymmetric params. In
10619		// particular, schema-affecting keys like `unique` and `default` may be
10620		// populated explicitly with their false/empty value on the to side
10621		// while the migration-replay from side simply omits the key. The raw
10622		// HashMap comparison previously surfaced this as a difference; the
10623		// canonical ColumnDefinition collapses None and "false" to the same
10624		// `false`, so the field is correctly seen as unchanged.
10625		let mut to_params = std::collections::HashMap::new();
10626		to_params.insert("primary_key".to_string(), "true".to_string());
10627		to_params.insert("auto_increment".to_string(), "true".to_string());
10628		to_params.insert("not_null".to_string(), "true".to_string());
10629		to_params.insert("null".to_string(), "false".to_string());
10630		to_params.insert("unique".to_string(), "false".to_string());
10631		let to_field = FieldState {
10632			name: "id".to_string(),
10633			field_type: super::super::FieldType::BigInteger,
10634			nullable: false,
10635			params: to_params,
10636			foreign_key: None,
10637		};
10638
10639		let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
10640
10641		// Act
10642		let changed =
10643			detector.has_field_changed_with_unique("id", &from_field, &to_field, None, None);
10644
10645		// Assert — schema is identical (BigInteger PK NOT NULL auto_increment,
10646		// not unique). Asymmetric param maps must not surface as a change.
10647		assert!(
10648			!changed,
10649			"identical schema with asymmetric param populations between migration replay and macro registry must not be detected as changed"
10650		);
10651	}
10652
10653	#[rstest]
10654	fn generate_operations_no_spurious_altercolumn_for_pk_via_offline_reconstructed_state() {
10655		// Arrange — regression for issue #4049.
10656		//
10657		// When `makemigrations` runs offline (no live DB), `from_state` is
10658		// reconstructed from migration files and keyed by the PascalCase form
10659		// of the table name (e.g. table `"clusters"` -> key `"Clusters"`),
10660		// while `to_state` is keyed by the registered struct name (`"Cluster"`).
10661		// On top of that, the two states populate `FieldState.params`
10662		// asymmetrically: migration replay only inserts schema-affecting params
10663		// when their value is true/Some, whereas the macro registry inserts
10664		// explicit "true"/"false" strings for `not_null`, `null`, etc.
10665		//
10666		// The diff must NOT emit a no-op `Operation::AlterColumn` for the
10667		// unchanged `id` primary key, even though the params HashMap differs.
10668		let mut from_id_params = std::collections::HashMap::new();
10669		from_id_params.insert("primary_key".to_string(), "true".to_string());
10670		from_id_params.insert("auto_increment".to_string(), "true".to_string());
10671		let from_id_field = FieldState {
10672			name: "id".to_string(),
10673			field_type: super::super::FieldType::BigInteger,
10674			nullable: false,
10675			params: from_id_params,
10676			foreign_key: None,
10677		};
10678		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
10679		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
10680
10681		// from_state: keyed by table-derived name "Clusters", no constraints,
10682		// migration-replay-style sparse params on the PK column.
10683		let mut from_model = build_model_state(
10684			"clusters",
10685			"Clusters",
10686			vec![from_id_field, org_field.clone(), name_field.clone()],
10687			Vec::new(),
10688			Vec::new(),
10689		);
10690		from_model.table_name = "clusters_cluster".to_string();
10691
10692		// to_state: keyed by struct name "Cluster", carries an added
10693		// unique_together constraint, macro-registry-style dense params on the
10694		// PK column (`not_null`, `null`, `unique` explicitly populated even
10695		// when their value is the default false). The from side omits these
10696		// keys entirely, which previously surfaced as a fictitious
10697		// difference in `has_field_changed`'s raw HashMap comparison.
10698		let mut to_id_params = std::collections::HashMap::new();
10699		to_id_params.insert("primary_key".to_string(), "true".to_string());
10700		to_id_params.insert("auto_increment".to_string(), "true".to_string());
10701		to_id_params.insert("not_null".to_string(), "true".to_string());
10702		to_id_params.insert("null".to_string(), "false".to_string());
10703		to_id_params.insert("unique".to_string(), "false".to_string());
10704		let to_id_field = FieldState {
10705			name: "id".to_string(),
10706			field_type: super::super::FieldType::BigInteger,
10707			nullable: false,
10708			params: to_id_params,
10709			foreign_key: None,
10710		};
10711		let unique_constraint = ConstraintDefinition {
10712			name: "clusters_cluster_organization_id_name_uniq".to_string(),
10713			constraint_type: "unique".to_string(),
10714			fields: vec!["organization_id".to_string(), "name".to_string()],
10715			expression: None,
10716			foreign_key_info: None,
10717		};
10718		let to_model = build_model_state(
10719			"clusters",
10720			"Cluster",
10721			vec![to_id_field, org_field, name_field],
10722			Vec::new(),
10723			vec![unique_constraint],
10724		);
10725
10726		let from_state = build_project_state(vec![(
10727			("clusters".to_string(), "Clusters".to_string()),
10728			from_model,
10729		)]);
10730		let to_state = build_project_state(vec![(
10731			("clusters".to_string(), "Cluster".to_string()),
10732			to_model,
10733		)]);
10734		let detector = MigrationAutodetector::new(from_state, to_state);
10735
10736		// Act
10737		let operations = detector.generate_operations();
10738
10739		// Assert — exactly one AddConstraint and no spurious AlterColumn for
10740		// the unchanged PK. The asymmetric param populations must collapse to
10741		// the same canonical `ColumnDefinition` and never surface as a diff.
10742		assert!(
10743			!operations
10744				.iter()
10745				.any(|op| matches!(op, super::super::Operation::AlterColumn { .. })),
10746			"no AlterColumn must be emitted for unchanged PK under offline state reconstruction, got: {:?}",
10747			operations
10748		);
10749		assert_eq!(
10750			operations.len(),
10751			1,
10752			"expected exactly one AddConstraint operation, got: {:?}",
10753			operations
10754		);
10755		assert!(
10756			matches!(
10757				&operations[0],
10758				super::super::Operation::AddConstraint { .. }
10759			),
10760			"expected the single operation to be AddConstraint, got: {:?}",
10761			operations[0]
10762		);
10763	}
10764
10765	#[rstest]
10766	fn generate_operations_no_spurious_altercolumn_for_option_pk_via_apply_migration_operations() {
10767		// Arrange — regression for issue #4052 (residual after #4050).
10768		//
10769		// Reproduces the production CLI path that #4050's regression test
10770		// missed: from_state is built by feeding a synthetic
10771		// `Operation::CreateTable` (modeled on `0001_initial.rs`) through
10772		// `ProjectState::apply_migration_operations`, so its `id` FieldState
10773		// flows through `column_def_to_field_state`. to_state mirrors the
10774		// `#[model]` macro's output for `id: Option<i64>` with
10775		// `#[field(primary_key = true)]` AFTER the macro fix that suppresses
10776		// `null = "true"` for primary keys (the Option<T> wrapper for PKs
10777		// reflects "id is None until DB assigns it on insert", not DB-level
10778		// nullability).
10779		//
10780		// Pre-fix, the macro emitted `null = "true"` for any Option<T>
10781		// field, including PKs. `to_model_state` then set
10782		// `FieldState.nullable = true` while `column_def_to_field_state`
10783		// produced `nullable = false`. `has_field_changed`'s direct
10784		// `nullable != nullable` short-circuit (added by #4050 to keep the
10785		// authoritative NOT NULL bit on the canonical comparison path)
10786		// returned true before the canonical `ColumnDefinition::from_field_state`
10787		// folding could absorb the asymmetry, surfacing as a no-op
10788		// `Operation::AlterColumn { old_definition: None, .. }` for the
10789		// unchanged PK.
10790
10791		// Build to_state via the model registry layer that the macro feeds.
10792		// Mirror the FIXED macro params for `id: Option<i64>` with
10793		// `#[field(primary_key = true)]`: `null = "false"` (forced by the
10794		// fix), `not_null = "true"`, `primary_key = "true"`,
10795		// `auto_increment = "true"`. The dense param population is exactly
10796		// what `ModelMetadata::to_model_state` consumes.
10797		let mut id_meta =
10798			super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger);
10799		id_meta = id_meta
10800			.with_param("primary_key", "true")
10801			.with_param("auto_increment", "true")
10802			.with_param("not_null", "true")
10803			.with_nullable(false);
10804		let mut name_meta =
10805			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255));
10806		name_meta = name_meta
10807			.with_param("max_length", "255")
10808			.with_param("not_null", "true")
10809			.with_nullable(false);
10810
10811		let mut metadata =
10812			super::super::model_registry::ModelMetadata::new("clusters", "Cluster", "clusters");
10813		metadata.add_field("id".to_string(), id_meta);
10814		metadata.add_field("name".to_string(), name_meta);
10815
10816		let to_model = metadata.to_model_state();
10817		// Sanity: the FIXED macro contract must fold `null = "false"` into
10818		// `FieldState.nullable = false` for the PK, matching the migration
10819		// replay side.
10820		let to_id = to_model.fields.get("id").expect("id field present");
10821		assert!(
10822			!to_id.nullable,
10823			"to_state PK FieldState.nullable must be false; got nullable=true \
10824			 with params={:?}. Did the #[model] macro regress to emitting \
10825			 null=\"true\" for Option<T> PKs?",
10826			to_id.params
10827		);
10828
10829		let to_state = build_project_state(vec![(
10830			("clusters".to_string(), "Cluster".to_string()),
10831			to_model,
10832		)]);
10833
10834		// Build from_state via the production CLI path: feed a CreateTable
10835		// (modeled on 0001_initial.rs) through apply_migration_operations.
10836		// This populates from_state via column_def_to_field_state, which
10837		// derives nullability from `not_null` (sparse params).
10838		let create_clusters = super::super::Operation::CreateTable {
10839			name: "clusters".to_string(),
10840			columns: vec![
10841				super::super::ColumnDefinition {
10842					name: "id".to_string(),
10843					type_definition: super::super::FieldType::BigInteger,
10844					not_null: true,
10845					unique: false,
10846					primary_key: true,
10847					auto_increment: true,
10848					default: None,
10849				},
10850				super::super::ColumnDefinition {
10851					name: "name".to_string(),
10852					type_definition: super::super::FieldType::VarChar(255),
10853					not_null: true,
10854					unique: false,
10855					primary_key: false,
10856					auto_increment: false,
10857					default: None,
10858				},
10859			],
10860			constraints: vec![],
10861			without_rowid: None,
10862			interleave_in_parent: None,
10863			partition: None,
10864		};
10865		let mut from_state = ProjectState::new();
10866		from_state.apply_migration_operations(&[create_clusters], "clusters");
10867
10868		// Sanity: the migration-replay path must produce nullable=false for
10869		// the PK.
10870		let from_clusters = from_state
10871			.find_model_by_table("clusters")
10872			.expect("clusters model present in from_state");
10873		assert!(
10874			!from_clusters
10875				.fields
10876				.get("id")
10877				.expect("id field in from_state")
10878				.nullable,
10879			"from_state PK FieldState.nullable must be false (column_def_to_field_state derives \
10880			 from not_null); got nullable=true"
10881		);
10882
10883		let detector = MigrationAutodetector::new(from_state, to_state);
10884
10885		// Act — call BOTH lower-level generate_operations() and the CLI
10886		// entry generate_migrations(), since #4052's reproducer asserts
10887		// against both.
10888		let direct_ops = detector.generate_operations();
10889		let migrations = detector.generate_migrations();
10890		let migration_ops: Vec<&super::super::Operation> = migrations
10891			.iter()
10892			.flat_map(|m| m.operations.iter())
10893			.collect();
10894
10895		// Assert — neither path may emit AlterColumn for the unchanged `id`
10896		// PK. Pre-fix, both emitted exactly such an AlterColumn.
10897		assert!(
10898			!direct_ops.iter().any(|op| matches!(
10899				op,
10900				super::super::Operation::AlterColumn { column, .. } if column == "id"
10901			)),
10902			"generate_operations() emitted spurious AlterColumn for unchanged `id` PK \
10903			 under apply_migration_operations from_state. ops={:?}",
10904			direct_ops
10905		);
10906		assert!(
10907			!migration_ops.iter().any(|op| matches!(
10908				op,
10909				super::super::Operation::AlterColumn { column, .. } if column == "id"
10910			)),
10911			"generate_migrations() emitted spurious AlterColumn for unchanged `id` PK \
10912			 under apply_migration_operations from_state. ops={:?}",
10913			migration_ops
10914		);
10915	}
10916
10917	#[rstest]
10918	fn generate_operations_no_spurious_altercolumn_for_replayed_foreign_key_column() {
10919		// Arrange — regression for the basis tutorial migration check.
10920		//
10921		// The replayed migration state only knows the physical `_id` column
10922		// type (`BigInteger`). The macro registry state keeps the
10923		// `ForeignKeyField<T>` placeholder type (`Uuid`) plus `fk_target`
10924		// params, and `ColumnDefinition::from_field_state` resolves that
10925		// placeholder to the referenced model's PK type. The autodetector must
10926		// compare the resolved column definitions, not the raw logical
10927		// `FieldState.field_type`, or it emits a no-op AlterColumn.
10928		let mut target_metadata = super::super::model_registry::ModelMetadata::new(
10929			"fk_drift_target_app",
10930			"FkDriftTarget",
10931			"fk_drift_targets",
10932		);
10933		target_metadata.add_field(
10934			"id".to_string(),
10935			super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger)
10936				.with_param("primary_key", "true")
10937				.with_param("auto_increment", "true")
10938				.with_param("not_null", "true")
10939				.with_nullable(false),
10940		);
10941		super::super::model_registry::global_registry().register_model(target_metadata);
10942
10943		let create_sources = super::super::Operation::CreateTable {
10944			name: "fk_drift_sources".to_string(),
10945			columns: vec![
10946				super::super::ColumnDefinition {
10947					name: "id".to_string(),
10948					type_definition: super::super::FieldType::BigInteger,
10949					not_null: true,
10950					unique: false,
10951					primary_key: true,
10952					auto_increment: true,
10953					default: None,
10954				},
10955				super::super::ColumnDefinition {
10956					name: "target_id".to_string(),
10957					type_definition: super::super::FieldType::BigInteger,
10958					not_null: true,
10959					unique: false,
10960					primary_key: false,
10961					auto_increment: false,
10962					default: None,
10963				},
10964			],
10965			constraints: vec![],
10966			without_rowid: None,
10967			interleave_in_parent: None,
10968			partition: None,
10969		};
10970		let mut from_state = ProjectState::new();
10971		from_state.apply_migration_operations(&[create_sources], "fk_drift_source_app");
10972
10973		let mut source_metadata = super::super::model_registry::ModelMetadata::new(
10974			"fk_drift_source_app",
10975			"FkDriftSource",
10976			"fk_drift_sources",
10977		);
10978		source_metadata.add_field(
10979			"id".to_string(),
10980			super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger)
10981				.with_param("primary_key", "true")
10982				.with_param("auto_increment", "true")
10983				.with_param("not_null", "true")
10984				.with_nullable(false),
10985		);
10986		source_metadata.add_field(
10987			"target_id".to_string(),
10988			super::super::model_registry::FieldMetadata::new(super::super::FieldType::Uuid)
10989				.with_param("fk_target", "FkDriftTarget")
10990				.with_param("fk_target_app", "fk_drift_target_app")
10991				.with_param("not_null", "true")
10992				.with_nullable(false),
10993		);
10994		let to_state = build_project_state(vec![(
10995			(
10996				"fk_drift_source_app".to_string(),
10997				"FkDriftSource".to_string(),
10998			),
10999			source_metadata.to_model_state(),
11000		)]);
11001		let detector = MigrationAutodetector::new(from_state, to_state);
11002
11003		// Act
11004		let operations = detector.generate_operations();
11005
11006		// Assert
11007		assert!(
11008			!operations.iter().any(|op| matches!(
11009				op,
11010				super::super::Operation::AlterColumn { column, .. } if column == "target_id"
11011			)),
11012			"unchanged FK _id column must not emit no-op AlterColumn, got: {:?}",
11013			operations
11014		);
11015		assert!(
11016			operations.is_empty(),
11017			"replayed FK column should be in sync with registry state, got: {:?}",
11018			operations
11019		);
11020	}
11021
11022	#[rstest]
11023	fn generate_operations_no_spurious_drift_for_replayed_auth_schema() {
11024		// Arrange - regression for issue #5367.
11025		//
11026		// The file-based replay path reconstructs old migrations from
11027		// `ColumnDefinition` values. That state can differ from today's
11028		// registry state even when the applied schema is equivalent:
11029		// - old UUID PK migrations may carry `auto_increment = true`;
11030		// - field-level UNIQUE may have been added through `AddConstraint`;
11031		// - DB defaults must round-trip through both replayed migrations and
11032		//   registry metadata.
11033		let create_auth_users = super::super::Operation::CreateTable {
11034			name: "auth_users".to_string(),
11035			columns: vec![
11036				super::super::ColumnDefinition {
11037					name: "id".to_string(),
11038					type_definition: super::super::FieldType::Uuid,
11039					not_null: true,
11040					unique: false,
11041					primary_key: true,
11042					auto_increment: false,
11043					default: None,
11044				},
11045				super::super::ColumnDefinition {
11046					name: "username".to_string(),
11047					type_definition: super::super::FieldType::VarChar(150),
11048					not_null: true,
11049					unique: true,
11050					primary_key: false,
11051					auto_increment: false,
11052					default: None,
11053				},
11054				super::super::ColumnDefinition {
11055					name: "email".to_string(),
11056					type_definition: super::super::FieldType::VarChar(254),
11057					not_null: true,
11058					unique: false,
11059					primary_key: false,
11060					auto_increment: false,
11061					default: None,
11062				},
11063				super::super::ColumnDefinition {
11064					name: "first_name".to_string(),
11065					type_definition: super::super::FieldType::VarChar(150),
11066					not_null: true,
11067					unique: false,
11068					primary_key: false,
11069					auto_increment: false,
11070					default: Some("''".to_string()),
11071				},
11072				super::super::ColumnDefinition {
11073					name: "last_name".to_string(),
11074					type_definition: super::super::FieldType::VarChar(150),
11075					not_null: true,
11076					unique: false,
11077					primary_key: false,
11078					auto_increment: false,
11079					default: Some("''".to_string()),
11080				},
11081				super::super::ColumnDefinition {
11082					name: "is_active".to_string(),
11083					type_definition: super::super::FieldType::Boolean,
11084					not_null: true,
11085					unique: false,
11086					primary_key: false,
11087					auto_increment: false,
11088					default: Some("true".to_string()),
11089				},
11090				super::super::ColumnDefinition {
11091					name: "is_staff".to_string(),
11092					type_definition: super::super::FieldType::Boolean,
11093					not_null: true,
11094					unique: false,
11095					primary_key: false,
11096					auto_increment: false,
11097					default: Some("false".to_string()),
11098				},
11099				super::super::ColumnDefinition {
11100					name: "is_superuser".to_string(),
11101					type_definition: super::super::FieldType::Boolean,
11102					not_null: true,
11103					unique: false,
11104					primary_key: false,
11105					auto_increment: false,
11106					default: Some("false".to_string()),
11107				},
11108			],
11109			constraints: vec![super::super::operations::Constraint::Unique {
11110				name: "auth_user_username_uniq".to_string(),
11111				columns: vec!["username".to_string()],
11112			}],
11113			without_rowid: None,
11114			interleave_in_parent: None,
11115			partition: None,
11116		};
11117		let add_email_unique = super::super::Operation::AddConstraint {
11118			table: "auth_users".to_string(),
11119			constraint_sql: "CONSTRAINT auth_user_email_uniq UNIQUE (email)".to_string(),
11120		};
11121		let create_auth_permission = super::super::Operation::CreateTable {
11122			name: "auth_permission".to_string(),
11123			columns: vec![
11124				super::super::ColumnDefinition {
11125					name: "id".to_string(),
11126					type_definition: super::super::FieldType::Uuid,
11127					not_null: true,
11128					unique: false,
11129					primary_key: true,
11130					auto_increment: true,
11131					default: None,
11132				},
11133				super::super::ColumnDefinition {
11134					name: "name".to_string(),
11135					type_definition: super::super::FieldType::VarChar(255),
11136					not_null: true,
11137					unique: false,
11138					primary_key: false,
11139					auto_increment: false,
11140					default: None,
11141				},
11142			],
11143			constraints: vec![],
11144			without_rowid: None,
11145			interleave_in_parent: None,
11146			partition: None,
11147		};
11148
11149		let mut from_state = ProjectState::new();
11150		from_state.apply_migration_operations(
11151			&[create_auth_users, add_email_unique, create_auth_permission],
11152			"auth",
11153		);
11154
11155		let mut user_metadata =
11156			super::super::model_registry::ModelMetadata::new("auth", "User", "auth_users");
11157		user_metadata.add_field(
11158			"id".to_string(),
11159			super::super::model_registry::FieldMetadata::new(super::super::FieldType::Uuid)
11160				.with_param("primary_key", "true")
11161				.with_param("not_null", "true")
11162				.with_nullable(false),
11163		);
11164		user_metadata.add_field(
11165			"username".to_string(),
11166			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(150))
11167				.with_param("max_length", "150")
11168				.with_param("unique", "true")
11169				.with_param("not_null", "true")
11170				.with_nullable(false),
11171		);
11172		user_metadata.add_field(
11173			"email".to_string(),
11174			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(254))
11175				.with_param("max_length", "254")
11176				.with_param("unique", "true")
11177				.with_param("not_null", "true")
11178				.with_nullable(false),
11179		);
11180		user_metadata.add_field(
11181			"first_name".to_string(),
11182			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(150))
11183				.with_param("max_length", "150")
11184				.with_param("default", "''")
11185				.with_param("not_null", "true")
11186				.with_nullable(false),
11187		);
11188		user_metadata.add_field(
11189			"last_name".to_string(),
11190			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(150))
11191				.with_param("max_length", "150")
11192				.with_param("default", "''")
11193				.with_param("not_null", "true")
11194				.with_nullable(false),
11195		);
11196		user_metadata.add_field(
11197			"is_active".to_string(),
11198			super::super::model_registry::FieldMetadata::new(super::super::FieldType::Boolean)
11199				.with_param("default", "true")
11200				.with_param("not_null", "true")
11201				.with_nullable(false),
11202		);
11203		user_metadata.add_field(
11204			"is_staff".to_string(),
11205			super::super::model_registry::FieldMetadata::new(super::super::FieldType::Boolean)
11206				.with_param("default", "false")
11207				.with_param("not_null", "true")
11208				.with_nullable(false),
11209		);
11210		user_metadata.add_field(
11211			"is_superuser".to_string(),
11212			super::super::model_registry::FieldMetadata::new(super::super::FieldType::Boolean)
11213				.with_param("default", "false")
11214				.with_param("not_null", "true")
11215				.with_nullable(false),
11216		);
11217
11218		let mut permission_metadata = super::super::model_registry::ModelMetadata::new(
11219			"auth",
11220			"AuthPermission",
11221			"auth_permission",
11222		);
11223		permission_metadata.add_field(
11224			"id".to_string(),
11225			super::super::model_registry::FieldMetadata::new(super::super::FieldType::Uuid)
11226				.with_param("primary_key", "true")
11227				.with_param("not_null", "true")
11228				.with_nullable(false),
11229		);
11230		permission_metadata.add_field(
11231			"name".to_string(),
11232			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255))
11233				.with_param("max_length", "255")
11234				.with_param("not_null", "true")
11235				.with_nullable(false),
11236		);
11237
11238		let to_state = build_project_state(vec![
11239			(
11240				("auth".to_string(), "User".to_string()),
11241				user_metadata.to_model_state(),
11242			),
11243			(
11244				("auth".to_string(), "AuthPermission".to_string()),
11245				permission_metadata.to_model_state(),
11246			),
11247		]);
11248		let detector = MigrationAutodetector::new(from_state, to_state);
11249
11250		// Act
11251		let operations = detector.generate_operations();
11252
11253		// Assert
11254		assert!(
11255			operations.is_empty(),
11256			"replayed auth schema should be in sync with registry state, got: {:?}",
11257			operations
11258		);
11259	}
11260
11261	#[rstest]
11262	fn generate_migrations_emits_add_constraint_for_added_unique_together() {
11263		// Arrange — regression for issue #4040.
11264		//
11265		// `generate_migrations()` is the entry point used by the
11266		// `makemigrations` CLI, in contrast to `generate_operations()`
11267		// which is used by tests / direct callers. PR #3998 added
11268		// `added_constraints` handling to `generate_operations()` but the
11269		// symmetric loop was missing from `generate_migrations()`, so the
11270		// CLI silently dropped `Operation::AddConstraint` for non-PK
11271		// constraints (e.g. `unique_together`) added to existing models.
11272		//
11273		// This test exercises the CLI path directly and asserts the
11274		// migration carries an `Operation::AddConstraint`.
11275		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11276		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
11277		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
11278
11279		// from_state mirrors the CLI's offline-reconstructed state with no
11280		// constraints declared on the existing model (matching what the
11281		// `0001_initial` migration produces before the constraint is added).
11282		let mut from_model = build_model_state(
11283			"clusters",
11284			"Cluster",
11285			vec![id_field.clone(), org_field.clone(), name_field.clone()],
11286			Vec::new(),
11287			Vec::new(),
11288		);
11289		from_model.table_name = "clusters_cluster".to_string();
11290
11291		// to_state carries the unique_together constraint declared via the
11292		// macro on the registered struct.
11293		let unique_constraint = ConstraintDefinition {
11294			name: "clusters_cluster_organization_id_name_uniq".to_string(),
11295			constraint_type: "unique".to_string(),
11296			fields: vec!["organization_id".to_string(), "name".to_string()],
11297			expression: None,
11298			foreign_key_info: None,
11299		};
11300		let mut to_model = build_model_state(
11301			"clusters",
11302			"Cluster",
11303			vec![id_field, org_field, name_field],
11304			Vec::new(),
11305			vec![unique_constraint],
11306		);
11307		to_model.table_name = "clusters_cluster".to_string();
11308
11309		let from_state = build_project_state(vec![(
11310			("clusters".to_string(), "Cluster".to_string()),
11311			from_model,
11312		)]);
11313		let to_state = build_project_state(vec![(
11314			("clusters".to_string(), "Cluster".to_string()),
11315			to_model,
11316		)]);
11317		let detector = MigrationAutodetector::new(from_state, to_state);
11318
11319		// Act
11320		let migrations = detector.generate_migrations();
11321
11322		// Assert — exactly one Migration for app "clusters" with exactly
11323		// one AddConstraint operation, targeted at the shared table.
11324		assert_eq!(
11325			migrations.len(),
11326			1,
11327			"expected exactly one Migration, got: {:?}",
11328			migrations
11329		);
11330		assert_eq!(migrations[0].app_label, "clusters");
11331		assert_eq!(
11332			migrations[0].operations.len(),
11333			1,
11334			"expected exactly one operation in the migration, got: {:?}",
11335			migrations[0].operations
11336		);
11337		let super::super::Operation::AddConstraint {
11338			table,
11339			constraint_sql,
11340		} = &migrations[0].operations[0]
11341		else {
11342			panic!(
11343				"expected Operation::AddConstraint, got: {:?}",
11344				migrations[0].operations[0]
11345			);
11346		};
11347		assert_eq!(table, "clusters_cluster");
11348		assert!(
11349			constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
11350			"constraint SQL should carry the constraint name, got: {}",
11351			constraint_sql
11352		);
11353	}
11354
11355	#[rstest]
11356	fn generate_migrations_emits_drop_constraint_for_removed_unique_together() {
11357		// Arrange — symmetric regression for issue #4040 covering the
11358		// removal direction: the existing model declares a
11359		// `unique_together` constraint, the new struct has dropped it, and
11360		// the CLI path must emit `Operation::DropConstraint`.
11361		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11362		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
11363		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
11364
11365		let unique_constraint = ConstraintDefinition {
11366			name: "clusters_cluster_organization_id_name_uniq".to_string(),
11367			constraint_type: "unique".to_string(),
11368			fields: vec!["organization_id".to_string(), "name".to_string()],
11369			expression: None,
11370			foreign_key_info: None,
11371		};
11372		let mut from_model = build_model_state(
11373			"clusters",
11374			"Cluster",
11375			vec![id_field.clone(), org_field.clone(), name_field.clone()],
11376			Vec::new(),
11377			vec![unique_constraint],
11378		);
11379		from_model.table_name = "clusters_cluster".to_string();
11380
11381		let mut to_model = build_model_state(
11382			"clusters",
11383			"Cluster",
11384			vec![id_field, org_field, name_field],
11385			Vec::new(),
11386			Vec::new(),
11387		);
11388		to_model.table_name = "clusters_cluster".to_string();
11389
11390		let from_state = build_project_state(vec![(
11391			("clusters".to_string(), "Cluster".to_string()),
11392			from_model,
11393		)]);
11394		let to_state = build_project_state(vec![(
11395			("clusters".to_string(), "Cluster".to_string()),
11396			to_model,
11397		)]);
11398		let detector = MigrationAutodetector::new(from_state, to_state);
11399
11400		// Act
11401		let migrations = detector.generate_migrations();
11402
11403		// Assert
11404		assert_eq!(
11405			migrations.len(),
11406			1,
11407			"expected exactly one Migration, got: {:?}",
11408			migrations
11409		);
11410		assert_eq!(migrations[0].app_label, "clusters");
11411		assert_eq!(
11412			migrations[0].operations.len(),
11413			1,
11414			"expected exactly one operation in the migration, got: {:?}",
11415			migrations[0].operations
11416		);
11417		let super::super::Operation::DropConstraint {
11418			table,
11419			constraint_name,
11420		} = &migrations[0].operations[0]
11421		else {
11422			panic!(
11423				"expected Operation::DropConstraint, got: {:?}",
11424				migrations[0].operations[0]
11425			);
11426		};
11427		assert_eq!(table, "clusters_cluster");
11428		assert_eq!(
11429			constraint_name,
11430			"clusters_cluster_organization_id_name_uniq"
11431		);
11432	}
11433
11434	#[rstest]
11435	fn shared_per_app_emissions_are_consistent_between_generate_paths() {
11436		// Arrange — regression for issue #4040 (structural).
11437		//
11438		// Issue #4040 was caused by `generate_operations()` and
11439		// `generate_migrations()` carrying parallel-but-divergent
11440		// per-change-set emission loops; PR #3998 updated only the former.
11441		// After the structural fix both methods route through
11442		// `emit_shared_per_app_operations()`, so the shared subset of ops
11443		// (CreateTable / column ops / constraint ops / auto-increment
11444		// resets) MUST always agree.
11445		//
11446		// This test exercises a representative scenario: an existing model
11447		// gains a unique constraint AND a new column. Both emissions must
11448		// appear identically in both methods. M2M / rename / move
11449		// divergences are intentionally not exercised here because they
11450		// remain method-specific by design.
11451		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11452		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
11453		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
11454		let new_col = FieldState::new("region", super::super::FieldType::VarChar(64), false);
11455
11456		let mut from_model = build_model_state(
11457			"clusters",
11458			"Cluster",
11459			vec![id_field.clone(), org_field.clone(), name_field.clone()],
11460			Vec::new(),
11461			Vec::new(),
11462		);
11463		from_model.table_name = "clusters_cluster".to_string();
11464
11465		let unique_constraint = ConstraintDefinition {
11466			name: "clusters_cluster_organization_id_name_uniq".to_string(),
11467			constraint_type: "unique".to_string(),
11468			fields: vec!["organization_id".to_string(), "name".to_string()],
11469			expression: None,
11470			foreign_key_info: None,
11471		};
11472		let mut to_model = build_model_state(
11473			"clusters",
11474			"Cluster",
11475			vec![id_field, org_field, name_field, new_col],
11476			Vec::new(),
11477			vec![unique_constraint],
11478		);
11479		to_model.table_name = "clusters_cluster".to_string();
11480
11481		let from_state = build_project_state(vec![(
11482			("clusters".to_string(), "Cluster".to_string()),
11483			from_model,
11484		)]);
11485		let to_state = build_project_state(vec![(
11486			("clusters".to_string(), "Cluster".to_string()),
11487			to_model,
11488		)]);
11489		let detector = MigrationAutodetector::new(from_state, to_state);
11490
11491		// Act
11492		let ops = detector.generate_operations();
11493		let migrations = detector.generate_migrations();
11494
11495		// Assert — flatten migrations into the same shape as `ops` and
11496		// compare as multisets (order is determined by per-app dependency
11497		// sort, which is the same algorithm but called with a different
11498		// scope, so we compare unordered).
11499		let mig_ops: Vec<&super::super::Operation> = migrations
11500			.iter()
11501			.flat_map(|m| m.operations.iter())
11502			.collect();
11503
11504		assert_eq!(
11505			ops.len(),
11506			mig_ops.len(),
11507			"shared per-app emissions diverged between generate_operations() ({:?}) and generate_migrations() ({:?})",
11508			ops,
11509			mig_ops
11510		);
11511		// Every op produced by generate_operations() must also appear in
11512		// generate_migrations() output.
11513		for op in &ops {
11514			assert!(
11515				mig_ops.iter().any(|m| *m == op),
11516				"generate_operations() produced {:?} but generate_migrations() did not",
11517				op
11518			);
11519		}
11520		// And vice versa.
11521		for op in &mig_ops {
11522			assert!(
11523				ops.iter().any(|o| o == *op),
11524				"generate_migrations() produced {:?} but generate_operations() did not",
11525				op
11526			);
11527		}
11528	}
11529
11530	#[rstest]
11531	fn detect_added_composite_pk_does_not_double_emit_add_constraint() {
11532		// Arrange — adding a composite PK should be emitted by the
11533		// `CreateCompositePrimaryKey` path only. The new
11534		// `added_constraints` emitter must skip composite PKs to avoid
11535		// emitting a redundant AddConstraint alongside it.
11536		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11537		let tenant_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
11538
11539		let from_model = build_model_state(
11540			"billing",
11541			"Invoice",
11542			vec![id_field.clone(), tenant_field.clone()],
11543			Vec::new(),
11544			Vec::new(),
11545		);
11546		let composite_pk = ConstraintDefinition {
11547			name: "billing_invoice_pkey".to_string(),
11548			constraint_type: "primary_key".to_string(),
11549			fields: vec!["id".to_string(), "tenant_id".to_string()],
11550			expression: None,
11551			foreign_key_info: None,
11552		};
11553		let to_model = build_model_state(
11554			"billing",
11555			"Invoice",
11556			vec![id_field, tenant_field],
11557			Vec::new(),
11558			vec![composite_pk],
11559		);
11560		let from_state = build_project_state(vec![(
11561			("billing".to_string(), "Invoice".to_string()),
11562			from_model,
11563		)]);
11564		let to_state = build_project_state(vec![(
11565			("billing".to_string(), "Invoice".to_string()),
11566			to_model,
11567		)]);
11568		let detector = MigrationAutodetector::new(from_state, to_state);
11569
11570		// Act
11571		let operations = detector.generate_operations();
11572
11573		// Assert — exactly one operation, and it must be the composite PK
11574		// path, not a duplicate AddConstraint.
11575		assert_eq!(operations.len(), 1, "got: {:?}", operations);
11576		assert!(
11577			matches!(
11578				&operations[0],
11579				super::super::Operation::CreateCompositePrimaryKey { columns, .. }
11580					if columns == &["id".to_string(), "tenant_id".to_string()]
11581			),
11582			"expected only CreateCompositePrimaryKey, got: {:?}",
11583			operations
11584		);
11585	}
11586
11587	/// Reproduces reinhardt-web#4448: when the file-based `from_state`
11588	/// reconstruction stores a column's uniqueness as `params["unique"] =
11589	/// "true"` (the path through `ProjectState::apply_migration_operations`
11590	/// → `column_def_to_field_state`), and the live model registry's
11591	/// `to_state` materialises the same column as a synthesised
11592	/// single-field `ConstraintDefinition`, the autodetector must NOT emit
11593	/// an `Operation::AddConstraint` for the redundant UNIQUE.
11594	#[rstest]
11595	fn inline_unique_param_on_from_side_does_not_emit_redundant_add_constraint() {
11596		// Arrange — `from_state` mimics what `apply_migration_operations`
11597		// produces for `0001_initial.rs`: the `username` column carries
11598		// `params["unique"] = "true"` and the model has NO peer constraint.
11599		let mut username_field =
11600			FieldState::new("username", super::super::FieldType::VarChar(150), false);
11601		username_field
11602			.params
11603			.insert("unique".to_string(), "true".to_string());
11604		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11605		let from_model = build_model_state(
11606			"users",
11607			"User",
11608			vec![id_field.clone(), username_field.clone()],
11609			Vec::new(),
11610			Vec::new(),
11611		);
11612
11613		// `to_state` mimics what `ModelMetadata::to_model_state()` produces
11614		// after the fix: a single-field UNIQUE `ConstraintDefinition` named
11615		// per the `{table}_{field}_uniq` convention, without a duplicate
11616		// inline field flag.
11617		let synthesised = ConstraintDefinition {
11618			name: "users_username_uniq".to_string(),
11619			constraint_type: "unique".to_string(),
11620			fields: vec!["username".to_string()],
11621			expression: None,
11622			foreign_key_info: None,
11623		};
11624		let to_model = build_model_state(
11625			"users",
11626			"User",
11627			vec![
11628				id_field,
11629				FieldState::new("username", super::super::FieldType::VarChar(150), false),
11630			],
11631			Vec::new(),
11632			vec![synthesised],
11633		);
11634
11635		let from_state = build_project_state(vec![(
11636			("users".to_string(), "User".to_string()),
11637			from_model,
11638		)]);
11639		let to_state =
11640			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
11641		let detector = MigrationAutodetector::new(from_state, to_state);
11642
11643		// Act
11644		let operations = detector.generate_operations();
11645
11646		// Assert — no AddConstraint is emitted, because the column is
11647		// already covered by inline `params["unique"]` in `from_state`.
11648		assert!(
11649			operations
11650				.iter()
11651				.all(|op| !matches!(op, super::super::Operation::AddConstraint { .. })),
11652			"expected NO Operation::AddConstraint, got: {:?}",
11653			operations
11654		);
11655	}
11656
11657	#[test]
11658	fn legacy_inline_and_named_unique_removal_changes_the_field_definition() {
11659		// Arrange
11660		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11661		let mut legacy_username =
11662			FieldState::new("username", super::super::FieldType::VarChar(150), false);
11663		legacy_username
11664			.params
11665			.insert("unique".to_string(), "true".to_string());
11666		let named_constraint = ConstraintDefinition {
11667			name: "users_username_uniq".to_string(),
11668			constraint_type: "unique".to_string(),
11669			fields: vec!["username".to_string()],
11670			expression: None,
11671			foreign_key_info: None,
11672		};
11673		let from_model = build_model_state(
11674			"users",
11675			"User",
11676			vec![id_field.clone(), legacy_username],
11677			Vec::new(),
11678			vec![named_constraint],
11679		);
11680		let to_model = build_model_state(
11681			"users",
11682			"User",
11683			vec![
11684				id_field,
11685				FieldState::new("username", super::super::FieldType::VarChar(150), false),
11686			],
11687			Vec::new(),
11688			Vec::new(),
11689		);
11690		let detector = MigrationAutodetector::new(
11691			build_project_state(vec![(
11692				("users".to_string(), "User".to_string()),
11693				from_model,
11694			)]),
11695			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]),
11696		);
11697
11698		// Act
11699		let operations = detector.generate_operations();
11700
11701		// Assert
11702		assert!(operations.iter().any(|operation| {
11703			matches!(
11704				operation,
11705				super::super::Operation::AlterColumn {
11706					new_definition,
11707					column,
11708					..
11709				} if column == "username" && !new_definition.unique
11710			)
11711		}));
11712		assert!(operations.iter().any(|operation| {
11713			matches!(
11714				operation,
11715				super::super::Operation::DropConstraint { constraint_name, .. }
11716					if constraint_name == "users_username_uniq"
11717			)
11718		}));
11719	}
11720
11721	#[test]
11722	fn constraint_definition_parser_handles_quoted_unique_identifiers() {
11723		// Arrange
11724		let sql = "CONSTRAINT uq_profile UNIQUE (\"profile,id\", \"name)\", \"display\"\"name\")";
11725
11726		// Act
11727		let constraint = ProjectState::constraint_definition_from_sql(sql)
11728			.expect("quoted UNIQUE columns should parse");
11729
11730		// Assert
11731		assert_eq!(constraint.name, "uq_profile");
11732		assert_eq!(
11733			constraint.fields,
11734			vec!["profile,id", "name)", "display\"name"]
11735		);
11736	}
11737
11738	#[test]
11739	fn removed_unique_constraint_precedes_removed_column_without_alter_column() {
11740		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11741		let username_field =
11742			FieldState::new("username", super::super::FieldType::VarChar(150), false);
11743		let unique_constraint = ConstraintDefinition {
11744			name: "users_username_uniq".to_string(),
11745			constraint_type: "unique".to_string(),
11746			fields: vec!["username".to_string()],
11747			expression: None,
11748			foreign_key_info: None,
11749		};
11750		let from_model = build_model_state(
11751			"users",
11752			"User",
11753			vec![id_field.clone(), username_field],
11754			Vec::new(),
11755			vec![unique_constraint],
11756		);
11757		let to_model = build_model_state("users", "User", vec![id_field], Vec::new(), Vec::new());
11758		let detector = MigrationAutodetector::new(
11759			build_project_state(vec![(
11760				("users".to_string(), "User".to_string()),
11761				from_model,
11762			)]),
11763			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]),
11764		);
11765
11766		let operations = detector.generate_operations();
11767		assert_eq!(operations.len(), 2, "unexpected operations: {operations:?}");
11768		assert!(matches!(
11769			&operations[0],
11770			super::super::Operation::DropConstraint { constraint_name, .. }
11771				if constraint_name == "users_username_uniq"
11772		));
11773		assert!(matches!(
11774			&operations[1],
11775			super::super::Operation::DropColumn { column, .. } if column == "username"
11776		));
11777		assert!(
11778			operations
11779				.iter()
11780				.all(|operation| !matches!(operation, super::super::Operation::AlterColumn { .. }))
11781		);
11782	}
11783
11784	/// Reproduces the DB-introspection variant of reinhardt-web#4448:
11785	/// `from_state` carries a single-field UNIQUE constraint with a
11786	/// dialect-specific auto-name (e.g. SQLite's
11787	/// `sqlite_autoindex_users_1`), and `to_state` declares the same
11788	/// column's UNIQUE with the table-derived name
11789	/// (`users_username_uniq`). The names differ but the semantics
11790	/// are identical — no `AddConstraint` must be emitted.
11791	#[rstest]
11792	fn single_field_unique_constraint_renames_do_not_emit_redundant_add_constraint() {
11793		// Arrange
11794		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11795		let username_field =
11796			FieldState::new("username", super::super::FieldType::VarChar(150), false);
11797		let auto_named = ConstraintDefinition {
11798			name: "sqlite_autoindex_users_1".to_string(),
11799			constraint_type: "unique".to_string(),
11800			fields: vec!["username".to_string()],
11801			expression: None,
11802			foreign_key_info: None,
11803		};
11804		let model_named = ConstraintDefinition {
11805			name: "users_username_uniq".to_string(),
11806			constraint_type: "unique".to_string(),
11807			fields: vec!["username".to_string()],
11808			expression: None,
11809			foreign_key_info: None,
11810		};
11811		let from_model = build_model_state(
11812			"users",
11813			"User",
11814			vec![id_field.clone(), username_field.clone()],
11815			Vec::new(),
11816			vec![auto_named],
11817		);
11818		let to_model = build_model_state(
11819			"users",
11820			"User",
11821			vec![id_field, username_field],
11822			Vec::new(),
11823			vec![model_named],
11824		);
11825		let from_state = build_project_state(vec![(
11826			("users".to_string(), "User".to_string()),
11827			from_model,
11828		)]);
11829		let to_state =
11830			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
11831		let detector = MigrationAutodetector::new(from_state, to_state);
11832
11833		// Act
11834		let operations = detector.generate_operations();
11835
11836		// Assert — neither AddConstraint nor DropConstraint is emitted.
11837		// A pure rename of an internal constraint name on a single-column
11838		// UNIQUE is treated as a no-op; emitting either would be invalid on
11839		// SQLite (no ALTER TABLE ADD CONSTRAINT) and would leave duplicate
11840		// constraints on dialects that recreate the table.
11841		let constraint_ops: Vec<_> = operations
11842			.iter()
11843			.filter(|op| {
11844				matches!(
11845					op,
11846					super::super::Operation::AddConstraint { .. }
11847						| super::super::Operation::DropConstraint { .. }
11848				)
11849			})
11850			.collect();
11851		assert!(
11852			constraint_ops.is_empty(),
11853			"expected no Add/DropConstraint ops, got: {:?}",
11854			constraint_ops
11855		);
11856	}
11857
11858	/// Symmetric guard for reinhardt-web#4448: when `from_state` has a
11859	/// single-field UNIQUE constraint and `to_state` represents the same
11860	/// uniqueness inline via `params["unique"] = "true"` only, no
11861	/// `DropConstraint` must be emitted. Without the symmetric check in
11862	/// `detect_removed_constraints` the fix would shift the redundancy
11863	/// from `AddConstraint` into `DropConstraint`.
11864	#[rstest]
11865	fn from_side_unique_constraint_matched_by_inline_unique_on_to_side_emits_no_drop() {
11866		// Arrange
11867		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
11868		let mut username_field =
11869			FieldState::new("username", super::super::FieldType::VarChar(150), false);
11870		username_field
11871			.params
11872			.insert("unique".to_string(), "true".to_string());
11873		let unique_constraint = ConstraintDefinition {
11874			name: "users_username_uniq".to_string(),
11875			constraint_type: "unique".to_string(),
11876			fields: vec!["username".to_string()],
11877			expression: None,
11878			foreign_key_info: None,
11879		};
11880		// from_state: constraint present, field has NO inline unique param.
11881		let bare_username =
11882			FieldState::new("username", super::super::FieldType::VarChar(150), false);
11883		let from_model = build_model_state(
11884			"users",
11885			"User",
11886			vec![id_field.clone(), bare_username],
11887			Vec::new(),
11888			vec![unique_constraint],
11889		);
11890		// to_state: no peer constraint, inline param only.
11891		let to_model = build_model_state(
11892			"users",
11893			"User",
11894			vec![id_field, username_field],
11895			Vec::new(),
11896			Vec::new(),
11897		);
11898		let from_state = build_project_state(vec![(
11899			("users".to_string(), "User".to_string()),
11900			from_model,
11901		)]);
11902		let to_state =
11903			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
11904		let detector = MigrationAutodetector::new(from_state, to_state);
11905
11906		// Act
11907		let operations = detector.generate_operations();
11908
11909		// Assert — no DropConstraint emitted; the inline `params["unique"]`
11910		// on the to-side already covers the column.
11911		assert!(
11912			operations
11913				.iter()
11914				.all(|op| !matches!(op, super::super::Operation::DropConstraint { .. })),
11915			"expected NO Operation::DropConstraint, got: {:?}",
11916			operations
11917		);
11918	}
11919
11920	/// Direct unit test for the dedup pass
11921	/// `MigrationAutodetector::dedup_redundant_unique_add_constraints`.
11922	/// Manually constructs a per-app operation list where an
11923	/// `Operation::AddColumn { column.unique = true }` is followed by a
11924	/// peer `Operation::AddConstraint` that ascribes a UNIQUE to the same
11925	/// column. The dedup pass must drop the redundant `AddConstraint`,
11926	/// regardless of how it got there. Second safety net for
11927	/// reinhardt-web#4448.
11928	#[rstest]
11929	fn dedup_pass_drops_add_constraint_redundant_with_unique_add_column() {
11930		// Arrange
11931		let ops = vec![
11932			super::super::Operation::AddColumn {
11933				table: "users".to_string(),
11934				column: super::super::ColumnDefinition {
11935					name: "username".to_string(),
11936					type_definition: super::super::FieldType::VarChar(150),
11937					not_null: true,
11938					unique: true,
11939					primary_key: false,
11940					auto_increment: false,
11941					default: None,
11942				},
11943				mysql_options: None,
11944			},
11945			super::super::Operation::AddConstraint {
11946				table: "users".to_string(),
11947				constraint_sql: "CONSTRAINT users_username_uniq UNIQUE (username)".to_string(),
11948			},
11949		];
11950		let mut by_app: std::collections::BTreeMap<String, Vec<super::super::Operation>> =
11951			std::collections::BTreeMap::new();
11952		by_app.insert("users".to_string(), ops);
11953
11954		// Act
11955		MigrationAutodetector::dedup_redundant_unique_add_constraints(&mut by_app);
11956
11957		// Assert — only AddColumn survives; the AddConstraint is dropped.
11958		let remaining = &by_app["users"];
11959		assert_eq!(
11960			remaining.len(),
11961			1,
11962			"expected one operation after dedup, got: {:?}",
11963			remaining
11964		);
11965		assert!(
11966			matches!(remaining[0], super::super::Operation::AddColumn { .. }),
11967			"expected the surviving op to be AddColumn, got: {:?}",
11968			remaining[0]
11969		);
11970	}
11971
11972	fn integer_id_field() -> FieldState {
11973		FieldState::new("id", super::super::FieldType::Integer, false)
11974	}
11975
11976	fn integer_fk_field(name: &str, referenced_table: &str) -> FieldState {
11977		FieldState::with_foreign_key(
11978			name,
11979			super::super::FieldType::Integer,
11980			false,
11981			ForeignKeyInfo {
11982				referenced_table: referenced_table.to_string(),
11983				referenced_column: "id".to_string(),
11984				on_delete: ForeignKeyAction::Cascade,
11985				on_update: ForeignKeyAction::Cascade,
11986			},
11987		)
11988	}
11989
11990	fn model_with_table(
11991		app_label: &str,
11992		name: &str,
11993		table_name: &str,
11994		fields: Vec<FieldState>,
11995	) -> ModelState {
11996		let mut model = ModelState::new(app_label, name);
11997		model.table_name = table_name.to_string();
11998		for field in fields {
11999			let field_name = field.name.clone();
12000			model.add_field(field);
12001			if model
12002				.fields
12003				.get(&field_name)
12004				.and_then(|field| field.foreign_key.as_ref())
12005				.is_some()
12006			{
12007				model.add_foreign_key_constraint_from_field(&field_name);
12008			}
12009		}
12010		model
12011	}
12012
12013	fn create_table_names(migration: &super::super::Migration) -> Vec<String> {
12014		migration
12015			.operations
12016			.iter()
12017			.filter_map(|operation| match operation {
12018				super::super::Operation::CreateTable { name, .. } => Some(name.clone()),
12019				_ => None,
12020			})
12021			.collect()
12022	}
12023
12024	#[rstest]
12025	fn detect_model_dependencies_reads_scalar_foreign_key_metadata() {
12026		// Arrange: FK ID column keeps a scalar field type; the relationship
12027		// lives on FieldState.foreign_key, which alphabetical detection missed.
12028		let mut to_state = ProjectState::new();
12029		to_state.add_model(model_with_table(
12030			"auth",
12031			"User",
12032			"auth_users",
12033			vec![integer_id_field()],
12034		));
12035		to_state.add_model(model_with_table(
12036			"auth",
12037			"ApiKey",
12038			"auth_api_keys",
12039			vec![
12040				integer_id_field(),
12041				integer_fk_field("user_id", "auth_users"),
12042			],
12043		));
12044		let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
12045
12046		// Act
12047		let changes = detector.detect_changes();
12048
12049		// Assert
12050		let deps = changes
12051			.model_dependencies
12052			.get(&("auth".to_string(), "ApiKey".to_string()))
12053			.expect("ApiKey must record a dependency on User");
12054		assert_eq!(
12055			deps,
12056			&vec![("auth".to_string(), "User".to_string())],
12057			"scalar Integer FK columns must contribute model_dependencies"
12058		);
12059	}
12060
12061	#[rstest]
12062	fn generate_migrations_orders_same_app_create_tables_by_foreign_key() {
12063		// Arrange: auth_api_keys sorts before auth_users lexicographically.
12064		let mut to_state = ProjectState::new();
12065		to_state.add_model(model_with_table(
12066			"auth",
12067			"ApiKey",
12068			"auth_api_keys",
12069			vec![
12070				integer_id_field(),
12071				integer_fk_field("user_id", "auth_users"),
12072			],
12073		));
12074		to_state.add_model(model_with_table(
12075			"auth",
12076			"User",
12077			"auth_users",
12078			vec![integer_id_field()],
12079		));
12080		let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
12081
12082		// Act
12083		let migrations = detector.generate_migrations();
12084		let operations = detector.generate_operations();
12085
12086		// Assert
12087		let auth = migrations
12088			.iter()
12089			.find(|migration| migration.app_label == "auth")
12090			.expect("auth migration");
12091		let table_names = create_table_names(auth);
12092		let users = table_names
12093			.iter()
12094			.position(|name| name == "auth_users")
12095			.expect("auth_users CreateTable");
12096		let api_keys = table_names
12097			.iter()
12098			.position(|name| name == "auth_api_keys")
12099			.expect("auth_api_keys CreateTable");
12100		assert!(
12101			users < api_keys,
12102			"auth_users must be created before auth_api_keys, got {table_names:?}"
12103		);
12104
12105		let operation_tables: Vec<String> = operations
12106			.iter()
12107			.filter_map(|operation| match operation {
12108				super::super::Operation::CreateTable { name, .. } => Some(name.clone()),
12109				_ => None,
12110			})
12111			.collect();
12112		let users = operation_tables
12113			.iter()
12114			.position(|name| name == "auth_users")
12115			.expect("auth_users in generate_operations");
12116		let api_keys = operation_tables
12117			.iter()
12118			.position(|name| name == "auth_api_keys")
12119			.expect("auth_api_keys in generate_operations");
12120		assert!(
12121			users < api_keys,
12122			"generate_operations must also emit auth_users before auth_api_keys, got {operation_tables:?}"
12123		);
12124	}
12125
12126	#[rstest]
12127	fn generate_migrations_records_cross_app_foreign_key_graph() {
12128		// Arrange: auth <- organizations <- clusters <- deployments <- github
12129		let mut to_state = ProjectState::new();
12130		to_state.add_model(model_with_table(
12131			"auth",
12132			"User",
12133			"auth_users",
12134			vec![integer_id_field()],
12135		));
12136		to_state.add_model(model_with_table(
12137			"organizations",
12138			"Organization",
12139			"organizations",
12140			vec![
12141				integer_id_field(),
12142				integer_fk_field("owner_id", "auth_users"),
12143			],
12144		));
12145		to_state.add_model(model_with_table(
12146			"clusters",
12147			"Cluster",
12148			"clusters",
12149			vec![
12150				integer_id_field(),
12151				integer_fk_field("organization_id", "organizations"),
12152			],
12153		));
12154		to_state.add_model(model_with_table(
12155			"deployments",
12156			"Deployment",
12157			"deployments",
12158			vec![
12159				integer_id_field(),
12160				integer_fk_field("organization_id", "organizations"),
12161				integer_fk_field("cluster_id", "clusters"),
12162			],
12163		));
12164		to_state.add_model(model_with_table(
12165			"github",
12166			"Project",
12167			"github_projects",
12168			vec![
12169				integer_id_field(),
12170				integer_fk_field("organization_id", "organizations"),
12171				integer_fk_field("deployment_id", "deployments"),
12172			],
12173		));
12174		let detector = MigrationAutodetector::new(ProjectState::new(), to_state.clone());
12175
12176		// Act
12177		let changes = detector.detect_changes();
12178		let migrations = detector.generate_migrations();
12179		let github_ops = migrations
12180			.iter()
12181			.find(|migration| migration.app_label == "github")
12182			.expect("github migration")
12183			.operations
12184			.as_slice();
12185		let providers =
12186			MigrationAutodetector::foreign_key_provider_apps(&to_state, github_ops, "github");
12187		let second_pass =
12188			MigrationAutodetector::new(to_state.clone(), to_state).generate_migrations();
12189
12190		// Assert
12191		assert_eq!(
12192			changes
12193				.model_dependencies
12194				.get(&("organizations".to_string(), "Organization".to_string()))
12195				.expect("organizations depends on auth"),
12196			&vec![("auth".to_string(), "User".to_string())]
12197		);
12198		let cluster_deps = changes
12199			.model_dependencies
12200			.get(&("clusters".to_string(), "Cluster".to_string()))
12201			.expect("clusters depends on organizations");
12202		assert_eq!(
12203			cluster_deps,
12204			&vec![("organizations".to_string(), "Organization".to_string())]
12205		);
12206		let deployment_deps = changes
12207			.model_dependencies
12208			.get(&("deployments".to_string(), "Deployment".to_string()))
12209			.expect("deployments depends on organizations and clusters");
12210		assert!(
12211			deployment_deps.contains(&("organizations".to_string(), "Organization".to_string()))
12212		);
12213		assert!(deployment_deps.contains(&("clusters".to_string(), "Cluster".to_string())));
12214		assert_eq!(
12215			providers,
12216			vec!["deployments".to_string(), "organizations".to_string()]
12217		);
12218		assert!(
12219			second_pass.is_empty(),
12220			"second autodetect against the same state must not emit extra operations, got {second_pass:?}"
12221		);
12222	}
12223
12224	#[rstest]
12225	fn generate_migrations_survives_circular_foreign_keys() {
12226		// Arrange: A <-> B. Cycles must warn rather than panic, and both
12227		// tables must still be created.
12228		let mut to_state = ProjectState::new();
12229		to_state.add_model(model_with_table(
12230			"cycles",
12231			"Alpha",
12232			"cycles_alpha",
12233			vec![
12234				integer_id_field(),
12235				integer_fk_field("beta_id", "cycles_beta"),
12236			],
12237		));
12238		to_state.add_model(model_with_table(
12239			"cycles",
12240			"Beta",
12241			"cycles_beta",
12242			vec![
12243				integer_id_field(),
12244				integer_fk_field("alpha_id", "cycles_alpha"),
12245			],
12246		));
12247		let detector = MigrationAutodetector::new(ProjectState::new(), to_state);
12248
12249		// Act
12250		let migrations = detector.generate_migrations();
12251
12252		// Assert
12253		let cycle_migration = migrations
12254			.iter()
12255			.find(|migration| migration.app_label == "cycles")
12256			.expect("cycles migration");
12257		let tables = create_table_names(cycle_migration);
12258		assert!(tables.contains(&"cycles_alpha".to_string()));
12259		assert!(tables.contains(&"cycles_beta".to_string()));
12260	}
12261}