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