Skip to main content

reinhardt_db/migrations/
operations.rs

1//! Migration operations
2//!
3//! This module provides various migration operations inspired by Django's migration system.
4//! Operations are organized into three categories:
5//!
6//! - **Model operations** (`models`): Create, delete, and rename models (tables)
7//! - **Field operations** (`fields`): Add, remove, alter, and rename fields (columns)
8//! - **Special operations** (`special`): Run raw SQL or custom code
9//!
10//! # Example
11//!
12//! ```rust
13//! use reinhardt_db::migrations::operations::{
14//!     models::{CreateModel, DeleteModel},
15//!     fields::{AddField, RemoveField},
16//!     special::RunSQL,
17//!     FieldDefinition,
18//! };
19//! use reinhardt_db::migrations::{ProjectState, FieldType};
20//!
21//! let mut state = ProjectState::new();
22//!
23//! // Create a model
24//! let create = CreateModel::new(
25//!     "User",
26//!     vec![
27//!         FieldDefinition::new("id", FieldType::Integer, true, false, Option::<&str>::None),
28//!         FieldDefinition::new("name", FieldType::VarChar(100), false, false, Option::<&str>::None),
29//!     ],
30//! );
31//! create.state_forwards("myapp", &mut state);
32//!
33//! // Add a field
34//! let add = AddField::new("User", FieldDefinition::new("email", FieldType::VarChar(255), false, false, Option::<&str>::None));
35//! add.state_forwards("myapp", &mut state);
36//!
37//! // Run custom SQL
38//! let sql = RunSQL::new("CREATE INDEX idx_email ON myapp_user(email)");
39//! ```
40
41pub mod fields;
42pub mod models;
43pub mod postgres;
44pub mod special;
45mod to_tokens;
46
47// Re-export commonly used types for convenience
48pub use fields::{AddField, AlterField, RemoveField, RenameField};
49pub use models::{CreateModel, DeleteModel, FieldDefinition, MoveModel, RenameModel};
50pub use postgres::{CreateCollation, CreateExtension, DropExtension};
51pub use special::{RunCode, RunSQL, StateOperation};
52
53// Legacy types for backward compatibility
54// These are maintained from the original operations.rs
55use super::{FieldState, FieldType, ModelState, ProjectState};
56use pg_escape::{quote_identifier, quote_literal};
57use reinhardt_query::prelude::{
58	Alias, AlterTableStatement, ColumnDef, CreateIndexStatement, CreateTableStatement,
59	DropIndexStatement, DropTableStatement, Query, SimpleExpr, Value,
60};
61use serde::{Deserialize, Serialize};
62
63/// Index type for database indexes
64///
65/// Specifies the type of index to create. Different index types have different
66/// performance characteristics and support different operators.
67///
68/// # Examples
69///
70/// ```rust
71/// use reinhardt_db::migrations::operations::IndexType;
72///
73/// // B-Tree is the default, best for equality and range queries
74/// let btree = IndexType::BTree;
75///
76/// // Hash is best for simple equality comparisons
77/// let hash = IndexType::Hash;
78///
79/// // GIN is best for containment operators (arrays, JSONB)
80/// let gin = IndexType::Gin;
81/// ```
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
83#[serde(rename_all = "lowercase")]
84pub enum IndexType {
85	/// B-tree index (default)
86	///
87	/// Best for: equality and range queries (=, <, >, <=, >=, BETWEEN)
88	/// Supported by: All databases
89	#[default]
90	BTree,
91
92	/// Hash index
93	///
94	/// Best for: simple equality comparisons (=)
95	/// Supported by: PostgreSQL, MySQL
96	Hash,
97
98	/// GIN (Generalized Inverted Index)
99	///
100	/// Best for: composite values (arrays, JSONB, full-text search)
101	/// Supported by: PostgreSQL
102	Gin,
103
104	/// GiST (Generalized Search Tree)
105	///
106	/// Best for: geometric data, full-text search, range types
107	/// Supported by: PostgreSQL
108	Gist,
109
110	/// BRIN (Block Range Index)
111	///
112	/// Best for: very large tables with naturally ordered data
113	/// Supported by: PostgreSQL
114	Brin,
115
116	/// Full-text index
117	///
118	/// Best for: full-text search on text columns
119	/// Supported by: MySQL
120	Fulltext,
121
122	/// Spatial index
123	///
124	/// Best for: geometric/geographic data
125	/// Supported by: MySQL
126	Spatial,
127}
128
129impl std::fmt::Display for IndexType {
130	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131		match self {
132			IndexType::BTree => write!(f, "btree"),
133			IndexType::Hash => write!(f, "hash"),
134			IndexType::Gin => write!(f, "gin"),
135			IndexType::Gist => write!(f, "gist"),
136			IndexType::Brin => write!(f, "brin"),
137			IndexType::Fulltext => write!(f, "fulltext"),
138			IndexType::Spatial => write!(f, "spatial"),
139		}
140	}
141}
142
143pub(crate) fn generated_index_name(
144	table: &str,
145	columns: &[String],
146	expressions: Option<&[String]>,
147) -> String {
148	let suffix = if expressions.is_some_and(|expressions| !expressions.is_empty()) {
149		"expr".to_string()
150	} else {
151		columns.join("_")
152	};
153	format!("idx_{table}_{suffix}")
154}
155// ============================================================================
156// MySQL-Specific ALTER TABLE Options
157// ============================================================================
158
159/// MySQL ALTER TABLE algorithm types
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
161#[serde(rename_all = "UPPERCASE")]
162pub enum MySqlAlgorithm {
163	/// Instant variant.
164	Instant,
165	/// Inplace variant.
166	Inplace,
167	/// Copy variant.
168	Copy,
169	#[default]
170	/// Default variant.
171	Default,
172}
173
174impl std::fmt::Display for MySqlAlgorithm {
175	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176		match self {
177			MySqlAlgorithm::Instant => write!(f, "INSTANT"),
178			MySqlAlgorithm::Inplace => write!(f, "INPLACE"),
179			MySqlAlgorithm::Copy => write!(f, "COPY"),
180			MySqlAlgorithm::Default => write!(f, "DEFAULT"),
181		}
182	}
183}
184
185/// MySQL ALTER TABLE lock types
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
187#[serde(rename_all = "UPPERCASE")]
188pub enum MySqlLock {
189	/// None variant.
190	None,
191	/// Shared variant.
192	Shared,
193	/// Exclusive variant.
194	Exclusive,
195	#[default]
196	/// Default variant.
197	Default,
198}
199
200impl std::fmt::Display for MySqlLock {
201	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202		match self {
203			MySqlLock::None => write!(f, "NONE"),
204			MySqlLock::Shared => write!(f, "SHARED"),
205			MySqlLock::Exclusive => write!(f, "EXCLUSIVE"),
206			MySqlLock::Default => write!(f, "DEFAULT"),
207		}
208	}
209}
210
211/// MySQL ALTER TABLE options
212#[non_exhaustive]
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
214pub struct AlterTableOptions {
215	#[serde(default, skip_serializing_if = "Option::is_none")]
216	/// The algorithm.
217	pub algorithm: Option<MySqlAlgorithm>,
218	#[serde(default, skip_serializing_if = "Option::is_none")]
219	/// The lock.
220	pub lock: Option<MySqlLock>,
221}
222
223impl AlterTableOptions {
224	/// Creates a new instance.
225	pub fn new() -> Self {
226		Self::default()
227	}
228	/// Sets the algorithm and returns self for chaining.
229	pub fn with_algorithm(mut self, algorithm: MySqlAlgorithm) -> Self {
230		self.algorithm = Some(algorithm);
231		self
232	}
233	/// Sets the lock and returns self for chaining.
234	pub fn with_lock(mut self, lock: MySqlLock) -> Self {
235		self.lock = Some(lock);
236		self
237	}
238	/// Returns the mpty.
239	pub fn is_empty(&self) -> bool {
240		self.algorithm.is_none() && self.lock.is_none()
241	}
242	/// Converts to sql suffix.
243	pub fn to_sql_suffix(&self) -> String {
244		let mut parts = Vec::new();
245		if let Some(algo) = &self.algorithm
246			&& *algo != MySqlAlgorithm::Default
247		{
248			parts.push(format!("ALGORITHM={}", algo));
249		}
250		if let Some(lock) = &self.lock
251			&& *lock != MySqlLock::Default
252		{
253			parts.push(format!("LOCK={}", lock));
254		}
255		if parts.is_empty() {
256			String::new()
257		} else {
258			format!(", {}", parts.join(", "))
259		}
260	}
261}
262
263// ============================================================================
264// MySQL Table Partitioning
265// ============================================================================
266
267/// Partition type for MySQL table partitioning
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
269#[serde(rename_all = "UPPERCASE")]
270pub enum PartitionType {
271	/// Range variant.
272	Range,
273	/// List variant.
274	List,
275	/// Hash variant.
276	Hash,
277	/// Key variant.
278	Key,
279}
280
281impl std::fmt::Display for PartitionType {
282	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283		match self {
284			PartitionType::Range => write!(f, "RANGE"),
285			PartitionType::List => write!(f, "LIST"),
286			PartitionType::Hash => write!(f, "HASH"),
287			PartitionType::Key => write!(f, "KEY"),
288		}
289	}
290}
291
292/// Partition value definition
293#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
294#[serde(tag = "type")]
295pub enum PartitionValues {
296	/// LessThan variant.
297	LessThan(String),
298	/// In variant.
299	In(Vec<String>),
300	/// ModuloCount variant.
301	ModuloCount(u32),
302}
303
304/// Individual partition definition
305#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
306pub struct PartitionDef {
307	/// The name.
308	pub name: String,
309	/// The values.
310	pub values: PartitionValues,
311}
312
313impl PartitionDef {
314	/// Creates a new instance.
315	pub fn new(name: impl Into<String>, values: PartitionValues) -> Self {
316		Self {
317			name: name.into(),
318			values,
319		}
320	}
321	/// Performs the less than operation.
322	pub fn less_than(name: impl Into<String>, value: impl Into<String>) -> Self {
323		Self::new(name, PartitionValues::LessThan(value.into()))
324	}
325	/// Performs the maxvalue operation.
326	pub fn maxvalue(name: impl Into<String>) -> Self {
327		Self::new(name, PartitionValues::LessThan("MAXVALUE".to_string()))
328	}
329	/// Performs the list in operation.
330	pub fn list_in(name: impl Into<String>, values: Vec<String>) -> Self {
331		Self::new(name, PartitionValues::In(values))
332	}
333}
334
335/// CockroachDB INTERLEAVE IN PARENT specification
336///
337/// Used to co-locate child table rows with parent table rows,
338/// improving join performance for hierarchical data.
339///
340/// **CockroachDB only**: This is ignored for other databases.
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
342pub struct InterleaveSpec {
343	/// Parent table name
344	pub parent_table: String,
345	/// Columns in the parent table to interleave with
346	pub parent_columns: Vec<String>,
347}
348
349/// Table partitioning options
350#[non_exhaustive]
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct PartitionOptions {
353	/// The partition type.
354	pub partition_type: PartitionType,
355	/// The column.
356	pub column: String,
357	/// The partitions.
358	pub partitions: Vec<PartitionDef>,
359}
360
361impl PartitionOptions {
362	/// Creates a new instance.
363	pub fn new(
364		partition_type: PartitionType,
365		column: impl Into<String>,
366		partitions: Vec<PartitionDef>,
367	) -> Self {
368		Self {
369			partition_type,
370			column: column.into(),
371			partitions,
372		}
373	}
374	/// Performs the range operation.
375	pub fn range(column: impl Into<String>, partitions: Vec<PartitionDef>) -> Self {
376		Self::new(PartitionType::Range, column, partitions)
377	}
378	/// Performs the list operation.
379	pub fn list(column: impl Into<String>, partitions: Vec<PartitionDef>) -> Self {
380		Self::new(PartitionType::List, column, partitions)
381	}
382	/// Performs the hash operation.
383	pub fn hash(column: impl Into<String>, num_partitions: u32) -> Self {
384		Self::new(
385			PartitionType::Hash,
386			column,
387			vec![PartitionDef::new(
388				"",
389				PartitionValues::ModuloCount(num_partitions),
390			)],
391		)
392	}
393	/// Performs the key operation.
394	pub fn key(column: impl Into<String>, num_partitions: u32) -> Self {
395		Self::new(
396			PartitionType::Key,
397			column,
398			vec![PartitionDef::new(
399				"",
400				PartitionValues::ModuloCount(num_partitions),
401			)],
402		)
403	}
404	/// Converts to sql.
405	pub fn to_sql(&self) -> String {
406		let mut sql = format!("PARTITION BY {}({})", self.partition_type, self.column);
407		match self.partition_type {
408			PartitionType::Hash | PartitionType::Key => {
409				if let Some(p) = self.partitions.first()
410					&& let PartitionValues::ModuloCount(n) = &p.values
411				{
412					sql.push_str(&format!(" PARTITIONS {}", n));
413				}
414			}
415			PartitionType::Range | PartitionType::List => {
416				sql.push_str(" (");
417				let defs: Vec<String> = self
418					.partitions
419					.iter()
420					.map(|p| {
421						let vals = match &p.values {
422							PartitionValues::LessThan(v) => {
423								if v == "MAXVALUE" {
424									"VALUES LESS THAN MAXVALUE".to_string()
425								} else {
426									format!("VALUES LESS THAN ('{}')", v)
427								}
428							}
429							PartitionValues::In(v) => format!(
430								"VALUES IN ({})",
431								v.iter()
432									.map(|x| format!("'{}'", x))
433									.collect::<Vec<_>>()
434									.join(", ")
435							),
436							PartitionValues::ModuloCount(_) => String::new(),
437						};
438						format!("PARTITION {} {}", p.name, vals)
439					})
440					.collect();
441				sql.push_str(&defs.join(", "));
442				sql.push(')');
443			}
444		}
445		sql
446	}
447}
448
449/// Deferrable constraint option for PostgreSQL
450///
451/// Controls when constraint checking is performed during a transaction.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
453#[serde(rename_all = "lowercase")]
454pub enum DeferrableOption {
455	/// DEFERRABLE INITIALLY IMMEDIATE
456	Immediate,
457	/// DEFERRABLE INITIALLY DEFERRED
458	Deferred,
459}
460
461impl std::fmt::Display for DeferrableOption {
462	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463		match self {
464			DeferrableOption::Immediate => write!(f, "DEFERRABLE INITIALLY IMMEDIATE"),
465			DeferrableOption::Deferred => write!(f, "DEFERRABLE INITIALLY DEFERRED"),
466		}
467	}
468}
469
470/// Constraint definition for tables
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
472#[serde(tag = "type")]
473pub enum Constraint {
474	/// PrimaryKey constraint
475	///
476	/// Used for composite primary keys defined at the table level.
477	/// Single-column primary keys are typically defined directly on the column.
478	PrimaryKey {
479		/// The constraint name.
480		name: String,
481		/// The columns that form the primary key.
482		columns: Vec<String>,
483	},
484	/// ForeignKey constraint
485	ForeignKey {
486		/// The constraint name.
487		name: String,
488		/// The columns in the referencing table.
489		columns: Vec<String>,
490		/// The referenced table name.
491		referenced_table: String,
492		/// The columns in the referenced table.
493		referenced_columns: Vec<String>,
494		/// Action on delete of the referenced row.
495		on_delete: super::ForeignKeyAction,
496		/// Action on update of the referenced row.
497		on_update: super::ForeignKeyAction,
498		/// Optional deferrable constraint option.
499		#[serde(default, skip_serializing_if = "Option::is_none")]
500		deferrable: Option<DeferrableOption>,
501	},
502	/// Unique constraint
503	Unique {
504		/// The constraint name.
505		name: String,
506		/// The columns that must be unique together.
507		columns: Vec<String>,
508	},
509	/// Check constraint
510	Check {
511		/// The constraint name.
512		name: String,
513		/// The SQL check expression.
514		expression: String,
515	},
516	/// OneToOne constraint (ForeignKey + Unique combination)
517	OneToOne {
518		/// The constraint name.
519		name: String,
520		/// The column in the referencing table.
521		column: String,
522		/// The referenced table name.
523		referenced_table: String,
524		/// The referenced column name.
525		referenced_column: String,
526		/// Action on delete of the referenced row.
527		on_delete: super::ForeignKeyAction,
528		/// Action on update of the referenced row.
529		on_update: super::ForeignKeyAction,
530		/// Optional deferrable constraint option.
531		#[serde(default, skip_serializing_if = "Option::is_none")]
532		deferrable: Option<DeferrableOption>,
533	},
534	/// ManyToMany relationship metadata (intermediate table reference)
535	ManyToMany {
536		/// The relationship name.
537		name: String,
538		/// The intermediate (through) table name.
539		through_table: String,
540		/// The column referencing the source table.
541		source_column: String,
542		/// The column referencing the target table.
543		target_column: String,
544		/// The target table name.
545		target_table: String,
546	},
547	/// Exclude constraint (PostgreSQL only)
548	Exclude {
549		/// The name.
550		name: String,
551		/// The elements.
552		elements: Vec<(String, String)>,
553		#[serde(default, skip_serializing_if = "Option::is_none")]
554		/// The using.
555		using: Option<String>,
556		#[serde(default, skip_serializing_if = "Option::is_none")]
557		/// The where clause.
558		where_clause: Option<String>,
559	},
560}
561
562impl std::fmt::Display for Constraint {
563	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564		match self {
565			Constraint::PrimaryKey { name, columns } => {
566				write!(
567					f,
568					"CONSTRAINT {} PRIMARY KEY ({})",
569					name,
570					columns.join(", ")
571				)
572			}
573			Constraint::ForeignKey {
574				name,
575				columns,
576				referenced_table,
577				referenced_columns,
578				on_delete,
579				on_update,
580				deferrable,
581			} => {
582				write!(
583					f,
584					"CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({}) ON DELETE {} ON UPDATE {}",
585					name,
586					columns.join(", "),
587					referenced_table,
588					referenced_columns.join(", "),
589					on_delete.to_sql_keyword(),
590					on_update.to_sql_keyword()
591				)?;
592				if let Some(defer_opt) = deferrable {
593					write!(f, " {}", defer_opt)?;
594				}
595				Ok(())
596			}
597			Constraint::Unique { name, columns } => {
598				let columns = columns
599					.iter()
600					.map(|column| quote_identifier(column))
601					.collect::<Vec<_>>()
602					.join(", ");
603				write!(f, "CONSTRAINT {} UNIQUE ({})", name, columns)
604			}
605			Constraint::Check { name, expression } => {
606				write!(f, "CONSTRAINT {} CHECK ({})", name, expression)
607			}
608			Constraint::OneToOne {
609				name,
610				column,
611				referenced_table,
612				referenced_column,
613				on_delete,
614				on_update,
615				deferrable,
616			} => {
617				write!(
618					f,
619					"CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({}) ON DELETE {} ON UPDATE {}",
620					name,
621					column,
622					referenced_table,
623					referenced_column,
624					on_delete.to_sql_keyword(),
625					on_update.to_sql_keyword()
626				)?;
627				if let Some(defer_opt) = deferrable {
628					write!(f, " {}", defer_opt)?;
629				}
630				write!(
631					f,
632					", CONSTRAINT {}_unique UNIQUE ({})",
633					name,
634					quote_identifier(column)
635				)
636			}
637			Constraint::ManyToMany { through_table, .. } => {
638				write!(f, "-- ManyToMany via {}", through_table)
639			}
640			Constraint::Exclude {
641				name,
642				elements,
643				using,
644				where_clause,
645			} => {
646				let elements_str: Vec<String> = elements
647					.iter()
648					.map(|(col, op)| format!("{} WITH {}", col, op))
649					.collect();
650				let using_str = using.as_deref().unwrap_or("gist");
651				if let Some(where_cl) = where_clause {
652					write!(
653						f,
654						"CONSTRAINT {} EXCLUDE USING {} ({}) WHERE ({})",
655						name,
656						using_str,
657						elements_str.join(", "),
658						where_cl
659					)
660				} else {
661					write!(
662						f,
663						"CONSTRAINT {} EXCLUDE USING {} ({})",
664						name,
665						using_str,
666						elements_str.join(", ")
667					)
668				}
669			}
670		}
671	}
672}
673
674/// Source for bulk data loading
675///
676/// Specifies where the data for bulk loading comes from.
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(tag = "type", content = "value")]
679pub enum BulkLoadSource {
680	/// Load from a file path
681	File(String),
682	/// Load from standard input (STDIN)
683	Stdin,
684	/// Load from a program's output (e.g., "gunzip -c file.csv.gz")
685	Program(String),
686}
687
688/// Format for bulk data loading
689///
690/// Specifies the format of the data being loaded.
691#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
692#[serde(rename_all = "lowercase")]
693pub enum BulkLoadFormat {
694	/// Plain text format (PostgreSQL default)
695	#[default]
696	Text,
697	/// CSV format
698	Csv,
699	/// Binary format (PostgreSQL-specific)
700	Binary,
701}
702
703impl std::fmt::Display for BulkLoadFormat {
704	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
705		match self {
706			BulkLoadFormat::Text => write!(f, "TEXT"),
707			BulkLoadFormat::Csv => write!(f, "CSV"),
708			BulkLoadFormat::Binary => write!(f, "BINARY"),
709		}
710	}
711}
712
713/// Options for bulk data loading
714///
715/// Provides fine-grained control over how data is parsed during bulk loading.
716#[non_exhaustive]
717#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
718pub struct BulkLoadOptions {
719	/// Field delimiter character (default: ',' for CSV, '\t' for TEXT)
720	#[serde(default, skip_serializing_if = "Option::is_none")]
721	pub delimiter: Option<char>,
722	/// String to represent NULL values
723	#[serde(default, skip_serializing_if = "Option::is_none")]
724	pub null_string: Option<String>,
725	/// Whether the file has a header row (CSV format)
726	#[serde(default)]
727	pub header: bool,
728	/// Columns to load into (if not all columns)
729	#[serde(default, skip_serializing_if = "Option::is_none")]
730	pub columns: Option<Vec<String>>,
731	/// Use LOCAL keyword (MySQL LOAD DATA LOCAL INFILE)
732	#[serde(default)]
733	pub local: bool,
734	/// Quote character for CSV (default: '"')
735	#[serde(default, skip_serializing_if = "Option::is_none")]
736	pub quote: Option<char>,
737	/// Escape character for CSV
738	#[serde(default, skip_serializing_if = "Option::is_none")]
739	pub escape: Option<char>,
740	/// Line terminator (default: '\n')
741	#[serde(default, skip_serializing_if = "Option::is_none")]
742	pub line_terminator: Option<String>,
743	/// Encoding of the file (MySQL-specific)
744	#[serde(default, skip_serializing_if = "Option::is_none")]
745	pub encoding: Option<String>,
746}
747
748impl BulkLoadOptions {
749	/// Create new BulkLoadOptions with default values
750	pub fn new() -> Self {
751		Self::default()
752	}
753
754	/// Set the field delimiter
755	pub fn with_delimiter(mut self, delimiter: char) -> Self {
756		self.delimiter = Some(delimiter);
757		self
758	}
759
760	/// Set the NULL string representation
761	pub fn with_null_string(mut self, null_string: impl Into<String>) -> Self {
762		self.null_string = Some(null_string.into());
763		self
764	}
765
766	/// Enable or disable header row
767	pub fn with_header(mut self, header: bool) -> Self {
768		self.header = header;
769		self
770	}
771
772	/// Set specific columns to load
773	pub fn with_columns(mut self, columns: Vec<String>) -> Self {
774		self.columns = Some(columns);
775		self
776	}
777
778	/// Enable LOCAL keyword for MySQL
779	pub fn with_local(mut self, local: bool) -> Self {
780		self.local = local;
781		self
782	}
783
784	/// Set the quote character for CSV
785	pub fn with_quote(mut self, quote: char) -> Self {
786		self.quote = Some(quote);
787		self
788	}
789
790	/// Set the escape character
791	pub fn with_escape(mut self, escape: char) -> Self {
792		self.escape = Some(escape);
793		self
794	}
795
796	/// Set the line terminator
797	pub fn with_line_terminator(mut self, terminator: impl Into<String>) -> Self {
798		self.line_terminator = Some(terminator.into());
799		self
800	}
801
802	/// Set the file encoding (MySQL-specific)
803	pub fn with_encoding(mut self, encoding: impl Into<String>) -> Self {
804		self.encoding = Some(encoding.into());
805		self
806	}
807}
808
809/// A migration operation (legacy enum for backward compatibility)
810///
811/// This enum is maintained for backward compatibility with existing code.
812/// New code should use the specific operation types from the `models`, `fields`,
813/// and `special` modules instead.
814#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
815#[serde(tag = "type")]
816pub enum Operation {
817	/// CreateTable variant.
818	CreateTable {
819		/// The name.
820		name: String,
821		/// The columns.
822		columns: Vec<ColumnDefinition>,
823		#[serde(default)]
824		/// The constraints.
825		constraints: Vec<Constraint>,
826		#[serde(default, skip_serializing_if = "Option::is_none")]
827		/// The without rowid.
828		without_rowid: Option<bool>,
829		#[serde(default, skip_serializing_if = "Option::is_none")]
830		/// The interleave in parent.
831		interleave_in_parent: Option<InterleaveSpec>,
832		#[serde(default, skip_serializing_if = "Option::is_none")]
833		/// The partition.
834		partition: Option<PartitionOptions>,
835	},
836	/// DropTable variant.
837	DropTable {
838		/// The name.
839		name: String,
840	},
841	/// AddColumn variant.
842	AddColumn {
843		/// The table.
844		table: String,
845		/// The column.
846		column: ColumnDefinition,
847		#[serde(default, skip_serializing_if = "Option::is_none")]
848		/// The mysql options.
849		mysql_options: Option<AlterTableOptions>,
850	},
851	/// DropColumn variant.
852	DropColumn {
853		/// The table.
854		table: String,
855		/// The column.
856		column: String,
857	},
858	/// AlterColumn variant.
859	AlterColumn {
860		/// The table.
861		table: String,
862		/// The column.
863		column: String,
864		/// Original column definition (before alteration).
865		/// This is required for generating accurate rollback SQL.
866		/// If None, rollback will attempt to reconstruct from ProjectState.
867		#[serde(default, skip_serializing_if = "Option::is_none")]
868		old_definition: Option<ColumnDefinition>,
869		/// The new definition.
870		new_definition: ColumnDefinition,
871		#[serde(default, skip_serializing_if = "Option::is_none")]
872		/// The mysql options.
873		mysql_options: Option<AlterTableOptions>,
874	},
875	/// RenameTable variant.
876	RenameTable {
877		/// The old name.
878		old_name: String,
879		/// The new name.
880		new_name: String,
881	},
882	/// RenameColumn variant.
883	RenameColumn {
884		/// The table.
885		table: String,
886		/// The old name.
887		old_name: String,
888		/// The new name.
889		new_name: String,
890	},
891	/// AddConstraint variant.
892	AddConstraint {
893		/// The table.
894		table: String,
895		/// The constraint sql.
896		constraint_sql: String,
897	},
898	/// DropConstraint variant.
899	DropConstraint {
900		/// The table.
901		table: String,
902		/// The constraint name.
903		constraint_name: String,
904	},
905	/// CreateIndex variant.
906	CreateIndex {
907		/// The table.
908		table: String,
909		/// The columns.
910		columns: Vec<String>,
911		/// The unique.
912		unique: bool,
913		/// Index type (B-Tree, Hash, GIN, GiST, etc.)
914		///
915		/// If not specified, the database will use its default index type (typically B-Tree).
916		#[serde(default, skip_serializing_if = "Option::is_none")]
917		index_type: Option<IndexType>,
918		/// Partial index condition (WHERE clause)
919		///
920		/// Creates a partial index that only indexes rows matching this condition.
921		/// Example: "status = 'active'" creates an index only for active rows.
922		#[serde(default, skip_serializing_if = "Option::is_none")]
923		where_clause: Option<String>,
924		/// Create index concurrently (PostgreSQL-specific)
925		///
926		/// When true, creates the index without locking the table for writes.
927		/// This is slower but allows concurrent operations during index creation.
928		#[serde(default)]
929		concurrently: bool,
930		/// Expression index (PostgreSQL, SQLite, MySQL 8.0+)
931		///
932		/// Index on computed expressions rather than simple column references.
933		/// When specified, these expressions are used instead of `columns`.
934		///
935		/// # Examples
936		///
937		/// ```rust,ignore
938		/// // Index on lowercase email for case-insensitive lookups
939		/// expressions: Some(vec!["LOWER(email)"]),
940		/// ```
941		///
942		/// **Note**: When `expressions` is Some, `columns` is ignored for SQL generation.
943		#[serde(default, skip_serializing_if = "Option::is_none")]
944		expressions: Option<Vec<String>>,
945		/// MySQL ALTER TABLE options (ALGORITHM, LOCK)
946		#[serde(default, skip_serializing_if = "Option::is_none")]
947		mysql_options: Option<AlterTableOptions>,
948		/// Operator class for index columns (PostgreSQL-specific)
949		///
950		/// Specifies a non-default operator class for the index.
951		/// Commonly used with extension-provided operator classes like `gin_trgm_ops`
952		/// for trigram similarity search with the pg_trgm extension.
953		///
954		/// # Examples
955		///
956		/// ```rust,ignore
957		/// // GIN index with trigram operator class for fuzzy text search
958		/// CreateIndex {
959		///     table: "products".to_string(),
960		///     columns: vec!["name".to_string()],
961		///     index_type: Some(IndexType::Gin),
962		///     operator_class: Some("gin_trgm_ops"),
963		///     ...
964		/// }
965		/// ```
966		#[serde(default, skip_serializing_if = "Option::is_none")]
967		operator_class: Option<String>,
968	},
969	/// Creates an index while retaining an explicit physical name.
970	CreateIndexRepair {
971		/// The table.
972		table: String,
973		/// Explicit physical index name. `None` uses the legacy generated name.
974		#[serde(default, skip_serializing_if = "Option::is_none")]
975		name: Option<String>,
976		/// The columns.
977		columns: Vec<String>,
978		/// Whether the index is unique.
979		unique: bool,
980		/// Index method.
981		#[serde(default, skip_serializing_if = "Option::is_none")]
982		index_type: Option<IndexType>,
983		/// Partial index condition.
984		#[serde(default, skip_serializing_if = "Option::is_none")]
985		where_clause: Option<String>,
986		/// Create index concurrently.
987		#[serde(default)]
988		concurrently: bool,
989		/// Expression-index definitions.
990		#[serde(default, skip_serializing_if = "Option::is_none")]
991		expressions: Option<Vec<String>>,
992		/// MySQL index options.
993		#[serde(default, skip_serializing_if = "Option::is_none")]
994		mysql_options: Option<AlterTableOptions>,
995		/// PostgreSQL operator class.
996		#[serde(default, skip_serializing_if = "Option::is_none")]
997		operator_class: Option<String>,
998	},
999	/// DropIndex variant.
1000	DropIndex {
1001		/// The table.
1002		table: String,
1003		/// The columns.
1004		columns: Vec<String>,
1005	},
1006	/// Drops an index while retaining its physical name and definition.
1007	DropNamedIndex {
1008		/// The table containing the index.
1009		table: String,
1010		/// Physical index name.
1011		name: String,
1012		/// Indexed columns.
1013		#[serde(default)]
1014		columns: Vec<String>,
1015		/// Whether the index is unique.
1016		#[serde(default)]
1017		unique: bool,
1018		/// Index method.
1019		#[serde(default, skip_serializing_if = "Option::is_none")]
1020		index_type: Option<IndexType>,
1021		/// Partial-index predicate.
1022		#[serde(default, skip_serializing_if = "Option::is_none")]
1023		where_clause: Option<String>,
1024		/// Whether the index was created concurrently.
1025		#[serde(default)]
1026		concurrently: bool,
1027		/// Expression-index definitions.
1028		#[serde(default, skip_serializing_if = "Option::is_none")]
1029		expressions: Option<Vec<String>>,
1030		/// MySQL index options.
1031		#[serde(default, skip_serializing_if = "Option::is_none")]
1032		mysql_options: Option<AlterTableOptions>,
1033		/// PostgreSQL operator class.
1034		#[serde(default, skip_serializing_if = "Option::is_none")]
1035		operator_class: Option<String>,
1036	},
1037	/// RunSQL variant.
1038	RunSQL {
1039		/// The sql.
1040		sql: String,
1041		/// The reverse sql.
1042		reverse_sql: Option<String>,
1043	},
1044	/// RunRust variant.
1045	RunRust {
1046		/// The code.
1047		code: String,
1048		/// The reverse code.
1049		reverse_code: Option<String>,
1050	},
1051	/// AlterTableComment variant.
1052	AlterTableComment {
1053		/// The table.
1054		table: String,
1055		/// The comment.
1056		comment: Option<String>,
1057	},
1058	/// AlterUniqueTogether variant.
1059	AlterUniqueTogether {
1060		/// The table.
1061		table: String,
1062		/// The unique together.
1063		unique_together: Vec<Vec<String>>,
1064	},
1065	/// AlterModelOptions variant.
1066	AlterModelOptions {
1067		/// The table.
1068		table: String,
1069		/// The options.
1070		options: std::collections::HashMap<String, String>,
1071	},
1072	/// CreateInheritedTable variant.
1073	CreateInheritedTable {
1074		/// The name.
1075		name: String,
1076		/// The columns.
1077		columns: Vec<ColumnDefinition>,
1078		/// The base table.
1079		base_table: String,
1080		/// The join column.
1081		join_column: String,
1082	},
1083	/// AddDiscriminatorColumn variant.
1084	AddDiscriminatorColumn {
1085		/// The table.
1086		table: String,
1087		/// The column name.
1088		column_name: String,
1089		/// The default value.
1090		default_value: String,
1091	},
1092	/// Move a model from one app to another
1093	///
1094	/// This operation handles cross-app model moves by:
1095	/// 1. Optionally renaming the table (if naming convention changes between apps)
1096	/// 2. Updating FK references to use the new table name
1097	///
1098	/// Note: This generates a RenameTable SQL if table name changes.
1099	/// The state tracking (from_app -> to_app) is handled at the ProjectState level.
1100	MoveModel {
1101		/// Name of the model being moved
1102		model_name: String,
1103		/// Source app label
1104		from_app: String,
1105		/// Target app label
1106		to_app: String,
1107		/// Whether to rename the underlying table
1108		rename_table: bool,
1109		/// Old table name (if rename_table is true)
1110		old_table_name: Option<String>,
1111		/// New table name (if rename_table is true)
1112		new_table_name: Option<String>,
1113	},
1114	/// Create a database schema (PostgreSQL, MySQL 5.0.2+)
1115	///
1116	/// Creates a new database schema namespace. In MySQL, this is equivalent to creating a database.
1117	CreateSchema {
1118		/// Name of the schema to create
1119		name: String,
1120		/// Whether to add IF NOT EXISTS clause
1121		#[serde(default)]
1122		if_not_exists: bool,
1123	},
1124	/// Drop a database schema
1125	///
1126	/// Drops an existing database schema. Use with caution as this will drop all objects in the schema.
1127	DropSchema {
1128		/// Name of the schema to drop
1129		name: String,
1130		/// Whether to add CASCADE clause (drops all contained objects)
1131		#[serde(default)]
1132		cascade: bool,
1133		/// Whether to add IF EXISTS clause
1134		#[serde(default = "default_true")]
1135		if_exists: bool,
1136	},
1137	/// Create a PostgreSQL extension (PostgreSQL-specific)
1138	///
1139	/// Creates a PostgreSQL extension like PostGIS, uuid-ossp, etc.
1140	/// This operation is only executed on PostgreSQL databases.
1141	CreateExtension {
1142		/// Name of the extension to create
1143		name: String,
1144		/// Whether to add IF NOT EXISTS clause
1145		#[serde(default = "default_true")]
1146		if_not_exists: bool,
1147		/// Optional schema to install the extension in
1148		#[serde(default)]
1149		schema: Option<String>,
1150	},
1151	/// Bulk data loading operation
1152	///
1153	/// Loads large amounts of data efficiently using database-native bulk loading commands:
1154	/// - PostgreSQL: `COPY table FROM source WITH (FORMAT csv, ...)`
1155	/// - MySQL: `LOAD DATA [LOCAL] INFILE 'path' INTO TABLE table ...`
1156	/// - SQLite: Not supported (falls back to INSERT statements)
1157	///
1158	/// # Performance
1159	///
1160	/// Bulk loading is typically 10-100x faster than individual INSERT statements
1161	/// for large datasets.
1162	///
1163	/// # Examples
1164	///
1165	/// ```rust,ignore
1166	/// use reinhardt_db::migrations::{Operation, BulkLoadSource, BulkLoadFormat, BulkLoadOptions};
1167	///
1168	/// // PostgreSQL COPY FROM file
1169	/// let op = Operation::BulkLoad {
1170	///     table: "events".to_string(),
1171	///     source: BulkLoadSource::File("/tmp/events.csv"),
1172	///     format: BulkLoadFormat::Csv,
1173	///     options: BulkLoadOptions::new()
1174	///         .with_header(true)
1175	///         .with_delimiter(','),
1176	/// };
1177	///
1178	/// // MySQL LOAD DATA LOCAL INFILE
1179	/// let op = Operation::BulkLoad {
1180	///     table: "events".to_string(),
1181	///     source: BulkLoadSource::File("/tmp/events.csv"),
1182	///     format: BulkLoadFormat::Csv,
1183	///     options: BulkLoadOptions::new()
1184	///         .with_local(true)
1185	///         .with_delimiter(','),
1186	/// };
1187	/// ```
1188	BulkLoad {
1189		/// Target table name
1190		table: String,
1191		/// Source of the data
1192		source: BulkLoadSource,
1193		/// Format of the data
1194		#[serde(default)]
1195		format: BulkLoadFormat,
1196		/// Additional loading options
1197		#[serde(default)]
1198		options: BulkLoadOptions,
1199	},
1200	/// Reset the auto-increment counter for a table
1201	///
1202	/// Sets the next value produced by the table's auto-increment mechanism.
1203	/// Typical uses include seeding IDs after a bulk import or shifting the
1204	/// sequence above a range reserved for historical data.
1205	///
1206	/// # Backend Behavior
1207	///
1208	/// - **PostgreSQL / CockroachDB**: `SELECT setval(pg_get_serial_sequence('{table}', '{column}'), {value}, false)`
1209	///   (resolves the sequence dynamically so both default `SERIAL` conventions
1210	///   and user-defined sequence names work; `false` makes the NEXT generated
1211	///   value equal `{value}`).
1212	/// - **MySQL**: `ALTER TABLE {table} AUTO_INCREMENT = {value}`.
1213	/// - **SQLite**: `INSERT OR REPLACE INTO sqlite_sequence(name, seq) VALUES (...)`
1214	///   (robust against tables that have not yet inserted any rows, where a
1215	///   simple `UPDATE` would silently no-op).
1216	SetAutoIncrementValue {
1217		/// The table whose auto-increment counter should be set.
1218		table: String,
1219		/// The auto-increment column (used to resolve the backing sequence
1220		/// on PostgreSQL / CockroachDB).
1221		column: String,
1222		/// The next value the counter should produce.
1223		value: i64,
1224	},
1225	/// Create a composite (multi-column) PRIMARY KEY constraint on an existing table
1226	///
1227	/// Emits `ALTER TABLE {table} ADD CONSTRAINT {name} PRIMARY KEY ({cols})`
1228	/// on every supported backend. When `constraint_name` is `None` the name
1229	/// defaults to `{table}_pkey`, matching PostgreSQL's conventional
1230	/// auto-generated identifier.
1231	///
1232	/// `columns` must be non-empty; emitting an empty column list would
1233	/// produce invalid SQL and is rejected as an `InvalidMigration` error at
1234	/// SQL generation time.
1235	CreateCompositePrimaryKey {
1236		/// The table to add the composite primary key to.
1237		table: String,
1238		/// The ordered list of columns participating in the primary key.
1239		columns: Vec<String>,
1240		/// Optional explicit constraint name. Defaults to `{table}_pkey`
1241		/// when `None`.
1242		#[serde(default, skip_serializing_if = "Option::is_none")]
1243		constraint_name: Option<String>,
1244	},
1245}
1246
1247fn mysql_quote_identifier(identifier: &str) -> String {
1248	format!("`{}`", identifier.replace('`', "``"))
1249}
1250
1251fn unquote_sql_identifier(identifier: &str) -> String {
1252	let trimmed = identifier.trim();
1253	let Some(quote) = trimmed.chars().next() else {
1254		return String::new();
1255	};
1256	let stripped = match quote {
1257		'"' => trimmed
1258			.strip_prefix('"')
1259			.and_then(|value| value.strip_suffix('"')),
1260		'`' => trimmed
1261			.strip_prefix('`')
1262			.and_then(|value| value.strip_suffix('`')),
1263		'\'' => trimmed
1264			.strip_prefix('\'')
1265			.and_then(|value| value.strip_suffix('\'')),
1266		_ => None,
1267	};
1268	stripped.map_or_else(
1269		|| trimmed.to_string(),
1270		|value| value.replace(&format!("{quote}{quote}"), &quote.to_string()),
1271	)
1272}
1273
1274fn split_sql_identifier_list(identifier_list: &str) -> Option<Vec<String>> {
1275	let mut identifiers = Vec::new();
1276	let mut current = String::new();
1277	let mut quote = None;
1278	let mut chars = identifier_list.chars().peekable();
1279
1280	while let Some(character) = chars.next() {
1281		if let Some(quote_char) = quote {
1282			current.push(character);
1283			if character == quote_char {
1284				if chars.peek() == Some(&quote_char) {
1285					current.push(chars.next().expect("peeked quote must exist"));
1286				} else {
1287					quote = None;
1288				}
1289			}
1290			continue;
1291		}
1292
1293		match character {
1294			'"' | '`' | '\'' => {
1295				quote = Some(character);
1296				current.push(character);
1297			}
1298			',' => {
1299				let identifier = unquote_sql_identifier(&current);
1300				if identifier.is_empty() {
1301					return None;
1302				}
1303				identifiers.push(identifier);
1304				current.clear();
1305			}
1306			_ => current.push(character),
1307		}
1308	}
1309
1310	if quote.is_some() {
1311		return None;
1312	}
1313	let identifier = unquote_sql_identifier(&current);
1314	if identifier.is_empty() {
1315		return None;
1316	}
1317	identifiers.push(identifier);
1318	Some(identifiers)
1319}
1320
1321fn mysql_quote_unique_constraint_columns(constraint_sql: &str) -> String {
1322	let Some(unique_start) = constraint_sql.find(" UNIQUE") else {
1323		return constraint_sql.to_string();
1324	};
1325	let Some(open_offset) = constraint_sql[unique_start..].find('(') else {
1326		return constraint_sql.to_string();
1327	};
1328	let open = unique_start + open_offset;
1329	let mut depth = 0usize;
1330	let mut quote = None;
1331	let mut close = None;
1332	let mut chars = constraint_sql[open..].char_indices().peekable();
1333
1334	while let Some((offset, character)) = chars.next() {
1335		if let Some(quote_char) = quote {
1336			if character == quote_char {
1337				if chars.peek().is_some_and(|(_, next)| *next == quote_char) {
1338					chars.next();
1339				} else {
1340					quote = None;
1341				}
1342			}
1343			continue;
1344		}
1345
1346		match character {
1347			'"' | '`' | '\'' => quote = Some(character),
1348			'(' => depth += 1,
1349			')' => {
1350				depth = depth.saturating_sub(1);
1351				if depth == 0 {
1352					close = Some(open + offset);
1353					break;
1354				}
1355			}
1356			_ => {}
1357		}
1358	}
1359
1360	let Some(close) = close else {
1361		return constraint_sql.to_string();
1362	};
1363	let Some(columns) = split_sql_identifier_list(&constraint_sql[open + 1..close]) else {
1364		return constraint_sql.to_string();
1365	};
1366	let quoted_columns = columns
1367		.iter()
1368		.map(|column| mysql_quote_identifier(column))
1369		.collect::<Vec<_>>()
1370		.join(", ");
1371	format!(
1372		"{}{}{}",
1373		&constraint_sql[..open + 1],
1374		quoted_columns,
1375		&constraint_sql[close..]
1376	)
1377}
1378
1379/// Default value provider for serde (returns true)
1380const fn default_true() -> bool {
1381	true
1382}
1383
1384impl Operation {
1385	/// Apply this operation to the project state (forward)
1386	pub fn state_forwards(&self, app_label: &str, state: &mut ProjectState) {
1387		match self {
1388			Operation::CreateTable { name, columns, .. } => {
1389				let mut model = ModelState::new(app_label, name.clone());
1390				for column in columns {
1391					let field = FieldState::new(
1392						column.name.to_string(),
1393						column.type_definition.clone(),
1394						false,
1395					);
1396					model.add_field(field);
1397				}
1398				state.add_model(model);
1399			}
1400			Operation::DropTable { name } => {
1401				state.remove_model(app_label, name);
1402			}
1403			Operation::AddColumn { table, column, .. } => {
1404				if let Some(model) = state.get_model_mut(app_label, table) {
1405					let field = FieldState::new(
1406						column.name.to_string(),
1407						column.type_definition.clone(),
1408						false,
1409					);
1410					model.add_field(field);
1411				}
1412			}
1413			Operation::DropColumn { table, column } => {
1414				if let Some(model) = state.get_model_mut(app_label, table) {
1415					model.remove_field(column);
1416				}
1417			}
1418			Operation::AlterColumn {
1419				table,
1420				column,
1421				new_definition,
1422				..
1423			} => {
1424				if let Some(model) = state.get_model_mut(app_label, table) {
1425					let field = FieldState::new(
1426						column.to_string(),
1427						new_definition.type_definition.clone(),
1428						false,
1429					);
1430					model.alter_field(column, field);
1431				}
1432			}
1433			Operation::RenameTable { old_name, new_name } => {
1434				state.rename_model(app_label, old_name, new_name.to_string());
1435			}
1436			Operation::RenameColumn {
1437				table,
1438				old_name,
1439				new_name,
1440			} => {
1441				if let Some(model) = state.get_model_mut(app_label, table) {
1442					model.rename_field(old_name, new_name.to_string());
1443				}
1444			}
1445			Operation::CreateInheritedTable {
1446				name,
1447				columns,
1448				base_table,
1449				join_column,
1450			} => {
1451				let mut model = ModelState::new(app_label, name.clone());
1452				model.base_model = Some(base_table.to_string());
1453				model.inheritance_type = Some("joined_table".to_string());
1454
1455				let join_field = FieldState::new(
1456					join_column.to_string(),
1457					FieldType::Custom(format!("INTEGER REFERENCES {}(id)", base_table)),
1458					false,
1459				);
1460				model.add_field(join_field);
1461
1462				for column in columns {
1463					let field = FieldState::new(
1464						column.name.to_string(),
1465						column.type_definition.clone(),
1466						false,
1467					);
1468					model.add_field(field);
1469				}
1470				state.add_model(model);
1471			}
1472			Operation::AddDiscriminatorColumn {
1473				table,
1474				column_name,
1475				default_value,
1476			} => {
1477				if let Some(model) = state.get_model_mut(app_label, table) {
1478					model.discriminator_column = Some(column_name.to_string());
1479					model.inheritance_type = Some("single_table".to_string());
1480					let field = FieldState::new(
1481						column_name.to_string(),
1482						FieldType::Custom(format!("VARCHAR(50) DEFAULT '{}'", default_value)),
1483						false,
1484					);
1485					model.add_field(field);
1486				}
1487			}
1488			Operation::AddConstraint { .. }
1489			| Operation::DropConstraint { .. }
1490			| Operation::CreateIndex { .. }
1491			| Operation::CreateIndexRepair { .. }
1492			| Operation::DropIndex { .. }
1493			| Operation::DropNamedIndex { .. }
1494			| Operation::RunSQL { .. }
1495			| Operation::RunRust { .. }
1496			| Operation::AlterTableComment { .. }
1497			| Operation::AlterUniqueTogether { .. }
1498			| Operation::AlterModelOptions { .. }
1499			| Operation::SetAutoIncrementValue { .. }
1500			| Operation::CreateCompositePrimaryKey { .. } => {
1501				// Counter/constraint-level ops do not affect ProjectState
1502				// (they track model-level structure only).
1503			}
1504			Operation::MoveModel {
1505				model_name,
1506				from_app,
1507				to_app,
1508				rename_table,
1509				old_table_name,
1510				new_table_name,
1511			} => {
1512				// Move the model from one app to another in the project state
1513				// First get the model, then remove it from the old location
1514				if let Some(model) = state.get_model(from_app, model_name).cloned() {
1515					state.remove_model(from_app, model_name);
1516
1517					// Create a new model with updated app label
1518					let mut new_model = model;
1519					new_model.app_label = to_app.to_string();
1520
1521					// Update table name if rename_table is true
1522					if *rename_table
1523						&& let (Some(_old_name), Some(new_name)) = (old_table_name, new_table_name)
1524					{
1525						new_model.table_name = new_name.to_string();
1526					}
1527
1528					state.add_model(new_model);
1529				}
1530			}
1531			// Schema operations don't affect ProjectState (models/fields only)
1532			Operation::CreateSchema { .. }
1533			| Operation::DropSchema { .. }
1534			| Operation::CreateExtension { .. } => {
1535				// No state changes for schema/extension operations
1536			}
1537			// BulkLoad is a data operation that doesn't affect model structure
1538			Operation::BulkLoad { .. } => {
1539				// No state changes for bulk data loading
1540			}
1541		}
1542	}
1543
1544	/// Generate column SQL without PRIMARY KEY constraint (for composite primary keys)
1545	///
1546	/// This function is used when the table has a composite primary key defined at the table level.
1547	/// It generates column definitions without individual PRIMARY KEY keywords to avoid conflicts.
1548	fn column_to_sql_without_pk(col: &ColumnDefinition, dialect: &SqlDialect) -> String {
1549		let mut parts = Vec::new();
1550
1551		// Column name
1552		parts.push(quote_identifier(&col.name));
1553
1554		// Column type
1555		if col.auto_increment {
1556			match dialect {
1557				SqlDialect::Postgres | SqlDialect::Cockroachdb => {
1558					// PostgreSQL 10+ uses GENERATED BY DEFAULT AS IDENTITY
1559					match &col.type_definition {
1560						FieldType::BigInteger => {
1561							parts
1562								.push("BIGINT GENERATED BY DEFAULT AS IDENTITY".to_string().into());
1563						}
1564						FieldType::Integer => {
1565							parts.push(
1566								"INTEGER GENERATED BY DEFAULT AS IDENTITY"
1567									.to_string()
1568									.into(),
1569							);
1570						}
1571						FieldType::SmallInteger => {
1572							parts.push(
1573								"SMALLINT GENERATED BY DEFAULT AS IDENTITY"
1574									.to_string()
1575									.into(),
1576							);
1577						}
1578						_ => {
1579							// Fallback for other types
1580							parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1581						}
1582					}
1583				}
1584				SqlDialect::Mysql => {
1585					parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1586					parts.push("AUTO_INCREMENT".to_string().into());
1587				}
1588				SqlDialect::Sqlite => {
1589					// SQLite requires the literal token `INTEGER` (not `BIGINT`/`SMALLINT`)
1590					// for AUTOINCREMENT columns. Widen any integer width to `INTEGER`
1591					// because SQLite's storage classes do not distinguish integer widths.
1592					match &col.type_definition {
1593						FieldType::BigInteger | FieldType::Integer | FieldType::SmallInteger => {
1594							parts.push("INTEGER".to_string().into());
1595						}
1596						_ => {
1597							// Non-integer auto_increment is invalid for SQLite; emit the
1598							// original type and let SQLite surface the error rather than
1599							// silently mis-emitting.
1600							parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1601						}
1602					}
1603					// For SQLite, if part of composite PK, we don't add AUTOINCREMENT here
1604					// It will be handled by the table-level PRIMARY KEY constraint
1605				}
1606			}
1607		} else {
1608			parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1609		}
1610
1611		// NOT NULL constraint
1612		if col.not_null {
1613			parts.push("NOT NULL".to_string().into());
1614		}
1615
1616		// UNIQUE constraint (but NOT PRIMARY KEY)
1617		if col.unique {
1618			parts.push("UNIQUE".to_string().into());
1619		}
1620
1621		// DEFAULT value
1622		if let Some(default) = &col.default {
1623			parts.push(format!("DEFAULT {}", default).into());
1624		}
1625
1626		parts.join(" ")
1627	}
1628
1629	/// Generate column SQL with all constraints
1630	fn column_to_sql(col: &ColumnDefinition, dialect: &SqlDialect) -> String {
1631		let mut parts = Vec::new();
1632
1633		// Column name
1634		parts.push(quote_identifier(&col.name));
1635
1636		// Column type (with auto_increment handling for PostgreSQL)
1637		if col.auto_increment {
1638			match dialect {
1639				SqlDialect::Postgres | SqlDialect::Cockroachdb => {
1640					// PostgreSQL 10+ uses GENERATED BY DEFAULT AS IDENTITY
1641					match &col.type_definition {
1642						FieldType::BigInteger => {
1643							parts
1644								.push("BIGINT GENERATED BY DEFAULT AS IDENTITY".to_string().into());
1645						}
1646						FieldType::Integer => {
1647							parts.push(
1648								"INTEGER GENERATED BY DEFAULT AS IDENTITY"
1649									.to_string()
1650									.into(),
1651							);
1652						}
1653						FieldType::SmallInteger => {
1654							parts.push(
1655								"SMALLINT GENERATED BY DEFAULT AS IDENTITY"
1656									.to_string()
1657									.into(),
1658							);
1659						}
1660						_ => {
1661							// Fallback for other types
1662							parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1663						}
1664					}
1665				}
1666				SqlDialect::Mysql => {
1667					parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1668					parts.push("AUTO_INCREMENT".to_string().into());
1669				}
1670				SqlDialect::Sqlite => {
1671					// SQLite requires the literal token `INTEGER` (not `BIGINT`/`SMALLINT`)
1672					// for AUTOINCREMENT columns. Widen any integer width to `INTEGER`
1673					// because SQLite's storage classes do not distinguish integer widths,
1674					// and `BIGINT PRIMARY KEY AUTOINCREMENT` is rejected at apply time
1675					// with: "AUTOINCREMENT is only allowed on an INTEGER PRIMARY KEY".
1676					//
1677					// For non-integer column types (e.g. `Uuid`), `auto_increment = true`
1678					// is meaningless in SQLite — the AUTOINCREMENT keyword would be
1679					// rejected. Emit the column type as a plain PRIMARY KEY (no
1680					// AUTOINCREMENT). See reinhardt-web#4378.
1681					let widened_to_integer = matches!(
1682						&col.type_definition,
1683						FieldType::BigInteger | FieldType::Integer | FieldType::SmallInteger
1684					);
1685					if widened_to_integer {
1686						parts.push("INTEGER".to_string().into());
1687					} else {
1688						parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1689					}
1690					// SQLite: AUTOINCREMENT requires `INTEGER PRIMARY KEY AUTOINCREMENT`.
1691					// Note that `INTEGER PRIMARY KEY` alone only enables rowid auto-assignment
1692					// (alias for the rowid); the explicit AUTOINCREMENT keyword is required to
1693					// guarantee monotonic, non-reused IDs (backed by sqlite_sequence).
1694					if col.primary_key {
1695						if widened_to_integer {
1696							parts.push("PRIMARY KEY AUTOINCREMENT".to_string().into());
1697						} else {
1698							parts.push("PRIMARY KEY".to_string().into());
1699						}
1700						// Return early to avoid duplicate PRIMARY KEY
1701						if col.unique {
1702							parts.push("UNIQUE".to_string().into());
1703						}
1704						if let Some(default) = &col.default {
1705							parts.push(format!("DEFAULT {}", default).into());
1706						}
1707						return parts.join(" ");
1708					}
1709				}
1710			}
1711		} else {
1712			parts.push(col.type_definition.to_sql_for_dialect(dialect).into());
1713		}
1714
1715		// NOT NULL constraint
1716		if col.not_null {
1717			parts.push("NOT NULL".to_string().into());
1718		}
1719
1720		// PRIMARY KEY constraint
1721		if col.primary_key {
1722			parts.push("PRIMARY KEY".to_string().into());
1723		}
1724
1725		// UNIQUE constraint
1726		if col.unique {
1727			parts.push("UNIQUE".to_string().into());
1728		}
1729
1730		// DEFAULT value
1731		if let Some(default) = &col.default {
1732			parts.push(format!("DEFAULT {}", default).into());
1733		}
1734
1735		parts.join(" ")
1736	}
1737
1738	/// Generate forward SQL
1739	pub fn to_sql(&self, dialect: &SqlDialect) -> String {
1740		match self {
1741			Operation::CreateTable {
1742				name,
1743				columns,
1744				constraints,
1745				without_rowid,
1746				interleave_in_parent,
1747				partition,
1748			} => {
1749				// Detect composite primary key
1750				let pk_columns: Vec<&String> = columns
1751					.iter()
1752					.filter(|col| col.primary_key)
1753					.map(|col| &col.name)
1754					.collect();
1755				let has_composite_pk = pk_columns.len() > 1;
1756
1757				let mut parts = Vec::new();
1758				for col in columns {
1759					// Use column_to_sql_without_pk for composite PKs to avoid duplicate PRIMARY KEY
1760					if has_composite_pk {
1761						parts.push(format!(
1762							"  {}",
1763							Self::column_to_sql_without_pk(col, dialect)
1764						));
1765					} else {
1766						parts.push(format!("  {}", Self::column_to_sql(col, dialect)));
1767					}
1768				}
1769
1770				// Add composite primary key constraint if detected
1771				if has_composite_pk {
1772					let pk_constraint_name = format!("{}_pkey", name);
1773					let quoted_pk_columns = pk_columns
1774						.iter()
1775						.map(|s| quote_identifier(s))
1776						.collect::<Vec<_>>()
1777						.join(", ");
1778					let pk_constraint = format!(
1779						"  CONSTRAINT {} PRIMARY KEY ({})",
1780						quote_identifier(&pk_constraint_name),
1781						quoted_pk_columns
1782					);
1783					parts.push(pk_constraint);
1784				}
1785
1786				for constraint in constraints {
1787					parts.push(format!("  {}", constraint));
1788				}
1789				let mut sql = format!(
1790					"CREATE TABLE {} (\n{}\n)",
1791					quote_identifier(name),
1792					parts.join(",\n")
1793				);
1794
1795				// SQLite: WITHOUT ROWID optimization for tables with explicit PRIMARY KEY
1796				if matches!(dialect, SqlDialect::Sqlite)
1797					&& let Some(true) = without_rowid
1798				{
1799					sql.push_str(" WITHOUT ROWID");
1800				}
1801
1802				// MySQL: Table partitioning
1803				if matches!(dialect, SqlDialect::Mysql)
1804					&& let Some(partition_opts) = partition
1805				{
1806					sql.push(' ');
1807					sql.push_str(&partition_opts.to_sql());
1808				}
1809
1810				// CockroachDB: INTERLEAVE IN PARENT for co-locating child rows with parent
1811				if matches!(dialect, SqlDialect::Cockroachdb)
1812					&& let Some(interleave) = interleave_in_parent
1813				{
1814					let quoted_columns = interleave
1815						.parent_columns
1816						.iter()
1817						.map(|col| quote_identifier(col))
1818						.collect::<Vec<_>>()
1819						.join(", ");
1820					sql.push_str(&format!(
1821						" INTERLEAVE IN PARENT {} ({})",
1822						quote_identifier(&interleave.parent_table),
1823						quoted_columns
1824					));
1825				}
1826
1827				sql.push(';');
1828				sql
1829			}
1830			Operation::DropTable { name } => format!("DROP TABLE {};", quote_identifier(name)),
1831			Operation::AddColumn {
1832				table,
1833				column,
1834				mysql_options,
1835			} => {
1836				let base_sql = format!(
1837					"ALTER TABLE {} ADD COLUMN {}",
1838					quote_identifier(table),
1839					Self::column_to_sql(column, dialect)
1840				);
1841
1842				// MySQL: Add ALGORITHM/LOCK options
1843				if matches!(dialect, SqlDialect::Mysql)
1844					&& let Some(opts) = mysql_options
1845				{
1846					let suffix = opts.to_sql_suffix();
1847					if !suffix.is_empty() {
1848						return format!("{}{};", base_sql, suffix);
1849					}
1850				}
1851
1852				format!("{};", base_sql)
1853			}
1854			Operation::DropColumn { table, column } => {
1855				format!(
1856					"ALTER TABLE {} DROP COLUMN {};",
1857					quote_identifier(table),
1858					quote_identifier(column)
1859				)
1860			}
1861			Operation::AlterColumn {
1862				table,
1863				column,
1864				old_definition,
1865				new_definition,
1866				mysql_options,
1867				..
1868			} => {
1869				let sql_type = new_definition.type_definition.to_sql_for_dialect(dialect);
1870				match dialect {
1871					SqlDialect::Postgres | SqlDialect::Cockroachdb => {
1872						let mut statements = Vec::new();
1873						if old_definition
1874							.as_ref()
1875							.is_some_and(|old_definition| old_definition.default.is_some())
1876						{
1877							statements.push(format!(
1878								"ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
1879								quote_identifier(table),
1880								quote_identifier(column)
1881							));
1882						}
1883						statements.push(format!(
1884							"ALTER TABLE {} ALTER COLUMN {} TYPE {};",
1885							quote_identifier(table),
1886							quote_identifier(column),
1887							sql_type
1888						));
1889						if let Some(default) = &new_definition.default {
1890							statements.push(format!(
1891								"ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
1892								quote_identifier(table),
1893								quote_identifier(column),
1894								default
1895							));
1896						}
1897						statements.join(" ")
1898					}
1899					SqlDialect::Mysql => {
1900						let base_sql = format!(
1901							"ALTER TABLE {} MODIFY COLUMN {}",
1902							quote_identifier(table),
1903							Self::column_to_sql(new_definition, dialect)
1904						);
1905
1906						// MySQL: Add ALGORITHM/LOCK options
1907						if let Some(opts) = mysql_options {
1908							let suffix = opts.to_sql_suffix();
1909							if !suffix.is_empty() {
1910								return format!("{}{};", base_sql, suffix);
1911							}
1912						}
1913
1914						format!("{};", base_sql)
1915					}
1916					SqlDialect::Sqlite => {
1917						format!(
1918							"-- SQLite does not support ALTER COLUMN, table recreation required for {}",
1919							quote_identifier(table)
1920						)
1921					}
1922				}
1923			}
1924			Operation::RenameColumn {
1925				table,
1926				old_name,
1927				new_name,
1928			} => {
1929				format!(
1930					"ALTER TABLE {} RENAME COLUMN {} TO {};",
1931					quote_identifier(table),
1932					quote_identifier(old_name),
1933					quote_identifier(new_name)
1934				)
1935			}
1936			Operation::RenameTable { old_name, new_name } => {
1937				format!(
1938					"ALTER TABLE {} RENAME TO {};",
1939					quote_identifier(old_name),
1940					quote_identifier(new_name)
1941				)
1942			}
1943			Operation::AddConstraint {
1944				table,
1945				constraint_sql,
1946			} => {
1947				let constraint_sql = if matches!(dialect, SqlDialect::Mysql) {
1948					mysql_quote_unique_constraint_columns(constraint_sql)
1949				} else {
1950					constraint_sql.clone()
1951				};
1952				format!(
1953					"ALTER TABLE {} ADD {};",
1954					quote_identifier(table),
1955					constraint_sql
1956				)
1957			}
1958			Operation::DropConstraint {
1959				table,
1960				constraint_name,
1961			} => {
1962				format!(
1963					"ALTER TABLE {} DROP CONSTRAINT {};",
1964					quote_identifier(table),
1965					quote_identifier(constraint_name)
1966				)
1967			}
1968			Operation::CreateIndex {
1969				table,
1970				columns,
1971				unique,
1972				index_type,
1973				where_clause,
1974				concurrently,
1975				expressions,
1976				mysql_options,
1977				operator_class,
1978			} => {
1979				let unique_str = if *unique { "UNIQUE " } else { "" };
1980
1981				// PostgreSQL: CONCURRENTLY keyword (must come before UNIQUE)
1982				let concurrent_str = if *concurrently && matches!(dialect, SqlDialect::Postgres) {
1983					"CONCURRENTLY "
1984				} else {
1985					""
1986				};
1987
1988				// MySQL: FULLTEXT/SPATIAL prefix (replaces UNIQUE for these types)
1989				let (mysql_prefix, effective_unique) = match (index_type, dialect) {
1990					(Some(IndexType::Fulltext), SqlDialect::Mysql) => ("FULLTEXT ", ""),
1991					(Some(IndexType::Spatial), SqlDialect::Mysql) => ("SPATIAL ", ""),
1992					_ => ("", unique_str),
1993				};
1994
1995				// Determine what to index: expressions or columns
1996				let (index_content, name_suffix) =
1997					if let Some(exprs) = expressions.as_ref().filter(|e| !e.is_empty()) {
1998						// For expression indexes, use expressions and generate a hash-based suffix
1999						// Expressions are assumed to be properly formatted, no additional quoting needed
2000						let content = exprs.join(", ");
2001						let suffix = "expr";
2002						(content, suffix.to_string())
2003					} else {
2004						// Use columns with optional operator class
2005						let content = if let Some(op_class) = operator_class {
2006							// Apply operator class to each column (PostgreSQL-specific)
2007							if matches!(dialect, SqlDialect::Postgres) {
2008								columns
2009									.iter()
2010									.map(|c| format!("{} {}", quote_identifier(c), op_class))
2011									.collect::<Vec<_>>()
2012									.join(", ")
2013							} else {
2014								// Quote column names for safety (reserved words, special chars)
2015								columns
2016									.iter()
2017									.map(|c| quote_identifier(c).to_string())
2018									.collect::<Vec<_>>()
2019									.join(", ")
2020							}
2021						} else {
2022							// Quote column names for safety (reserved words, special chars)
2023							columns
2024								.iter()
2025								.map(|c| quote_identifier(c).to_string())
2026								.collect::<Vec<_>>()
2027								.join(", ")
2028						};
2029						(content, columns.join("_"))
2030					};
2031
2032				let idx_name = if name_suffix == "expr" {
2033					format!("idx_{table}_expr")
2034				} else {
2035					generated_index_name(table, columns, None)
2036				};
2037
2038				// Index type clause (USING type) - PostgreSQL, CockroachDB
2039				let using_clause = match (index_type, dialect) {
2040					(Some(IndexType::BTree), _) => String::new(), // Default, no need to specify
2041					(Some(idx_type), SqlDialect::Postgres | SqlDialect::Cockroachdb) => {
2042						format!(" USING {}", idx_type)
2043					}
2044					// MySQL FULLTEXT/SPATIAL handled via prefix, not USING
2045					(Some(IndexType::Fulltext | IndexType::Spatial), SqlDialect::Mysql) => {
2046						String::new()
2047					}
2048					_ => String::new(),
2049				};
2050
2051				// Build base SQL with correct syntax per dialect
2052				// PostgreSQL: CREATE [UNIQUE] INDEX [CONCURRENTLY] name [USING type] ON table (cols)
2053				// MySQL: CREATE [FULLTEXT|SPATIAL|UNIQUE] INDEX name ON table (cols)
2054				// SQLite: CREATE [UNIQUE] INDEX name ON table (cols)
2055				let mut sql = match dialect {
2056					SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2057						// CONCURRENTLY goes between INDEX and index_name
2058						format!(
2059							"CREATE {}INDEX {}{}",
2060							effective_unique,
2061							concurrent_str,
2062							quote_identifier(&idx_name)
2063						)
2064					}
2065					SqlDialect::Mysql => {
2066						// MySQL doesn't support CONCURRENTLY or USING (except for FULLTEXT/SPATIAL prefix)
2067						format!(
2068							"CREATE {}{}INDEX {}",
2069							mysql_prefix,
2070							effective_unique,
2071							quote_identifier(&idx_name)
2072						)
2073					}
2074					SqlDialect::Sqlite => {
2075						// SQLite doesn't support CONCURRENTLY or USING
2076						format!(
2077							"CREATE {}INDEX {}",
2078							effective_unique,
2079							quote_identifier(&idx_name)
2080						)
2081					}
2082				};
2083				// PostgreSQL: ON table USING method (columns)
2084				// MySQL/SQLite: ON table (columns)
2085				// Quote table name for safety (reserved words, special chars)
2086				sql.push_str(&format!(
2087					" ON {}{} ({})",
2088					quote_identifier(table),
2089					using_clause,
2090					index_content
2091				));
2092
2093				// Add WHERE clause for partial indexes (PostgreSQL, SQLite, CockroachDB - not MySQL)
2094				if let Some(where_cond) = where_clause
2095					&& !matches!(dialect, SqlDialect::Mysql)
2096				{
2097					sql.push_str(&format!(" WHERE {}", where_cond));
2098				}
2099
2100				// MySQL: Add ALGORITHM/LOCK options
2101				if matches!(dialect, SqlDialect::Mysql)
2102					&& let Some(opts) = mysql_options
2103				{
2104					let suffix = opts.to_sql_suffix();
2105					if !suffix.is_empty() {
2106						sql.push_str(&suffix);
2107					}
2108				}
2109
2110				sql.push(';');
2111				sql
2112			}
2113			Operation::CreateIndexRepair {
2114				table,
2115				name,
2116				columns,
2117				unique,
2118				index_type,
2119				where_clause,
2120				concurrently,
2121				expressions,
2122				mysql_options,
2123				operator_class,
2124			} => {
2125				let create = Operation::CreateIndex {
2126					table: table.clone(),
2127					columns: columns.clone(),
2128					unique: *unique,
2129					index_type: *index_type,
2130					where_clause: where_clause.clone(),
2131					concurrently: *concurrently,
2132					expressions: expressions.clone(),
2133					mysql_options: *mysql_options,
2134					operator_class: operator_class.clone(),
2135				};
2136				let sql = create.to_sql(dialect);
2137				name.as_ref().map_or(sql.clone(), |name| {
2138					let generated_name =
2139						generated_index_name(table, columns, expressions.as_deref());
2140					let generated_name = quote_identifier(&generated_name);
2141					let name = quote_identifier(name);
2142					sql.replacen(generated_name.as_ref(), name.as_ref(), 1)
2143				})
2144			}
2145			Operation::DropIndex { table, columns } => {
2146				let idx_name = generated_index_name(table, columns, None);
2147				match dialect {
2148					SqlDialect::Mysql => {
2149						format!(
2150							"DROP INDEX {} ON {};",
2151							quote_identifier(&idx_name),
2152							quote_identifier(table)
2153						)
2154					}
2155					SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
2156						format!("DROP INDEX {};", quote_identifier(&idx_name))
2157					}
2158				}
2159			}
2160			Operation::DropNamedIndex { table, name, .. } => match dialect {
2161				SqlDialect::Mysql => format!(
2162					"DROP INDEX {} ON {};",
2163					quote_identifier(name),
2164					quote_identifier(table)
2165				),
2166				SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
2167					format!("DROP INDEX {};", quote_identifier(name))
2168				}
2169			},
2170			Operation::RunSQL { sql, .. } => sql.to_string(),
2171			Operation::RunRust { code, .. } => {
2172				// For SQL generation, RunRust is a no-op comment
2173				format!("-- RunRust: {}", code.lines().next().unwrap_or(""))
2174			}
2175			Operation::AlterTableComment { table, comment } => match dialect {
2176				SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2177					if let Some(comment_text) = comment {
2178						format!(
2179							"COMMENT ON TABLE {} IS '{}';",
2180							quote_identifier(table),
2181							comment_text
2182						)
2183					} else {
2184						format!("COMMENT ON TABLE {} IS NULL;", quote_identifier(table))
2185					}
2186				}
2187				SqlDialect::Mysql => {
2188					if let Some(comment_text) = comment {
2189						format!(
2190							"ALTER TABLE {} COMMENT='{}';",
2191							quote_identifier(table),
2192							comment_text
2193						)
2194					} else {
2195						format!("ALTER TABLE {} COMMENT='';", quote_identifier(table))
2196					}
2197				}
2198				SqlDialect::Sqlite => String::new(),
2199			},
2200			Operation::AlterUniqueTogether {
2201				table,
2202				unique_together,
2203			} => {
2204				let mut sql = Vec::new();
2205				for (idx, fields) in unique_together.iter().enumerate() {
2206					let constraint_name = format!("{}_{}_uniq", table, idx);
2207					let fields_str = fields
2208						.iter()
2209						.map(|f| quote_identifier(f))
2210						.collect::<Vec<_>>()
2211						.join(", ");
2212					sql.push(format!(
2213						"ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({});",
2214						quote_identifier(table),
2215						quote_identifier(&constraint_name),
2216						fields_str
2217					));
2218				}
2219				sql.join("\n")
2220			}
2221			Operation::AlterModelOptions { .. } => String::new(),
2222			Operation::CreateInheritedTable {
2223				name,
2224				columns,
2225				base_table,
2226				join_column,
2227			} => {
2228				let mut parts = Vec::new();
2229				parts.push(format!(
2230					"  {} INTEGER REFERENCES {}(id)",
2231					quote_identifier(join_column),
2232					quote_identifier(base_table)
2233				));
2234				for col in columns {
2235					parts.push(format!("  {}", Self::column_to_sql(col, dialect)));
2236				}
2237				format!(
2238					"CREATE TABLE {} (\n{}\n);",
2239					quote_identifier(name),
2240					parts.join(",\n")
2241				)
2242			}
2243			Operation::AddDiscriminatorColumn {
2244				table,
2245				column_name,
2246				default_value,
2247			} => {
2248				format!(
2249					"ALTER TABLE {} ADD COLUMN {} VARCHAR(50) DEFAULT '{}';",
2250					quote_identifier(table),
2251					quote_identifier(column_name),
2252					default_value
2253				)
2254			}
2255			Operation::MoveModel {
2256				rename_table,
2257				old_table_name,
2258				new_table_name,
2259				..
2260			} => {
2261				// MoveModel generates a RenameTable SQL if table name changes
2262				// Otherwise it's a state-only operation (no SQL needed)
2263				if *rename_table {
2264					if let (Some(old_name), Some(new_name)) = (old_table_name, new_table_name) {
2265						match dialect {
2266							SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
2267								format!(
2268									"ALTER TABLE {} RENAME TO {};",
2269									quote_identifier(old_name),
2270									quote_identifier(new_name)
2271								)
2272							}
2273							SqlDialect::Mysql => {
2274								format!(
2275									"RENAME TABLE {} TO {};",
2276									quote_identifier(old_name),
2277									quote_identifier(new_name)
2278								)
2279							}
2280						}
2281					} else {
2282						"-- MoveModel: No table rename specified".to_string()
2283					}
2284				} else {
2285					// State-only operation, no SQL needed
2286					"-- MoveModel: State-only operation (no table rename)".to_string()
2287				}
2288			}
2289			Operation::CreateSchema {
2290				name,
2291				if_not_exists,
2292			} => {
2293				let if_not_exists_clause = if *if_not_exists { " IF NOT EXISTS" } else { "" };
2294				format!(
2295					"CREATE SCHEMA{} {};",
2296					if_not_exists_clause,
2297					quote_identifier(name)
2298				)
2299			}
2300			Operation::DropSchema {
2301				name,
2302				cascade,
2303				if_exists,
2304			} => {
2305				let if_exists_clause = if *if_exists { " IF EXISTS" } else { "" };
2306				let cascade_clause = if *cascade { " CASCADE" } else { "" };
2307				format!(
2308					"DROP SCHEMA{} {}{};",
2309					if_exists_clause,
2310					quote_identifier(name),
2311					cascade_clause
2312				)
2313			}
2314			Operation::CreateExtension {
2315				name,
2316				if_not_exists,
2317				schema,
2318			} => {
2319				// PostgreSQL-specific
2320				let if_not_exists_clause = if *if_not_exists { " IF NOT EXISTS" } else { "" };
2321				let schema_clause = if let Some(s) = schema {
2322					format!(" SCHEMA {}", quote_identifier(s))
2323				} else {
2324					String::new()
2325				};
2326				format!(
2327					"CREATE EXTENSION{} {}{};",
2328					if_not_exists_clause,
2329					quote_identifier(name),
2330					schema_clause
2331				)
2332			}
2333			Operation::BulkLoad {
2334				table,
2335				source,
2336				format,
2337				options,
2338			} => Self::bulk_load_to_sql(table, source, format, options, dialect),
2339			Operation::SetAutoIncrementValue {
2340				table,
2341				column,
2342				value,
2343			} => Self::set_auto_increment_to_sql(table, column, *value, dialect),
2344			Operation::CreateCompositePrimaryKey {
2345				table,
2346				columns,
2347				constraint_name,
2348			} => Self::create_composite_pk_to_sql(table, columns, constraint_name.as_deref()),
2349		}
2350	}
2351
2352	/// Generate `SetAutoIncrementValue` SQL for each dialect
2353	///
2354	/// PostgreSQL / CockroachDB resolve the backing sequence via
2355	/// `pg_get_serial_sequence(...)` so that both the default
2356	/// `{table}_{column}_seq` naming and user-customized sequences work without
2357	/// the caller having to know the sequence name.
2358	fn set_auto_increment_to_sql(
2359		table: &str,
2360		column: &str,
2361		value: i64,
2362		dialect: &SqlDialect,
2363	) -> String {
2364		match dialect {
2365			SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2366				// pg_get_serial_sequence takes a regclass literal for the table
2367				// and a text literal for the column. `setval(..., value, false)`
2368				// makes the NEXT generated value equal `value`, matching the
2369				// intent of "set the auto-increment to <value>".
2370				format!(
2371					"SELECT setval(pg_get_serial_sequence({}, {}), {}, false);",
2372					quote_literal(table),
2373					quote_literal(column),
2374					value
2375				)
2376			}
2377			SqlDialect::Mysql => {
2378				format!(
2379					"ALTER TABLE {} AUTO_INCREMENT = {};",
2380					quote_identifier(table),
2381					value
2382				)
2383			}
2384			SqlDialect::Sqlite => {
2385				// INSERT OR REPLACE so the statement works whether or not a
2386				// sqlite_sequence row already exists for the table. UPDATE
2387				// would silently no-op on fresh tables that have never had
2388				// a row inserted.
2389				format!(
2390					"INSERT OR REPLACE INTO sqlite_sequence(name, seq) VALUES ({}, {});",
2391					quote_literal(table),
2392					value
2393				)
2394			}
2395		}
2396	}
2397
2398	/// Generate `CreateCompositePrimaryKey` SQL
2399	///
2400	/// Produces `ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY (...)` for
2401	/// every supported backend. Emits guaranteed-fail SQL if the column list
2402	/// is empty so the migration aborts at execution time instead of silently
2403	/// succeeding.
2404	///
2405	/// Workaround for the shared infallible `String` return type used by every
2406	/// `to_sql` arm. Converting the entire pipeline to `Result` would cascade
2407	/// through dozens of call sites, so this arm instead emits a deliberately
2408	/// invalid SQL statement (a bare identifier) that every supported backend's
2409	/// parser rejects before execution. This replaces the earlier `SELECT 1/0`
2410	/// fallback, which silently returned `NULL` on SQLite and lax-mode MySQL
2411	/// (reinhardt-web#4325). The identifier text encodes the diagnostic so it
2412	/// surfaces in the parser error message.
2413	///
2414	/// The long-term fix — migrating the `to_sql` family to `Result` and
2415	/// returning a structured `MigrationError::EmptyCompositePrimaryKey` — is
2416	/// shown in the ideal implementation below.
2417	///
2418	/// Remove this workaround once the `to_sql` family is migrated to a
2419	/// fallible signature.
2420	///
2421	/// Ideal implementation (without workaround):
2422	///   fn create_composite_pk_to_sql(
2423	///       table: &str,
2424	///       columns: &[String],
2425	///       constraint_name: Option<&str>,
2426	///   ) -> Result<String, MigrationError> {
2427	///       if columns.is_empty() {
2428	///           return Err(MigrationError::EmptyCompositePrimaryKey {
2429	///               table: table.to_owned(),
2430	///           });
2431	///       }
2432	///       // ... build the ALTER TABLE statement ...
2433	///   }
2434	fn create_composite_pk_to_sql(
2435		table: &str,
2436		columns: &[String],
2437		constraint_name: Option<&str>,
2438	) -> String {
2439		if columns.is_empty() {
2440			// Deliberately invalid SQL: a bare identifier is not a valid
2441			// statement in PostgreSQL, MySQL, or SQLite grammar, so every
2442			// backend's parser rejects it before execution. This avoids the
2443			// lax-mode MySQL / SQLite silent-pass that the previous
2444			// `SELECT 1/0` fallback was prone to (reinhardt-web#4325). The
2445			// identifier text preserves the diagnostic in the parser error.
2446			return format!(
2447				"SYNTAX_ERROR_create_composite_pk_on_{}_requires_at_least_one_column;",
2448				table.replace(|c: char| !c.is_ascii_alphanumeric(), "_")
2449			);
2450		}
2451
2452		let default_name;
2453		let name: &str = match constraint_name {
2454			Some(n) => n,
2455			None => {
2456				default_name = format!("{}_pkey", table);
2457				&default_name
2458			}
2459		};
2460
2461		let quoted_columns = columns
2462			.iter()
2463			.map(|c| quote_identifier(c).to_string())
2464			.collect::<Vec<_>>()
2465			.join(", ");
2466
2467		format!(
2468			"ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({});",
2469			quote_identifier(table),
2470			quote_identifier(name),
2471			quoted_columns
2472		)
2473	}
2474
2475	/// Generate bulk load SQL for different dialects
2476	fn bulk_load_to_sql(
2477		table: &str,
2478		source: &BulkLoadSource,
2479		format: &BulkLoadFormat,
2480		options: &BulkLoadOptions,
2481		dialect: &SqlDialect,
2482	) -> String {
2483		match dialect {
2484			SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2485				Self::postgres_copy_from_sql(table, source, format, options)
2486			}
2487			SqlDialect::Mysql => Self::mysql_load_data_sql(table, source, format, options),
2488			SqlDialect::Sqlite => {
2489				// SQLite does not support bulk loading natively
2490				format!(
2491					"-- SQLite does not support bulk loading. Use INSERT statements instead for table {}",
2492					quote_identifier(table)
2493				)
2494			}
2495		}
2496	}
2497
2498	/// Generate PostgreSQL COPY FROM SQL
2499	fn postgres_copy_from_sql(
2500		table: &str,
2501		source: &BulkLoadSource,
2502		format: &BulkLoadFormat,
2503		options: &BulkLoadOptions,
2504	) -> String {
2505		let source_clause = match source {
2506			BulkLoadSource::File(path) => format!("'{}'", path),
2507			BulkLoadSource::Stdin => "STDIN".to_string(),
2508			BulkLoadSource::Program(cmd) => format!("PROGRAM '{}'", cmd),
2509		};
2510
2511		let columns_clause = if let Some(cols) = &options.columns {
2512			let quoted_cols = cols
2513				.iter()
2514				.map(|c| quote_identifier(c))
2515				.collect::<Vec<_>>()
2516				.join(", ");
2517			format!(" ({})", quoted_cols)
2518		} else {
2519			String::new()
2520		};
2521
2522		let mut with_options = Vec::new();
2523
2524		// Format
2525		with_options.push(format!("FORMAT {}", format));
2526
2527		// Delimiter
2528		if let Some(delim) = options.delimiter {
2529			with_options.push(format!("DELIMITER '{}'", delim));
2530		}
2531
2532		// NULL string
2533		if let Some(null_str) = &options.null_string {
2534			with_options.push(format!("NULL '{}'", null_str));
2535		}
2536
2537		// Header
2538		if options.header {
2539			with_options.push("HEADER true".to_string());
2540		}
2541
2542		// Quote character
2543		if let Some(quote) = options.quote {
2544			with_options.push(format!("QUOTE '{}'", quote));
2545		}
2546
2547		// Escape character
2548		if let Some(escape) = options.escape {
2549			with_options.push(format!("ESCAPE '{}'", escape));
2550		}
2551
2552		format!(
2553			"COPY {}{} FROM {} WITH ({});",
2554			quote_identifier(table),
2555			columns_clause,
2556			source_clause,
2557			with_options.join(", ")
2558		)
2559	}
2560
2561	/// Generate MySQL LOAD DATA SQL
2562	fn mysql_load_data_sql(
2563		table: &str,
2564		source: &BulkLoadSource,
2565		format: &BulkLoadFormat,
2566		options: &BulkLoadOptions,
2567	) -> String {
2568		let local_clause = if options.local { " LOCAL" } else { "" };
2569
2570		let file_path = match source {
2571			BulkLoadSource::File(path) => path.clone(),
2572			BulkLoadSource::Stdin => {
2573				return format!(
2574					"-- MySQL does not support LOAD DATA from STDIN directly for table {}",
2575					quote_identifier(table)
2576				);
2577			}
2578			BulkLoadSource::Program(_) => {
2579				return format!(
2580					"-- MySQL does not support LOAD DATA from PROGRAM directly for table {}",
2581					quote_identifier(table)
2582				);
2583			}
2584		};
2585
2586		let columns_clause = if let Some(cols) = &options.columns {
2587			let quoted_cols = cols
2588				.iter()
2589				.map(|c| quote_identifier(c))
2590				.collect::<Vec<_>>()
2591				.join(", ");
2592			format!(" ({})", quoted_cols)
2593		} else {
2594			String::new()
2595		};
2596
2597		// Field terminator (delimiter)
2598		let delimiter = options.delimiter.unwrap_or(match format {
2599			BulkLoadFormat::Csv => ',',
2600			BulkLoadFormat::Text | BulkLoadFormat::Binary => '\t',
2601		});
2602
2603		let mut field_options = Vec::new();
2604		field_options.push(format!("TERMINATED BY '{}'", delimiter));
2605
2606		// Quote character for CSV
2607		if *format == BulkLoadFormat::Csv {
2608			let quote = options.quote.unwrap_or('"');
2609			field_options.push(format!("ENCLOSED BY '{}'", quote));
2610		}
2611
2612		// Escape character
2613		if let Some(escape) = options.escape {
2614			field_options.push(format!("ESCAPED BY '{}'", escape));
2615		}
2616
2617		// Line terminator
2618		let line_terminator = options
2619			.line_terminator
2620			.clone()
2621			.unwrap_or_else(|| "\\n".to_string());
2622
2623		// Encoding
2624		let encoding_clause = if let Some(enc) = &options.encoding {
2625			format!(" CHARACTER SET {}", enc)
2626		} else {
2627			String::new()
2628		};
2629
2630		// Header handling (skip first line)
2631		let ignore_clause = if options.header {
2632			" IGNORE 1 LINES"
2633		} else {
2634			""
2635		};
2636
2637		format!(
2638			"LOAD DATA{} INFILE '{}'{} INTO TABLE {} FIELDS {} LINES TERMINATED BY '{}'{}{};",
2639			local_clause,
2640			file_path,
2641			encoding_clause,
2642			quote_identifier(table),
2643			field_options.join(" "),
2644			line_terminator,
2645			ignore_clause,
2646			columns_clause
2647		)
2648	}
2649
2650	/// Generate reverse SQL (for rollback)
2651	///
2652	/// # Arguments
2653	///
2654	/// * `dialect` - SQL dialect for generating database-specific SQL
2655	/// * `project_state` - Project state for accessing model definitions (needed for DropTable, etc.)
2656	///
2657	/// # Returns
2658	///
2659	/// * `Ok(Some(stmts))` - Reverse DDL as one or more SQL statements; each element is a
2660	///   single statement intended to be dispatched separately through
2661	///   `SchemaEditor::execute()` (which is backed by sqlx Extended Query and accepts only
2662	///   one statement per payload). Operations that revert to a single payload (e.g.
2663	///   `DropTable`, `AddColumn`) return a one-element `Vec`; operations that need
2664	///   multiple payloads to round-trip cleanly (e.g. `AlterColumn` on PostgreSQL and
2665	///   CockroachDB, which split type reversion and NOT NULL restoration into two
2666	///   statements) return a multi-element `Vec`.
2667	/// * `Ok(None)` - Operation is not reversible (see Design Limitation below)
2668	/// * `Err(_)` - Error generating reverse SQL
2669	///
2670	/// # Design Limitation
2671	///
2672	/// Destructive operations (`DropTable`, `DropColumn`, `DropConstraint`, `AlterColumn`)
2673	/// require a pre-operation `ProjectState` snapshot to generate reverse SQL. When the
2674	/// `project_state` parameter does not contain the necessary model/column/constraint
2675	/// definition, this method returns `Ok(None)` instead of failing.
2676	///
2677	/// This is an intentional design decision: the migration system cannot reconstruct
2678	/// lost schema information. Callers must provide the state from before the operation
2679	/// was applied to enable proper rollback. This matches Django's migration behavior
2680	/// where `state_forwards` must be called before operations are reversed.
2681	pub fn to_reverse_sql(
2682		&self,
2683		dialect: &SqlDialect,
2684		project_state: &ProjectState,
2685	) -> super::Result<Option<Vec<String>>> {
2686		match self {
2687			Operation::CreateTable { name, .. } => Ok(Some(vec![format!(
2688				"DROP TABLE {};",
2689				quote_identifier(name)
2690			)])),
2691			Operation::AddColumn { table, column, .. } => Ok(Some(vec![format!(
2692				"ALTER TABLE {} DROP COLUMN {};",
2693				quote_identifier(table),
2694				quote_identifier(&column.name)
2695			)])),
2696			Operation::RunSQL { reverse_sql, .. } => {
2697				Ok(reverse_sql.as_ref().map(|s| vec![s.to_string()]))
2698			}
2699			Operation::RunRust { reverse_code, .. } => Ok(reverse_code.as_ref().map(|code| {
2700				vec![format!(
2701					"-- RunRust (reverse): {}",
2702					code.lines().next().unwrap_or("")
2703				)]
2704			})),
2705			// Phase 1: Simple reverse operations
2706			Operation::RenameTable { old_name, new_name } => Ok(Some(vec![format!(
2707				"ALTER TABLE {} RENAME TO {};",
2708				quote_identifier(new_name),
2709				quote_identifier(old_name)
2710			)])),
2711			Operation::RenameColumn {
2712				table,
2713				old_name,
2714				new_name,
2715			} => Ok(Some(vec![format!(
2716				"ALTER TABLE {} RENAME COLUMN {} TO {};",
2717				quote_identifier(table),
2718				quote_identifier(new_name),
2719				quote_identifier(old_name)
2720			)])),
2721			Operation::CreateIndex {
2722				table,
2723				columns,
2724				expressions,
2725				..
2726			} => {
2727				// Use the same naming convention as to_sql(), including expression indexes.
2728				// This ensures the rollback DROP INDEX targets the correct index name
2729				let index_name = generated_index_name(table, columns, expressions.as_deref());
2730				// MySQL requires `DROP INDEX <name> ON <table>`; PostgreSQL/SQLite/CockroachDB
2731				// only need the index name. Mirror the dialect dispatch used by the forward
2732				// `Operation::DropIndex` SQL generator above.
2733				let sql = match dialect {
2734					SqlDialect::Mysql => format!(
2735						"DROP INDEX {} ON {};",
2736						quote_identifier(&index_name),
2737						quote_identifier(table)
2738					),
2739					SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
2740						format!("DROP INDEX {};", quote_identifier(&index_name))
2741					}
2742				};
2743				Ok(Some(vec![sql]))
2744			}
2745			Operation::CreateIndexRepair {
2746				table,
2747				name,
2748				columns,
2749				expressions,
2750				..
2751			} => {
2752				let index_name = name.clone().unwrap_or_else(|| {
2753					generated_index_name(table, columns, expressions.as_deref())
2754				});
2755				let sql = match dialect {
2756					SqlDialect::Mysql => format!(
2757						"DROP INDEX {} ON {};",
2758						quote_identifier(&index_name),
2759						quote_identifier(table)
2760					),
2761					SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
2762						format!("DROP INDEX {};", quote_identifier(&index_name))
2763					}
2764				};
2765				Ok(Some(vec![sql]))
2766			}
2767			Operation::AddConstraint {
2768				table,
2769				constraint_sql,
2770			} => {
2771				// Extract constraint name from SQL
2772				// Expects format: "CONSTRAINT <name> ..." or "ADD CONSTRAINT <name> ..."
2773				let constraint_name =
2774					Self::extract_constraint_name(constraint_sql).ok_or_else(|| {
2775						super::MigrationError::InvalidMigration(format!(
2776							"Cannot extract constraint name from: {}",
2777							constraint_sql
2778						))
2779					})?;
2780				Ok(Some(vec![format!(
2781					"ALTER TABLE {} DROP CONSTRAINT {};",
2782					quote_identifier(table),
2783					quote_identifier(&constraint_name)
2784				)]))
2785			}
2786			// Phase 2: Complex reverse operations using ProjectState
2787			Operation::DropColumn { table, column } => {
2788				// Retrieve original column definition from ProjectState
2789				if let Some(model) = project_state.find_model_by_table(table)
2790					&& let Some(field) = model.get_field(column)
2791				{
2792					let col_def = ColumnDefinition::from_field_state(column.clone(), field);
2793					let col_sql = Self::column_to_sql(&col_def, dialect);
2794					return Ok(Some(vec![format!(
2795						"ALTER TABLE {} ADD COLUMN {};",
2796						quote_identifier(table),
2797						col_sql
2798					)]));
2799				}
2800				// Cannot reconstruct without state
2801				Ok(None)
2802			}
2803			Operation::AlterColumn {
2804				table,
2805				column,
2806				old_definition,
2807				new_definition: _,
2808				..
2809			} => {
2810				// Resolve the original column definition to revert to.
2811				// Prioritize the explicit `old_definition` over ProjectState lookup.
2812				let resolved_old_def = old_definition.clone().or_else(|| {
2813					project_state
2814						.find_model_by_table(table)
2815						.and_then(|model| model.get_field(column))
2816						.map(|field| ColumnDefinition::from_field_state(column.clone(), field))
2817				});
2818
2819				let Some(old_def) = resolved_old_def else {
2820					// Cannot reconstruct without state
2821					return Ok(None);
2822				};
2823
2824				let type_sql = old_def.type_definition.to_sql_for_dialect(dialect);
2825				let null_clause = if old_def.not_null { " NOT NULL" } else { "" };
2826
2827				// Dispatch reverse SQL per dialect.
2828				// SQLite is handled via the SQLite-recreation path before reaching
2829				// here (see executor::rollback_migration and
2830				// `Operation::reverse_requires_sqlite_recreation`). The placeholder
2831				// comment below is a defensive fallback: emitting executable
2832				// ALTER COLUMN syntax on SQLite would always error (#4582).
2833				let stmts = match dialect {
2834					SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2835						// Emit type reversion and nullability restoration as two
2836						// independent single-statement payloads. The executor
2837						// (see `MigrationExecutor::rollback_migration`) iterates
2838						// the returned `Vec<String>` and dispatches each through
2839						// `SchemaEditor::execute()` — backed by sqlx Extended
2840						// Query, which accepts only one statement per payload.
2841						//
2842						// This shape is required by CockroachDB, which rejects
2843						// the comma-combined `ALTER TABLE ... ALTER COLUMN c
2844						// TYPE T, ALTER COLUMN c {SET|DROP} NOT NULL` form that
2845						// PostgreSQL accepts. Unifying both dialects on the
2846						// multi-statement path keeps the contract uniform and
2847						// restores NOT NULL rollback fidelity on Cockroach.
2848						// Refs #4630, #4640.
2849						let nullability_clause = if old_def.not_null {
2850							"SET NOT NULL"
2851						} else {
2852							"DROP NOT NULL"
2853						};
2854						vec![
2855							format!(
2856								"ALTER TABLE {table} ALTER COLUMN {column} TYPE {type_sql};",
2857								table = quote_identifier(table),
2858								column = quote_identifier(column),
2859								type_sql = type_sql,
2860							),
2861							format!(
2862								"ALTER TABLE {table} ALTER COLUMN {column} {nullability_clause};",
2863								table = quote_identifier(table),
2864								column = quote_identifier(column),
2865								nullability_clause = nullability_clause,
2866							),
2867						]
2868					}
2869					SqlDialect::Mysql => vec![format!(
2870						"ALTER TABLE {} MODIFY COLUMN {} {}{};",
2871						quote_identifier(table),
2872						quote_identifier(column),
2873						type_sql,
2874						null_clause
2875					)],
2876					SqlDialect::Sqlite => vec![format!(
2877						"-- SQLite does not support ALTER COLUMN, table recreation required for {}",
2878						quote_identifier(table)
2879					)],
2880				};
2881				Ok(Some(stmts))
2882			}
2883			Operation::DropIndex { table, columns } => {
2884				// Enhancement opportunity: Full index reconstruction would preserve
2885				// index_type, where_clause, operator_class, and other advanced properties.
2886				// The current implementation generates a basic CREATE INDEX statement.
2887				let index_name = generated_index_name(table, columns, None);
2888				let columns_list = columns
2889					.iter()
2890					.map(|c| quote_identifier(c).to_string())
2891					.collect::<Vec<_>>()
2892					.join(", ");
2893				Ok(Some(vec![format!(
2894					"CREATE INDEX {} ON {} ({});",
2895					quote_identifier(&index_name),
2896					quote_identifier(table),
2897					columns_list
2898				)]))
2899			}
2900			Operation::DropNamedIndex {
2901				table,
2902				name,
2903				columns,
2904				unique,
2905				index_type,
2906				where_clause,
2907				concurrently,
2908				expressions,
2909				mysql_options,
2910				operator_class,
2911				..
2912			} => {
2913				let create = Operation::CreateIndexRepair {
2914					table: table.clone(),
2915					name: Some(name.clone()),
2916					columns: columns.clone(),
2917					unique: *unique,
2918					index_type: *index_type,
2919					where_clause: where_clause.clone(),
2920					concurrently: *concurrently,
2921					expressions: expressions.clone(),
2922					mysql_options: *mysql_options,
2923					operator_class: operator_class.clone(),
2924				};
2925				Ok(Some(vec![create.to_sql(dialect)]))
2926			}
2927			Operation::DropConstraint {
2928				table,
2929				constraint_name,
2930			} => {
2931				// Retrieve constraint definition from ProjectState
2932				if let Some(model) = project_state.find_model_by_table(table)
2933					&& let Some(constraint_def) = model
2934						.constraints
2935						.iter()
2936						.find(|c| c.name == *constraint_name)
2937				{
2938					let constraint = constraint_def.to_constraint();
2939					return Ok(Some(vec![format!(
2940						"ALTER TABLE {} ADD {};",
2941						quote_identifier(table),
2942						constraint
2943					)]));
2944				}
2945				// Cannot reconstruct without state
2946				Ok(None)
2947			}
2948			Operation::DropTable { name } => {
2949				// Retrieve table definition from ProjectState and reconstruct CREATE TABLE
2950				if let Some(model) = project_state.find_model_by_table(name) {
2951					let mut parts = Vec::new();
2952
2953					// Convert fields to column definitions
2954					for (field_name, field) in &model.fields {
2955						let col_def = ColumnDefinition::from_field_state(field_name.clone(), field);
2956						parts.push(format!("  {}", Self::column_to_sql(&col_def, dialect)));
2957					}
2958
2959					// Add constraints
2960					for constraint_def in &model.constraints {
2961						let constraint = constraint_def.to_constraint();
2962						parts.push(format!("  {}", constraint));
2963					}
2964
2965					return Ok(Some(vec![format!(
2966						"CREATE TABLE {} (\n{}\n);",
2967						quote_identifier(name),
2968						parts.join(",\n")
2969					)]));
2970				}
2971				// Cannot reconstruct without state
2972				Ok(None)
2973			}
2974			Operation::BulkLoad { table, .. } => {
2975				// Reverse of bulk load is to truncate the table (remove loaded data)
2976				// Note: This removes ALL data, not just the data loaded by this operation
2977				Ok(Some(vec![format!(
2978					"TRUNCATE TABLE {};",
2979					quote_identifier(table)
2980				)]))
2981			}
2982			_ => Ok(None),
2983		}
2984	}
2985
2986	/// Apply operation to project state (backward/reverse)
2987	///
2988	/// This method updates the ProjectState to reflect the reverse of this operation.
2989	/// Used during migration rollback to track state changes.
2990	///
2991	/// # Arguments
2992	///
2993	/// * `app_label` - Application label for the model being modified
2994	/// * `state` - Mutable reference to the ProjectState to update
2995	///
2996	/// # Limitations
2997	///
2998	/// Some operations cannot fully reverse state without additional snapshot information:
2999	/// - `DropTable`: Cannot recreate model structure (columns, constraints) without snapshot
3000	/// - `DropColumn`: Cannot recreate column definition without snapshot
3001	/// - `AlterColumn`: Cannot restore original column definition without snapshot
3002	///
3003	/// For these operations, use `to_reverse_sql` with ProjectState before the operation
3004	/// is applied to generate proper reverse SQL.
3005	pub fn state_backwards(&self, app_label: &str, state: &mut ProjectState) {
3006		match self {
3007			Operation::CreateTable { name, .. } => {
3008				// Reverse: Remove the model from state
3009				state
3010					.models
3011					.remove(&(app_label.to_string(), name.to_string()));
3012			}
3013			Operation::DropTable { name: _ } => {
3014				// Cannot reconstruct ModelState without snapshot.
3015				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
3016			}
3017			Operation::RenameTable { old_name, new_name } => {
3018				// Reverse: Rename back from new_name to old_name
3019				if let Some(mut model) = state
3020					.models
3021					.remove(&(app_label.to_string(), new_name.to_string()))
3022				{
3023					model.table_name = old_name.to_string();
3024					state
3025						.models
3026						.insert((app_label.to_string(), old_name.to_string()), model);
3027				}
3028			}
3029			Operation::AddColumn { table, column, .. } => {
3030				// Reverse: Remove the column from the model
3031				if let Some(model) = state.find_model_by_table_mut(table) {
3032					model.remove_field(&column.name);
3033				}
3034			}
3035			Operation::DropColumn {
3036				table: _,
3037				column: _,
3038			} => {
3039				// Cannot reconstruct column definition without snapshot.
3040				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
3041			}
3042			Operation::AlterColumn {
3043				table: _,
3044				column: _,
3045				..
3046			} => {
3047				// Cannot restore original column definition without snapshot.
3048				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
3049			}
3050			Operation::RenameColumn {
3051				table,
3052				old_name,
3053				new_name,
3054			} => {
3055				// Reverse: Rename field back from new_name to old_name
3056				if let Some(model) = state.find_model_by_table_mut(table) {
3057					model.rename_field(new_name, old_name.to_string());
3058				}
3059			}
3060			Operation::AddConstraint { table, .. } => {
3061				// Reverse: Would need to remove the constraint
3062				// This requires parsing constraint_sql to get the name
3063				if let Some(model) = state.find_model_by_table_mut(table) {
3064					// Cannot reliably remove without constraint name extraction
3065					// Constraints vector remains unchanged
3066					let _ = model;
3067				}
3068			}
3069			Operation::DropConstraint {
3070				table: _,
3071				constraint_name: _,
3072			} => {
3073				// Cannot reconstruct constraint definition without snapshot.
3074				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
3075			}
3076			_ => {
3077				// Other operations don't affect schema state
3078			}
3079		}
3080	}
3081
3082	/// Extract constraint name from constraint SQL
3083	///
3084	/// Supports patterns:
3085	/// - "CONSTRAINT name CHECK ..."
3086	/// - "ADD CONSTRAINT name ..."
3087	fn extract_constraint_name(constraint_sql: &str) -> Option<String> {
3088		let sql = constraint_sql.trim();
3089
3090		// Pattern 1: "CONSTRAINT name ..."
3091		if sql.starts_with("CONSTRAINT ") || sql.contains(" CONSTRAINT ") {
3092			let parts: Vec<&str> = sql.split_whitespace().collect();
3093			if let Some(pos) = parts.iter().position(|&s| s == "CONSTRAINT")
3094				&& pos + 1 < parts.len()
3095			{
3096				return Some(parts[pos + 1].to_string());
3097			}
3098		}
3099
3100		None
3101	}
3102}
3103
3104/// Column definition for legacy operations
3105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3106pub struct ColumnDefinition {
3107	/// The name.
3108	pub name: String,
3109	/// The type definition.
3110	pub type_definition: FieldType,
3111	#[serde(default)]
3112	/// The not null.
3113	pub not_null: bool,
3114	#[serde(default)]
3115	/// The unique.
3116	pub unique: bool,
3117	#[serde(default)]
3118	/// The primary key.
3119	pub primary_key: bool,
3120	#[serde(default)]
3121	/// The auto increment.
3122	pub auto_increment: bool,
3123	#[serde(default)]
3124	/// The default.
3125	pub default: Option<String>,
3126}
3127
3128impl ColumnDefinition {
3129	/// Create a new column definition
3130	pub fn new(name: impl Into<String>, type_def: FieldType) -> Self {
3131		Self {
3132			name: name.into(),
3133			type_definition: type_def,
3134			not_null: false,
3135			unique: false,
3136			primary_key: false,
3137			auto_increment: false,
3138			default: None,
3139		}
3140	}
3141
3142	/// Create a ColumnDefinition from FieldState with attribute parsing
3143	///
3144	/// Reads boolean attributes (primary_key, unique, auto_increment) and the
3145	/// default expression from `FieldState.params`, and derives the NOT NULL
3146	/// constraint from `FieldState.nullable` (the single source of truth
3147	/// populated by `FieldMetadata::is_nullable()` in
3148	/// `ModelMetadata::to_model_state()`).
3149	///
3150	/// # Arguments
3151	///
3152	/// * `name` - Column name
3153	/// * `field_state` - FieldState containing field metadata and params
3154	///
3155	/// # Notes
3156	///
3157	/// - If `primary_key` is true, `not_null` is forced to true regardless of
3158	///   `FieldState.nullable` — primary keys cannot accept NULL.
3159	/// - Default values are false/None for unspecified attributes
3160	pub fn from_field_state(name: impl Into<String>, field_state: &FieldState) -> Self {
3161		let name_str = name.into();
3162		let params = &field_state.params;
3163
3164		// Parse attributes from params HashMap
3165		let primary_key = params
3166			.get("primary_key")
3167			.and_then(|v| v.parse::<bool>().ok())
3168			.unwrap_or(false);
3169
3170		// Derive `not_null` from FieldState's nullability field (single source
3171		// of truth set in `ModelMetadata::to_model_state()` from
3172		// `FieldMetadata::is_nullable()`). Primary keys are always NOT NULL
3173		// regardless of the nullability flag.
3174		//
3175		// Fixes #4573: the previous implementation derived `not_null` from a
3176		// `params["not_null"]` key, which the `#[model]` proc-macro only
3177		// emits conditionally (when its `is_not_null` calculation returns
3178		// `true`). The same macro also always emits `params["null"]`, which
3179		// `is_nullable()` reads into `field_state.nullable`. Keeping the two
3180		// keys as parallel sources of truth for nullability was the root
3181		// cause of the drift: any code path that bypassed or mis-routed the
3182		// `not_null` emission (e.g., offline state reconstruction, future
3183		// macro refactors, or hand-built `FieldState` values in tests) would
3184		// produce NULLABLE columns for non-Optional fields. Consolidating on
3185		// `field_state.nullable` removes that class of regression.
3186		let not_null = !field_state.nullable || primary_key;
3187
3188		let unique = params
3189			.get("unique")
3190			.and_then(|v| v.parse::<bool>().ok())
3191			.unwrap_or(false);
3192
3193		let auto_increment = params
3194			.get("auto_increment")
3195			.and_then(|v| v.parse::<bool>().ok())
3196			.unwrap_or(false);
3197
3198		let default = params.get("default").cloned();
3199
3200		// Resolve ForeignKey column type from the referenced model's primary
3201		// key in the global `ModelRegistry`. This addresses the macro-level
3202		// limitation that the target model's PK type is not knowable at
3203		// macro-expansion time (see issue #4430). The macro emits a
3204		// placeholder `FieldType::Uuid` for `ForeignKeyField<T>` `_id`
3205		// columns and tags the field with the `fk_target` parameter; here
3206		// we look up the referenced model and adopt its PK column type.
3207		//
3208		// If the lookup fails (e.g., the target model has not been
3209		// registered yet), we fall back to the placeholder field type so
3210		// existing behavior is preserved and the caller can surface a
3211		// downstream error rather than crash here.
3212		let type_definition = resolve_foreign_key_column_type(field_state)
3213			.unwrap_or_else(|| field_state.field_type.clone());
3214
3215		Self {
3216			name: name_str,
3217			type_definition,
3218			not_null,
3219			unique,
3220			primary_key,
3221			auto_increment,
3222			default,
3223		}
3224	}
3225}
3226
3227/// Resolve the column type of a `ForeignKeyField<T>` `_id` column by
3228/// looking up the target model's primary key in the global
3229/// `ModelRegistry`. Returns `None` if `field_state` is not tagged as a
3230/// foreign key column or if the target model / its PK cannot be
3231/// resolved.
3232///
3233/// This indirection exists because the `#[model]` macro cannot resolve
3234/// the target model's PK type at macro-expansion time (the registry is
3235/// populated at process startup via `#[ctor::ctor]`). See issue #4430.
3236///
3237/// # Lookup Strategy
3238///
3239/// The resolver coordinates two lookup paths against the
3240/// `ModelRegistry`:
3241///
3242/// 1. **Qualified `(fk_target_app, fk_target)` lookup.** The
3243///    `#[model]` macro emits `fk_target_app` for every
3244///    `ForeignKeyField<T>` field by reading the target type's *own*
3245///    `<T as Model>::app_label()` at registration time. That value is
3246///    authoritative — it respects `#[app_label = "..."]` overrides
3247///    and matches whatever key the target was registered under,
3248///    regardless of how the user spelled the type (bare ident,
3249///    `use`-imported ident, absolute path, or crate-relative path).
3250///    The qualified lookup is therefore trusted as the primary
3251///    resolution path.
3252/// 2. **By-name lookup.** Used as a defensive fallback for cases
3253///    where `fk_target_app` is absent (e.g. manually-constructed
3254///    `FieldState` outside the macro path) or the qualified lookup
3255///    misses (e.g. the target model isn't registered yet during
3256///    partial registry population at startup). The by-name lookup
3257///    returns `Some` only when *exactly one* model is registered
3258///    under the name; on ambiguity it returns `None`.
3259///
3260/// When both paths return `None` and the name is ambiguous across two
3261/// or more apps (`ModelRegistry::count_models_by_name > 1`), the
3262/// resolver emits a `tracing::warn!` so operators see a targeted
3263/// diagnostic. A genuinely missing name returns `None` silently —
3264/// that case is normal during partial registry population at startup.
3265///
3266/// See issue #4436 and PR #4440 review threads on `model_derive.rs`
3267/// line 2863 and `operations.rs` line 2836.
3268fn resolve_foreign_key_column_type(field_state: &FieldState) -> Option<FieldType> {
3269	resolve_foreign_key_column_type_with(field_state, super::model_registry::global_registry())
3270}
3271
3272/// Registry-injected variant of [`resolve_foreign_key_column_type`].
3273///
3274/// Exists so unit tests can exercise the qualified-hit / by-name
3275/// fallback / ambiguous-miss branches against a local
3276/// [`super::model_registry::ModelRegistry`] without touching global
3277/// state. Production code paths go through
3278/// [`resolve_foreign_key_column_type`].
3279fn resolve_foreign_key_column_type_with(
3280	field_state: &FieldState,
3281	registry: &super::model_registry::ModelRegistry,
3282) -> Option<FieldType> {
3283	let target_model = field_state.params.get("fk_target")?;
3284	// `fk_target_app` is sourced from the target type's own
3285	// `Model::app_label()` (see `model_derive.rs`), so the qualified
3286	// lookup is authoritative. The by-name fallback is defensive: it
3287	// covers manually-constructed `FieldState`s and partial-registry
3288	// init races where the target isn't registered yet.
3289	let target = match field_state.params.get("fk_target_app") {
3290		Some(app) => registry
3291			.find_model_qualified(app, target_model)
3292			.or_else(|| registry.find_model_by_name(target_model)),
3293		None => registry.find_model_by_name(target_model),
3294	};
3295	let target = match target {
3296		Some(t) => t,
3297		None => {
3298			// `find_model_by_name` returns `None` for both "missing"
3299			// and "ambiguous". Warn only on ambiguity so operators see
3300			// a targeted message; silent on genuinely missing targets
3301			// (normal during partial registry population at startup).
3302			if registry.count_models_by_name(target_model) > 1 {
3303				tracing::warn!(
3304					model_name = %target_model,
3305					fk_target_app = ?field_state.params.get("fk_target_app"),
3306					"FK target name is ambiguous across apps and the qualified \
3307					 lookup did not resolve a unique target. Refusing to resolve \
3308					 to avoid silent wrong-target resolution. Ensure the FK \
3309					 target type is registered and that its `Model::app_label()` \
3310					 matches one of the registered apps.",
3311				);
3312			}
3313			return None;
3314		}
3315	};
3316	// Find the primary key field of the target model.
3317	let pk_field = target
3318		.fields
3319		.values()
3320		.find(|f| f.params.get("primary_key").map(String::as_str) == Some("true"))?;
3321	Some(pk_field.field_type.clone())
3322}
3323
3324/// Convert a field type string (e.g., "reinhardt.orm.models.CharField") to FieldType.
3325///
3326/// This function parses the field type path generated by the `#[model(...)]` macro
3327/// and converts it to the corresponding `FieldType` enum variant.
3328///
3329/// # Arguments
3330///
3331/// * `field_type` - The field type path string (e.g., "reinhardt.orm.models.CharField")
3332/// * `attributes` - Field attributes containing parameters like max_length, max_digits, etc.
3333///
3334/// # Returns
3335///
3336/// * `Ok(FieldType)` - The converted FieldType
3337/// * `Err(String)` - Error message if the field type is unsupported
3338///
3339/// # Examples
3340///
3341/// ```rust,ignore
3342/// use reinhardt_db::migrations::operations::field_type_string_to_field_type;
3343/// use std::collections::HashMap;
3344///
3345/// let mut attrs = HashMap::new();
3346/// attrs.insert("max_length".to_string(), "100".to_string());
3347///
3348/// let field_type = field_type_string_to_field_type("reinhardt.orm.models.CharField", &attrs);
3349/// assert!(field_type.is_ok());
3350/// ```
3351pub fn field_type_string_to_field_type(
3352	field_type: &str,
3353	attributes: &std::collections::HashMap<String, String>,
3354) -> Result<FieldType, String> {
3355	// Extract the type name from the full path
3356	let type_name = field_type.split('.').next_back().unwrap_or(field_type);
3357
3358	match type_name {
3359		// Integer types
3360		"IntegerField"
3361		| "PositiveIntegerField"
3362		| "SmallIntegerField"
3363		| "PositiveSmallIntegerField" => Ok(FieldType::Integer),
3364		"BigIntegerField" | "PositiveBigIntegerField" => Ok(FieldType::BigInteger),
3365		"AutoField" => Ok(FieldType::Integer),
3366		"BigAutoField" => Ok(FieldType::BigInteger),
3367		"SmallAutoField" => Ok(FieldType::SmallInteger),
3368
3369		// String types
3370		"CharField" => {
3371			let max_length = attributes
3372				.get("max_length")
3373				.and_then(|v| v.parse::<u32>().ok())
3374				.ok_or_else(|| "CharField requires max_length attribute".to_string())?;
3375			Ok(FieldType::VarChar(max_length))
3376		}
3377		"TextField" => Ok(FieldType::Text),
3378		"SlugField" => {
3379			let max_length = attributes
3380				.get("max_length")
3381				.and_then(|v| v.parse::<u32>().ok())
3382				.unwrap_or(50);
3383			Ok(FieldType::VarChar(max_length))
3384		}
3385		"EmailField" => {
3386			let max_length = attributes
3387				.get("max_length")
3388				.and_then(|v| v.parse::<u32>().ok())
3389				.unwrap_or(254);
3390			Ok(FieldType::VarChar(max_length))
3391		}
3392		"URLField" => {
3393			let max_length = attributes
3394				.get("max_length")
3395				.and_then(|v| v.parse::<u32>().ok())
3396				.unwrap_or(200);
3397			Ok(FieldType::VarChar(max_length))
3398		}
3399
3400		// Boolean type
3401		"BooleanField" => Ok(FieldType::Boolean),
3402		"NullBooleanField" => Ok(FieldType::Boolean),
3403
3404		// Date/time types
3405		"DateField" => Ok(FieldType::Date),
3406		"TimeField" => Ok(FieldType::Time),
3407		"DateTimeField" => Ok(FieldType::DateTime),
3408		"DurationField" => Ok(FieldType::BigInteger), // Stored as microseconds
3409
3410		// Numeric types
3411		"FloatField" => Ok(FieldType::Float),
3412		"DecimalField" => {
3413			let precision = attributes
3414				.get("max_digits")
3415				.and_then(|v| v.parse::<u32>().ok())
3416				.unwrap_or(10);
3417			let scale = attributes
3418				.get("decimal_places")
3419				.and_then(|v| v.parse::<u32>().ok())
3420				.unwrap_or(2);
3421			Ok(FieldType::Decimal { precision, scale })
3422		}
3423
3424		// Binary types
3425		"BinaryField" => Ok(FieldType::Binary),
3426
3427		// UUID type
3428		"UUIDField" => Ok(FieldType::Uuid),
3429
3430		// JSON types
3431		"JSONField" => Ok(FieldType::Json),
3432
3433		// File fields (stored as path strings)
3434		"FileField" | "ImageField" => {
3435			let max_length = attributes
3436				.get("max_length")
3437				.and_then(|v| v.parse::<u32>().ok())
3438				.unwrap_or(100);
3439			Ok(FieldType::VarChar(max_length))
3440		}
3441
3442		// IP Address fields
3443		"GenericIPAddressField" | "IPAddressField" => {
3444			// PostgreSQL uses INET, others use VARCHAR
3445			Ok(FieldType::VarChar(39)) // Max length for IPv6
3446		}
3447
3448		// Relationship fields (stored as foreign key reference)
3449		"ForeignKey" => {
3450			// ForeignKey is typically stored as integer ID
3451			Ok(FieldType::BigInteger)
3452		}
3453		"OneToOneField" => Ok(FieldType::BigInteger),
3454
3455		// Unknown type
3456		other => Err(format!("Unsupported field type: {}", other)),
3457	}
3458}
3459
3460/// SQL dialect for generating database-specific SQL
3461#[derive(Debug, Clone, Copy)]
3462pub enum SqlDialect {
3463	/// Sqlite variant.
3464	Sqlite,
3465	/// Postgres variant.
3466	Postgres,
3467	/// Mysql variant.
3468	Mysql,
3469	/// Cockroachdb variant.
3470	Cockroachdb,
3471}
3472
3473// ============================================================================
3474// SQLite Table Recreation Support
3475// ============================================================================
3476
3477/// Represents a SQLite table recreation operation
3478///
3479/// SQLite has limited ALTER TABLE support - operations like DROP COLUMN,
3480/// ALTER COLUMN TYPE, and constraint modifications require recreating the table.
3481///
3482/// This struct generates the 4-step SQL pattern:
3483/// 1. CREATE TABLE temp_table (with new schema)
3484/// 2. INSERT INTO temp_table SELECT columns FROM old_table
3485/// 3. DROP TABLE old_table
3486/// 4. ALTER TABLE temp_table RENAME TO old_table
3487///
3488/// This type is integrated into `DatabaseMigrationExecutor` which automatically
3489/// detects SQLite operations requiring recreation and applies the 4-step process
3490/// within the migration's transaction context.
3491#[derive(Debug, Clone)]
3492pub struct SqliteTableRecreation {
3493	/// Original table name
3494	pub table_name: String,
3495	/// New column definitions (after modification)
3496	pub new_columns: Vec<ColumnDefinition>,
3497	/// Columns to copy from old table (in order matching new_columns)
3498	pub columns_to_copy: Vec<String>,
3499	/// Constraints for the new table (parsed from introspection)
3500	pub constraints: Vec<Constraint>,
3501	/// Raw constraint SQL strings (for AddConstraint operations)
3502	pub raw_constraint_sqls: Vec<String>,
3503	/// WITHOUT ROWID option
3504	pub without_rowid: bool,
3505}
3506
3507impl SqliteTableRecreation {
3508	/// Create a new table recreation for dropping a column
3509	pub fn for_drop_column(
3510		table_name: impl Into<String>,
3511		current_columns: Vec<ColumnDefinition>,
3512		column_to_drop: &str,
3513		current_constraints: Vec<Constraint>,
3514	) -> Self {
3515		let table_name = table_name.into();
3516		let new_columns: Vec<_> = current_columns
3517			.into_iter()
3518			.filter(|c| c.name != column_to_drop)
3519			.collect();
3520		let columns_to_copy: Vec<_> = new_columns.iter().map(|c| c.name.to_string()).collect();
3521
3522		// Filter out constraints that reference the dropped column
3523		let constraints: Vec<_> = current_constraints
3524			.into_iter()
3525			.filter(|c| !Self::constraint_references_column(c, column_to_drop))
3526			.collect();
3527
3528		Self {
3529			table_name,
3530			new_columns,
3531			columns_to_copy,
3532			constraints,
3533			raw_constraint_sqls: Vec::new(),
3534			without_rowid: false,
3535		}
3536	}
3537
3538	/// Create a new table recreation for altering a column type
3539	pub fn for_alter_column(
3540		table_name: impl Into<String>,
3541		current_columns: Vec<ColumnDefinition>,
3542		column_name: &str,
3543		new_definition: ColumnDefinition,
3544		current_constraints: Vec<Constraint>,
3545	) -> Self {
3546		let table_name = table_name.into();
3547		let new_columns: Vec<_> = current_columns
3548			.into_iter()
3549			.map(|c| {
3550				if c.name == column_name {
3551					new_definition.clone()
3552				} else {
3553					c
3554				}
3555			})
3556			.collect();
3557		let columns_to_copy: Vec<_> = new_columns.iter().map(|c| c.name.to_string()).collect();
3558
3559		Self {
3560			table_name,
3561			new_columns,
3562			columns_to_copy,
3563			constraints: current_constraints,
3564			raw_constraint_sqls: Vec::new(),
3565			without_rowid: false,
3566		}
3567	}
3568
3569	/// Create a new table recreation for adding a constraint
3570	///
3571	/// Since SQLite doesn't support `ALTER TABLE ADD CONSTRAINT`, we need to
3572	/// recreate the table with the new constraint included.
3573	pub fn for_add_constraint(
3574		table_name: impl Into<String>,
3575		current_columns: Vec<ColumnDefinition>,
3576		current_constraints: Vec<Constraint>,
3577		constraint_sql: String,
3578	) -> Self {
3579		let table_name = table_name.into();
3580		let columns_to_copy: Vec<_> = current_columns.iter().map(|c| c.name.to_string()).collect();
3581
3582		Self {
3583			table_name,
3584			new_columns: current_columns,
3585			columns_to_copy,
3586			constraints: current_constraints,
3587			raw_constraint_sqls: vec![constraint_sql],
3588			without_rowid: false,
3589		}
3590	}
3591
3592	/// Create a new table recreation for dropping a constraint
3593	///
3594	/// Since SQLite doesn't support `ALTER TABLE DROP CONSTRAINT`, we need to
3595	/// recreate the table without the specified constraint.
3596	pub fn for_drop_constraint(
3597		table_name: impl Into<String>,
3598		current_columns: Vec<ColumnDefinition>,
3599		current_constraints: Vec<Constraint>,
3600		constraint_name: &str,
3601	) -> Self {
3602		let table_name = table_name.into();
3603		let columns_to_copy: Vec<_> = current_columns.iter().map(|c| c.name.to_string()).collect();
3604
3605		// Filter out the constraint by name
3606		let constraints: Vec<_> = current_constraints
3607			.into_iter()
3608			.filter(|c| !Self::constraint_has_name(c, constraint_name))
3609			.collect();
3610
3611		Self {
3612			table_name,
3613			new_columns: current_columns,
3614			columns_to_copy,
3615			constraints,
3616			raw_constraint_sqls: Vec::new(),
3617			without_rowid: false,
3618		}
3619	}
3620
3621	/// Generate the 4-step SQL statements for table recreation
3622	pub fn to_sql_statements(&self) -> Vec<String> {
3623		let temp_table = format!("{}_new", self.table_name);
3624
3625		// Step 1: CREATE TABLE with new schema
3626		let column_defs: Vec<String> = self
3627			.new_columns
3628			.iter()
3629			.map(|c| Operation::column_to_sql(c, &SqlDialect::Sqlite))
3630			.collect();
3631
3632		let constraint_defs: Vec<String> = self.constraints.iter().map(|c| c.to_string()).collect();
3633
3634		let mut create_parts = column_defs;
3635		create_parts.extend(constraint_defs);
3636		// Include raw constraint SQLs (from AddConstraint operations)
3637		create_parts.extend(self.raw_constraint_sqls.clone());
3638
3639		let mut create_sql = format!(
3640			"CREATE TABLE \"{}\" (\n  {}\n)",
3641			temp_table,
3642			create_parts.join(",\n  ")
3643		);
3644		if self.without_rowid {
3645			create_sql.push_str(" WITHOUT ROWID");
3646		}
3647		create_sql.push(';');
3648
3649		// Step 2: Copy data
3650		let columns_list = self
3651			.columns_to_copy
3652			.iter()
3653			.map(|c| format!("\"{}\"", c))
3654			.collect::<Vec<_>>()
3655			.join(", ");
3656		let insert_sql = format!(
3657			"INSERT INTO \"{}\" SELECT {} FROM \"{}\";",
3658			temp_table, columns_list, self.table_name
3659		);
3660
3661		// Step 3: Drop old table
3662		let drop_sql = format!("DROP TABLE \"{}\";", self.table_name);
3663
3664		// Step 4: Rename new table
3665		let rename_sql = format!(
3666			"ALTER TABLE \"{}\" RENAME TO \"{}\";",
3667			temp_table, self.table_name
3668		);
3669
3670		vec![create_sql, insert_sql, drop_sql, rename_sql]
3671	}
3672
3673	/// Check if a constraint references a specific column
3674	fn constraint_references_column(constraint: &Constraint, column_name: &str) -> bool {
3675		match constraint {
3676			Constraint::PrimaryKey { columns, .. } => columns.iter().any(|c| c == column_name),
3677			Constraint::ForeignKey { columns, .. } => columns.iter().any(|c| c == column_name),
3678			Constraint::Unique { columns, .. } => columns.iter().any(|c| c == column_name),
3679			Constraint::Check { expression, .. } => expression.contains(column_name),
3680			Constraint::OneToOne { column, .. } => column == column_name,
3681			Constraint::ManyToMany { source_column, .. } => source_column == column_name,
3682			Constraint::Exclude { elements, .. } => {
3683				elements.iter().any(|(col, _)| col == column_name)
3684			}
3685		}
3686	}
3687
3688	/// Check if a constraint has the specified name
3689	fn constraint_has_name(constraint: &Constraint, constraint_name: &str) -> bool {
3690		match constraint {
3691			Constraint::PrimaryKey { name, .. } => name == constraint_name,
3692			Constraint::ForeignKey { name, .. } => name == constraint_name,
3693			Constraint::Unique { name, .. } => name == constraint_name,
3694			Constraint::Check { name, .. } => name == constraint_name,
3695			Constraint::OneToOne { name, .. } => name == constraint_name,
3696			Constraint::ManyToMany { name, .. } => name == constraint_name,
3697			Constraint::Exclude { name, .. } => name == constraint_name,
3698		}
3699	}
3700}
3701
3702impl Operation {
3703	/// Check if this operation requires SQLite table recreation
3704	pub fn requires_sqlite_recreation(&self) -> bool {
3705		matches!(
3706			self,
3707			Operation::DropColumn { .. }
3708				| Operation::AlterColumn { .. }
3709				| Operation::AddConstraint { .. }
3710				| Operation::DropConstraint { .. }
3711		)
3712	}
3713
3714	/// Check if the reverse of this operation requires SQLite table recreation
3715	///
3716	/// When rolling back a migration on SQLite, some reverse operations also require
3717	/// table recreation. This method identifies those cases.
3718	///
3719	/// | Forward Operation | Reverse Operation | Requires Recreation |
3720	/// |-------------------|-------------------|---------------------|
3721	/// | AddColumn         | DropColumn        | Yes                 |
3722	/// | AlterColumn       | AlterColumn       | Yes                 |
3723	/// | AddConstraint     | DropConstraint    | Yes                 |
3724	/// | DropConstraint    | AddConstraint     | Yes                 |
3725	pub fn reverse_requires_sqlite_recreation(&self) -> bool {
3726		matches!(
3727			self,
3728			// AddColumn → Reverse DropColumn (requires recreation)
3729			Operation::AddColumn { .. }
3730				// AlterColumn → Reverse AlterColumn (requires recreation)
3731				| Operation::AlterColumn { .. }
3732				// AddConstraint → Reverse DropConstraint (requires recreation)
3733				| Operation::AddConstraint { .. }
3734				// DropConstraint → Reverse AddConstraint (requires recreation)
3735				| Operation::DropConstraint { .. }
3736		)
3737	}
3738
3739	/// Generate the reverse operation (for rollback on SQLite)
3740	///
3741	/// This method returns the conceptual reverse `Operation`, which can be used
3742	/// with `handle_sqlite_recreation()` for databases that don't support direct
3743	/// ALTER TABLE operations.
3744	///
3745	/// # Arguments
3746	///
3747	/// * `project_state` - Project state for accessing model definitions
3748	///
3749	/// # Returns
3750	///
3751	/// * `Ok(Some(op))` - Reverse operation generated successfully
3752	/// * `Ok(None)` - Operation is not reversible or state information is missing
3753	/// * `Err(_)` - Error generating reverse operation
3754	pub fn to_reverse_operation(
3755		&self,
3756		project_state: &ProjectState,
3757	) -> super::Result<Option<Operation>> {
3758		match self {
3759			Operation::CreateTable { name, .. } => {
3760				Ok(Some(Operation::DropTable { name: name.clone() }))
3761			}
3762			Operation::DropTable { name } => {
3763				// Reconstruct CreateTable from ProjectState
3764				if let Some(model) = project_state.find_model_by_table(name) {
3765					let columns: Vec<ColumnDefinition> = model
3766						.fields
3767						.iter()
3768						.map(|(field_name, field)| {
3769							ColumnDefinition::from_field_state(field_name.clone(), field)
3770						})
3771						.collect();
3772					let constraints: Vec<Constraint> = model
3773						.constraints
3774						.iter()
3775						.map(|c| c.to_constraint())
3776						.collect();
3777					return Ok(Some(Operation::CreateTable {
3778						name: name.clone(),
3779						columns,
3780						constraints,
3781						without_rowid: None,
3782						interleave_in_parent: None,
3783						partition: None,
3784					}));
3785				}
3786				Ok(None)
3787			}
3788			Operation::AddColumn { table, column, .. } => Ok(Some(Operation::DropColumn {
3789				table: table.clone(),
3790				column: column.name.clone(),
3791			})),
3792			Operation::DropColumn { table, column } => {
3793				// Reconstruct AddColumn from ProjectState
3794				if let Some(model) = project_state.find_model_by_table(table)
3795					&& let Some(field) = model.get_field(column)
3796				{
3797					let col_def = ColumnDefinition::from_field_state(column.clone(), field);
3798					return Ok(Some(Operation::AddColumn {
3799						table: table.clone(),
3800						column: col_def,
3801						mysql_options: None,
3802					}));
3803				}
3804				Ok(None)
3805			}
3806			Operation::AlterColumn {
3807				table,
3808				column,
3809				old_definition,
3810				new_definition: _,
3811				..
3812			} => {
3813				// Reconstruct AlterColumn with the original definition. Prefer the
3814				// explicit `old_definition` carried by the forward operation; fall
3815				// back to ProjectState lookup only if `old_definition` is absent.
3816				let resolved_old_def = old_definition.clone().or_else(|| {
3817					project_state
3818						.find_model_by_table(table)
3819						.and_then(|model| model.get_field(column))
3820						.map(|field| ColumnDefinition::from_field_state(column.clone(), field))
3821				});
3822
3823				if let Some(col_def) = resolved_old_def {
3824					return Ok(Some(Operation::AlterColumn {
3825						table: table.clone(),
3826						column: column.clone(),
3827						old_definition: None,
3828						new_definition: col_def,
3829						mysql_options: None,
3830					}));
3831				}
3832				Ok(None)
3833			}
3834			Operation::AddConstraint {
3835				table,
3836				constraint_sql,
3837			} => {
3838				// Extract constraint name to create DropConstraint
3839				if let Some(constraint_name) = Self::extract_constraint_name(constraint_sql) {
3840					return Ok(Some(Operation::DropConstraint {
3841						table: table.clone(),
3842						constraint_name,
3843					}));
3844				}
3845				Err(super::MigrationError::InvalidMigration(format!(
3846					"Cannot extract constraint name from: {}",
3847					constraint_sql
3848				)))
3849			}
3850			Operation::DropConstraint {
3851				table,
3852				constraint_name,
3853			} => {
3854				// Reconstruct AddConstraint from ProjectState
3855				if let Some(model) = project_state.find_model_by_table(table)
3856					&& let Some(constraint_def) = model
3857						.constraints
3858						.iter()
3859						.find(|c| c.name == *constraint_name)
3860				{
3861					let constraint = constraint_def.to_constraint();
3862					return Ok(Some(Operation::AddConstraint {
3863						table: table.clone(),
3864						constraint_sql: format!("{}", constraint),
3865					}));
3866				}
3867				Ok(None)
3868			}
3869			Operation::RenameTable { old_name, new_name } => Ok(Some(Operation::RenameTable {
3870				old_name: new_name.clone(),
3871				new_name: old_name.clone(),
3872			})),
3873			Operation::RenameColumn {
3874				table,
3875				old_name,
3876				new_name,
3877			} => Ok(Some(Operation::RenameColumn {
3878				table: table.clone(),
3879				old_name: new_name.clone(),
3880				new_name: old_name.clone(),
3881			})),
3882			Operation::CreateIndex {
3883				table,
3884				columns,
3885				unique,
3886				index_type,
3887				where_clause,
3888				concurrently,
3889				expressions,
3890				mysql_options,
3891				operator_class,
3892			} => Ok(Some(Operation::DropNamedIndex {
3893				table: table.clone(),
3894				name: generated_index_name(table, columns, expressions.as_deref()),
3895				columns: columns.clone(),
3896				unique: *unique,
3897				index_type: *index_type,
3898				where_clause: where_clause.clone(),
3899				concurrently: *concurrently,
3900				expressions: expressions.clone(),
3901				mysql_options: *mysql_options,
3902				operator_class: operator_class.clone(),
3903			})),
3904			Operation::CreateIndexRepair {
3905				table,
3906				name,
3907				columns,
3908				unique,
3909				index_type,
3910				where_clause,
3911				concurrently,
3912				expressions,
3913				mysql_options,
3914				operator_class,
3915			} => Ok(Some(Operation::DropNamedIndex {
3916				table: table.clone(),
3917				name: name.clone().unwrap_or_else(|| {
3918					generated_index_name(table, columns, expressions.as_deref())
3919				}),
3920				columns: columns.clone(),
3921				unique: *unique,
3922				index_type: *index_type,
3923				where_clause: where_clause.clone(),
3924				concurrently: *concurrently,
3925				expressions: expressions.clone(),
3926				mysql_options: *mysql_options,
3927				operator_class: operator_class.clone(),
3928			})),
3929			Operation::DropIndex { table, columns } => {
3930				// Basic index recreation (without advanced properties)
3931				// Note: Cannot determine if the original index was unique from DropIndex alone
3932				Ok(Some(Operation::CreateIndex {
3933					table: table.clone(),
3934					columns: columns.clone(),
3935					unique: false,
3936					index_type: None,
3937					where_clause: None,
3938					concurrently: false,
3939					expressions: None,
3940					mysql_options: None,
3941					operator_class: None,
3942				}))
3943			}
3944			Operation::DropNamedIndex {
3945				table,
3946				name,
3947				columns,
3948				unique,
3949				index_type,
3950				where_clause,
3951				concurrently,
3952				expressions,
3953				mysql_options,
3954				operator_class,
3955				..
3956			} => Ok(Some(Operation::CreateIndexRepair {
3957				table: table.clone(),
3958				name: Some(name.clone()),
3959				columns: columns.clone(),
3960				unique: *unique,
3961				index_type: *index_type,
3962				where_clause: where_clause.clone(),
3963				concurrently: *concurrently,
3964				expressions: expressions.clone(),
3965				mysql_options: *mysql_options,
3966				operator_class: operator_class.clone(),
3967			})),
3968			// Operations that are not reversible as Operations
3969			Operation::RunSQL { .. } | Operation::RunRust { .. } | Operation::BulkLoad { .. } => {
3970				Ok(None)
3971			}
3972			// Other operations - not reversible via to_reverse_operation
3973			_ => Ok(None),
3974		}
3975	}
3976}
3977
3978// Re-export for convenience (legacy)
3979pub use Operation::{AddColumn, AlterColumn, CreateTable, DropColumn};
3980
3981/// Operation statement types (reinhardt-query or sanitized raw SQL)
3982pub enum OperationStatement {
3983	/// TableCreate variant.
3984	TableCreate(CreateTableStatement),
3985	/// TableDrop variant.
3986	TableDrop(DropTableStatement),
3987	/// TableAlter variant.
3988	TableAlter(AlterTableStatement),
3989	/// TableRename variant.
3990	TableRename(AlterTableStatement),
3991	/// IndexCreate variant.
3992	IndexCreate(CreateIndexStatement),
3993	/// IndexDrop variant.
3994	IndexDrop(DropIndexStatement),
3995	/// Sanitized raw SQL (identifiers escaped with pg_escape::quote_identifier)
3996	RawSql(String),
3997}
3998
3999impl OperationStatement {
4000	/// Execute the operation statement
4001	pub async fn execute<'c, E>(&self, executor: E) -> Result<(), sqlx::Error>
4002	where
4003		E: sqlx::Executor<'c, Database = sqlx::Postgres>,
4004	{
4005		use crate::backends::sql_build_helpers;
4006		use crate::backends::types::DatabaseType;
4007		let db_type = DatabaseType::Postgres;
4008		match self {
4009			OperationStatement::TableCreate(stmt) => {
4010				let sql = sql_build_helpers::build_create_table_sql(db_type, stmt);
4011				sqlx::query(&sql).execute(executor).await?;
4012			}
4013			OperationStatement::TableDrop(stmt) => {
4014				let sql = sql_build_helpers::build_drop_table_sql(db_type, stmt);
4015				sqlx::query(&sql).execute(executor).await?;
4016			}
4017			OperationStatement::TableAlter(stmt) => {
4018				let sql = sql_build_helpers::build_alter_table_sql(db_type, stmt);
4019				sqlx::query(&sql).execute(executor).await?;
4020			}
4021			OperationStatement::TableRename(stmt) => {
4022				let sql = sql_build_helpers::build_alter_table_sql(db_type, stmt);
4023				sqlx::query(&sql).execute(executor).await?;
4024			}
4025			OperationStatement::IndexCreate(stmt) => {
4026				let sql = sql_build_helpers::build_create_index_sql(db_type, stmt);
4027				sqlx::query(&sql).execute(executor).await?;
4028			}
4029			OperationStatement::IndexDrop(stmt) => {
4030				let sql = sql_build_helpers::build_drop_index_sql(db_type, stmt);
4031				sqlx::query(&sql).execute(executor).await?;
4032			}
4033			OperationStatement::RawSql(sql) => {
4034				// Already sanitized with pg_escape::quote_identifier
4035				sqlx::query(sql).execute(executor).await?;
4036			}
4037		}
4038		Ok(())
4039	}
4040
4041	/// Convert to SQL string for logging/debugging
4042	///
4043	/// # Arguments
4044	///
4045	/// * `db_type` - Database type to generate SQL for (PostgreSQL, MySQL, SQLite)
4046	pub fn to_sql_string(&self, db_type: crate::backends::types::DatabaseType) -> String {
4047		use crate::backends::sql_build_helpers;
4048
4049		match self {
4050			OperationStatement::TableCreate(stmt) => {
4051				sql_build_helpers::build_create_table_sql(db_type, stmt)
4052			}
4053			OperationStatement::TableDrop(stmt) => {
4054				sql_build_helpers::build_drop_table_sql(db_type, stmt)
4055			}
4056			OperationStatement::TableAlter(stmt) => {
4057				sql_build_helpers::build_alter_table_sql(db_type, stmt)
4058			}
4059			OperationStatement::TableRename(stmt) => {
4060				sql_build_helpers::build_alter_table_sql(db_type, stmt)
4061			}
4062			OperationStatement::IndexCreate(stmt) => {
4063				sql_build_helpers::build_create_index_sql(db_type, stmt)
4064			}
4065			OperationStatement::IndexDrop(stmt) => {
4066				sql_build_helpers::build_drop_index_sql(db_type, stmt)
4067			}
4068			OperationStatement::RawSql(sql) => sql.clone(),
4069		}
4070	}
4071}
4072
4073impl Operation {
4074	/// Convert Operation to reinhardt-query statement or sanitized raw SQL
4075	pub fn to_statement(&self) -> OperationStatement {
4076		match self {
4077			Operation::CreateTable {
4078				name,
4079				columns,
4080				constraints,
4081				..
4082			} => {
4083				OperationStatement::TableCreate(self.build_create_table(name, columns, constraints))
4084			}
4085			Operation::DropTable { name } => {
4086				OperationStatement::TableDrop(self.build_drop_table(name))
4087			}
4088			Operation::AddColumn { table, column, .. } => {
4089				OperationStatement::TableAlter(self.build_add_column(table, column))
4090			}
4091			Operation::DropColumn { table, column } => {
4092				OperationStatement::TableAlter(self.build_drop_column(table, column))
4093			}
4094			Operation::AlterColumn {
4095				table,
4096				column,
4097				new_definition,
4098				..
4099			} => OperationStatement::TableAlter(self.build_alter_column(
4100				table,
4101				column,
4102				new_definition,
4103			)),
4104			Operation::RenameTable { old_name, new_name } => {
4105				OperationStatement::TableRename(self.build_rename_table(old_name, new_name))
4106			}
4107			// reinhardt-query does not support RENAME COLUMN, use sanitized raw SQL
4108			Operation::RenameColumn {
4109				table,
4110				old_name,
4111				new_name,
4112			} => OperationStatement::RawSql(format!(
4113				"ALTER TABLE {} RENAME COLUMN {} TO {}",
4114				quote_identifier(table),
4115				quote_identifier(old_name),
4116				quote_identifier(new_name)
4117			)),
4118			Operation::AddConstraint {
4119				table,
4120				constraint_sql,
4121			} => {
4122				// NOTE: constraint_sql validation is the caller's responsibility
4123				OperationStatement::RawSql(format!(
4124					"ALTER TABLE {} ADD {}",
4125					quote_identifier(table),
4126					constraint_sql
4127				))
4128			}
4129			Operation::DropConstraint {
4130				table,
4131				constraint_name,
4132			} => OperationStatement::RawSql(format!(
4133				"ALTER TABLE {} DROP CONSTRAINT {}",
4134				quote_identifier(table),
4135				quote_identifier(constraint_name)
4136			)),
4137			Operation::CreateIndex {
4138				table,
4139				columns,
4140				unique,
4141				..
4142			} => {
4143				let idx_name = format!("idx_{}_{}", table, columns.join("_"));
4144				OperationStatement::IndexCreate(
4145					self.build_create_index(&idx_name, table, columns, *unique),
4146				)
4147			}
4148			Operation::CreateIndexRepair {
4149				table,
4150				name,
4151				columns,
4152				unique,
4153				expressions,
4154				..
4155			} => {
4156				let generated_name;
4157				let idx_name = if let Some(name) = name.as_deref() {
4158					name
4159				} else {
4160					generated_name = generated_index_name(table, columns, expressions.as_deref());
4161					&generated_name
4162				};
4163				OperationStatement::IndexCreate(
4164					self.build_create_index(idx_name, table, columns, *unique),
4165				)
4166			}
4167			Operation::DropIndex { table, columns } => {
4168				let idx_name = format!("idx_{}_{}", table, columns.join("_"));
4169				OperationStatement::IndexDrop(self.build_drop_index(&idx_name))
4170			}
4171			Operation::DropNamedIndex { name, .. } => {
4172				OperationStatement::IndexDrop(self.build_drop_index(name))
4173			}
4174			Operation::RunSQL { sql, .. } => OperationStatement::RawSql(sql.to_string()),
4175			Operation::RunRust { code, .. } => {
4176				// RunRust operations don't produce SQL
4177				OperationStatement::RawSql(format!(
4178					"-- RunRust: {}",
4179					code.lines().next().unwrap_or("")
4180				))
4181			}
4182			Operation::AlterTableComment { table, comment } => {
4183				// PostgreSQL-specific COMMENT ON TABLE
4184				OperationStatement::RawSql(if let Some(comment_text) = comment {
4185					format!(
4186						"COMMENT ON TABLE {} IS '{}'",
4187						quote_identifier(table),
4188						comment_text.replace('\'', "''") // Escape single quotes
4189					)
4190				} else {
4191					format!("COMMENT ON TABLE {} IS NULL", quote_identifier(table))
4192				})
4193			}
4194			Operation::AlterUniqueTogether {
4195				table,
4196				unique_together,
4197			} => {
4198				let mut sqls = Vec::new();
4199				for (idx, fields) in unique_together.iter().enumerate() {
4200					let constraint_name = format!("{}_{}_uniq", table, idx);
4201					let fields_str: Vec<String> = fields
4202						.iter()
4203						.map(|f| quote_identifier(f).to_string())
4204						.collect();
4205					sqls.push(format!(
4206						"ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})",
4207						quote_identifier(table),
4208						quote_identifier(&constraint_name),
4209						fields_str.join(", ")
4210					));
4211				}
4212				OperationStatement::RawSql(sqls.join(";\n"))
4213			}
4214			Operation::AlterModelOptions { .. } => OperationStatement::RawSql(String::new()),
4215			Operation::CreateInheritedTable {
4216				name,
4217				columns,
4218				base_table,
4219				join_column,
4220			} => {
4221				let mut stmt = Query::create_table();
4222				stmt.table(Alias::new(name.as_str())).if_not_exists();
4223
4224				// Add join column (foreign key to base table)
4225				let join_col = ColumnDef::new(Alias::new(join_column.as_str()));
4226				let join_col = join_col.integer();
4227				stmt.col(join_col);
4228
4229				// Add other columns
4230				for col in columns {
4231					let mut column = ColumnDef::new(Alias::new(col.name.as_str()));
4232					column = self.apply_column_type(column, &col.type_definition);
4233					stmt.col(column);
4234				}
4235
4236				// Add foreign key
4237				let mut fk = reinhardt_query::prelude::ForeignKey::create();
4238				fk.from_tbl(Alias::new(name.as_str()))
4239					.from_col(Alias::new(join_column.as_str()))
4240					.to_tbl(Alias::new(base_table.as_str()))
4241					.to_col(Alias::new("id"));
4242				stmt.foreign_key_from_builder(&mut fk);
4243
4244				OperationStatement::TableCreate(stmt.to_owned())
4245			}
4246			Operation::AddDiscriminatorColumn {
4247				table,
4248				column_name,
4249				default_value,
4250			} => {
4251				let mut stmt = Query::alter_table();
4252				stmt.table(Alias::new(table.as_str()));
4253
4254				let mut col = ColumnDef::new(Alias::new(column_name.as_str()));
4255				col = col
4256					.string_len(50)
4257					.default(SimpleExpr::from(default_value.to_string()));
4258				stmt.add_column(col);
4259
4260				OperationStatement::TableAlter(stmt.to_owned())
4261			}
4262			Operation::MoveModel {
4263				rename_table,
4264				old_table_name,
4265				new_table_name,
4266				..
4267			} => {
4268				// MoveModel generates a table rename if table name changes
4269				if *rename_table {
4270					if let (Some(old_name), Some(new_name)) = (old_table_name, new_table_name) {
4271						OperationStatement::TableRename(self.build_rename_table(old_name, new_name))
4272					} else {
4273						// No table rename needed
4274						OperationStatement::RawSql("-- MoveModel: State-only operation".to_string())
4275					}
4276				} else {
4277					// State-only operation, no SQL
4278					OperationStatement::RawSql("-- MoveModel: State-only operation".to_string())
4279				}
4280			}
4281			Operation::CreateSchema {
4282				name,
4283				if_not_exists,
4284			} => {
4285				// Use schema.rs helper (reinhardt-query doesn't support CREATE SCHEMA)
4286				let sql = if *if_not_exists {
4287					format!("CREATE SCHEMA IF NOT EXISTS {}", quote_identifier(name))
4288				} else {
4289					format!("CREATE SCHEMA {}", quote_identifier(name))
4290				};
4291				OperationStatement::RawSql(sql)
4292			}
4293			Operation::DropSchema {
4294				name,
4295				cascade,
4296				if_exists,
4297			} => {
4298				// Use schema.rs helper (reinhardt-query doesn't support DROP SCHEMA)
4299				let if_exists_clause = if *if_exists { " IF EXISTS" } else { "" };
4300				let cascade_clause = if *cascade { " CASCADE" } else { "" };
4301				let sql = format!(
4302					"DROP SCHEMA{} {}{}",
4303					if_exists_clause,
4304					quote_identifier(name),
4305					cascade_clause
4306				);
4307				OperationStatement::RawSql(sql)
4308			}
4309			Operation::CreateExtension {
4310				name,
4311				if_not_exists,
4312				schema,
4313			} => {
4314				// PostgreSQL-specific: Use extensions.rs helper
4315				let if_not_exists_clause = if *if_not_exists { " IF NOT EXISTS" } else { "" };
4316				let schema_clause = if let Some(s) = schema {
4317					format!(" SCHEMA {}", quote_identifier(s))
4318				} else {
4319					String::new()
4320				};
4321				let sql = format!(
4322					"CREATE EXTENSION{} {}{}",
4323					if_not_exists_clause,
4324					quote_identifier(name),
4325					schema_clause
4326				);
4327				OperationStatement::RawSql(sql)
4328			}
4329			Operation::BulkLoad {
4330				table,
4331				source,
4332				format,
4333				options,
4334			} => {
4335				// BulkLoad uses dialect-specific raw SQL
4336				// Default to PostgreSQL COPY FROM syntax for to_statement()
4337				OperationStatement::RawSql(Self::postgres_copy_from_sql(
4338					table, source, format, options,
4339				))
4340			}
4341			Operation::SetAutoIncrementValue { table, .. } => {
4342				// `to_statement` has no dialect context, but `SetAutoIncrementValue`
4343				// renders fundamentally different SQL per backend (PostgreSQL
4344				// `setval`, MySQL `ALTER TABLE AUTO_INCREMENT`, SQLite
4345				// `sqlite_sequence` upsert). Silently emitting PostgreSQL-only
4346				// SQL here would break MySQL/SQLite migrations.
4347				//
4348				// Emit guaranteed-fail SQL that aborts execution with a visible
4349				// diagnostic pointing callers at the dialect-aware `to_sql`
4350				// path. Converting the signature to
4351				// `Result<OperationStatement, MigrationError>` would cascade
4352				// through dozens of call sites.
4353				OperationStatement::RawSql(format!(
4354					"SELECT 1/0 AS \"SetAutoIncrementValue on {} requires dialect-aware rendering; call Operation::to_sql(&dialect) instead of to_statement()\";",
4355					table.replace('"', "\"\"")
4356				))
4357			}
4358			Operation::CreateCompositePrimaryKey {
4359				table,
4360				columns,
4361				constraint_name,
4362			} => OperationStatement::RawSql(Self::create_composite_pk_to_sql(
4363				table,
4364				columns,
4365				constraint_name.as_deref(),
4366			)),
4367		}
4368	}
4369
4370	/// Build CREATE TABLE statement
4371	fn build_create_table(
4372		&self,
4373		name: &str,
4374		columns: &[ColumnDefinition],
4375		constraints: &[Constraint],
4376	) -> CreateTableStatement {
4377		let mut stmt = Query::create_table();
4378		stmt.table(Alias::new(name)).if_not_exists();
4379
4380		for col in columns {
4381			let mut column = ColumnDef::new(Alias::new(col.name.as_str()));
4382			column = self.apply_column_type(column, &col.type_definition);
4383
4384			if col.not_null {
4385				column = column.not_null(true);
4386			}
4387			if col.unique {
4388				column = column.unique(true);
4389			}
4390			if col.primary_key {
4391				column = column.primary_key(true);
4392			}
4393			if col.auto_increment {
4394				column = column.auto_increment(true);
4395			}
4396			if let Some(default) = &col.default {
4397				column = column.default(SimpleExpr::from(self.convert_default_value(default)));
4398			}
4399
4400			stmt.col(column);
4401		}
4402
4403		// Add table-level constraints
4404		for constraint in constraints {
4405			match constraint {
4406				Constraint::PrimaryKey { columns, .. } => {
4407					let col_idens: Vec<Alias> =
4408						columns.iter().map(|c| Alias::new(c.as_str())).collect();
4409					stmt.primary_key(col_idens);
4410				}
4411				Constraint::ForeignKey {
4412					name,
4413					columns,
4414					referenced_table,
4415					referenced_columns,
4416					on_delete,
4417					on_update,
4418					..
4419				} => {
4420					let mut fk = reinhardt_query::prelude::ForeignKey::create();
4421					fk.name(Alias::new(name.as_str()))
4422						.from_tbl(Alias::new(name.as_str()))
4423						.to_tbl(Alias::new(referenced_table.as_str()));
4424
4425					for col in columns {
4426						fk.from_col(Alias::new(col.as_str()));
4427					}
4428					for col in referenced_columns {
4429						fk.to_col(Alias::new(col.as_str()));
4430					}
4431
4432					fk.on_delete((*on_delete).into());
4433					fk.on_update((*on_update).into());
4434
4435					stmt.foreign_key_from_builder(&mut fk);
4436				}
4437				Constraint::Unique { columns, .. } => {
4438					let col_idens: Vec<Alias> =
4439						columns.iter().map(|c| Alias::new(c.as_str())).collect();
4440					stmt.unique(col_idens);
4441				}
4442				Constraint::Check { name, expression } => {
4443					// Note: reinhardt-query doesn't have direct CHECK constraint support
4444					// This would need to be handled with raw SQL if needed
4445					let _ = (name, expression); // Suppress unused warnings
4446				}
4447				Constraint::OneToOne {
4448					name,
4449					column,
4450					referenced_table,
4451					referenced_column,
4452					on_delete,
4453					on_update,
4454					..
4455				} => {
4456					// OneToOne is ForeignKey + Unique
4457					let mut fk = reinhardt_query::prelude::ForeignKey::create();
4458					fk.name(Alias::new(name.as_str()))
4459						.from_tbl(Alias::new(name.as_str()))
4460						.to_tbl(Alias::new(referenced_table.as_str()))
4461						.from_col(Alias::new(column.as_str()))
4462						.to_col(Alias::new(referenced_column.as_str()))
4463						.on_delete((*on_delete).into())
4464						.on_update((*on_update).into());
4465
4466					stmt.foreign_key_from_builder(&mut fk);
4467
4468					// Add UNIQUE constraint separately if needed
4469					// Note: This should ideally be handled via UNIQUE column definition
4470				}
4471				Constraint::ManyToMany { .. } => {
4472					// ManyToMany is metadata only, no actual constraint in this table
4473					// The intermediate table handles the relationship
4474				}
4475				Constraint::Exclude { .. } => {
4476					// Exclude constraints are PostgreSQL-specific and not directly supported by reinhardt-query
4477					// They need to be handled with raw SQL if needed
4478				}
4479			}
4480		}
4481
4482		stmt.to_owned()
4483	}
4484
4485	/// Build DROP TABLE statement
4486	fn build_drop_table(&self, name: &str) -> DropTableStatement {
4487		Query::drop_table()
4488			.table(Alias::new(name))
4489			.if_exists()
4490			.cascade()
4491			.to_owned()
4492	}
4493
4494	/// Build ALTER TABLE ADD COLUMN statement
4495	fn build_add_column(&self, table: &str, column: &ColumnDefinition) -> AlterTableStatement {
4496		let mut stmt = Query::alter_table();
4497		stmt.table(Alias::new(table));
4498
4499		let mut col_def = ColumnDef::new(Alias::new(column.name.as_str()));
4500		col_def = self.apply_column_type(col_def, &column.type_definition);
4501
4502		if column.not_null {
4503			col_def = col_def.not_null(true);
4504		}
4505		if let Some(default) = &column.default {
4506			col_def = col_def.default(SimpleExpr::from(self.convert_default_value(default)));
4507		}
4508
4509		stmt.add_column(col_def);
4510		stmt.to_owned()
4511	}
4512
4513	/// Build ALTER TABLE DROP COLUMN statement
4514	fn build_drop_column(&self, table: &str, column: &str) -> AlterTableStatement {
4515		Query::alter_table()
4516			.table(Alias::new(table))
4517			.drop_column(Alias::new(column))
4518			.to_owned()
4519	}
4520
4521	/// Build ALTER TABLE ALTER COLUMN statement
4522	fn build_alter_column(
4523		&self,
4524		table: &str,
4525		column: &str,
4526		new_definition: &ColumnDefinition,
4527	) -> AlterTableStatement {
4528		let mut stmt = Query::alter_table();
4529		stmt.table(Alias::new(table));
4530
4531		let mut col_def = ColumnDef::new(Alias::new(column));
4532		col_def = self.apply_column_type(col_def, &new_definition.type_definition);
4533
4534		if new_definition.not_null {
4535			col_def = col_def.not_null(true);
4536		}
4537
4538		stmt.modify_column(col_def);
4539		stmt.to_owned()
4540	}
4541
4542	/// Build ALTER TABLE RENAME statement
4543	fn build_rename_table(&self, old_name: &str, new_name: &str) -> AlterTableStatement {
4544		Query::alter_table()
4545			.table(Alias::new(old_name))
4546			.rename_table(Alias::new(new_name))
4547			.to_owned()
4548	}
4549
4550	/// Build CREATE INDEX statement
4551	fn build_create_index(
4552		&self,
4553		name: &str,
4554		table: &str,
4555		columns: &[String],
4556		unique: bool,
4557	) -> CreateIndexStatement {
4558		let mut stmt = Query::create_index();
4559		stmt.name(Alias::new(name)).table(Alias::new(table));
4560
4561		for col in columns {
4562			stmt.col(Alias::new(col));
4563		}
4564
4565		if unique {
4566			stmt.unique();
4567		}
4568
4569		stmt.to_owned()
4570	}
4571
4572	/// Build DROP INDEX statement
4573	fn build_drop_index(&self, name: &str) -> DropIndexStatement {
4574		Query::drop_index().name(Alias::new(name)).to_owned()
4575	}
4576
4577	/// Apply column type to ColumnDef using `reinhardt_query`'s fluent API
4578	fn apply_column_type(&self, col_def: ColumnDef, field_type: &FieldType) -> ColumnDef {
4579		use FieldType;
4580		match field_type {
4581			FieldType::Integer => col_def.integer(),
4582			FieldType::BigInteger => col_def.big_integer(),
4583			FieldType::SmallInteger => col_def.small_integer(),
4584			FieldType::TinyInt => col_def.tiny_integer(),
4585			FieldType::VarChar(max_length) => col_def.string_len(*max_length),
4586			FieldType::Char(max_length) => col_def.char_len(*max_length),
4587			FieldType::Text | FieldType::TinyText | FieldType::MediumText | FieldType::LongText => {
4588				col_def.text()
4589			}
4590			// Use custom "BOOLEAN" type name instead of col_def.boolean() to ensure
4591			// consistent type naming across all databases. This is important for SQLite
4592			// where col_def.boolean() would generate "INTEGER", but we need "BOOLEAN"
4593			// so that sqlx's type_info().name() returns "BOOLEAN" and our convert_row
4594			// can properly detect boolean columns and convert integer 0/1 to bool values.
4595			FieldType::Boolean => col_def.custom(Alias::new("BOOLEAN")),
4596			FieldType::DateTime => col_def.timestamp(),
4597			FieldType::TimestampTz => col_def.timestamp_with_time_zone(),
4598			FieldType::Date => col_def.date(),
4599			FieldType::Time => col_def.time(),
4600			FieldType::Decimal { precision, scale } => col_def.decimal(*precision, *scale),
4601			FieldType::Float => col_def.float(),
4602			FieldType::Double | FieldType::Real => col_def.double(),
4603			FieldType::Json => col_def.json(),
4604			FieldType::JsonBinary => col_def.json_binary(),
4605			FieldType::Uuid => col_def.uuid(),
4606			FieldType::Binary | FieldType::Bytea => col_def.binary(0),
4607			FieldType::Blob | FieldType::TinyBlob | FieldType::MediumBlob | FieldType::LongBlob => {
4608				col_def.binary(0)
4609			}
4610			FieldType::MediumInt => col_def.integer(),
4611			FieldType::Year => col_def.small_integer(),
4612			FieldType::Enum { values } => {
4613				col_def.custom(Alias::new(format!("ENUM({})", values.join(","))))
4614			}
4615			FieldType::Set { values } => {
4616				col_def.custom(Alias::new(format!("SET({})", values.join(","))))
4617			}
4618			FieldType::ForeignKey { .. } => {
4619				// ForeignKey is a relationship, the actual column is typically an integer
4620				col_def.integer()
4621			}
4622			FieldType::OneToOne { .. } => {
4623				// OneToOne is a relationship, not a column type
4624				// The actual column will be a foreign key (typically BigInteger)
4625				col_def.big_integer()
4626			}
4627			FieldType::ManyToMany { .. } => {
4628				// ManyToMany is a relationship, not a column type
4629				// No column is created in the model table (uses intermediate table)
4630				col_def.big_integer()
4631			}
4632			// PostgreSQL-specific types
4633			FieldType::Array(inner) => {
4634				// PostgreSQL array type: use custom with array notation
4635				let inner_sql = inner.to_sql_string();
4636				col_def.custom(Alias::new(format!("{}[]", inner_sql)))
4637			}
4638			FieldType::HStore => col_def.custom(Alias::new("HSTORE")),
4639			FieldType::CIText => col_def.custom(Alias::new("CITEXT")),
4640			FieldType::Int4Range => col_def.custom(Alias::new("INT4RANGE")),
4641			FieldType::Int8Range => col_def.custom(Alias::new("INT8RANGE")),
4642			FieldType::NumRange => col_def.custom(Alias::new("NUMRANGE")),
4643			FieldType::DateRange => col_def.custom(Alias::new("DATERANGE")),
4644			FieldType::TsRange => col_def.custom(Alias::new("TSRANGE")),
4645			FieldType::TsTzRange => col_def.custom(Alias::new("TSTZRANGE")),
4646			FieldType::TsVector => col_def.custom(Alias::new("TSVECTOR")),
4647			FieldType::TsQuery => col_def.custom(Alias::new("TSQUERY")),
4648			FieldType::Custom(custom_type) => col_def.custom(Alias::new(custom_type)),
4649		}
4650	}
4651
4652	/// Convert default value string to `reinhardt_query::prelude::Value`
4653	fn convert_default_value(&self, default: &str) -> Value {
4654		let trimmed = default.trim();
4655
4656		// NULL
4657		if trimmed.eq_ignore_ascii_case("null") {
4658			return Value::String(None);
4659		}
4660
4661		// Boolean
4662		if trimmed.eq_ignore_ascii_case("true") {
4663			return Value::Bool(Some(true));
4664		}
4665		if trimmed.eq_ignore_ascii_case("false") {
4666			return Value::Bool(Some(false));
4667		}
4668
4669		// Integer
4670		if let Ok(i) = trimmed.parse::<i64>() {
4671			return Value::BigInt(Some(i));
4672		}
4673
4674		// Float
4675		if let Ok(f) = trimmed.parse::<f64>() {
4676			return Value::Double(Some(f));
4677		}
4678
4679		// String (quoted)
4680		if (trimmed.starts_with('"') && trimmed.ends_with('"'))
4681			|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
4682		{
4683			let unquoted = &trimmed[1..trimmed.len() - 1];
4684			return Value::String(Some(Box::new(unquoted.to_string())));
4685		}
4686
4687		// JSON array/object
4688		if ((trimmed.starts_with('[') && trimmed.ends_with(']'))
4689			|| (trimmed.starts_with('{') && trimmed.ends_with('}')))
4690			&& let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
4691		{
4692			return json_to_sea_value(&json);
4693		}
4694
4695		// SQL constants that should remain unquoted
4696		const SQL_CONSTANTS: &[&str] = &[
4697			"CURRENT_TIMESTAMP",
4698			"CURRENT_DATE",
4699			"CURRENT_TIME",
4700			"CURRENT_USER",
4701			"SESSION_USER",
4702			"LOCALTIME",
4703			"LOCALTIMESTAMP",
4704		];
4705
4706		// SQL function calls (e.g., NOW(), CURRENT_TIMESTAMP()) - keep unquoted
4707		if trimmed.ends_with("()") || trimmed.contains('(') {
4708			return Value::String(Some(Box::new(trimmed.to_string())));
4709		}
4710
4711		// SQL constants - keep unquoted
4712		if SQL_CONSTANTS
4713			.iter()
4714			.any(|c| trimmed.eq_ignore_ascii_case(c))
4715		{
4716			return Value::String(Some(Box::new(trimmed.to_string())));
4717		}
4718
4719		// Default: plain string - auto-quote as SQL string literal
4720		Value::String(Some(Box::new(format!("'{}'", trimmed.replace('\'', "''")))))
4721	}
4722}
4723
4724/// Helper function to convert `serde_json::Value` to `reinhardt_query::prelude::Value`
4725fn json_to_sea_value(json: &serde_json::Value) -> Value {
4726	match json {
4727		serde_json::Value::Null => Value::String(None),
4728		serde_json::Value::Bool(b) => Value::Bool(Some(*b)),
4729		serde_json::Value::Number(n) => {
4730			if let Some(i) = n.as_i64() {
4731				Value::BigInt(Some(i))
4732			} else if let Some(f) = n.as_f64() {
4733				Value::Double(Some(f))
4734			} else {
4735				Value::String(Some(Box::new(n.to_string())))
4736			}
4737		}
4738		serde_json::Value::String(s) => Value::String(Some(Box::new(s.clone()))),
4739		serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
4740			// Store as JSON string
4741			Value::String(Some(Box::new(json.to_string())))
4742		}
4743	}
4744}
4745
4746// MigrationOperation trait implementation for legacy Operation enum
4747use super::operation_trait::MigrationOperation;
4748
4749impl MigrationOperation for Operation {
4750	fn migration_name_fragment(&self) -> Option<String> {
4751		match self {
4752			Operation::CreateTable { name, .. } => Some(name.to_lowercase()),
4753			Operation::DropTable { name } => Some(format!("delete_{}", name.to_lowercase())),
4754			Operation::AddColumn { table, column, .. } => Some(format!(
4755				"{}_{}",
4756				table.to_lowercase(),
4757				column.name.to_lowercase()
4758			)),
4759			Operation::DropColumn { table, column } => Some(format!(
4760				"remove_{}_{}",
4761				table.to_lowercase(),
4762				column.to_lowercase()
4763			)),
4764			Operation::AlterColumn { table, column, .. } => Some(format!(
4765				"alter_{}_{}",
4766				table.to_lowercase(),
4767				column.to_lowercase()
4768			)),
4769			Operation::RenameTable { old_name, new_name } => Some(format!(
4770				"rename_{}_to_{}",
4771				old_name.to_lowercase(),
4772				new_name.to_lowercase()
4773			)),
4774			Operation::RenameColumn {
4775				table, new_name, ..
4776			} => Some(format!(
4777				"rename_{}_{}",
4778				table.to_lowercase(),
4779				new_name.to_lowercase()
4780			)),
4781			Operation::AddConstraint { table, .. } => {
4782				Some(format!("add_constraint_{}", table.to_lowercase()))
4783			}
4784			Operation::DropConstraint {
4785				table: _,
4786				constraint_name,
4787			} => Some(format!(
4788				"drop_constraint_{}",
4789				constraint_name.to_lowercase()
4790			)),
4791			Operation::CreateIndex { table, unique, .. } => {
4792				if *unique {
4793					Some(format!("create_unique_index_{}", table.to_lowercase()))
4794				} else {
4795					Some(format!("create_index_{}", table.to_lowercase()))
4796				}
4797			}
4798			Operation::CreateIndexRepair { table, unique, .. } => {
4799				if *unique {
4800					Some(format!("create_unique_index_{}", table.to_lowercase()))
4801				} else {
4802					Some(format!("create_index_{}", table.to_lowercase()))
4803				}
4804			}
4805			Operation::DropIndex { table, .. } => {
4806				Some(format!("drop_index_{}", table.to_lowercase()))
4807			}
4808			Operation::DropNamedIndex { table, .. } => {
4809				Some(format!("drop_index_{}", table.to_lowercase()))
4810			}
4811			Operation::RunSQL { .. } => None,  // Triggers auto-naming
4812			Operation::RunRust { .. } => None, // Triggers auto-naming
4813			Operation::AlterTableComment { table, .. } => {
4814				Some(format!("alter_comment_{}", table.to_lowercase()))
4815			}
4816			Operation::AlterUniqueTogether { table, .. } => {
4817				Some(format!("alter_unique_{}", table.to_lowercase()))
4818			}
4819			Operation::AlterModelOptions { table, .. } => {
4820				Some(format!("alter_options_{}", table.to_lowercase()))
4821			}
4822			Operation::CreateInheritedTable { name, .. } => {
4823				Some(format!("create_inherited_{}", name.to_lowercase()))
4824			}
4825			Operation::AddDiscriminatorColumn { table, .. } => {
4826				Some(format!("add_discriminator_{}", table.to_lowercase()))
4827			}
4828			Operation::MoveModel {
4829				model_name,
4830				from_app,
4831				to_app,
4832				..
4833			} => Some(format!(
4834				"move_{}_{}_{}_{}",
4835				from_app.to_lowercase(),
4836				model_name.to_lowercase(),
4837				to_app.to_lowercase(),
4838				model_name.to_lowercase()
4839			)),
4840			Operation::CreateSchema { name, .. } => {
4841				Some(format!("create_schema_{}", name.to_lowercase()))
4842			}
4843			Operation::DropSchema { name, .. } => {
4844				Some(format!("drop_schema_{}", name.to_lowercase()))
4845			}
4846			Operation::CreateExtension { name, .. } => {
4847				Some(format!("create_extension_{}", name.to_lowercase()))
4848			}
4849			Operation::BulkLoad { table, .. } => {
4850				Some(format!("bulk_load_{}", table.to_lowercase()))
4851			}
4852			Operation::SetAutoIncrementValue { table, column, .. } => Some(format!(
4853				"set_auto_increment_{}_{}",
4854				table.to_lowercase(),
4855				column.to_lowercase()
4856			)),
4857			Operation::CreateCompositePrimaryKey { table, .. } => {
4858				Some(format!("composite_pk_{}", table.to_lowercase()))
4859			}
4860		}
4861	}
4862
4863	fn describe(&self) -> String {
4864		match self {
4865			Operation::CreateTable { name, .. } => format!("Create table {}", name),
4866			Operation::DropTable { name } => format!("Drop table {}", name),
4867			Operation::AddColumn { table, column, .. } => {
4868				format!("Add column {} to {}", column.name, table)
4869			}
4870			Operation::DropColumn { table, column } => {
4871				format!("Drop column {} from {}", column, table)
4872			}
4873			Operation::AlterColumn { table, column, .. } => {
4874				format!("Alter column {} on {}", column, table)
4875			}
4876			Operation::RenameTable { old_name, new_name } => {
4877				format!("Rename table {} to {}", old_name, new_name)
4878			}
4879			Operation::RenameColumn {
4880				table,
4881				old_name,
4882				new_name,
4883			} => format!("Rename column {} to {} on {}", old_name, new_name, table),
4884			Operation::AddConstraint { table, .. } => format!("Add constraint on {}", table),
4885			Operation::DropConstraint {
4886				table,
4887				constraint_name,
4888			} => format!("Drop constraint {} from {}", constraint_name, table),
4889			Operation::CreateIndex { table, unique, .. } => {
4890				if *unique {
4891					format!("Create unique index on {}", table)
4892				} else {
4893					format!("Create index on {}", table)
4894				}
4895			}
4896			Operation::CreateIndexRepair { table, unique, .. } => {
4897				if *unique {
4898					format!("Create unique index on {}", table)
4899				} else {
4900					format!("Create index on {}", table)
4901				}
4902			}
4903			Operation::DropIndex { table, .. } => format!("Drop index on {}", table),
4904			Operation::DropNamedIndex { table, .. } => format!("Drop index on {}", table),
4905			Operation::RunSQL { sql, .. } => {
4906				let preview = if sql.len() > 50 {
4907					format!("{}...", &sql[..50])
4908				} else {
4909					(*sql).to_string()
4910				};
4911				format!("RunSQL: {}", preview)
4912			}
4913			Operation::RunRust { code, .. } => {
4914				let preview = if code.len() > 50 {
4915					format!("{}...", &code[..50])
4916				} else {
4917					(*code).to_string()
4918				};
4919				format!("RunRust: {}", preview)
4920			}
4921			Operation::AlterTableComment { table, comment } => match comment {
4922				Some(c) => format!("Set comment on {} to '{}'", table, c),
4923				None => format!("Remove comment from {}", table),
4924			},
4925			Operation::AlterUniqueTogether { table, .. } => {
4926				format!("Alter unique_together on {}", table)
4927			}
4928			Operation::AlterModelOptions { table, .. } => {
4929				format!("Alter model options on {}", table)
4930			}
4931			Operation::CreateInheritedTable {
4932				name, base_table, ..
4933			} => {
4934				format!("Create inherited table {} from {}", name, base_table)
4935			}
4936			Operation::AddDiscriminatorColumn {
4937				table, column_name, ..
4938			} => format!("Add discriminator column {} to {}", column_name, table),
4939			Operation::MoveModel {
4940				model_name,
4941				from_app,
4942				to_app,
4943				..
4944			} => format!("Move model {} from {} to {}", model_name, from_app, to_app),
4945			Operation::CreateSchema { name, .. } => format!("Create schema {}", name),
4946			Operation::DropSchema { name, .. } => format!("Drop schema {}", name),
4947			Operation::CreateExtension { name, .. } => format!("Create extension {}", name),
4948			Operation::BulkLoad { table, source, .. } => {
4949				let source_desc = match source {
4950					BulkLoadSource::File(path) => format!("file '{}'", path),
4951					BulkLoadSource::Stdin => "STDIN".to_string(),
4952					BulkLoadSource::Program(cmd) => format!("program '{}'", cmd),
4953				};
4954				format!("Bulk load data into {} from {}", table, source_desc)
4955			}
4956			Operation::SetAutoIncrementValue {
4957				table,
4958				column,
4959				value,
4960			} => format!("Set auto-increment of {}.{} to {}", table, column, value),
4961			Operation::CreateCompositePrimaryKey { table, columns, .. } => format!(
4962				"Create composite primary key on {} ({})",
4963				table,
4964				columns.join(", ")
4965			),
4966		}
4967	}
4968
4969	/// Normalize operation for semantic comparison
4970	///
4971	/// Returns a normalized version where order-independent elements are sorted.
4972	/// This enables detection of semantically equivalent operations regardless of element ordering.
4973	fn normalize(&self) -> Self
4974	where
4975		Self: Sized + Clone,
4976	{
4977		match self {
4978			// CreateTable: Sort columns and constraints
4979			Operation::CreateTable {
4980				name,
4981				columns,
4982				constraints,
4983				without_rowid,
4984				interleave_in_parent,
4985				partition,
4986			} => {
4987				let mut sorted_columns = columns.clone();
4988				sorted_columns.sort_by(|a, b| a.name.cmp(&b.name));
4989
4990				let mut sorted_constraints = constraints.clone();
4991				sorted_constraints.sort();
4992
4993				Operation::CreateTable {
4994					name: name.clone(),
4995					columns: sorted_columns,
4996					constraints: sorted_constraints,
4997					without_rowid: *without_rowid,
4998					interleave_in_parent: interleave_in_parent.clone(),
4999					partition: partition.clone(),
5000				}
5001			}
5002			// CreateIndex: Sort columns
5003			Operation::CreateIndex {
5004				table,
5005				columns,
5006				unique,
5007				index_type,
5008				where_clause,
5009				concurrently,
5010				expressions,
5011				mysql_options,
5012				operator_class,
5013			} => {
5014				let mut sorted_columns = columns.clone();
5015				sorted_columns.sort();
5016
5017				Operation::CreateIndex {
5018					table: table.clone(),
5019					columns: sorted_columns,
5020					unique: *unique,
5021					index_type: *index_type,
5022					where_clause: where_clause.clone(),
5023					concurrently: *concurrently,
5024					expressions: expressions.clone(),
5025					mysql_options: *mysql_options,
5026					operator_class: operator_class.clone(),
5027				}
5028			}
5029			Operation::CreateIndexRepair {
5030				table,
5031				name,
5032				columns,
5033				unique,
5034				index_type,
5035				where_clause,
5036				concurrently,
5037				expressions,
5038				mysql_options,
5039				operator_class,
5040			} => {
5041				let mut sorted_columns = columns.clone();
5042				sorted_columns.sort();
5043
5044				Operation::CreateIndexRepair {
5045					table: table.clone(),
5046					name: name.clone(),
5047					columns: sorted_columns,
5048					unique: *unique,
5049					index_type: *index_type,
5050					where_clause: where_clause.clone(),
5051					concurrently: *concurrently,
5052					expressions: expressions.clone(),
5053					mysql_options: *mysql_options,
5054					operator_class: operator_class.clone(),
5055				}
5056			}
5057			// DropIndex: Sort columns
5058			Operation::DropIndex { table, columns } => {
5059				let mut sorted_columns = columns.clone();
5060				sorted_columns.sort();
5061
5062				Operation::DropIndex {
5063					table: table.clone(),
5064					columns: sorted_columns,
5065				}
5066			}
5067			Operation::DropNamedIndex {
5068				table,
5069				name,
5070				columns,
5071				unique,
5072				index_type,
5073				where_clause,
5074				concurrently,
5075				expressions,
5076				mysql_options,
5077				operator_class,
5078			} => {
5079				let mut sorted_columns = columns.clone();
5080				sorted_columns.sort();
5081				Operation::DropNamedIndex {
5082					table: table.clone(),
5083					name: name.clone(),
5084					columns: sorted_columns,
5085					unique: *unique,
5086					index_type: *index_type,
5087					where_clause: where_clause.clone(),
5088					concurrently: *concurrently,
5089					expressions: expressions.clone(),
5090					mysql_options: *mysql_options,
5091					operator_class: operator_class.clone(),
5092				}
5093			}
5094			// AlterUniqueTogether: Sort field lists and sort within each list
5095			Operation::AlterUniqueTogether {
5096				table,
5097				unique_together,
5098			} => {
5099				let mut sorted_unique_together: Vec<Vec<String>> = unique_together
5100					.iter()
5101					.map(|field_list| {
5102						let mut sorted = field_list.clone();
5103						sorted.sort();
5104						sorted
5105					})
5106					.collect();
5107				sorted_unique_together.sort();
5108
5109				Operation::AlterUniqueTogether {
5110					table: table.clone(),
5111					unique_together: sorted_unique_together,
5112				}
5113			}
5114			// AlterModelOptions: HashMap cannot be sorted, but we can normalize by converting to sorted Vec
5115			// However, since HashMap doesn't guarantee order and the operation uses HashMap,
5116			// we'll just clone it as-is. For true semantic equality, this would need to be changed
5117			// to a BTreeMap at the type level.
5118			Operation::AlterModelOptions { table, options } => Operation::AlterModelOptions {
5119				table: table.clone(),
5120				options: options.clone(),
5121			},
5122			// All other operations: Return clone (order doesn't matter or not applicable)
5123			_ => self.clone(),
5124		}
5125	}
5126}
5127
5128#[cfg(test)]
5129mod tests {
5130	use super::*;
5131	use FieldType;
5132	use rstest::rstest;
5133
5134	#[test]
5135	fn test_create_table_to_statement() {
5136		let op = Operation::CreateTable {
5137			name: "users".to_string(),
5138			columns: vec![
5139				ColumnDefinition {
5140					name: "id".to_string(),
5141					type_definition: FieldType::Integer,
5142					not_null: false,
5143					unique: false,
5144					primary_key: true,
5145					auto_increment: true,
5146					default: None,
5147				},
5148				ColumnDefinition {
5149					name: "name".to_string(),
5150					type_definition: FieldType::VarChar(100),
5151					not_null: true,
5152					unique: false,
5153					primary_key: false,
5154					auto_increment: false,
5155					default: None,
5156				},
5157			],
5158			constraints: vec![],
5159			without_rowid: None,
5160			partition: None,
5161			interleave_in_parent: None,
5162		};
5163
5164		let stmt = op.to_statement();
5165		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5166		assert!(
5167			sql.contains("CREATE TABLE"),
5168			"SQL should contain CREATE TABLE keyword, got: {}",
5169			sql
5170		);
5171		assert!(
5172			sql.contains("users"),
5173			"SQL should reference 'users' table, got: {}",
5174			sql
5175		);
5176		assert!(
5177			sql.contains("id") && sql.contains("name"),
5178			"SQL should contain both 'id' and 'name' columns, got: {}",
5179			sql
5180		);
5181	}
5182
5183	#[test]
5184	fn test_drop_table_to_statement() {
5185		let op = Operation::DropTable {
5186			name: "users".to_string(),
5187		};
5188
5189		let stmt = op.to_statement();
5190		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5191		assert!(
5192			sql.contains("DROP TABLE"),
5193			"SQL should contain DROP TABLE keyword, got: {}",
5194			sql
5195		);
5196		assert!(
5197			sql.contains("users"),
5198			"SQL should reference 'users' table, got: {}",
5199			sql
5200		);
5201		assert!(
5202			sql.contains("CASCADE"),
5203			"SQL should include CASCADE option, got: {}",
5204			sql
5205		);
5206	}
5207
5208	#[test]
5209	fn test_add_column_to_statement() {
5210		let op = Operation::AddColumn {
5211			table: "users".to_string(),
5212			column: ColumnDefinition {
5213				name: "email".to_string(),
5214				type_definition: FieldType::VarChar(255),
5215				not_null: true,
5216				unique: false,
5217				primary_key: false,
5218				auto_increment: false,
5219				default: Some("''".to_string()),
5220			},
5221			mysql_options: None,
5222		};
5223
5224		let stmt = op.to_statement();
5225		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5226		assert!(
5227			sql.contains("ALTER TABLE"),
5228			"SQL should contain ALTER TABLE keyword, got: {}",
5229			sql
5230		);
5231		assert!(
5232			sql.contains("users"),
5233			"SQL should reference 'users' table, got: {}",
5234			sql
5235		);
5236		assert!(
5237			sql.contains("ADD COLUMN"),
5238			"SQL should contain ADD COLUMN clause, got: {}",
5239			sql
5240		);
5241		assert!(
5242			sql.contains("email"),
5243			"SQL should reference 'email' column, got: {}",
5244			sql
5245		);
5246	}
5247
5248	#[test]
5249	fn test_drop_column_to_statement() {
5250		let op = Operation::DropColumn {
5251			table: "users".to_string(),
5252			column: "email".to_string(),
5253		};
5254
5255		let stmt = op.to_statement();
5256		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5257		assert!(
5258			sql.contains("ALTER TABLE"),
5259			"SQL should contain ALTER TABLE keyword, got: {}",
5260			sql
5261		);
5262		assert!(
5263			sql.contains("users"),
5264			"SQL should reference 'users' table, got: {}",
5265			sql
5266		);
5267		assert!(
5268			sql.contains("DROP COLUMN"),
5269			"SQL should contain DROP COLUMN clause, got: {}",
5270			sql
5271		);
5272		assert!(
5273			sql.contains("email"),
5274			"SQL should reference 'email' column, got: {}",
5275			sql
5276		);
5277	}
5278
5279	#[test]
5280	fn test_alter_column_to_statement() {
5281		let op = Operation::AlterColumn {
5282			table: "users".to_string(),
5283			column: "age".to_string(),
5284			old_definition: None,
5285			new_definition: ColumnDefinition {
5286				name: "age".to_string(),
5287				type_definition: FieldType::BigInteger,
5288				not_null: true,
5289				unique: false,
5290				primary_key: false,
5291				auto_increment: false,
5292				default: None,
5293			},
5294			mysql_options: None,
5295		};
5296
5297		let stmt = op.to_statement();
5298		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5299		assert!(
5300			sql.contains("ALTER TABLE"),
5301			"SQL should contain ALTER TABLE keyword, got: {}",
5302			sql
5303		);
5304		assert!(
5305			sql.contains("users"),
5306			"SQL should reference 'users' table, got: {}",
5307			sql
5308		);
5309		assert!(
5310			sql.contains("age"),
5311			"SQL should reference 'age' column, got: {}",
5312			sql
5313		);
5314	}
5315
5316	#[test]
5317	fn test_rename_table_to_statement() {
5318		let op = Operation::RenameTable {
5319			old_name: "users".to_string(),
5320			new_name: "accounts".to_string(),
5321		};
5322
5323		let stmt = op.to_statement();
5324		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5325		assert!(
5326			sql.contains("users"),
5327			"SQL should reference old table name 'users', got: {}",
5328			sql
5329		);
5330		assert!(
5331			sql.contains("accounts"),
5332			"SQL should reference new table name 'accounts', got: {}",
5333			sql
5334		);
5335	}
5336
5337	#[test]
5338	fn test_rename_column_to_statement() {
5339		let op = Operation::RenameColumn {
5340			table: "users".to_string(),
5341			old_name: "name".to_string(),
5342			new_name: "full_name".to_string(),
5343		};
5344
5345		let stmt = op.to_statement();
5346		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5347		assert!(
5348			sql.contains("ALTER TABLE"),
5349			"SQL should contain ALTER TABLE keyword, got: {}",
5350			sql
5351		);
5352		assert!(
5353			sql.contains("users"),
5354			"SQL should reference 'users' table, got: {}",
5355			sql
5356		);
5357		assert!(
5358			sql.contains("RENAME COLUMN"),
5359			"SQL should contain RENAME COLUMN clause, got: {}",
5360			sql
5361		);
5362		assert!(
5363			sql.contains("name"),
5364			"SQL should reference old column name 'name', got: {}",
5365			sql
5366		);
5367		assert!(
5368			sql.contains("full_name"),
5369			"SQL should reference new column name 'full_name', got: {}",
5370			sql
5371		);
5372	}
5373
5374	#[test]
5375	fn test_add_constraint_to_statement() {
5376		let op = Operation::AddConstraint {
5377			table: "users".to_string(),
5378			constraint_sql: "CONSTRAINT age_check CHECK (age >= 0)".to_string(),
5379		};
5380
5381		let stmt = op.to_statement();
5382		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5383		assert!(
5384			sql.contains("ALTER TABLE"),
5385			"SQL should contain ALTER TABLE keyword, got: {}",
5386			sql
5387		);
5388		assert!(
5389			sql.contains("users"),
5390			"SQL should reference 'users' table, got: {}",
5391			sql
5392		);
5393		assert!(
5394			sql.contains("ADD"),
5395			"SQL should contain ADD keyword, got: {}",
5396			sql
5397		);
5398		assert!(
5399			sql.contains("age_check"),
5400			"SQL should contain constraint name 'age_check', got: {}",
5401			sql
5402		);
5403	}
5404
5405	#[test]
5406	fn test_add_unique_constraint_to_sql_uses_mysql_identifier_quotes() {
5407		// Arrange
5408		let op = Operation::AddConstraint {
5409			table: "users".to_string(),
5410			constraint_sql: "CONSTRAINT users_group_uniq UNIQUE (\"group\")".to_string(),
5411		};
5412
5413		// Act
5414		let mysql_sql = op.to_sql(&SqlDialect::Mysql);
5415		let postgres_sql = op.to_sql(&SqlDialect::Postgres);
5416
5417		// Assert
5418		assert_eq!(
5419			mysql_sql,
5420			"ALTER TABLE users ADD CONSTRAINT users_group_uniq UNIQUE (`group`);"
5421		);
5422		assert_eq!(
5423			postgres_sql,
5424			"ALTER TABLE users ADD CONSTRAINT users_group_uniq UNIQUE (\"group\");"
5425		);
5426	}
5427
5428	#[test]
5429	fn test_drop_constraint_to_statement() {
5430		let op = Operation::DropConstraint {
5431			table: "users".to_string(),
5432			constraint_name: "age_check".to_string(),
5433		};
5434
5435		let stmt = op.to_statement();
5436		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5437		assert!(
5438			sql.contains("ALTER TABLE"),
5439			"SQL should contain ALTER TABLE keyword, got: {}",
5440			sql
5441		);
5442		assert!(
5443			sql.contains("users"),
5444			"SQL should reference 'users' table, got: {}",
5445			sql
5446		);
5447		assert!(
5448			sql.contains("DROP CONSTRAINT"),
5449			"SQL should contain DROP CONSTRAINT clause, got: {}",
5450			sql
5451		);
5452		assert!(
5453			sql.contains("age_check"),
5454			"SQL should reference constraint 'age_check', got: {}",
5455			sql
5456		);
5457	}
5458
5459	#[test]
5460	fn test_create_index_to_statement() {
5461		let op = Operation::CreateIndex {
5462			table: "users".to_string(),
5463			columns: vec!["email".to_string()],
5464			unique: false,
5465			index_type: None,
5466			where_clause: None,
5467			concurrently: false,
5468			expressions: None,
5469			mysql_options: None,
5470			operator_class: None,
5471		};
5472
5473		let stmt = op.to_statement();
5474		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5475		assert!(
5476			sql.contains("CREATE INDEX"),
5477			"SQL should contain CREATE INDEX keywords, got: {}",
5478			sql
5479		);
5480		assert!(
5481			sql.contains("users"),
5482			"SQL should reference 'users' table, got: {}",
5483			sql
5484		);
5485		assert!(
5486			sql.contains("email"),
5487			"SQL should reference 'email' column, got: {}",
5488			sql
5489		);
5490	}
5491
5492	#[test]
5493	fn test_create_unique_index_to_statement() {
5494		let op = Operation::CreateIndex {
5495			table: "users".to_string(),
5496			columns: vec!["email".to_string()],
5497			unique: true,
5498			index_type: None,
5499			where_clause: None,
5500			concurrently: false,
5501			expressions: None,
5502			mysql_options: None,
5503			operator_class: None,
5504		};
5505
5506		let stmt = op.to_statement();
5507		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5508		assert!(
5509			sql.contains("CREATE UNIQUE INDEX"),
5510			"SQL should contain CREATE UNIQUE INDEX keywords, got: {}",
5511			sql
5512		);
5513		assert!(
5514			sql.contains("users"),
5515			"SQL should reference 'users' table, got: {}",
5516			sql
5517		);
5518		assert!(
5519			sql.contains("email"),
5520			"SQL should reference 'email' column, got: {}",
5521			sql
5522		);
5523	}
5524
5525	#[test]
5526	fn test_drop_index_to_statement() {
5527		let op = Operation::DropIndex {
5528			table: "users".to_string(),
5529			columns: vec!["email".to_string()],
5530		};
5531
5532		let stmt = op.to_statement();
5533		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5534		assert!(
5535			sql.contains("DROP INDEX"),
5536			"SQL should contain DROP INDEX keywords, got: {}",
5537			sql
5538		);
5539		assert!(
5540			sql.contains("idx_users_email"),
5541			"SQL should contain generated index name 'idx_users_email', got: {}",
5542			sql
5543		);
5544	}
5545
5546	#[test]
5547	fn test_run_sql_to_statement() {
5548		let op = Operation::RunSQL {
5549			sql: "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"".to_string(),
5550			reverse_sql: Some("DROP EXTENSION \"uuid-ossp\"".to_string()),
5551		};
5552
5553		let stmt = op.to_statement();
5554		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5555		assert!(
5556			sql.contains("CREATE EXTENSION"),
5557			"SQL should contain CREATE EXTENSION keywords, got: {}",
5558			sql
5559		);
5560		assert!(
5561			sql.contains("uuid-ossp"),
5562			"SQL should reference 'uuid-ossp' extension, got: {}",
5563			sql
5564		);
5565	}
5566
5567	#[test]
5568	fn test_alter_table_comment_to_statement() {
5569		let op = Operation::AlterTableComment {
5570			table: "users".to_string(),
5571			comment: Some("User accounts table".to_string()),
5572		};
5573
5574		let stmt = op.to_statement();
5575		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5576		assert!(
5577			sql.contains("COMMENT ON TABLE"),
5578			"SQL should contain COMMENT ON TABLE keywords, got: {}",
5579			sql
5580		);
5581		assert!(
5582			sql.contains("users"),
5583			"SQL should reference 'users' table, got: {}",
5584			sql
5585		);
5586		assert!(
5587			sql.contains("User accounts table"),
5588			"SQL should include comment text 'User accounts table', got: {}",
5589			sql
5590		);
5591	}
5592
5593	#[test]
5594	fn test_alter_table_comment_null_to_statement() {
5595		let op = Operation::AlterTableComment {
5596			table: "users".to_string(),
5597			comment: None,
5598		};
5599
5600		let stmt = op.to_statement();
5601		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5602		assert!(
5603			sql.contains("COMMENT ON TABLE"),
5604			"SQL should contain COMMENT ON TABLE keywords, got: {}",
5605			sql
5606		);
5607		assert!(
5608			sql.contains("users"),
5609			"SQL should reference 'users' table, got: {}",
5610			sql
5611		);
5612		assert!(
5613			sql.contains("NULL"),
5614			"SQL should include NULL for null comment, got: {}",
5615			sql
5616		);
5617	}
5618
5619	#[test]
5620	fn test_alter_unique_together_to_statement() {
5621		let op = Operation::AlterUniqueTogether {
5622			table: "users".to_string(),
5623			unique_together: vec![vec!["email".to_string(), "username".to_string()]],
5624		};
5625
5626		let stmt = op.to_statement();
5627		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5628		assert!(
5629			sql.contains("ALTER TABLE"),
5630			"SQL should contain ALTER TABLE keyword, got: {}",
5631			sql
5632		);
5633		assert!(
5634			sql.contains("users"),
5635			"SQL should reference 'users' table, got: {}",
5636			sql
5637		);
5638		assert!(
5639			sql.contains("ADD CONSTRAINT"),
5640			"SQL should contain ADD CONSTRAINT clause, got: {}",
5641			sql
5642		);
5643		assert!(
5644			sql.contains("UNIQUE"),
5645			"SQL should contain UNIQUE keyword, got: {}",
5646			sql
5647		);
5648		assert!(
5649			sql.contains("email") && sql.contains("username"),
5650			"SQL should reference both 'email' and 'username' columns, got: {}",
5651			sql
5652		);
5653	}
5654
5655	#[test]
5656	fn test_alter_unique_together_empty() {
5657		let op = Operation::AlterUniqueTogether {
5658			table: "users".to_string(),
5659			unique_together: vec![],
5660		};
5661
5662		let stmt = op.to_statement();
5663		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5664		assert_eq!(
5665			sql, "",
5666			"SQL should be empty for empty unique_together constraint"
5667		);
5668	}
5669
5670	#[test]
5671	fn test_alter_model_options_to_statement() {
5672		let mut options = std::collections::HashMap::new();
5673		options.insert("db_table".to_string(), "custom_users".to_string());
5674
5675		let op = Operation::AlterModelOptions {
5676			table: "users".to_string(),
5677			options,
5678		};
5679
5680		let stmt = op.to_statement();
5681		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5682		assert_eq!(sql, "", "SQL should be empty for model options operation");
5683	}
5684
5685	#[test]
5686	fn test_create_inherited_table_to_statement() {
5687		let op = Operation::CreateInheritedTable {
5688			name: "admin_users".to_string(),
5689			columns: vec![ColumnDefinition {
5690				name: "admin_level".to_string(),
5691				type_definition: FieldType::Integer,
5692				not_null: true,
5693				unique: false,
5694				primary_key: false,
5695				auto_increment: false,
5696				default: Some("1".to_string()),
5697			}],
5698			base_table: "users".to_string(),
5699			join_column: "user_id".to_string(),
5700		};
5701
5702		let stmt = op.to_statement();
5703		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5704		assert!(
5705			sql.contains("CREATE TABLE"),
5706			"SQL should contain CREATE TABLE keywords, got: {}",
5707			sql
5708		);
5709		assert!(
5710			sql.contains("admin_users"),
5711			"SQL should reference 'admin_users' table, got: {}",
5712			sql
5713		);
5714		assert!(
5715			sql.contains("user_id"),
5716			"SQL should include join column 'user_id', got: {}",
5717			sql
5718		);
5719	}
5720
5721	#[test]
5722	fn test_add_discriminator_column_to_statement() {
5723		let op = Operation::AddDiscriminatorColumn {
5724			table: "users".to_string(),
5725			column_name: "user_type".to_string(),
5726			default_value: "regular".to_string(),
5727		};
5728
5729		let stmt = op.to_statement();
5730		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5731		assert!(
5732			sql.contains("ALTER TABLE"),
5733			"SQL should contain ALTER TABLE keyword, got: {}",
5734			sql
5735		);
5736		assert!(
5737			sql.contains("users"),
5738			"SQL should reference 'users' table, got: {}",
5739			sql
5740		);
5741		assert!(
5742			sql.contains("ADD COLUMN"),
5743			"SQL should contain ADD COLUMN clause, got: {}",
5744			sql
5745		);
5746		assert!(
5747			sql.contains("user_type"),
5748			"SQL should reference 'user_type' column, got: {}",
5749			sql
5750		);
5751	}
5752
5753	#[test]
5754	fn test_state_forwards_create_table() {
5755		let mut state = ProjectState::new();
5756		let op = Operation::CreateTable {
5757			name: "users".to_string(),
5758			columns: vec![
5759				ColumnDefinition {
5760					name: "id".to_string(),
5761					type_definition: FieldType::Integer,
5762					not_null: false,
5763					unique: false,
5764					primary_key: true,
5765					auto_increment: true,
5766					default: None,
5767				},
5768				ColumnDefinition {
5769					name: "name".to_string(),
5770					type_definition: FieldType::VarChar(100),
5771					not_null: true,
5772					unique: false,
5773					primary_key: false,
5774					auto_increment: false,
5775					default: None,
5776				},
5777			],
5778			constraints: vec![],
5779			without_rowid: None,
5780			partition: None,
5781			interleave_in_parent: None,
5782		};
5783
5784		op.state_forwards("myapp", &mut state);
5785		let model = state.get_model("myapp", "users");
5786		assert!(model.is_some(), "Model 'users' should exist in state");
5787		let model = model.unwrap();
5788		assert_eq!(
5789			model.fields.len(),
5790			2,
5791			"Model should have exactly 2 fields, got: {}",
5792			model.fields.len()
5793		);
5794		assert!(
5795			model.fields.contains_key("id"),
5796			"Model should contain 'id' field"
5797		);
5798		assert!(
5799			model.fields.contains_key("name"),
5800			"Model should contain 'name' field"
5801		);
5802	}
5803
5804	#[test]
5805	fn test_state_forwards_drop_table() {
5806		let mut state = ProjectState::new();
5807		let mut model = ModelState::new("myapp", "users");
5808		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5809		state.add_model(model);
5810
5811		let op = Operation::DropTable {
5812			name: "users".to_string(),
5813		};
5814
5815		op.state_forwards("myapp", &mut state);
5816		assert!(
5817			state.get_model("myapp", "users").is_none(),
5818			"Model 'users' should be removed from state after drop"
5819		);
5820	}
5821
5822	#[test]
5823	fn test_state_forwards_add_column() {
5824		let mut state = ProjectState::new();
5825		let mut model = ModelState::new("myapp", "users");
5826		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5827		state.add_model(model);
5828
5829		let op = Operation::AddColumn {
5830			table: "users".to_string(),
5831			column: ColumnDefinition {
5832				name: "email".to_string(),
5833				type_definition: FieldType::VarChar(255),
5834				not_null: true,
5835				unique: false,
5836				primary_key: false,
5837				auto_increment: false,
5838				default: None,
5839			},
5840			mysql_options: None,
5841		};
5842
5843		op.state_forwards("myapp", &mut state);
5844		let model = state.get_model("myapp", "users").unwrap();
5845		assert_eq!(
5846			model.fields.len(),
5847			2,
5848			"Model should have 2 fields after adding 'email', got: {}",
5849			model.fields.len()
5850		);
5851		assert!(
5852			model.fields.contains_key("email"),
5853			"Model should contain newly added 'email' field"
5854		);
5855	}
5856
5857	#[test]
5858	fn test_state_forwards_drop_column() {
5859		let mut state = ProjectState::new();
5860		let mut model = ModelState::new("myapp", "users");
5861		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5862		model.add_field(FieldState::new(
5863			"email".to_string(),
5864			FieldType::VarChar(255),
5865			false,
5866		));
5867		state.add_model(model);
5868
5869		let op = Operation::DropColumn {
5870			table: "users".to_string(),
5871			column: "email".to_string(),
5872		};
5873
5874		op.state_forwards("myapp", &mut state);
5875		let model = state.get_model("myapp", "users").unwrap();
5876		assert_eq!(
5877			model.fields.len(),
5878			1,
5879			"Model should have 1 field after dropping 'email', got: {}",
5880			model.fields.len()
5881		);
5882		assert!(
5883			!model.fields.contains_key("email"),
5884			"Model should not contain dropped 'email' field"
5885		);
5886	}
5887
5888	#[test]
5889	fn test_state_forwards_rename_table() {
5890		let mut state = ProjectState::new();
5891		let mut model = ModelState::new("myapp", "users");
5892		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5893		state.add_model(model);
5894
5895		let op = Operation::RenameTable {
5896			old_name: "users".to_string(),
5897			new_name: "accounts".to_string(),
5898		};
5899
5900		op.state_forwards("myapp", &mut state);
5901		assert!(
5902			state.get_model("myapp", "users").is_none(),
5903			"Old model name 'users' should not exist after rename"
5904		);
5905		assert!(
5906			state.get_model("myapp", "accounts").is_some(),
5907			"New model name 'accounts' should exist after rename"
5908		);
5909	}
5910
5911	#[test]
5912	fn test_state_forwards_rename_column() {
5913		let mut state = ProjectState::new();
5914		let mut model = ModelState::new("myapp", "users");
5915		model.add_field(FieldState::new(
5916			"name".to_string(),
5917			FieldType::VarChar(255),
5918			false,
5919		));
5920		state.add_model(model);
5921
5922		let op = Operation::RenameColumn {
5923			table: "users".to_string(),
5924			old_name: "name".to_string(),
5925			new_name: "full_name".to_string(),
5926		};
5927
5928		op.state_forwards("myapp", &mut state);
5929		let model = state.get_model("myapp", "users").unwrap();
5930		assert!(
5931			!model.fields.contains_key("name"),
5932			"Old field name 'name' should not exist after rename"
5933		);
5934		assert!(
5935			model.fields.contains_key("full_name"),
5936			"New field name 'full_name' should exist after rename"
5937		);
5938	}
5939
5940	#[test]
5941	fn test_to_reverse_sql_create_table() {
5942		let op = Operation::CreateTable {
5943			name: "users".to_string(),
5944			columns: vec![],
5945			constraints: vec![],
5946			without_rowid: None,
5947			partition: None,
5948			interleave_in_parent: None,
5949		};
5950
5951		let state = ProjectState::default();
5952		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5953		assert!(
5954			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_some(),
5955			"CreateTable should have reverse SQL operation"
5956		);
5957		let sql = reverse.unwrap().unwrap().join("\n");
5958		assert!(
5959			sql.contains("DROP TABLE"),
5960			"Reverse SQL should contain DROP TABLE, got: {}",
5961			sql
5962		);
5963		assert!(
5964			sql.contains("users"),
5965			"Reverse SQL should reference 'users' table, got: {}",
5966			sql
5967		);
5968	}
5969
5970	#[test]
5971	fn test_to_reverse_sql_drop_table() {
5972		let op = Operation::DropTable {
5973			name: "users".to_string(),
5974		};
5975
5976		let state = ProjectState::default();
5977		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5978		assert!(
5979			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_none(),
5980			"DropTable should not have reverse SQL (cannot recreate table structure)"
5981		);
5982	}
5983
5984	#[test]
5985	fn test_to_reverse_sql_add_column() {
5986		let op = Operation::AddColumn {
5987			table: "users".to_string(),
5988			column: ColumnDefinition {
5989				name: "email".to_string(),
5990				type_definition: FieldType::VarChar(255),
5991				not_null: false,
5992				unique: false,
5993				primary_key: false,
5994				auto_increment: false,
5995				default: None,
5996			},
5997			mysql_options: None,
5998		};
5999
6000		let state = ProjectState::default();
6001		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
6002		assert!(
6003			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_some(),
6004			"AddColumn should have reverse SQL operation"
6005		);
6006		let sql = reverse.unwrap().unwrap().join("\n");
6007		assert!(
6008			sql.contains("DROP COLUMN"),
6009			"Reverse SQL should contain DROP COLUMN, got: {}",
6010			sql
6011		);
6012		assert!(
6013			sql.contains("email"),
6014			"Reverse SQL should reference 'email' column, got: {}",
6015			sql
6016		);
6017	}
6018
6019	/// Build an [`Operation::AlterColumn`] that carries an `old_definition`,
6020	/// so reverse-SQL generation has all the inputs it needs without a
6021	/// populated `ProjectState`. Used by the dialect-dispatch regression
6022	/// tests for reinhardt-web#4582.
6023	fn alter_column_with_old_def() -> Operation {
6024		Operation::AlterColumn {
6025			table: "products".to_string(),
6026			column: "name".to_string(),
6027			old_definition: Some(ColumnDefinition {
6028				name: "name".to_string(),
6029				type_definition: FieldType::VarChar(50),
6030				not_null: false,
6031				unique: false,
6032				primary_key: false,
6033				auto_increment: false,
6034				default: None,
6035			}),
6036			new_definition: ColumnDefinition {
6037				name: "name".to_string(),
6038				type_definition: FieldType::Text,
6039				not_null: false,
6040				unique: false,
6041				primary_key: false,
6042				auto_increment: false,
6043				default: None,
6044			},
6045			mysql_options: None,
6046		}
6047	}
6048
6049	/// Reverse SQL for Postgres must use `ALTER COLUMN ... TYPE` syntax
6050	/// (regression coverage for reinhardt-web#4582).
6051	#[test]
6052	fn test_to_reverse_sql_alter_column_postgres() {
6053		// Arrange
6054		let op = alter_column_with_old_def();
6055		let state = ProjectState::default();
6056
6057		// Act: reverse SQL is now returned as a multi-statement `Vec<String>`
6058		// (see `Operation::to_reverse_sql` doc); flatten to a single string for
6059		// the substring-based assertions below.
6060		let stmts = op
6061			.to_reverse_sql(&SqlDialect::Postgres, &state)
6062			.expect("reverse SQL should succeed")
6063			.expect("reverse SQL should be present");
6064		let sql = stmts.join("\n");
6065
6066		// Assert
6067		assert!(
6068			sql.contains("ALTER COLUMN") && sql.contains("TYPE"),
6069			"Postgres reverse SQL should use ALTER COLUMN ... TYPE syntax, got: {}",
6070			sql
6071		);
6072		assert!(
6073			sql.contains("VARCHAR(50)"),
6074			"Postgres reverse SQL should restore VARCHAR(50), got: {}",
6075			sql
6076		);
6077		// Multi-statement contract (#4640): type reversion and nullability
6078		// restoration are emitted as two independent statements so
6079		// `SchemaEditor::execute()` (sqlx Extended Query) can dispatch each
6080		// payload without rejection.
6081		assert_eq!(
6082			stmts.len(),
6083			2,
6084			"Postgres AlterColumn reverse SQL must emit two statements \
6085			 (type + nullability), got: {:?}",
6086			stmts
6087		);
6088		assert!(
6089			stmts[1].contains("DROP NOT NULL"),
6090			"Postgres second statement must restore DROP NOT NULL (was_nullable), got: {}",
6091			stmts[1]
6092		);
6093	}
6094
6095	/// Reverse SQL for MySQL must use `MODIFY COLUMN` syntax — the previous
6096	/// implementation emitted Postgres `ALTER COLUMN ... TYPE` which MySQL
6097	/// rejects with error 1064 (reinhardt-web#4582).
6098	#[test]
6099	fn test_to_reverse_sql_alter_column_mysql() {
6100		// Arrange
6101		let op = alter_column_with_old_def();
6102		let state = ProjectState::default();
6103
6104		// Act: MySQL AlterColumn reverse SQL remains a single `MODIFY COLUMN`
6105		// statement (one-element `Vec`).
6106		let stmts = op
6107			.to_reverse_sql(&SqlDialect::Mysql, &state)
6108			.expect("reverse SQL should succeed")
6109			.expect("reverse SQL should be present");
6110		assert_eq!(
6111			stmts.len(),
6112			1,
6113			"MySQL AlterColumn reverse SQL should remain a single statement, got: {:?}",
6114			stmts
6115		);
6116		let sql = stmts.join("\n");
6117
6118		// Assert
6119		assert!(
6120			sql.contains("MODIFY COLUMN"),
6121			"MySQL reverse SQL should use MODIFY COLUMN syntax, got: {}",
6122			sql
6123		);
6124		assert!(
6125			!sql.contains("ALTER COLUMN"),
6126			"MySQL reverse SQL must not emit Postgres ALTER COLUMN syntax, got: {}",
6127			sql
6128		);
6129		assert!(
6130			!sql.contains(" TYPE "),
6131			"MySQL reverse SQL must not contain Postgres ' TYPE ' token, got: {}",
6132			sql
6133		);
6134		assert!(
6135			sql.contains("VARCHAR(50)"),
6136			"MySQL reverse SQL should restore VARCHAR(50), got: {}",
6137			sql
6138		);
6139	}
6140
6141	#[rstest]
6142	#[case::postgres(SqlDialect::Postgres)]
6143	#[case::cockroachdb(SqlDialect::Cockroachdb)]
6144	fn test_to_sql_alter_column_sets_default_for_postgres_family(#[case] dialect: SqlDialect) {
6145		// Arrange
6146		let op = Operation::AlterColumn {
6147			table: "users".to_string(),
6148			column: "is_active".to_string(),
6149			old_definition: Some(ColumnDefinition {
6150				name: "is_active".to_string(),
6151				type_definition: FieldType::Boolean,
6152				not_null: true,
6153				unique: false,
6154				primary_key: false,
6155				auto_increment: false,
6156				default: None,
6157			}),
6158			new_definition: ColumnDefinition {
6159				name: "is_active".to_string(),
6160				type_definition: FieldType::Boolean,
6161				not_null: true,
6162				unique: false,
6163				primary_key: false,
6164				auto_increment: false,
6165				default: Some("true".to_string()),
6166			},
6167			mysql_options: None,
6168		};
6169
6170		// Act
6171		let sql = op.to_sql(&dialect);
6172
6173		// Assert
6174		assert!(
6175			sql.contains("ALTER COLUMN is_active SET DEFAULT true"),
6176			"AlterColumn must apply new database defaults, got: {}",
6177			sql
6178		);
6179	}
6180
6181	#[rstest]
6182	#[case::postgres(SqlDialect::Postgres)]
6183	#[case::cockroachdb(SqlDialect::Cockroachdb)]
6184	fn test_to_sql_alter_column_drops_default_for_postgres_family(#[case] dialect: SqlDialect) {
6185		// Arrange
6186		let op = Operation::AlterColumn {
6187			table: "users".to_string(),
6188			column: "is_active".to_string(),
6189			old_definition: Some(ColumnDefinition {
6190				name: "is_active".to_string(),
6191				type_definition: FieldType::Boolean,
6192				not_null: true,
6193				unique: false,
6194				primary_key: false,
6195				auto_increment: false,
6196				default: Some("true".to_string()),
6197			}),
6198			new_definition: ColumnDefinition {
6199				name: "is_active".to_string(),
6200				type_definition: FieldType::Boolean,
6201				not_null: true,
6202				unique: false,
6203				primary_key: false,
6204				auto_increment: false,
6205				default: None,
6206			},
6207			mysql_options: None,
6208		};
6209
6210		// Act
6211		let sql = op.to_sql(&dialect);
6212
6213		// Assert
6214		assert!(
6215			sql.contains("ALTER COLUMN is_active DROP DEFAULT"),
6216			"AlterColumn must remove dropped database defaults, got: {}",
6217			sql
6218		);
6219	}
6220
6221	#[test]
6222	fn test_to_sql_alter_column_mysql_preserves_full_column_definition() {
6223		// Arrange
6224		let op = Operation::AlterColumn {
6225			table: "users".to_string(),
6226			column: "is_active".to_string(),
6227			old_definition: None,
6228			new_definition: ColumnDefinition {
6229				name: "is_active".to_string(),
6230				type_definition: FieldType::Boolean,
6231				not_null: true,
6232				unique: false,
6233				primary_key: false,
6234				auto_increment: false,
6235				default: Some("true".to_string()),
6236			},
6237			mysql_options: None,
6238		};
6239
6240		// Act
6241		let sql = op.to_sql(&SqlDialect::Mysql);
6242
6243		// Assert
6244		assert!(
6245			sql.contains("MODIFY COLUMN is_active TINYINT(1) NOT NULL DEFAULT true"),
6246			"MySQL AlterColumn must include type, nullability, and default, got: {}",
6247			sql
6248		);
6249	}
6250
6251	/// Reverse SQL for CockroachDB must emit the column-type reversion **and**
6252	/// the nullability restoration as two independent single-statement payloads
6253	/// — the same shape PostgreSQL uses post-#4640.
6254	///
6255	/// History: PR #4633 split CockroachDB off from PostgreSQL because
6256	/// CockroachDB rejects the comma-combined `ALTER COLUMN ... TYPE T,
6257	/// ALTER COLUMN ... {SET|DROP} NOT NULL` form that PostgreSQL accepts.
6258	/// The interim stop-gap (#4633) emitted only the column-type reversion
6259	/// and dropped NOT NULL rollback fidelity. This Issue (#4640) restored
6260	/// full fidelity by changing `Operation::to_reverse_sql` to return
6261	/// `Vec<String>`, so each statement is dispatched separately by
6262	/// `SchemaEditor::execute()` (sqlx Extended Query, single-statement).
6263	///
6264	/// This test pins the post-#4640 multi-statement contract: a future
6265	/// regression that re-folds CockroachDB into a single payload (and thus
6266	/// drops nullability) — or that emits the Postgres comma-combined form —
6267	/// will fail here at `cargo test` time instead of at downstream
6268	/// CockroachDB deployments.
6269	///
6270	/// Identifier quoting note: `quote_identifier` here is
6271	/// `pg_escape::quote_identifier`, which only quotes identifiers that fall
6272	/// outside PostgreSQL's unquoted-identifier grammar (anything outside
6273	/// `[a-z_][a-z0-9_]*`, or a reserved keyword). `products` and `name`
6274	/// are plain lowercase ASCII matching that grammar, so the expected
6275	/// output is unquoted. Identifier-quoting-by-default is tracked
6276	/// separately in #4674.
6277	#[test]
6278	fn test_to_reverse_sql_alter_column_cockroachdb() {
6279		// Arrange
6280		let op = alter_column_with_old_def();
6281		let state = ProjectState::default();
6282
6283		// Act
6284		let stmts = op
6285			.to_reverse_sql(&SqlDialect::Cockroachdb, &state)
6286			.expect("reverse SQL should succeed")
6287			.expect("reverse SQL should be present");
6288
6289		// Assert: exact two-element Vec, type reversion first, nullability
6290		// restoration second. `not_null` on the `old_definition` (see
6291		// `alter_column_with_old_def`) is `false`, so the second statement
6292		// is `DROP NOT NULL`. Asserting `==` (not `contains`) is
6293		// intentional — a permissive `contains` check would allow a future
6294		// regression to re-fold these into a single comma-combined payload
6295		// (which CockroachDB rejects).
6296		assert_eq!(
6297			stmts,
6298			vec![
6299				"ALTER TABLE products ALTER COLUMN name TYPE VARCHAR(50);".to_string(),
6300				"ALTER TABLE products ALTER COLUMN name DROP NOT NULL;".to_string(),
6301			],
6302			"CockroachDB reverse SQL must emit exactly [type_stmt, nullability_stmt], \
6303			 got: {:?}",
6304			stmts
6305		);
6306
6307		// Defensive: every emitted statement must be a single SQL statement.
6308		// `SchemaEditor::execute()` is a single-statement dispatcher (sqlx
6309		// Extended Query); a regression that re-introduces the Postgres
6310		// comma-combined form inside a single Vec element would re-break
6311		// CockroachDB rollback. Trim trailing `;` so the check looks only at
6312		// internal separators.
6313		for stmt in &stmts {
6314			let trimmed = stmt.trim().trim_end_matches(';').trim();
6315			assert!(
6316				!trimmed.contains(';'),
6317				"each emitted statement must be a single SQL statement, got: {}",
6318				stmt
6319			);
6320			assert!(
6321				!stmt.contains(", ALTER COLUMN"),
6322				"emitted statements must not use the Postgres comma-combined form \
6323				 (CockroachDB rejects it), got: {}",
6324				stmt
6325			);
6326		}
6327	}
6328
6329	/// SQLite has no general `ALTER COLUMN`; the rollback path is handled
6330	/// via table recreation in the executor. `to_reverse_sql` therefore
6331	/// returns an inert comment so that any accidental fall-through does
6332	/// not produce executable SQL that SQLite would reject
6333	/// (reinhardt-web#4582).
6334	#[test]
6335	fn test_to_reverse_sql_alter_column_sqlite() {
6336		// Arrange
6337		let op = alter_column_with_old_def();
6338		let state = ProjectState::default();
6339
6340		// Act: SQLite emits a single inert comment (executor recreates the
6341		// table via the SQLite-recreation path), so the returned `Vec` is
6342		// one element.
6343		let stmts = op
6344			.to_reverse_sql(&SqlDialect::Sqlite, &state)
6345			.expect("reverse SQL should succeed")
6346			.expect("reverse SQL should be present");
6347		assert_eq!(
6348			stmts.len(),
6349			1,
6350			"SQLite AlterColumn reverse SQL should remain a single comment, got: {:?}",
6351			stmts
6352		);
6353		let sql = &stmts[0];
6354
6355		// Assert
6356		assert!(
6357			sql.trim_start().starts_with("--"),
6358			"SQLite reverse SQL should be a SQL comment (recreation handled by executor), got: {}",
6359			sql
6360		);
6361		// Strip leading `--` (and any whitespace) before checking; the comment
6362		// body itself is allowed to mention ALTER COLUMN as English prose.
6363		let body = sql.trim_start_matches("--").trim_start();
6364		assert!(
6365			!body.to_uppercase().contains("ALTER TABLE"),
6366			"SQLite reverse SQL body must not emit executable ALTER TABLE statement, got: {}",
6367			sql
6368		);
6369	}
6370
6371	/// `to_reverse_operation` for `AlterColumn` must prefer the explicit
6372	/// `old_definition` carried by the forward operation, instead of
6373	/// always falling back to an (often empty) `ProjectState` lookup
6374	/// (reinhardt-web#4582).
6375	#[test]
6376	fn test_to_reverse_operation_alter_column_uses_old_definition() {
6377		// Arrange
6378		let op = alter_column_with_old_def();
6379		let state = ProjectState::default();
6380
6381		// Act
6382		let reverse = op
6383			.to_reverse_operation(&state)
6384			.expect("reverse operation should succeed")
6385			.expect("reverse operation should be present (old_definition is supplied)");
6386
6387		// Assert
6388		match reverse {
6389			Operation::AlterColumn { new_definition, .. } => {
6390				assert!(
6391					matches!(new_definition.type_definition, FieldType::VarChar(50)),
6392					"reverse AlterColumn should restore VARCHAR(50), got: {:?}",
6393					new_definition.type_definition
6394				);
6395			}
6396			other => panic!("reverse operation should be AlterColumn, got: {:?}", other),
6397		}
6398	}
6399
6400	#[test]
6401	fn test_to_reverse_sql_run_sql_with_reverse() {
6402		let op = Operation::RunSQL {
6403			sql: "CREATE INDEX idx_name ON users(name)".to_string(),
6404			reverse_sql: Some("DROP INDEX idx_name".to_string()),
6405		};
6406
6407		let state = ProjectState::default();
6408		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
6409		assert!(
6410			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_some(),
6411			"RunSQL with reverse_sql should have reverse SQL"
6412		);
6413		let sql = reverse.unwrap().unwrap().join("\n");
6414		assert!(
6415			sql.contains("DROP INDEX"),
6416			"Reverse SQL should contain provided reverse_sql, got: {}",
6417			sql
6418		);
6419	}
6420
6421	#[test]
6422	fn test_to_reverse_sql_run_sql_without_reverse() {
6423		let op = Operation::RunSQL {
6424			sql: "CREATE INDEX idx_name ON users(name)".to_string(),
6425			reverse_sql: None,
6426		};
6427
6428		let state = ProjectState::default();
6429		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
6430		assert!(
6431			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_none(),
6432			"RunSQL without reverse_sql should not have reverse SQL"
6433		);
6434	}
6435
6436	#[test]
6437	fn test_column_definition_new() {
6438		let col = ColumnDefinition::new("id", FieldType::Integer);
6439		assert_eq!(col.name, "id", "Column name should be 'id'");
6440		assert_eq!(
6441			col.type_definition,
6442			FieldType::Integer,
6443			"Column type should be Integer"
6444		);
6445		assert!(!col.not_null, "not_null should default to false");
6446		assert!(!col.unique, "unique should default to false");
6447		assert!(!col.primary_key, "primary_key should default to false");
6448		assert!(
6449			!col.auto_increment,
6450			"auto_increment should default to false"
6451		);
6452		assert!(col.default.is_none(), "default should be None");
6453	}
6454
6455	// -----------------------------------------------------------------------
6456	// Regression tests for issue #4573:
6457	// `ColumnDefinition::from_field_state` previously read a non-existent
6458	// `params["not_null"]` key and silently emitted NULLABLE columns for
6459	// every non-PK field, violating the non-Optional Rust type ↔ NOT NULL
6460	// schema contract. These tests pin the corrected behavior:
6461	//   not_null = !field_state.nullable || primary_key
6462	// -----------------------------------------------------------------------
6463
6464	#[rstest]
6465	fn from_field_state_non_optional_bool_with_true_default() {
6466		// Arrange
6467		// Models a Rust field declared as `pub is_active: bool` with
6468		// `#[field(default = true)]`. The proc-macro sets
6469		// `field_state.nullable = false` via `FieldMetadata::with_nullable`,
6470		// and emits the default value as `params["default"] = "true"`.
6471		let mut field_state = FieldState::new("is_active", FieldType::Boolean, false);
6472		field_state
6473			.params
6474			.insert("default".to_string(), "true".to_string());
6475
6476		// Act
6477		let col = ColumnDefinition::from_field_state("is_active", &field_state);
6478
6479		// Assert
6480		assert_eq!(col.name, "is_active", "Column name should round-trip");
6481		assert_eq!(
6482			col.type_definition,
6483			FieldType::Boolean,
6484			"Boolean field type should round-trip"
6485		);
6486		assert!(
6487			col.not_null,
6488			"Non-Optional bool must emit NOT NULL (regression #4573)"
6489		);
6490		assert_eq!(
6491			col.default,
6492			Some("true".to_string()),
6493			"`#[field(default = true)]` must propagate as Some(\"true\")"
6494		);
6495		assert!(!col.primary_key, "Non-PK field must not be primary_key");
6496	}
6497
6498	#[rstest]
6499	fn from_field_state_non_optional_bool_with_false_default() {
6500		// Arrange
6501		// Models a Rust field declared as `pub is_superuser: bool` with
6502		// `#[field(default = false)]` — the symptom previously hand-patched
6503		// in PR #4513.
6504		let mut field_state = FieldState::new("is_superuser", FieldType::Boolean, false);
6505		field_state
6506			.params
6507			.insert("default".to_string(), "false".to_string());
6508
6509		// Act
6510		let col = ColumnDefinition::from_field_state("is_superuser", &field_state);
6511
6512		// Assert
6513		assert!(
6514			col.not_null,
6515			"Non-Optional bool with default=false must emit NOT NULL"
6516		);
6517		assert_eq!(
6518			col.default,
6519			Some("false".to_string()),
6520			"default=false must propagate as Some(\"false\")"
6521		);
6522	}
6523
6524	#[rstest]
6525	fn from_field_state_optional_bool_with_default() {
6526		// Arrange
6527		// Models a Rust field declared as `pub maybe_flag: Option<bool>` with
6528		// `#[field(default = true)]`. Existing behavior must be preserved:
6529		// nullable column with default still set.
6530		let mut field_state = FieldState::new("maybe_flag", FieldType::Boolean, true);
6531		field_state
6532			.params
6533			.insert("default".to_string(), "true".to_string());
6534
6535		// Act
6536		let col = ColumnDefinition::from_field_state("maybe_flag", &field_state);
6537
6538		// Assert
6539		assert!(
6540			!col.not_null,
6541			"Optional bool must remain NULLABLE — no regression on Option<T>"
6542		);
6543		assert_eq!(
6544			col.default,
6545			Some("true".to_string()),
6546			"Default propagation must work for Optional fields too"
6547		);
6548	}
6549
6550	#[rstest]
6551	fn from_field_state_non_optional_non_bool() {
6552		// Arrange
6553		// Models a Rust field declared as `pub username: String` with no
6554		// default. This confirms the fix is type-agnostic (not bool-only) and
6555		// prevents required string fields from being emitted as NULLABLE.
6556		let field_state = FieldState::new("username", FieldType::VarChar(150), false);
6557
6558		// Act
6559		let col = ColumnDefinition::from_field_state("username", &field_state);
6560
6561		// Assert
6562		assert!(
6563			col.not_null,
6564			"Non-Optional String must emit NOT NULL (regression #4573 — bug \
6565			 affected all field types, not just bool)"
6566		);
6567		assert!(
6568			col.default.is_none(),
6569			"No default annotation → default = None"
6570		);
6571	}
6572
6573	#[rstest]
6574	fn from_field_state_primary_key_is_always_not_null() {
6575		// Arrange
6576		// A primary key column must be NOT NULL even when the nullability
6577		// flag would otherwise allow NULL. This pins the `|| primary_key`
6578		// short-circuit in the corrected expression.
6579		let mut field_state = FieldState::new("id", FieldType::Uuid, true);
6580		field_state
6581			.params
6582			.insert("primary_key".to_string(), "true".to_string());
6583
6584		// Act
6585		let col = ColumnDefinition::from_field_state("id", &field_state);
6586
6587		// Assert
6588		assert!(
6589			col.primary_key,
6590			"primary_key param must propagate to ColumnDefinition"
6591		);
6592		assert!(
6593			col.not_null,
6594			"Primary key must be NOT NULL regardless of nullable flag"
6595		);
6596	}
6597
6598	#[rstest]
6599	fn from_field_state_optional_field_remains_nullable() {
6600		// Arrange
6601		// Models a Rust field declared as `pub last_login: Option<DateTime<Utc>>`
6602		// with no default. Existing nullable-field behavior must be preserved.
6603		let field_state = FieldState::new("last_login", FieldType::TimestampTz, true);
6604
6605		// Act
6606		let col = ColumnDefinition::from_field_state("last_login", &field_state);
6607
6608		// Assert
6609		assert!(
6610			!col.not_null,
6611			"Optional field with no default must remain NULLABLE"
6612		);
6613		assert!(col.default.is_none(), "No default → default = None");
6614		assert!(!col.primary_key, "Non-PK field must not be primary_key");
6615	}
6616
6617	#[test]
6618	fn test_convert_default_value_null() {
6619		let op = Operation::CreateTable {
6620			name: "test".to_string(),
6621			columns: vec![],
6622			constraints: vec![],
6623			without_rowid: None,
6624			partition: None,
6625			interleave_in_parent: None,
6626		};
6627		let value = op.convert_default_value("null");
6628		assert!(
6629			matches!(value, Value::String(None)),
6630			"NULL value should be converted to Value::String(None)"
6631		);
6632	}
6633
6634	#[test]
6635	fn test_convert_default_value_bool() {
6636		let op = Operation::CreateTable {
6637			name: "test".to_string(),
6638			columns: vec![],
6639			constraints: vec![],
6640			without_rowid: None,
6641			partition: None,
6642			interleave_in_parent: None,
6643		};
6644		let value = op.convert_default_value("true");
6645		assert!(
6646			matches!(value, Value::Bool(Some(true))),
6647			"'true' should be converted to Value::Bool(Some(true))"
6648		);
6649
6650		let value = op.convert_default_value("false");
6651		assert!(
6652			matches!(value, Value::Bool(Some(false))),
6653			"'false' should be converted to Value::Bool(Some(false))"
6654		);
6655	}
6656
6657	#[test]
6658	fn test_convert_default_value_integer() {
6659		let op = Operation::CreateTable {
6660			name: "test".to_string(),
6661			columns: vec![],
6662			constraints: vec![],
6663			without_rowid: None,
6664			partition: None,
6665			interleave_in_parent: None,
6666		};
6667		let value = op.convert_default_value("42");
6668		assert!(
6669			matches!(value, Value::BigInt(Some(42))),
6670			"Integer '42' should be converted to Value::BigInt(Some(42))"
6671		);
6672	}
6673
6674	#[test]
6675	fn test_convert_default_value_float() {
6676		let op = Operation::CreateTable {
6677			name: "test".to_string(),
6678			columns: vec![],
6679			constraints: vec![],
6680			without_rowid: None,
6681			partition: None,
6682			interleave_in_parent: None,
6683		};
6684		let value = op.convert_default_value("3.15");
6685		assert!(
6686			matches!(value, Value::Double(_)),
6687			"Float '3.15' should be converted to Value::Double"
6688		);
6689	}
6690
6691	#[test]
6692	fn test_convert_default_value_string() {
6693		let op = Operation::CreateTable {
6694			name: "test".to_string(),
6695			columns: vec![],
6696			constraints: vec![],
6697			without_rowid: None,
6698			partition: None,
6699			interleave_in_parent: None,
6700		};
6701		let value = op.convert_default_value("'hello'");
6702		match value {
6703			Value::String(Some(s)) => assert_eq!(
6704				*s, "hello",
6705				"Quoted string should be unquoted and stored as 'hello'"
6706			),
6707			_ => {
6708				panic!("Expected Value::String(Some(\"hello\")), got different variant")
6709			}
6710		}
6711	}
6712
6713	#[rstest]
6714	#[case("pending", "'pending'")]
6715	#[case("active", "'active'")]
6716	#[case("hello world", "'hello world'")]
6717	#[case("it's", "'it''s'")]
6718	fn test_convert_default_value_plain_string(#[case] input: &str, #[case] expected: &str) {
6719		// Arrange
6720		let op = Operation::CreateTable {
6721			name: "test".to_string(),
6722			columns: vec![],
6723			constraints: vec![],
6724			without_rowid: None,
6725			partition: None,
6726			interleave_in_parent: None,
6727		};
6728
6729		// Act
6730		let value = op.convert_default_value(input);
6731
6732		// Assert
6733		match value {
6734			Value::String(Some(s)) => assert_eq!(
6735				*s, expected,
6736				"Plain string '{input}' should be auto-quoted as SQL string literal"
6737			),
6738			_ => {
6739				panic!("Expected Value::String(Some(\"{expected}\")), got {value:?}")
6740			}
6741		}
6742	}
6743
6744	#[rstest]
6745	#[case("CURRENT_TIMESTAMP")]
6746	#[case("current_timestamp")]
6747	#[case("CURRENT_DATE")]
6748	#[case("CURRENT_TIME")]
6749	#[case("CURRENT_USER")]
6750	#[case("SESSION_USER")]
6751	#[case("LOCALTIME")]
6752	#[case("LOCALTIMESTAMP")]
6753	fn test_convert_default_value_sql_constant(#[case] input: &str) {
6754		// Arrange
6755		let op = Operation::CreateTable {
6756			name: "test".to_string(),
6757			columns: vec![],
6758			constraints: vec![],
6759			without_rowid: None,
6760			partition: None,
6761			interleave_in_parent: None,
6762		};
6763
6764		// Act
6765		let value = op.convert_default_value(input);
6766
6767		// Assert
6768		match value {
6769			Value::String(Some(s)) => {
6770				assert_eq!(*s, input, "SQL constant '{input}' should remain unquoted")
6771			}
6772			_ => {
6773				panic!("Expected Value::String(Some(\"{input}\")), got {value:?}")
6774			}
6775		}
6776	}
6777
6778	#[rstest]
6779	#[case("NOW()")]
6780	#[case("uuid_generate_v4()")]
6781	#[case("gen_random_uuid()")]
6782	fn test_convert_default_value_sql_function(#[case] input: &str) {
6783		// Arrange
6784		let op = Operation::CreateTable {
6785			name: "test".to_string(),
6786			columns: vec![],
6787			constraints: vec![],
6788			without_rowid: None,
6789			partition: None,
6790			interleave_in_parent: None,
6791		};
6792
6793		// Act
6794		let value = op.convert_default_value(input);
6795
6796		// Assert
6797		match value {
6798			Value::String(Some(s)) => {
6799				assert_eq!(*s, input, "SQL function '{input}' should remain unquoted")
6800			}
6801			_ => {
6802				panic!("Expected Value::String(Some(\"{input}\")), got {value:?}")
6803			}
6804		}
6805	}
6806
6807	#[test]
6808	fn test_apply_column_type_integer() {
6809		let op = Operation::CreateTable {
6810			name: "test".to_string(),
6811			columns: vec![],
6812			constraints: vec![],
6813			without_rowid: None,
6814			partition: None,
6815			interleave_in_parent: None,
6816		};
6817		let col = ColumnDef::new(Alias::new("id"));
6818		let _col = op.apply_column_type(col, &FieldType::Integer);
6819		// This test verifies that INTEGER type application doesn't panic
6820		// Internal state cannot be easily asserted with reinhardt_query's ColumnDef API
6821	}
6822
6823	#[test]
6824	fn test_apply_column_type_varchar_with_length() {
6825		let op = Operation::CreateTable {
6826			name: "test".to_string(),
6827			columns: vec![],
6828			constraints: vec![],
6829			without_rowid: None,
6830			partition: None,
6831			interleave_in_parent: None,
6832		};
6833		let col = ColumnDef::new(Alias::new("name"));
6834		let _col = op.apply_column_type(col, &FieldType::VarChar(100));
6835		// This test verifies that VARCHAR(100) type application doesn't panic
6836		// Internal state cannot be easily asserted with reinhardt_query's ColumnDef API
6837	}
6838
6839	#[test]
6840	fn test_apply_column_type_custom() {
6841		let op = Operation::CreateTable {
6842			name: "test".to_string(),
6843			columns: vec![],
6844			constraints: vec![],
6845			without_rowid: None,
6846			partition: None,
6847			interleave_in_parent: None,
6848		};
6849		let col = ColumnDef::new(Alias::new("data"));
6850		let _col = op.apply_column_type(col, &FieldType::Custom("CUSTOM_TYPE".to_string()));
6851		// This test verifies that custom type application doesn't panic
6852		// Internal state cannot be easily asserted with reinhardt_query's ColumnDef API
6853	}
6854
6855	#[test]
6856	fn test_create_index_composite() {
6857		let op = Operation::CreateIndex {
6858			table: "users".to_string(),
6859			columns: vec!["first_name".to_string(), "last_name".to_string()],
6860			unique: false,
6861			index_type: None,
6862			where_clause: None,
6863			concurrently: false,
6864			expressions: None,
6865			mysql_options: None,
6866			operator_class: None,
6867		};
6868
6869		let sql = op.to_sql(&SqlDialect::Postgres);
6870		assert!(
6871			sql.contains("first_name"),
6872			"SQL should include 'first_name' column, got: {}",
6873			sql
6874		);
6875		assert!(
6876			sql.contains("last_name"),
6877			"SQL should include 'last_name' column, got: {}",
6878			sql
6879		);
6880		assert!(
6881			sql.contains("idx_users_first_name_last_name"),
6882			"SQL should include composite index name, got: {}",
6883			sql
6884		);
6885	}
6886
6887	#[test]
6888	fn test_alter_table_comment_with_quotes() {
6889		let op = Operation::AlterTableComment {
6890			table: "users".to_string(),
6891			comment: Some("User's account table".to_string()),
6892		};
6893
6894		let stmt = op.to_statement();
6895		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
6896		assert!(
6897			sql.contains("COMMENT ON TABLE"),
6898			"SQL should contain COMMENT ON TABLE keywords, got: {}",
6899			sql
6900		);
6901		assert!(
6902			sql.contains("User''s account table"),
6903			"SQL should properly escape single quotes in comment, got: {}",
6904			sql
6905		);
6906	}
6907
6908	#[test]
6909	fn test_state_forwards_alter_column() {
6910		let mut state = ProjectState::new();
6911		let mut model = ModelState::new("myapp", "users");
6912		model.add_field(FieldState::new(
6913			"age".to_string(),
6914			FieldType::Integer,
6915			false,
6916		));
6917		state.add_model(model);
6918
6919		let op = Operation::AlterColumn {
6920			table: "users".to_string(),
6921			column: "age".to_string(),
6922			old_definition: None,
6923			new_definition: ColumnDefinition {
6924				name: "age".to_string(),
6925				type_definition: FieldType::BigInteger,
6926				not_null: true,
6927				unique: false,
6928				primary_key: false,
6929				auto_increment: false,
6930				default: None,
6931			},
6932			mysql_options: None,
6933		};
6934
6935		op.state_forwards("myapp", &mut state);
6936		let model = state.get_model("myapp", "users").unwrap();
6937		let field = model.fields.get("age").unwrap();
6938		assert_eq!(
6939			field.field_type,
6940			FieldType::BigInteger,
6941			"Field type should be updated to BigInteger, got: {}",
6942			field.field_type
6943		);
6944	}
6945
6946	#[test]
6947	fn test_state_forwards_create_inherited_table() {
6948		let mut state = ProjectState::new();
6949		let op = Operation::CreateInheritedTable {
6950			name: "admin_users".to_string(),
6951			columns: vec![ColumnDefinition {
6952				name: "admin_level".to_string(),
6953				type_definition: FieldType::Integer,
6954				not_null: true,
6955				unique: false,
6956				primary_key: false,
6957				auto_increment: false,
6958				default: None,
6959			}],
6960			base_table: "users".to_string(),
6961			join_column: "user_id".to_string(),
6962		};
6963
6964		op.state_forwards("myapp", &mut state);
6965		let model = state.get_model("myapp", "admin_users");
6966		assert!(
6967			model.is_some(),
6968			"Inherited table 'admin_users' should exist in state"
6969		);
6970		let model = model.unwrap();
6971		assert_eq!(
6972			model.base_model,
6973			Some("users".to_string()),
6974			"base_model should be set to 'users'"
6975		);
6976		assert_eq!(
6977			model.inheritance_type,
6978			Some("joined_table".to_string()),
6979			"inheritance_type should be 'joined_table'"
6980		);
6981	}
6982
6983	#[test]
6984	fn test_state_forwards_add_discriminator_column() {
6985		let mut state = ProjectState::new();
6986		let mut model = ModelState::new("myapp", "users");
6987		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
6988		state.add_model(model);
6989
6990		let op = Operation::AddDiscriminatorColumn {
6991			table: "users".to_string(),
6992			column_name: "user_type".to_string(),
6993			default_value: "regular".to_string(),
6994		};
6995
6996		op.state_forwards("myapp", &mut state);
6997		let model = state.get_model("myapp", "users").unwrap();
6998		assert_eq!(
6999			model.discriminator_column,
7000			Some("user_type".to_string()),
7001			"discriminator_column should be set to 'user_type'"
7002		);
7003		assert_eq!(
7004			model.inheritance_type,
7005			Some("single_table".to_string()),
7006			"inheritance_type should be 'single_table'"
7007		);
7008	}
7009
7010	#[rstest]
7011	fn test_to_reverse_sql_create_table_quotes_identifiers() {
7012		// Arrange
7013		let op = Operation::CreateTable {
7014			name: "user-data".to_string(),
7015			columns: vec![],
7016			constraints: vec![],
7017			without_rowid: None,
7018			partition: None,
7019			interleave_in_parent: None,
7020		};
7021		let state = ProjectState::default();
7022
7023		// Act
7024		let sql = op
7025			.to_reverse_sql(&SqlDialect::Postgres, &state)
7026			.unwrap()
7027			.unwrap()
7028			.join("\n");
7029
7030		// Assert
7031		assert_eq!(
7032			sql, "DROP TABLE \"user-data\";",
7033			"Identifiers with special characters must be quoted"
7034		);
7035	}
7036
7037	#[rstest]
7038	fn test_to_reverse_sql_add_column_quotes_identifiers() {
7039		// Arrange
7040		let op = Operation::AddColumn {
7041			table: "my table".to_string(),
7042			column: ColumnDefinition {
7043				name: "my column".to_string(),
7044				type_definition: FieldType::VarChar(255),
7045				not_null: false,
7046				unique: false,
7047				primary_key: false,
7048				auto_increment: false,
7049				default: None,
7050			},
7051			mysql_options: None,
7052		};
7053		let state = ProjectState::default();
7054
7055		// Act
7056		let sql = op
7057			.to_reverse_sql(&SqlDialect::Postgres, &state)
7058			.unwrap()
7059			.unwrap()
7060			.join("\n");
7061
7062		// Assert
7063		assert_eq!(
7064			sql, "ALTER TABLE \"my table\" DROP COLUMN \"my column\";",
7065			"Table and column names with spaces must be quoted"
7066		);
7067	}
7068
7069	#[rstest]
7070	fn test_to_reverse_sql_rename_table_quotes_identifiers() {
7071		// Arrange: both names contain special characters requiring quoting
7072		let op = Operation::RenameTable {
7073			old_name: "old; DROP TABLE users;--".to_string(),
7074			new_name: "new-name".to_string(),
7075		};
7076		let state = ProjectState::default();
7077
7078		// Act
7079		let sql = op
7080			.to_reverse_sql(&SqlDialect::Postgres, &state)
7081			.unwrap()
7082			.unwrap()
7083			.join("\n");
7084
7085		// Assert
7086		assert_eq!(
7087			sql, "ALTER TABLE \"new-name\" RENAME TO \"old; DROP TABLE users;--\";",
7088			"SQL injection attempt must be quoted as identifier"
7089		);
7090	}
7091
7092	#[rstest]
7093	fn test_to_reverse_sql_rename_column_quotes_identifiers() {
7094		// Arrange: use identifiers with special characters to verify quoting
7095		let op = Operation::RenameColumn {
7096			table: "my table".to_string(),
7097			old_name: "old col".to_string(),
7098			new_name: "new col".to_string(),
7099		};
7100		let state = ProjectState::default();
7101
7102		// Act
7103		let sql = op
7104			.to_reverse_sql(&SqlDialect::Postgres, &state)
7105			.unwrap()
7106			.unwrap()
7107			.join("\n");
7108
7109		// Assert
7110		assert_eq!(
7111			sql, "ALTER TABLE \"my table\" RENAME COLUMN \"new col\" TO \"old col\";",
7112			"Identifiers with spaces must be quoted"
7113		);
7114	}
7115
7116	#[rstest]
7117	fn test_to_reverse_sql_create_index_quotes_identifiers() {
7118		// Arrange
7119		let op = Operation::CreateIndex {
7120			table: "my-table".to_string(),
7121			columns: vec!["col a".to_string()],
7122			unique: false,
7123			index_type: None,
7124			where_clause: None,
7125			concurrently: false,
7126			expressions: None,
7127			mysql_options: None,
7128			operator_class: None,
7129		};
7130		let state = ProjectState::default();
7131
7132		// Act
7133		let sql = op
7134			.to_reverse_sql(&SqlDialect::Postgres, &state)
7135			.unwrap()
7136			.unwrap()
7137			.join("\n");
7138
7139		// Assert
7140		assert!(
7141			sql.contains("DROP INDEX \"idx_my-table_col a\""),
7142			"Index name must be quoted, got: {}",
7143			sql
7144		);
7145	}
7146
7147	/// Regression test for kent8192/reinhardt-web#4583.
7148	///
7149	/// MySQL requires `DROP INDEX <name> ON <table>` when reversing a `CreateIndex`
7150	/// operation. The previous implementation emitted `DROP INDEX <name>;` for every
7151	/// dialect, which produced malformed SQL on MySQL (1064 syntax error). This test
7152	/// pins the MySQL-specific `ON <table>` clause to prevent regressions.
7153	#[rstest]
7154	fn test_to_reverse_sql_create_index_emits_on_table_clause_for_mysql() {
7155		// Arrange
7156		let op = Operation::CreateIndex {
7157			table: "users".to_string(),
7158			columns: vec!["email".to_string()],
7159			unique: false,
7160			index_type: None,
7161			where_clause: None,
7162			concurrently: false,
7163			expressions: None,
7164			mysql_options: None,
7165			operator_class: None,
7166		};
7167		let state = ProjectState::default();
7168
7169		// Act
7170		let sql = op
7171			.to_reverse_sql(&SqlDialect::Mysql, &state)
7172			.unwrap()
7173			.unwrap()
7174			.join("\n");
7175
7176		// Assert: `quote_identifier` is `pg_escape::quote_identifier`, which only
7177		// adds quotes when the identifier contains reserved or non-lowercase
7178		// characters. For plain ASCII names, the output is unquoted. What matters
7179		// for regression is the presence of the `ON <table>` suffix.
7180		assert_eq!(
7181			sql, "DROP INDEX idx_users_email ON users;",
7182			"MySQL reverse SQL must include `ON <table>` clause"
7183		);
7184	}
7185
7186	/// Verify Postgres / SQLite / CockroachDB continue to emit the bare
7187	/// `DROP INDEX <name>;` form without an `ON <table>` clause.
7188	#[rstest]
7189	#[case(SqlDialect::Postgres, "DROP INDEX idx_users_email;")]
7190	#[case(SqlDialect::Sqlite, "DROP INDEX idx_users_email;")]
7191	#[case(SqlDialect::Cockroachdb, "DROP INDEX idx_users_email;")]
7192	fn test_to_reverse_sql_create_index_omits_on_table_for_non_mysql(
7193		#[case] dialect: SqlDialect,
7194		#[case] expected: &str,
7195	) {
7196		// Arrange
7197		let op = Operation::CreateIndex {
7198			table: "users".to_string(),
7199			columns: vec!["email".to_string()],
7200			unique: false,
7201			index_type: None,
7202			where_clause: None,
7203			concurrently: false,
7204			expressions: None,
7205			mysql_options: None,
7206			operator_class: None,
7207		};
7208		let state = ProjectState::default();
7209
7210		// Act
7211		let sql = op
7212			.to_reverse_sql(&dialect, &state)
7213			.unwrap()
7214			.unwrap()
7215			.join("\n");
7216
7217		// Assert
7218		assert_eq!(
7219			sql, expected,
7220			"Non-MySQL reverse SQL must remain unchanged for dialect {:?}",
7221			dialect
7222		);
7223	}
7224
7225	#[rstest]
7226	fn test_to_reverse_sql_add_constraint_quotes_identifiers() {
7227		// Arrange: table name with special characters triggers quoting
7228		let op = Operation::AddConstraint {
7229			table: "my-table".to_string(),
7230			constraint_sql: "CONSTRAINT chk_positive CHECK (x > 0)".to_string(),
7231		};
7232		let state = ProjectState::default();
7233
7234		// Act
7235		let sql = op
7236			.to_reverse_sql(&SqlDialect::Postgres, &state)
7237			.unwrap()
7238			.unwrap()
7239			.join("\n");
7240
7241		// Assert
7242		assert!(
7243			sql.contains("ALTER TABLE \"my-table\""),
7244			"Table name with special characters must be quoted, got: {}",
7245			sql
7246		);
7247		assert!(
7248			sql.contains("DROP CONSTRAINT"),
7249			"Should contain DROP CONSTRAINT, got: {}",
7250			sql
7251		);
7252	}
7253
7254	#[rstest]
7255	fn test_to_reverse_sql_bulk_load_quotes_identifiers() {
7256		// Arrange
7257		let op = Operation::BulkLoad {
7258			table: "user-data".to_string(),
7259			source: BulkLoadSource::Stdin,
7260			format: BulkLoadFormat::default(),
7261			options: BulkLoadOptions::default(),
7262		};
7263		let state = ProjectState::default();
7264
7265		// Act
7266		let sql = op
7267			.to_reverse_sql(&SqlDialect::Postgres, &state)
7268			.unwrap()
7269			.unwrap()
7270			.join("\n");
7271
7272		// Assert
7273		assert_eq!(
7274			sql, "TRUNCATE TABLE \"user-data\";",
7275			"Table name must be quoted"
7276		);
7277	}
7278
7279	// ========================================================================
7280	// SetAutoIncrementValue — per-backend SQL rendering
7281	// ========================================================================
7282
7283	#[rstest]
7284	#[case::postgres(SqlDialect::Postgres)]
7285	#[case::cockroachdb(SqlDialect::Cockroachdb)]
7286	fn test_set_auto_increment_postgres_uses_setval(#[case] dialect: SqlDialect) {
7287		// Arrange
7288		let op = Operation::SetAutoIncrementValue {
7289			table: "users".to_string(),
7290			column: "id".to_string(),
7291			value: 1000,
7292		};
7293
7294		// Act
7295		let sql = op.to_sql(&dialect);
7296
7297		// Assert
7298		assert_eq!(
7299			sql,
7300			"SELECT setval(pg_get_serial_sequence('users', 'id'), 1000, false);"
7301		);
7302	}
7303
7304	#[test]
7305	fn test_set_auto_increment_mysql_alters_table() {
7306		// Arrange
7307		let op = Operation::SetAutoIncrementValue {
7308			table: "users".to_string(),
7309			column: "id".to_string(),
7310			value: 1000,
7311		};
7312
7313		// Act
7314		let sql = op.to_sql(&SqlDialect::Mysql);
7315
7316		// Assert: identifier quoting uses pg_escape's `quote_identifier`
7317		// uniformly across dialects (matches convention used elsewhere in
7318		// this module, e.g. AlterTableComment). pg_escape omits quotes when
7319		// the identifier needs no escaping.
7320		assert_eq!(sql, "ALTER TABLE users AUTO_INCREMENT = 1000;");
7321	}
7322
7323	#[test]
7324	fn test_set_auto_increment_sqlite_upserts_sqlite_sequence() {
7325		// Arrange
7326		let op = Operation::SetAutoIncrementValue {
7327			table: "users".to_string(),
7328			column: "id".to_string(),
7329			value: 1000,
7330		};
7331
7332		// Act
7333		let sql = op.to_sql(&SqlDialect::Sqlite);
7334
7335		// Assert: INSERT OR REPLACE is robust vs. UPDATE which no-ops when
7336		// the sqlite_sequence row does not yet exist.
7337		assert_eq!(
7338			sql,
7339			"INSERT OR REPLACE INTO sqlite_sequence(name, seq) VALUES ('users', 1000);"
7340		);
7341	}
7342
7343	#[test]
7344	fn test_set_auto_increment_postgres_escapes_literals() {
7345		// Arrange: embedded single quote must be doubled to avoid injection.
7346		let op = Operation::SetAutoIncrementValue {
7347			table: "user's".to_string(),
7348			column: "id".to_string(),
7349			value: 42,
7350		};
7351
7352		// Act
7353		let sql = op.to_sql(&SqlDialect::Postgres);
7354
7355		// Assert
7356		assert!(
7357			sql.contains("'user''s'"),
7358			"single quote in table name must be escaped: {}",
7359			sql
7360		);
7361	}
7362
7363	// ========================================================================
7364	// CreateCompositePrimaryKey — SQL rendering and edge cases
7365	// ========================================================================
7366
7367	#[rstest]
7368	#[case::postgres(SqlDialect::Postgres)]
7369	#[case::mysql(SqlDialect::Mysql)]
7370	#[case::sqlite(SqlDialect::Sqlite)]
7371	#[case::cockroachdb(SqlDialect::Cockroachdb)]
7372	fn test_composite_pk_default_name(#[case] dialect: SqlDialect) {
7373		// Arrange
7374		let op = Operation::CreateCompositePrimaryKey {
7375			table: "order_items".to_string(),
7376			columns: vec!["order_id".to_string(), "line_number".to_string()],
7377			constraint_name: None,
7378		};
7379
7380		// Act
7381		let sql = op.to_sql(&dialect);
7382
7383		// Assert
7384		assert!(
7385			sql.contains("ALTER TABLE"),
7386			"SQL should use ALTER TABLE: {}",
7387			sql
7388		);
7389		assert!(
7390			sql.contains("ADD CONSTRAINT"),
7391			"SQL should add a named constraint: {}",
7392			sql
7393		);
7394		assert!(
7395			sql.contains("PRIMARY KEY"),
7396			"SQL should add PRIMARY KEY: {}",
7397			sql
7398		);
7399		assert!(
7400			sql.contains("order_items_pkey"),
7401			"Default constraint name should be table_pkey: {}",
7402			sql
7403		);
7404		assert!(
7405			sql.contains("order_id") && sql.contains("line_number"),
7406			"Both PK columns must appear: {}",
7407			sql
7408		);
7409	}
7410
7411	#[test]
7412	fn test_composite_pk_custom_name_and_quoting() {
7413		// Arrange
7414		let op = Operation::CreateCompositePrimaryKey {
7415			table: "tbl".to_string(),
7416			columns: vec!["a".to_string(), "b".to_string()],
7417			constraint_name: Some("my_pk".to_string()),
7418		};
7419
7420		// Act
7421		let sql = op.to_sql(&SqlDialect::Postgres);
7422
7423		// Assert: pg_escape omits quotes for identifiers that need no escaping.
7424		assert_eq!(
7425			sql,
7426			"ALTER TABLE tbl ADD CONSTRAINT my_pk PRIMARY KEY (a, b);"
7427		);
7428	}
7429
7430	#[test]
7431	fn test_composite_pk_empty_columns_produces_failing_sql() {
7432		// Arrange: empty column list is invalid; we emit a deliberately
7433		// invalid SQL statement (a bare identifier) so every backend's
7434		// parser rejects it before execution, replacing the earlier
7435		// `SELECT 1/0` fallback that silently passed on SQLite and
7436		// lax-mode MySQL (reinhardt-web#4325).
7437		let op = Operation::CreateCompositePrimaryKey {
7438			table: "tbl".to_string(),
7439			columns: vec![],
7440			constraint_name: None,
7441		};
7442
7443		// Act: verify behavior on every supported dialect.
7444		for dialect in [SqlDialect::Postgres, SqlDialect::Mysql, SqlDialect::Sqlite] {
7445			let sql = op.to_sql(&dialect);
7446
7447			// Assert: the emitted statement encodes the diagnostic and is
7448			// not a syntactically valid SELECT/DDL on any backend.
7449			assert!(
7450				sql.starts_with("SYNTAX_ERROR_create_composite_pk_on_")
7451					&& sql.contains("requires_at_least_one_column"),
7452				"Empty column list must emit a syntax-error statement with diagnostic ({:?}): {}",
7453				dialect,
7454				sql
7455			);
7456			assert!(
7457				!sql.contains("SELECT 1/0"),
7458				"Must not fall back to SELECT 1/0 (silently passes on SQLite / lax MySQL): {}",
7459				sql
7460			);
7461		}
7462	}
7463
7464	// ========================================================================
7465	// column_to_sql — SQLite AUTOINCREMENT type widening (Issue #4184)
7466	//
7467	// SQLite rejects `BIGINT PRIMARY KEY AUTOINCREMENT` at apply time with:
7468	//   "AUTOINCREMENT is only allowed on an INTEGER PRIMARY KEY"
7469	// The default `BigAutoField` from CoreSettings produces FieldType::BigInteger
7470	// + auto_increment, so the SQLite emitter must widen integer widths to the
7471	// literal `INTEGER` token.
7472	// ========================================================================
7473
7474	#[rstest]
7475	#[case::big_integer(FieldType::BigInteger)]
7476	#[case::integer(FieldType::Integer)]
7477	#[case::small_integer(FieldType::SmallInteger)]
7478	fn test_column_to_sql_sqlite_auto_increment_pk_emits_integer(#[case] field_type: FieldType) {
7479		// Arrange: BigAutoField/AutoField/SmallAutoField PK with auto_increment.
7480		let mut col = ColumnDefinition::new("id", field_type);
7481		col.primary_key = true;
7482		col.auto_increment = true;
7483		col.not_null = true;
7484
7485		// Act
7486		let sql = Operation::column_to_sql(&col, &SqlDialect::Sqlite);
7487
7488		// Assert: must use the literal `INTEGER` token (not BIGINT/SMALLINT)
7489		// to satisfy SQLite's AUTOINCREMENT constraint.
7490		assert!(
7491			sql.contains("INTEGER PRIMARY KEY AUTOINCREMENT"),
7492			"SQLite auto_increment PK must emit `INTEGER PRIMARY KEY AUTOINCREMENT`: {}",
7493			sql
7494		);
7495		assert!(
7496			!sql.contains("BIGINT"),
7497			"SQLite auto_increment must not emit BIGINT (rejected by SQLite): {}",
7498			sql
7499		);
7500		assert!(
7501			!sql.contains("SMALLINT"),
7502			"SQLite auto_increment must not emit SMALLINT (rejected by SQLite): {}",
7503			sql
7504		);
7505	}
7506
7507	#[test]
7508	fn test_column_to_sql_sqlite_big_integer_without_auto_increment_no_autoincrement() {
7509		// Arrange: plain BigInteger column without auto_increment must not emit
7510		// the AUTOINCREMENT keyword. SQLite represents all integer widths as
7511		// INTEGER (storage class), so emitting INTEGER (per to_sql_for_dialect)
7512		// is correct even without auto_increment.
7513		let mut col = ColumnDefinition::new("count", FieldType::BigInteger);
7514		col.not_null = true;
7515
7516		// Act
7517		let sql = Operation::column_to_sql(&col, &SqlDialect::Sqlite);
7518
7519		// Assert
7520		assert!(
7521			!sql.contains("AUTOINCREMENT"),
7522			"Non-auto_increment column must not emit AUTOINCREMENT: {}",
7523			sql
7524		);
7525		// SQLite accepts `BIGINT` declarations via type affinity, but our emitter
7526		// normalizes integer widths to `INTEGER` for consistency with the
7527		// auto_increment path. This assertion guards that normalization, not a
7528		// SQLite-level prohibition on BIGINT.
7529		assert!(
7530			!sql.contains("BIGINT"),
7531			"emitter is expected to normalize BigInteger to INTEGER for SQLite: {}",
7532			sql
7533		);
7534	}
7535
7536	#[test]
7537	fn test_column_to_sql_postgres_big_integer_auto_increment_unchanged() {
7538		// Arrange: regression guard — Postgres path must remain GENERATED AS IDENTITY.
7539		let mut col = ColumnDefinition::new("id", FieldType::BigInteger);
7540		col.primary_key = true;
7541		col.auto_increment = true;
7542		col.not_null = true;
7543
7544		// Act
7545		let sql = Operation::column_to_sql(&col, &SqlDialect::Postgres);
7546
7547		// Assert
7548		assert!(
7549			sql.contains("BIGINT GENERATED BY DEFAULT AS IDENTITY"),
7550			"Postgres auto_increment BigInteger must emit identity syntax: {}",
7551			sql
7552		);
7553	}
7554
7555	#[test]
7556	fn test_column_to_sql_sqlite_auto_increment_uuid_pk_omits_autoincrement() {
7557		// Arrange: a UUID primary key with auto_increment=true. The `#[model]`
7558		// macro previously emitted `auto_increment="true"` for every PK
7559		// regardless of type, which produced `"id" UUID PRIMARY KEY AUTOINCREMENT`
7560		// — rejected by SQLite with "AUTOINCREMENT is only allowed on an
7561		// INTEGER PRIMARY KEY". The emitter must defend against this combination
7562		// by omitting AUTOINCREMENT when the column type was not widened to
7563		// INTEGER. See reinhardt-web#4378.
7564		let mut col = ColumnDefinition::new("id", FieldType::Uuid);
7565		col.primary_key = true;
7566		col.auto_increment = true;
7567		col.not_null = true;
7568
7569		// Act
7570		let sql = Operation::column_to_sql(&col, &SqlDialect::Sqlite);
7571
7572		// Assert: PRIMARY KEY must be emitted, AUTOINCREMENT must NOT.
7573		assert!(
7574			sql.contains("PRIMARY KEY"),
7575			"UUID PK must still emit PRIMARY KEY: {}",
7576			sql
7577		);
7578		assert!(
7579			!sql.contains("AUTOINCREMENT"),
7580			"non-integer auto_increment PK must not emit AUTOINCREMENT (SQLite rejects it): {}",
7581			sql
7582		);
7583		// SQLite's `to_sql_for_dialect(Uuid)` returns `TEXT` (the storage class
7584		// SQLite actually uses for UUIDs); the important guarantee is that the
7585		// type was NOT silently widened to `INTEGER`, which would change the
7586		// column's semantics.
7587		assert!(
7588			!sql.contains("INTEGER"),
7589			"UUID column type must not be widened to INTEGER: {}",
7590			sql
7591		);
7592	}
7593
7594	#[test]
7595	fn test_column_to_sql_without_pk_sqlite_auto_increment_emits_integer() {
7596		// Arrange: composite PK path also widens to INTEGER for SQLite.
7597		let mut col = ColumnDefinition::new("id", FieldType::BigInteger);
7598		col.auto_increment = true;
7599		col.not_null = true;
7600
7601		// Act
7602		let sql = Operation::column_to_sql_without_pk(&col, &SqlDialect::Sqlite);
7603
7604		// Assert
7605		assert!(
7606			sql.contains("INTEGER"),
7607			"SQLite auto_increment column (composite PK path) must emit INTEGER: {}",
7608			sql
7609		);
7610		assert!(
7611			!sql.contains("BIGINT"),
7612			"SQLite auto_increment must not emit BIGINT in composite PK path: {}",
7613			sql
7614		);
7615	}
7616
7617	mod resolve_foreign_key_column_type_tests {
7618		use super::super::resolve_foreign_key_column_type_with;
7619		use super::FieldType;
7620		use crate::migrations::autodetector::FieldState;
7621		use crate::migrations::model_registry::{FieldMetadata, ModelMetadata, ModelRegistry};
7622
7623		/// Helper: build a target model registered under `(app, name)`
7624		/// whose PK column is of `pk_type`.
7625		fn target_model(app: &str, name: &str, table: &str, pk_type: FieldType) -> ModelMetadata {
7626			let mut meta = ModelMetadata::new(app, name, table);
7627			meta.add_field(
7628				"id".to_string(),
7629				FieldMetadata::new(pk_type).with_param("primary_key", "true"),
7630			);
7631			meta
7632		}
7633
7634		/// Helper: build a `ForeignKeyField`-style FieldState whose
7635		/// `fk_target` (and optionally `fk_target_app`) drive the
7636		/// resolver.
7637		fn fk_field_state(target_model: &str, target_app: Option<&str>) -> FieldState {
7638			let mut fs = FieldState::new("owner_id", FieldType::Uuid, false);
7639			fs.params
7640				.insert("fk_target".to_string(), target_model.to_string());
7641			if let Some(app) = target_app {
7642				fs.params
7643					.insert("fk_target_app".to_string(), app.to_string());
7644			}
7645			fs
7646		}
7647
7648		#[test]
7649		fn qualified_hit_resolves_to_target_pk_type() {
7650			// Arrange
7651			let registry = ModelRegistry::new();
7652			registry.register_model(target_model(
7653				"auth",
7654				"User",
7655				"auth_user",
7656				FieldType::BigInteger,
7657			));
7658			let fs = fk_field_state("User", Some("auth"));
7659
7660			// Act
7661			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7662
7663			// Assert: qualified lookup hits and returns the target's PK type.
7664			assert_eq!(resolved, Some(FieldType::BigInteger));
7665		}
7666
7667		#[test]
7668		fn qualified_miss_falls_back_to_by_name_when_unambiguous() {
7669			// Arrange: target registered under a different app than the
7670			// macro emitted (simulates the `use`-import edge case).
7671			let registry = ModelRegistry::new();
7672			registry.register_model(target_model(
7673				"reinhardt_auth",
7674				"User",
7675				"auth_user",
7676				FieldType::Uuid,
7677			));
7678			// Macro emitted the current crate's app, which is wrong here.
7679			let fs = fk_field_state("User", Some("blog"));
7680
7681			// Act
7682			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7683
7684			// Assert: by-name fallback resolves to the only registered
7685			// `User` model, preserving the pre-#4436 resolution path.
7686			assert_eq!(resolved, Some(FieldType::Uuid));
7687		}
7688
7689		#[test]
7690		fn ambiguous_by_name_returns_none() {
7691			// Arrange: two apps register the same model name.
7692			let registry = ModelRegistry::new();
7693			registry.register_model(target_model(
7694				"auth",
7695				"User",
7696				"auth_user",
7697				FieldType::BigInteger,
7698			));
7699			registry.register_model(target_model(
7700				"billing",
7701				"User",
7702				"billing_user",
7703				FieldType::Uuid,
7704			));
7705			// No `fk_target_app` -> straight to by-name lookup.
7706			let fs = fk_field_state("User", None);
7707
7708			// Act
7709			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7710
7711			// Assert: conservative `None` rather than silently picking
7712			// one of the two `User` models.
7713			assert_eq!(resolved, None);
7714		}
7715
7716		#[test]
7717		fn path_typed_disambiguates_ambiguous_name() {
7718			// Arrange: two apps register `User`. The user wrote a
7719			// path-typed FK target (`ForeignKeyField<reinhardt_auth::User>`),
7720			// so the macro emits `fk_target_app="reinhardt_auth"` —
7721			// trusted as a user-explicit qualifier. The resolver must
7722			// use it to pick the correct `User`, not the unrelated
7723			// `blog.User`.
7724			let registry = ModelRegistry::new();
7725			registry.register_model(target_model(
7726				"blog",
7727				"User",
7728				"blog_user",
7729				FieldType::BigInteger,
7730			));
7731			registry.register_model(target_model(
7732				"reinhardt_auth",
7733				"User",
7734				"reinhardt_auth_user",
7735				FieldType::Uuid,
7736			));
7737			let fs = fk_field_state("User", Some("reinhardt_auth"));
7738
7739			// Act
7740			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7741
7742			// Assert: qualified hit picks `reinhardt_auth.User`
7743			// (FieldType::Uuid), not `blog.User` (FieldType::BigInteger).
7744			assert_eq!(resolved, Some(FieldType::Uuid));
7745		}
7746
7747		#[test]
7748		fn qualified_miss_with_ambiguous_by_name_returns_none() {
7749			// Arrange: qualified lookup misses AND the by-name fallback
7750			// is itself ambiguous. The resolver must still refuse to
7751			// guess.
7752			let registry = ModelRegistry::new();
7753			registry.register_model(target_model(
7754				"auth",
7755				"User",
7756				"auth_user",
7757				FieldType::BigInteger,
7758			));
7759			registry.register_model(target_model(
7760				"billing",
7761				"User",
7762				"billing_user",
7763				FieldType::Uuid,
7764			));
7765			let fs = fk_field_state("User", Some("blog")); // misses; falls back; ambiguous.
7766
7767			// Act
7768			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7769
7770			// Assert
7771			assert_eq!(resolved, None);
7772		}
7773
7774		#[test]
7775		fn no_fk_target_param_returns_none() {
7776			// Arrange: a non-FK field has no `fk_target` param.
7777			let registry = ModelRegistry::new();
7778			registry.register_model(target_model(
7779				"auth",
7780				"User",
7781				"auth_user",
7782				FieldType::BigInteger,
7783			));
7784			let fs = FieldState::new("name", FieldType::VarChar(64), false);
7785
7786			// Act
7787			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7788
7789			// Assert
7790			assert_eq!(resolved, None);
7791		}
7792	}
7793}