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: SnapshotColumn,
209        new: 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: (*old_col).clone(),
423                        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((
567                c.column_name.as_str(),
568                (c, rt, rc, c.fk_on_delete.clone()),
569            ))
570        })
571        .collect();
572    let new_fks: HashMap<&str, (&SnapshotColumn, String, String, Option<String>)> = new_et
573        .columns
574        .iter()
575        .filter_map(|c| {
576            let (rt, rc) = fk_target(c)?;
577            Some((
578                c.column_name.as_str(),
579                (c, rt, rc, c.fk_on_delete.clone()),
580            ))
581        })
582        .collect();
583
584    let old_names: HashSet<&str> = old_fks.keys().copied().collect();
585    let new_names: HashSet<&str> = new_fks.keys().copied().collect();
586
587    for col in new_names.difference(&old_names) {
588        let (_, rt, rc, od) = &new_fks[col];
589        changes.push(SchemaChange::AddForeignKey {
590            table: table.to_string(),
591            column: (*col).to_string(),
592            referenced_table: rt.clone(),
593            referenced_column: rc.clone(),
594            on_delete: od.clone(),
595        });
596    }
597
598    for col in old_names.difference(&new_names) {
599        let (_, rt, _, _) = &old_fks[col];
600        changes.push(SchemaChange::DropForeignKey {
601            table: table.to_string(),
602            column: (*col).to_string(),
603            referenced_table: rt.clone(),
604        });
605    }
606
607    for col in old_names.intersection(&new_names) {
608        let (_, old_rt, old_rc, old_od) = &old_fks[col];
609        let (_, new_rt, new_rc, new_od) = &new_fks[col];
610        if old_rt != new_rt || old_rc != new_rc || old_od != new_od {
611            changes.push(SchemaChange::DropForeignKey {
612                table: table.to_string(),
613                column: (*col).to_string(),
614                referenced_table: old_rt.clone(),
615            });
616            changes.push(SchemaChange::AddForeignKey {
617                table: table.to_string(),
618                column: (*col).to_string(),
619                referenced_table: new_rt.clone(),
620                referenced_column: new_rc.clone(),
621                on_delete: new_od.clone(),
622            });
623        }
624    }
625
626    changes
627}
628
629/// Compares two columns for structural equality, ignoring index state
630/// (`has_index` / `is_unique`). Index changes are handled separately by
631/// `diff_indexes` so they emit `CreateIndex`/`DropIndex` instead of
632/// `AlterColumn`.
633fn columns_structurally_equal(a: &SnapshotColumn, b: &SnapshotColumn) -> bool {
634    a.field_name == b.field_name
635        && a.column_name == b.column_name
636        && a.type_name == b.type_name
637        && a.is_primary_key == b.is_primary_key
638        && a.is_required == b.is_required
639        && a.is_foreign_key == b.is_foreign_key
640        && a.max_length == b.max_length
641        && a.is_auto_increment == b.is_auto_increment
642        && a.fk_referenced_table == b.fk_referenced_table
643        && a.fk_referenced_column == b.fk_referenced_column
644}
645
646/// Returns `CreateIndex`/`DropIndex` changes when a column's index state
647/// transitions between None / NonUnique / Unique.
648fn diff_indexes(table: &str, old: &SnapshotColumn, new: &SnapshotColumn) -> Vec<SchemaChange> {
649    let old_kind = index_kind(old);
650    let new_kind = index_kind(new);
651    if old_kind == new_kind {
652        return Vec::new();
653    }
654    let mut changes = Vec::new();
655    if old_kind != IndexKind::None {
656        changes.push(SchemaChange::DropIndex {
657            table: table.to_string(),
658            column: new.column_name.clone(),
659            is_unique: old_kind == IndexKind::Unique,
660        });
661    }
662    if new_kind != IndexKind::None {
663        changes.push(SchemaChange::CreateIndex {
664            table: table.to_string(),
665            column: new.column_name.clone(),
666            is_unique: new_kind == IndexKind::Unique,
667        });
668    }
669    changes
670}
671
672#[derive(Clone, Copy, PartialEq, Eq)]
673enum IndexKind {
674    None,
675    NonUnique,
676    Unique,
677}
678
679fn index_kind(col: &SnapshotColumn) -> IndexKind {
680    if col.is_unique {
681        IndexKind::Unique
682    } else if col.has_index {
683        IndexKind::NonUnique
684    } else {
685        IndexKind::None
686    }
687}
688
689impl MigrationEngine {
690    fn initial_create_with_fks(&self, current: &ModelSnapshot) -> Vec<SchemaChange> {
691        let mut changes = self.initial_create(current);
692        for et in &current.entity_types {
693            Self::append_create_table_fks(&mut changes, &et.table_name, &et.columns);
694            Self::append_create_table_indexes(&mut changes, &et.table_name, &et.columns);
695        }
696        changes
697    }
698
699    // -----------------------------------------------------------------------
700    // SQL generation
701    // -----------------------------------------------------------------------
702
703    /// Standard foreign-key constraint name used by Add/DropForeignKey.
704    pub fn foreign_key_name(table: &str, column: &str, referenced_table: &str) -> String {
705        format!("fk_{}_{}_{}", table, column, referenced_table)
706    }
707
708    /// Standard index name used by CreateIndex/DropIndex.
709    pub fn index_name(table: &str, column: &str) -> String {
710        format!("ix_{}_{}", table, column)
711    }
712
713    pub fn generate_alter_column_sql(
714        &self,
715        table: &str,
716        column_name: &str,
717        new: &SnapshotColumn,
718    ) -> String {
719        let q = |s: &str| self.dialect.quote(s);
720        let col_type = self.dialect.map_column_type(new);
721        match self.dialect {
722            MigrationDialect::Postgres => {
723                let nullable = if new.is_required {
724                    "SET NOT NULL"
725                } else {
726                    "DROP NOT NULL"
727                };
728                format!(
729                    "ALTER TABLE {} ALTER COLUMN {} TYPE {};\nALTER TABLE {} ALTER COLUMN {} {};\n",
730                    q(table),
731                    q(column_name),
732                    col_type,
733                    q(table),
734                    q(column_name),
735                    nullable
736                )
737            }
738            MigrationDialect::MySql => {
739                let nullable = if new.is_required { "NOT NULL" } else { "NULL" };
740                format!(
741                    "ALTER TABLE {} MODIFY COLUMN {} {} {};\n",
742                    q(table),
743                    q(column_name),
744                    col_type,
745                    nullable
746                )
747            }
748            MigrationDialect::Sqlite => format!(
749                "-- SQLite does not support ALTER COLUMN type changes; \
750                 rebuild table manually for {}.{}\n",
751                q(table),
752                q(column_name)
753            ),
754        }
755    }
756
757    fn generate_up_sql(&self, changes: &[SchemaChange]) -> String {
758        self.generate_up_sql_inner(changes, true)
759    }
760
761    fn generate_ddl_sql(&self, changes: &[SchemaChange]) -> String {
762        self.generate_up_sql_inner(changes, false)
763    }
764
765    fn generate_up_sql_inner(&self, changes: &[SchemaChange], record_history: bool) -> String {
766        let mut sql = String::from("-- Up Migration (auto-generated by rust-ef)\n\n");
767        let q = |s: &str| self.dialect.quote(s);
768        let create_kw = if record_history {
769            "CREATE TABLE"
770        } else {
771            "CREATE TABLE IF NOT EXISTS"
772        };
773
774        for change in changes {
775            match change {
776                SchemaChange::CreateTable { table, columns } => {
777                    // PostgreSQL: create sequences before the table
778                    if self.dialect == MigrationDialect::Postgres {
779                        for c in columns {
780                            if c.is_sequence {
781                                if let Some(seq_name) = &c.sequence_name {
782                                    sql.push_str(&format!(
783                                        "CREATE SEQUENCE IF NOT EXISTS {};\n",
784                                        q(seq_name)
785                                    ));
786                                }
787                            }
788                        }
789                    }
790
791                    sql.push_str(&format!("{} {} (\n", create_kw, q(table)));
792
793                    // Separate primary key columns from regular columns
794                    let pk_columns: Vec<&str> = columns
795                        .iter()
796                        .filter(|c| c.is_primary_key)
797                        .map(|c| c.column_name.as_str())
798                        .collect();
799
800                    let col_defs: Vec<String> = columns
801                        .iter()
802                        .map(|c| {
803                            let nullable = if c.is_required { "NOT NULL" } else { "NULL" };
804                            let col_type = self.dialect.map_column_type(c);
805                            // PostgreSQL sequence column: add DEFAULT nextval('seq_name')
806                            if c.is_sequence && self.dialect == MigrationDialect::Postgres {
807                                if let Some(seq_name) = &c.sequence_name {
808                                    return format!(
809                                        "{} {} DEFAULT nextval('{}') {}",
810                                        q(&c.column_name),
811                                        col_type,
812                                        seq_name,
813                                        nullable
814                                    );
815                                }
816                            }
817                            // Don't put PRIMARY KEY on individual columns; handle separately
818                            [q(&c.column_name), col_type, nullable.to_string()]
819                                .into_iter()
820                                .filter(|s| !s.is_empty())
821                                .collect::<Vec<_>>()
822                                .join(" ")
823                        })
824                        .collect();
825                    sql.push_str(&format!("    {}\n", col_defs.join(",\n    ")));
826
827                    // Composite primary key constraint
828                    if pk_columns.len() == 1 {
829                        sql.push_str(&format!("    ,PRIMARY KEY ({})\n", q(pk_columns[0])));
830                    } else if pk_columns.len() > 1 {
831                        let pk_list: Vec<String> = pk_columns.iter().map(|c| q(c)).collect();
832                        sql.push_str(&format!("    ,PRIMARY KEY ({})\n", pk_list.join(", ")));
833                    }
834
835                    sql.push_str(");\n\n");
836                }
837                SchemaChange::DropTable { table } => {
838                    sql.push_str(&format!("DROP TABLE IF EXISTS {};\n", q(table)));
839                }
840                SchemaChange::AddColumn { table, column } => {
841                    let col_type = self.dialect.map_column_type(column);
842                    let nullable = if column.is_required {
843                        "NOT NULL"
844                    } else {
845                        "NULL"
846                    };
847                    sql.push_str(&format!(
848                        "ALTER TABLE {} ADD COLUMN {} {} {};\n",
849                        q(table),
850                        q(&column.column_name),
851                        col_type,
852                        nullable
853                    ));
854                }
855                SchemaChange::DropColumn { table, column_name } => {
856                    sql.push_str(&format!(
857                        "ALTER TABLE {} DROP COLUMN IF EXISTS {};\n",
858                        q(table),
859                        q(column_name)
860                    ));
861                }
862                SchemaChange::AlterColumn {
863                    table,
864                    column_name,
865                    old: _,
866                    new,
867                } => {
868                    sql.push_str(&self.generate_alter_column_sql(table, column_name, new));
869                }
870                SchemaChange::AddForeignKey {
871                    table,
872                    column,
873                    referenced_table,
874                    referenced_column,
875                    on_delete,
876                } => {
877                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
878                    let on_delete_clause = on_delete
879                        .as_deref()
880                        .map(|c| format!(" ON DELETE {}", c))
881                        .unwrap_or_default();
882                    sql.push_str(&format!(
883                        "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}){};\n",
884                        q(table),
885                        q(&fk_name),
886                        q(column),
887                        q(referenced_table),
888                        q(referenced_column),
889                        on_delete_clause
890                    ));
891                }
892                SchemaChange::DropForeignKey {
893                    table,
894                    column,
895                    referenced_table,
896                } => {
897                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
898                    match self.dialect {
899                        MigrationDialect::MySql => sql.push_str(&format!(
900                            "ALTER TABLE {} DROP FOREIGN KEY {};\n",
901                            q(table),
902                            q(&fk_name)
903                        )),
904                        _ => sql.push_str(&format!(
905                            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};\n",
906                            q(table),
907                            q(&fk_name)
908                        )),
909                    }
910                }
911                SchemaChange::CreateIndex {
912                    table,
913                    column,
914                    is_unique,
915                } => {
916                    let unique_kw = if *is_unique { "UNIQUE " } else { "" };
917                    let idx_name = index_name(table, column);
918                    sql.push_str(&format!(
919                        "CREATE {unique_kw}INDEX {} ON {} ({});\n",
920                        q(&idx_name),
921                        q(table),
922                        q(column)
923                    ));
924                }
925                SchemaChange::DropIndex {
926                    table,
927                    column,
928                    is_unique: _,
929                } => {
930                    let idx_name = index_name(table, column);
931                    match self.dialect {
932                        MigrationDialect::MySql => {
933                            sql.push_str(&format!("DROP INDEX {} ON {};\n", q(&idx_name), q(table)))
934                        }
935                        _ => sql.push_str(&format!("DROP INDEX IF EXISTS {};\n", q(&idx_name))),
936                    }
937                }
938            }
939        }
940
941        if record_history {
942            sql.push_str(&format!(
943                "INSERT INTO {}(migration_id, product_version) VALUES ('{{migration_id}}', '{}');\n",
944                q(MIGRATION_HISTORY_TABLE),
945                PRODUCT_VERSION
946            ));
947        }
948
949        sql
950    }
951
952    fn generate_down_sql(&self, changes: &[SchemaChange]) -> String {
953        let mut sql = String::from("-- Down Migration (auto-generated by rust-ef)\n\n");
954        let q = |s: &str| self.dialect.quote(s);
955
956        // Reverse the changes for the down migration
957        for change in changes.iter().rev() {
958            match change {
959                SchemaChange::CreateTable { table, .. } => {
960                    sql.push_str(&format!("DROP TABLE IF EXISTS {};\n", q(table)));
961                }
962                SchemaChange::DropTable { table } => {
963                    // Can't fully restore a dropped table ?log warning
964                    sql.push_str(&format!(
965                        "-- WARNING: Cannot restore table {} (original schema unknown)\n",
966                        q(table)
967                    ));
968                }
969                SchemaChange::AddColumn { table, column } => {
970                    sql.push_str(&format!(
971                        "ALTER TABLE {} DROP COLUMN IF EXISTS {};\n",
972                        q(table),
973                        q(&column.column_name)
974                    ));
975                }
976                SchemaChange::DropColumn { table, column_name } => {
977                    // Can't fully restore dropped column without original type
978                    sql.push_str(&format!(
979                        "-- WARNING: Cannot restore column {} on {} (original type unknown)\n",
980                        q(column_name),
981                        q(table)
982                    ));
983                }
984                SchemaChange::AlterColumn {
985                    table,
986                    column_name,
987                    old,
988                    new: _,
989                } => {
990                    sql.push_str(&self.generate_alter_column_sql(table, column_name, old));
991                }
992                SchemaChange::AddForeignKey {
993                    table,
994                    column,
995                    referenced_table,
996                    ..
997                } => {
998                    let fk_name = Self::foreign_key_name(table, column, referenced_table);
999                    match self.dialect {
1000                        MigrationDialect::MySql => sql.push_str(&format!(
1001                            "ALTER TABLE {} DROP FOREIGN KEY {};\n",
1002                            q(table),
1003                            q(&fk_name)
1004                        )),
1005                        _ => sql.push_str(&format!(
1006                            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {};\n",
1007                            q(table),
1008                            q(&fk_name)
1009                        )),
1010                    }
1011                }
1012                SchemaChange::DropForeignKey {
1013                    table,
1014                    column,
1015                    referenced_table,
1016                    ..
1017                } => {
1018                    sql.push_str(&format!(
1019                        "-- WARNING: Cannot restore foreign key constraint {} on {}.{}\n",
1020                        q(&Self::foreign_key_name(table, column, referenced_table)),
1021                        q(table),
1022                        q(column)
1023                    ));
1024                }
1025                SchemaChange::CreateIndex {
1026                    table,
1027                    column,
1028                    is_unique: _,
1029                } => {
1030                    let idx_name = index_name(table, column);
1031                    match self.dialect {
1032                        MigrationDialect::MySql => {
1033                            sql.push_str(&format!("DROP INDEX {} ON {};\n", q(&idx_name), q(table)))
1034                        }
1035                        _ => sql.push_str(&format!("DROP INDEX IF EXISTS {};\n", q(&idx_name))),
1036                    }
1037                }
1038                SchemaChange::DropIndex {
1039                    table,
1040                    column,
1041                    is_unique,
1042                } => {
1043                    let unique_kw = if *is_unique { "UNIQUE " } else { "" };
1044                    let idx_name = index_name(table, column);
1045                    sql.push_str(&format!(
1046                        "CREATE {unique_kw}INDEX {} ON {} ({});\n",
1047                        q(&idx_name),
1048                        q(table),
1049                        q(column)
1050                    ));
1051                }
1052            }
1053        }
1054
1055        sql.push_str(&format!(
1056            "DELETE FROM {} WHERE migration_id = '{{migration_id}}';\n",
1057            q(MIGRATION_HISTORY_TABLE)
1058        ));
1059
1060        sql
1061    }
1062
1063    /// Ensures the migration history table exists.
1064    pub async fn ensure_history_table(
1065        &self,
1066        provider: &dyn crate::provider::IDatabaseProvider,
1067    ) -> EFResult<()> {
1068        let sql = create_migration_history_table_sql(self.dialect);
1069        provider.execute_migration_command(&sql).await
1070    }
1071
1072    /// Applies a migration's Up SQL against a database provider.
1073    pub async fn apply(
1074        &self,
1075        provider: &dyn crate::provider::IDatabaseProvider,
1076        migration: &Migration,
1077    ) -> EFResult<()> {
1078        if self.is_applied(provider, &migration.id).await? {
1079            return Ok(());
1080        }
1081        self.ensure_history_table(provider).await?;
1082        let sql = migration.up_sql.replace("{migration_id}", &migration.id);
1083        for statement in split_sql_statements(&sql) {
1084            if !statement.is_empty() {
1085                provider.execute_migration_command(&statement).await?;
1086            }
1087        }
1088        Ok(())
1089    }
1090
1091    /// Reverts a migration using its Down SQL.
1092    pub async fn revert(
1093        &self,
1094        provider: &dyn crate::provider::IDatabaseProvider,
1095        migration: &Migration,
1096    ) -> EFResult<()> {
1097        if !self.is_applied(provider, &migration.id).await? {
1098            return Err(crate::error::EFError::migration(format!(
1099                "migration '{}' is not applied",
1100                migration.id
1101            )));
1102        }
1103        let sql = migration.down_sql.replace("{migration_id}", &migration.id);
1104        for statement in split_sql_statements(&sql) {
1105            if !statement.is_empty() {
1106                provider.execute_migration_command(&statement).await?;
1107            }
1108        }
1109        Ok(())
1110    }
1111
1112    /// Returns migrations already recorded in the database history table.
1113    pub async fn get_applied_migrations(
1114        &self,
1115        provider: &dyn crate::provider::IDatabaseProvider,
1116    ) -> EFResult<Vec<MigrationHistoryEntry>> {
1117        self.ensure_history_table(provider).await?;
1118        let q = |s: &str| self.dialect.quote(s);
1119        let table = q(MIGRATION_HISTORY_TABLE);
1120        let sql = format!(
1121            "SELECT {}, {} FROM {} ORDER BY {}",
1122            q("migration_id"),
1123            q("product_version"),
1124            table,
1125            q("migration_id")
1126        );
1127        let mut conn = provider.get_connection().await?;
1128        let rows = conn.query(&sql, &[]).await?;
1129        Ok(rows
1130            .into_iter()
1131            .map(|row| MigrationHistoryEntry {
1132                migration_id: row
1133                    .first()
1134                    .and_then(|v| String::try_from(v.clone()).ok())
1135                    .unwrap_or_default(),
1136                product_version: row
1137                    .get(1)
1138                    .and_then(|v| String::try_from(v.clone()).ok())
1139                    .unwrap_or_default(),
1140            })
1141            .collect())
1142    }
1143
1144    /// Returns whether a migration id is already recorded in history.
1145    pub async fn is_applied(
1146        &self,
1147        provider: &dyn crate::provider::IDatabaseProvider,
1148        migration_id: &str,
1149    ) -> EFResult<bool> {
1150        let applied = self.get_applied_migrations(provider).await?;
1151        Ok(applied.iter().any(|e| e.migration_id == migration_id))
1152    }
1153
1154    /// Applies all pending migrations in order, skipping already-applied ids.
1155    pub async fn apply_pending(
1156        &self,
1157        provider: &dyn crate::provider::IDatabaseProvider,
1158        migrations: &[Migration],
1159    ) -> EFResult<usize> {
1160        let applied: std::collections::HashSet<String> = self
1161            .get_applied_migrations(provider)
1162            .await?
1163            .into_iter()
1164            .map(|e| e.migration_id)
1165            .collect();
1166        let mut count = 0usize;
1167        for migration in migrations {
1168            if applied.contains(&migration.id) {
1169                continue;
1170            }
1171            self.apply(provider, migration).await?;
1172            count += 1;
1173        }
1174        Ok(count)
1175    }
1176
1177    /// Reverts the most recently applied migration from the given list.
1178    pub async fn revert_last(
1179        &self,
1180        provider: &dyn crate::provider::IDatabaseProvider,
1181        migrations: &[Migration],
1182    ) -> EFResult<Option<String>> {
1183        let applied = self.get_applied_migrations(provider).await?;
1184        let last = applied.last().map(|e| e.migration_id.clone());
1185        let Some(last_id) = last else {
1186            return Ok(None);
1187        };
1188        let migration = migrations.iter().find(|m| m.id == last_id).ok_or_else(|| {
1189            crate::error::EFError::migration(format!(
1190                "applied migration '{}' not found in local migration set",
1191                last_id
1192            ))
1193        })?;
1194        self.revert(provider, migration).await?;
1195        Ok(Some(last_id))
1196    }
1197
1198    /// Reverts all migrations applied strictly after `target` (exclusive),
1199    /// leaving the database at `target`'s state. Returns the reverted ids in
1200    /// the order they were reverted (most-recent first).
1201    ///
1202    /// If `target` is `None`, reverts ALL applied migrations.
1203    /// Returns an error if `target` is not currently applied.
1204    pub async fn revert_to_target(
1205        &self,
1206        provider: &dyn crate::provider::IDatabaseProvider,
1207        migrations: &[Migration],
1208        target: Option<&str>,
1209    ) -> EFResult<Vec<String>> {
1210        let applied = self.get_applied_migrations(provider).await?;
1211        let applied_ids: Vec<&str> = applied.iter().map(|e| e.migration_id.as_str()).collect();
1212
1213        let to_revert: Vec<String> = match target {
1214            Some(t) => {
1215                let idx = applied_ids.iter().position(|id| *id == t).ok_or_else(|| {
1216                    crate::error::EFError::migration(format!(
1217                        "target migration '{t}' is not applied"
1218                    ))
1219                })?;
1220                applied_ids[idx + 1..]
1221                    .iter()
1222                    .map(|s| s.to_string())
1223                    .collect()
1224            }
1225            None => applied_ids.iter().map(|s| s.to_string()).collect(),
1226        };
1227
1228        let mut reverted = Vec::with_capacity(to_revert.len());
1229        for id in to_revert.iter().rev() {
1230            let migration = migrations.iter().find(|m| m.id == *id).ok_or_else(|| {
1231                crate::error::EFError::migration(format!(
1232                    "applied migration '{id}' not found in local migration set"
1233                ))
1234            })?;
1235            self.revert(provider, migration).await?;
1236            reverted.push(id.clone());
1237        }
1238        Ok(reverted)
1239    }
1240
1241    /// Generates a combined SQL script transitioning the database from the
1242    /// `from` migration state to the `to` migration state.
1243    ///
1244    /// Semantics (mirroring `dotnet ef migrations script`):
1245    /// - `from` is exclusive: the DB is assumed to already be at `from`.
1246    /// - `to` is inclusive: the script brings the DB up to and including `to`.
1247    /// - `from = None`: start from an empty database (before any migration).
1248    /// - `to = None`: end at the latest migration.
1249    /// - When `from` precedes `to`: emits Up SQL for migrations in `(from, to]`.
1250    /// - When `from` follows `to`: emits Down SQL for migrations in `(to, from]`.
1251    /// - When `from == to`: emits a no-op comment.
1252    pub fn generate_script(
1253        migrations: &[Migration],
1254        from: Option<&str>,
1255        to: Option<&str>,
1256    ) -> EFResult<String> {
1257        // Resolve from/to to indices. from=None means "before first migration" (-1).
1258        // to=None means "after last migration" (migrations.len() - 1).
1259        let from_idx: i64 = match from {
1260            Some(f) => migrations.iter().position(|m| m.id == f).ok_or_else(|| {
1261                crate::error::EFError::migration(format!(
1262                    "from migration '{f}' not found in local set"
1263                ))
1264            })? as i64,
1265            None => -1,
1266        };
1267        let to_idx: i64 = match to {
1268            Some(t) => migrations.iter().position(|m| m.id == t).ok_or_else(|| {
1269                crate::error::EFError::migration(format!(
1270                    "to migration '{t}' not found in local set"
1271                ))
1272            })? as i64,
1273            None => migrations.len() as i64 - 1,
1274        };
1275
1276        let mut sql = String::new();
1277        if from_idx < to_idx {
1278            // Forward: up scripts for migrations in (from_idx, to_idx].
1279            sql.push_str("-- Migration script (forward)\n\n");
1280            let start = (from_idx + 1) as usize;
1281            let end = (to_idx + 1) as usize; // exclusive
1282            for m in &migrations[start..end] {
1283                sql.push_str(&format!("-- Up: {}\n", m.id));
1284                sql.push_str(&m.up_sql.replace("{migration_id}", &m.id));
1285                sql.push('\n');
1286            }
1287        } else if from_idx > to_idx {
1288            // Reverse: down scripts for migrations in (to_idx, from_idx].
1289            sql.push_str("-- Migration script (reverse)\n\n");
1290            let start = to_idx as usize + 1; // inclusive
1291            let end = from_idx as usize + 1; // exclusive
1292            for m in migrations[start..end].iter().rev() {
1293                sql.push_str(&format!("-- Down: {}\n", m.id));
1294                sql.push_str(&m.down_sql.replace("{migration_id}", &m.id));
1295                sql.push('\n');
1296            }
1297        } else {
1298            sql.push_str("-- Nothing to do (from == to)\n");
1299        }
1300        Ok(sql)
1301    }
1302
1303    /// Generates and applies the initial schema for the given entity types.
1304    /// Corresponds to EF Core `Database.EnsureCreated()`.
1305    /// Does not use the migrations history table (idempotent DDL only).
1306    pub async fn ensure_created(
1307        &self,
1308        provider: &dyn crate::provider::IDatabaseProvider,
1309        entity_types: &[EntityTypeMeta],
1310    ) -> EFResult<()> {
1311        let snapshot = self.create_snapshot("__ensure_created__", entity_types);
1312        let changes = self.initial_create(&snapshot);
1313        let sql = self.generate_ddl_sql(&changes);
1314        for statement in split_sql_statements(&sql) {
1315            if !statement.is_empty() {
1316                provider.execute_migration_command(&statement).await?;
1317            }
1318        }
1319        Ok(())
1320    }
1321
1322    /// Drops all tables for the given entity types.
1323    /// Corresponds to EF Core `Database.EnsureDeleted()`.
1324    pub async fn ensure_deleted(
1325        &self,
1326        provider: &dyn crate::provider::IDatabaseProvider,
1327        entity_types: &[EntityTypeMeta],
1328    ) -> EFResult<()> {
1329        let q = |s: &str| self.dialect.quote(s);
1330        for meta in entity_types {
1331            let sql = format!("DROP TABLE IF EXISTS {};", q(meta.table_name.as_ref()));
1332            provider.execute_migration_command(&sql).await?;
1333        }
1334        Ok(())
1335    }
1336
1337    /// Inserts seed data configured via `ModelBuilder::has_data`.
1338    pub async fn apply_seed_data(
1339        &self,
1340        provider: &dyn crate::provider::IDatabaseProvider,
1341        meta: &EntityTypeMeta,
1342        rows: &[std::collections::HashMap<String, crate::provider::DbValue>],
1343    ) -> EFResult<()> {
1344        if rows.is_empty() {
1345            return Ok(());
1346        }
1347
1348        let gen = provider.sql_generator();
1349        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
1350        if scalar_props.is_empty() {
1351            return Ok(());
1352        }
1353
1354        let col_names: Vec<&str> = scalar_props
1355            .iter()
1356            .map(|p| p.column_name.as_ref())
1357            .collect();
1358
1359        let mut conn = provider.get_connection().await?;
1360        for row in rows {
1361            let params: Vec<crate::provider::DbValue> = scalar_props
1362                .iter()
1363                .map(|p| {
1364                    row.get(p.field_name.as_ref())
1365                        .cloned()
1366                        .unwrap_or(crate::provider::DbValue::Null)
1367                })
1368                .collect();
1369
1370            let sql = seed_insert_sql(self.dialect, meta.table_name.as_ref(), &col_names, gen);
1371            conn.execute(&sql, &params).await?;
1372        }
1373        Ok(())
1374    }
1375}
1376
1377/// Represents a record in the migration history table.
1378/// Table name: `__ef_migrations_history`
1379/// Corresponds to EFCore's `__EFMigrationsHistory`.
1380#[derive(Debug, Clone)]
1381pub struct MigrationHistoryEntry {
1382    pub migration_id: String,
1383    pub product_version: String,
1384}
1385
1386/// Table name for migration history.
1387pub const MIGRATION_HISTORY_TABLE: &str = "__ef_migrations_history";
1388
1389/// Product version recorded in migration history.
1390pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
1391
1392fn seed_insert_sql(
1393    dialect: MigrationDialect,
1394    table: &str,
1395    columns: &[&str],
1396    gen: &dyn crate::provider::ISqlGenerator,
1397) -> String {
1398    let quoted_table = dialect.quote(table);
1399    let quoted_cols: Vec<String> = columns.iter().map(|c| dialect.quote(c)).collect();
1400    let placeholders: Vec<String> = (0..columns.len())
1401        .map(|i| gen.parameter_placeholder(i + 1))
1402        .collect();
1403    let cols = quoted_cols.join(", ");
1404    let vals = placeholders.join(", ");
1405    match dialect {
1406        MigrationDialect::Sqlite => {
1407            format!("INSERT OR IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
1408        }
1409        MigrationDialect::Postgres => {
1410            format!("INSERT INTO {quoted_table} ({cols}) VALUES ({vals}) ON CONFLICT DO NOTHING")
1411        }
1412        MigrationDialect::MySql => {
1413            format!("INSERT IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
1414        }
1415    }
1416}
1417
1418/// Split a migration script into individual executable statements.
1419fn split_sql_statements(sql: &str) -> Vec<String> {
1420    sql.lines()
1421        .filter(|l| {
1422            let trimmed = l.trim();
1423            !trimmed.is_empty() && !trimmed.starts_with("--")
1424        })
1425        .collect::<Vec<_>>()
1426        .join("\n")
1427        .split(';')
1428        .map(|s| s.trim().to_string())
1429        .filter(|s| !s.is_empty())
1430        .collect()
1431}
1432
1433/// SQL to create the migration history table.
1434pub fn create_migration_history_table_sql(dialect: MigrationDialect) -> String {
1435    match dialect {
1436        MigrationDialect::Postgres => format!(
1437            r#"CREATE TABLE IF NOT EXISTS "{table}" (
1438    "migration_id" VARCHAR(150) NOT NULL PRIMARY KEY,
1439    "product_version" VARCHAR(32) NOT NULL,
1440    "applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
1441);"#,
1442            table = MIGRATION_HISTORY_TABLE
1443        ),
1444        MigrationDialect::MySql => format!(
1445            r#"CREATE TABLE IF NOT EXISTS `{table}` (
1446    `migration_id` VARCHAR(150) NOT NULL PRIMARY KEY,
1447    `product_version` VARCHAR(32) NOT NULL,
1448    `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
1449) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"#,
1450            table = MIGRATION_HISTORY_TABLE
1451        ),
1452        MigrationDialect::Sqlite => format!(
1453            r#"CREATE TABLE IF NOT EXISTS "{table}" (
1454    "migration_id" TEXT NOT NULL PRIMARY KEY,
1455    "product_version" TEXT NOT NULL
1456);"#,
1457            table = MIGRATION_HISTORY_TABLE
1458        ),
1459    }
1460}
1461
1462// ---------------------------------------------------------------------------
1463// Filesystem migration store (CLI / project migrations folder)
1464// ---------------------------------------------------------------------------
1465
1466use std::fs;
1467use std::path::{Path, PathBuf};
1468
1469/// Reads and writes migration scripts on disk.
1470///
1471/// Layout:
1472/// ```text
1473/// Migrations/
1474///   20260625_InitialCreate/
1475///     up.sql
1476///     down.sql
1477/// ```
1478#[derive(Debug, Clone)]
1479pub struct MigrationStore {
1480    root: PathBuf,
1481}
1482
1483impl MigrationStore {
1484    pub fn new(root: impl Into<PathBuf>) -> Self {
1485        Self { root: root.into() }
1486    }
1487
1488    pub fn root(&self) -> &Path {
1489        &self.root
1490    }
1491
1492    /// Saves a migration as `{id}/up.sql` and `{id}/down.sql`.
1493    pub fn save(&self, migration: &Migration) -> EFResult<()> {
1494        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
1495        let dir = self.root.join(&migration.id);
1496        fs::create_dir_all(&dir).map_err(migration_io_err)?;
1497        fs::write(dir.join("up.sql"), &migration.up_sql).map_err(migration_io_err)?;
1498        fs::write(dir.join("down.sql"), &migration.down_sql).map_err(migration_io_err)?;
1499        Ok(())
1500    }
1501
1502    /// Loads all migrations sorted by id (timestamp prefix recommended).
1503    pub fn load_all(&self) -> EFResult<Vec<Migration>> {
1504        if !self.root.exists() {
1505            return Ok(Vec::new());
1506        }
1507        let mut ids: Vec<String> = fs::read_dir(&self.root)
1508            .map_err(migration_io_err)?
1509            .filter_map(|e| e.ok())
1510            .filter(|e| e.path().is_dir())
1511            .filter_map(|e| e.file_name().into_string().ok())
1512            .collect();
1513        ids.sort();
1514        ids.into_iter().map(|id| self.load(&id)).collect()
1515    }
1516
1517    pub fn load(&self, id: &str) -> EFResult<Migration> {
1518        let dir = self.root.join(id);
1519        let up_sql = fs::read_to_string(dir.join("up.sql")).map_err(migration_io_err)?;
1520        let down_sql = fs::read_to_string(dir.join("down.sql")).map_err(migration_io_err)?;
1521        Ok(Migration {
1522            id: id.to_string(),
1523            description: id.to_string(),
1524            up_sql,
1525            down_sql,
1526        })
1527    }
1528
1529    /// Writes a model snapshot JSON file for the next diff baseline.
1530    pub fn save_snapshot(&self, snapshot: &ModelSnapshot) -> EFResult<()> {
1531        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
1532        let json = snapshot_to_json(snapshot);
1533        fs::write(self.root.join("model_snapshot.json"), json).map_err(migration_io_err)
1534    }
1535
1536    pub fn load_snapshot(&self) -> EFResult<Option<ModelSnapshot>> {
1537        let path = self.root.join("model_snapshot.json");
1538        if !path.exists() {
1539            return Ok(None);
1540        }
1541        let text = fs::read_to_string(path).map_err(migration_io_err)?;
1542        parse_model_snapshot_json(&text)
1543    }
1544}
1545
1546/// Parses a model snapshot JSON file (same format as [`MigrationStore::save_snapshot`]).
1547pub fn parse_model_snapshot_json(text: &str) -> EFResult<Option<ModelSnapshot>> {
1548    snapshot_from_json(text)
1549}
1550
1551fn migration_io_err(e: std::io::Error) -> crate::error::EFError {
1552    crate::error::EFError::migration(e.to_string())
1553}
1554
1555fn snapshot_to_json(snapshot: &ModelSnapshot) -> String {
1556    let mut out = String::from("{\n  \"migration_id\": \"");
1557    out.push_str(&snapshot.migration_id.replace('"', "\\\""));
1558    out.push_str("\",\n  \"entity_types\": [\n");
1559    for (i, et) in snapshot.entity_types.iter().enumerate() {
1560        if i > 0 {
1561            out.push(',');
1562        }
1563        out.push_str("    {\n");
1564        out.push_str(&format!(
1565            "      \"type_name\": \"{}\",\n",
1566            et.type_name.replace('"', "\\\"")
1567        ));
1568        out.push_str(&format!(
1569            "      \"table_name\": \"{}\",\n",
1570            et.table_name.replace('"', "\\\"")
1571        ));
1572        out.push_str("      \"columns\": [\n");
1573        for (j, col) in et.columns.iter().enumerate() {
1574            if j > 0 {
1575                out.push(',');
1576            }
1577            out.push_str(&format!(
1578                "        {{\"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",
1579                col.field_name.replace('"', "\\\""),
1580                col.column_name.replace('"', "\\\""),
1581                col.type_name.replace('"', "\\\""),
1582                col.is_primary_key,
1583                col.is_required,
1584                col.is_foreign_key,
1585                col.max_length.map(|n| n.to_string()).unwrap_or_else(|| "null".into()),
1586                col.is_auto_increment,
1587                col.is_sequence,
1588                col.sequence_name
1589                    .as_ref()
1590                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1591                    .unwrap_or_else(|| "null".into()),
1592                col.fk_referenced_table
1593                    .as_ref()
1594                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1595                    .unwrap_or_else(|| "null".into()),
1596                col.fk_referenced_column
1597                    .as_ref()
1598                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1599                    .unwrap_or_else(|| "null".into()),
1600                col.has_index,
1601                col.is_unique,
1602                col.fk_on_delete
1603                    .as_ref()
1604                    .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
1605                    .unwrap_or_else(|| "null".into())
1606            ));
1607        }
1608        out.push_str("      ]\n    }\n");
1609    }
1610    out.push_str("  ]\n}\n");
1611    out
1612}
1613
1614fn snapshot_from_json(text: &str) -> EFResult<Option<ModelSnapshot>> {
1615    // Minimal JSON parser for snapshot files written by save_snapshot.
1616    let migration_id =
1617        extract_json_string(text, "migration_id").unwrap_or_else(|| "__snapshot__".to_string());
1618    let mut entity_types = Vec::new();
1619    if let Some(arr_start) = text.find("\"entity_types\"") {
1620        let slice = &text[arr_start..];
1621        for table_block in slice.split("\"table_name\"").skip(1) {
1622            let table_name = extract_quoted_after_colon(table_block).unwrap_or_default();
1623            let _type_name = entity_types.len().to_string(); // fallback
1624            let columns_start = table_block.find("\"columns\"");
1625            let mut columns = Vec::new();
1626            if let Some(cs) = columns_start {
1627                let col_slice = &table_block[cs..];
1628                for col_chunk in col_slice.split("\"field_name\"").skip(1) {
1629                    if let Some(field_name) = extract_quoted_after_colon(col_chunk) {
1630                        let column_name = extract_json_string(col_chunk, "column_name")
1631                            .unwrap_or_else(|| field_name.clone());
1632                        let type_name_col = extract_json_string(col_chunk, "type_name")
1633                            .unwrap_or_else(|| "String".to_string());
1634                        columns.push(SnapshotColumn {
1635                            field_name,
1636                            column_name,
1637                            type_name: type_name_col,
1638                            is_primary_key: col_chunk.contains("\"is_primary_key\":true"),
1639                            is_required: col_chunk.contains("\"is_required\":true"),
1640                            is_foreign_key: col_chunk.contains("\"is_foreign_key\":true"),
1641                            max_length: None,
1642                            is_auto_increment: col_chunk.contains("\"is_auto_increment\":true"),
1643                            is_sequence: col_chunk.contains("\"is_sequence\":true"),
1644                            sequence_name: extract_json_string(col_chunk, "sequence_name"),
1645                            fk_referenced_table: extract_json_string(
1646                                col_chunk,
1647                                "fk_referenced_table",
1648                            ),
1649                            fk_referenced_column: extract_json_string(
1650                                col_chunk,
1651                                "fk_referenced_column",
1652                            ),
1653                            has_index: col_chunk.contains("\"has_index\":true"),
1654                            is_unique: col_chunk.contains("\"is_unique\":true"),
1655                            fk_on_delete: extract_json_string(col_chunk, "fk_on_delete"),
1656                        });
1657                    }
1658                }
1659            }
1660            if !table_name.is_empty() {
1661                entity_types.push(SnapshotEntityType {
1662                    type_name: table_name.clone(),
1663                    table_name,
1664                    columns,
1665                });
1666            }
1667        }
1668    }
1669    if entity_types.is_empty() {
1670        return Ok(None);
1671    }
1672    Ok(Some(ModelSnapshot {
1673        migration_id,
1674        entity_types,
1675    }))
1676}
1677
1678fn extract_json_string(haystack: &str, key: &str) -> Option<String> {
1679    let needle = format!("\"{key}\"");
1680    let pos = haystack.find(&needle)?;
1681    extract_quoted_after_colon(&haystack[pos + needle.len()..])
1682}
1683
1684fn extract_quoted_after_colon(s: &str) -> Option<String> {
1685    let colon = s.find(':')?;
1686    let rest = s[colon + 1..].trim_start();
1687    if !rest.starts_with('"') {
1688        return None;
1689    }
1690    let inner = &rest[1..];
1691    let end = inner.find('"')?;
1692    Some(inner[..end].to_string())
1693}