Skip to main content

rust_ef/
migration.rs

1//! Migration engine ?model snapshot diffing, migration generation, and history tracking.
2//!
3//! Corresponds to EFCore's migration system. Implements full model diffing:
4//!   - Detect added/removed tables
5//!   - Detect added/removed/altered columns
6//!   - Generate Up/Down SQL with dialect-specific type mappings
7//!   - Maintain `__ef_migrations_history` tracking table
8
9use crate::error::EFResult;
10use crate::metadata::{EntityTypeMeta, NavigationKind, NavigationMeta};
11use std::collections::{HashMap, HashSet};
12
13/// Represents a single migration with up/down SQL scripts.
14#[derive(Debug, Clone)]
15pub struct Migration {
16    pub id: String,
17    pub description: String,
18    pub up_sql: String,
19    pub down_sql: String,
20}
21
22/// A snapshot of the entity model at a point in time.
23/// Corresponds to EFCore's `ModelSnapshot`.
24#[derive(Debug, Clone)]
25pub struct ModelSnapshot {
26    pub migration_id: String,
27    pub entity_types: Vec<SnapshotEntityType>,
28}
29
30/// Serialized form of EntityTypeMeta for snapshot storage.
31#[derive(Debug, Clone)]
32pub struct SnapshotEntityType {
33    pub type_name: String,
34    pub table_name: String,
35    pub columns: Vec<SnapshotColumn>,
36}
37
38/// Serialized form of PropertyMeta for snapshot storage.
39#[derive(Debug, Clone, PartialEq, Default)]
40pub struct SnapshotColumn {
41    pub field_name: String,
42    pub column_name: String,
43    pub type_name: String,
44    pub is_primary_key: bool,
45    pub is_required: bool,
46    pub is_foreign_key: bool,
47    pub max_length: Option<usize>,
48    pub is_auto_increment: bool,
49    /// Whether this column is backed by a database sequence (PostgreSQL).
50    pub is_sequence: bool,
51    /// The sequence name when `is_sequence` is true.
52    pub sequence_name: Option<String>,
53    /// Referenced table when `is_foreign_key` is true.
54    pub fk_referenced_table: Option<String>,
55    /// Referenced column when `is_foreign_key` is true.
56    pub fk_referenced_column: Option<String>,
57    /// Non-unique index on this column.
58    pub has_index: bool,
59    /// Unique constraint/index on this column.
60    pub is_unique: bool,
61    /// ON DELETE clause for the FK constraint (e.g. "CASCADE", "SET NULL").
62    /// `None` means no explicit clause (DB default applies).
63    pub fk_on_delete: Option<String>,
64}
65
66/// Specifies the database SQL dialect for migration generation.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum MigrationDialect {
69    Postgres,
70    MySql,
71    Sqlite,
72}
73
74impl MigrationDialect {
75    /// Quote an identifier according to dialect rules.
76    pub fn quote(&self, ident: &str) -> String {
77        match self {
78            MigrationDialect::Postgres | MigrationDialect::Sqlite => format!("\"{}\"", ident),
79            MigrationDialect::MySql => format!("`{}`", ident),
80        }
81    }
82
83    /// Map a Rust type name to the dialect-specific column type.
84    pub fn map_column_type(&self, col: &SnapshotColumn) -> String {
85        // type_name comes from std::any::type_name::<T>() which returns
86        // fully-qualified paths (e.g. "alloc::string::String"). Use ends_with
87        // / contains matching to handle both simple and qualified names.
88        let tn = col.type_name.as_str();
89
90        // Auto-increment handling (must be checked before plain i32/i64)
91        if col.is_auto_increment {
92            if tn.ends_with("i32") {
93                return match self {
94                    MigrationDialect::Postgres => "SERIAL".into(),
95                    MigrationDialect::MySql => "INT AUTO_INCREMENT".into(),
96                    MigrationDialect::Sqlite => "INTEGER".into(),
97                };
98            }
99            if tn.ends_with("i64") {
100                return match self {
101                    MigrationDialect::Postgres => "BIGSERIAL".into(),
102                    MigrationDialect::MySql => "BIGINT AUTO_INCREMENT".into(),
103                    MigrationDialect::Sqlite => "INTEGER".into(),
104                };
105            }
106        }
107
108        // Sequence handling (PostgreSQL: plain type + DEFAULT nextval in DDL;
109        // non-PG: fall back to auto_increment syntax)
110        if col.is_sequence {
111            if tn.ends_with("i32") {
112                return match self {
113                    MigrationDialect::Postgres => "INTEGER".into(),
114                    MigrationDialect::MySql => "INT AUTO_INCREMENT".into(),
115                    MigrationDialect::Sqlite => "INTEGER".into(),
116                };
117            }
118            if tn.ends_with("i64") {
119                return match self {
120                    MigrationDialect::Postgres => "BIGINT".into(),
121                    MigrationDialect::MySql => "BIGINT AUTO_INCREMENT".into(),
122                    MigrationDialect::Sqlite => "INTEGER".into(),
123                };
124            }
125        }
126
127        let base: &str = if tn.ends_with("i16") {
128            "SMALLINT"
129        } else if tn.ends_with("i32") {
130            "INTEGER"
131        } else if tn.ends_with("i64") {
132            "BIGINT"
133        } else if tn.ends_with("f32") {
134            "REAL"
135        } else if tn.ends_with("f64") {
136            "DOUBLE PRECISION"
137        } else if tn.ends_with("bool") {
138            "BOOLEAN"
139        } else if tn.ends_with("String") {
140            return match col.max_length {
141                Some(n) => format!("VARCHAR({})", n),
142                None => "TEXT".into(),
143            };
144        } else if tn.ends_with("Vec<u8>") {
145            return match self {
146                MigrationDialect::Postgres => "BYTEA".into(),
147                MigrationDialect::MySql | MigrationDialect::Sqlite => "BLOB".into(),
148            };
149        } else if tn.contains("NaiveDateTime") {
150            return match self {
151                MigrationDialect::Postgres => "TIMESTAMP".into(),
152                MigrationDialect::MySql => "DATETIME".into(),
153                MigrationDialect::Sqlite => "TEXT".into(),
154            };
155        } else if tn.contains("NaiveDate") {
156            return match self {
157                MigrationDialect::Postgres => "DATE".into(),
158                MigrationDialect::MySql => "DATE".into(),
159                MigrationDialect::Sqlite => "TEXT".into(),
160            };
161        } else if tn.contains("DateTime") {
162            // chrono::DateTime<Utc> → TIMESTAMPTZ (PG) / DATETIME (MySQL) / TEXT (SQLite)
163            return match self {
164                MigrationDialect::Postgres => "TIMESTAMPTZ".into(),
165                MigrationDialect::MySql => "DATETIME".into(),
166                MigrationDialect::Sqlite => "TEXT".into(),
167            };
168        } else if tn.contains("Uuid") {
169            return match self {
170                MigrationDialect::Postgres => "UUID".into(),
171                MigrationDialect::MySql => "CHAR(36)".into(),
172                MigrationDialect::Sqlite => "TEXT".into(),
173            };
174        } else if tn.contains("Decimal") {
175            return match self {
176                MigrationDialect::Postgres => "NUMERIC".into(),
177                MigrationDialect::MySql => "DECIMAL(38,18)".into(),
178                MigrationDialect::Sqlite => "TEXT".into(),
179            };
180        } else {
181            "TEXT"
182        };
183        base.to_string()
184    }
185}
186
187/// Single atomic schema change detected during diffing.
188#[derive(Debug, Clone)]
189pub(crate) enum SchemaChange {
190    CreateTable {
191        table: String,
192        columns: Vec<SnapshotColumn>,
193    },
194    DropTable {
195        table: String,
196    },
197    AddColumn {
198        table: String,
199        column: SnapshotColumn,
200    },
201    DropColumn {
202        table: String,
203        column_name: String,
204    },
205    AlterColumn {
206        table: String,
207        column_name: String,
208        old: Box<SnapshotColumn>,
209        new: Box<SnapshotColumn>,
210    },
211    AddForeignKey {
212        table: String,
213        column: String,
214        referenced_table: String,
215        referenced_column: String,
216        on_delete: Option<String>,
217    },
218    DropForeignKey {
219        table: String,
220        column: String,
221        referenced_table: String,
222    },
223    CreateIndex {
224        table: String,
225        column: String,
226        is_unique: bool,
227    },
228    DropIndex {
229        table: String,
230        column: String,
231        is_unique: bool,
232    },
233}
234
235/// The migration engine ?compares old and new model snapshots to generate
236/// migration SQL.
237pub struct MigrationEngine {
238    dialect: MigrationDialect,
239}
240
241impl MigrationEngine {
242    pub fn new(dialect: MigrationDialect) -> Self {
243        Self { dialect }
244    }
245
246    /// Generates a migration by diffing the current model against a snapshot.
247    pub fn generate(
248        &self,
249        name: &str,
250        current: &[EntityTypeMeta],
251        previous_snapshot: &Option<ModelSnapshot>,
252    ) -> EFResult<Migration> {
253        let current_snapshot = self.create_snapshot("__current__", current);
254
255        let changes = match previous_snapshot {
256            Some(prev) => self.diff(prev, &current_snapshot),
257            None => self.initial_create_with_fks(&current_snapshot),
258        };
259
260        let up_sql = self.generate_up_sql(&changes);
261        let down_sql = self.generate_down_sql(&changes);
262
263        Ok(Migration {
264            id: name.to_string(),
265            description: name.to_string(),
266            up_sql,
267            down_sql,
268        })
269    }
270
271    /// Creates a snapshot from current entity type metadata.
272    pub fn create_snapshot(
273        &self,
274        migration_id: &str,
275        entity_types: &[EntityTypeMeta],
276    ) -> ModelSnapshot {
277        let types = entity_types
278            .iter()
279            .map(|et| SnapshotEntityType {
280                type_name: et.type_name.to_string(),
281                table_name: et.table_name.to_string(),
282                columns: et
283                    .properties
284                    .iter()
285                    .filter(|p| !p.is_not_mapped)
286                    .map(|p| {
287                        let (fk_table, fk_col, fk_on_delete) =
288                            fk_reference_for_property(et, p.column_name.as_ref());
289                        SnapshotColumn {
290                            field_name: p.field_name.to_string(),
291                            column_name: p.column_name.to_string(),
292                            type_name: p.type_name.to_string(),
293                            is_primary_key: p.is_primary_key,
294                            is_required: p.is_required,
295                            is_foreign_key: p.is_foreign_key,
296                            max_length: p.max_length,
297                            is_auto_increment: p.is_auto_increment,
298                            is_sequence: p.is_sequence,
299                            sequence_name: p.sequence_name.as_ref().map(|s| s.to_string()),
300                            fk_referenced_table: fk_table,
301                            fk_referenced_column: fk_col,
302                            has_index: p.has_index,
303                            is_unique: p.is_unique,
304                            fk_on_delete,
305                        }
306                    })
307                    .collect(),
308            })
309            .collect();
310
311        ModelSnapshot {
312            migration_id: migration_id.to_string(),
313            entity_types: types,
314        }
315    }
316
317    // -----------------------------------------------------------------------
318    // Diffing
319    // -----------------------------------------------------------------------
320
321    fn initial_create(&self, current: &ModelSnapshot) -> Vec<SchemaChange> {
322        current
323            .entity_types
324            .iter()
325            .map(|et| SchemaChange::CreateTable {
326                table: et.table_name.clone(),
327                columns: et.columns.clone(),
328            })
329            .collect()
330    }
331
332    fn diff(&self, old: &ModelSnapshot, new: &ModelSnapshot) -> Vec<SchemaChange> {
333        let mut changes = Vec::new();
334
335        // Build lookup maps by table name
336        let old_tables: HashMap<&str, &SnapshotEntityType> = old
337            .entity_types
338            .iter()
339            .map(|e| (e.table_name.as_str(), e))
340            .collect();
341        let new_tables: HashMap<&str, &SnapshotEntityType> = new
342            .entity_types
343            .iter()
344            .map(|e| (e.table_name.as_str(), e))
345            .collect();
346
347        let old_names: HashSet<&str> = old_tables.keys().copied().collect();
348        let new_names: HashSet<&str> = new_tables.keys().copied().collect();
349
350        // Dropped tables
351        for name in old_names.difference(&new_names) {
352            changes.push(SchemaChange::DropTable {
353                table: name.to_string(),
354            });
355        }
356
357        // New tables
358        for name in new_names.difference(&old_names) {
359            let et = new_tables[name];
360            changes.push(SchemaChange::CreateTable {
361                table: et.table_name.clone(),
362                columns: et.columns.clone(),
363            });
364            Self::append_create_table_fks(&mut changes, &et.table_name, &et.columns);
365            Self::append_create_table_indexes(&mut changes, &et.table_name, &et.columns);
366        }
367
368        // Tables present in both ?compare columns
369        for name in old_names.intersection(&new_names) {
370            let old_et = old_tables[name];
371            let new_et = new_tables[name];
372            let table = &old_et.table_name;
373
374            let old_cols: HashMap<&str, &SnapshotColumn> = old_et
375                .columns
376                .iter()
377                .map(|c| (c.column_name.as_str(), c))
378                .collect();
379            let new_cols: HashMap<&str, &SnapshotColumn> = new_et
380                .columns
381                .iter()
382                .map(|c| (c.column_name.as_str(), c))
383                .collect();
384
385            let old_col_names: HashSet<&str> = old_cols.keys().copied().collect();
386            let new_col_names: HashSet<&str> = new_cols.keys().copied().collect();
387
388            // Added columns (with indexes if configured)
389            for col_name in new_col_names.difference(&old_col_names) {
390                let col = new_cols[col_name];
391                changes.push(SchemaChange::AddColumn {
392                    table: table.clone(),
393                    column: (*col).clone(),
394                });
395                changes.extend(diff_indexes(table, &SnapshotColumn::default(), col));
396            }
397
398            // Foreign keys (after add-column, before drop-column)
399            changes.extend(diff_foreign_keys(table, old_et, new_et));
400
401            // Dropped columns
402            for col_name in old_col_names.difference(&new_col_names) {
403                changes.push(SchemaChange::DropColumn {
404                    table: table.clone(),
405                    column_name: col_name.to_string(),
406                });
407            }
408
409            // Altered columns and index changes (present in both)
410            for col_name in old_col_names.intersection(&new_col_names) {
411                let old_col = old_cols[col_name];
412                let new_col = new_cols[col_name];
413
414                // Index state changes → CreateIndex/DropIndex
415                changes.extend(diff_indexes(table, old_col, new_col));
416
417                // Structural changes (excluding index fields) → AlterColumn
418                if !columns_structurally_equal(old_col, new_col) {
419                    changes.push(SchemaChange::AlterColumn {
420                        table: table.clone(),
421                        column_name: col_name.to_string(),
422                        old: Box::new((*old_col).clone()),
423                        new: Box::new((*new_col).clone()),
424                    });
425                }
426            }
427        }
428
429        changes
430    }
431
432    fn append_create_table_fks(
433        changes: &mut Vec<SchemaChange>,
434        table: &str,
435        columns: &[SnapshotColumn],
436    ) {
437        for col in columns {
438            if let Some((ref_table, ref_col)) = fk_target(col) {
439                changes.push(SchemaChange::AddForeignKey {
440                    table: table.to_string(),
441                    column: col.column_name.clone(),
442                    referenced_table: ref_table,
443                    referenced_column: ref_col,
444                    on_delete: col.fk_on_delete.clone(),
445                });
446            }
447        }
448    }
449
450    /// Emits `CreateIndex` changes for indexed columns in a newly created table.
451    fn append_create_table_indexes(
452        changes: &mut Vec<SchemaChange>,
453        table: &str,
454        columns: &[SnapshotColumn],
455    ) {
456        for col in columns {
457            if col.is_unique {
458                changes.push(SchemaChange::CreateIndex {
459                    table: table.to_string(),
460                    column: col.column_name.clone(),
461                    is_unique: true,
462                });
463            } else if col.has_index {
464                changes.push(SchemaChange::CreateIndex {
465                    table: table.to_string(),
466                    column: col.column_name.clone(),
467                    is_unique: false,
468                });
469            }
470        }
471    }
472}
473
474fn fk_target(col: &SnapshotColumn) -> Option<(String, String)> {
475    if !col.is_foreign_key {
476        return None;
477    }
478    Some((
479        col.fk_referenced_table.clone()?,
480        col.fk_referenced_column.clone()?,
481    ))
482}
483
484/// Standard index name used by CreateIndex/DropIndex SQL generation.
485fn index_name(table: &str, column: &str) -> String {
486    format!("ix_{}_{}", table, column)
487}
488
489fn fk_reference_for_property(
490    meta: &EntityTypeMeta,
491    column_name: &str,
492) -> (Option<String>, Option<String>, Option<String>) {
493    for nav in &meta.navigations {
494        if nav.kind != NavigationKind::BelongsTo {
495            continue;
496        }
497        let matches = nav
498            .fk_column
499            .as_ref()
500            .is_some_and(|fk| fk.as_ref() == column_name);
501        if matches {
502            let on_delete = resolve_fk_on_delete_clause(nav, meta);
503            return (
504                nav.related_table.as_ref().map(|s| s.to_string()),
505                nav.referenced_key_column.as_ref().map(|s| s.to_string()),
506                on_delete,
507            );
508        }
509    }
510    (None, None, None)
511}
512
513/// Resolves the `ON DELETE` SQL clause for a FK by checking:
514/// 1. The child's BelongsTo navigation `delete_behavior` (if user configured it here)
515/// 2. The principal's inverse HasMany navigation via `resolve_delete_behavior`
516/// 3. Fallback: nullability of the FK property on the child entity
517fn resolve_fk_on_delete_clause(
518    belongs_to_nav: &NavigationMeta,
519    child_meta: &EntityTypeMeta,
520) -> Option<String> {
521    use crate::relations::DeleteBehavior;
522
523    // 1. Explicit on the BelongsTo side
524    if let Some(b) = belongs_to_nav.delete_behavior {
525        return Some(b.to_sql_clause().to_string());
526    }
527
528    // 2. Find the principal's HasMany navigation pointing back
529    if let Some(meta_fn) = belongs_to_nav.related_entity_meta {
530        let principal_meta = meta_fn();
531        for principal_nav in &principal_meta.navigations {
532            if principal_nav.kind == NavigationKind::HasMany
533                && principal_nav.related_type_id == child_meta.type_id
534            {
535                let behavior = crate::db_context::resolve_delete_behavior(principal_nav);
536                return Some(behavior.to_sql_clause().to_string());
537            }
538        }
539    }
540
541    // 3. Fallback: nullability from the FK property's Rust type
542    if let Some(fk_prop) = child_meta.properties.iter().find(|p| p.is_foreign_key) {
543        let is_nullable = fk_prop.type_name.contains("Option");
544        let behavior = if is_nullable {
545            DeleteBehavior::Restrict
546        } else {
547            DeleteBehavior::Cascade
548        };
549        return Some(behavior.to_sql_clause().to_string());
550    }
551
552    None
553}
554
555fn diff_foreign_keys(
556    table: &str,
557    old_et: &SnapshotEntityType,
558    new_et: &SnapshotEntityType,
559) -> Vec<SchemaChange> {
560    let mut changes = Vec::new();
561    let old_fks: HashMap<&str, (&SnapshotColumn, String, String, Option<String>)> = old_et
562        .columns
563        .iter()
564        .filter_map(|c| {
565            let (rt, rc) = fk_target(c)?;
566            Some((c.column_name.as_str(), (c, rt, rc, c.fk_on_delete.clone())))
567        })
568        .collect();
569    let new_fks: HashMap<&str, (&SnapshotColumn, String, String, Option<String>)> = new_et
570        .columns
571        .iter()
572        .filter_map(|c| {
573            let (rt, rc) = fk_target(c)?;
574            Some((c.column_name.as_str(), (c, rt, rc, c.fk_on_delete.clone())))
575        })
576        .collect();
577
578    let old_names: HashSet<&str> = old_fks.keys().copied().collect();
579    let new_names: HashSet<&str> = new_fks.keys().copied().collect();
580
581    for col in new_names.difference(&old_names) {
582        let (_, rt, rc, od) = &new_fks[col];
583        changes.push(SchemaChange::AddForeignKey {
584            table: table.to_string(),
585            column: (*col).to_string(),
586            referenced_table: rt.clone(),
587            referenced_column: rc.clone(),
588            on_delete: od.clone(),
589        });
590    }
591
592    for col in old_names.difference(&new_names) {
593        let (_, rt, _, _) = &old_fks[col];
594        changes.push(SchemaChange::DropForeignKey {
595            table: table.to_string(),
596            column: (*col).to_string(),
597            referenced_table: rt.clone(),
598        });
599    }
600
601    for col in old_names.intersection(&new_names) {
602        let (_, old_rt, old_rc, old_od) = &old_fks[col];
603        let (_, new_rt, new_rc, new_od) = &new_fks[col];
604        if old_rt != new_rt || old_rc != new_rc || old_od != new_od {
605            changes.push(SchemaChange::DropForeignKey {
606                table: table.to_string(),
607                column: (*col).to_string(),
608                referenced_table: old_rt.clone(),
609            });
610            changes.push(SchemaChange::AddForeignKey {
611                table: table.to_string(),
612                column: (*col).to_string(),
613                referenced_table: new_rt.clone(),
614                referenced_column: new_rc.clone(),
615                on_delete: new_od.clone(),
616            });
617        }
618    }
619
620    changes
621}
622
623/// Compares two columns for structural equality, ignoring index state
624/// (`has_index` / `is_unique`). Index changes are handled separately by
625/// `diff_indexes` so they emit `CreateIndex`/`DropIndex` instead of
626/// `AlterColumn`.
627fn columns_structurally_equal(a: &SnapshotColumn, b: &SnapshotColumn) -> bool {
628    a.field_name == b.field_name
629        && a.column_name == b.column_name
630        && a.type_name == b.type_name
631        && a.is_primary_key == b.is_primary_key
632        && a.is_required == b.is_required
633        && a.is_foreign_key == b.is_foreign_key
634        && a.max_length == b.max_length
635        && a.is_auto_increment == b.is_auto_increment
636        && a.fk_referenced_table == b.fk_referenced_table
637        && a.fk_referenced_column == b.fk_referenced_column
638}
639
640/// Returns `CreateIndex`/`DropIndex` changes when a column's index state
641/// transitions between None / NonUnique / Unique.
642fn diff_indexes(table: &str, old: &SnapshotColumn, new: &SnapshotColumn) -> Vec<SchemaChange> {
643    let old_kind = index_kind(old);
644    let new_kind = index_kind(new);
645    if old_kind == new_kind {
646        return Vec::new();
647    }
648    let mut changes = Vec::new();
649    if old_kind != IndexKind::None {
650        changes.push(SchemaChange::DropIndex {
651            table: table.to_string(),
652            column: new.column_name.clone(),
653            is_unique: old_kind == IndexKind::Unique,
654        });
655    }
656    if new_kind != IndexKind::None {
657        changes.push(SchemaChange::CreateIndex {
658            table: table.to_string(),
659            column: new.column_name.clone(),
660            is_unique: new_kind == IndexKind::Unique,
661        });
662    }
663    changes
664}
665
666#[derive(Clone, Copy, PartialEq, Eq)]
667enum IndexKind {
668    None,
669    NonUnique,
670    Unique,
671}
672
673fn index_kind(col: &SnapshotColumn) -> IndexKind {
674    if col.is_unique {
675        IndexKind::Unique
676    } else if col.has_index {
677        IndexKind::NonUnique
678    } else {
679        IndexKind::None
680    }
681}
682
683impl MigrationEngine {
684    fn initial_create_with_fks(&self, current: &ModelSnapshot) -> Vec<SchemaChange> {
685        let mut changes = self.initial_create(current);
686        for et in &current.entity_types {
687            Self::append_create_table_fks(&mut changes, &et.table_name, &et.columns);
688            Self::append_create_table_indexes(&mut changes, &et.table_name, &et.columns);
689        }
690        changes
691    }
692
693    // -----------------------------------------------------------------------
694    // SQL generation
695    // -----------------------------------------------------------------------
696
697    /// Standard foreign-key constraint name used by Add/DropForeignKey.
698    pub fn foreign_key_name(table: &str, column: &str, referenced_table: &str) -> String {
699        format!("fk_{}_{}_{}", table, column, referenced_table)
700    }
701
702    /// Standard index name used by CreateIndex/DropIndex.
703    pub fn index_name(table: &str, column: &str) -> String {
704        format!("ix_{}_{}", table, column)
705    }
706
707    pub fn generate_alter_column_sql(
708        &self,
709        table: &str,
710        column_name: &str,
711        new: &SnapshotColumn,
712    ) -> String {
713        let q = |s: &str| self.dialect.quote(s);
714        let col_type = self.dialect.map_column_type(new);
715        match self.dialect {
716            MigrationDialect::Postgres => {
717                let nullable = if new.is_required {
718                    "SET NOT NULL"
719                } else {
720                    "DROP NOT NULL"
721                };
722                format!(
723                    "ALTER TABLE {} ALTER COLUMN {} TYPE {};\nALTER TABLE {} ALTER COLUMN {} {};\n",
724                    q(table),
725                    q(column_name),
726                    col_type,
727                    q(table),
728                    q(column_name),
729                    nullable
730                )
731            }
732            MigrationDialect::MySql => {
733                let nullable = if new.is_required { "NOT NULL" } else { "NULL" };
734                format!(
735                    "ALTER TABLE {} MODIFY COLUMN {} {} {};\n",
736                    q(table),
737                    q(column_name),
738                    col_type,
739                    nullable
740                )
741            }
742            MigrationDialect::Sqlite => format!(
743                "-- SQLite does not support ALTER COLUMN type changes; \
744                 rebuild table manually for {}.{}\n",
745                q(table),
746                q(column_name)
747            ),
748        }
749    }
750
751    fn generate_up_sql(&self, changes: &[SchemaChange]) -> String {
752        self.generate_up_sql_inner(changes, true)
753    }
754
755    fn generate_ddl_sql(&self, changes: &[SchemaChange]) -> String {
756        self.generate_up_sql_inner(changes, false)
757    }
758
759    fn generate_up_sql_inner(&self, changes: &[SchemaChange], record_history: bool) -> String {
760        let mut sql = String::from("-- Up Migration (auto-generated by rust-ef)\n\n");
761        let q = |s: &str| self.dialect.quote(s);
762        let create_kw = if record_history {
763            "CREATE TABLE"
764        } else {
765            "CREATE TABLE IF NOT EXISTS"
766        };
767
768        for change in changes {
769            match change {
770                SchemaChange::CreateTable { table, columns } => {
771                    // PostgreSQL: create sequences before the table
772                    if self.dialect == MigrationDialect::Postgres {
773                        for c in columns {
774                            if c.is_sequence {
775                                if let Some(seq_name) = &c.sequence_name {
776                                    sql.push_str(&format!(
777                                        "CREATE SEQUENCE IF NOT EXISTS {};\n",
778                                        q(seq_name)
779                                    ));
780                                }
781                            }
782                        }
783                    }
784
785                    sql.push_str(&format!("{} {} (\n", create_kw, q(table)));
786
787                    // Separate primary key columns from regular columns
788                    let pk_columns: Vec<&str> = columns
789                        .iter()
790                        .filter(|c| c.is_primary_key)
791                        .map(|c| c.column_name.as_str())
792                        .collect();
793
794                    let col_defs: Vec<String> = columns
795                        .iter()
796                        .map(|c| {
797                            let nullable = if c.is_required { "NOT NULL" } else { "NULL" };
798                            let col_type = self.dialect.map_column_type(c);
799                            // PostgreSQL sequence column: add DEFAULT nextval('seq_name')
800                            if c.is_sequence && self.dialect == MigrationDialect::Postgres {
801                                if let Some(seq_name) = &c.sequence_name {
802                                    return format!(
803                                        "{} {} DEFAULT nextval('{}') {}",
804                                        q(&c.column_name),
805                                        col_type,
806                                        seq_name,
807                                        nullable
808                                    );
809                                }
810                            }
811                            // Don't put PRIMARY KEY on individual columns; handle separately
812                            [q(&c.column_name), col_type, nullable.to_string()]
813                                .into_iter()
814                                .filter(|s| !s.is_empty())
815                                .collect::<Vec<_>>()
816                                .join(" ")
817                        })
818                        .collect();
819                    sql.push_str(&format!("    {}\n", col_defs.join(",\n    ")));
820
821                    // Composite primary key constraint
822                    if pk_columns.len() == 1 {
823                        sql.push_str(&format!("    ,PRIMARY KEY ({})\n", q(pk_columns[0])));
824                    } else if pk_columns.len() > 1 {
825                        let pk_list: Vec<String> = pk_columns.iter().map(|c| q(c)).collect();
826                        sql.push_str(&format!("    ,PRIMARY KEY ({})\n", pk_list.join(", ")));
827                    }
828
829                    sql.push_str(");\n\n");
830                }
831                SchemaChange::DropTable { table } => {
832                    sql.push_str(&format!("DROP TABLE IF EXISTS {};\n", q(table)));
833                }
834                SchemaChange::AddColumn { table, column } => {
835                    let col_type = self.dialect.map_column_type(column);
836                    let nullable = if column.is_required {
837                        "NOT NULL"
838                    } else {
839                        "NULL"
840                    };
841                    sql.push_str(&format!(
842                        "ALTER TABLE {} ADD COLUMN {} {} {};\n",
843                        q(table),
844                        q(&column.column_name),
845                        col_type,
846                        nullable
847                    ));
848                }
849                SchemaChange::DropColumn { table, column_name } => {
850                    sql.push_str(&format!(
851                        "ALTER TABLE {} DROP COLUMN IF EXISTS {};\n",
852                        q(table),
853                        q(column_name)
854                    ));
855                }
856                SchemaChange::AlterColumn {
857                    table,
858                    column_name,
859                    old: _,
860                    new,
861                } => {
862                    sql.push_str(&self.generate_alter_column_sql(table, column_name, new));
863                }
864                SchemaChange::AddForeignKey {
865                    table,
866                    column,
867                    referenced_table,
868                    referenced_column,
869                    on_delete,
870                } => {
871                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
872                    let on_delete_clause = on_delete
873                        .as_deref()
874                        .map(|c| format!(" ON DELETE {}", c))
875                        .unwrap_or_default();
876                    sql.push_str(&format!(
877                        "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}){};\n",
878                        q(table),
879                        q(&fk_name),
880                        q(column),
881                        q(referenced_table),
882                        q(referenced_column),
883                        on_delete_clause
884                    ));
885                }
886                SchemaChange::DropForeignKey {
887                    table,
888                    column,
889                    referenced_table,
890                } => {
891                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
892                    match self.dialect {
893                        MigrationDialect::MySql => sql.push_str(&format!(
894                            "ALTER TABLE {} DROP FOREIGN KEY {};\n",
895                            q(table),
896                            q(&fk_name)
897                        )),
898                        _ => sql.push_str(&format!(
899                            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};\n",
900                            q(table),
901                            q(&fk_name)
902                        )),
903                    }
904                }
905                SchemaChange::CreateIndex {
906                    table,
907                    column,
908                    is_unique,
909                } => {
910                    let unique_kw = if *is_unique { "UNIQUE " } else { "" };
911                    let idx_name = index_name(table, column);
912                    sql.push_str(&format!(
913                        "CREATE {unique_kw}INDEX {} ON {} ({});\n",
914                        q(&idx_name),
915                        q(table),
916                        q(column)
917                    ));
918                }
919                SchemaChange::DropIndex {
920                    table,
921                    column,
922                    is_unique: _,
923                } => {
924                    let idx_name = index_name(table, column);
925                    match self.dialect {
926                        MigrationDialect::MySql => {
927                            sql.push_str(&format!("DROP INDEX {} ON {};\n", q(&idx_name), q(table)))
928                        }
929                        _ => sql.push_str(&format!("DROP INDEX IF EXISTS {};\n", q(&idx_name))),
930                    }
931                }
932            }
933        }
934
935        if record_history {
936            sql.push_str(&format!(
937                "INSERT INTO {}(migration_id, product_version) VALUES ('{{migration_id}}', '{}');\n",
938                q(MIGRATION_HISTORY_TABLE),
939                PRODUCT_VERSION
940            ));
941        }
942
943        sql
944    }
945
946    fn generate_down_sql(&self, changes: &[SchemaChange]) -> String {
947        let mut sql = String::from("-- Down Migration (auto-generated by rust-ef)\n\n");
948        let q = |s: &str| self.dialect.quote(s);
949
950        // Reverse the changes for the down migration
951        for change in changes.iter().rev() {
952            match change {
953                SchemaChange::CreateTable { table, .. } => {
954                    sql.push_str(&format!("DROP TABLE IF EXISTS {};\n", q(table)));
955                }
956                SchemaChange::DropTable { table } => {
957                    // Can't fully restore a dropped table ?log warning
958                    sql.push_str(&format!(
959                        "-- WARNING: Cannot restore table {} (original schema unknown)\n",
960                        q(table)
961                    ));
962                }
963                SchemaChange::AddColumn { table, column } => {
964                    sql.push_str(&format!(
965                        "ALTER TABLE {} DROP COLUMN IF EXISTS {};\n",
966                        q(table),
967                        q(&column.column_name)
968                    ));
969                }
970                SchemaChange::DropColumn { table, column_name } => {
971                    // Can't fully restore dropped column without original type
972                    sql.push_str(&format!(
973                        "-- WARNING: Cannot restore column {} on {} (original type unknown)\n",
974                        q(column_name),
975                        q(table)
976                    ));
977                }
978                SchemaChange::AlterColumn {
979                    table,
980                    column_name,
981                    old,
982                    new: _,
983                } => {
984                    sql.push_str(&self.generate_alter_column_sql(table, column_name, old));
985                }
986                SchemaChange::AddForeignKey {
987                    table,
988                    column,
989                    referenced_table,
990                    ..
991                } => {
992                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
993                    match self.dialect {
994                        MigrationDialect::MySql => sql.push_str(&format!(
995                            "ALTER TABLE {} DROP FOREIGN KEY {};\n",
996                            q(table),
997                            q(&fk_name)
998                        )),
999                        _ => sql.push_str(&format!(
1000                            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};\n",
1001                            q(table),
1002                            q(&fk_name)
1003                        )),
1004                    }
1005                }
1006                SchemaChange::DropForeignKey {
1007                    table,
1008                    column,
1009                    referenced_table,
1010                    ..
1011                } => {
1012                    sql.push_str(&format!(
1013                        "-- WARNING: Cannot restore foreign key constraint {} on {}.{}\n",
1014                        q(&Self::foreign_key_name(table, column, referenced_table)),
1015                        q(table),
1016                        q(column)
1017                    ));
1018                }
1019                SchemaChange::CreateIndex {
1020                    table,
1021                    column,
1022                    is_unique: _,
1023                } => {
1024                    let idx_name = index_name(table, column);
1025                    match self.dialect {
1026                        MigrationDialect::MySql => {
1027                            sql.push_str(&format!("DROP INDEX {} ON {};\n", q(&idx_name), q(table)))
1028                        }
1029                        _ => sql.push_str(&format!("DROP INDEX IF EXISTS {};\n", q(&idx_name))),
1030                    }
1031                }
1032                SchemaChange::DropIndex {
1033                    table,
1034                    column,
1035                    is_unique,
1036                } => {
1037                    let unique_kw = if *is_unique { "UNIQUE " } else { "" };
1038                    let idx_name = index_name(table, column);
1039                    sql.push_str(&format!(
1040                        "CREATE {unique_kw}INDEX {} ON {} ({});\n",
1041                        q(&idx_name),
1042                        q(table),
1043                        q(column)
1044                    ));
1045                }
1046            }
1047        }
1048
1049        sql.push_str(&format!(
1050            "DELETE FROM {} WHERE migration_id = '{{migration_id}}';\n",
1051            q(MIGRATION_HISTORY_TABLE)
1052        ));
1053
1054        sql
1055    }
1056
1057    /// Ensures the migration history table exists.
1058    pub async fn ensure_history_table(
1059        &self,
1060        provider: &dyn crate::provider::IDatabaseProvider,
1061    ) -> EFResult<()> {
1062        let sql = create_migration_history_table_sql(self.dialect);
1063        provider.execute_migration_command(&sql).await
1064    }
1065
1066    /// Applies a migration's Up SQL against a database provider.
1067    pub async fn apply(
1068        &self,
1069        provider: &dyn crate::provider::IDatabaseProvider,
1070        migration: &Migration,
1071    ) -> EFResult<()> {
1072        if self.is_applied(provider, &migration.id).await? {
1073            return Ok(());
1074        }
1075        self.ensure_history_table(provider).await?;
1076        let sql = migration.up_sql.replace("{migration_id}", &migration.id);
1077        for statement in split_sql_statements(&sql) {
1078            if !statement.is_empty() {
1079                provider.execute_migration_command(&statement).await?;
1080            }
1081        }
1082        Ok(())
1083    }
1084
1085    /// Reverts a migration using its Down SQL.
1086    pub async fn revert(
1087        &self,
1088        provider: &dyn crate::provider::IDatabaseProvider,
1089        migration: &Migration,
1090    ) -> EFResult<()> {
1091        if !self.is_applied(provider, &migration.id).await? {
1092            return Err(crate::error::EFError::migration(format!(
1093                "migration '{}' is not applied",
1094                migration.id
1095            )));
1096        }
1097        let sql = migration.down_sql.replace("{migration_id}", &migration.id);
1098        for statement in split_sql_statements(&sql) {
1099            if !statement.is_empty() {
1100                provider.execute_migration_command(&statement).await?;
1101            }
1102        }
1103        Ok(())
1104    }
1105
1106    /// Returns migrations already recorded in the database history table.
1107    pub async fn get_applied_migrations(
1108        &self,
1109        provider: &dyn crate::provider::IDatabaseProvider,
1110    ) -> EFResult<Vec<MigrationHistoryEntry>> {
1111        self.ensure_history_table(provider).await?;
1112        let q = |s: &str| self.dialect.quote(s);
1113        let table = q(MIGRATION_HISTORY_TABLE);
1114        let sql = format!(
1115            "SELECT {}, {} FROM {} ORDER BY {}",
1116            q("migration_id"),
1117            q("product_version"),
1118            table,
1119            q("migration_id")
1120        );
1121        let mut conn = provider.get_connection().await?;
1122        let rows = conn.query(&sql, &[]).await?;
1123        Ok(rows
1124            .into_iter()
1125            .map(|row| MigrationHistoryEntry {
1126                migration_id: row
1127                    .first()
1128                    .and_then(|v| String::try_from(v.clone()).ok())
1129                    .unwrap_or_default(),
1130                product_version: row
1131                    .get(1)
1132                    .and_then(|v| String::try_from(v.clone()).ok())
1133                    .unwrap_or_default(),
1134            })
1135            .collect())
1136    }
1137
1138    /// Returns whether a migration id is already recorded in history.
1139    pub async fn is_applied(
1140        &self,
1141        provider: &dyn crate::provider::IDatabaseProvider,
1142        migration_id: &str,
1143    ) -> EFResult<bool> {
1144        let applied = self.get_applied_migrations(provider).await?;
1145        Ok(applied.iter().any(|e| e.migration_id == migration_id))
1146    }
1147
1148    /// Applies all pending migrations in order, skipping already-applied ids.
1149    pub async fn apply_pending(
1150        &self,
1151        provider: &dyn crate::provider::IDatabaseProvider,
1152        migrations: &[Migration],
1153    ) -> EFResult<usize> {
1154        let applied: std::collections::HashSet<String> = self
1155            .get_applied_migrations(provider)
1156            .await?
1157            .into_iter()
1158            .map(|e| e.migration_id)
1159            .collect();
1160        let mut count = 0usize;
1161        for migration in migrations {
1162            if applied.contains(&migration.id) {
1163                continue;
1164            }
1165            self.apply(provider, migration).await?;
1166            count += 1;
1167        }
1168        Ok(count)
1169    }
1170
1171    /// Reverts the most recently applied migration from the given list.
1172    pub async fn revert_last(
1173        &self,
1174        provider: &dyn crate::provider::IDatabaseProvider,
1175        migrations: &[Migration],
1176    ) -> EFResult<Option<String>> {
1177        let applied = self.get_applied_migrations(provider).await?;
1178        let last = applied.last().map(|e| e.migration_id.clone());
1179        let Some(last_id) = last else {
1180            return Ok(None);
1181        };
1182        let migration = migrations.iter().find(|m| m.id == last_id).ok_or_else(|| {
1183            crate::error::EFError::migration(format!(
1184                "applied migration '{}' not found in local migration set",
1185                last_id
1186            ))
1187        })?;
1188        self.revert(provider, migration).await?;
1189        Ok(Some(last_id))
1190    }
1191
1192    /// Reverts all migrations applied strictly after `target` (exclusive),
1193    /// leaving the database at `target`'s state. Returns the reverted ids in
1194    /// the order they were reverted (most-recent first).
1195    ///
1196    /// If `target` is `None`, reverts ALL applied migrations.
1197    /// Returns an error if `target` is not currently applied.
1198    pub async fn revert_to_target(
1199        &self,
1200        provider: &dyn crate::provider::IDatabaseProvider,
1201        migrations: &[Migration],
1202        target: Option<&str>,
1203    ) -> EFResult<Vec<String>> {
1204        let applied = self.get_applied_migrations(provider).await?;
1205        let applied_ids: Vec<&str> = applied.iter().map(|e| e.migration_id.as_str()).collect();
1206
1207        let to_revert: Vec<String> = match target {
1208            Some(t) => {
1209                let idx = applied_ids.iter().position(|id| *id == t).ok_or_else(|| {
1210                    crate::error::EFError::migration(format!(
1211                        "target migration '{t}' is not applied"
1212                    ))
1213                })?;
1214                applied_ids[idx + 1..]
1215                    .iter()
1216                    .map(|s| s.to_string())
1217                    .collect()
1218            }
1219            None => applied_ids.iter().map(|s| s.to_string()).collect(),
1220        };
1221
1222        let mut reverted = Vec::with_capacity(to_revert.len());
1223        for id in to_revert.iter().rev() {
1224            let migration = migrations.iter().find(|m| m.id == *id).ok_or_else(|| {
1225                crate::error::EFError::migration(format!(
1226                    "applied migration '{id}' not found in local migration set"
1227                ))
1228            })?;
1229            self.revert(provider, migration).await?;
1230            reverted.push(id.clone());
1231        }
1232        Ok(reverted)
1233    }
1234
1235    /// Generates a combined SQL script transitioning the database from the
1236    /// `from` migration state to the `to` migration state.
1237    ///
1238    /// Semantics (mirroring `dotnet ef migrations script`):
1239    /// - `from` is exclusive: the DB is assumed to already be at `from`.
1240    /// - `to` is inclusive: the script brings the DB up to and including `to`.
1241    /// - `from = None`: start from an empty database (before any migration).
1242    /// - `to = None`: end at the latest migration.
1243    /// - When `from` precedes `to`: emits Up SQL for migrations in `(from, to]`.
1244    /// - When `from` follows `to`: emits Down SQL for migrations in `(to, from]`.
1245    /// - When `from == to`: emits a no-op comment.
1246    pub fn generate_script(
1247        migrations: &[Migration],
1248        from: Option<&str>,
1249        to: Option<&str>,
1250    ) -> EFResult<String> {
1251        // Resolve from/to to indices. from=None means "before first migration" (-1).
1252        // to=None means "after last migration" (migrations.len() - 1).
1253        let from_idx: i64 = match from {
1254            Some(f) => migrations.iter().position(|m| m.id == f).ok_or_else(|| {
1255                crate::error::EFError::migration(format!(
1256                    "from migration '{f}' not found in local set"
1257                ))
1258            })? as i64,
1259            None => -1,
1260        };
1261        let to_idx: i64 = match to {
1262            Some(t) => migrations.iter().position(|m| m.id == t).ok_or_else(|| {
1263                crate::error::EFError::migration(format!(
1264                    "to migration '{t}' not found in local set"
1265                ))
1266            })? as i64,
1267            None => migrations.len() as i64 - 1,
1268        };
1269
1270        let mut sql = String::new();
1271        if from_idx < to_idx {
1272            // Forward: up scripts for migrations in (from_idx, to_idx].
1273            sql.push_str("-- Migration script (forward)\n\n");
1274            let start = (from_idx + 1) as usize;
1275            let end = (to_idx + 1) as usize; // exclusive
1276            for m in &migrations[start..end] {
1277                sql.push_str(&format!("-- Up: {}\n", m.id));
1278                sql.push_str(&m.up_sql.replace("{migration_id}", &m.id));
1279                sql.push('\n');
1280            }
1281        } else if from_idx > to_idx {
1282            // Reverse: down scripts for migrations in (to_idx, from_idx].
1283            sql.push_str("-- Migration script (reverse)\n\n");
1284            let start = to_idx as usize + 1; // inclusive
1285            let end = from_idx as usize + 1; // exclusive
1286            for m in migrations[start..end].iter().rev() {
1287                sql.push_str(&format!("-- Down: {}\n", m.id));
1288                sql.push_str(&m.down_sql.replace("{migration_id}", &m.id));
1289                sql.push('\n');
1290            }
1291        } else {
1292            sql.push_str("-- Nothing to do (from == to)\n");
1293        }
1294        Ok(sql)
1295    }
1296
1297    /// Generates and applies the initial schema for the given entity types.
1298    /// Corresponds to EF Core `Database.EnsureCreated()`.
1299    /// Does not use the migrations history table (idempotent DDL only).
1300    pub async fn ensure_created(
1301        &self,
1302        provider: &dyn crate::provider::IDatabaseProvider,
1303        entity_types: &[EntityTypeMeta],
1304    ) -> EFResult<()> {
1305        let snapshot = self.create_snapshot("__ensure_created__", entity_types);
1306        let changes = self.initial_create(&snapshot);
1307        let sql = self.generate_ddl_sql(&changes);
1308        for statement in split_sql_statements(&sql) {
1309            if !statement.is_empty() {
1310                provider.execute_migration_command(&statement).await?;
1311            }
1312        }
1313        Ok(())
1314    }
1315
1316    /// Drops all tables for the given entity types.
1317    /// Corresponds to EF Core `Database.EnsureDeleted()`.
1318    pub async fn ensure_deleted(
1319        &self,
1320        provider: &dyn crate::provider::IDatabaseProvider,
1321        entity_types: &[EntityTypeMeta],
1322    ) -> EFResult<()> {
1323        let q = |s: &str| self.dialect.quote(s);
1324        for meta in entity_types {
1325            let sql = format!("DROP TABLE IF EXISTS {};", q(meta.table_name.as_ref()));
1326            provider.execute_migration_command(&sql).await?;
1327        }
1328        Ok(())
1329    }
1330
1331    /// Inserts seed data configured via `ModelBuilder::has_data`.
1332    pub async fn apply_seed_data(
1333        &self,
1334        provider: &dyn crate::provider::IDatabaseProvider,
1335        meta: &EntityTypeMeta,
1336        rows: &[std::collections::HashMap<String, crate::provider::DbValue>],
1337    ) -> EFResult<()> {
1338        if rows.is_empty() {
1339            return Ok(());
1340        }
1341
1342        let gen = provider.sql_generator();
1343        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
1344        if scalar_props.is_empty() {
1345            return Ok(());
1346        }
1347
1348        let col_names: Vec<&str> = scalar_props
1349            .iter()
1350            .map(|p| p.column_name.as_ref())
1351            .collect();
1352
1353        let mut conn = provider.get_connection().await?;
1354        for row in rows {
1355            let params: Vec<crate::provider::DbValue> = scalar_props
1356                .iter()
1357                .map(|p| {
1358                    row.get(p.field_name.as_ref())
1359                        .cloned()
1360                        .unwrap_or(crate::provider::DbValue::Null)
1361                })
1362                .collect();
1363
1364            let sql = seed_insert_sql(self.dialect, meta.table_name.as_ref(), &col_names, gen);
1365            conn.execute(&sql, &params).await?;
1366        }
1367        Ok(())
1368    }
1369}
1370
1371/// Represents a record in the migration history table.
1372/// Table name: `__ef_migrations_history`
1373/// Corresponds to EFCore's `__EFMigrationsHistory`.
1374#[derive(Debug, Clone)]
1375pub struct MigrationHistoryEntry {
1376    pub migration_id: String,
1377    pub product_version: String,
1378}
1379
1380/// Table name for migration history.
1381pub const MIGRATION_HISTORY_TABLE: &str = "__ef_migrations_history";
1382
1383/// Product version recorded in migration history.
1384pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
1385
1386fn seed_insert_sql(
1387    dialect: MigrationDialect,
1388    table: &str,
1389    columns: &[&str],
1390    gen: &dyn crate::provider::ISqlGenerator,
1391) -> String {
1392    let quoted_table = dialect.quote(table);
1393    let quoted_cols: Vec<String> = columns.iter().map(|c| dialect.quote(c)).collect();
1394    let placeholders: Vec<String> = (0..columns.len())
1395        .map(|i| gen.parameter_placeholder(i + 1))
1396        .collect();
1397    let cols = quoted_cols.join(", ");
1398    let vals = placeholders.join(", ");
1399    match dialect {
1400        MigrationDialect::Sqlite => {
1401            format!("INSERT OR IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
1402        }
1403        MigrationDialect::Postgres => {
1404            format!("INSERT INTO {quoted_table} ({cols}) VALUES ({vals}) ON CONFLICT DO NOTHING")
1405        }
1406        MigrationDialect::MySql => {
1407            format!("INSERT IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
1408        }
1409    }
1410}
1411
1412/// Split a migration script into individual executable statements.
1413fn split_sql_statements(sql: &str) -> Vec<String> {
1414    sql.lines()
1415        .filter(|l| {
1416            let trimmed = l.trim();
1417            !trimmed.is_empty() && !trimmed.starts_with("--")
1418        })
1419        .collect::<Vec<_>>()
1420        .join("\n")
1421        .split(';')
1422        .map(|s| s.trim().to_string())
1423        .filter(|s| !s.is_empty())
1424        .collect()
1425}
1426
1427/// SQL to create the migration history table.
1428pub fn create_migration_history_table_sql(dialect: MigrationDialect) -> String {
1429    match dialect {
1430        MigrationDialect::Postgres => format!(
1431            r#"CREATE TABLE IF NOT EXISTS "{table}" (
1432    "migration_id" VARCHAR(150) NOT NULL PRIMARY KEY,
1433    "product_version" VARCHAR(32) NOT NULL,
1434    "applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
1435);"#,
1436            table = MIGRATION_HISTORY_TABLE
1437        ),
1438        MigrationDialect::MySql => format!(
1439            r#"CREATE TABLE IF NOT EXISTS `{table}` (
1440    `migration_id` VARCHAR(150) NOT NULL PRIMARY KEY,
1441    `product_version` VARCHAR(32) NOT NULL,
1442    `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
1443) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"#,
1444            table = MIGRATION_HISTORY_TABLE
1445        ),
1446        MigrationDialect::Sqlite => format!(
1447            r#"CREATE TABLE IF NOT EXISTS "{table}" (
1448    "migration_id" TEXT NOT NULL PRIMARY KEY,
1449    "product_version" TEXT NOT NULL
1450);"#,
1451            table = MIGRATION_HISTORY_TABLE
1452        ),
1453    }
1454}
1455
1456// ---------------------------------------------------------------------------
1457// Filesystem migration store (CLI / project migrations folder)
1458// ---------------------------------------------------------------------------
1459
1460use std::fs;
1461use std::path::{Path, PathBuf};
1462
1463/// Reads and writes migration scripts on disk.
1464///
1465/// Layout:
1466/// ```text
1467/// Migrations/
1468///   20260625_InitialCreate/
1469///     up.sql
1470///     down.sql
1471/// ```
1472#[derive(Debug, Clone)]
1473pub struct MigrationStore {
1474    root: PathBuf,
1475}
1476
1477impl MigrationStore {
1478    pub fn new(root: impl Into<PathBuf>) -> Self {
1479        Self { root: root.into() }
1480    }
1481
1482    pub fn root(&self) -> &Path {
1483        &self.root
1484    }
1485
1486    /// Saves a migration as `{id}/up.sql` and `{id}/down.sql`.
1487    pub fn save(&self, migration: &Migration) -> EFResult<()> {
1488        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
1489        let dir = self.root.join(&migration.id);
1490        fs::create_dir_all(&dir).map_err(migration_io_err)?;
1491        fs::write(dir.join("up.sql"), &migration.up_sql).map_err(migration_io_err)?;
1492        fs::write(dir.join("down.sql"), &migration.down_sql).map_err(migration_io_err)?;
1493        Ok(())
1494    }
1495
1496    /// Loads all migrations sorted by id (timestamp prefix recommended).
1497    pub fn load_all(&self) -> EFResult<Vec<Migration>> {
1498        if !self.root.exists() {
1499            return Ok(Vec::new());
1500        }
1501        let mut ids: Vec<String> = fs::read_dir(&self.root)
1502            .map_err(migration_io_err)?
1503            .filter_map(|e| e.ok())
1504            .filter(|e| e.path().is_dir())
1505            .filter_map(|e| e.file_name().into_string().ok())
1506            .collect();
1507        ids.sort();
1508        ids.into_iter().map(|id| self.load(&id)).collect()
1509    }
1510
1511    pub fn load(&self, id: &str) -> EFResult<Migration> {
1512        let dir = self.root.join(id);
1513        let up_sql = fs::read_to_string(dir.join("up.sql")).map_err(migration_io_err)?;
1514        let down_sql = fs::read_to_string(dir.join("down.sql")).map_err(migration_io_err)?;
1515        Ok(Migration {
1516            id: id.to_string(),
1517            description: id.to_string(),
1518            up_sql,
1519            down_sql,
1520        })
1521    }
1522
1523    /// Writes a model snapshot JSON file for the next diff baseline.
1524    pub fn save_snapshot(&self, snapshot: &ModelSnapshot) -> EFResult<()> {
1525        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
1526        let json = snapshot_to_json(snapshot);
1527        fs::write(self.root.join("model_snapshot.json"), json).map_err(migration_io_err)
1528    }
1529
1530    pub fn load_snapshot(&self) -> EFResult<Option<ModelSnapshot>> {
1531        let path = self.root.join("model_snapshot.json");
1532        if !path.exists() {
1533            return Ok(None);
1534        }
1535        let text = fs::read_to_string(path).map_err(migration_io_err)?;
1536        parse_model_snapshot_json(&text)
1537    }
1538}
1539
1540/// Parses a model snapshot JSON file (same format as [`MigrationStore::save_snapshot`]).
1541pub fn parse_model_snapshot_json(text: &str) -> EFResult<Option<ModelSnapshot>> {
1542    snapshot_from_json(text)
1543}
1544
1545fn migration_io_err(e: std::io::Error) -> crate::error::EFError {
1546    crate::error::EFError::migration(e.to_string())
1547}
1548
1549fn snapshot_to_json(snapshot: &ModelSnapshot) -> String {
1550    let mut out = String::from("{\n  \"migration_id\": \"");
1551    out.push_str(&snapshot.migration_id.replace('"', "\\\""));
1552    out.push_str("\",\n  \"entity_types\": [\n");
1553    for (i, et) in snapshot.entity_types.iter().enumerate() {
1554        if i > 0 {
1555            out.push(',');
1556        }
1557        out.push_str("    {\n");
1558        out.push_str(&format!(
1559            "      \"type_name\": \"{}\",\n",
1560            et.type_name.replace('"', "\\\"")
1561        ));
1562        out.push_str(&format!(
1563            "      \"table_name\": \"{}\",\n",
1564            et.table_name.replace('"', "\\\"")
1565        ));
1566        out.push_str("      \"columns\": [\n");
1567        for (j, col) in et.columns.iter().enumerate() {
1568            if j > 0 {
1569                out.push(',');
1570            }
1571            out.push_str(&format!(
1572                "        {{\"field_name\":\"{}\",\"column_name\":\"{}\",\"type_name\":\"{}\",\"is_primary_key\":{},\"is_required\":{},\"is_foreign_key\":{},\"max_length\":{},\"is_auto_increment\":{},\"is_sequence\":{},\"sequence_name\":{},\"fk_referenced_table\":{},\"fk_referenced_column\":{},\"has_index\":{},\"is_unique\":{},\"fk_on_delete\":{}}}\n",
1573                col.field_name.replace('"', "\\\""),
1574                col.column_name.replace('"', "\\\""),
1575                col.type_name.replace('"', "\\\""),
1576                col.is_primary_key,
1577                col.is_required,
1578                col.is_foreign_key,
1579                col.max_length.map(|n| n.to_string()).unwrap_or_else(|| "null".into()),
1580                col.is_auto_increment,
1581                col.is_sequence,
1582                col.sequence_name
1583                    .as_ref()
1584                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1585                    .unwrap_or_else(|| "null".into()),
1586                col.fk_referenced_table
1587                    .as_ref()
1588                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1589                    .unwrap_or_else(|| "null".into()),
1590                col.fk_referenced_column
1591                    .as_ref()
1592                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1593                    .unwrap_or_else(|| "null".into()),
1594                col.has_index,
1595                col.is_unique,
1596                col.fk_on_delete
1597                    .as_ref()
1598                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1599                    .unwrap_or_else(|| "null".into())
1600            ));
1601        }
1602        out.push_str("      ]\n    }\n");
1603    }
1604    out.push_str("  ]\n}\n");
1605    out
1606}
1607
1608fn snapshot_from_json(text: &str) -> EFResult<Option<ModelSnapshot>> {
1609    // Minimal JSON parser for snapshot files written by save_snapshot.
1610    let migration_id =
1611        extract_json_string(text, "migration_id").unwrap_or_else(|| "__snapshot__".to_string());
1612    let mut entity_types = Vec::new();
1613    if let Some(arr_start) = text.find("\"entity_types\"") {
1614        let slice = &text[arr_start..];
1615        for table_block in slice.split("\"table_name\"").skip(1) {
1616            let table_name = extract_quoted_after_colon(table_block).unwrap_or_default();
1617            let _type_name = entity_types.len().to_string(); // fallback
1618            let columns_start = table_block.find("\"columns\"");
1619            let mut columns = Vec::new();
1620            if let Some(cs) = columns_start {
1621                let col_slice = &table_block[cs..];
1622                for col_chunk in col_slice.split("\"field_name\"").skip(1) {
1623                    if let Some(field_name) = extract_quoted_after_colon(col_chunk) {
1624                        let column_name = extract_json_string(col_chunk, "column_name")
1625                            .unwrap_or_else(|| field_name.clone());
1626                        let type_name_col = extract_json_string(col_chunk, "type_name")
1627                            .unwrap_or_else(|| "String".to_string());
1628                        columns.push(SnapshotColumn {
1629                            field_name,
1630                            column_name,
1631                            type_name: type_name_col,
1632                            is_primary_key: col_chunk.contains("\"is_primary_key\":true"),
1633                            is_required: col_chunk.contains("\"is_required\":true"),
1634                            is_foreign_key: col_chunk.contains("\"is_foreign_key\":true"),
1635                            max_length: None,
1636                            is_auto_increment: col_chunk.contains("\"is_auto_increment\":true"),
1637                            is_sequence: col_chunk.contains("\"is_sequence\":true"),
1638                            sequence_name: extract_json_string(col_chunk, "sequence_name"),
1639                            fk_referenced_table: extract_json_string(
1640                                col_chunk,
1641                                "fk_referenced_table",
1642                            ),
1643                            fk_referenced_column: extract_json_string(
1644                                col_chunk,
1645                                "fk_referenced_column",
1646                            ),
1647                            has_index: col_chunk.contains("\"has_index\":true"),
1648                            is_unique: col_chunk.contains("\"is_unique\":true"),
1649                            fk_on_delete: extract_json_string(col_chunk, "fk_on_delete"),
1650                        });
1651                    }
1652                }
1653            }
1654            if !table_name.is_empty() {
1655                entity_types.push(SnapshotEntityType {
1656                    type_name: table_name.clone(),
1657                    table_name,
1658                    columns,
1659                });
1660            }
1661        }
1662    }
1663    if entity_types.is_empty() {
1664        return Ok(None);
1665    }
1666    Ok(Some(ModelSnapshot {
1667        migration_id,
1668        entity_types,
1669    }))
1670}
1671
1672fn extract_json_string(haystack: &str, key: &str) -> Option<String> {
1673    let needle = format!("\"{key}\"");
1674    let pos = haystack.find(&needle)?;
1675    extract_quoted_after_colon(&haystack[pos + needle.len()..])
1676}
1677
1678fn extract_quoted_after_colon(s: &str) -> Option<String> {
1679    let colon = s.find(':')?;
1680    let rest = s[colon + 1..].trim_start();
1681    if !rest.starts_with('"') {
1682        return None;
1683    }
1684    let inner = &rest[1..];
1685    let end = inner.find('"')?;
1686    Some(inner[..end].to_string())
1687}