Skip to main content

rust_ef/migration/
history.rs

1//! Migration history table management and filesystem migration store.
2
3use super::dialect::MigrationDialect;
4use super::snapshot_io::{migration_io_err, snapshot_to_json};
5use super::types::{Migration, ModelSnapshot};
6use crate::error::EFResult;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Represents a record in the migration history table.
11/// Table name: `__ef_migrations_history`
12/// Corresponds to EFCore's `__EFMigrationsHistory`.
13#[derive(Debug, Clone)]
14pub struct MigrationHistoryEntry {
15    pub migration_id: String,
16    pub product_version: String,
17}
18
19/// Table name for migration history.
20pub const MIGRATION_HISTORY_TABLE: &str = "__ef_migrations_history";
21
22/// Product version recorded in migration history.
23pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25pub(super) fn seed_insert_sql(
26    dialect: MigrationDialect,
27    table: &str,
28    columns: &[&str],
29    gen: &dyn crate::provider::ISqlGenerator,
30) -> String {
31    let quoted_table = dialect.quote(table);
32    let quoted_cols: Vec<String> = columns.iter().map(|c| dialect.quote(c)).collect();
33    let placeholders: Vec<String> = (0..columns.len())
34        .map(|i| gen.parameter_placeholder(i + 1))
35        .collect();
36    let cols = quoted_cols.join(", ");
37    let vals = placeholders.join(", ");
38    match dialect {
39        MigrationDialect::Sqlite => {
40            format!("INSERT OR IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
41        }
42        MigrationDialect::Postgres => {
43            format!("INSERT INTO {quoted_table} ({cols}) VALUES ({vals}) ON CONFLICT DO NOTHING")
44        }
45        MigrationDialect::MySql => {
46            format!("INSERT IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
47        }
48    }
49}
50
51/// Split a migration script into individual executable statements.
52pub(super) fn split_sql_statements(sql: &str) -> Vec<String> {
53    sql.lines()
54        .filter(|l| {
55            let trimmed = l.trim();
56            !trimmed.is_empty() && !trimmed.starts_with("--")
57        })
58        .collect::<Vec<_>>()
59        .join("\n")
60        .split(';')
61        .map(|s| s.trim().to_string())
62        .filter(|s| !s.is_empty())
63        .collect()
64}
65
66/// SQL to create the migration history table.
67pub fn create_migration_history_table_sql(dialect: MigrationDialect) -> String {
68    match dialect {
69        MigrationDialect::Postgres => format!(
70            r#"CREATE TABLE IF NOT EXISTS "{table}" (
71    "migration_id" VARCHAR(150) NOT NULL PRIMARY KEY,
72    "product_version" VARCHAR(32) NOT NULL,
73    "applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
74);"#,
75            table = MIGRATION_HISTORY_TABLE
76        ),
77        MigrationDialect::MySql => format!(
78            r#"CREATE TABLE IF NOT EXISTS `{table}` (
79    `migration_id` VARCHAR(150) NOT NULL PRIMARY KEY,
80    `product_version` VARCHAR(32) NOT NULL,
81    `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
82) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"#,
83            table = MIGRATION_HISTORY_TABLE
84        ),
85        MigrationDialect::Sqlite => format!(
86            r#"CREATE TABLE IF NOT EXISTS "{table}" (
87    "migration_id" TEXT NOT NULL PRIMARY KEY,
88    "product_version" TEXT NOT NULL
89);"#,
90            table = MIGRATION_HISTORY_TABLE
91        ),
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Filesystem migration store (CLI / project migrations folder)
97// ---------------------------------------------------------------------------
98
99/// Reads and writes migration scripts on disk.
100///
101/// Layout:
102/// ```text
103/// Migrations/
104///   20260625_InitialCreate/
105///     up.sql
106///     down.sql
107/// ```
108#[derive(Debug, Clone)]
109pub struct MigrationStore {
110    root: PathBuf,
111}
112
113impl MigrationStore {
114    pub fn new(root: impl Into<PathBuf>) -> Self {
115        Self { root: root.into() }
116    }
117
118    pub fn root(&self) -> &Path {
119        &self.root
120    }
121
122    /// Saves a migration as `{id}/up.sql` and `{id}/down.sql`.
123    pub fn save(&self, migration: &Migration) -> EFResult<()> {
124        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
125        let dir = self.root.join(&migration.id);
126        fs::create_dir_all(&dir).map_err(migration_io_err)?;
127        fs::write(dir.join("up.sql"), &migration.up_sql).map_err(migration_io_err)?;
128        fs::write(dir.join("down.sql"), &migration.down_sql).map_err(migration_io_err)?;
129        Ok(())
130    }
131
132    /// Loads all migrations sorted by id (timestamp prefix recommended).
133    pub fn load_all(&self) -> EFResult<Vec<Migration>> {
134        if !self.root.exists() {
135            return Ok(Vec::new());
136        }
137        let mut ids: Vec<String> = fs::read_dir(&self.root)
138            .map_err(migration_io_err)?
139            .filter_map(|e| e.ok())
140            .filter(|e| e.path().is_dir())
141            .filter_map(|e| e.file_name().into_string().ok())
142            .collect();
143        ids.sort();
144        ids.into_iter().map(|id| self.load(&id)).collect()
145    }
146
147    pub fn load(&self, id: &str) -> EFResult<Migration> {
148        let dir = self.root.join(id);
149        let up_sql = fs::read_to_string(dir.join("up.sql")).map_err(migration_io_err)?;
150        let down_sql = fs::read_to_string(dir.join("down.sql")).map_err(migration_io_err)?;
151        Ok(Migration {
152            id: id.to_string(),
153            description: id.to_string(),
154            up_sql,
155            down_sql,
156        })
157    }
158
159    /// Writes a model snapshot JSON file for the next diff baseline.
160    pub fn save_snapshot(&self, snapshot: &ModelSnapshot) -> EFResult<()> {
161        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
162        let json = snapshot_to_json(snapshot);
163        fs::write(self.root.join("model_snapshot.json"), json).map_err(migration_io_err)
164    }
165
166    pub fn load_snapshot(&self) -> EFResult<Option<ModelSnapshot>> {
167        let path = self.root.join("model_snapshot.json");
168        if !path.exists() {
169            return Ok(None);
170        }
171        let text = fs::read_to_string(path).map_err(migration_io_err)?;
172        super::snapshot_io::parse_model_snapshot_json(&text)
173    }
174}