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;
5use super::types::{Migration, ModelSnapshot};
6use crate::error::{EFError, 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.
52///
53/// The splitter understands the lexical cases a migration script actually
54/// contains: semicolons inside single-quoted literals (including doubled `''`
55/// and, for MySQL, backslash escapes), `--` line comments, `/* ... */` block
56/// comments, and PostgreSQL `$tag$ ... $tag$` dollar-quoted strings (function
57/// bodies). Comments are dropped, statement text is trimmed, and empty
58/// statements are skipped.
59pub(super) fn split_sql_statements(sql: &str, dialect: MigrationDialect) -> Vec<String> {
60    let chars: Vec<char> = sql.chars().collect();
61    let mut statements = Vec::new();
62    let mut current = String::new();
63    let mut in_string = false;
64    let mut in_line_comment = false;
65    let mut in_block_comment = false;
66    let mut dollar_tag: Option<String> = None;
67    let mut i = 0;
68
69    while i < chars.len() {
70        let c = chars[i];
71
72        if in_line_comment {
73            if c == '\n' {
74                in_line_comment = false;
75                current.push('\n');
76            }
77            i += 1;
78            continue;
79        }
80        if in_block_comment {
81            if c == '*' && chars.get(i + 1) == Some(&'/') {
82                in_block_comment = false;
83                i += 2;
84            } else {
85                i += 1;
86            }
87            continue;
88        }
89        if let Some(tag) = &dollar_tag {
90            let tag_chars: Vec<char> = tag.chars().collect();
91            if c == '$' && chars[i..].starts_with(&tag_chars) {
92                current.push_str(tag);
93                i += tag_chars.len();
94                dollar_tag = None;
95            } else {
96                current.push(c);
97                i += 1;
98            }
99            continue;
100        }
101        if in_string {
102            match c {
103                // MySQL: backslash escapes the next character inside a literal.
104                '\\' if dialect == MigrationDialect::MySql => {
105                    current.push(c);
106                    if let Some(next) = chars.get(i + 1) {
107                        current.push(*next);
108                        i += 1;
109                    }
110                }
111                // A doubled quote is an escaped quote in every supported dialect.
112                '\'' if chars.get(i + 1) == Some(&'\'') => {
113                    current.push_str("''");
114                    i += 1;
115                }
116                '\'' => {
117                    in_string = false;
118                    current.push(c);
119                }
120                _ => current.push(c),
121            }
122            i += 1;
123            continue;
124        }
125
126        match c {
127            '\'' => {
128                in_string = true;
129                current.push(c);
130            }
131            '-' if chars.get(i + 1) == Some(&'-') => {
132                in_line_comment = true;
133                i += 1;
134            }
135            '/' if chars.get(i + 1) == Some(&'*') => {
136                in_block_comment = true;
137                i += 1;
138            }
139            '$' if dialect == MigrationDialect::Postgres => match dollar_quote_tag(&chars, i) {
140                Some(tag) => {
141                    current.push_str(&tag);
142                    i += tag.chars().count() - 1;
143                    dollar_tag = Some(tag);
144                }
145                None => current.push(c),
146            },
147            // MySQL also accepts `#` line comments.
148            '#' if dialect == MigrationDialect::MySql => {
149                in_line_comment = true;
150            }
151            ';' => {
152                let statement = current.trim();
153                if !statement.is_empty() {
154                    statements.push(statement.to_string());
155                }
156                current.clear();
157            }
158            _ => current.push(c),
159        }
160        i += 1;
161    }
162
163    let statement = current.trim();
164    if !statement.is_empty() {
165        statements.push(statement.to_string());
166    }
167    statements
168}
169
170/// Returns the full `$tag$` delimiter starting at `start`, if `chars[start..]`
171/// opens a PostgreSQL dollar-quoted string (`$$` or `$identifier$`).
172///
173/// PostgreSQL only treats `$...$` as a dollar quote when the tag is empty or a
174/// valid identifier (it cannot start with a digit), which keeps positional
175/// parameters such as `$1` out of this path.
176fn dollar_quote_tag(chars: &[char], start: usize) -> Option<String> {
177    let rest = &chars[start..];
178    let close = rest.iter().skip(1).position(|c| *c == '$').map(|p| p + 1)?;
179    let tag: String = rest[..=close].iter().collect();
180    let name = &tag[1..tag.len() - 1];
181    let valid_ident = match name.chars().next() {
182        None => true,
183        Some(first) => {
184            (first.is_ascii_alphabetic() || first == '_')
185                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
186        }
187    };
188    valid_ident.then_some(tag)
189}
190
191/// Validates a migration id used as a directory name and history key.
192///
193/// Rejects empty ids, path separators, `..`, drive letters, control characters,
194/// and ids longer than the history table's 150-character column.
195pub fn validate_migration_id(id: &str) -> EFResult<()> {
196    if id.is_empty() {
197        return Err(EFError::migration("migration id must not be empty"));
198    }
199    if id.len() > 150 {
200        return Err(EFError::migration(format!(
201            "migration id '{id}' exceeds 150 characters"
202        )));
203    }
204    if id == "." || id == ".." {
205        return Err(EFError::migration(format!("invalid migration id '{id}'")));
206    }
207    if id
208        .chars()
209        .any(|c| c == '/' || c == '\\' || c == ':' || c.is_control())
210    {
211        return Err(EFError::migration(format!(
212            "migration id '{id}' must not contain path separators, ':' or control characters"
213        )));
214    }
215    // Windows strips trailing dots/spaces from directory names, which would
216    // make the on-disk id differ from the history key.
217    if id.ends_with('.') || id.ends_with(' ') {
218        return Err(EFError::migration(format!(
219            "migration id '{id}' must not end with '.' or a space"
220        )));
221    }
222    Ok(())
223}
224
225/// SQL to create the migration history table.
226pub fn create_migration_history_table_sql(dialect: MigrationDialect) -> String {
227    match dialect {
228        MigrationDialect::Postgres => format!(
229            r#"CREATE TABLE IF NOT EXISTS "{table}" (
230    "migration_id" VARCHAR(150) NOT NULL PRIMARY KEY,
231    "product_version" VARCHAR(32) NOT NULL,
232    "applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
233);"#,
234            table = MIGRATION_HISTORY_TABLE
235        ),
236        MigrationDialect::MySql => format!(
237            r#"CREATE TABLE IF NOT EXISTS `{table}` (
238    `migration_id` VARCHAR(150) NOT NULL PRIMARY KEY,
239    `product_version` VARCHAR(32) NOT NULL,
240    `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
241) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"#,
242            table = MIGRATION_HISTORY_TABLE
243        ),
244        MigrationDialect::Sqlite => format!(
245            r#"CREATE TABLE IF NOT EXISTS "{table}" (
246    "migration_id" TEXT NOT NULL PRIMARY KEY,
247    "product_version" TEXT NOT NULL
248);"#,
249            table = MIGRATION_HISTORY_TABLE
250        ),
251    }
252}
253
254// ---------------------------------------------------------------------------
255// Filesystem migration store (CLI / project migrations folder)
256// ---------------------------------------------------------------------------
257
258/// Reads and writes migration scripts on disk.
259///
260/// Layout:
261/// ```text
262/// Migrations/
263///   20260625_InitialCreate/
264///     up.sql
265///     down.sql
266/// ```
267#[derive(Debug, Clone)]
268pub struct MigrationStore {
269    root: PathBuf,
270}
271
272impl MigrationStore {
273    pub fn new(root: impl Into<PathBuf>) -> Self {
274        Self { root: root.into() }
275    }
276
277    pub fn root(&self) -> &Path {
278        &self.root
279    }
280
281    /// Saves a migration as `{id}/up.sql` and `{id}/down.sql`.
282    ///
283    /// `id` must be a safe, ordered directory name (no path separators/`..`);
284    /// use a timestamp prefix so [`Self::load_all`] applies migrations in
285    /// creation order.
286    pub fn save(&self, migration: &Migration) -> EFResult<()> {
287        validate_migration_id(&migration.id)?;
288        fs::create_dir_all(&self.root).map_err(migration_io_err)?;
289        let dir = self.root.join(&migration.id);
290        fs::create_dir_all(&dir).map_err(migration_io_err)?;
291        fs::write(dir.join("up.sql"), &migration.up_sql).map_err(migration_io_err)?;
292        fs::write(dir.join("down.sql"), &migration.down_sql).map_err(migration_io_err)?;
293        Ok(())
294    }
295
296    /// Loads all migrations sorted by id (timestamp prefix recommended).
297    pub fn load_all(&self) -> EFResult<Vec<Migration>> {
298        if !self.root.exists() {
299            return Ok(Vec::new());
300        }
301        let mut ids: Vec<String> = fs::read_dir(&self.root)
302            .map_err(migration_io_err)?
303            .filter_map(|e| e.ok())
304            .filter(|e| e.path().is_dir())
305            .filter_map(|e| e.file_name().into_string().ok())
306            .collect();
307        ids.sort();
308        ids.into_iter().map(|id| self.load(&id)).collect()
309    }
310
311    pub fn load(&self, id: &str) -> EFResult<Migration> {
312        validate_migration_id(id)?;
313        let dir = self.root.join(id);
314        let up_sql = fs::read_to_string(dir.join("up.sql")).map_err(migration_io_err)?;
315        let down_sql = fs::read_to_string(dir.join("down.sql")).map_err(migration_io_err)?;
316        Ok(Migration {
317            id: id.to_string(),
318            description: id.to_string(),
319            up_sql,
320            down_sql,
321        })
322    }
323
324    /// Writes a model snapshot JSON file for the next diff baseline.
325    pub fn save_snapshot(&self, snapshot: &ModelSnapshot) -> EFResult<()> {
326        super::snapshot::write_snapshot(self.root.join("model_snapshot.json"), snapshot)
327    }
328
329    pub fn load_snapshot(&self) -> EFResult<Option<ModelSnapshot>> {
330        let path = self.root.join("model_snapshot.json");
331        if !path.exists() {
332            return Ok(None);
333        }
334        let text = fs::read_to_string(path).map_err(migration_io_err)?;
335        super::snapshot_io::parse_model_snapshot_json(&text)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn split(sql: &str, dialect: MigrationDialect) -> Vec<String> {
344        split_sql_statements(sql, dialect)
345    }
346
347    #[test]
348    fn splits_plain_statements_and_drops_empty_ones() {
349        let out = split(
350            "CREATE TABLE a (id INT); ;CREATE TABLE b (id INT)",
351            MigrationDialect::Sqlite,
352        );
353        assert_eq!(
354            out,
355            vec!["CREATE TABLE a (id INT)", "CREATE TABLE b (id INT)"]
356        );
357    }
358
359    #[test]
360    fn semicolon_inside_string_literal_does_not_split() {
361        let out = split(
362            "INSERT INTO t VALUES ('a;b');SELECT 1",
363            MigrationDialect::MySql,
364        );
365        assert_eq!(out, vec!["INSERT INTO t VALUES ('a;b')", "SELECT 1"]);
366    }
367
368    #[test]
369    fn doubled_quote_is_an_escaped_quote() {
370        let out = split(
371            "INSERT INTO t VALUES ('it''s;ok');SELECT 1",
372            MigrationDialect::Postgres,
373        );
374        assert_eq!(out, vec!["INSERT INTO t VALUES ('it''s;ok')", "SELECT 1"]);
375    }
376
377    #[test]
378    fn mysql_backslash_escape_keeps_the_string_open() {
379        let out = split(
380            "INSERT INTO t VALUES ('it\\'s;ok');SELECT 1",
381            MigrationDialect::MySql,
382        );
383        assert_eq!(out, vec!["INSERT INTO t VALUES ('it\\'s;ok')", "SELECT 1"]);
384    }
385
386    #[test]
387    fn postgres_backslash_is_not_an_escape() {
388        // `'a\'` closes the literal; the following `;` separates statements.
389        let out = split("SELECT 'a\\';SELECT 1", MigrationDialect::Postgres);
390        assert_eq!(out, vec!["SELECT 'a\\'", "SELECT 1"]);
391    }
392
393    #[test]
394    fn line_comments_are_stripped() {
395        let out = split(
396            "-- note; still comment\nSELECT 1;",
397            MigrationDialect::Postgres,
398        );
399        assert_eq!(out, vec!["SELECT 1"]);
400    }
401
402    #[test]
403    fn block_comments_are_stripped() {
404        let out = split("/* a;b */ SELECT 1; /* tail */", MigrationDialect::Sqlite);
405        assert_eq!(out, vec!["SELECT 1"]);
406    }
407
408    #[test]
409    fn postgres_dollar_quoted_body_keeps_semicolons() {
410        let out = split(
411            "CREATE FUNCTION f() RETURNS void AS $$ BEGIN PERFORM 1; END $$ LANGUAGE plpgsql; SELECT 2",
412            MigrationDialect::Postgres,
413        );
414        assert_eq!(out.len(), 2);
415        assert!(out[0].contains("PERFORM 1; END"));
416        assert_eq!(out[1], "SELECT 2");
417    }
418
419    #[test]
420    fn dollar_inside_a_literal_is_not_a_dollar_quote() {
421        let out = split("SELECT '$';SELECT 1", MigrationDialect::Postgres);
422        assert_eq!(out, vec!["SELECT '$'", "SELECT 1"]);
423    }
424
425    #[test]
426    fn postgres_positional_parameters_are_not_dollar_quotes() {
427        let out = split("SELECT $1, $2;SELECT 3", MigrationDialect::Postgres);
428        assert_eq!(out, vec!["SELECT $1, $2", "SELECT 3"]);
429    }
430
431    #[test]
432    fn mysql_hash_comment_is_stripped() {
433        let out = split("# note; here\nSELECT 1;", MigrationDialect::MySql);
434        assert_eq!(out, vec!["SELECT 1"]);
435    }
436}