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				old_definition,
1647				new_definition,
1648				mysql_options,
1649				..
1650			} => {
1651				let sql_type = new_definition.type_definition.to_sql_for_dialect(dialect);
1652				match dialect {
1653					SqlDialect::Postgres | SqlDialect::Cockroachdb => {
1654						let mut statements = Vec::new();
1655						if old_definition
1656							.as_ref()
1657							.is_some_and(|old_definition| old_definition.default.is_some())
1658						{
1659							statements.push(format!(
1660								"ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
1661								quote_identifier(table),
1662								quote_identifier(column)
1663							));
1664						}
1665						statements.push(format!(
1666							"ALTER TABLE {} ALTER COLUMN {} TYPE {};",
1667							quote_identifier(table),
1668							quote_identifier(column),
1669							sql_type
1670						));
1671						if let Some(default) = &new_definition.default {
1672							statements.push(format!(
1673								"ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
1674								quote_identifier(table),
1675								quote_identifier(column),
1676								default
1677							));
1678						}
1679						statements.join(" ")
1680					}
1681					SqlDialect::Mysql => {
1682						let base_sql = format!(
1683							"ALTER TABLE {} MODIFY COLUMN {}",
1684							quote_identifier(table),
1685							Self::column_to_sql(new_definition, dialect)
1686						);
1687
1688						// MySQL: Add ALGORITHM/LOCK options
1689						if let Some(opts) = mysql_options {
1690							let suffix = opts.to_sql_suffix();
1691							if !suffix.is_empty() {
1692								return format!("{}{};", base_sql, suffix);
1693							}
1694						}
1695
1696						format!("{};", base_sql)
1697					}
1698					SqlDialect::Sqlite => {
1699						format!(
1700							"-- SQLite does not support ALTER COLUMN, table recreation required for {}",
1701							quote_identifier(table)
1702						)
1703					}
1704				}
1705			}
1706			Operation::RenameColumn {
1707				table,
1708				old_name,
1709				new_name,
1710			} => {
1711				format!(
1712					"ALTER TABLE {} RENAME COLUMN {} TO {};",
1713					quote_identifier(table),
1714					quote_identifier(old_name),
1715					quote_identifier(new_name)
1716				)
1717			}
1718			Operation::RenameTable { old_name, new_name } => {
1719				format!(
1720					"ALTER TABLE {} RENAME TO {};",
1721					quote_identifier(old_name),
1722					quote_identifier(new_name)
1723				)
1724			}
1725			Operation::AddConstraint {
1726				table,
1727				constraint_sql,
1728			} => {
1729				format!(
1730					"ALTER TABLE {} ADD {};",
1731					quote_identifier(table),
1732					constraint_sql
1733				)
1734			}
1735			Operation::DropConstraint {
1736				table,
1737				constraint_name,
1738			} => {
1739				format!(
1740					"ALTER TABLE {} DROP CONSTRAINT {};",
1741					quote_identifier(table),
1742					quote_identifier(constraint_name)
1743				)
1744			}
1745			Operation::CreateIndex {
1746				table,
1747				columns,
1748				unique,
1749				index_type,
1750				where_clause,
1751				concurrently,
1752				expressions,
1753				mysql_options,
1754				operator_class,
1755			} => {
1756				let unique_str = if *unique { "UNIQUE " } else { "" };
1757
1758				// PostgreSQL: CONCURRENTLY keyword (must come before UNIQUE)
1759				let concurrent_str = if *concurrently && matches!(dialect, SqlDialect::Postgres) {
1760					"CONCURRENTLY "
1761				} else {
1762					""
1763				};
1764
1765				// MySQL: FULLTEXT/SPATIAL prefix (replaces UNIQUE for these types)
1766				let (mysql_prefix, effective_unique) = match (index_type, dialect) {
1767					(Some(IndexType::Fulltext), SqlDialect::Mysql) => ("FULLTEXT ", ""),
1768					(Some(IndexType::Spatial), SqlDialect::Mysql) => ("SPATIAL ", ""),
1769					_ => ("", unique_str),
1770				};
1771
1772				// Determine what to index: expressions or columns
1773				let (index_content, name_suffix) =
1774					if let Some(exprs) = expressions.as_ref().filter(|e| !e.is_empty()) {
1775						// For expression indexes, use expressions and generate a hash-based suffix
1776						// Expressions are assumed to be properly formatted, no additional quoting needed
1777						let content = exprs.join(", ");
1778						let suffix = "expr";
1779						(content, suffix.to_string())
1780					} else {
1781						// Use columns with optional operator class
1782						let content = if let Some(op_class) = operator_class {
1783							// Apply operator class to each column (PostgreSQL-specific)
1784							if matches!(dialect, SqlDialect::Postgres) {
1785								columns
1786									.iter()
1787									.map(|c| format!("{} {}", quote_identifier(c), op_class))
1788									.collect::<Vec<_>>()
1789									.join(", ")
1790							} else {
1791								// Quote column names for safety (reserved words, special chars)
1792								columns
1793									.iter()
1794									.map(|c| quote_identifier(c).to_string())
1795									.collect::<Vec<_>>()
1796									.join(", ")
1797							}
1798						} else {
1799							// Quote column names for safety (reserved words, special chars)
1800							columns
1801								.iter()
1802								.map(|c| quote_identifier(c).to_string())
1803								.collect::<Vec<_>>()
1804								.join(", ")
1805						};
1806						(content, columns.join("_"))
1807					};
1808
1809				let idx_name = format!("idx_{}_{}", table, name_suffix);
1810
1811				// Index type clause (USING type) - PostgreSQL, CockroachDB
1812				let using_clause = match (index_type, dialect) {
1813					(Some(IndexType::BTree), _) => String::new(), // Default, no need to specify
1814					(Some(idx_type), SqlDialect::Postgres | SqlDialect::Cockroachdb) => {
1815						format!(" USING {}", idx_type)
1816					}
1817					// MySQL FULLTEXT/SPATIAL handled via prefix, not USING
1818					(Some(IndexType::Fulltext | IndexType::Spatial), SqlDialect::Mysql) => {
1819						String::new()
1820					}
1821					_ => String::new(),
1822				};
1823
1824				// Build base SQL with correct syntax per dialect
1825				// PostgreSQL: CREATE [UNIQUE] INDEX [CONCURRENTLY] name [USING type] ON table (cols)
1826				// MySQL: CREATE [FULLTEXT|SPATIAL|UNIQUE] INDEX name ON table (cols)
1827				// SQLite: CREATE [UNIQUE] INDEX name ON table (cols)
1828				let mut sql = match dialect {
1829					SqlDialect::Postgres | SqlDialect::Cockroachdb => {
1830						// CONCURRENTLY goes between INDEX and index_name
1831						format!(
1832							"CREATE {}INDEX {}{}",
1833							effective_unique,
1834							concurrent_str,
1835							quote_identifier(&idx_name)
1836						)
1837					}
1838					SqlDialect::Mysql => {
1839						// MySQL doesn't support CONCURRENTLY or USING (except for FULLTEXT/SPATIAL prefix)
1840						format!(
1841							"CREATE {}{}INDEX {}",
1842							mysql_prefix,
1843							effective_unique,
1844							quote_identifier(&idx_name)
1845						)
1846					}
1847					SqlDialect::Sqlite => {
1848						// SQLite doesn't support CONCURRENTLY or USING
1849						format!(
1850							"CREATE {}INDEX {}",
1851							effective_unique,
1852							quote_identifier(&idx_name)
1853						)
1854					}
1855				};
1856				// PostgreSQL: ON table USING method (columns)
1857				// MySQL/SQLite: ON table (columns)
1858				// Quote table name for safety (reserved words, special chars)
1859				sql.push_str(&format!(
1860					" ON {}{} ({})",
1861					quote_identifier(table),
1862					using_clause,
1863					index_content
1864				));
1865
1866				// Add WHERE clause for partial indexes (PostgreSQL, SQLite, CockroachDB - not MySQL)
1867				if let Some(where_cond) = where_clause
1868					&& !matches!(dialect, SqlDialect::Mysql)
1869				{
1870					sql.push_str(&format!(" WHERE {}", where_cond));
1871				}
1872
1873				// MySQL: Add ALGORITHM/LOCK options
1874				if matches!(dialect, SqlDialect::Mysql)
1875					&& let Some(opts) = mysql_options
1876				{
1877					let suffix = opts.to_sql_suffix();
1878					if !suffix.is_empty() {
1879						sql.push_str(&suffix);
1880					}
1881				}
1882
1883				sql.push(';');
1884				sql
1885			}
1886			Operation::DropIndex { table, columns } => {
1887				let idx_name = format!("idx_{}_{}", table, columns.join("_"));
1888				match dialect {
1889					SqlDialect::Mysql => {
1890						format!(
1891							"DROP INDEX {} ON {};",
1892							quote_identifier(&idx_name),
1893							quote_identifier(table)
1894						)
1895					}
1896					SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
1897						format!("DROP INDEX {};", quote_identifier(&idx_name))
1898					}
1899				}
1900			}
1901			Operation::RunSQL { sql, .. } => sql.to_string(),
1902			Operation::RunRust { code, .. } => {
1903				// For SQL generation, RunRust is a no-op comment
1904				format!("-- RunRust: {}", code.lines().next().unwrap_or(""))
1905			}
1906			Operation::AlterTableComment { table, comment } => match dialect {
1907				SqlDialect::Postgres | SqlDialect::Cockroachdb => {
1908					if let Some(comment_text) = comment {
1909						format!(
1910							"COMMENT ON TABLE {} IS '{}';",
1911							quote_identifier(table),
1912							comment_text
1913						)
1914					} else {
1915						format!("COMMENT ON TABLE {} IS NULL;", quote_identifier(table))
1916					}
1917				}
1918				SqlDialect::Mysql => {
1919					if let Some(comment_text) = comment {
1920						format!(
1921							"ALTER TABLE {} COMMENT='{}';",
1922							quote_identifier(table),
1923							comment_text
1924						)
1925					} else {
1926						format!("ALTER TABLE {} COMMENT='';", quote_identifier(table))
1927					}
1928				}
1929				SqlDialect::Sqlite => String::new(),
1930			},
1931			Operation::AlterUniqueTogether {
1932				table,
1933				unique_together,
1934			} => {
1935				let mut sql = Vec::new();
1936				for (idx, fields) in unique_together.iter().enumerate() {
1937					let constraint_name = format!("{}_{}_uniq", table, idx);
1938					let fields_str = fields
1939						.iter()
1940						.map(|f| quote_identifier(f))
1941						.collect::<Vec<_>>()
1942						.join(", ");
1943					sql.push(format!(
1944						"ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({});",
1945						quote_identifier(table),
1946						quote_identifier(&constraint_name),
1947						fields_str
1948					));
1949				}
1950				sql.join("\n")
1951			}
1952			Operation::AlterModelOptions { .. } => String::new(),
1953			Operation::CreateInheritedTable {
1954				name,
1955				columns,
1956				base_table,
1957				join_column,
1958			} => {
1959				let mut parts = Vec::new();
1960				parts.push(format!(
1961					"  {} INTEGER REFERENCES {}(id)",
1962					quote_identifier(join_column),
1963					quote_identifier(base_table)
1964				));
1965				for col in columns {
1966					parts.push(format!("  {}", Self::column_to_sql(col, dialect)));
1967				}
1968				format!(
1969					"CREATE TABLE {} (\n{}\n);",
1970					quote_identifier(name),
1971					parts.join(",\n")
1972				)
1973			}
1974			Operation::AddDiscriminatorColumn {
1975				table,
1976				column_name,
1977				default_value,
1978			} => {
1979				format!(
1980					"ALTER TABLE {} ADD COLUMN {} VARCHAR(50) DEFAULT '{}';",
1981					quote_identifier(table),
1982					quote_identifier(column_name),
1983					default_value
1984				)
1985			}
1986			Operation::MoveModel {
1987				rename_table,
1988				old_table_name,
1989				new_table_name,
1990				..
1991			} => {
1992				// MoveModel generates a RenameTable SQL if table name changes
1993				// Otherwise it's a state-only operation (no SQL needed)
1994				if *rename_table {
1995					if let (Some(old_name), Some(new_name)) = (old_table_name, new_table_name) {
1996						match dialect {
1997							SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
1998								format!(
1999									"ALTER TABLE {} RENAME TO {};",
2000									quote_identifier(old_name),
2001									quote_identifier(new_name)
2002								)
2003							}
2004							SqlDialect::Mysql => {
2005								format!(
2006									"RENAME TABLE {} TO {};",
2007									quote_identifier(old_name),
2008									quote_identifier(new_name)
2009								)
2010							}
2011						}
2012					} else {
2013						"-- MoveModel: No table rename specified".to_string()
2014					}
2015				} else {
2016					// State-only operation, no SQL needed
2017					"-- MoveModel: State-only operation (no table rename)".to_string()
2018				}
2019			}
2020			Operation::CreateSchema {
2021				name,
2022				if_not_exists,
2023			} => {
2024				let if_not_exists_clause = if *if_not_exists { " IF NOT EXISTS" } else { "" };
2025				format!(
2026					"CREATE SCHEMA{} {};",
2027					if_not_exists_clause,
2028					quote_identifier(name)
2029				)
2030			}
2031			Operation::DropSchema {
2032				name,
2033				cascade,
2034				if_exists,
2035			} => {
2036				let if_exists_clause = if *if_exists { " IF EXISTS" } else { "" };
2037				let cascade_clause = if *cascade { " CASCADE" } else { "" };
2038				format!(
2039					"DROP SCHEMA{} {}{};",
2040					if_exists_clause,
2041					quote_identifier(name),
2042					cascade_clause
2043				)
2044			}
2045			Operation::CreateExtension {
2046				name,
2047				if_not_exists,
2048				schema,
2049			} => {
2050				// PostgreSQL-specific
2051				let if_not_exists_clause = if *if_not_exists { " IF NOT EXISTS" } else { "" };
2052				let schema_clause = if let Some(s) = schema {
2053					format!(" SCHEMA {}", quote_identifier(s))
2054				} else {
2055					String::new()
2056				};
2057				format!(
2058					"CREATE EXTENSION{} {}{};",
2059					if_not_exists_clause,
2060					quote_identifier(name),
2061					schema_clause
2062				)
2063			}
2064			Operation::BulkLoad {
2065				table,
2066				source,
2067				format,
2068				options,
2069			} => Self::bulk_load_to_sql(table, source, format, options, dialect),
2070			Operation::SetAutoIncrementValue {
2071				table,
2072				column,
2073				value,
2074			} => Self::set_auto_increment_to_sql(table, column, *value, dialect),
2075			Operation::CreateCompositePrimaryKey {
2076				table,
2077				columns,
2078				constraint_name,
2079			} => Self::create_composite_pk_to_sql(table, columns, constraint_name.as_deref()),
2080		}
2081	}
2082
2083	/// Generate `SetAutoIncrementValue` SQL for each dialect
2084	///
2085	/// PostgreSQL / CockroachDB resolve the backing sequence via
2086	/// `pg_get_serial_sequence(...)` so that both the default
2087	/// `{table}_{column}_seq` naming and user-customized sequences work without
2088	/// the caller having to know the sequence name.
2089	fn set_auto_increment_to_sql(
2090		table: &str,
2091		column: &str,
2092		value: i64,
2093		dialect: &SqlDialect,
2094	) -> String {
2095		match dialect {
2096			SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2097				// pg_get_serial_sequence takes a regclass literal for the table
2098				// and a text literal for the column. `setval(..., value, false)`
2099				// makes the NEXT generated value equal `value`, matching the
2100				// intent of "set the auto-increment to <value>".
2101				format!(
2102					"SELECT setval(pg_get_serial_sequence({}, {}), {}, false);",
2103					quote_literal(table),
2104					quote_literal(column),
2105					value
2106				)
2107			}
2108			SqlDialect::Mysql => {
2109				format!(
2110					"ALTER TABLE {} AUTO_INCREMENT = {};",
2111					quote_identifier(table),
2112					value
2113				)
2114			}
2115			SqlDialect::Sqlite => {
2116				// INSERT OR REPLACE so the statement works whether or not a
2117				// sqlite_sequence row already exists for the table. UPDATE
2118				// would silently no-op on fresh tables that have never had
2119				// a row inserted.
2120				format!(
2121					"INSERT OR REPLACE INTO sqlite_sequence(name, seq) VALUES ({}, {});",
2122					quote_literal(table),
2123					value
2124				)
2125			}
2126		}
2127	}
2128
2129	/// Generate `CreateCompositePrimaryKey` SQL
2130	///
2131	/// Produces `ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY (...)` for
2132	/// every supported backend. Emits guaranteed-fail SQL if the column list
2133	/// is empty so the migration aborts at execution time instead of silently
2134	/// succeeding.
2135	///
2136	/// Workaround for the shared infallible `String` return type used by every
2137	/// `to_sql` arm. Converting the entire pipeline to `Result` would cascade
2138	/// through dozens of call sites, so this arm instead emits a deliberately
2139	/// invalid SQL statement (a bare identifier) that every supported backend's
2140	/// parser rejects before execution. This replaces the earlier `SELECT 1/0`
2141	/// fallback, which silently returned `NULL` on SQLite and lax-mode MySQL
2142	/// (reinhardt-web#4325). The identifier text encodes the diagnostic so it
2143	/// surfaces in the parser error message.
2144	///
2145	/// The long-term fix — migrating the `to_sql` family to `Result` and
2146	/// returning a structured `MigrationError::EmptyCompositePrimaryKey` — is
2147	/// shown in the ideal implementation below.
2148	///
2149	/// Remove this workaround once the `to_sql` family is migrated to a
2150	/// fallible signature.
2151	///
2152	/// Ideal implementation (without workaround):
2153	///   fn create_composite_pk_to_sql(
2154	///       table: &str,
2155	///       columns: &[String],
2156	///       constraint_name: Option<&str>,
2157	///   ) -> Result<String, MigrationError> {
2158	///       if columns.is_empty() {
2159	///           return Err(MigrationError::EmptyCompositePrimaryKey {
2160	///               table: table.to_owned(),
2161	///           });
2162	///       }
2163	///       // ... build the ALTER TABLE statement ...
2164	///   }
2165	fn create_composite_pk_to_sql(
2166		table: &str,
2167		columns: &[String],
2168		constraint_name: Option<&str>,
2169	) -> String {
2170		if columns.is_empty() {
2171			// Deliberately invalid SQL: a bare identifier is not a valid
2172			// statement in PostgreSQL, MySQL, or SQLite grammar, so every
2173			// backend's parser rejects it before execution. This avoids the
2174			// lax-mode MySQL / SQLite silent-pass that the previous
2175			// `SELECT 1/0` fallback was prone to (reinhardt-web#4325). The
2176			// identifier text preserves the diagnostic in the parser error.
2177			return format!(
2178				"SYNTAX_ERROR_create_composite_pk_on_{}_requires_at_least_one_column;",
2179				table.replace(|c: char| !c.is_ascii_alphanumeric(), "_")
2180			);
2181		}
2182
2183		let default_name;
2184		let name: &str = match constraint_name {
2185			Some(n) => n,
2186			None => {
2187				default_name = format!("{}_pkey", table);
2188				&default_name
2189			}
2190		};
2191
2192		let quoted_columns = columns
2193			.iter()
2194			.map(|c| quote_identifier(c).to_string())
2195			.collect::<Vec<_>>()
2196			.join(", ");
2197
2198		format!(
2199			"ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({});",
2200			quote_identifier(table),
2201			quote_identifier(name),
2202			quoted_columns
2203		)
2204	}
2205
2206	/// Generate bulk load SQL for different dialects
2207	fn bulk_load_to_sql(
2208		table: &str,
2209		source: &BulkLoadSource,
2210		format: &BulkLoadFormat,
2211		options: &BulkLoadOptions,
2212		dialect: &SqlDialect,
2213	) -> String {
2214		match dialect {
2215			SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2216				Self::postgres_copy_from_sql(table, source, format, options)
2217			}
2218			SqlDialect::Mysql => Self::mysql_load_data_sql(table, source, format, options),
2219			SqlDialect::Sqlite => {
2220				// SQLite does not support bulk loading natively
2221				format!(
2222					"-- SQLite does not support bulk loading. Use INSERT statements instead for table {}",
2223					quote_identifier(table)
2224				)
2225			}
2226		}
2227	}
2228
2229	/// Generate PostgreSQL COPY FROM SQL
2230	fn postgres_copy_from_sql(
2231		table: &str,
2232		source: &BulkLoadSource,
2233		format: &BulkLoadFormat,
2234		options: &BulkLoadOptions,
2235	) -> String {
2236		let source_clause = match source {
2237			BulkLoadSource::File(path) => format!("'{}'", path),
2238			BulkLoadSource::Stdin => "STDIN".to_string(),
2239			BulkLoadSource::Program(cmd) => format!("PROGRAM '{}'", cmd),
2240		};
2241
2242		let columns_clause = if let Some(cols) = &options.columns {
2243			let quoted_cols = cols
2244				.iter()
2245				.map(|c| quote_identifier(c))
2246				.collect::<Vec<_>>()
2247				.join(", ");
2248			format!(" ({})", quoted_cols)
2249		} else {
2250			String::new()
2251		};
2252
2253		let mut with_options = Vec::new();
2254
2255		// Format
2256		with_options.push(format!("FORMAT {}", format));
2257
2258		// Delimiter
2259		if let Some(delim) = options.delimiter {
2260			with_options.push(format!("DELIMITER '{}'", delim));
2261		}
2262
2263		// NULL string
2264		if let Some(null_str) = &options.null_string {
2265			with_options.push(format!("NULL '{}'", null_str));
2266		}
2267
2268		// Header
2269		if options.header {
2270			with_options.push("HEADER true".to_string());
2271		}
2272
2273		// Quote character
2274		if let Some(quote) = options.quote {
2275			with_options.push(format!("QUOTE '{}'", quote));
2276		}
2277
2278		// Escape character
2279		if let Some(escape) = options.escape {
2280			with_options.push(format!("ESCAPE '{}'", escape));
2281		}
2282
2283		format!(
2284			"COPY {}{} FROM {} WITH ({});",
2285			quote_identifier(table),
2286			columns_clause,
2287			source_clause,
2288			with_options.join(", ")
2289		)
2290	}
2291
2292	/// Generate MySQL LOAD DATA SQL
2293	fn mysql_load_data_sql(
2294		table: &str,
2295		source: &BulkLoadSource,
2296		format: &BulkLoadFormat,
2297		options: &BulkLoadOptions,
2298	) -> String {
2299		let local_clause = if options.local { " LOCAL" } else { "" };
2300
2301		let file_path = match source {
2302			BulkLoadSource::File(path) => path.clone(),
2303			BulkLoadSource::Stdin => {
2304				return format!(
2305					"-- MySQL does not support LOAD DATA from STDIN directly for table {}",
2306					quote_identifier(table)
2307				);
2308			}
2309			BulkLoadSource::Program(_) => {
2310				return format!(
2311					"-- MySQL does not support LOAD DATA from PROGRAM directly for table {}",
2312					quote_identifier(table)
2313				);
2314			}
2315		};
2316
2317		let columns_clause = if let Some(cols) = &options.columns {
2318			let quoted_cols = cols
2319				.iter()
2320				.map(|c| quote_identifier(c))
2321				.collect::<Vec<_>>()
2322				.join(", ");
2323			format!(" ({})", quoted_cols)
2324		} else {
2325			String::new()
2326		};
2327
2328		// Field terminator (delimiter)
2329		let delimiter = options.delimiter.unwrap_or(match format {
2330			BulkLoadFormat::Csv => ',',
2331			BulkLoadFormat::Text | BulkLoadFormat::Binary => '\t',
2332		});
2333
2334		let mut field_options = Vec::new();
2335		field_options.push(format!("TERMINATED BY '{}'", delimiter));
2336
2337		// Quote character for CSV
2338		if *format == BulkLoadFormat::Csv {
2339			let quote = options.quote.unwrap_or('"');
2340			field_options.push(format!("ENCLOSED BY '{}'", quote));
2341		}
2342
2343		// Escape character
2344		if let Some(escape) = options.escape {
2345			field_options.push(format!("ESCAPED BY '{}'", escape));
2346		}
2347
2348		// Line terminator
2349		let line_terminator = options
2350			.line_terminator
2351			.clone()
2352			.unwrap_or_else(|| "\\n".to_string());
2353
2354		// Encoding
2355		let encoding_clause = if let Some(enc) = &options.encoding {
2356			format!(" CHARACTER SET {}", enc)
2357		} else {
2358			String::new()
2359		};
2360
2361		// Header handling (skip first line)
2362		let ignore_clause = if options.header {
2363			" IGNORE 1 LINES"
2364		} else {
2365			""
2366		};
2367
2368		format!(
2369			"LOAD DATA{} INFILE '{}'{} INTO TABLE {} FIELDS {} LINES TERMINATED BY '{}'{}{};",
2370			local_clause,
2371			file_path,
2372			encoding_clause,
2373			quote_identifier(table),
2374			field_options.join(" "),
2375			line_terminator,
2376			ignore_clause,
2377			columns_clause
2378		)
2379	}
2380
2381	/// Generate reverse SQL (for rollback)
2382	///
2383	/// # Arguments
2384	///
2385	/// * `dialect` - SQL dialect for generating database-specific SQL
2386	/// * `project_state` - Project state for accessing model definitions (needed for DropTable, etc.)
2387	///
2388	/// # Returns
2389	///
2390	/// * `Ok(Some(stmts))` - Reverse DDL as one or more SQL statements; each element is a
2391	///   single statement intended to be dispatched separately through
2392	///   `SchemaEditor::execute()` (which is backed by sqlx Extended Query and accepts only
2393	///   one statement per payload). Operations that revert to a single payload (e.g.
2394	///   `DropTable`, `AddColumn`) return a one-element `Vec`; operations that need
2395	///   multiple payloads to round-trip cleanly (e.g. `AlterColumn` on PostgreSQL and
2396	///   CockroachDB, which split type reversion and NOT NULL restoration into two
2397	///   statements) return a multi-element `Vec`.
2398	/// * `Ok(None)` - Operation is not reversible (see Design Limitation below)
2399	/// * `Err(_)` - Error generating reverse SQL
2400	///
2401	/// # Design Limitation
2402	///
2403	/// Destructive operations (`DropTable`, `DropColumn`, `DropConstraint`, `AlterColumn`)
2404	/// require a pre-operation `ProjectState` snapshot to generate reverse SQL. When the
2405	/// `project_state` parameter does not contain the necessary model/column/constraint
2406	/// definition, this method returns `Ok(None)` instead of failing.
2407	///
2408	/// This is an intentional design decision: the migration system cannot reconstruct
2409	/// lost schema information. Callers must provide the state from before the operation
2410	/// was applied to enable proper rollback. This matches Django's migration behavior
2411	/// where `state_forwards` must be called before operations are reversed.
2412	pub fn to_reverse_sql(
2413		&self,
2414		dialect: &SqlDialect,
2415		project_state: &ProjectState,
2416	) -> super::Result<Option<Vec<String>>> {
2417		match self {
2418			Operation::CreateTable { name, .. } => Ok(Some(vec![format!(
2419				"DROP TABLE {};",
2420				quote_identifier(name)
2421			)])),
2422			Operation::AddColumn { table, column, .. } => Ok(Some(vec![format!(
2423				"ALTER TABLE {} DROP COLUMN {};",
2424				quote_identifier(table),
2425				quote_identifier(&column.name)
2426			)])),
2427			Operation::RunSQL { reverse_sql, .. } => {
2428				Ok(reverse_sql.as_ref().map(|s| vec![s.to_string()]))
2429			}
2430			Operation::RunRust { reverse_code, .. } => Ok(reverse_code.as_ref().map(|code| {
2431				vec![format!(
2432					"-- RunRust (reverse): {}",
2433					code.lines().next().unwrap_or("")
2434				)]
2435			})),
2436			// Phase 1: Simple reverse operations
2437			Operation::RenameTable { old_name, new_name } => Ok(Some(vec![format!(
2438				"ALTER TABLE {} RENAME TO {};",
2439				quote_identifier(new_name),
2440				quote_identifier(old_name)
2441			)])),
2442			Operation::RenameColumn {
2443				table,
2444				old_name,
2445				new_name,
2446			} => Ok(Some(vec![format!(
2447				"ALTER TABLE {} RENAME COLUMN {} TO {};",
2448				quote_identifier(table),
2449				quote_identifier(new_name),
2450				quote_identifier(old_name)
2451			)])),
2452			Operation::CreateIndex { table, columns, .. } => {
2453				// Use the same naming convention as to_sql(): idx_{table}_{columns_joined}
2454				// This ensures the rollback DROP INDEX targets the correct index name
2455				let columns_joined = columns.join("_");
2456				let index_name = format!("idx_{}_{}", table, columns_joined);
2457				// MySQL requires `DROP INDEX <name> ON <table>`; PostgreSQL/SQLite/CockroachDB
2458				// only need the index name. Mirror the dialect dispatch used by the forward
2459				// `Operation::DropIndex` SQL generator above.
2460				let sql = match dialect {
2461					SqlDialect::Mysql => format!(
2462						"DROP INDEX {} ON {};",
2463						quote_identifier(&index_name),
2464						quote_identifier(table)
2465					),
2466					SqlDialect::Postgres | SqlDialect::Sqlite | SqlDialect::Cockroachdb => {
2467						format!("DROP INDEX {};", quote_identifier(&index_name))
2468					}
2469				};
2470				Ok(Some(vec![sql]))
2471			}
2472			Operation::AddConstraint {
2473				table,
2474				constraint_sql,
2475			} => {
2476				// Extract constraint name from SQL
2477				// Expects format: "CONSTRAINT <name> ..." or "ADD CONSTRAINT <name> ..."
2478				let constraint_name =
2479					Self::extract_constraint_name(constraint_sql).ok_or_else(|| {
2480						super::MigrationError::InvalidMigration(format!(
2481							"Cannot extract constraint name from: {}",
2482							constraint_sql
2483						))
2484					})?;
2485				Ok(Some(vec![format!(
2486					"ALTER TABLE {} DROP CONSTRAINT {};",
2487					quote_identifier(table),
2488					quote_identifier(&constraint_name)
2489				)]))
2490			}
2491			// Phase 2: Complex reverse operations using ProjectState
2492			Operation::DropColumn { table, column } => {
2493				// Retrieve original column definition from ProjectState
2494				if let Some(model) = project_state.find_model_by_table(table)
2495					&& let Some(field) = model.get_field(column)
2496				{
2497					let col_def = ColumnDefinition::from_field_state(column.clone(), field);
2498					let col_sql = Self::column_to_sql(&col_def, dialect);
2499					return Ok(Some(vec![format!(
2500						"ALTER TABLE {} ADD COLUMN {};",
2501						quote_identifier(table),
2502						col_sql
2503					)]));
2504				}
2505				// Cannot reconstruct without state
2506				Ok(None)
2507			}
2508			Operation::AlterColumn {
2509				table,
2510				column,
2511				old_definition,
2512				new_definition: _,
2513				..
2514			} => {
2515				// Resolve the original column definition to revert to.
2516				// Prioritize the explicit `old_definition` over ProjectState lookup.
2517				let resolved_old_def = old_definition.clone().or_else(|| {
2518					project_state
2519						.find_model_by_table(table)
2520						.and_then(|model| model.get_field(column))
2521						.map(|field| ColumnDefinition::from_field_state(column.clone(), field))
2522				});
2523
2524				let Some(old_def) = resolved_old_def else {
2525					// Cannot reconstruct without state
2526					return Ok(None);
2527				};
2528
2529				let type_sql = old_def.type_definition.to_sql_for_dialect(dialect);
2530				let null_clause = if old_def.not_null { " NOT NULL" } else { "" };
2531
2532				// Dispatch reverse SQL per dialect.
2533				// SQLite is handled via the SQLite-recreation path before reaching
2534				// here (see executor::rollback_migration and
2535				// `Operation::reverse_requires_sqlite_recreation`). The placeholder
2536				// comment below is a defensive fallback: emitting executable
2537				// ALTER COLUMN syntax on SQLite would always error (#4582).
2538				let stmts = match dialect {
2539					SqlDialect::Postgres | SqlDialect::Cockroachdb => {
2540						// Emit type reversion and nullability restoration as two
2541						// independent single-statement payloads. The executor
2542						// (see `MigrationExecutor::rollback_migration`) iterates
2543						// the returned `Vec<String>` and dispatches each through
2544						// `SchemaEditor::execute()` — backed by sqlx Extended
2545						// Query, which accepts only one statement per payload.
2546						//
2547						// This shape is required by CockroachDB, which rejects
2548						// the comma-combined `ALTER TABLE ... ALTER COLUMN c
2549						// TYPE T, ALTER COLUMN c {SET|DROP} NOT NULL` form that
2550						// PostgreSQL accepts. Unifying both dialects on the
2551						// multi-statement path keeps the contract uniform and
2552						// restores NOT NULL rollback fidelity on Cockroach.
2553						// Refs #4630, #4640.
2554						let nullability_clause = if old_def.not_null {
2555							"SET NOT NULL"
2556						} else {
2557							"DROP NOT NULL"
2558						};
2559						vec![
2560							format!(
2561								"ALTER TABLE {table} ALTER COLUMN {column} TYPE {type_sql};",
2562								table = quote_identifier(table),
2563								column = quote_identifier(column),
2564								type_sql = type_sql,
2565							),
2566							format!(
2567								"ALTER TABLE {table} ALTER COLUMN {column} {nullability_clause};",
2568								table = quote_identifier(table),
2569								column = quote_identifier(column),
2570								nullability_clause = nullability_clause,
2571							),
2572						]
2573					}
2574					SqlDialect::Mysql => vec![format!(
2575						"ALTER TABLE {} MODIFY COLUMN {} {}{};",
2576						quote_identifier(table),
2577						quote_identifier(column),
2578						type_sql,
2579						null_clause
2580					)],
2581					SqlDialect::Sqlite => vec![format!(
2582						"-- SQLite does not support ALTER COLUMN, table recreation required for {}",
2583						quote_identifier(table)
2584					)],
2585				};
2586				Ok(Some(stmts))
2587			}
2588			Operation::DropIndex { table, columns } => {
2589				// Enhancement opportunity: Full index reconstruction would preserve
2590				// index_type, where_clause, operator_class, and other advanced properties.
2591				// The current implementation generates a basic CREATE INDEX statement.
2592				let columns_joined = columns.join("_");
2593				let index_name = format!("idx_{}_{}", table, columns_joined);
2594				let columns_list = columns
2595					.iter()
2596					.map(|c| quote_identifier(c).to_string())
2597					.collect::<Vec<_>>()
2598					.join(", ");
2599				Ok(Some(vec![format!(
2600					"CREATE INDEX {} ON {} ({});",
2601					quote_identifier(&index_name),
2602					quote_identifier(table),
2603					columns_list
2604				)]))
2605			}
2606			Operation::DropConstraint {
2607				table,
2608				constraint_name,
2609			} => {
2610				// Retrieve constraint definition from ProjectState
2611				if let Some(model) = project_state.find_model_by_table(table)
2612					&& let Some(constraint_def) = model
2613						.constraints
2614						.iter()
2615						.find(|c| c.name == *constraint_name)
2616				{
2617					let constraint = constraint_def.to_constraint();
2618					return Ok(Some(vec![format!(
2619						"ALTER TABLE {} ADD {};",
2620						quote_identifier(table),
2621						constraint
2622					)]));
2623				}
2624				// Cannot reconstruct without state
2625				Ok(None)
2626			}
2627			Operation::DropTable { name } => {
2628				// Retrieve table definition from ProjectState and reconstruct CREATE TABLE
2629				if let Some(model) = project_state.find_model_by_table(name) {
2630					let mut parts = Vec::new();
2631
2632					// Convert fields to column definitions
2633					for (field_name, field) in &model.fields {
2634						let col_def = ColumnDefinition::from_field_state(field_name.clone(), field);
2635						parts.push(format!("  {}", Self::column_to_sql(&col_def, dialect)));
2636					}
2637
2638					// Add constraints
2639					for constraint_def in &model.constraints {
2640						let constraint = constraint_def.to_constraint();
2641						parts.push(format!("  {}", constraint));
2642					}
2643
2644					return Ok(Some(vec![format!(
2645						"CREATE TABLE {} (\n{}\n);",
2646						quote_identifier(name),
2647						parts.join(",\n")
2648					)]));
2649				}
2650				// Cannot reconstruct without state
2651				Ok(None)
2652			}
2653			Operation::BulkLoad { table, .. } => {
2654				// Reverse of bulk load is to truncate the table (remove loaded data)
2655				// Note: This removes ALL data, not just the data loaded by this operation
2656				Ok(Some(vec![format!(
2657					"TRUNCATE TABLE {};",
2658					quote_identifier(table)
2659				)]))
2660			}
2661			_ => Ok(None),
2662		}
2663	}
2664
2665	/// Apply operation to project state (backward/reverse)
2666	///
2667	/// This method updates the ProjectState to reflect the reverse of this operation.
2668	/// Used during migration rollback to track state changes.
2669	///
2670	/// # Arguments
2671	///
2672	/// * `app_label` - Application label for the model being modified
2673	/// * `state` - Mutable reference to the ProjectState to update
2674	///
2675	/// # Limitations
2676	///
2677	/// Some operations cannot fully reverse state without additional snapshot information:
2678	/// - `DropTable`: Cannot recreate model structure (columns, constraints) without snapshot
2679	/// - `DropColumn`: Cannot recreate column definition without snapshot
2680	/// - `AlterColumn`: Cannot restore original column definition without snapshot
2681	///
2682	/// For these operations, use `to_reverse_sql` with ProjectState before the operation
2683	/// is applied to generate proper reverse SQL.
2684	pub fn state_backwards(&self, app_label: &str, state: &mut ProjectState) {
2685		match self {
2686			Operation::CreateTable { name, .. } => {
2687				// Reverse: Remove the model from state
2688				state
2689					.models
2690					.remove(&(app_label.to_string(), name.to_string()));
2691			}
2692			Operation::DropTable { name: _ } => {
2693				// Cannot reconstruct ModelState without snapshot.
2694				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
2695			}
2696			Operation::RenameTable { old_name, new_name } => {
2697				// Reverse: Rename back from new_name to old_name
2698				if let Some(mut model) = state
2699					.models
2700					.remove(&(app_label.to_string(), new_name.to_string()))
2701				{
2702					model.table_name = old_name.to_string();
2703					state
2704						.models
2705						.insert((app_label.to_string(), old_name.to_string()), model);
2706				}
2707			}
2708			Operation::AddColumn { table, column, .. } => {
2709				// Reverse: Remove the column from the model
2710				if let Some(model) = state.find_model_by_table_mut(table) {
2711					model.remove_field(&column.name);
2712				}
2713			}
2714			Operation::DropColumn {
2715				table: _,
2716				column: _,
2717			} => {
2718				// Cannot reconstruct column definition without snapshot.
2719				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
2720			}
2721			Operation::AlterColumn {
2722				table: _,
2723				column: _,
2724				..
2725			} => {
2726				// Cannot restore original column definition without snapshot.
2727				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
2728			}
2729			Operation::RenameColumn {
2730				table,
2731				old_name,
2732				new_name,
2733			} => {
2734				// Reverse: Rename field back from new_name to old_name
2735				if let Some(model) = state.find_model_by_table_mut(table) {
2736					model.rename_field(new_name, old_name.to_string());
2737				}
2738			}
2739			Operation::AddConstraint { table, .. } => {
2740				// Reverse: Would need to remove the constraint
2741				// This requires parsing constraint_sql to get the name
2742				if let Some(model) = state.find_model_by_table_mut(table) {
2743					// Cannot reliably remove without constraint name extraction
2744					// Constraints vector remains unchanged
2745					let _ = model;
2746				}
2747			}
2748			Operation::DropConstraint {
2749				table: _,
2750				constraint_name: _,
2751			} => {
2752				// Cannot reconstruct constraint definition without snapshot.
2753				// For proper rollback, use to_reverse_sql with pre-operation ProjectState.
2754			}
2755			_ => {
2756				// Other operations don't affect schema state
2757			}
2758		}
2759	}
2760
2761	/// Extract constraint name from constraint SQL
2762	///
2763	/// Supports patterns:
2764	/// - "CONSTRAINT name CHECK ..."
2765	/// - "ADD CONSTRAINT name ..."
2766	fn extract_constraint_name(constraint_sql: &str) -> Option<String> {
2767		let sql = constraint_sql.trim();
2768
2769		// Pattern 1: "CONSTRAINT name ..."
2770		if sql.starts_with("CONSTRAINT ") || sql.contains(" CONSTRAINT ") {
2771			let parts: Vec<&str> = sql.split_whitespace().collect();
2772			if let Some(pos) = parts.iter().position(|&s| s == "CONSTRAINT")
2773				&& pos + 1 < parts.len()
2774			{
2775				return Some(parts[pos + 1].to_string());
2776			}
2777		}
2778
2779		None
2780	}
2781}
2782
2783/// Column definition for legacy operations
2784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2785pub struct ColumnDefinition {
2786	/// The name.
2787	pub name: String,
2788	/// The type definition.
2789	pub type_definition: FieldType,
2790	#[serde(default)]
2791	/// The not null.
2792	pub not_null: bool,
2793	#[serde(default)]
2794	/// The unique.
2795	pub unique: bool,
2796	#[serde(default)]
2797	/// The primary key.
2798	pub primary_key: bool,
2799	#[serde(default)]
2800	/// The auto increment.
2801	pub auto_increment: bool,
2802	#[serde(default)]
2803	/// The default.
2804	pub default: Option<String>,
2805}
2806
2807impl ColumnDefinition {
2808	/// Create a new column definition
2809	pub fn new(name: impl Into<String>, type_def: FieldType) -> Self {
2810		Self {
2811			name: name.into(),
2812			type_definition: type_def,
2813			not_null: false,
2814			unique: false,
2815			primary_key: false,
2816			auto_increment: false,
2817			default: None,
2818		}
2819	}
2820
2821	/// Create a ColumnDefinition from FieldState with attribute parsing
2822	///
2823	/// Reads boolean attributes (primary_key, unique, auto_increment) and the
2824	/// default expression from `FieldState.params`, and derives the NOT NULL
2825	/// constraint from `FieldState.nullable` (the single source of truth
2826	/// populated by `FieldMetadata::is_nullable()` in
2827	/// `ModelMetadata::to_model_state()`).
2828	///
2829	/// # Arguments
2830	///
2831	/// * `name` - Column name
2832	/// * `field_state` - FieldState containing field metadata and params
2833	///
2834	/// # Notes
2835	///
2836	/// - If `primary_key` is true, `not_null` is forced to true regardless of
2837	///   `FieldState.nullable` — primary keys cannot accept NULL.
2838	/// - Default values are false/None for unspecified attributes
2839	pub fn from_field_state(name: impl Into<String>, field_state: &FieldState) -> Self {
2840		let name_str = name.into();
2841		let params = &field_state.params;
2842
2843		// Parse attributes from params HashMap
2844		let primary_key = params
2845			.get("primary_key")
2846			.and_then(|v| v.parse::<bool>().ok())
2847			.unwrap_or(false);
2848
2849		// Derive `not_null` from FieldState's nullability field (single source
2850		// of truth set in `ModelMetadata::to_model_state()` from
2851		// `FieldMetadata::is_nullable()`). Primary keys are always NOT NULL
2852		// regardless of the nullability flag.
2853		//
2854		// Fixes #4573: the previous implementation derived `not_null` from a
2855		// `params["not_null"]` key, which the `#[model]` proc-macro only
2856		// emits conditionally (when its `is_not_null` calculation returns
2857		// `true`). The same macro also always emits `params["null"]`, which
2858		// `is_nullable()` reads into `field_state.nullable`. Keeping the two
2859		// keys as parallel sources of truth for nullability was the root
2860		// cause of the drift: any code path that bypassed or mis-routed the
2861		// `not_null` emission (e.g., offline state reconstruction, future
2862		// macro refactors, or hand-built `FieldState` values in tests) would
2863		// produce NULLABLE columns for non-Optional fields. Consolidating on
2864		// `field_state.nullable` removes that class of regression.
2865		let not_null = !field_state.nullable || primary_key;
2866
2867		let unique = params
2868			.get("unique")
2869			.and_then(|v| v.parse::<bool>().ok())
2870			.unwrap_or(false);
2871
2872		let auto_increment = params
2873			.get("auto_increment")
2874			.and_then(|v| v.parse::<bool>().ok())
2875			.unwrap_or(false);
2876
2877		let default = params.get("default").cloned();
2878
2879		// Resolve ForeignKey column type from the referenced model's primary
2880		// key in the global `ModelRegistry`. This addresses the macro-level
2881		// limitation that the target model's PK type is not knowable at
2882		// macro-expansion time (see issue #4430). The macro emits a
2883		// placeholder `FieldType::Uuid` for `ForeignKeyField<T>` `_id`
2884		// columns and tags the field with the `fk_target` parameter; here
2885		// we look up the referenced model and adopt its PK column type.
2886		//
2887		// If the lookup fails (e.g., the target model has not been
2888		// registered yet), we fall back to the placeholder field type so
2889		// existing behavior is preserved and the caller can surface a
2890		// downstream error rather than crash here.
2891		let type_definition = resolve_foreign_key_column_type(field_state)
2892			.unwrap_or_else(|| field_state.field_type.clone());
2893
2894		Self {
2895			name: name_str,
2896			type_definition,
2897			not_null,
2898			unique,
2899			primary_key,
2900			auto_increment,
2901			default,
2902		}
2903	}
2904}
2905
2906/// Resolve the column type of a `ForeignKeyField<T>` `_id` column by
2907/// looking up the target model's primary key in the global
2908/// `ModelRegistry`. Returns `None` if `field_state` is not tagged as a
2909/// foreign key column or if the target model / its PK cannot be
2910/// resolved.
2911///
2912/// This indirection exists because the `#[model]` macro cannot resolve
2913/// the target model's PK type at macro-expansion time (the registry is
2914/// populated at process startup via `#[ctor::ctor]`). See issue #4430.
2915///
2916/// # Lookup Strategy
2917///
2918/// The resolver coordinates two lookup paths against the
2919/// `ModelRegistry`:
2920///
2921/// 1. **Qualified `(fk_target_app, fk_target)` lookup.** The
2922///    `#[model]` macro emits `fk_target_app` for every
2923///    `ForeignKeyField<T>` field by reading the target type's *own*
2924///    `<T as Model>::app_label()` at registration time. That value is
2925///    authoritative — it respects `#[app_label = "..."]` overrides
2926///    and matches whatever key the target was registered under,
2927///    regardless of how the user spelled the type (bare ident,
2928///    `use`-imported ident, absolute path, or crate-relative path).
2929///    The qualified lookup is therefore trusted as the primary
2930///    resolution path.
2931/// 2. **By-name lookup.** Used as a defensive fallback for cases
2932///    where `fk_target_app` is absent (e.g. manually-constructed
2933///    `FieldState` outside the macro path) or the qualified lookup
2934///    misses (e.g. the target model isn't registered yet during
2935///    partial registry population at startup). The by-name lookup
2936///    returns `Some` only when *exactly one* model is registered
2937///    under the name; on ambiguity it returns `None`.
2938///
2939/// When both paths return `None` and the name is ambiguous across two
2940/// or more apps (`ModelRegistry::count_models_by_name > 1`), the
2941/// resolver emits a `tracing::warn!` so operators see a targeted
2942/// diagnostic. A genuinely missing name returns `None` silently —
2943/// that case is normal during partial registry population at startup.
2944///
2945/// See issue #4436 and PR #4440 review threads on `model_derive.rs`
2946/// line 2863 and `operations.rs` line 2836.
2947fn resolve_foreign_key_column_type(field_state: &FieldState) -> Option<FieldType> {
2948	resolve_foreign_key_column_type_with(field_state, super::model_registry::global_registry())
2949}
2950
2951/// Registry-injected variant of [`resolve_foreign_key_column_type`].
2952///
2953/// Exists so unit tests can exercise the qualified-hit / by-name
2954/// fallback / ambiguous-miss branches against a local
2955/// [`super::model_registry::ModelRegistry`] without touching global
2956/// state. Production code paths go through
2957/// [`resolve_foreign_key_column_type`].
2958fn resolve_foreign_key_column_type_with(
2959	field_state: &FieldState,
2960	registry: &super::model_registry::ModelRegistry,
2961) -> Option<FieldType> {
2962	let target_model = field_state.params.get("fk_target")?;
2963	// `fk_target_app` is sourced from the target type's own
2964	// `Model::app_label()` (see `model_derive.rs`), so the qualified
2965	// lookup is authoritative. The by-name fallback is defensive: it
2966	// covers manually-constructed `FieldState`s and partial-registry
2967	// init races where the target isn't registered yet.
2968	let target = match field_state.params.get("fk_target_app") {
2969		Some(app) => registry
2970			.find_model_qualified(app, target_model)
2971			.or_else(|| registry.find_model_by_name(target_model)),
2972		None => registry.find_model_by_name(target_model),
2973	};
2974	let target = match target {
2975		Some(t) => t,
2976		None => {
2977			// `find_model_by_name` returns `None` for both "missing"
2978			// and "ambiguous". Warn only on ambiguity so operators see
2979			// a targeted message; silent on genuinely missing targets
2980			// (normal during partial registry population at startup).
2981			if registry.count_models_by_name(target_model) > 1 {
2982				tracing::warn!(
2983					model_name = %target_model,
2984					fk_target_app = ?field_state.params.get("fk_target_app"),
2985					"FK target name is ambiguous across apps and the qualified \
2986					 lookup did not resolve a unique target. Refusing to resolve \
2987					 to avoid silent wrong-target resolution. Ensure the FK \
2988					 target type is registered and that its `Model::app_label()` \
2989					 matches one of the registered apps.",
2990				);
2991			}
2992			return None;
2993		}
2994	};
2995	// Find the primary key field of the target model.
2996	let pk_field = target
2997		.fields
2998		.values()
2999		.find(|f| f.params.get("primary_key").map(String::as_str) == Some("true"))?;
3000	Some(pk_field.field_type.clone())
3001}
3002
3003/// Convert a field type string (e.g., "reinhardt.orm.models.CharField") to FieldType.
3004///
3005/// This function parses the field type path generated by the `#[model(...)]` macro
3006/// and converts it to the corresponding `FieldType` enum variant.
3007///
3008/// # Arguments
3009///
3010/// * `field_type` - The field type path string (e.g., "reinhardt.orm.models.CharField")
3011/// * `attributes` - Field attributes containing parameters like max_length, max_digits, etc.
3012///
3013/// # Returns
3014///
3015/// * `Ok(FieldType)` - The converted FieldType
3016/// * `Err(String)` - Error message if the field type is unsupported
3017///
3018/// # Examples
3019///
3020/// ```rust,ignore
3021/// use reinhardt_db::migrations::operations::field_type_string_to_field_type;
3022/// use std::collections::HashMap;
3023///
3024/// let mut attrs = HashMap::new();
3025/// attrs.insert("max_length".to_string(), "100".to_string());
3026///
3027/// let field_type = field_type_string_to_field_type("reinhardt.orm.models.CharField", &attrs);
3028/// assert!(field_type.is_ok());
3029/// ```
3030pub fn field_type_string_to_field_type(
3031	field_type: &str,
3032	attributes: &std::collections::HashMap<String, String>,
3033) -> Result<FieldType, String> {
3034	// Extract the type name from the full path
3035	let type_name = field_type.split('.').next_back().unwrap_or(field_type);
3036
3037	match type_name {
3038		// Integer types
3039		"IntegerField"
3040		| "PositiveIntegerField"
3041		| "SmallIntegerField"
3042		| "PositiveSmallIntegerField" => Ok(FieldType::Integer),
3043		"BigIntegerField" | "PositiveBigIntegerField" => Ok(FieldType::BigInteger),
3044		"AutoField" => Ok(FieldType::Integer),
3045		"BigAutoField" => Ok(FieldType::BigInteger),
3046		"SmallAutoField" => Ok(FieldType::SmallInteger),
3047
3048		// String types
3049		"CharField" => {
3050			let max_length = attributes
3051				.get("max_length")
3052				.and_then(|v| v.parse::<u32>().ok())
3053				.ok_or_else(|| "CharField requires max_length attribute".to_string())?;
3054			Ok(FieldType::VarChar(max_length))
3055		}
3056		"TextField" => Ok(FieldType::Text),
3057		"SlugField" => {
3058			let max_length = attributes
3059				.get("max_length")
3060				.and_then(|v| v.parse::<u32>().ok())
3061				.unwrap_or(50);
3062			Ok(FieldType::VarChar(max_length))
3063		}
3064		"EmailField" => {
3065			let max_length = attributes
3066				.get("max_length")
3067				.and_then(|v| v.parse::<u32>().ok())
3068				.unwrap_or(254);
3069			Ok(FieldType::VarChar(max_length))
3070		}
3071		"URLField" => {
3072			let max_length = attributes
3073				.get("max_length")
3074				.and_then(|v| v.parse::<u32>().ok())
3075				.unwrap_or(200);
3076			Ok(FieldType::VarChar(max_length))
3077		}
3078
3079		// Boolean type
3080		"BooleanField" => Ok(FieldType::Boolean),
3081		"NullBooleanField" => Ok(FieldType::Boolean),
3082
3083		// Date/time types
3084		"DateField" => Ok(FieldType::Date),
3085		"TimeField" => Ok(FieldType::Time),
3086		"DateTimeField" => Ok(FieldType::DateTime),
3087		"DurationField" => Ok(FieldType::BigInteger), // Stored as microseconds
3088
3089		// Numeric types
3090		"FloatField" => Ok(FieldType::Float),
3091		"DecimalField" => {
3092			let precision = attributes
3093				.get("max_digits")
3094				.and_then(|v| v.parse::<u32>().ok())
3095				.unwrap_or(10);
3096			let scale = attributes
3097				.get("decimal_places")
3098				.and_then(|v| v.parse::<u32>().ok())
3099				.unwrap_or(2);
3100			Ok(FieldType::Decimal { precision, scale })
3101		}
3102
3103		// Binary types
3104		"BinaryField" => Ok(FieldType::Binary),
3105
3106		// UUID type
3107		"UUIDField" => Ok(FieldType::Uuid),
3108
3109		// JSON types
3110		"JSONField" => Ok(FieldType::Json),
3111
3112		// File fields (stored as path strings)
3113		"FileField" | "ImageField" => {
3114			let max_length = attributes
3115				.get("max_length")
3116				.and_then(|v| v.parse::<u32>().ok())
3117				.unwrap_or(100);
3118			Ok(FieldType::VarChar(max_length))
3119		}
3120
3121		// IP Address fields
3122		"GenericIPAddressField" | "IPAddressField" => {
3123			// PostgreSQL uses INET, others use VARCHAR
3124			Ok(FieldType::VarChar(39)) // Max length for IPv6
3125		}
3126
3127		// Relationship fields (stored as foreign key reference)
3128		"ForeignKey" => {
3129			// ForeignKey is typically stored as integer ID
3130			Ok(FieldType::BigInteger)
3131		}
3132		"OneToOneField" => Ok(FieldType::BigInteger),
3133
3134		// Unknown type
3135		other => Err(format!("Unsupported field type: {}", other)),
3136	}
3137}
3138
3139/// SQL dialect for generating database-specific SQL
3140#[derive(Debug, Clone, Copy)]
3141pub enum SqlDialect {
3142	/// Sqlite variant.
3143	Sqlite,
3144	/// Postgres variant.
3145	Postgres,
3146	/// Mysql variant.
3147	Mysql,
3148	/// Cockroachdb variant.
3149	Cockroachdb,
3150}
3151
3152// ============================================================================
3153// SQLite Table Recreation Support
3154// ============================================================================
3155
3156/// Represents a SQLite table recreation operation
3157///
3158/// SQLite has limited ALTER TABLE support - operations like DROP COLUMN,
3159/// ALTER COLUMN TYPE, and constraint modifications require recreating the table.
3160///
3161/// This struct generates the 4-step SQL pattern:
3162/// 1. CREATE TABLE temp_table (with new schema)
3163/// 2. INSERT INTO temp_table SELECT columns FROM old_table
3164/// 3. DROP TABLE old_table
3165/// 4. ALTER TABLE temp_table RENAME TO old_table
3166///
3167/// This type is integrated into `DatabaseMigrationExecutor` which automatically
3168/// detects SQLite operations requiring recreation and applies the 4-step process
3169/// within the migration's transaction context.
3170#[derive(Debug, Clone)]
3171pub struct SqliteTableRecreation {
3172	/// Original table name
3173	pub table_name: String,
3174	/// New column definitions (after modification)
3175	pub new_columns: Vec<ColumnDefinition>,
3176	/// Columns to copy from old table (in order matching new_columns)
3177	pub columns_to_copy: Vec<String>,
3178	/// Constraints for the new table (parsed from introspection)
3179	pub constraints: Vec<Constraint>,
3180	/// Raw constraint SQL strings (for AddConstraint operations)
3181	pub raw_constraint_sqls: Vec<String>,
3182	/// WITHOUT ROWID option
3183	pub without_rowid: bool,
3184}
3185
3186impl SqliteTableRecreation {
3187	/// Create a new table recreation for dropping a column
3188	pub fn for_drop_column(
3189		table_name: impl Into<String>,
3190		current_columns: Vec<ColumnDefinition>,
3191		column_to_drop: &str,
3192		current_constraints: Vec<Constraint>,
3193	) -> Self {
3194		let table_name = table_name.into();
3195		let new_columns: Vec<_> = current_columns
3196			.into_iter()
3197			.filter(|c| c.name != column_to_drop)
3198			.collect();
3199		let columns_to_copy: Vec<_> = new_columns.iter().map(|c| c.name.to_string()).collect();
3200
3201		// Filter out constraints that reference the dropped column
3202		let constraints: Vec<_> = current_constraints
3203			.into_iter()
3204			.filter(|c| !Self::constraint_references_column(c, column_to_drop))
3205			.collect();
3206
3207		Self {
3208			table_name,
3209			new_columns,
3210			columns_to_copy,
3211			constraints,
3212			raw_constraint_sqls: Vec::new(),
3213			without_rowid: false,
3214		}
3215	}
3216
3217	/// Create a new table recreation for altering a column type
3218	pub fn for_alter_column(
3219		table_name: impl Into<String>,
3220		current_columns: Vec<ColumnDefinition>,
3221		column_name: &str,
3222		new_definition: ColumnDefinition,
3223		current_constraints: Vec<Constraint>,
3224	) -> Self {
3225		let table_name = table_name.into();
3226		let new_columns: Vec<_> = current_columns
3227			.into_iter()
3228			.map(|c| {
3229				if c.name == column_name {
3230					new_definition.clone()
3231				} else {
3232					c
3233				}
3234			})
3235			.collect();
3236		let columns_to_copy: Vec<_> = new_columns.iter().map(|c| c.name.to_string()).collect();
3237
3238		Self {
3239			table_name,
3240			new_columns,
3241			columns_to_copy,
3242			constraints: current_constraints,
3243			raw_constraint_sqls: Vec::new(),
3244			without_rowid: false,
3245		}
3246	}
3247
3248	/// Create a new table recreation for adding a constraint
3249	///
3250	/// Since SQLite doesn't support `ALTER TABLE ADD CONSTRAINT`, we need to
3251	/// recreate the table with the new constraint included.
3252	pub fn for_add_constraint(
3253		table_name: impl Into<String>,
3254		current_columns: Vec<ColumnDefinition>,
3255		current_constraints: Vec<Constraint>,
3256		constraint_sql: String,
3257	) -> Self {
3258		let table_name = table_name.into();
3259		let columns_to_copy: Vec<_> = current_columns.iter().map(|c| c.name.to_string()).collect();
3260
3261		Self {
3262			table_name,
3263			new_columns: current_columns,
3264			columns_to_copy,
3265			constraints: current_constraints,
3266			raw_constraint_sqls: vec![constraint_sql],
3267			without_rowid: false,
3268		}
3269	}
3270
3271	/// Create a new table recreation for dropping a constraint
3272	///
3273	/// Since SQLite doesn't support `ALTER TABLE DROP CONSTRAINT`, we need to
3274	/// recreate the table without the specified constraint.
3275	pub fn for_drop_constraint(
3276		table_name: impl Into<String>,
3277		current_columns: Vec<ColumnDefinition>,
3278		current_constraints: Vec<Constraint>,
3279		constraint_name: &str,
3280	) -> Self {
3281		let table_name = table_name.into();
3282		let columns_to_copy: Vec<_> = current_columns.iter().map(|c| c.name.to_string()).collect();
3283
3284		// Filter out the constraint by name
3285		let constraints: Vec<_> = current_constraints
3286			.into_iter()
3287			.filter(|c| !Self::constraint_has_name(c, constraint_name))
3288			.collect();
3289
3290		Self {
3291			table_name,
3292			new_columns: current_columns,
3293			columns_to_copy,
3294			constraints,
3295			raw_constraint_sqls: Vec::new(),
3296			without_rowid: false,
3297		}
3298	}
3299
3300	/// Generate the 4-step SQL statements for table recreation
3301	pub fn to_sql_statements(&self) -> Vec<String> {
3302		let temp_table = format!("{}_new", self.table_name);
3303
3304		// Step 1: CREATE TABLE with new schema
3305		let column_defs: Vec<String> = self
3306			.new_columns
3307			.iter()
3308			.map(|c| Operation::column_to_sql(c, &SqlDialect::Sqlite))
3309			.collect();
3310
3311		let constraint_defs: Vec<String> = self.constraints.iter().map(|c| c.to_string()).collect();
3312
3313		let mut create_parts = column_defs;
3314		create_parts.extend(constraint_defs);
3315		// Include raw constraint SQLs (from AddConstraint operations)
3316		create_parts.extend(self.raw_constraint_sqls.clone());
3317
3318		let mut create_sql = format!(
3319			"CREATE TABLE \"{}\" (\n  {}\n)",
3320			temp_table,
3321			create_parts.join(",\n  ")
3322		);
3323		if self.without_rowid {
3324			create_sql.push_str(" WITHOUT ROWID");
3325		}
3326		create_sql.push(';');
3327
3328		// Step 2: Copy data
3329		let columns_list = self
3330			.columns_to_copy
3331			.iter()
3332			.map(|c| format!("\"{}\"", c))
3333			.collect::<Vec<_>>()
3334			.join(", ");
3335		let insert_sql = format!(
3336			"INSERT INTO \"{}\" SELECT {} FROM \"{}\";",
3337			temp_table, columns_list, self.table_name
3338		);
3339
3340		// Step 3: Drop old table
3341		let drop_sql = format!("DROP TABLE \"{}\";", self.table_name);
3342
3343		// Step 4: Rename new table
3344		let rename_sql = format!(
3345			"ALTER TABLE \"{}\" RENAME TO \"{}\";",
3346			temp_table, self.table_name
3347		);
3348
3349		vec![create_sql, insert_sql, drop_sql, rename_sql]
3350	}
3351
3352	/// Check if a constraint references a specific column
3353	fn constraint_references_column(constraint: &Constraint, column_name: &str) -> bool {
3354		match constraint {
3355			Constraint::PrimaryKey { columns, .. } => columns.iter().any(|c| c == column_name),
3356			Constraint::ForeignKey { columns, .. } => columns.iter().any(|c| c == column_name),
3357			Constraint::Unique { columns, .. } => columns.iter().any(|c| c == column_name),
3358			Constraint::Check { expression, .. } => expression.contains(column_name),
3359			Constraint::OneToOne { column, .. } => column == column_name,
3360			Constraint::ManyToMany { source_column, .. } => source_column == column_name,
3361			Constraint::Exclude { elements, .. } => {
3362				elements.iter().any(|(col, _)| col == column_name)
3363			}
3364		}
3365	}
3366
3367	/// Check if a constraint has the specified name
3368	fn constraint_has_name(constraint: &Constraint, constraint_name: &str) -> bool {
3369		match constraint {
3370			Constraint::PrimaryKey { name, .. } => name == constraint_name,
3371			Constraint::ForeignKey { name, .. } => name == constraint_name,
3372			Constraint::Unique { name, .. } => name == constraint_name,
3373			Constraint::Check { name, .. } => name == constraint_name,
3374			Constraint::OneToOne { name, .. } => name == constraint_name,
3375			Constraint::ManyToMany { name, .. } => name == constraint_name,
3376			Constraint::Exclude { name, .. } => name == constraint_name,
3377		}
3378	}
3379}
3380
3381impl Operation {
3382	/// Check if this operation requires SQLite table recreation
3383	pub fn requires_sqlite_recreation(&self) -> bool {
3384		matches!(
3385			self,
3386			Operation::DropColumn { .. }
3387				| Operation::AlterColumn { .. }
3388				| Operation::AddConstraint { .. }
3389				| Operation::DropConstraint { .. }
3390		)
3391	}
3392
3393	/// Check if the reverse of this operation requires SQLite table recreation
3394	///
3395	/// When rolling back a migration on SQLite, some reverse operations also require
3396	/// table recreation. This method identifies those cases.
3397	///
3398	/// | Forward Operation | Reverse Operation | Requires Recreation |
3399	/// |-------------------|-------------------|---------------------|
3400	/// | AddColumn         | DropColumn        | Yes                 |
3401	/// | AlterColumn       | AlterColumn       | Yes                 |
3402	/// | AddConstraint     | DropConstraint    | Yes                 |
3403	/// | DropConstraint    | AddConstraint     | Yes                 |
3404	pub fn reverse_requires_sqlite_recreation(&self) -> bool {
3405		matches!(
3406			self,
3407			// AddColumn → Reverse DropColumn (requires recreation)
3408			Operation::AddColumn { .. }
3409				// AlterColumn → Reverse AlterColumn (requires recreation)
3410				| Operation::AlterColumn { .. }
3411				// AddConstraint → Reverse DropConstraint (requires recreation)
3412				| Operation::AddConstraint { .. }
3413				// DropConstraint → Reverse AddConstraint (requires recreation)
3414				| Operation::DropConstraint { .. }
3415		)
3416	}
3417
3418	/// Generate the reverse operation (for rollback on SQLite)
3419	///
3420	/// This method returns the conceptual reverse `Operation`, which can be used
3421	/// with `handle_sqlite_recreation()` for databases that don't support direct
3422	/// ALTER TABLE operations.
3423	///
3424	/// # Arguments
3425	///
3426	/// * `project_state` - Project state for accessing model definitions
3427	///
3428	/// # Returns
3429	///
3430	/// * `Ok(Some(op))` - Reverse operation generated successfully
3431	/// * `Ok(None)` - Operation is not reversible or state information is missing
3432	/// * `Err(_)` - Error generating reverse operation
3433	pub fn to_reverse_operation(
3434		&self,
3435		project_state: &ProjectState,
3436	) -> super::Result<Option<Operation>> {
3437		match self {
3438			Operation::CreateTable { name, .. } => {
3439				Ok(Some(Operation::DropTable { name: name.clone() }))
3440			}
3441			Operation::DropTable { name } => {
3442				// Reconstruct CreateTable from ProjectState
3443				if let Some(model) = project_state.find_model_by_table(name) {
3444					let columns: Vec<ColumnDefinition> = model
3445						.fields
3446						.iter()
3447						.map(|(field_name, field)| {
3448							ColumnDefinition::from_field_state(field_name.clone(), field)
3449						})
3450						.collect();
3451					let constraints: Vec<Constraint> = model
3452						.constraints
3453						.iter()
3454						.map(|c| c.to_constraint())
3455						.collect();
3456					return Ok(Some(Operation::CreateTable {
3457						name: name.clone(),
3458						columns,
3459						constraints,
3460						without_rowid: None,
3461						interleave_in_parent: None,
3462						partition: None,
3463					}));
3464				}
3465				Ok(None)
3466			}
3467			Operation::AddColumn { table, column, .. } => Ok(Some(Operation::DropColumn {
3468				table: table.clone(),
3469				column: column.name.clone(),
3470			})),
3471			Operation::DropColumn { table, column } => {
3472				// Reconstruct AddColumn from ProjectState
3473				if let Some(model) = project_state.find_model_by_table(table)
3474					&& let Some(field) = model.get_field(column)
3475				{
3476					let col_def = ColumnDefinition::from_field_state(column.clone(), field);
3477					return Ok(Some(Operation::AddColumn {
3478						table: table.clone(),
3479						column: col_def,
3480						mysql_options: None,
3481					}));
3482				}
3483				Ok(None)
3484			}
3485			Operation::AlterColumn {
3486				table,
3487				column,
3488				old_definition,
3489				new_definition: _,
3490				..
3491			} => {
3492				// Reconstruct AlterColumn with the original definition. Prefer the
3493				// explicit `old_definition` carried by the forward operation; fall
3494				// back to ProjectState lookup only if `old_definition` is absent.
3495				let resolved_old_def = old_definition.clone().or_else(|| {
3496					project_state
3497						.find_model_by_table(table)
3498						.and_then(|model| model.get_field(column))
3499						.map(|field| ColumnDefinition::from_field_state(column.clone(), field))
3500				});
3501
3502				if let Some(col_def) = resolved_old_def {
3503					return Ok(Some(Operation::AlterColumn {
3504						table: table.clone(),
3505						column: column.clone(),
3506						old_definition: None,
3507						new_definition: col_def,
3508						mysql_options: None,
3509					}));
3510				}
3511				Ok(None)
3512			}
3513			Operation::AddConstraint {
3514				table,
3515				constraint_sql,
3516			} => {
3517				// Extract constraint name to create DropConstraint
3518				if let Some(constraint_name) = Self::extract_constraint_name(constraint_sql) {
3519					return Ok(Some(Operation::DropConstraint {
3520						table: table.clone(),
3521						constraint_name,
3522					}));
3523				}
3524				Err(super::MigrationError::InvalidMigration(format!(
3525					"Cannot extract constraint name from: {}",
3526					constraint_sql
3527				)))
3528			}
3529			Operation::DropConstraint {
3530				table,
3531				constraint_name,
3532			} => {
3533				// Reconstruct AddConstraint from ProjectState
3534				if let Some(model) = project_state.find_model_by_table(table)
3535					&& let Some(constraint_def) = model
3536						.constraints
3537						.iter()
3538						.find(|c| c.name == *constraint_name)
3539				{
3540					let constraint = constraint_def.to_constraint();
3541					return Ok(Some(Operation::AddConstraint {
3542						table: table.clone(),
3543						constraint_sql: format!("{}", constraint),
3544					}));
3545				}
3546				Ok(None)
3547			}
3548			Operation::RenameTable { old_name, new_name } => Ok(Some(Operation::RenameTable {
3549				old_name: new_name.clone(),
3550				new_name: old_name.clone(),
3551			})),
3552			Operation::RenameColumn {
3553				table,
3554				old_name,
3555				new_name,
3556			} => Ok(Some(Operation::RenameColumn {
3557				table: table.clone(),
3558				old_name: new_name.clone(),
3559				new_name: old_name.clone(),
3560			})),
3561			Operation::CreateIndex { table, columns, .. } => Ok(Some(Operation::DropIndex {
3562				table: table.clone(),
3563				columns: columns.clone(),
3564			})),
3565			Operation::DropIndex { table, columns } => {
3566				// Basic index recreation (without advanced properties)
3567				// Note: Cannot determine if the original index was unique from DropIndex alone
3568				Ok(Some(Operation::CreateIndex {
3569					table: table.clone(),
3570					columns: columns.clone(),
3571					unique: false,
3572					index_type: None,
3573					where_clause: None,
3574					concurrently: false,
3575					expressions: None,
3576					mysql_options: None,
3577					operator_class: None,
3578				}))
3579			}
3580			// Operations that are not reversible as Operations
3581			Operation::RunSQL { .. } | Operation::RunRust { .. } | Operation::BulkLoad { .. } => {
3582				Ok(None)
3583			}
3584			// Other operations - not reversible via to_reverse_operation
3585			_ => Ok(None),
3586		}
3587	}
3588}
3589
3590// Re-export for convenience (legacy)
3591pub use Operation::{AddColumn, AlterColumn, CreateTable, DropColumn};
3592
3593/// Operation statement types (reinhardt-query or sanitized raw SQL)
3594pub enum OperationStatement {
3595	/// TableCreate variant.
3596	TableCreate(CreateTableStatement),
3597	/// TableDrop variant.
3598	TableDrop(DropTableStatement),
3599	/// TableAlter variant.
3600	TableAlter(AlterTableStatement),
3601	/// TableRename variant.
3602	TableRename(AlterTableStatement),
3603	/// IndexCreate variant.
3604	IndexCreate(CreateIndexStatement),
3605	/// IndexDrop variant.
3606	IndexDrop(DropIndexStatement),
3607	/// Sanitized raw SQL (identifiers escaped with pg_escape::quote_identifier)
3608	RawSql(String),
3609}
3610
3611impl OperationStatement {
3612	/// Execute the operation statement
3613	pub async fn execute<'c, E>(&self, executor: E) -> Result<(), sqlx::Error>
3614	where
3615		E: sqlx::Executor<'c, Database = sqlx::Postgres>,
3616	{
3617		use crate::backends::sql_build_helpers;
3618		use crate::backends::types::DatabaseType;
3619		let db_type = DatabaseType::Postgres;
3620		match self {
3621			OperationStatement::TableCreate(stmt) => {
3622				let sql = sql_build_helpers::build_create_table_sql(db_type, stmt);
3623				sqlx::query(&sql).execute(executor).await?;
3624			}
3625			OperationStatement::TableDrop(stmt) => {
3626				let sql = sql_build_helpers::build_drop_table_sql(db_type, stmt);
3627				sqlx::query(&sql).execute(executor).await?;
3628			}
3629			OperationStatement::TableAlter(stmt) => {
3630				let sql = sql_build_helpers::build_alter_table_sql(db_type, stmt);
3631				sqlx::query(&sql).execute(executor).await?;
3632			}
3633			OperationStatement::TableRename(stmt) => {
3634				let sql = sql_build_helpers::build_alter_table_sql(db_type, stmt);
3635				sqlx::query(&sql).execute(executor).await?;
3636			}
3637			OperationStatement::IndexCreate(stmt) => {
3638				let sql = sql_build_helpers::build_create_index_sql(db_type, stmt);
3639				sqlx::query(&sql).execute(executor).await?;
3640			}
3641			OperationStatement::IndexDrop(stmt) => {
3642				let sql = sql_build_helpers::build_drop_index_sql(db_type, stmt);
3643				sqlx::query(&sql).execute(executor).await?;
3644			}
3645			OperationStatement::RawSql(sql) => {
3646				// Already sanitized with pg_escape::quote_identifier
3647				sqlx::query(sql).execute(executor).await?;
3648			}
3649		}
3650		Ok(())
3651	}
3652
3653	/// Convert to SQL string for logging/debugging
3654	///
3655	/// # Arguments
3656	///
3657	/// * `db_type` - Database type to generate SQL for (PostgreSQL, MySQL, SQLite)
3658	pub fn to_sql_string(&self, db_type: crate::backends::types::DatabaseType) -> String {
3659		use crate::backends::sql_build_helpers;
3660
3661		match self {
3662			OperationStatement::TableCreate(stmt) => {
3663				sql_build_helpers::build_create_table_sql(db_type, stmt)
3664			}
3665			OperationStatement::TableDrop(stmt) => {
3666				sql_build_helpers::build_drop_table_sql(db_type, stmt)
3667			}
3668			OperationStatement::TableAlter(stmt) => {
3669				sql_build_helpers::build_alter_table_sql(db_type, stmt)
3670			}
3671			OperationStatement::TableRename(stmt) => {
3672				sql_build_helpers::build_alter_table_sql(db_type, stmt)
3673			}
3674			OperationStatement::IndexCreate(stmt) => {
3675				sql_build_helpers::build_create_index_sql(db_type, stmt)
3676			}
3677			OperationStatement::IndexDrop(stmt) => {
3678				sql_build_helpers::build_drop_index_sql(db_type, stmt)
3679			}
3680			OperationStatement::RawSql(sql) => sql.clone(),
3681		}
3682	}
3683}
3684
3685impl Operation {
3686	/// Convert Operation to reinhardt-query statement or sanitized raw SQL
3687	pub fn to_statement(&self) -> OperationStatement {
3688		match self {
3689			Operation::CreateTable {
3690				name,
3691				columns,
3692				constraints,
3693				..
3694			} => {
3695				OperationStatement::TableCreate(self.build_create_table(name, columns, constraints))
3696			}
3697			Operation::DropTable { name } => {
3698				OperationStatement::TableDrop(self.build_drop_table(name))
3699			}
3700			Operation::AddColumn { table, column, .. } => {
3701				OperationStatement::TableAlter(self.build_add_column(table, column))
3702			}
3703			Operation::DropColumn { table, column } => {
3704				OperationStatement::TableAlter(self.build_drop_column(table, column))
3705			}
3706			Operation::AlterColumn {
3707				table,
3708				column,
3709				new_definition,
3710				..
3711			} => OperationStatement::TableAlter(self.build_alter_column(
3712				table,
3713				column,
3714				new_definition,
3715			)),
3716			Operation::RenameTable { old_name, new_name } => {
3717				OperationStatement::TableRename(self.build_rename_table(old_name, new_name))
3718			}
3719			// reinhardt-query does not support RENAME COLUMN, use sanitized raw SQL
3720			Operation::RenameColumn {
3721				table,
3722				old_name,
3723				new_name,
3724			} => OperationStatement::RawSql(format!(
3725				"ALTER TABLE {} RENAME COLUMN {} TO {}",
3726				quote_identifier(table),
3727				quote_identifier(old_name),
3728				quote_identifier(new_name)
3729			)),
3730			Operation::AddConstraint {
3731				table,
3732				constraint_sql,
3733			} => {
3734				// NOTE: constraint_sql validation is the caller's responsibility
3735				OperationStatement::RawSql(format!(
3736					"ALTER TABLE {} ADD {}",
3737					quote_identifier(table),
3738					constraint_sql
3739				))
3740			}
3741			Operation::DropConstraint {
3742				table,
3743				constraint_name,
3744			} => OperationStatement::RawSql(format!(
3745				"ALTER TABLE {} DROP CONSTRAINT {}",
3746				quote_identifier(table),
3747				quote_identifier(constraint_name)
3748			)),
3749			Operation::CreateIndex {
3750				table,
3751				columns,
3752				unique,
3753				..
3754			} => {
3755				let idx_name = format!("idx_{}_{}", table, columns.join("_"));
3756				OperationStatement::IndexCreate(
3757					self.build_create_index(&idx_name, table, columns, *unique),
3758				)
3759			}
3760			Operation::DropIndex { table, columns } => {
3761				let idx_name = format!("idx_{}_{}", table, columns.join("_"));
3762				OperationStatement::IndexDrop(self.build_drop_index(&idx_name))
3763			}
3764			Operation::RunSQL { sql, .. } => OperationStatement::RawSql(sql.to_string()),
3765			Operation::RunRust { code, .. } => {
3766				// RunRust operations don't produce SQL
3767				OperationStatement::RawSql(format!(
3768					"-- RunRust: {}",
3769					code.lines().next().unwrap_or("")
3770				))
3771			}
3772			Operation::AlterTableComment { table, comment } => {
3773				// PostgreSQL-specific COMMENT ON TABLE
3774				OperationStatement::RawSql(if let Some(comment_text) = comment {
3775					format!(
3776						"COMMENT ON TABLE {} IS '{}'",
3777						quote_identifier(table),
3778						comment_text.replace('\'', "''") // Escape single quotes
3779					)
3780				} else {
3781					format!("COMMENT ON TABLE {} IS NULL", quote_identifier(table))
3782				})
3783			}
3784			Operation::AlterUniqueTogether {
3785				table,
3786				unique_together,
3787			} => {
3788				let mut sqls = Vec::new();
3789				for (idx, fields) in unique_together.iter().enumerate() {
3790					let constraint_name = format!("{}_{}_uniq", table, idx);
3791					let fields_str: Vec<String> = fields
3792						.iter()
3793						.map(|f| quote_identifier(f).to_string())
3794						.collect();
3795					sqls.push(format!(
3796						"ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})",
3797						quote_identifier(table),
3798						quote_identifier(&constraint_name),
3799						fields_str.join(", ")
3800					));
3801				}
3802				OperationStatement::RawSql(sqls.join(";\n"))
3803			}
3804			Operation::AlterModelOptions { .. } => OperationStatement::RawSql(String::new()),
3805			Operation::CreateInheritedTable {
3806				name,
3807				columns,
3808				base_table,
3809				join_column,
3810			} => {
3811				let mut stmt = Query::create_table();
3812				stmt.table(Alias::new(name.as_str())).if_not_exists();
3813
3814				// Add join column (foreign key to base table)
3815				let join_col = ColumnDef::new(Alias::new(join_column.as_str()));
3816				let join_col = join_col.integer();
3817				stmt.col(join_col);
3818
3819				// Add other columns
3820				for col in columns {
3821					let mut column = ColumnDef::new(Alias::new(col.name.as_str()));
3822					column = self.apply_column_type(column, &col.type_definition);
3823					stmt.col(column);
3824				}
3825
3826				// Add foreign key
3827				let mut fk = reinhardt_query::prelude::ForeignKey::create();
3828				fk.from_tbl(Alias::new(name.as_str()))
3829					.from_col(Alias::new(join_column.as_str()))
3830					.to_tbl(Alias::new(base_table.as_str()))
3831					.to_col(Alias::new("id"));
3832				stmt.foreign_key_from_builder(&mut fk);
3833
3834				OperationStatement::TableCreate(stmt.to_owned())
3835			}
3836			Operation::AddDiscriminatorColumn {
3837				table,
3838				column_name,
3839				default_value,
3840			} => {
3841				let mut stmt = Query::alter_table();
3842				stmt.table(Alias::new(table.as_str()));
3843
3844				let mut col = ColumnDef::new(Alias::new(column_name.as_str()));
3845				col = col
3846					.string_len(50)
3847					.default(SimpleExpr::from(default_value.to_string()));
3848				stmt.add_column(col);
3849
3850				OperationStatement::TableAlter(stmt.to_owned())
3851			}
3852			Operation::MoveModel {
3853				rename_table,
3854				old_table_name,
3855				new_table_name,
3856				..
3857			} => {
3858				// MoveModel generates a table rename if table name changes
3859				if *rename_table {
3860					if let (Some(old_name), Some(new_name)) = (old_table_name, new_table_name) {
3861						OperationStatement::TableRename(self.build_rename_table(old_name, new_name))
3862					} else {
3863						// No table rename needed
3864						OperationStatement::RawSql("-- MoveModel: State-only operation".to_string())
3865					}
3866				} else {
3867					// State-only operation, no SQL
3868					OperationStatement::RawSql("-- MoveModel: State-only operation".to_string())
3869				}
3870			}
3871			Operation::CreateSchema {
3872				name,
3873				if_not_exists,
3874			} => {
3875				// Use schema.rs helper (reinhardt-query doesn't support CREATE SCHEMA)
3876				let sql = if *if_not_exists {
3877					format!("CREATE SCHEMA IF NOT EXISTS {}", quote_identifier(name))
3878				} else {
3879					format!("CREATE SCHEMA {}", quote_identifier(name))
3880				};
3881				OperationStatement::RawSql(sql)
3882			}
3883			Operation::DropSchema {
3884				name,
3885				cascade,
3886				if_exists,
3887			} => {
3888				// Use schema.rs helper (reinhardt-query doesn't support DROP SCHEMA)
3889				let if_exists_clause = if *if_exists { " IF EXISTS" } else { "" };
3890				let cascade_clause = if *cascade { " CASCADE" } else { "" };
3891				let sql = format!(
3892					"DROP SCHEMA{} {}{}",
3893					if_exists_clause,
3894					quote_identifier(name),
3895					cascade_clause
3896				);
3897				OperationStatement::RawSql(sql)
3898			}
3899			Operation::CreateExtension {
3900				name,
3901				if_not_exists,
3902				schema,
3903			} => {
3904				// PostgreSQL-specific: Use extensions.rs helper
3905				let if_not_exists_clause = if *if_not_exists { " IF NOT EXISTS" } else { "" };
3906				let schema_clause = if let Some(s) = schema {
3907					format!(" SCHEMA {}", quote_identifier(s))
3908				} else {
3909					String::new()
3910				};
3911				let sql = format!(
3912					"CREATE EXTENSION{} {}{}",
3913					if_not_exists_clause,
3914					quote_identifier(name),
3915					schema_clause
3916				);
3917				OperationStatement::RawSql(sql)
3918			}
3919			Operation::BulkLoad {
3920				table,
3921				source,
3922				format,
3923				options,
3924			} => {
3925				// BulkLoad uses dialect-specific raw SQL
3926				// Default to PostgreSQL COPY FROM syntax for to_statement()
3927				OperationStatement::RawSql(Self::postgres_copy_from_sql(
3928					table, source, format, options,
3929				))
3930			}
3931			Operation::SetAutoIncrementValue { table, .. } => {
3932				// `to_statement` has no dialect context, but `SetAutoIncrementValue`
3933				// renders fundamentally different SQL per backend (PostgreSQL
3934				// `setval`, MySQL `ALTER TABLE AUTO_INCREMENT`, SQLite
3935				// `sqlite_sequence` upsert). Silently emitting PostgreSQL-only
3936				// SQL here would break MySQL/SQLite migrations.
3937				//
3938				// Emit guaranteed-fail SQL that aborts execution with a visible
3939				// diagnostic pointing callers at the dialect-aware `to_sql`
3940				// path. Converting the signature to
3941				// `Result<OperationStatement, MigrationError>` would cascade
3942				// through dozens of call sites.
3943				OperationStatement::RawSql(format!(
3944					"SELECT 1/0 AS \"SetAutoIncrementValue on {} requires dialect-aware rendering; call Operation::to_sql(&dialect) instead of to_statement()\";",
3945					table.replace('"', "\"\"")
3946				))
3947			}
3948			Operation::CreateCompositePrimaryKey {
3949				table,
3950				columns,
3951				constraint_name,
3952			} => OperationStatement::RawSql(Self::create_composite_pk_to_sql(
3953				table,
3954				columns,
3955				constraint_name.as_deref(),
3956			)),
3957		}
3958	}
3959
3960	/// Build CREATE TABLE statement
3961	fn build_create_table(
3962		&self,
3963		name: &str,
3964		columns: &[ColumnDefinition],
3965		constraints: &[Constraint],
3966	) -> CreateTableStatement {
3967		let mut stmt = Query::create_table();
3968		stmt.table(Alias::new(name)).if_not_exists();
3969
3970		for col in columns {
3971			let mut column = ColumnDef::new(Alias::new(col.name.as_str()));
3972			column = self.apply_column_type(column, &col.type_definition);
3973
3974			if col.not_null {
3975				column = column.not_null(true);
3976			}
3977			if col.unique {
3978				column = column.unique(true);
3979			}
3980			if col.primary_key {
3981				column = column.primary_key(true);
3982			}
3983			if col.auto_increment {
3984				column = column.auto_increment(true);
3985			}
3986			if let Some(default) = &col.default {
3987				column = column.default(SimpleExpr::from(self.convert_default_value(default)));
3988			}
3989
3990			stmt.col(column);
3991		}
3992
3993		// Add table-level constraints
3994		for constraint in constraints {
3995			match constraint {
3996				Constraint::PrimaryKey { columns, .. } => {
3997					let col_idens: Vec<Alias> =
3998						columns.iter().map(|c| Alias::new(c.as_str())).collect();
3999					stmt.primary_key(col_idens);
4000				}
4001				Constraint::ForeignKey {
4002					name,
4003					columns,
4004					referenced_table,
4005					referenced_columns,
4006					on_delete,
4007					on_update,
4008					..
4009				} => {
4010					let mut fk = reinhardt_query::prelude::ForeignKey::create();
4011					fk.name(Alias::new(name.as_str()))
4012						.from_tbl(Alias::new(name.as_str()))
4013						.to_tbl(Alias::new(referenced_table.as_str()));
4014
4015					for col in columns {
4016						fk.from_col(Alias::new(col.as_str()));
4017					}
4018					for col in referenced_columns {
4019						fk.to_col(Alias::new(col.as_str()));
4020					}
4021
4022					fk.on_delete((*on_delete).into());
4023					fk.on_update((*on_update).into());
4024
4025					stmt.foreign_key_from_builder(&mut fk);
4026				}
4027				Constraint::Unique { columns, .. } => {
4028					let col_idens: Vec<Alias> =
4029						columns.iter().map(|c| Alias::new(c.as_str())).collect();
4030					stmt.unique(col_idens);
4031				}
4032				Constraint::Check { name, expression } => {
4033					// Note: reinhardt-query doesn't have direct CHECK constraint support
4034					// This would need to be handled with raw SQL if needed
4035					let _ = (name, expression); // Suppress unused warnings
4036				}
4037				Constraint::OneToOne {
4038					name,
4039					column,
4040					referenced_table,
4041					referenced_column,
4042					on_delete,
4043					on_update,
4044					..
4045				} => {
4046					// OneToOne is ForeignKey + Unique
4047					let mut fk = reinhardt_query::prelude::ForeignKey::create();
4048					fk.name(Alias::new(name.as_str()))
4049						.from_tbl(Alias::new(name.as_str()))
4050						.to_tbl(Alias::new(referenced_table.as_str()))
4051						.from_col(Alias::new(column.as_str()))
4052						.to_col(Alias::new(referenced_column.as_str()))
4053						.on_delete((*on_delete).into())
4054						.on_update((*on_update).into());
4055
4056					stmt.foreign_key_from_builder(&mut fk);
4057
4058					// Add UNIQUE constraint separately if needed
4059					// Note: This should ideally be handled via UNIQUE column definition
4060				}
4061				Constraint::ManyToMany { .. } => {
4062					// ManyToMany is metadata only, no actual constraint in this table
4063					// The intermediate table handles the relationship
4064				}
4065				Constraint::Exclude { .. } => {
4066					// Exclude constraints are PostgreSQL-specific and not directly supported by reinhardt-query
4067					// They need to be handled with raw SQL if needed
4068				}
4069			}
4070		}
4071
4072		stmt.to_owned()
4073	}
4074
4075	/// Build DROP TABLE statement
4076	fn build_drop_table(&self, name: &str) -> DropTableStatement {
4077		Query::drop_table()
4078			.table(Alias::new(name))
4079			.if_exists()
4080			.cascade()
4081			.to_owned()
4082	}
4083
4084	/// Build ALTER TABLE ADD COLUMN statement
4085	fn build_add_column(&self, table: &str, column: &ColumnDefinition) -> AlterTableStatement {
4086		let mut stmt = Query::alter_table();
4087		stmt.table(Alias::new(table));
4088
4089		let mut col_def = ColumnDef::new(Alias::new(column.name.as_str()));
4090		col_def = self.apply_column_type(col_def, &column.type_definition);
4091
4092		if column.not_null {
4093			col_def = col_def.not_null(true);
4094		}
4095		if let Some(default) = &column.default {
4096			col_def = col_def.default(SimpleExpr::from(self.convert_default_value(default)));
4097		}
4098
4099		stmt.add_column(col_def);
4100		stmt.to_owned()
4101	}
4102
4103	/// Build ALTER TABLE DROP COLUMN statement
4104	fn build_drop_column(&self, table: &str, column: &str) -> AlterTableStatement {
4105		Query::alter_table()
4106			.table(Alias::new(table))
4107			.drop_column(Alias::new(column))
4108			.to_owned()
4109	}
4110
4111	/// Build ALTER TABLE ALTER COLUMN statement
4112	fn build_alter_column(
4113		&self,
4114		table: &str,
4115		column: &str,
4116		new_definition: &ColumnDefinition,
4117	) -> AlterTableStatement {
4118		let mut stmt = Query::alter_table();
4119		stmt.table(Alias::new(table));
4120
4121		let mut col_def = ColumnDef::new(Alias::new(column));
4122		col_def = self.apply_column_type(col_def, &new_definition.type_definition);
4123
4124		if new_definition.not_null {
4125			col_def = col_def.not_null(true);
4126		}
4127
4128		stmt.modify_column(col_def);
4129		stmt.to_owned()
4130	}
4131
4132	/// Build ALTER TABLE RENAME statement
4133	fn build_rename_table(&self, old_name: &str, new_name: &str) -> AlterTableStatement {
4134		Query::alter_table()
4135			.table(Alias::new(old_name))
4136			.rename_table(Alias::new(new_name))
4137			.to_owned()
4138	}
4139
4140	/// Build CREATE INDEX statement
4141	fn build_create_index(
4142		&self,
4143		name: &str,
4144		table: &str,
4145		columns: &[String],
4146		unique: bool,
4147	) -> CreateIndexStatement {
4148		let mut stmt = Query::create_index();
4149		stmt.name(Alias::new(name)).table(Alias::new(table));
4150
4151		for col in columns {
4152			stmt.col(Alias::new(col));
4153		}
4154
4155		if unique {
4156			stmt.unique();
4157		}
4158
4159		stmt.to_owned()
4160	}
4161
4162	/// Build DROP INDEX statement
4163	fn build_drop_index(&self, name: &str) -> DropIndexStatement {
4164		Query::drop_index().name(Alias::new(name)).to_owned()
4165	}
4166
4167	/// Apply column type to ColumnDef using `reinhardt_query`'s fluent API
4168	fn apply_column_type(&self, col_def: ColumnDef, field_type: &FieldType) -> ColumnDef {
4169		use FieldType;
4170		match field_type {
4171			FieldType::Integer => col_def.integer(),
4172			FieldType::BigInteger => col_def.big_integer(),
4173			FieldType::SmallInteger => col_def.small_integer(),
4174			FieldType::TinyInt => col_def.tiny_integer(),
4175			FieldType::VarChar(max_length) => col_def.string_len(*max_length),
4176			FieldType::Char(max_length) => col_def.char_len(*max_length),
4177			FieldType::Text | FieldType::TinyText | FieldType::MediumText | FieldType::LongText => {
4178				col_def.text()
4179			}
4180			// Use custom "BOOLEAN" type name instead of col_def.boolean() to ensure
4181			// consistent type naming across all databases. This is important for SQLite
4182			// where col_def.boolean() would generate "INTEGER", but we need "BOOLEAN"
4183			// so that sqlx's type_info().name() returns "BOOLEAN" and our convert_row
4184			// can properly detect boolean columns and convert integer 0/1 to bool values.
4185			FieldType::Boolean => col_def.custom(Alias::new("BOOLEAN")),
4186			FieldType::DateTime => col_def.timestamp(),
4187			FieldType::TimestampTz => col_def.timestamp_with_time_zone(),
4188			FieldType::Date => col_def.date(),
4189			FieldType::Time => col_def.time(),
4190			FieldType::Decimal { precision, scale } => col_def.decimal(*precision, *scale),
4191			FieldType::Float => col_def.float(),
4192			FieldType::Double | FieldType::Real => col_def.double(),
4193			FieldType::Json => col_def.json(),
4194			FieldType::JsonBinary => col_def.json_binary(),
4195			FieldType::Uuid => col_def.uuid(),
4196			FieldType::Binary | FieldType::Bytea => col_def.binary(0),
4197			FieldType::Blob | FieldType::TinyBlob | FieldType::MediumBlob | FieldType::LongBlob => {
4198				col_def.binary(0)
4199			}
4200			FieldType::MediumInt => col_def.integer(),
4201			FieldType::Year => col_def.small_integer(),
4202			FieldType::Enum { values } => {
4203				col_def.custom(Alias::new(format!("ENUM({})", values.join(","))))
4204			}
4205			FieldType::Set { values } => {
4206				col_def.custom(Alias::new(format!("SET({})", values.join(","))))
4207			}
4208			FieldType::ForeignKey { .. } => {
4209				// ForeignKey is a relationship, the actual column is typically an integer
4210				col_def.integer()
4211			}
4212			FieldType::OneToOne { .. } => {
4213				// OneToOne is a relationship, not a column type
4214				// The actual column will be a foreign key (typically BigInteger)
4215				col_def.big_integer()
4216			}
4217			FieldType::ManyToMany { .. } => {
4218				// ManyToMany is a relationship, not a column type
4219				// No column is created in the model table (uses intermediate table)
4220				col_def.big_integer()
4221			}
4222			// PostgreSQL-specific types
4223			FieldType::Array(inner) => {
4224				// PostgreSQL array type: use custom with array notation
4225				let inner_sql = inner.to_sql_string();
4226				col_def.custom(Alias::new(format!("{}[]", inner_sql)))
4227			}
4228			FieldType::HStore => col_def.custom(Alias::new("HSTORE")),
4229			FieldType::CIText => col_def.custom(Alias::new("CITEXT")),
4230			FieldType::Int4Range => col_def.custom(Alias::new("INT4RANGE")),
4231			FieldType::Int8Range => col_def.custom(Alias::new("INT8RANGE")),
4232			FieldType::NumRange => col_def.custom(Alias::new("NUMRANGE")),
4233			FieldType::DateRange => col_def.custom(Alias::new("DATERANGE")),
4234			FieldType::TsRange => col_def.custom(Alias::new("TSRANGE")),
4235			FieldType::TsTzRange => col_def.custom(Alias::new("TSTZRANGE")),
4236			FieldType::TsVector => col_def.custom(Alias::new("TSVECTOR")),
4237			FieldType::TsQuery => col_def.custom(Alias::new("TSQUERY")),
4238			FieldType::Custom(custom_type) => col_def.custom(Alias::new(custom_type)),
4239		}
4240	}
4241
4242	/// Convert default value string to `reinhardt_query::prelude::Value`
4243	fn convert_default_value(&self, default: &str) -> Value {
4244		let trimmed = default.trim();
4245
4246		// NULL
4247		if trimmed.eq_ignore_ascii_case("null") {
4248			return Value::String(None);
4249		}
4250
4251		// Boolean
4252		if trimmed.eq_ignore_ascii_case("true") {
4253			return Value::Bool(Some(true));
4254		}
4255		if trimmed.eq_ignore_ascii_case("false") {
4256			return Value::Bool(Some(false));
4257		}
4258
4259		// Integer
4260		if let Ok(i) = trimmed.parse::<i64>() {
4261			return Value::BigInt(Some(i));
4262		}
4263
4264		// Float
4265		if let Ok(f) = trimmed.parse::<f64>() {
4266			return Value::Double(Some(f));
4267		}
4268
4269		// String (quoted)
4270		if (trimmed.starts_with('"') && trimmed.ends_with('"'))
4271			|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
4272		{
4273			let unquoted = &trimmed[1..trimmed.len() - 1];
4274			return Value::String(Some(Box::new(unquoted.to_string())));
4275		}
4276
4277		// JSON array/object
4278		if ((trimmed.starts_with('[') && trimmed.ends_with(']'))
4279			|| (trimmed.starts_with('{') && trimmed.ends_with('}')))
4280			&& let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
4281		{
4282			return json_to_sea_value(&json);
4283		}
4284
4285		// SQL constants that should remain unquoted
4286		const SQL_CONSTANTS: &[&str] = &[
4287			"CURRENT_TIMESTAMP",
4288			"CURRENT_DATE",
4289			"CURRENT_TIME",
4290			"CURRENT_USER",
4291			"SESSION_USER",
4292			"LOCALTIME",
4293			"LOCALTIMESTAMP",
4294		];
4295
4296		// SQL function calls (e.g., NOW(), CURRENT_TIMESTAMP()) - keep unquoted
4297		if trimmed.ends_with("()") || trimmed.contains('(') {
4298			return Value::String(Some(Box::new(trimmed.to_string())));
4299		}
4300
4301		// SQL constants - keep unquoted
4302		if SQL_CONSTANTS
4303			.iter()
4304			.any(|c| trimmed.eq_ignore_ascii_case(c))
4305		{
4306			return Value::String(Some(Box::new(trimmed.to_string())));
4307		}
4308
4309		// Default: plain string - auto-quote as SQL string literal
4310		Value::String(Some(Box::new(format!("'{}'", trimmed.replace('\'', "''")))))
4311	}
4312}
4313
4314/// Helper function to convert `serde_json::Value` to `reinhardt_query::prelude::Value`
4315fn json_to_sea_value(json: &serde_json::Value) -> Value {
4316	match json {
4317		serde_json::Value::Null => Value::String(None),
4318		serde_json::Value::Bool(b) => Value::Bool(Some(*b)),
4319		serde_json::Value::Number(n) => {
4320			if let Some(i) = n.as_i64() {
4321				Value::BigInt(Some(i))
4322			} else if let Some(f) = n.as_f64() {
4323				Value::Double(Some(f))
4324			} else {
4325				Value::String(Some(Box::new(n.to_string())))
4326			}
4327		}
4328		serde_json::Value::String(s) => Value::String(Some(Box::new(s.clone()))),
4329		serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
4330			// Store as JSON string
4331			Value::String(Some(Box::new(json.to_string())))
4332		}
4333	}
4334}
4335
4336// MigrationOperation trait implementation for legacy Operation enum
4337use super::operation_trait::MigrationOperation;
4338
4339impl MigrationOperation for Operation {
4340	fn migration_name_fragment(&self) -> Option<String> {
4341		match self {
4342			Operation::CreateTable { name, .. } => Some(name.to_lowercase()),
4343			Operation::DropTable { name } => Some(format!("delete_{}", name.to_lowercase())),
4344			Operation::AddColumn { table, column, .. } => Some(format!(
4345				"{}_{}",
4346				table.to_lowercase(),
4347				column.name.to_lowercase()
4348			)),
4349			Operation::DropColumn { table, column } => Some(format!(
4350				"remove_{}_{}",
4351				table.to_lowercase(),
4352				column.to_lowercase()
4353			)),
4354			Operation::AlterColumn { table, column, .. } => Some(format!(
4355				"alter_{}_{}",
4356				table.to_lowercase(),
4357				column.to_lowercase()
4358			)),
4359			Operation::RenameTable { old_name, new_name } => Some(format!(
4360				"rename_{}_to_{}",
4361				old_name.to_lowercase(),
4362				new_name.to_lowercase()
4363			)),
4364			Operation::RenameColumn {
4365				table, new_name, ..
4366			} => Some(format!(
4367				"rename_{}_{}",
4368				table.to_lowercase(),
4369				new_name.to_lowercase()
4370			)),
4371			Operation::AddConstraint { table, .. } => {
4372				Some(format!("add_constraint_{}", table.to_lowercase()))
4373			}
4374			Operation::DropConstraint {
4375				table: _,
4376				constraint_name,
4377			} => Some(format!(
4378				"drop_constraint_{}",
4379				constraint_name.to_lowercase()
4380			)),
4381			Operation::CreateIndex { table, unique, .. } => {
4382				if *unique {
4383					Some(format!("create_unique_index_{}", table.to_lowercase()))
4384				} else {
4385					Some(format!("create_index_{}", table.to_lowercase()))
4386				}
4387			}
4388			Operation::DropIndex { table, .. } => {
4389				Some(format!("drop_index_{}", table.to_lowercase()))
4390			}
4391			Operation::RunSQL { .. } => None,  // Triggers auto-naming
4392			Operation::RunRust { .. } => None, // Triggers auto-naming
4393			Operation::AlterTableComment { table, .. } => {
4394				Some(format!("alter_comment_{}", table.to_lowercase()))
4395			}
4396			Operation::AlterUniqueTogether { table, .. } => {
4397				Some(format!("alter_unique_{}", table.to_lowercase()))
4398			}
4399			Operation::AlterModelOptions { table, .. } => {
4400				Some(format!("alter_options_{}", table.to_lowercase()))
4401			}
4402			Operation::CreateInheritedTable { name, .. } => {
4403				Some(format!("create_inherited_{}", name.to_lowercase()))
4404			}
4405			Operation::AddDiscriminatorColumn { table, .. } => {
4406				Some(format!("add_discriminator_{}", table.to_lowercase()))
4407			}
4408			Operation::MoveModel {
4409				model_name,
4410				from_app,
4411				to_app,
4412				..
4413			} => Some(format!(
4414				"move_{}_{}_{}_{}",
4415				from_app.to_lowercase(),
4416				model_name.to_lowercase(),
4417				to_app.to_lowercase(),
4418				model_name.to_lowercase()
4419			)),
4420			Operation::CreateSchema { name, .. } => {
4421				Some(format!("create_schema_{}", name.to_lowercase()))
4422			}
4423			Operation::DropSchema { name, .. } => {
4424				Some(format!("drop_schema_{}", name.to_lowercase()))
4425			}
4426			Operation::CreateExtension { name, .. } => {
4427				Some(format!("create_extension_{}", name.to_lowercase()))
4428			}
4429			Operation::BulkLoad { table, .. } => {
4430				Some(format!("bulk_load_{}", table.to_lowercase()))
4431			}
4432			Operation::SetAutoIncrementValue { table, column, .. } => Some(format!(
4433				"set_auto_increment_{}_{}",
4434				table.to_lowercase(),
4435				column.to_lowercase()
4436			)),
4437			Operation::CreateCompositePrimaryKey { table, .. } => {
4438				Some(format!("composite_pk_{}", table.to_lowercase()))
4439			}
4440		}
4441	}
4442
4443	fn describe(&self) -> String {
4444		match self {
4445			Operation::CreateTable { name, .. } => format!("Create table {}", name),
4446			Operation::DropTable { name } => format!("Drop table {}", name),
4447			Operation::AddColumn { table, column, .. } => {
4448				format!("Add column {} to {}", column.name, table)
4449			}
4450			Operation::DropColumn { table, column } => {
4451				format!("Drop column {} from {}", column, table)
4452			}
4453			Operation::AlterColumn { table, column, .. } => {
4454				format!("Alter column {} on {}", column, table)
4455			}
4456			Operation::RenameTable { old_name, new_name } => {
4457				format!("Rename table {} to {}", old_name, new_name)
4458			}
4459			Operation::RenameColumn {
4460				table,
4461				old_name,
4462				new_name,
4463			} => format!("Rename column {} to {} on {}", old_name, new_name, table),
4464			Operation::AddConstraint { table, .. } => format!("Add constraint on {}", table),
4465			Operation::DropConstraint {
4466				table,
4467				constraint_name,
4468			} => format!("Drop constraint {} from {}", constraint_name, table),
4469			Operation::CreateIndex { table, unique, .. } => {
4470				if *unique {
4471					format!("Create unique index on {}", table)
4472				} else {
4473					format!("Create index on {}", table)
4474				}
4475			}
4476			Operation::DropIndex { table, .. } => format!("Drop index on {}", table),
4477			Operation::RunSQL { sql, .. } => {
4478				let preview = if sql.len() > 50 {
4479					format!("{}...", &sql[..50])
4480				} else {
4481					(*sql).to_string()
4482				};
4483				format!("RunSQL: {}", preview)
4484			}
4485			Operation::RunRust { code, .. } => {
4486				let preview = if code.len() > 50 {
4487					format!("{}...", &code[..50])
4488				} else {
4489					(*code).to_string()
4490				};
4491				format!("RunRust: {}", preview)
4492			}
4493			Operation::AlterTableComment { table, comment } => match comment {
4494				Some(c) => format!("Set comment on {} to '{}'", table, c),
4495				None => format!("Remove comment from {}", table),
4496			},
4497			Operation::AlterUniqueTogether { table, .. } => {
4498				format!("Alter unique_together on {}", table)
4499			}
4500			Operation::AlterModelOptions { table, .. } => {
4501				format!("Alter model options on {}", table)
4502			}
4503			Operation::CreateInheritedTable {
4504				name, base_table, ..
4505			} => {
4506				format!("Create inherited table {} from {}", name, base_table)
4507			}
4508			Operation::AddDiscriminatorColumn {
4509				table, column_name, ..
4510			} => format!("Add discriminator column {} to {}", column_name, table),
4511			Operation::MoveModel {
4512				model_name,
4513				from_app,
4514				to_app,
4515				..
4516			} => format!("Move model {} from {} to {}", model_name, from_app, to_app),
4517			Operation::CreateSchema { name, .. } => format!("Create schema {}", name),
4518			Operation::DropSchema { name, .. } => format!("Drop schema {}", name),
4519			Operation::CreateExtension { name, .. } => format!("Create extension {}", name),
4520			Operation::BulkLoad { table, source, .. } => {
4521				let source_desc = match source {
4522					BulkLoadSource::File(path) => format!("file '{}'", path),
4523					BulkLoadSource::Stdin => "STDIN".to_string(),
4524					BulkLoadSource::Program(cmd) => format!("program '{}'", cmd),
4525				};
4526				format!("Bulk load data into {} from {}", table, source_desc)
4527			}
4528			Operation::SetAutoIncrementValue {
4529				table,
4530				column,
4531				value,
4532			} => format!("Set auto-increment of {}.{} to {}", table, column, value),
4533			Operation::CreateCompositePrimaryKey { table, columns, .. } => format!(
4534				"Create composite primary key on {} ({})",
4535				table,
4536				columns.join(", ")
4537			),
4538		}
4539	}
4540
4541	/// Normalize operation for semantic comparison
4542	///
4543	/// Returns a normalized version where order-independent elements are sorted.
4544	/// This enables detection of semantically equivalent operations regardless of element ordering.
4545	fn normalize(&self) -> Self
4546	where
4547		Self: Sized + Clone,
4548	{
4549		match self {
4550			// CreateTable: Sort columns and constraints
4551			Operation::CreateTable {
4552				name,
4553				columns,
4554				constraints,
4555				without_rowid,
4556				interleave_in_parent,
4557				partition,
4558			} => {
4559				let mut sorted_columns = columns.clone();
4560				sorted_columns.sort_by(|a, b| a.name.cmp(&b.name));
4561
4562				let mut sorted_constraints = constraints.clone();
4563				sorted_constraints.sort();
4564
4565				Operation::CreateTable {
4566					name: name.clone(),
4567					columns: sorted_columns,
4568					constraints: sorted_constraints,
4569					without_rowid: *without_rowid,
4570					interleave_in_parent: interleave_in_parent.clone(),
4571					partition: partition.clone(),
4572				}
4573			}
4574			// CreateIndex: Sort columns
4575			Operation::CreateIndex {
4576				table,
4577				columns,
4578				unique,
4579				index_type,
4580				where_clause,
4581				concurrently,
4582				expressions,
4583				mysql_options,
4584				operator_class,
4585			} => {
4586				let mut sorted_columns = columns.clone();
4587				sorted_columns.sort();
4588
4589				Operation::CreateIndex {
4590					table: table.clone(),
4591					columns: sorted_columns,
4592					unique: *unique,
4593					index_type: *index_type,
4594					where_clause: where_clause.clone(),
4595					concurrently: *concurrently,
4596					expressions: expressions.clone(),
4597					mysql_options: *mysql_options,
4598					operator_class: operator_class.clone(),
4599				}
4600			}
4601			// DropIndex: Sort columns
4602			Operation::DropIndex { table, columns } => {
4603				let mut sorted_columns = columns.clone();
4604				sorted_columns.sort();
4605
4606				Operation::DropIndex {
4607					table: table.clone(),
4608					columns: sorted_columns,
4609				}
4610			}
4611			// AlterUniqueTogether: Sort field lists and sort within each list
4612			Operation::AlterUniqueTogether {
4613				table,
4614				unique_together,
4615			} => {
4616				let mut sorted_unique_together: Vec<Vec<String>> = unique_together
4617					.iter()
4618					.map(|field_list| {
4619						let mut sorted = field_list.clone();
4620						sorted.sort();
4621						sorted
4622					})
4623					.collect();
4624				sorted_unique_together.sort();
4625
4626				Operation::AlterUniqueTogether {
4627					table: table.clone(),
4628					unique_together: sorted_unique_together,
4629				}
4630			}
4631			// AlterModelOptions: HashMap cannot be sorted, but we can normalize by converting to sorted Vec
4632			// However, since HashMap doesn't guarantee order and the operation uses HashMap,
4633			// we'll just clone it as-is. For true semantic equality, this would need to be changed
4634			// to a BTreeMap at the type level.
4635			Operation::AlterModelOptions { table, options } => Operation::AlterModelOptions {
4636				table: table.clone(),
4637				options: options.clone(),
4638			},
4639			// All other operations: Return clone (order doesn't matter or not applicable)
4640			_ => self.clone(),
4641		}
4642	}
4643}
4644
4645#[cfg(test)]
4646mod tests {
4647	use super::*;
4648	use FieldType;
4649	use rstest::rstest;
4650
4651	#[test]
4652	fn test_create_table_to_statement() {
4653		let op = Operation::CreateTable {
4654			name: "users".to_string(),
4655			columns: vec![
4656				ColumnDefinition {
4657					name: "id".to_string(),
4658					type_definition: FieldType::Integer,
4659					not_null: false,
4660					unique: false,
4661					primary_key: true,
4662					auto_increment: true,
4663					default: None,
4664				},
4665				ColumnDefinition {
4666					name: "name".to_string(),
4667					type_definition: FieldType::VarChar(100),
4668					not_null: true,
4669					unique: false,
4670					primary_key: false,
4671					auto_increment: false,
4672					default: None,
4673				},
4674			],
4675			constraints: vec![],
4676			without_rowid: None,
4677			partition: None,
4678			interleave_in_parent: None,
4679		};
4680
4681		let stmt = op.to_statement();
4682		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4683		assert!(
4684			sql.contains("CREATE TABLE"),
4685			"SQL should contain CREATE TABLE keyword, got: {}",
4686			sql
4687		);
4688		assert!(
4689			sql.contains("users"),
4690			"SQL should reference 'users' table, got: {}",
4691			sql
4692		);
4693		assert!(
4694			sql.contains("id") && sql.contains("name"),
4695			"SQL should contain both 'id' and 'name' columns, got: {}",
4696			sql
4697		);
4698	}
4699
4700	#[test]
4701	fn test_drop_table_to_statement() {
4702		let op = Operation::DropTable {
4703			name: "users".to_string(),
4704		};
4705
4706		let stmt = op.to_statement();
4707		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4708		assert!(
4709			sql.contains("DROP TABLE"),
4710			"SQL should contain DROP TABLE keyword, got: {}",
4711			sql
4712		);
4713		assert!(
4714			sql.contains("users"),
4715			"SQL should reference 'users' table, got: {}",
4716			sql
4717		);
4718		assert!(
4719			sql.contains("CASCADE"),
4720			"SQL should include CASCADE option, got: {}",
4721			sql
4722		);
4723	}
4724
4725	#[test]
4726	fn test_add_column_to_statement() {
4727		let op = Operation::AddColumn {
4728			table: "users".to_string(),
4729			column: ColumnDefinition {
4730				name: "email".to_string(),
4731				type_definition: FieldType::VarChar(255),
4732				not_null: true,
4733				unique: false,
4734				primary_key: false,
4735				auto_increment: false,
4736				default: Some("''".to_string()),
4737			},
4738			mysql_options: None,
4739		};
4740
4741		let stmt = op.to_statement();
4742		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4743		assert!(
4744			sql.contains("ALTER TABLE"),
4745			"SQL should contain ALTER TABLE keyword, got: {}",
4746			sql
4747		);
4748		assert!(
4749			sql.contains("users"),
4750			"SQL should reference 'users' table, got: {}",
4751			sql
4752		);
4753		assert!(
4754			sql.contains("ADD COLUMN"),
4755			"SQL should contain ADD COLUMN clause, got: {}",
4756			sql
4757		);
4758		assert!(
4759			sql.contains("email"),
4760			"SQL should reference 'email' column, got: {}",
4761			sql
4762		);
4763	}
4764
4765	#[test]
4766	fn test_drop_column_to_statement() {
4767		let op = Operation::DropColumn {
4768			table: "users".to_string(),
4769			column: "email".to_string(),
4770		};
4771
4772		let stmt = op.to_statement();
4773		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4774		assert!(
4775			sql.contains("ALTER TABLE"),
4776			"SQL should contain ALTER TABLE keyword, got: {}",
4777			sql
4778		);
4779		assert!(
4780			sql.contains("users"),
4781			"SQL should reference 'users' table, got: {}",
4782			sql
4783		);
4784		assert!(
4785			sql.contains("DROP COLUMN"),
4786			"SQL should contain DROP COLUMN clause, got: {}",
4787			sql
4788		);
4789		assert!(
4790			sql.contains("email"),
4791			"SQL should reference 'email' column, got: {}",
4792			sql
4793		);
4794	}
4795
4796	#[test]
4797	fn test_alter_column_to_statement() {
4798		let op = Operation::AlterColumn {
4799			table: "users".to_string(),
4800			column: "age".to_string(),
4801			old_definition: None,
4802			new_definition: ColumnDefinition {
4803				name: "age".to_string(),
4804				type_definition: FieldType::BigInteger,
4805				not_null: true,
4806				unique: false,
4807				primary_key: false,
4808				auto_increment: false,
4809				default: None,
4810			},
4811			mysql_options: None,
4812		};
4813
4814		let stmt = op.to_statement();
4815		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4816		assert!(
4817			sql.contains("ALTER TABLE"),
4818			"SQL should contain ALTER TABLE keyword, got: {}",
4819			sql
4820		);
4821		assert!(
4822			sql.contains("users"),
4823			"SQL should reference 'users' table, got: {}",
4824			sql
4825		);
4826		assert!(
4827			sql.contains("age"),
4828			"SQL should reference 'age' column, got: {}",
4829			sql
4830		);
4831	}
4832
4833	#[test]
4834	fn test_rename_table_to_statement() {
4835		let op = Operation::RenameTable {
4836			old_name: "users".to_string(),
4837			new_name: "accounts".to_string(),
4838		};
4839
4840		let stmt = op.to_statement();
4841		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4842		assert!(
4843			sql.contains("users"),
4844			"SQL should reference old table name 'users', got: {}",
4845			sql
4846		);
4847		assert!(
4848			sql.contains("accounts"),
4849			"SQL should reference new table name 'accounts', got: {}",
4850			sql
4851		);
4852	}
4853
4854	#[test]
4855	fn test_rename_column_to_statement() {
4856		let op = Operation::RenameColumn {
4857			table: "users".to_string(),
4858			old_name: "name".to_string(),
4859			new_name: "full_name".to_string(),
4860		};
4861
4862		let stmt = op.to_statement();
4863		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4864		assert!(
4865			sql.contains("ALTER TABLE"),
4866			"SQL should contain ALTER TABLE keyword, got: {}",
4867			sql
4868		);
4869		assert!(
4870			sql.contains("users"),
4871			"SQL should reference 'users' table, got: {}",
4872			sql
4873		);
4874		assert!(
4875			sql.contains("RENAME COLUMN"),
4876			"SQL should contain RENAME COLUMN clause, got: {}",
4877			sql
4878		);
4879		assert!(
4880			sql.contains("name"),
4881			"SQL should reference old column name 'name', got: {}",
4882			sql
4883		);
4884		assert!(
4885			sql.contains("full_name"),
4886			"SQL should reference new column name 'full_name', got: {}",
4887			sql
4888		);
4889	}
4890
4891	#[test]
4892	fn test_add_constraint_to_statement() {
4893		let op = Operation::AddConstraint {
4894			table: "users".to_string(),
4895			constraint_sql: "CONSTRAINT age_check CHECK (age >= 0)".to_string(),
4896		};
4897
4898		let stmt = op.to_statement();
4899		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4900		assert!(
4901			sql.contains("ALTER TABLE"),
4902			"SQL should contain ALTER TABLE keyword, got: {}",
4903			sql
4904		);
4905		assert!(
4906			sql.contains("users"),
4907			"SQL should reference 'users' table, got: {}",
4908			sql
4909		);
4910		assert!(
4911			sql.contains("ADD"),
4912			"SQL should contain ADD keyword, got: {}",
4913			sql
4914		);
4915		assert!(
4916			sql.contains("age_check"),
4917			"SQL should contain constraint name 'age_check', got: {}",
4918			sql
4919		);
4920	}
4921
4922	#[test]
4923	fn test_drop_constraint_to_statement() {
4924		let op = Operation::DropConstraint {
4925			table: "users".to_string(),
4926			constraint_name: "age_check".to_string(),
4927		};
4928
4929		let stmt = op.to_statement();
4930		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4931		assert!(
4932			sql.contains("ALTER TABLE"),
4933			"SQL should contain ALTER TABLE keyword, got: {}",
4934			sql
4935		);
4936		assert!(
4937			sql.contains("users"),
4938			"SQL should reference 'users' table, got: {}",
4939			sql
4940		);
4941		assert!(
4942			sql.contains("DROP CONSTRAINT"),
4943			"SQL should contain DROP CONSTRAINT clause, got: {}",
4944			sql
4945		);
4946		assert!(
4947			sql.contains("age_check"),
4948			"SQL should reference constraint 'age_check', got: {}",
4949			sql
4950		);
4951	}
4952
4953	#[test]
4954	fn test_create_index_to_statement() {
4955		let op = Operation::CreateIndex {
4956			table: "users".to_string(),
4957			columns: vec!["email".to_string()],
4958			unique: false,
4959			index_type: None,
4960			where_clause: None,
4961			concurrently: false,
4962			expressions: None,
4963			mysql_options: None,
4964			operator_class: None,
4965		};
4966
4967		let stmt = op.to_statement();
4968		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
4969		assert!(
4970			sql.contains("CREATE INDEX"),
4971			"SQL should contain CREATE INDEX keywords, got: {}",
4972			sql
4973		);
4974		assert!(
4975			sql.contains("users"),
4976			"SQL should reference 'users' table, got: {}",
4977			sql
4978		);
4979		assert!(
4980			sql.contains("email"),
4981			"SQL should reference 'email' column, got: {}",
4982			sql
4983		);
4984	}
4985
4986	#[test]
4987	fn test_create_unique_index_to_statement() {
4988		let op = Operation::CreateIndex {
4989			table: "users".to_string(),
4990			columns: vec!["email".to_string()],
4991			unique: true,
4992			index_type: None,
4993			where_clause: None,
4994			concurrently: false,
4995			expressions: None,
4996			mysql_options: None,
4997			operator_class: None,
4998		};
4999
5000		let stmt = op.to_statement();
5001		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5002		assert!(
5003			sql.contains("CREATE UNIQUE INDEX"),
5004			"SQL should contain CREATE UNIQUE INDEX keywords, got: {}",
5005			sql
5006		);
5007		assert!(
5008			sql.contains("users"),
5009			"SQL should reference 'users' table, got: {}",
5010			sql
5011		);
5012		assert!(
5013			sql.contains("email"),
5014			"SQL should reference 'email' column, got: {}",
5015			sql
5016		);
5017	}
5018
5019	#[test]
5020	fn test_drop_index_to_statement() {
5021		let op = Operation::DropIndex {
5022			table: "users".to_string(),
5023			columns: vec!["email".to_string()],
5024		};
5025
5026		let stmt = op.to_statement();
5027		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5028		assert!(
5029			sql.contains("DROP INDEX"),
5030			"SQL should contain DROP INDEX keywords, got: {}",
5031			sql
5032		);
5033		assert!(
5034			sql.contains("idx_users_email"),
5035			"SQL should contain generated index name 'idx_users_email', got: {}",
5036			sql
5037		);
5038	}
5039
5040	#[test]
5041	fn test_run_sql_to_statement() {
5042		let op = Operation::RunSQL {
5043			sql: "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"".to_string(),
5044			reverse_sql: Some("DROP EXTENSION \"uuid-ossp\"".to_string()),
5045		};
5046
5047		let stmt = op.to_statement();
5048		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5049		assert!(
5050			sql.contains("CREATE EXTENSION"),
5051			"SQL should contain CREATE EXTENSION keywords, got: {}",
5052			sql
5053		);
5054		assert!(
5055			sql.contains("uuid-ossp"),
5056			"SQL should reference 'uuid-ossp' extension, got: {}",
5057			sql
5058		);
5059	}
5060
5061	#[test]
5062	fn test_alter_table_comment_to_statement() {
5063		let op = Operation::AlterTableComment {
5064			table: "users".to_string(),
5065			comment: Some("User accounts table".to_string()),
5066		};
5067
5068		let stmt = op.to_statement();
5069		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5070		assert!(
5071			sql.contains("COMMENT ON TABLE"),
5072			"SQL should contain COMMENT ON TABLE keywords, got: {}",
5073			sql
5074		);
5075		assert!(
5076			sql.contains("users"),
5077			"SQL should reference 'users' table, got: {}",
5078			sql
5079		);
5080		assert!(
5081			sql.contains("User accounts table"),
5082			"SQL should include comment text 'User accounts table', got: {}",
5083			sql
5084		);
5085	}
5086
5087	#[test]
5088	fn test_alter_table_comment_null_to_statement() {
5089		let op = Operation::AlterTableComment {
5090			table: "users".to_string(),
5091			comment: None,
5092		};
5093
5094		let stmt = op.to_statement();
5095		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5096		assert!(
5097			sql.contains("COMMENT ON TABLE"),
5098			"SQL should contain COMMENT ON TABLE keywords, got: {}",
5099			sql
5100		);
5101		assert!(
5102			sql.contains("users"),
5103			"SQL should reference 'users' table, got: {}",
5104			sql
5105		);
5106		assert!(
5107			sql.contains("NULL"),
5108			"SQL should include NULL for null comment, got: {}",
5109			sql
5110		);
5111	}
5112
5113	#[test]
5114	fn test_alter_unique_together_to_statement() {
5115		let op = Operation::AlterUniqueTogether {
5116			table: "users".to_string(),
5117			unique_together: vec![vec!["email".to_string(), "username".to_string()]],
5118		};
5119
5120		let stmt = op.to_statement();
5121		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5122		assert!(
5123			sql.contains("ALTER TABLE"),
5124			"SQL should contain ALTER TABLE keyword, got: {}",
5125			sql
5126		);
5127		assert!(
5128			sql.contains("users"),
5129			"SQL should reference 'users' table, got: {}",
5130			sql
5131		);
5132		assert!(
5133			sql.contains("ADD CONSTRAINT"),
5134			"SQL should contain ADD CONSTRAINT clause, got: {}",
5135			sql
5136		);
5137		assert!(
5138			sql.contains("UNIQUE"),
5139			"SQL should contain UNIQUE keyword, got: {}",
5140			sql
5141		);
5142		assert!(
5143			sql.contains("email") && sql.contains("username"),
5144			"SQL should reference both 'email' and 'username' columns, got: {}",
5145			sql
5146		);
5147	}
5148
5149	#[test]
5150	fn test_alter_unique_together_empty() {
5151		let op = Operation::AlterUniqueTogether {
5152			table: "users".to_string(),
5153			unique_together: vec![],
5154		};
5155
5156		let stmt = op.to_statement();
5157		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5158		assert_eq!(
5159			sql, "",
5160			"SQL should be empty for empty unique_together constraint"
5161		);
5162	}
5163
5164	#[test]
5165	fn test_alter_model_options_to_statement() {
5166		let mut options = std::collections::HashMap::new();
5167		options.insert("db_table".to_string(), "custom_users".to_string());
5168
5169		let op = Operation::AlterModelOptions {
5170			table: "users".to_string(),
5171			options,
5172		};
5173
5174		let stmt = op.to_statement();
5175		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5176		assert_eq!(sql, "", "SQL should be empty for model options operation");
5177	}
5178
5179	#[test]
5180	fn test_create_inherited_table_to_statement() {
5181		let op = Operation::CreateInheritedTable {
5182			name: "admin_users".to_string(),
5183			columns: vec![ColumnDefinition {
5184				name: "admin_level".to_string(),
5185				type_definition: FieldType::Integer,
5186				not_null: true,
5187				unique: false,
5188				primary_key: false,
5189				auto_increment: false,
5190				default: Some("1".to_string()),
5191			}],
5192			base_table: "users".to_string(),
5193			join_column: "user_id".to_string(),
5194		};
5195
5196		let stmt = op.to_statement();
5197		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5198		assert!(
5199			sql.contains("CREATE TABLE"),
5200			"SQL should contain CREATE TABLE keywords, got: {}",
5201			sql
5202		);
5203		assert!(
5204			sql.contains("admin_users"),
5205			"SQL should reference 'admin_users' table, got: {}",
5206			sql
5207		);
5208		assert!(
5209			sql.contains("user_id"),
5210			"SQL should include join column 'user_id', got: {}",
5211			sql
5212		);
5213	}
5214
5215	#[test]
5216	fn test_add_discriminator_column_to_statement() {
5217		let op = Operation::AddDiscriminatorColumn {
5218			table: "users".to_string(),
5219			column_name: "user_type".to_string(),
5220			default_value: "regular".to_string(),
5221		};
5222
5223		let stmt = op.to_statement();
5224		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
5225		assert!(
5226			sql.contains("ALTER TABLE"),
5227			"SQL should contain ALTER TABLE keyword, got: {}",
5228			sql
5229		);
5230		assert!(
5231			sql.contains("users"),
5232			"SQL should reference 'users' table, got: {}",
5233			sql
5234		);
5235		assert!(
5236			sql.contains("ADD COLUMN"),
5237			"SQL should contain ADD COLUMN clause, got: {}",
5238			sql
5239		);
5240		assert!(
5241			sql.contains("user_type"),
5242			"SQL should reference 'user_type' column, got: {}",
5243			sql
5244		);
5245	}
5246
5247	#[test]
5248	fn test_state_forwards_create_table() {
5249		let mut state = ProjectState::new();
5250		let op = Operation::CreateTable {
5251			name: "users".to_string(),
5252			columns: vec![
5253				ColumnDefinition {
5254					name: "id".to_string(),
5255					type_definition: FieldType::Integer,
5256					not_null: false,
5257					unique: false,
5258					primary_key: true,
5259					auto_increment: true,
5260					default: None,
5261				},
5262				ColumnDefinition {
5263					name: "name".to_string(),
5264					type_definition: FieldType::VarChar(100),
5265					not_null: true,
5266					unique: false,
5267					primary_key: false,
5268					auto_increment: false,
5269					default: None,
5270				},
5271			],
5272			constraints: vec![],
5273			without_rowid: None,
5274			partition: None,
5275			interleave_in_parent: None,
5276		};
5277
5278		op.state_forwards("myapp", &mut state);
5279		let model = state.get_model("myapp", "users");
5280		assert!(model.is_some(), "Model 'users' should exist in state");
5281		let model = model.unwrap();
5282		assert_eq!(
5283			model.fields.len(),
5284			2,
5285			"Model should have exactly 2 fields, got: {}",
5286			model.fields.len()
5287		);
5288		assert!(
5289			model.fields.contains_key("id"),
5290			"Model should contain 'id' field"
5291		);
5292		assert!(
5293			model.fields.contains_key("name"),
5294			"Model should contain 'name' field"
5295		);
5296	}
5297
5298	#[test]
5299	fn test_state_forwards_drop_table() {
5300		let mut state = ProjectState::new();
5301		let mut model = ModelState::new("myapp", "users");
5302		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5303		state.add_model(model);
5304
5305		let op = Operation::DropTable {
5306			name: "users".to_string(),
5307		};
5308
5309		op.state_forwards("myapp", &mut state);
5310		assert!(
5311			state.get_model("myapp", "users").is_none(),
5312			"Model 'users' should be removed from state after drop"
5313		);
5314	}
5315
5316	#[test]
5317	fn test_state_forwards_add_column() {
5318		let mut state = ProjectState::new();
5319		let mut model = ModelState::new("myapp", "users");
5320		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5321		state.add_model(model);
5322
5323		let op = Operation::AddColumn {
5324			table: "users".to_string(),
5325			column: ColumnDefinition {
5326				name: "email".to_string(),
5327				type_definition: FieldType::VarChar(255),
5328				not_null: true,
5329				unique: false,
5330				primary_key: false,
5331				auto_increment: false,
5332				default: None,
5333			},
5334			mysql_options: None,
5335		};
5336
5337		op.state_forwards("myapp", &mut state);
5338		let model = state.get_model("myapp", "users").unwrap();
5339		assert_eq!(
5340			model.fields.len(),
5341			2,
5342			"Model should have 2 fields after adding 'email', got: {}",
5343			model.fields.len()
5344		);
5345		assert!(
5346			model.fields.contains_key("email"),
5347			"Model should contain newly added 'email' field"
5348		);
5349	}
5350
5351	#[test]
5352	fn test_state_forwards_drop_column() {
5353		let mut state = ProjectState::new();
5354		let mut model = ModelState::new("myapp", "users");
5355		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5356		model.add_field(FieldState::new(
5357			"email".to_string(),
5358			FieldType::VarChar(255),
5359			false,
5360		));
5361		state.add_model(model);
5362
5363		let op = Operation::DropColumn {
5364			table: "users".to_string(),
5365			column: "email".to_string(),
5366		};
5367
5368		op.state_forwards("myapp", &mut state);
5369		let model = state.get_model("myapp", "users").unwrap();
5370		assert_eq!(
5371			model.fields.len(),
5372			1,
5373			"Model should have 1 field after dropping 'email', got: {}",
5374			model.fields.len()
5375		);
5376		assert!(
5377			!model.fields.contains_key("email"),
5378			"Model should not contain dropped 'email' field"
5379		);
5380	}
5381
5382	#[test]
5383	fn test_state_forwards_rename_table() {
5384		let mut state = ProjectState::new();
5385		let mut model = ModelState::new("myapp", "users");
5386		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
5387		state.add_model(model);
5388
5389		let op = Operation::RenameTable {
5390			old_name: "users".to_string(),
5391			new_name: "accounts".to_string(),
5392		};
5393
5394		op.state_forwards("myapp", &mut state);
5395		assert!(
5396			state.get_model("myapp", "users").is_none(),
5397			"Old model name 'users' should not exist after rename"
5398		);
5399		assert!(
5400			state.get_model("myapp", "accounts").is_some(),
5401			"New model name 'accounts' should exist after rename"
5402		);
5403	}
5404
5405	#[test]
5406	fn test_state_forwards_rename_column() {
5407		let mut state = ProjectState::new();
5408		let mut model = ModelState::new("myapp", "users");
5409		model.add_field(FieldState::new(
5410			"name".to_string(),
5411			FieldType::VarChar(255),
5412			false,
5413		));
5414		state.add_model(model);
5415
5416		let op = Operation::RenameColumn {
5417			table: "users".to_string(),
5418			old_name: "name".to_string(),
5419			new_name: "full_name".to_string(),
5420		};
5421
5422		op.state_forwards("myapp", &mut state);
5423		let model = state.get_model("myapp", "users").unwrap();
5424		assert!(
5425			!model.fields.contains_key("name"),
5426			"Old field name 'name' should not exist after rename"
5427		);
5428		assert!(
5429			model.fields.contains_key("full_name"),
5430			"New field name 'full_name' should exist after rename"
5431		);
5432	}
5433
5434	#[test]
5435	fn test_to_reverse_sql_create_table() {
5436		let op = Operation::CreateTable {
5437			name: "users".to_string(),
5438			columns: vec![],
5439			constraints: vec![],
5440			without_rowid: None,
5441			partition: None,
5442			interleave_in_parent: None,
5443		};
5444
5445		let state = ProjectState::default();
5446		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5447		assert!(
5448			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_some(),
5449			"CreateTable should have reverse SQL operation"
5450		);
5451		let sql = reverse.unwrap().unwrap().join("\n");
5452		assert!(
5453			sql.contains("DROP TABLE"),
5454			"Reverse SQL should contain DROP TABLE, got: {}",
5455			sql
5456		);
5457		assert!(
5458			sql.contains("users"),
5459			"Reverse SQL should reference 'users' table, got: {}",
5460			sql
5461		);
5462	}
5463
5464	#[test]
5465	fn test_to_reverse_sql_drop_table() {
5466		let op = Operation::DropTable {
5467			name: "users".to_string(),
5468		};
5469
5470		let state = ProjectState::default();
5471		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5472		assert!(
5473			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_none(),
5474			"DropTable should not have reverse SQL (cannot recreate table structure)"
5475		);
5476	}
5477
5478	#[test]
5479	fn test_to_reverse_sql_add_column() {
5480		let op = Operation::AddColumn {
5481			table: "users".to_string(),
5482			column: ColumnDefinition {
5483				name: "email".to_string(),
5484				type_definition: FieldType::VarChar(255),
5485				not_null: false,
5486				unique: false,
5487				primary_key: false,
5488				auto_increment: false,
5489				default: None,
5490			},
5491			mysql_options: None,
5492		};
5493
5494		let state = ProjectState::default();
5495		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5496		assert!(
5497			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_some(),
5498			"AddColumn should have reverse SQL operation"
5499		);
5500		let sql = reverse.unwrap().unwrap().join("\n");
5501		assert!(
5502			sql.contains("DROP COLUMN"),
5503			"Reverse SQL should contain DROP COLUMN, got: {}",
5504			sql
5505		);
5506		assert!(
5507			sql.contains("email"),
5508			"Reverse SQL should reference 'email' column, got: {}",
5509			sql
5510		);
5511	}
5512
5513	/// Build an [`Operation::AlterColumn`] that carries an `old_definition`,
5514	/// so reverse-SQL generation has all the inputs it needs without a
5515	/// populated `ProjectState`. Used by the dialect-dispatch regression
5516	/// tests for reinhardt-web#4582.
5517	fn alter_column_with_old_def() -> Operation {
5518		Operation::AlterColumn {
5519			table: "products".to_string(),
5520			column: "name".to_string(),
5521			old_definition: Some(ColumnDefinition {
5522				name: "name".to_string(),
5523				type_definition: FieldType::VarChar(50),
5524				not_null: false,
5525				unique: false,
5526				primary_key: false,
5527				auto_increment: false,
5528				default: None,
5529			}),
5530			new_definition: ColumnDefinition {
5531				name: "name".to_string(),
5532				type_definition: FieldType::Text,
5533				not_null: false,
5534				unique: false,
5535				primary_key: false,
5536				auto_increment: false,
5537				default: None,
5538			},
5539			mysql_options: None,
5540		}
5541	}
5542
5543	/// Reverse SQL for Postgres must use `ALTER COLUMN ... TYPE` syntax
5544	/// (regression coverage for reinhardt-web#4582).
5545	#[test]
5546	fn test_to_reverse_sql_alter_column_postgres() {
5547		// Arrange
5548		let op = alter_column_with_old_def();
5549		let state = ProjectState::default();
5550
5551		// Act: reverse SQL is now returned as a multi-statement `Vec<String>`
5552		// (see `Operation::to_reverse_sql` doc); flatten to a single string for
5553		// the substring-based assertions below.
5554		let stmts = op
5555			.to_reverse_sql(&SqlDialect::Postgres, &state)
5556			.expect("reverse SQL should succeed")
5557			.expect("reverse SQL should be present");
5558		let sql = stmts.join("\n");
5559
5560		// Assert
5561		assert!(
5562			sql.contains("ALTER COLUMN") && sql.contains("TYPE"),
5563			"Postgres reverse SQL should use ALTER COLUMN ... TYPE syntax, got: {}",
5564			sql
5565		);
5566		assert!(
5567			sql.contains("VARCHAR(50)"),
5568			"Postgres reverse SQL should restore VARCHAR(50), got: {}",
5569			sql
5570		);
5571		// Multi-statement contract (#4640): type reversion and nullability
5572		// restoration are emitted as two independent statements so
5573		// `SchemaEditor::execute()` (sqlx Extended Query) can dispatch each
5574		// payload without rejection.
5575		assert_eq!(
5576			stmts.len(),
5577			2,
5578			"Postgres AlterColumn reverse SQL must emit two statements \
5579			 (type + nullability), got: {:?}",
5580			stmts
5581		);
5582		assert!(
5583			stmts[1].contains("DROP NOT NULL"),
5584			"Postgres second statement must restore DROP NOT NULL (was_nullable), got: {}",
5585			stmts[1]
5586		);
5587	}
5588
5589	/// Reverse SQL for MySQL must use `MODIFY COLUMN` syntax — the previous
5590	/// implementation emitted Postgres `ALTER COLUMN ... TYPE` which MySQL
5591	/// rejects with error 1064 (reinhardt-web#4582).
5592	#[test]
5593	fn test_to_reverse_sql_alter_column_mysql() {
5594		// Arrange
5595		let op = alter_column_with_old_def();
5596		let state = ProjectState::default();
5597
5598		// Act: MySQL AlterColumn reverse SQL remains a single `MODIFY COLUMN`
5599		// statement (one-element `Vec`).
5600		let stmts = op
5601			.to_reverse_sql(&SqlDialect::Mysql, &state)
5602			.expect("reverse SQL should succeed")
5603			.expect("reverse SQL should be present");
5604		assert_eq!(
5605			stmts.len(),
5606			1,
5607			"MySQL AlterColumn reverse SQL should remain a single statement, got: {:?}",
5608			stmts
5609		);
5610		let sql = stmts.join("\n");
5611
5612		// Assert
5613		assert!(
5614			sql.contains("MODIFY COLUMN"),
5615			"MySQL reverse SQL should use MODIFY COLUMN syntax, got: {}",
5616			sql
5617		);
5618		assert!(
5619			!sql.contains("ALTER COLUMN"),
5620			"MySQL reverse SQL must not emit Postgres ALTER COLUMN syntax, got: {}",
5621			sql
5622		);
5623		assert!(
5624			!sql.contains(" TYPE "),
5625			"MySQL reverse SQL must not contain Postgres ' TYPE ' token, got: {}",
5626			sql
5627		);
5628		assert!(
5629			sql.contains("VARCHAR(50)"),
5630			"MySQL reverse SQL should restore VARCHAR(50), got: {}",
5631			sql
5632		);
5633	}
5634
5635	#[rstest]
5636	#[case::postgres(SqlDialect::Postgres)]
5637	#[case::cockroachdb(SqlDialect::Cockroachdb)]
5638	fn test_to_sql_alter_column_sets_default_for_postgres_family(#[case] dialect: SqlDialect) {
5639		// Arrange
5640		let op = Operation::AlterColumn {
5641			table: "users".to_string(),
5642			column: "is_active".to_string(),
5643			old_definition: Some(ColumnDefinition {
5644				name: "is_active".to_string(),
5645				type_definition: FieldType::Boolean,
5646				not_null: true,
5647				unique: false,
5648				primary_key: false,
5649				auto_increment: false,
5650				default: None,
5651			}),
5652			new_definition: ColumnDefinition {
5653				name: "is_active".to_string(),
5654				type_definition: FieldType::Boolean,
5655				not_null: true,
5656				unique: false,
5657				primary_key: false,
5658				auto_increment: false,
5659				default: Some("true".to_string()),
5660			},
5661			mysql_options: None,
5662		};
5663
5664		// Act
5665		let sql = op.to_sql(&dialect);
5666
5667		// Assert
5668		assert!(
5669			sql.contains("ALTER COLUMN is_active SET DEFAULT true"),
5670			"AlterColumn must apply new database defaults, got: {}",
5671			sql
5672		);
5673	}
5674
5675	#[rstest]
5676	#[case::postgres(SqlDialect::Postgres)]
5677	#[case::cockroachdb(SqlDialect::Cockroachdb)]
5678	fn test_to_sql_alter_column_drops_default_for_postgres_family(#[case] dialect: SqlDialect) {
5679		// Arrange
5680		let op = Operation::AlterColumn {
5681			table: "users".to_string(),
5682			column: "is_active".to_string(),
5683			old_definition: Some(ColumnDefinition {
5684				name: "is_active".to_string(),
5685				type_definition: FieldType::Boolean,
5686				not_null: true,
5687				unique: false,
5688				primary_key: false,
5689				auto_increment: false,
5690				default: Some("true".to_string()),
5691			}),
5692			new_definition: ColumnDefinition {
5693				name: "is_active".to_string(),
5694				type_definition: FieldType::Boolean,
5695				not_null: true,
5696				unique: false,
5697				primary_key: false,
5698				auto_increment: false,
5699				default: None,
5700			},
5701			mysql_options: None,
5702		};
5703
5704		// Act
5705		let sql = op.to_sql(&dialect);
5706
5707		// Assert
5708		assert!(
5709			sql.contains("ALTER COLUMN is_active DROP DEFAULT"),
5710			"AlterColumn must remove dropped database defaults, got: {}",
5711			sql
5712		);
5713	}
5714
5715	#[test]
5716	fn test_to_sql_alter_column_mysql_preserves_full_column_definition() {
5717		// Arrange
5718		let op = Operation::AlterColumn {
5719			table: "users".to_string(),
5720			column: "is_active".to_string(),
5721			old_definition: None,
5722			new_definition: ColumnDefinition {
5723				name: "is_active".to_string(),
5724				type_definition: FieldType::Boolean,
5725				not_null: true,
5726				unique: false,
5727				primary_key: false,
5728				auto_increment: false,
5729				default: Some("true".to_string()),
5730			},
5731			mysql_options: None,
5732		};
5733
5734		// Act
5735		let sql = op.to_sql(&SqlDialect::Mysql);
5736
5737		// Assert
5738		assert!(
5739			sql.contains("MODIFY COLUMN is_active TINYINT(1) NOT NULL DEFAULT true"),
5740			"MySQL AlterColumn must include type, nullability, and default, got: {}",
5741			sql
5742		);
5743	}
5744
5745	/// Reverse SQL for CockroachDB must emit the column-type reversion **and**
5746	/// the nullability restoration as two independent single-statement payloads
5747	/// — the same shape PostgreSQL uses post-#4640.
5748	///
5749	/// History: PR #4633 split CockroachDB off from PostgreSQL because
5750	/// CockroachDB rejects the comma-combined `ALTER COLUMN ... TYPE T,
5751	/// ALTER COLUMN ... {SET|DROP} NOT NULL` form that PostgreSQL accepts.
5752	/// The interim stop-gap (#4633) emitted only the column-type reversion
5753	/// and dropped NOT NULL rollback fidelity. This Issue (#4640) restored
5754	/// full fidelity by changing `Operation::to_reverse_sql` to return
5755	/// `Vec<String>`, so each statement is dispatched separately by
5756	/// `SchemaEditor::execute()` (sqlx Extended Query, single-statement).
5757	///
5758	/// This test pins the post-#4640 multi-statement contract: a future
5759	/// regression that re-folds CockroachDB into a single payload (and thus
5760	/// drops nullability) — or that emits the Postgres comma-combined form —
5761	/// will fail here at `cargo test` time instead of at downstream
5762	/// CockroachDB deployments.
5763	///
5764	/// Identifier quoting note: `quote_identifier` here is
5765	/// `pg_escape::quote_identifier`, which only quotes identifiers that fall
5766	/// outside PostgreSQL's unquoted-identifier grammar (anything outside
5767	/// `[a-z_][a-z0-9_]*`, or a reserved keyword). `products` and `name`
5768	/// are plain lowercase ASCII matching that grammar, so the expected
5769	/// output is unquoted. Identifier-quoting-by-default is tracked
5770	/// separately in #4674.
5771	#[test]
5772	fn test_to_reverse_sql_alter_column_cockroachdb() {
5773		// Arrange
5774		let op = alter_column_with_old_def();
5775		let state = ProjectState::default();
5776
5777		// Act
5778		let stmts = op
5779			.to_reverse_sql(&SqlDialect::Cockroachdb, &state)
5780			.expect("reverse SQL should succeed")
5781			.expect("reverse SQL should be present");
5782
5783		// Assert: exact two-element Vec, type reversion first, nullability
5784		// restoration second. `not_null` on the `old_definition` (see
5785		// `alter_column_with_old_def`) is `false`, so the second statement
5786		// is `DROP NOT NULL`. Asserting `==` (not `contains`) is
5787		// intentional — a permissive `contains` check would allow a future
5788		// regression to re-fold these into a single comma-combined payload
5789		// (which CockroachDB rejects).
5790		assert_eq!(
5791			stmts,
5792			vec![
5793				"ALTER TABLE products ALTER COLUMN name TYPE VARCHAR(50);".to_string(),
5794				"ALTER TABLE products ALTER COLUMN name DROP NOT NULL;".to_string(),
5795			],
5796			"CockroachDB reverse SQL must emit exactly [type_stmt, nullability_stmt], \
5797			 got: {:?}",
5798			stmts
5799		);
5800
5801		// Defensive: every emitted statement must be a single SQL statement.
5802		// `SchemaEditor::execute()` is a single-statement dispatcher (sqlx
5803		// Extended Query); a regression that re-introduces the Postgres
5804		// comma-combined form inside a single Vec element would re-break
5805		// CockroachDB rollback. Trim trailing `;` so the check looks only at
5806		// internal separators.
5807		for stmt in &stmts {
5808			let trimmed = stmt.trim().trim_end_matches(';').trim();
5809			assert!(
5810				!trimmed.contains(';'),
5811				"each emitted statement must be a single SQL statement, got: {}",
5812				stmt
5813			);
5814			assert!(
5815				!stmt.contains(", ALTER COLUMN"),
5816				"emitted statements must not use the Postgres comma-combined form \
5817				 (CockroachDB rejects it), got: {}",
5818				stmt
5819			);
5820		}
5821	}
5822
5823	/// SQLite has no general `ALTER COLUMN`; the rollback path is handled
5824	/// via table recreation in the executor. `to_reverse_sql` therefore
5825	/// returns an inert comment so that any accidental fall-through does
5826	/// not produce executable SQL that SQLite would reject
5827	/// (reinhardt-web#4582).
5828	#[test]
5829	fn test_to_reverse_sql_alter_column_sqlite() {
5830		// Arrange
5831		let op = alter_column_with_old_def();
5832		let state = ProjectState::default();
5833
5834		// Act: SQLite emits a single inert comment (executor recreates the
5835		// table via the SQLite-recreation path), so the returned `Vec` is
5836		// one element.
5837		let stmts = op
5838			.to_reverse_sql(&SqlDialect::Sqlite, &state)
5839			.expect("reverse SQL should succeed")
5840			.expect("reverse SQL should be present");
5841		assert_eq!(
5842			stmts.len(),
5843			1,
5844			"SQLite AlterColumn reverse SQL should remain a single comment, got: {:?}",
5845			stmts
5846		);
5847		let sql = &stmts[0];
5848
5849		// Assert
5850		assert!(
5851			sql.trim_start().starts_with("--"),
5852			"SQLite reverse SQL should be a SQL comment (recreation handled by executor), got: {}",
5853			sql
5854		);
5855		// Strip leading `--` (and any whitespace) before checking; the comment
5856		// body itself is allowed to mention ALTER COLUMN as English prose.
5857		let body = sql.trim_start_matches("--").trim_start();
5858		assert!(
5859			!body.to_uppercase().contains("ALTER TABLE"),
5860			"SQLite reverse SQL body must not emit executable ALTER TABLE statement, got: {}",
5861			sql
5862		);
5863	}
5864
5865	/// `to_reverse_operation` for `AlterColumn` must prefer the explicit
5866	/// `old_definition` carried by the forward operation, instead of
5867	/// always falling back to an (often empty) `ProjectState` lookup
5868	/// (reinhardt-web#4582).
5869	#[test]
5870	fn test_to_reverse_operation_alter_column_uses_old_definition() {
5871		// Arrange
5872		let op = alter_column_with_old_def();
5873		let state = ProjectState::default();
5874
5875		// Act
5876		let reverse = op
5877			.to_reverse_operation(&state)
5878			.expect("reverse operation should succeed")
5879			.expect("reverse operation should be present (old_definition is supplied)");
5880
5881		// Assert
5882		match reverse {
5883			Operation::AlterColumn { new_definition, .. } => {
5884				assert!(
5885					matches!(new_definition.type_definition, FieldType::VarChar(50)),
5886					"reverse AlterColumn should restore VARCHAR(50), got: {:?}",
5887					new_definition.type_definition
5888				);
5889			}
5890			other => panic!("reverse operation should be AlterColumn, got: {:?}", other),
5891		}
5892	}
5893
5894	#[test]
5895	fn test_to_reverse_sql_run_sql_with_reverse() {
5896		let op = Operation::RunSQL {
5897			sql: "CREATE INDEX idx_name ON users(name)".to_string(),
5898			reverse_sql: Some("DROP INDEX idx_name".to_string()),
5899		};
5900
5901		let state = ProjectState::default();
5902		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5903		assert!(
5904			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_some(),
5905			"RunSQL with reverse_sql should have reverse SQL"
5906		);
5907		let sql = reverse.unwrap().unwrap().join("\n");
5908		assert!(
5909			sql.contains("DROP INDEX"),
5910			"Reverse SQL should contain provided reverse_sql, got: {}",
5911			sql
5912		);
5913	}
5914
5915	#[test]
5916	fn test_to_reverse_sql_run_sql_without_reverse() {
5917		let op = Operation::RunSQL {
5918			sql: "CREATE INDEX idx_name ON users(name)".to_string(),
5919			reverse_sql: None,
5920		};
5921
5922		let state = ProjectState::default();
5923		let reverse = op.to_reverse_sql(&SqlDialect::Postgres, &state);
5924		assert!(
5925			reverse.is_ok() && reverse.as_ref().ok().unwrap().is_none(),
5926			"RunSQL without reverse_sql should not have reverse SQL"
5927		);
5928	}
5929
5930	#[test]
5931	fn test_column_definition_new() {
5932		let col = ColumnDefinition::new("id", FieldType::Integer);
5933		assert_eq!(col.name, "id", "Column name should be 'id'");
5934		assert_eq!(
5935			col.type_definition,
5936			FieldType::Integer,
5937			"Column type should be Integer"
5938		);
5939		assert!(!col.not_null, "not_null should default to false");
5940		assert!(!col.unique, "unique should default to false");
5941		assert!(!col.primary_key, "primary_key should default to false");
5942		assert!(
5943			!col.auto_increment,
5944			"auto_increment should default to false"
5945		);
5946		assert!(col.default.is_none(), "default should be None");
5947	}
5948
5949	// -----------------------------------------------------------------------
5950	// Regression tests for issue #4573:
5951	// `ColumnDefinition::from_field_state` previously read a non-existent
5952	// `params["not_null"]` key and silently emitted NULLABLE columns for
5953	// every non-PK field, violating the non-Optional Rust type ↔ NOT NULL
5954	// schema contract. These tests pin the corrected behavior:
5955	//   not_null = !field_state.nullable || primary_key
5956	// -----------------------------------------------------------------------
5957
5958	#[rstest]
5959	fn from_field_state_non_optional_bool_with_true_default() {
5960		// Arrange
5961		// Models a Rust field declared as `pub is_active: bool` with
5962		// `#[field(default = true)]`. The proc-macro sets
5963		// `field_state.nullable = false` via `FieldMetadata::with_nullable`,
5964		// and emits the default value as `params["default"] = "true"`.
5965		let mut field_state = FieldState::new("is_active", FieldType::Boolean, false);
5966		field_state
5967			.params
5968			.insert("default".to_string(), "true".to_string());
5969
5970		// Act
5971		let col = ColumnDefinition::from_field_state("is_active", &field_state);
5972
5973		// Assert
5974		assert_eq!(col.name, "is_active", "Column name should round-trip");
5975		assert_eq!(
5976			col.type_definition,
5977			FieldType::Boolean,
5978			"Boolean field type should round-trip"
5979		);
5980		assert!(
5981			col.not_null,
5982			"Non-Optional bool must emit NOT NULL (regression #4573)"
5983		);
5984		assert_eq!(
5985			col.default,
5986			Some("true".to_string()),
5987			"`#[field(default = true)]` must propagate as Some(\"true\")"
5988		);
5989		assert!(!col.primary_key, "Non-PK field must not be primary_key");
5990	}
5991
5992	#[rstest]
5993	fn from_field_state_non_optional_bool_with_false_default() {
5994		// Arrange
5995		// Models a Rust field declared as `pub is_superuser: bool` with
5996		// `#[field(default = false)]` — the symptom previously hand-patched
5997		// in PR #4513.
5998		let mut field_state = FieldState::new("is_superuser", FieldType::Boolean, false);
5999		field_state
6000			.params
6001			.insert("default".to_string(), "false".to_string());
6002
6003		// Act
6004		let col = ColumnDefinition::from_field_state("is_superuser", &field_state);
6005
6006		// Assert
6007		assert!(
6008			col.not_null,
6009			"Non-Optional bool with default=false must emit NOT NULL"
6010		);
6011		assert_eq!(
6012			col.default,
6013			Some("false".to_string()),
6014			"default=false must propagate as Some(\"false\")"
6015		);
6016	}
6017
6018	#[rstest]
6019	fn from_field_state_optional_bool_with_default() {
6020		// Arrange
6021		// Models a Rust field declared as `pub maybe_flag: Option<bool>` with
6022		// `#[field(default = true)]`. Existing behavior must be preserved:
6023		// nullable column with default still set.
6024		let mut field_state = FieldState::new("maybe_flag", FieldType::Boolean, true);
6025		field_state
6026			.params
6027			.insert("default".to_string(), "true".to_string());
6028
6029		// Act
6030		let col = ColumnDefinition::from_field_state("maybe_flag", &field_state);
6031
6032		// Assert
6033		assert!(
6034			!col.not_null,
6035			"Optional bool must remain NULLABLE — no regression on Option<T>"
6036		);
6037		assert_eq!(
6038			col.default,
6039			Some("true".to_string()),
6040			"Default propagation must work for Optional fields too"
6041		);
6042	}
6043
6044	#[rstest]
6045	fn from_field_state_non_optional_non_bool() {
6046		// Arrange
6047		// Models a Rust field declared as `pub username: String` with no
6048		// default. This confirms the fix is type-agnostic (not bool-only) and
6049		// prevents required string fields from being emitted as NULLABLE.
6050		let field_state = FieldState::new("username", FieldType::VarChar(150), false);
6051
6052		// Act
6053		let col = ColumnDefinition::from_field_state("username", &field_state);
6054
6055		// Assert
6056		assert!(
6057			col.not_null,
6058			"Non-Optional String must emit NOT NULL (regression #4573 — bug \
6059			 affected all field types, not just bool)"
6060		);
6061		assert!(
6062			col.default.is_none(),
6063			"No default annotation → default = None"
6064		);
6065	}
6066
6067	#[rstest]
6068	fn from_field_state_primary_key_is_always_not_null() {
6069		// Arrange
6070		// A primary key column must be NOT NULL even when the nullability
6071		// flag would otherwise allow NULL. This pins the `|| primary_key`
6072		// short-circuit in the corrected expression.
6073		let mut field_state = FieldState::new("id", FieldType::Uuid, true);
6074		field_state
6075			.params
6076			.insert("primary_key".to_string(), "true".to_string());
6077
6078		// Act
6079		let col = ColumnDefinition::from_field_state("id", &field_state);
6080
6081		// Assert
6082		assert!(
6083			col.primary_key,
6084			"primary_key param must propagate to ColumnDefinition"
6085		);
6086		assert!(
6087			col.not_null,
6088			"Primary key must be NOT NULL regardless of nullable flag"
6089		);
6090	}
6091
6092	#[rstest]
6093	fn from_field_state_optional_field_remains_nullable() {
6094		// Arrange
6095		// Models a Rust field declared as `pub last_login: Option<DateTime<Utc>>`
6096		// with no default. Existing nullable-field behavior must be preserved.
6097		let field_state = FieldState::new("last_login", FieldType::TimestampTz, true);
6098
6099		// Act
6100		let col = ColumnDefinition::from_field_state("last_login", &field_state);
6101
6102		// Assert
6103		assert!(
6104			!col.not_null,
6105			"Optional field with no default must remain NULLABLE"
6106		);
6107		assert!(col.default.is_none(), "No default → default = None");
6108		assert!(!col.primary_key, "Non-PK field must not be primary_key");
6109	}
6110
6111	#[test]
6112	fn test_convert_default_value_null() {
6113		let op = Operation::CreateTable {
6114			name: "test".to_string(),
6115			columns: vec![],
6116			constraints: vec![],
6117			without_rowid: None,
6118			partition: None,
6119			interleave_in_parent: None,
6120		};
6121		let value = op.convert_default_value("null");
6122		assert!(
6123			matches!(value, Value::String(None)),
6124			"NULL value should be converted to Value::String(None)"
6125		);
6126	}
6127
6128	#[test]
6129	fn test_convert_default_value_bool() {
6130		let op = Operation::CreateTable {
6131			name: "test".to_string(),
6132			columns: vec![],
6133			constraints: vec![],
6134			without_rowid: None,
6135			partition: None,
6136			interleave_in_parent: None,
6137		};
6138		let value = op.convert_default_value("true");
6139		assert!(
6140			matches!(value, Value::Bool(Some(true))),
6141			"'true' should be converted to Value::Bool(Some(true))"
6142		);
6143
6144		let value = op.convert_default_value("false");
6145		assert!(
6146			matches!(value, Value::Bool(Some(false))),
6147			"'false' should be converted to Value::Bool(Some(false))"
6148		);
6149	}
6150
6151	#[test]
6152	fn test_convert_default_value_integer() {
6153		let op = Operation::CreateTable {
6154			name: "test".to_string(),
6155			columns: vec![],
6156			constraints: vec![],
6157			without_rowid: None,
6158			partition: None,
6159			interleave_in_parent: None,
6160		};
6161		let value = op.convert_default_value("42");
6162		assert!(
6163			matches!(value, Value::BigInt(Some(42))),
6164			"Integer '42' should be converted to Value::BigInt(Some(42))"
6165		);
6166	}
6167
6168	#[test]
6169	fn test_convert_default_value_float() {
6170		let op = Operation::CreateTable {
6171			name: "test".to_string(),
6172			columns: vec![],
6173			constraints: vec![],
6174			without_rowid: None,
6175			partition: None,
6176			interleave_in_parent: None,
6177		};
6178		let value = op.convert_default_value("3.15");
6179		assert!(
6180			matches!(value, Value::Double(_)),
6181			"Float '3.15' should be converted to Value::Double"
6182		);
6183	}
6184
6185	#[test]
6186	fn test_convert_default_value_string() {
6187		let op = Operation::CreateTable {
6188			name: "test".to_string(),
6189			columns: vec![],
6190			constraints: vec![],
6191			without_rowid: None,
6192			partition: None,
6193			interleave_in_parent: None,
6194		};
6195		let value = op.convert_default_value("'hello'");
6196		match value {
6197			Value::String(Some(s)) => assert_eq!(
6198				*s, "hello",
6199				"Quoted string should be unquoted and stored as 'hello'"
6200			),
6201			_ => {
6202				panic!("Expected Value::String(Some(\"hello\")), got different variant")
6203			}
6204		}
6205	}
6206
6207	#[rstest]
6208	#[case("pending", "'pending'")]
6209	#[case("active", "'active'")]
6210	#[case("hello world", "'hello world'")]
6211	#[case("it's", "'it''s'")]
6212	fn test_convert_default_value_plain_string(#[case] input: &str, #[case] expected: &str) {
6213		// Arrange
6214		let op = Operation::CreateTable {
6215			name: "test".to_string(),
6216			columns: vec![],
6217			constraints: vec![],
6218			without_rowid: None,
6219			partition: None,
6220			interleave_in_parent: None,
6221		};
6222
6223		// Act
6224		let value = op.convert_default_value(input);
6225
6226		// Assert
6227		match value {
6228			Value::String(Some(s)) => assert_eq!(
6229				*s, expected,
6230				"Plain string '{input}' should be auto-quoted as SQL string literal"
6231			),
6232			_ => {
6233				panic!("Expected Value::String(Some(\"{expected}\")), got {value:?}")
6234			}
6235		}
6236	}
6237
6238	#[rstest]
6239	#[case("CURRENT_TIMESTAMP")]
6240	#[case("current_timestamp")]
6241	#[case("CURRENT_DATE")]
6242	#[case("CURRENT_TIME")]
6243	#[case("CURRENT_USER")]
6244	#[case("SESSION_USER")]
6245	#[case("LOCALTIME")]
6246	#[case("LOCALTIMESTAMP")]
6247	fn test_convert_default_value_sql_constant(#[case] input: &str) {
6248		// Arrange
6249		let op = Operation::CreateTable {
6250			name: "test".to_string(),
6251			columns: vec![],
6252			constraints: vec![],
6253			without_rowid: None,
6254			partition: None,
6255			interleave_in_parent: None,
6256		};
6257
6258		// Act
6259		let value = op.convert_default_value(input);
6260
6261		// Assert
6262		match value {
6263			Value::String(Some(s)) => {
6264				assert_eq!(*s, input, "SQL constant '{input}' should remain unquoted")
6265			}
6266			_ => {
6267				panic!("Expected Value::String(Some(\"{input}\")), got {value:?}")
6268			}
6269		}
6270	}
6271
6272	#[rstest]
6273	#[case("NOW()")]
6274	#[case("uuid_generate_v4()")]
6275	#[case("gen_random_uuid()")]
6276	fn test_convert_default_value_sql_function(#[case] input: &str) {
6277		// Arrange
6278		let op = Operation::CreateTable {
6279			name: "test".to_string(),
6280			columns: vec![],
6281			constraints: vec![],
6282			without_rowid: None,
6283			partition: None,
6284			interleave_in_parent: None,
6285		};
6286
6287		// Act
6288		let value = op.convert_default_value(input);
6289
6290		// Assert
6291		match value {
6292			Value::String(Some(s)) => {
6293				assert_eq!(*s, input, "SQL function '{input}' should remain unquoted")
6294			}
6295			_ => {
6296				panic!("Expected Value::String(Some(\"{input}\")), got {value:?}")
6297			}
6298		}
6299	}
6300
6301	#[test]
6302	fn test_apply_column_type_integer() {
6303		let op = Operation::CreateTable {
6304			name: "test".to_string(),
6305			columns: vec![],
6306			constraints: vec![],
6307			without_rowid: None,
6308			partition: None,
6309			interleave_in_parent: None,
6310		};
6311		let col = ColumnDef::new(Alias::new("id"));
6312		let _col = op.apply_column_type(col, &FieldType::Integer);
6313		// This test verifies that INTEGER type application doesn't panic
6314		// Internal state cannot be easily asserted with reinhardt_query's ColumnDef API
6315	}
6316
6317	#[test]
6318	fn test_apply_column_type_varchar_with_length() {
6319		let op = Operation::CreateTable {
6320			name: "test".to_string(),
6321			columns: vec![],
6322			constraints: vec![],
6323			without_rowid: None,
6324			partition: None,
6325			interleave_in_parent: None,
6326		};
6327		let col = ColumnDef::new(Alias::new("name"));
6328		let _col = op.apply_column_type(col, &FieldType::VarChar(100));
6329		// This test verifies that VARCHAR(100) type application doesn't panic
6330		// Internal state cannot be easily asserted with reinhardt_query's ColumnDef API
6331	}
6332
6333	#[test]
6334	fn test_apply_column_type_custom() {
6335		let op = Operation::CreateTable {
6336			name: "test".to_string(),
6337			columns: vec![],
6338			constraints: vec![],
6339			without_rowid: None,
6340			partition: None,
6341			interleave_in_parent: None,
6342		};
6343		let col = ColumnDef::new(Alias::new("data"));
6344		let _col = op.apply_column_type(col, &FieldType::Custom("CUSTOM_TYPE".to_string()));
6345		// This test verifies that custom type application doesn't panic
6346		// Internal state cannot be easily asserted with reinhardt_query's ColumnDef API
6347	}
6348
6349	#[test]
6350	fn test_create_index_composite() {
6351		let op = Operation::CreateIndex {
6352			table: "users".to_string(),
6353			columns: vec!["first_name".to_string(), "last_name".to_string()],
6354			unique: false,
6355			index_type: None,
6356			where_clause: None,
6357			concurrently: false,
6358			expressions: None,
6359			mysql_options: None,
6360			operator_class: None,
6361		};
6362
6363		let sql = op.to_sql(&SqlDialect::Postgres);
6364		assert!(
6365			sql.contains("first_name"),
6366			"SQL should include 'first_name' column, got: {}",
6367			sql
6368		);
6369		assert!(
6370			sql.contains("last_name"),
6371			"SQL should include 'last_name' column, got: {}",
6372			sql
6373		);
6374		assert!(
6375			sql.contains("idx_users_first_name_last_name"),
6376			"SQL should include composite index name, got: {}",
6377			sql
6378		);
6379	}
6380
6381	#[test]
6382	fn test_alter_table_comment_with_quotes() {
6383		let op = Operation::AlterTableComment {
6384			table: "users".to_string(),
6385			comment: Some("User's account table".to_string()),
6386		};
6387
6388		let stmt = op.to_statement();
6389		let sql = stmt.to_sql_string(crate::backends::types::DatabaseType::Postgres);
6390		assert!(
6391			sql.contains("COMMENT ON TABLE"),
6392			"SQL should contain COMMENT ON TABLE keywords, got: {}",
6393			sql
6394		);
6395		assert!(
6396			sql.contains("User''s account table"),
6397			"SQL should properly escape single quotes in comment, got: {}",
6398			sql
6399		);
6400	}
6401
6402	#[test]
6403	fn test_state_forwards_alter_column() {
6404		let mut state = ProjectState::new();
6405		let mut model = ModelState::new("myapp", "users");
6406		model.add_field(FieldState::new(
6407			"age".to_string(),
6408			FieldType::Integer,
6409			false,
6410		));
6411		state.add_model(model);
6412
6413		let op = Operation::AlterColumn {
6414			table: "users".to_string(),
6415			column: "age".to_string(),
6416			old_definition: None,
6417			new_definition: ColumnDefinition {
6418				name: "age".to_string(),
6419				type_definition: FieldType::BigInteger,
6420				not_null: true,
6421				unique: false,
6422				primary_key: false,
6423				auto_increment: false,
6424				default: None,
6425			},
6426			mysql_options: None,
6427		};
6428
6429		op.state_forwards("myapp", &mut state);
6430		let model = state.get_model("myapp", "users").unwrap();
6431		let field = model.fields.get("age").unwrap();
6432		assert_eq!(
6433			field.field_type,
6434			FieldType::BigInteger,
6435			"Field type should be updated to BigInteger, got: {}",
6436			field.field_type
6437		);
6438	}
6439
6440	#[test]
6441	fn test_state_forwards_create_inherited_table() {
6442		let mut state = ProjectState::new();
6443		let op = Operation::CreateInheritedTable {
6444			name: "admin_users".to_string(),
6445			columns: vec![ColumnDefinition {
6446				name: "admin_level".to_string(),
6447				type_definition: FieldType::Integer,
6448				not_null: true,
6449				unique: false,
6450				primary_key: false,
6451				auto_increment: false,
6452				default: None,
6453			}],
6454			base_table: "users".to_string(),
6455			join_column: "user_id".to_string(),
6456		};
6457
6458		op.state_forwards("myapp", &mut state);
6459		let model = state.get_model("myapp", "admin_users");
6460		assert!(
6461			model.is_some(),
6462			"Inherited table 'admin_users' should exist in state"
6463		);
6464		let model = model.unwrap();
6465		assert_eq!(
6466			model.base_model,
6467			Some("users".to_string()),
6468			"base_model should be set to 'users'"
6469		);
6470		assert_eq!(
6471			model.inheritance_type,
6472			Some("joined_table".to_string()),
6473			"inheritance_type should be 'joined_table'"
6474		);
6475	}
6476
6477	#[test]
6478	fn test_state_forwards_add_discriminator_column() {
6479		let mut state = ProjectState::new();
6480		let mut model = ModelState::new("myapp", "users");
6481		model.add_field(FieldState::new("id".to_string(), FieldType::Integer, false));
6482		state.add_model(model);
6483
6484		let op = Operation::AddDiscriminatorColumn {
6485			table: "users".to_string(),
6486			column_name: "user_type".to_string(),
6487			default_value: "regular".to_string(),
6488		};
6489
6490		op.state_forwards("myapp", &mut state);
6491		let model = state.get_model("myapp", "users").unwrap();
6492		assert_eq!(
6493			model.discriminator_column,
6494			Some("user_type".to_string()),
6495			"discriminator_column should be set to 'user_type'"
6496		);
6497		assert_eq!(
6498			model.inheritance_type,
6499			Some("single_table".to_string()),
6500			"inheritance_type should be 'single_table'"
6501		);
6502	}
6503
6504	#[rstest]
6505	fn test_to_reverse_sql_create_table_quotes_identifiers() {
6506		// Arrange
6507		let op = Operation::CreateTable {
6508			name: "user-data".to_string(),
6509			columns: vec![],
6510			constraints: vec![],
6511			without_rowid: None,
6512			partition: None,
6513			interleave_in_parent: None,
6514		};
6515		let state = ProjectState::default();
6516
6517		// Act
6518		let sql = op
6519			.to_reverse_sql(&SqlDialect::Postgres, &state)
6520			.unwrap()
6521			.unwrap()
6522			.join("\n");
6523
6524		// Assert
6525		assert_eq!(
6526			sql, "DROP TABLE \"user-data\";",
6527			"Identifiers with special characters must be quoted"
6528		);
6529	}
6530
6531	#[rstest]
6532	fn test_to_reverse_sql_add_column_quotes_identifiers() {
6533		// Arrange
6534		let op = Operation::AddColumn {
6535			table: "my table".to_string(),
6536			column: ColumnDefinition {
6537				name: "my column".to_string(),
6538				type_definition: FieldType::VarChar(255),
6539				not_null: false,
6540				unique: false,
6541				primary_key: false,
6542				auto_increment: false,
6543				default: None,
6544			},
6545			mysql_options: None,
6546		};
6547		let state = ProjectState::default();
6548
6549		// Act
6550		let sql = op
6551			.to_reverse_sql(&SqlDialect::Postgres, &state)
6552			.unwrap()
6553			.unwrap()
6554			.join("\n");
6555
6556		// Assert
6557		assert_eq!(
6558			sql, "ALTER TABLE \"my table\" DROP COLUMN \"my column\";",
6559			"Table and column names with spaces must be quoted"
6560		);
6561	}
6562
6563	#[rstest]
6564	fn test_to_reverse_sql_rename_table_quotes_identifiers() {
6565		// Arrange: both names contain special characters requiring quoting
6566		let op = Operation::RenameTable {
6567			old_name: "old; DROP TABLE users;--".to_string(),
6568			new_name: "new-name".to_string(),
6569		};
6570		let state = ProjectState::default();
6571
6572		// Act
6573		let sql = op
6574			.to_reverse_sql(&SqlDialect::Postgres, &state)
6575			.unwrap()
6576			.unwrap()
6577			.join("\n");
6578
6579		// Assert
6580		assert_eq!(
6581			sql, "ALTER TABLE \"new-name\" RENAME TO \"old; DROP TABLE users;--\";",
6582			"SQL injection attempt must be quoted as identifier"
6583		);
6584	}
6585
6586	#[rstest]
6587	fn test_to_reverse_sql_rename_column_quotes_identifiers() {
6588		// Arrange: use identifiers with special characters to verify quoting
6589		let op = Operation::RenameColumn {
6590			table: "my table".to_string(),
6591			old_name: "old col".to_string(),
6592			new_name: "new col".to_string(),
6593		};
6594		let state = ProjectState::default();
6595
6596		// Act
6597		let sql = op
6598			.to_reverse_sql(&SqlDialect::Postgres, &state)
6599			.unwrap()
6600			.unwrap()
6601			.join("\n");
6602
6603		// Assert
6604		assert_eq!(
6605			sql, "ALTER TABLE \"my table\" RENAME COLUMN \"new col\" TO \"old col\";",
6606			"Identifiers with spaces must be quoted"
6607		);
6608	}
6609
6610	#[rstest]
6611	fn test_to_reverse_sql_create_index_quotes_identifiers() {
6612		// Arrange
6613		let op = Operation::CreateIndex {
6614			table: "my-table".to_string(),
6615			columns: vec!["col a".to_string()],
6616			unique: false,
6617			index_type: None,
6618			where_clause: None,
6619			concurrently: false,
6620			expressions: None,
6621			mysql_options: None,
6622			operator_class: None,
6623		};
6624		let state = ProjectState::default();
6625
6626		// Act
6627		let sql = op
6628			.to_reverse_sql(&SqlDialect::Postgres, &state)
6629			.unwrap()
6630			.unwrap()
6631			.join("\n");
6632
6633		// Assert
6634		assert!(
6635			sql.contains("DROP INDEX \"idx_my-table_col a\""),
6636			"Index name must be quoted, got: {}",
6637			sql
6638		);
6639	}
6640
6641	/// Regression test for kent8192/reinhardt-web#4583.
6642	///
6643	/// MySQL requires `DROP INDEX <name> ON <table>` when reversing a `CreateIndex`
6644	/// operation. The previous implementation emitted `DROP INDEX <name>;` for every
6645	/// dialect, which produced malformed SQL on MySQL (1064 syntax error). This test
6646	/// pins the MySQL-specific `ON <table>` clause to prevent regressions.
6647	#[rstest]
6648	fn test_to_reverse_sql_create_index_emits_on_table_clause_for_mysql() {
6649		// Arrange
6650		let op = Operation::CreateIndex {
6651			table: "users".to_string(),
6652			columns: vec!["email".to_string()],
6653			unique: false,
6654			index_type: None,
6655			where_clause: None,
6656			concurrently: false,
6657			expressions: None,
6658			mysql_options: None,
6659			operator_class: None,
6660		};
6661		let state = ProjectState::default();
6662
6663		// Act
6664		let sql = op
6665			.to_reverse_sql(&SqlDialect::Mysql, &state)
6666			.unwrap()
6667			.unwrap()
6668			.join("\n");
6669
6670		// Assert: `quote_identifier` is `pg_escape::quote_identifier`, which only
6671		// adds quotes when the identifier contains reserved or non-lowercase
6672		// characters. For plain ASCII names, the output is unquoted. What matters
6673		// for regression is the presence of the `ON <table>` suffix.
6674		assert_eq!(
6675			sql, "DROP INDEX idx_users_email ON users;",
6676			"MySQL reverse SQL must include `ON <table>` clause"
6677		);
6678	}
6679
6680	/// Verify Postgres / SQLite / CockroachDB continue to emit the bare
6681	/// `DROP INDEX <name>;` form without an `ON <table>` clause.
6682	#[rstest]
6683	#[case(SqlDialect::Postgres, "DROP INDEX idx_users_email;")]
6684	#[case(SqlDialect::Sqlite, "DROP INDEX idx_users_email;")]
6685	#[case(SqlDialect::Cockroachdb, "DROP INDEX idx_users_email;")]
6686	fn test_to_reverse_sql_create_index_omits_on_table_for_non_mysql(
6687		#[case] dialect: SqlDialect,
6688		#[case] expected: &str,
6689	) {
6690		// Arrange
6691		let op = Operation::CreateIndex {
6692			table: "users".to_string(),
6693			columns: vec!["email".to_string()],
6694			unique: false,
6695			index_type: None,
6696			where_clause: None,
6697			concurrently: false,
6698			expressions: None,
6699			mysql_options: None,
6700			operator_class: None,
6701		};
6702		let state = ProjectState::default();
6703
6704		// Act
6705		let sql = op
6706			.to_reverse_sql(&dialect, &state)
6707			.unwrap()
6708			.unwrap()
6709			.join("\n");
6710
6711		// Assert
6712		assert_eq!(
6713			sql, expected,
6714			"Non-MySQL reverse SQL must remain unchanged for dialect {:?}",
6715			dialect
6716		);
6717	}
6718
6719	#[rstest]
6720	fn test_to_reverse_sql_add_constraint_quotes_identifiers() {
6721		// Arrange: table name with special characters triggers quoting
6722		let op = Operation::AddConstraint {
6723			table: "my-table".to_string(),
6724			constraint_sql: "CONSTRAINT chk_positive CHECK (x > 0)".to_string(),
6725		};
6726		let state = ProjectState::default();
6727
6728		// Act
6729		let sql = op
6730			.to_reverse_sql(&SqlDialect::Postgres, &state)
6731			.unwrap()
6732			.unwrap()
6733			.join("\n");
6734
6735		// Assert
6736		assert!(
6737			sql.contains("ALTER TABLE \"my-table\""),
6738			"Table name with special characters must be quoted, got: {}",
6739			sql
6740		);
6741		assert!(
6742			sql.contains("DROP CONSTRAINT"),
6743			"Should contain DROP CONSTRAINT, got: {}",
6744			sql
6745		);
6746	}
6747
6748	#[rstest]
6749	fn test_to_reverse_sql_bulk_load_quotes_identifiers() {
6750		// Arrange
6751		let op = Operation::BulkLoad {
6752			table: "user-data".to_string(),
6753			source: BulkLoadSource::Stdin,
6754			format: BulkLoadFormat::default(),
6755			options: BulkLoadOptions::default(),
6756		};
6757		let state = ProjectState::default();
6758
6759		// Act
6760		let sql = op
6761			.to_reverse_sql(&SqlDialect::Postgres, &state)
6762			.unwrap()
6763			.unwrap()
6764			.join("\n");
6765
6766		// Assert
6767		assert_eq!(
6768			sql, "TRUNCATE TABLE \"user-data\";",
6769			"Table name must be quoted"
6770		);
6771	}
6772
6773	// ========================================================================
6774	// SetAutoIncrementValue — per-backend SQL rendering
6775	// ========================================================================
6776
6777	#[rstest]
6778	#[case::postgres(SqlDialect::Postgres)]
6779	#[case::cockroachdb(SqlDialect::Cockroachdb)]
6780	fn test_set_auto_increment_postgres_uses_setval(#[case] dialect: SqlDialect) {
6781		// Arrange
6782		let op = Operation::SetAutoIncrementValue {
6783			table: "users".to_string(),
6784			column: "id".to_string(),
6785			value: 1000,
6786		};
6787
6788		// Act
6789		let sql = op.to_sql(&dialect);
6790
6791		// Assert
6792		assert_eq!(
6793			sql,
6794			"SELECT setval(pg_get_serial_sequence('users', 'id'), 1000, false);"
6795		);
6796	}
6797
6798	#[test]
6799	fn test_set_auto_increment_mysql_alters_table() {
6800		// Arrange
6801		let op = Operation::SetAutoIncrementValue {
6802			table: "users".to_string(),
6803			column: "id".to_string(),
6804			value: 1000,
6805		};
6806
6807		// Act
6808		let sql = op.to_sql(&SqlDialect::Mysql);
6809
6810		// Assert: identifier quoting uses pg_escape's `quote_identifier`
6811		// uniformly across dialects (matches convention used elsewhere in
6812		// this module, e.g. AlterTableComment). pg_escape omits quotes when
6813		// the identifier needs no escaping.
6814		assert_eq!(sql, "ALTER TABLE users AUTO_INCREMENT = 1000;");
6815	}
6816
6817	#[test]
6818	fn test_set_auto_increment_sqlite_upserts_sqlite_sequence() {
6819		// Arrange
6820		let op = Operation::SetAutoIncrementValue {
6821			table: "users".to_string(),
6822			column: "id".to_string(),
6823			value: 1000,
6824		};
6825
6826		// Act
6827		let sql = op.to_sql(&SqlDialect::Sqlite);
6828
6829		// Assert: INSERT OR REPLACE is robust vs. UPDATE which no-ops when
6830		// the sqlite_sequence row does not yet exist.
6831		assert_eq!(
6832			sql,
6833			"INSERT OR REPLACE INTO sqlite_sequence(name, seq) VALUES ('users', 1000);"
6834		);
6835	}
6836
6837	#[test]
6838	fn test_set_auto_increment_postgres_escapes_literals() {
6839		// Arrange: embedded single quote must be doubled to avoid injection.
6840		let op = Operation::SetAutoIncrementValue {
6841			table: "user's".to_string(),
6842			column: "id".to_string(),
6843			value: 42,
6844		};
6845
6846		// Act
6847		let sql = op.to_sql(&SqlDialect::Postgres);
6848
6849		// Assert
6850		assert!(
6851			sql.contains("'user''s'"),
6852			"single quote in table name must be escaped: {}",
6853			sql
6854		);
6855	}
6856
6857	// ========================================================================
6858	// CreateCompositePrimaryKey — SQL rendering and edge cases
6859	// ========================================================================
6860
6861	#[rstest]
6862	#[case::postgres(SqlDialect::Postgres)]
6863	#[case::mysql(SqlDialect::Mysql)]
6864	#[case::sqlite(SqlDialect::Sqlite)]
6865	#[case::cockroachdb(SqlDialect::Cockroachdb)]
6866	fn test_composite_pk_default_name(#[case] dialect: SqlDialect) {
6867		// Arrange
6868		let op = Operation::CreateCompositePrimaryKey {
6869			table: "order_items".to_string(),
6870			columns: vec!["order_id".to_string(), "line_number".to_string()],
6871			constraint_name: None,
6872		};
6873
6874		// Act
6875		let sql = op.to_sql(&dialect);
6876
6877		// Assert
6878		assert!(
6879			sql.contains("ALTER TABLE"),
6880			"SQL should use ALTER TABLE: {}",
6881			sql
6882		);
6883		assert!(
6884			sql.contains("ADD CONSTRAINT"),
6885			"SQL should add a named constraint: {}",
6886			sql
6887		);
6888		assert!(
6889			sql.contains("PRIMARY KEY"),
6890			"SQL should add PRIMARY KEY: {}",
6891			sql
6892		);
6893		assert!(
6894			sql.contains("order_items_pkey"),
6895			"Default constraint name should be table_pkey: {}",
6896			sql
6897		);
6898		assert!(
6899			sql.contains("order_id") && sql.contains("line_number"),
6900			"Both PK columns must appear: {}",
6901			sql
6902		);
6903	}
6904
6905	#[test]
6906	fn test_composite_pk_custom_name_and_quoting() {
6907		// Arrange
6908		let op = Operation::CreateCompositePrimaryKey {
6909			table: "tbl".to_string(),
6910			columns: vec!["a".to_string(), "b".to_string()],
6911			constraint_name: Some("my_pk".to_string()),
6912		};
6913
6914		// Act
6915		let sql = op.to_sql(&SqlDialect::Postgres);
6916
6917		// Assert: pg_escape omits quotes for identifiers that need no escaping.
6918		assert_eq!(
6919			sql,
6920			"ALTER TABLE tbl ADD CONSTRAINT my_pk PRIMARY KEY (a, b);"
6921		);
6922	}
6923
6924	#[test]
6925	fn test_composite_pk_empty_columns_produces_failing_sql() {
6926		// Arrange: empty column list is invalid; we emit a deliberately
6927		// invalid SQL statement (a bare identifier) so every backend's
6928		// parser rejects it before execution, replacing the earlier
6929		// `SELECT 1/0` fallback that silently passed on SQLite and
6930		// lax-mode MySQL (reinhardt-web#4325).
6931		let op = Operation::CreateCompositePrimaryKey {
6932			table: "tbl".to_string(),
6933			columns: vec![],
6934			constraint_name: None,
6935		};
6936
6937		// Act: verify behavior on every supported dialect.
6938		for dialect in [SqlDialect::Postgres, SqlDialect::Mysql, SqlDialect::Sqlite] {
6939			let sql = op.to_sql(&dialect);
6940
6941			// Assert: the emitted statement encodes the diagnostic and is
6942			// not a syntactically valid SELECT/DDL on any backend.
6943			assert!(
6944				sql.starts_with("SYNTAX_ERROR_create_composite_pk_on_")
6945					&& sql.contains("requires_at_least_one_column"),
6946				"Empty column list must emit a syntax-error statement with diagnostic ({:?}): {}",
6947				dialect,
6948				sql
6949			);
6950			assert!(
6951				!sql.contains("SELECT 1/0"),
6952				"Must not fall back to SELECT 1/0 (silently passes on SQLite / lax MySQL): {}",
6953				sql
6954			);
6955		}
6956	}
6957
6958	// ========================================================================
6959	// column_to_sql — SQLite AUTOINCREMENT type widening (Issue #4184)
6960	//
6961	// SQLite rejects `BIGINT PRIMARY KEY AUTOINCREMENT` at apply time with:
6962	//   "AUTOINCREMENT is only allowed on an INTEGER PRIMARY KEY"
6963	// The default `BigAutoField` from CoreSettings produces FieldType::BigInteger
6964	// + auto_increment, so the SQLite emitter must widen integer widths to the
6965	// literal `INTEGER` token.
6966	// ========================================================================
6967
6968	#[rstest]
6969	#[case::big_integer(FieldType::BigInteger)]
6970	#[case::integer(FieldType::Integer)]
6971	#[case::small_integer(FieldType::SmallInteger)]
6972	fn test_column_to_sql_sqlite_auto_increment_pk_emits_integer(#[case] field_type: FieldType) {
6973		// Arrange: BigAutoField/AutoField/SmallAutoField PK with auto_increment.
6974		let mut col = ColumnDefinition::new("id", field_type);
6975		col.primary_key = true;
6976		col.auto_increment = true;
6977		col.not_null = true;
6978
6979		// Act
6980		let sql = Operation::column_to_sql(&col, &SqlDialect::Sqlite);
6981
6982		// Assert: must use the literal `INTEGER` token (not BIGINT/SMALLINT)
6983		// to satisfy SQLite's AUTOINCREMENT constraint.
6984		assert!(
6985			sql.contains("INTEGER PRIMARY KEY AUTOINCREMENT"),
6986			"SQLite auto_increment PK must emit `INTEGER PRIMARY KEY AUTOINCREMENT`: {}",
6987			sql
6988		);
6989		assert!(
6990			!sql.contains("BIGINT"),
6991			"SQLite auto_increment must not emit BIGINT (rejected by SQLite): {}",
6992			sql
6993		);
6994		assert!(
6995			!sql.contains("SMALLINT"),
6996			"SQLite auto_increment must not emit SMALLINT (rejected by SQLite): {}",
6997			sql
6998		);
6999	}
7000
7001	#[test]
7002	fn test_column_to_sql_sqlite_big_integer_without_auto_increment_no_autoincrement() {
7003		// Arrange: plain BigInteger column without auto_increment must not emit
7004		// the AUTOINCREMENT keyword. SQLite represents all integer widths as
7005		// INTEGER (storage class), so emitting INTEGER (per to_sql_for_dialect)
7006		// is correct even without auto_increment.
7007		let mut col = ColumnDefinition::new("count", FieldType::BigInteger);
7008		col.not_null = true;
7009
7010		// Act
7011		let sql = Operation::column_to_sql(&col, &SqlDialect::Sqlite);
7012
7013		// Assert
7014		assert!(
7015			!sql.contains("AUTOINCREMENT"),
7016			"Non-auto_increment column must not emit AUTOINCREMENT: {}",
7017			sql
7018		);
7019		// SQLite accepts `BIGINT` declarations via type affinity, but our emitter
7020		// normalizes integer widths to `INTEGER` for consistency with the
7021		// auto_increment path. This assertion guards that normalization, not a
7022		// SQLite-level prohibition on BIGINT.
7023		assert!(
7024			!sql.contains("BIGINT"),
7025			"emitter is expected to normalize BigInteger to INTEGER for SQLite: {}",
7026			sql
7027		);
7028	}
7029
7030	#[test]
7031	fn test_column_to_sql_postgres_big_integer_auto_increment_unchanged() {
7032		// Arrange: regression guard — Postgres path must remain GENERATED AS IDENTITY.
7033		let mut col = ColumnDefinition::new("id", FieldType::BigInteger);
7034		col.primary_key = true;
7035		col.auto_increment = true;
7036		col.not_null = true;
7037
7038		// Act
7039		let sql = Operation::column_to_sql(&col, &SqlDialect::Postgres);
7040
7041		// Assert
7042		assert!(
7043			sql.contains("BIGINT GENERATED BY DEFAULT AS IDENTITY"),
7044			"Postgres auto_increment BigInteger must emit identity syntax: {}",
7045			sql
7046		);
7047	}
7048
7049	#[test]
7050	fn test_column_to_sql_sqlite_auto_increment_uuid_pk_omits_autoincrement() {
7051		// Arrange: a UUID primary key with auto_increment=true. The `#[model]`
7052		// macro previously emitted `auto_increment="true"` for every PK
7053		// regardless of type, which produced `"id" UUID PRIMARY KEY AUTOINCREMENT`
7054		// — rejected by SQLite with "AUTOINCREMENT is only allowed on an
7055		// INTEGER PRIMARY KEY". The emitter must defend against this combination
7056		// by omitting AUTOINCREMENT when the column type was not widened to
7057		// INTEGER. See reinhardt-web#4378.
7058		let mut col = ColumnDefinition::new("id", FieldType::Uuid);
7059		col.primary_key = true;
7060		col.auto_increment = true;
7061		col.not_null = true;
7062
7063		// Act
7064		let sql = Operation::column_to_sql(&col, &SqlDialect::Sqlite);
7065
7066		// Assert: PRIMARY KEY must be emitted, AUTOINCREMENT must NOT.
7067		assert!(
7068			sql.contains("PRIMARY KEY"),
7069			"UUID PK must still emit PRIMARY KEY: {}",
7070			sql
7071		);
7072		assert!(
7073			!sql.contains("AUTOINCREMENT"),
7074			"non-integer auto_increment PK must not emit AUTOINCREMENT (SQLite rejects it): {}",
7075			sql
7076		);
7077		// SQLite's `to_sql_for_dialect(Uuid)` returns `TEXT` (the storage class
7078		// SQLite actually uses for UUIDs); the important guarantee is that the
7079		// type was NOT silently widened to `INTEGER`, which would change the
7080		// column's semantics.
7081		assert!(
7082			!sql.contains("INTEGER"),
7083			"UUID column type must not be widened to INTEGER: {}",
7084			sql
7085		);
7086	}
7087
7088	#[test]
7089	fn test_column_to_sql_without_pk_sqlite_auto_increment_emits_integer() {
7090		// Arrange: composite PK path also widens to INTEGER for SQLite.
7091		let mut col = ColumnDefinition::new("id", FieldType::BigInteger);
7092		col.auto_increment = true;
7093		col.not_null = true;
7094
7095		// Act
7096		let sql = Operation::column_to_sql_without_pk(&col, &SqlDialect::Sqlite);
7097
7098		// Assert
7099		assert!(
7100			sql.contains("INTEGER"),
7101			"SQLite auto_increment column (composite PK path) must emit INTEGER: {}",
7102			sql
7103		);
7104		assert!(
7105			!sql.contains("BIGINT"),
7106			"SQLite auto_increment must not emit BIGINT in composite PK path: {}",
7107			sql
7108		);
7109	}
7110
7111	mod resolve_foreign_key_column_type_tests {
7112		use super::super::resolve_foreign_key_column_type_with;
7113		use super::FieldType;
7114		use crate::migrations::autodetector::FieldState;
7115		use crate::migrations::model_registry::{FieldMetadata, ModelMetadata, ModelRegistry};
7116
7117		/// Helper: build a target model registered under `(app, name)`
7118		/// whose PK column is of `pk_type`.
7119		fn target_model(app: &str, name: &str, table: &str, pk_type: FieldType) -> ModelMetadata {
7120			let mut meta = ModelMetadata::new(app, name, table);
7121			meta.add_field(
7122				"id".to_string(),
7123				FieldMetadata::new(pk_type).with_param("primary_key", "true"),
7124			);
7125			meta
7126		}
7127
7128		/// Helper: build a `ForeignKeyField`-style FieldState whose
7129		/// `fk_target` (and optionally `fk_target_app`) drive the
7130		/// resolver.
7131		fn fk_field_state(target_model: &str, target_app: Option<&str>) -> FieldState {
7132			let mut fs = FieldState::new("owner_id", FieldType::Uuid, false);
7133			fs.params
7134				.insert("fk_target".to_string(), target_model.to_string());
7135			if let Some(app) = target_app {
7136				fs.params
7137					.insert("fk_target_app".to_string(), app.to_string());
7138			}
7139			fs
7140		}
7141
7142		#[test]
7143		fn qualified_hit_resolves_to_target_pk_type() {
7144			// Arrange
7145			let registry = ModelRegistry::new();
7146			registry.register_model(target_model(
7147				"auth",
7148				"User",
7149				"auth_user",
7150				FieldType::BigInteger,
7151			));
7152			let fs = fk_field_state("User", Some("auth"));
7153
7154			// Act
7155			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7156
7157			// Assert: qualified lookup hits and returns the target's PK type.
7158			assert_eq!(resolved, Some(FieldType::BigInteger));
7159		}
7160
7161		#[test]
7162		fn qualified_miss_falls_back_to_by_name_when_unambiguous() {
7163			// Arrange: target registered under a different app than the
7164			// macro emitted (simulates the `use`-import edge case).
7165			let registry = ModelRegistry::new();
7166			registry.register_model(target_model(
7167				"reinhardt_auth",
7168				"User",
7169				"auth_user",
7170				FieldType::Uuid,
7171			));
7172			// Macro emitted the current crate's app, which is wrong here.
7173			let fs = fk_field_state("User", Some("blog"));
7174
7175			// Act
7176			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7177
7178			// Assert: by-name fallback resolves to the only registered
7179			// `User` model, preserving the pre-#4436 resolution path.
7180			assert_eq!(resolved, Some(FieldType::Uuid));
7181		}
7182
7183		#[test]
7184		fn ambiguous_by_name_returns_none() {
7185			// Arrange: two apps register the same model name.
7186			let registry = ModelRegistry::new();
7187			registry.register_model(target_model(
7188				"auth",
7189				"User",
7190				"auth_user",
7191				FieldType::BigInteger,
7192			));
7193			registry.register_model(target_model(
7194				"billing",
7195				"User",
7196				"billing_user",
7197				FieldType::Uuid,
7198			));
7199			// No `fk_target_app` -> straight to by-name lookup.
7200			let fs = fk_field_state("User", None);
7201
7202			// Act
7203			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7204
7205			// Assert: conservative `None` rather than silently picking
7206			// one of the two `User` models.
7207			assert_eq!(resolved, None);
7208		}
7209
7210		#[test]
7211		fn path_typed_disambiguates_ambiguous_name() {
7212			// Arrange: two apps register `User`. The user wrote a
7213			// path-typed FK target (`ForeignKeyField<reinhardt_auth::User>`),
7214			// so the macro emits `fk_target_app="reinhardt_auth"` —
7215			// trusted as a user-explicit qualifier. The resolver must
7216			// use it to pick the correct `User`, not the unrelated
7217			// `blog.User`.
7218			let registry = ModelRegistry::new();
7219			registry.register_model(target_model(
7220				"blog",
7221				"User",
7222				"blog_user",
7223				FieldType::BigInteger,
7224			));
7225			registry.register_model(target_model(
7226				"reinhardt_auth",
7227				"User",
7228				"reinhardt_auth_user",
7229				FieldType::Uuid,
7230			));
7231			let fs = fk_field_state("User", Some("reinhardt_auth"));
7232
7233			// Act
7234			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7235
7236			// Assert: qualified hit picks `reinhardt_auth.User`
7237			// (FieldType::Uuid), not `blog.User` (FieldType::BigInteger).
7238			assert_eq!(resolved, Some(FieldType::Uuid));
7239		}
7240
7241		#[test]
7242		fn qualified_miss_with_ambiguous_by_name_returns_none() {
7243			// Arrange: qualified lookup misses AND the by-name fallback
7244			// is itself ambiguous. The resolver must still refuse to
7245			// guess.
7246			let registry = ModelRegistry::new();
7247			registry.register_model(target_model(
7248				"auth",
7249				"User",
7250				"auth_user",
7251				FieldType::BigInteger,
7252			));
7253			registry.register_model(target_model(
7254				"billing",
7255				"User",
7256				"billing_user",
7257				FieldType::Uuid,
7258			));
7259			let fs = fk_field_state("User", Some("blog")); // misses; falls back; ambiguous.
7260
7261			// Act
7262			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7263
7264			// Assert
7265			assert_eq!(resolved, None);
7266		}
7267
7268		#[test]
7269		fn no_fk_target_param_returns_none() {
7270			// Arrange: a non-FK field has no `fk_target` param.
7271			let registry = ModelRegistry::new();
7272			registry.register_model(target_model(
7273				"auth",
7274				"User",
7275				"auth_user",
7276				FieldType::BigInteger,
7277			));
7278			let fs = FieldState::new("name", FieldType::VarChar(64), false);
7279
7280			// Act
7281			let resolved = resolve_foreign_key_column_type_with(&fs, &registry);
7282
7283			// Assert
7284			assert_eq!(resolved, None);
7285		}
7286	}
7287}