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