Skip to main content

drizzle_migrations/
migrator.rs

1//! Runtime migration runner for programmatic migrations
2//!
3//! Provides the low-level pieces behind runtime migration execution:
4//! - [`Migration`] values holding SQL and metadata
5//! - [`Migrations`] for tracking-table SQL and pending migration checks
6//! - [`MigrationDir`](crate::MigrationDir) for filesystem discovery when embedding or testing
7//!
8//! # Usage
9//!
10//! ## Embedded Migrations (recommended for production/serverless)
11//!
12//! Use `drizzle::include_migrations!` or `include_str!` to embed migration SQL at compile time:
13//!
14//! ```rust
15//! # let _ = r####"
16//! use drizzle_migrations::{Migration, Migrations};
17//! use drizzle_types::Dialect;
18//!
19//! const MIGRATIONS: &[Migration] = &[
20//!     Migration::new("20231220143052_init", include_str!("../drizzle/20231220143052_init/migration.sql")),
21//!     Migration::new("20231221093015_users", include_str!("../drizzle/20231221093015_users/migration.sql")),
22//! ];
23//!
24//! async fn run_migrations(db: &Database) -> Result<(), MigratorError> {
25//!     let set = Migrations::new(MIGRATIONS.to_vec(), Dialect::SQLite);
26//!
27//!     // Ensure migrations table exists
28//!     db.execute(&set.create_table_sql()).await?;
29//!
30//!     // Get applied migration names (matches drizzle-orm beta.19+ semantics).
31//!     let applied: Vec<String> = db.query_column::<String>(&set.applied_names_sql()).await?;
32//!
33//!     // Apply pending migrations by name set-difference
34//!     for migration in set.pending(&applied) {
35//!         for statement in migration.statements() {
36//!             db.execute(statement).await?;
37//!         }
38//!         db.execute(&set.record_migration_sql(migration)).await?;
39//!     }
40//!     Ok(())
41//! }
42//! # "####;
43//! ```
44//!
45//! ## Loading from Filesystem (for development)
46//!
47//! ```rust
48//! # let _ = r####"
49//! use drizzle_migrations::{MigrationDir, Migrations};
50//! use drizzle_types::Dialect;
51//!
52//! let migrations = MigrationDir::new("./drizzle").discover()?;
53//! let set = Migrations::new(migrations, Dialect::SQLite);
54//! # "####;
55//! ```
56
57use crate::config::Tracking;
58use drizzle_types::Dialect;
59use sha2::{Digest, Sha256};
60
61fn quote_identifier(dialect: Dialect, identifier: &str) -> String {
62    match dialect {
63        Dialect::MySQL => format!("`{}`", identifier.replace('`', "``")),
64        _ => format!("\"{}\"", identifier.replace('"', "\"\"")),
65    }
66}
67
68/// A migration with its SQL content
69///
70/// Represents a single migration that can be applied to the database.
71/// The `hash` field is used to track which migrations have been applied.
72#[derive(Debug, Clone)]
73pub struct Migration {
74    /// Migration tag (folder name)
75    tag: String,
76    /// Unique hash identifying this migration (computed from SQL content)
77    hash: String,
78    /// Timestamp or folder millis for ordering
79    created_at: i64,
80    /// SQL statements to execute (pre-split if breakpoints were used)
81    sql: Vec<String>,
82}
83
84/// SQLite statements prepared for execution by a runtime adapter.
85///
86/// Generated table rebuilds carry `PRAGMA foreign_keys=OFF/ON` sentinels.
87/// SQLite ignores those pragmas inside a transaction, so adapters must apply
88/// the connection setting before opening their transaction and restore it
89/// after completion. The sentinels are excluded from
90/// [`SqliteMigrationExecution::statements`].
91#[derive(Debug, Clone, Copy)]
92pub struct SqliteMigrationExecution<'a> {
93    statements: &'a [String],
94    suspends_foreign_keys: bool,
95}
96
97impl<'a> SqliteMigrationExecution<'a> {
98    /// Whether the adapter must disable foreign-key enforcement before its
99    /// transaction and restore it afterward.
100    #[inline]
101    #[must_use]
102    pub const fn suspends_foreign_keys(self) -> bool {
103        self.suspends_foreign_keys
104    }
105
106    /// Statements to execute inside the migration transaction.
107    pub fn statements(self) -> impl Iterator<Item = &'a str> + 'a {
108        self.statements.iter().filter_map(|statement| {
109            sqlite_foreign_keys_setting(statement)
110                .expect("SQLite migration execution was validated before construction")
111                .is_none()
112                .then_some(statement.as_str())
113        })
114    }
115}
116
117/// Invalid SQLite foreign-key suspension sentinels in a migration.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
119pub enum SqliteMigrationExecutionError {
120    #[error("PRAGMA foreign_keys=OFF is nested without a matching ON")]
121    NestedForeignKeysOff,
122    #[error("PRAGMA foreign_keys=ON has no preceding OFF")]
123    ForeignKeysOnWithoutOff,
124    #[error("PRAGMA foreign_keys=OFF has no matching ON")]
125    ForeignKeysOffWithoutOn,
126    #[error("unsupported PRAGMA foreign_keys assignment in migration")]
127    UnsupportedForeignKeysPragma,
128}
129
130/// Outcome of a successful `migrate(...)` call.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum MigrateOutcome {
133    /// The database was already in sync with the local migration set — no
134    /// migrations were applied.
135    UpToDate,
136    /// Pending migrations ran successfully. `tags` contains the folder names
137    /// of each applied migration, in execution order.
138    Applied { tags: Vec<String> },
139}
140
141impl MigrateOutcome {
142    /// Was the database already up to date with the local migration set?
143    #[inline]
144    #[must_use]
145    pub const fn is_up_to_date(&self) -> bool {
146        matches!(self, Self::UpToDate)
147    }
148
149    /// Number of migrations applied during this call (0 when up to date).
150    #[inline]
151    #[must_use]
152    pub fn applied_count(&self) -> usize {
153        match self {
154            Self::UpToDate => 0,
155            Self::Applied { tags } => tags.len(),
156        }
157    }
158
159    /// Tags of migrations applied during this call (empty when up to date).
160    #[inline]
161    #[must_use]
162    pub fn applied_tags(&self) -> &[String] {
163        match self {
164            Self::UpToDate => &[],
165            Self::Applied { tags } => tags,
166        }
167    }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct AppliedMigrationMetadata {
172    pub id: Option<i64>,
173    pub hash: String,
174    pub created_at: i64,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct MatchedMigrationMetadata {
179    pub id: Option<i64>,
180    pub hash: String,
181    pub created_at: i64,
182    pub name: String,
183}
184
185impl Migration {
186    /// Create a new migration from embedded SQL
187    ///
188    /// The hash is computed from the SQL content.
189    /// SQL is split on `"--> statement-breakpoint"` markers.
190    #[must_use]
191    pub fn new(tag: &str, sql: &str) -> Self {
192        let hash = compute_hash(sql);
193        let created_at = parse_timestamp_from_tag(tag);
194        let statements = split_statements(sql);
195
196        Self {
197            tag: tag.to_string(),
198            hash,
199            created_at,
200            sql: statements,
201        }
202    }
203
204    /// Create a migration with explicit hash and timestamp
205    pub fn with_hash(
206        tag: impl Into<String>,
207        hash: impl Into<String>,
208        created_at: i64,
209        sql: Vec<String>,
210    ) -> Self {
211        Self {
212            tag: tag.into(),
213            hash: hash.into(),
214            created_at,
215            sql,
216        }
217    }
218
219    /// Get the migration tag (folder name)
220    #[inline]
221    #[must_use]
222    pub fn tag(&self) -> &str {
223        &self.tag
224    }
225
226    /// Get the migration folder name used by drizzle-orm tracking metadata.
227    #[inline]
228    #[must_use]
229    pub fn name(&self) -> &str {
230        &self.tag
231    }
232
233    /// Get the migration hash (used for tracking)
234    #[inline]
235    #[must_use]
236    pub fn hash(&self) -> &str {
237        &self.hash
238    }
239
240    /// Get the creation timestamp
241    #[inline]
242    #[must_use]
243    pub const fn created_at(&self) -> i64 {
244        self.created_at
245    }
246
247    /// Get the raw SQL statements (already split).
248    ///
249    /// SQLite transaction-owning adapters must use [`Self::sqlite_execution`]
250    /// instead so foreign-key suspension sentinels are handled outside the
251    /// transaction.
252    #[inline]
253    #[must_use]
254    pub fn statements(&self) -> &[String] {
255        &self.sql
256    }
257
258    /// Validate SQLite foreign-key suspension sentinels and prepare the
259    /// statement stream for a transaction-owning runtime adapter.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`SqliteMigrationExecutionError`] when foreign-key suspension
264    /// pragmas are nested, unbalanced, or use an unsupported assignment form.
265    pub fn sqlite_execution(
266        &self,
267    ) -> Result<SqliteMigrationExecution<'_>, SqliteMigrationExecutionError> {
268        let mut foreign_keys_disabled = false;
269        let mut suspends_foreign_keys = false;
270        for statement in &self.sql {
271            match sqlite_foreign_keys_setting(statement)? {
272                Some(false) if foreign_keys_disabled => {
273                    return Err(SqliteMigrationExecutionError::NestedForeignKeysOff);
274                }
275                Some(false) => {
276                    foreign_keys_disabled = true;
277                    suspends_foreign_keys = true;
278                }
279                Some(true) if !foreign_keys_disabled => {
280                    return Err(SqliteMigrationExecutionError::ForeignKeysOnWithoutOff);
281                }
282                Some(true) => foreign_keys_disabled = false,
283                None => {}
284            }
285        }
286        if foreign_keys_disabled {
287            return Err(SqliteMigrationExecutionError::ForeignKeysOffWithoutOn);
288        }
289        Ok(SqliteMigrationExecution {
290            statements: &self.sql,
291            suspends_foreign_keys,
292        })
293    }
294
295    /// Check if this migration is empty
296    #[inline]
297    #[must_use]
298    pub fn is_empty(&self) -> bool {
299        self.sql.is_empty() || self.sql.iter().all(|s| s.trim().is_empty())
300    }
301
302    /// Whether this migration contains a PostgreSQL concurrent-index command.
303    #[must_use]
304    pub fn has_postgres_concurrent_index(&self) -> bool {
305        self.sql
306            .iter()
307            .any(|statement| is_postgres_concurrent_index_statement(statement))
308    }
309}
310
311fn sqlite_foreign_keys_setting(
312    statement: &str,
313) -> Result<Option<bool>, SqliteMigrationExecutionError> {
314    let normalized: String = strip_sql_comments(statement)
315        .trim()
316        .trim_end_matches(';')
317        .chars()
318        .filter(|character| !character.is_ascii_whitespace())
319        .flat_map(char::to_lowercase)
320        .collect();
321    match sqlite_foreign_keys_assignment(&normalized) {
322        Some("=off" | "=0" | "=false" | "=no" | "(off)" | "(0)" | "(false)" | "(no)") => {
323            Ok(Some(false))
324        }
325        Some("=on" | "=1" | "=true" | "=yes" | "(on)" | "(1)" | "(true)" | "(yes)") => {
326            Ok(Some(true))
327        }
328        Some(_) => Err(SqliteMigrationExecutionError::UnsupportedForeignKeysPragma),
329        _ => Ok(None),
330    }
331}
332
333fn sqlite_foreign_keys_assignment(normalized: &str) -> Option<&str> {
334    let pragma = normalized.strip_prefix("pragma")?;
335    for name in [
336        "foreign_keys",
337        "\"foreign_keys\"",
338        "'foreign_keys'",
339        "`foreign_keys`",
340        "[foreign_keys]",
341    ] {
342        if let Some(assignment) = pragma.strip_prefix(name)
343            && matches!(assignment.as_bytes().first(), Some(b'=' | b'('))
344        {
345            return Some(assignment);
346        }
347        if let Some((_, assignment)) = pragma.rsplit_once(&format!(".{name}"))
348            && matches!(assignment.as_bytes().first(), Some(b'=' | b'('))
349        {
350            return Some(assignment);
351        }
352    }
353    None
354}
355
356fn strip_sql_comments(statement: &str) -> String {
357    let mut output = String::with_capacity(statement.len());
358    let mut characters = statement.chars().peekable();
359    let mut quote = None;
360
361    while let Some(character) = characters.next() {
362        if let Some(terminator) = quote {
363            output.push(character);
364            if character == terminator {
365                if terminator != ']' && characters.peek() == Some(&terminator) {
366                    output.push(characters.next().expect("peeked quote is present"));
367                } else {
368                    quote = None;
369                }
370            }
371            continue;
372        }
373
374        match character {
375            '\'' | '"' | '`' => {
376                quote = Some(character);
377                output.push(character);
378            }
379            '[' => {
380                quote = Some(']');
381                output.push(character);
382            }
383            '-' if characters.peek() == Some(&'-') => {
384                characters.next();
385                for comment_character in characters.by_ref() {
386                    if comment_character == '\n' {
387                        output.push('\n');
388                        break;
389                    }
390                }
391            }
392            '/' if characters.peek() == Some(&'*') => {
393                characters.next();
394                let mut closed = false;
395                while let Some(comment_character) = characters.next() {
396                    if comment_character == '*' && characters.peek() == Some(&'/') {
397                        characters.next();
398                        closed = true;
399                        break;
400                    }
401                }
402                if !closed {
403                    output.push_str("/*");
404                }
405            }
406            _ => output.push(character),
407        }
408    }
409
410    output
411}
412
413/// A collection of migrations ready to be applied
414#[derive(Debug, Clone)]
415pub struct Migrations {
416    /// Ordered list of migrations
417    list: Vec<Migration>,
418    /// Database dialect
419    dialect: Dialect,
420    /// Migrations table name
421    table: String,
422    /// Migrations schema (`PostgreSQL` only)
423    schema: Option<String>,
424}
425
426impl Migrations {
427    /// Create a new migration set from migrations
428    #[must_use]
429    pub fn new(migrations: Vec<Migration>, dialect: Dialect) -> Self {
430        Self {
431            list: migrations,
432            dialect,
433            table: "__drizzle_migrations".to_string(),
434            schema: match dialect {
435                Dialect::PostgreSQL => Some("drizzle".to_string()),
436                _ => None,
437            },
438        }
439    }
440
441    pub fn with_tracking(migrations: Vec<Migration>, dialect: Dialect, tracking: Tracking) -> Self {
442        Self {
443            list: migrations,
444            dialect,
445            table: tracking.table.into_owned(),
446            schema: tracking.schema.map(std::borrow::Cow::into_owned),
447        }
448    }
449
450    /// Create an empty migration set
451    #[must_use]
452    pub fn empty(dialect: Dialect) -> Self {
453        Self::new(Vec::new(), dialect)
454    }
455
456    /// Get all migrations
457    #[inline]
458    #[must_use]
459    pub fn all(&self) -> &[Migration] {
460        &self.list
461    }
462
463    /// Get migrations that haven't been applied yet, by set-difference on name.
464    ///
465    /// Mirrors drizzle-orm's beta.19 `getMigrationsToRun`: a local migration is
466    /// pending iff its `name` (folder name) does not appear in the DB's
467    /// migrations table. This is resilient to same-second `created_at`
468    /// collisions and re-applies out-of-order migrations (e.g. after a
469    /// branch merge) instead of silently skipping them.
470    ///
471    /// `applied_names` should contain the non-null `name` column values from
472    /// the migrations tracking table, typically loaded via
473    /// [`Migrations::applied_names_sql`].
474    pub fn pending<'a, S>(&'a self, applied_names: &'a [S]) -> impl Iterator<Item = &'a Migration>
475    where
476        S: AsRef<str>,
477    {
478        self.list.iter().filter(move |m| {
479            let name = m.name();
480            !applied_names.iter().any(|applied| applied.as_ref() == name)
481        })
482    }
483
484    /// Check if there are pending migrations, by name set-difference.
485    pub fn has_pending<S>(&self, applied_names: &[S]) -> bool
486    where
487        S: AsRef<str>,
488    {
489        self.pending(applied_names).next().is_some()
490    }
491
492    /// Get the dialect
493    #[inline]
494    #[must_use]
495    pub const fn dialect(&self) -> Dialect {
496        self.dialect
497    }
498
499    /// Get the migrations tracking table name.
500    #[inline]
501    #[must_use]
502    pub fn table_name(&self) -> &str {
503        &self.table
504    }
505
506    /// Get the migrations tracking schema, if any.
507    #[inline]
508    #[must_use]
509    pub fn schema_name(&self) -> Option<&str> {
510        self.schema.as_deref()
511    }
512
513    /// Get the SQL table identifier used in queries.
514    #[inline]
515    #[must_use]
516    pub fn table_ident_sql(&self) -> String {
517        self.table_ident()
518    }
519
520    /// Stable advisory-lock key for serializing PostgreSQL migration runners.
521    #[must_use]
522    pub fn postgres_advisory_lock_key(&self) -> i64 {
523        let digest =
524            Sha256::digest(format!("drizzle-rs:migrate:{}", self.table_ident()).as_bytes());
525        i64::from_be_bytes(
526            digest[..8]
527                .try_into()
528                .expect("SHA-256 prefix is eight bytes"),
529        )
530    }
531
532    /// Whether any migration requires execution outside a PostgreSQL transaction.
533    #[must_use]
534    pub fn has_postgres_concurrent_index(&self) -> bool {
535        self.list
536            .iter()
537            .any(Migration::has_postgres_concurrent_index)
538    }
539
540    /// Create a partial unique index that prevents duplicate non-null names in
541    /// the migration tracking table.
542    #[must_use]
543    pub fn create_name_unique_index_sql(&self) -> Option<String> {
544        if self.dialect == Dialect::MySQL {
545            return None;
546        }
547        let digest = Sha256::digest(self.table_ident().as_bytes());
548        let suffix = digest[..8]
549            .iter()
550            .map(|byte| format!("{byte:02x}"))
551            .collect::<String>();
552        let index = quote_identifier(self.dialect, &format!("drizzle_migration_name_{suffix}"));
553        Some(format!(
554            "CREATE UNIQUE INDEX IF NOT EXISTS {index} ON {} (\"name\") WHERE \"name\" IS NOT NULL;",
555            self.table_ident()
556        ))
557    }
558
559    /// Get the full table identifier (with schema for `PostgreSQL`)
560    fn table_ident(&self) -> String {
561        match (&self.dialect, &self.schema) {
562            (Dialect::PostgreSQL, Some(schema)) => format!(
563                "{}.{}",
564                quote_identifier(self.dialect, schema),
565                quote_identifier(self.dialect, &self.table)
566            ),
567            _ => quote_identifier(self.dialect, &self.table),
568        }
569    }
570
571    /// Get the SQL to create the migrations schema (`PostgreSQL` only)
572    #[must_use]
573    pub fn create_schema_sql(&self) -> Option<String> {
574        self.schema.as_ref().map(|schema| {
575            format!(
576                "CREATE SCHEMA IF NOT EXISTS {};",
577                quote_identifier(self.dialect, schema)
578            )
579        })
580    }
581
582    /// Get the SQL to create the migrations tracking table
583    ///
584    /// Table schema matches current drizzle-orm:
585    /// - `SQLite`: id (INTEGER PK), hash, `created_at`, name, `applied_at`
586    /// - `PostgreSQL`: id (SERIAL PK), hash, `created_at`, name, `applied_at`
587    /// - `MySQL`: id (SERIAL PK), hash, `created_at`, name, `applied_at`
588    #[must_use]
589    pub fn create_table_sql(&self) -> String {
590        let table = self.table_ident();
591
592        match self.dialect {
593            Dialect::SQLite => format!(
594                r"CREATE TABLE IF NOT EXISTS {table} (
595    id INTEGER PRIMARY KEY,
596    hash text NOT NULL,
597    created_at numeric,
598    name text,
599    applied_at TEXT
600);"
601            ),
602            Dialect::PostgreSQL => format!(
603                r"CREATE TABLE IF NOT EXISTS {table} (
604    id SERIAL PRIMARY KEY,
605    hash TEXT NOT NULL,
606    created_at BIGINT,
607    name TEXT,
608    applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
609);"
610            ),
611            Dialect::MySQL => format!(
612                r"CREATE TABLE IF NOT EXISTS {table} (
613    id SERIAL PRIMARY KEY,
614    hash text NOT NULL,
615    created_at BIGINT,
616    name text,
617    applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
618);"
619            ),
620        }
621    }
622
623    /// Get the SQL to record a migration as applied.
624    #[must_use]
625    pub fn record_migration_sql(&self, migration: &Migration) -> String {
626        let table = self.table_ident();
627        let hash = escape_sql_string(migration.hash());
628        let name = escape_sql_string(migration.name());
629        let created_at = migration.created_at();
630
631        match self.dialect {
632            Dialect::SQLite | Dialect::PostgreSQL => {
633                format!(
634                    r#"INSERT INTO {table} ("hash", "created_at", "name", "applied_at") VALUES ('{hash}', {created_at}, '{name}', CURRENT_TIMESTAMP);"#
635                )
636            }
637            Dialect::MySQL => {
638                format!(
639                    r"INSERT INTO {table} (`hash`, `created_at`, `name`, `applied_at`) VALUES ('{hash}', {created_at}, '{name}', CURRENT_TIMESTAMP);"
640                )
641            }
642        }
643    }
644
645    /// Get the SQL to record a migration as *started* (phase 1 of two-phase
646    /// tracking on non-transactional paths).
647    ///
648    /// The row is written with `applied_at` explicitly `NULL`, which marks the
649    /// migration **dirty**: its statements are about to run but have not been
650    /// confirmed. [`Migrations::record_migration_finished_sql`] clears the
651    /// marker once they have. A crash between the two leaves the dirty row
652    /// behind, which is exactly the signal
653    /// [`Migrations::interrupted_migration_error`] reports.
654    ///
655    /// Transactional paths must keep using
656    /// [`Migrations::record_migration_sql`] — a single insert inside the same
657    /// transaction as the statements is already atomic.
658    ///
659    /// `applied_at` is written explicitly because the `PostgreSQL` column
660    /// carries `DEFAULT CURRENT_TIMESTAMP`; omitting it would silently mark
661    /// the migration complete before it ran.
662    #[must_use]
663    pub fn record_migration_started_sql(&self, migration: &Migration) -> String {
664        let table = self.table_ident();
665        let hash = escape_sql_string(migration.hash());
666        let name = escape_sql_string(migration.name());
667        let created_at = migration.created_at();
668
669        match self.dialect {
670            Dialect::SQLite | Dialect::PostgreSQL => {
671                format!(
672                    r#"INSERT INTO {table} ("hash", "created_at", "name", "applied_at") VALUES ('{hash}', {created_at}, '{name}', NULL);"#
673                )
674            }
675            Dialect::MySQL => {
676                format!(
677                    r"INSERT INTO {table} (`hash`, `created_at`, `name`, `applied_at`) VALUES ('{hash}', {created_at}, '{name}', NULL);"
678                )
679            }
680        }
681    }
682
683    /// Get the SQL to mark a started migration as finished (phase 3 of
684    /// two-phase tracking).
685    ///
686    /// Only clears rows that are still dirty, so a concurrent runner that
687    /// already completed the migration is not re-stamped.
688    #[must_use]
689    pub fn record_migration_finished_sql(&self, migration: &Migration) -> String {
690        let table = self.table_ident();
691        let name = escape_sql_string(migration.name());
692
693        match self.dialect {
694            Dialect::MySQL => format!(
695                r"UPDATE {table} SET `applied_at` = CURRENT_TIMESTAMP WHERE `name` = '{name}' AND `applied_at` IS NULL;"
696            ),
697            _ => format!(
698                r#"UPDATE {table} SET "applied_at" = CURRENT_TIMESTAMP WHERE "name" = '{name}' AND "applied_at" IS NULL;"#
699            ),
700        }
701    }
702
703    /// Get the SQL to drop a migration's dirty marker.
704    ///
705    /// Used when a non-transactional run fails on its *first* statement, where
706    /// nothing can have been applied and leaving a dirty row would demand a
707    /// pointless repair. Never touches a completed row.
708    #[must_use]
709    pub fn clear_migration_started_sql(&self, migration: &Migration) -> String {
710        let table = self.table_ident();
711        let name = escape_sql_string(migration.name());
712
713        match self.dialect {
714            Dialect::MySQL => {
715                format!(r"DELETE FROM {table} WHERE `name` = '{name}' AND `applied_at` IS NULL;")
716            }
717            _ => {
718                format!(r#"DELETE FROM {table} WHERE "name" = '{name}' AND "applied_at" IS NULL;"#)
719            }
720        }
721    }
722
723    /// Get the SQL to backfill `name`/`applied_at` on a legacy tracking row.
724    ///
725    /// The v0 tracking table had only `id`/`hash`/`created_at`; the upgrade
726    /// adds `name` and `applied_at` and backfills both. `applied_at` is derived
727    /// from the row's `created_at` rather than left `NULL` — a `NULL` here
728    /// would be indistinguishable from an interrupted migration and would make
729    /// every upgraded database look dirty.
730    #[must_use]
731    pub fn backfill_migration_metadata_sql(&self, row: &MatchedMigrationMetadata) -> String {
732        let table = self.table_ident();
733        let name = escape_sql_string(&row.name);
734        let created_at = row.created_at;
735
736        let (name_column, applied_column, applied_expr, where_clause) = match self.dialect {
737            Dialect::MySQL => (
738                "`name`",
739                "`applied_at`",
740                format!("FROM_UNIXTIME({created_at} / 1000)"),
741                row.id.map_or_else(
742                    || {
743                        format!(
744                            "`created_at` = {created_at} AND `hash` = '{}'",
745                            escape_sql_string(&row.hash)
746                        )
747                    },
748                    |id| format!("`id` = {id}"),
749                ),
750            ),
751            Dialect::PostgreSQL => (
752                "\"name\"",
753                "\"applied_at\"",
754                format!("to_timestamp({created_at}::double precision / 1000.0)"),
755                row.id.map_or_else(
756                    || {
757                        format!(
758                            "\"created_at\" = {created_at} AND \"hash\" = '{}'",
759                            escape_sql_string(&row.hash)
760                        )
761                    },
762                    |id| format!("\"id\" = {id}"),
763                ),
764            ),
765            Dialect::SQLite => (
766                "\"name\"",
767                "\"applied_at\"",
768                format!("datetime({created_at} / 1000, 'unixepoch')"),
769                row.id.map_or_else(
770                    || {
771                        format!(
772                            "\"created_at\" = {created_at} AND \"hash\" = '{}'",
773                            escape_sql_string(&row.hash)
774                        )
775                    },
776                    |id| format!("\"id\" = {id}"),
777                ),
778            ),
779        };
780
781        format!(
782            "UPDATE {table} SET {name_column} = '{name}', {applied_column} = {applied_expr} WHERE {where_clause}"
783        )
784    }
785
786    /// Get the SQL to query applied migration names.
787    ///
788    /// A row counts as applied only when it has both a non-null `name` *and* a
789    /// non-null `applied_at`:
790    ///
791    /// * `name IS NULL` — written before the v0 → v1 tracking-table upgrade
792    ///   (which backfills `name`), so it cannot be matched to a local
793    ///   migration.
794    /// * `applied_at IS NULL` — a two-phase **dirty marker**: the migration
795    ///   started but was never confirmed complete. Reporting it as applied
796    ///   would silently skip a half-applied migration. See
797    ///   [`Migrations::dirty_names_sql`].
798    ///
799    /// Pair with [`Migrations::pending`].
800    #[must_use]
801    pub fn applied_names_sql(&self) -> String {
802        let table = self.table_ident();
803        match self.dialect {
804            Dialect::MySQL => {
805                format!(
806                    "SELECT `name` FROM {table} WHERE `name` IS NOT NULL AND `applied_at` IS NOT NULL ORDER BY id;"
807                )
808            }
809            _ => format!(
810                r#"SELECT "name" FROM {table} WHERE "name" IS NOT NULL AND "applied_at" IS NOT NULL ORDER BY id;"#
811            ),
812        }
813    }
814
815    /// Get the SQL to query full applied-migration records: `hash`, `name`,
816    /// and a `dirty` flag (`applied_at IS NULL` — started but never finished).
817    ///
818    /// Unlike [`Migrations::applied_names_sql`] this returns interrupted rows
819    /// too, so integrity checks can report drift, missing-local, and
820    /// interrupted migrations from a single query.
821    #[must_use]
822    pub fn applied_records_sql(&self) -> String {
823        let table = self.table_ident();
824        match self.dialect {
825            Dialect::MySQL => {
826                format!(
827                    "SELECT `hash`, `name`, (`applied_at` IS NULL) AS dirty FROM {table} WHERE `name` IS NOT NULL ORDER BY id;"
828                )
829            }
830            _ => format!(
831                r#"SELECT "hash", "name", ("applied_at" IS NULL) AS dirty FROM {table} WHERE "name" IS NOT NULL ORDER BY id;"#
832            ),
833        }
834    }
835
836    /// Get the SQL to query interrupted ("dirty") migration names.
837    ///
838    /// These are rows whose `name` is known but whose `applied_at` is `NULL` —
839    /// a migration that started on a non-transactional path and never reported
840    /// completion.
841    #[must_use]
842    pub fn dirty_names_sql(&self) -> String {
843        let table = self.table_ident();
844        match self.dialect {
845            Dialect::MySQL => {
846                format!(
847                    "SELECT `name` FROM {table} WHERE `name` IS NOT NULL AND `applied_at` IS NULL ORDER BY id;"
848                )
849            }
850            _ => format!(
851                r#"SELECT "name" FROM {table} WHERE "name" IS NOT NULL AND "applied_at" IS NULL ORDER BY id;"#
852            ),
853        }
854    }
855
856    /// Build the standard error for interrupted migrations, or `None` when
857    /// `dirty_names` is empty.
858    ///
859    /// Every driver calls this after loading
860    /// [`Migrations::dirty_names_sql`] so the message is identical everywhere.
861    #[must_use]
862    pub fn interrupted_migration_error<S: AsRef<str>>(
863        &self,
864        dirty_names: &[S],
865    ) -> Option<MigratorError> {
866        if dirty_names.is_empty() {
867            return None;
868        }
869
870        let table = self.table_ident();
871        let names = dirty_names
872            .iter()
873            .map(|name| format!("`{}`", name.as_ref()))
874            .collect::<Vec<_>>()
875            .join(", ");
876        let plural = if dirty_names.len() == 1 { "" } else { "s" };
877        let first = escape_sql_string(dirty_names[0].as_ref());
878
879        Some(MigratorError::InterruptedMigration(format!(
880            "migration{plural} {names} {} interrupted mid-apply: the tracking row in {table} has \
881             a NULL `applied_at`, so an earlier run recorded the migration as started but never \
882             recorded it as finished. The database may be in a partially-migrated state, and \
883             re-running the migration as-is would fail (for example with `table already exists`).\n\
884             Recovery options:\n  \
885             1. re-run with repair enabled (`drizzle migrate --repair`, or `migrate_with_repair` \
886             on the driver) to reconcile each remaining statement against the live schema\n  \
887             2. resolve the partial state by hand, then either complete the row \
888             (UPDATE {table} SET \"applied_at\" = CURRENT_TIMESTAMP WHERE \"name\" = '{first}';) \
889             or discard it and re-run from scratch \
890             (DELETE FROM {table} WHERE \"name\" = '{first}';)",
891            if dirty_names.len() == 1 {
892                "was"
893            } else {
894                "were"
895            },
896        )))
897    }
898
899    /// Resolve dirty tracking-row names to their local migrations, in local
900    /// execution order.
901    ///
902    /// # Errors
903    ///
904    /// Returns [`MigratorError::UnrepairableMigration`] when a dirty row names
905    /// a migration that is not present locally — repair cannot reconcile
906    /// statements it does not have.
907    pub fn resolve_dirty_migrations<S: AsRef<str>>(
908        &self,
909        dirty_names: &[S],
910    ) -> Result<Vec<&Migration>, MigratorError> {
911        let mut unknown = Vec::new();
912        for name in dirty_names {
913            if !self.list.iter().any(|m| m.name() == name.as_ref()) {
914                unknown.push(name.as_ref().to_string());
915            }
916        }
917
918        if !unknown.is_empty() {
919            return Err(MigratorError::UnrepairableMigration(format!(
920                "cannot repair: the tracking table in {} marks migration(s) {} as interrupted, \
921                 but they are not present in the local migration set, so their statements are \
922                 unknown. Restore the migration folder(s) and retry, or resolve the partial state \
923                 by hand and delete the row(s) from {}.",
924                self.table_ident(),
925                unknown
926                    .iter()
927                    .map(|name| format!("`{name}`"))
928                    .collect::<Vec<_>>()
929                    .join(", "),
930                self.table_ident(),
931            )));
932        }
933
934        Ok(self
935            .list
936            .iter()
937            .filter(|m| dirty_names.iter().any(|name| name.as_ref() == m.name()))
938            .collect())
939    }
940
941    /// Get the SQL to check if migrations table exists
942    #[must_use]
943    pub fn table_exists_sql(&self) -> String {
944        let table = self.table.replace('\'', "''");
945        match self.dialect {
946            Dialect::SQLite => format!(
947                "SELECT name FROM sqlite_master WHERE type='table' AND name='{table}';"
948            ),
949            Dialect::PostgreSQL => self.schema.as_ref().map_or_else(
950                || {
951                    format!(
952                        "SELECT table_name FROM information_schema.tables WHERE table_name='{table}';"
953                    )
954                },
955                |schema| {
956                    let schema = schema.replace('\'', "''");
957                    format!(
958                        "SELECT table_name FROM information_schema.tables WHERE table_schema='{schema}' AND table_name='{table}';"
959                    )
960                },
961            ),
962            Dialect::MySQL => format!(
963                "SELECT table_name FROM information_schema.tables WHERE table_name='{table}';"
964            ),
965        }
966    }
967}
968
969/// Errors that can occur during migration
970#[derive(Debug, thiserror::Error)]
971pub enum MigratorError {
972    #[error("Journal error: {0}")]
973    JournalError(String),
974
975    #[error("IO error: {0}")]
976    IoError(String),
977
978    #[error("Missing migration file: {0}")]
979    MissingMigration(String),
980
981    #[error("Migration failed: {0}")]
982    ExecutionError(String),
983
984    /// A tracking row exists with `applied_at` NULL: the migration started but
985    /// never reported completion. Produced by
986    /// [`Migrations::interrupted_migration_error`].
987    #[error("{0}")]
988    InterruptedMigration(String),
989
990    /// Repair could not reconcile every statement of an interrupted migration.
991    /// Produced by [`crate::repair::Plan::into_executable`].
992    #[error("{0}")]
993    UnrepairableMigration(String),
994}
995
996/// Detect PostgreSQL `CREATE/DROP INDEX CONCURRENTLY` statements.
997#[must_use]
998pub fn is_postgres_concurrent_index_statement(sql: &str) -> bool {
999    let tokens = sql
1000        .split_whitespace()
1001        .take(4)
1002        .map(|token| token.trim_matches(|character: char| !character.is_ascii_alphabetic()))
1003        .map(str::to_ascii_uppercase)
1004        .collect::<Vec<_>>();
1005
1006    matches!(
1007        tokens.as_slice(),
1008        [create, index, concurrently, ..]
1009            if create == "CREATE" && index == "INDEX" && concurrently == "CONCURRENTLY"
1010    ) || matches!(
1011        tokens.as_slice(),
1012        [create, unique, index, concurrently, ..]
1013            if create == "CREATE"
1014                && unique == "UNIQUE"
1015                && index == "INDEX"
1016                && concurrently == "CONCURRENTLY"
1017    ) || matches!(
1018        tokens.as_slice(),
1019        [drop, index, concurrently, ..]
1020            if drop == "DROP" && index == "INDEX" && concurrently == "CONCURRENTLY"
1021    )
1022}
1023
1024// =============================================================================
1025// Helper Functions
1026// =============================================================================
1027
1028/// Compute hash of the SQL content
1029pub(crate) fn compute_hash(sql: &str) -> String {
1030    let digest = Sha256::digest(sql.as_bytes());
1031    let mut out = String::with_capacity(digest.len() * 2);
1032
1033    for byte in digest {
1034        use std::fmt::Write;
1035        let _ = write!(&mut out, "{byte:02x}");
1036    }
1037
1038    out
1039}
1040
1041/// Split SQL content into individual statements
1042pub(crate) fn split_statements(sql: &str) -> Vec<String> {
1043    split_on_semicolons(sql)
1044}
1045
1046/// Per-statement token context for [`split_on_semicolons`].
1047///
1048/// Tracks whether the statement being accumulated is a compound-bodied
1049/// object (`CREATE TRIGGER|PROCEDURE|FUNCTION|EVENT ... BEGIN ...; END` or a
1050/// PostgreSQL `BEGIN ATOMIC ...; END` body) so its internal semicolons are
1051/// not treated as statement boundaries. Mirrors SQLite's
1052/// `sqlite3_complete()`: a compound body terminates only at an `END` token
1053/// that directly follows a body semicolon (which keeps `CASE ... END` inside
1054/// the body inert), itself followed by a semicolon.
1055#[derive(Default)]
1056struct StatementState {
1057    /// First few identifier tokens of the statement (lowercased).
1058    header_tokens: Vec<String>,
1059    /// Header names an object kind that can carry a `BEGIN ... END` body.
1060    compound_header: bool,
1061    /// Nesting depth of compound bodies within the current statement.
1062    compound_depth: usize,
1063    /// Last token was `BEGIN` (a following `ATOMIC` opens a body).
1064    pending_begin: bool,
1065    /// Saw a body-terminating `END`; the next semicolon closes one level.
1066    pending_end: bool,
1067    /// Positioned at the start of a body statement (right after `BEGIN` or a
1068    /// body semicolon), where `END` may legally terminate the body.
1069    at_body_start: bool,
1070    /// Previous consumed character was part of a word (guards token starts).
1071    last_char_wordy: bool,
1072}
1073
1074impl StatementState {
1075    /// Kinds of `CREATE` statements that may contain compound bodies.
1076    const COMPOUND_KINDS: [&'static str; 4] = ["trigger", "procedure", "function", "event"];
1077    /// `CREATE <kind>` statements that never do (guards against objects
1078    /// merely *named* `function` etc.).
1079    const PLAIN_KINDS: [&'static str; 5] = ["table", "index", "view", "schema", "virtual"];
1080
1081    /// Record significant (non-whitespace, non-comment) content that is not
1082    /// an identifier token.
1083    fn note_significant(&mut self) {
1084        self.pending_begin = false;
1085        self.pending_end = false;
1086        self.at_body_start = false;
1087    }
1088
1089    /// Process an identifier token encountered in normal state.
1090    fn note_token(&mut self, token: &str) {
1091        let lower = token.to_ascii_lowercase();
1092        let was_pending_begin = self.pending_begin;
1093        let was_at_body_start = self.at_body_start;
1094        self.note_significant();
1095
1096        if self.header_tokens.len() < 6 {
1097            self.header_tokens.push(lower.clone());
1098            if self.header_tokens[0] == "create"
1099                && self.header_tokens.len() > 1
1100                && !Self::PLAIN_KINDS.contains(&self.header_tokens[1].as_str())
1101                && self.header_tokens[1..]
1102                    .iter()
1103                    .any(|t| Self::COMPOUND_KINDS.contains(&t.as_str()))
1104            {
1105                self.compound_header = true;
1106            }
1107        }
1108
1109        match lower.as_str() {
1110            "begin" if self.compound_header && self.compound_depth == 0 => {
1111                self.compound_depth = 1;
1112                self.at_body_start = true;
1113            }
1114            "begin" => self.pending_begin = true,
1115            "atomic" if was_pending_begin => {
1116                self.compound_depth += 1;
1117                self.at_body_start = true;
1118            }
1119            "end" if self.compound_depth > 0 && was_at_body_start => {
1120                self.pending_end = true;
1121            }
1122            _ => {}
1123        }
1124        self.last_char_wordy = true;
1125    }
1126}
1127
1128/// Split SQL on `--> statement-breakpoint` markers and top-level semicolons.
1129///
1130/// State-aware: quotes, comments, dollar-quoted bodies, and compound
1131/// statement bodies (trigger/procedure/function bodies, `BEGIN ATOMIC`)
1132/// keep their internal semicolons.
1133fn split_on_semicolons(sql: &str) -> Vec<String> {
1134    const BREAKPOINT: &str = "--> statement-breakpoint";
1135
1136    let mut statements = Vec::new();
1137    let mut current = String::new();
1138    let mut pos = 0;
1139
1140    let mut in_single_quote = false;
1141    let mut in_double_quote = false;
1142    let mut in_line_comment = false;
1143    let mut block_comment_depth = 0usize;
1144    let mut dollar_tag: Option<String> = None;
1145    let mut state = StatementState::default();
1146
1147    while pos < sql.len() {
1148        // Line comment state
1149        if in_line_comment {
1150            let ch = sql[pos..].chars().next().unwrap_or('\0');
1151            let ch_len = ch.len_utf8();
1152            current.push_str(&sql[pos..pos + ch_len]);
1153            pos += ch_len;
1154            if ch == '\n' {
1155                in_line_comment = false;
1156            }
1157            continue;
1158        }
1159
1160        // Block comment state
1161        if block_comment_depth > 0 {
1162            if sql[pos..].starts_with("/*") {
1163                current.push_str("/*");
1164                pos += 2;
1165                block_comment_depth += 1;
1166                continue;
1167            }
1168            if sql[pos..].starts_with("*/") {
1169                current.push_str("*/");
1170                pos += 2;
1171                block_comment_depth = block_comment_depth.saturating_sub(1);
1172                continue;
1173            }
1174
1175            let ch = sql[pos..].chars().next().unwrap_or('\0');
1176            let ch_len = ch.len_utf8();
1177            current.push_str(&sql[pos..pos + ch_len]);
1178            pos += ch_len;
1179            continue;
1180        }
1181
1182        // Dollar-quoted string state ($$...$$ or $tag$...$tag$)
1183        if let Some(tag) = dollar_tag.as_deref() {
1184            if sql[pos..].starts_with(tag) {
1185                current.push_str(tag);
1186                pos += tag.len();
1187                dollar_tag = None;
1188                state.last_char_wordy = true;
1189                continue;
1190            }
1191
1192            let ch = sql[pos..].chars().next().unwrap_or('\0');
1193            let ch_len = ch.len_utf8();
1194            current.push_str(&sql[pos..pos + ch_len]);
1195            pos += ch_len;
1196            continue;
1197        }
1198
1199        // Single-quoted string state
1200        if in_single_quote {
1201            if sql[pos..].starts_with("''") {
1202                current.push_str("''");
1203                pos += 2;
1204                continue;
1205            }
1206            if sql[pos..].starts_with('\'') {
1207                current.push('\'');
1208                pos += 1;
1209                in_single_quote = false;
1210                state.last_char_wordy = true;
1211                continue;
1212            }
1213
1214            let ch = sql[pos..].chars().next().unwrap_or('\0');
1215            let ch_len = ch.len_utf8();
1216            current.push_str(&sql[pos..pos + ch_len]);
1217            pos += ch_len;
1218            continue;
1219        }
1220
1221        // Double-quoted identifier/string state
1222        if in_double_quote {
1223            if sql[pos..].starts_with("\"\"") {
1224                current.push_str("\"\"");
1225                pos += 2;
1226                continue;
1227            }
1228            if sql[pos..].starts_with('"') {
1229                current.push('"');
1230                pos += 1;
1231                in_double_quote = false;
1232                state.last_char_wordy = true;
1233                continue;
1234            }
1235
1236            let ch = sql[pos..].chars().next().unwrap_or('\0');
1237            let ch_len = ch.len_utf8();
1238            current.push_str(&sql[pos..pos + ch_len]);
1239            pos += ch_len;
1240            continue;
1241        }
1242
1243        // Enter comment states
1244        if sql[pos..].starts_with(BREAKPOINT) && line_prefix_is_whitespace(sql, pos) {
1245            let stmt = current.trim().to_string();
1246            if !stmt.is_empty() {
1247                statements.push(stmt);
1248            }
1249            current.clear();
1250            state = StatementState::default();
1251            pos += BREAKPOINT.len();
1252            continue;
1253        }
1254        if sql[pos..].starts_with("--") {
1255            current.push_str("--");
1256            pos += 2;
1257            in_line_comment = true;
1258            continue;
1259        }
1260        if sql[pos..].starts_with("/*") {
1261            current.push_str("/*");
1262            pos += 2;
1263            block_comment_depth = 1;
1264            continue;
1265        }
1266
1267        // Enter quote states
1268        if sql[pos..].starts_with('\'') {
1269            current.push('\'');
1270            pos += 1;
1271            in_single_quote = true;
1272            state.note_significant();
1273            state.last_char_wordy = false;
1274            continue;
1275        }
1276        if sql[pos..].starts_with('"') {
1277            current.push('"');
1278            pos += 1;
1279            in_double_quote = true;
1280            state.note_significant();
1281            state.last_char_wordy = false;
1282            continue;
1283        }
1284
1285        // Enter dollar-quoted state if a valid tag starts here.
1286        if sql[pos..].starts_with('$')
1287            && let Some(tag) = parse_dollar_tag_start(sql, pos)
1288        {
1289            current.push_str(tag);
1290            pos += tag.len();
1291            dollar_tag = Some(tag.to_string());
1292            state.note_significant();
1293            state.last_char_wordy = false;
1294            continue;
1295        }
1296
1297        // Statement boundary (inert inside compound bodies)
1298        if sql[pos..].starts_with(';') {
1299            pos += 1;
1300            if state.compound_depth > 0 {
1301                if state.pending_end {
1302                    state.pending_end = false;
1303                    state.compound_depth -= 1;
1304                }
1305                if state.compound_depth > 0 {
1306                    current.push(';');
1307                    state.at_body_start = true;
1308                    state.last_char_wordy = false;
1309                    continue;
1310                }
1311                // Depth reached zero: this semicolon closes the compound
1312                // statement, so fall through to the boundary handling.
1313            }
1314            let stmt = current.trim().to_string();
1315            if !stmt.is_empty() {
1316                statements.push(stmt);
1317            }
1318            current.clear();
1319            state = StatementState::default();
1320            continue;
1321        }
1322
1323        let ch = sql[pos..].chars().next().unwrap_or('\0');
1324        if !state.last_char_wordy && (ch.is_ascii_alphabetic() || ch == '_') {
1325            let rest = &sql[pos..];
1326            let token_len = rest
1327                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
1328                .unwrap_or(rest.len());
1329            current.push_str(&rest[..token_len]);
1330            pos += token_len;
1331            state.note_token(&rest[..token_len]);
1332            continue;
1333        }
1334
1335        let ch_len = ch.len_utf8();
1336        current.push_str(&sql[pos..pos + ch_len]);
1337        pos += ch_len;
1338        if !ch.is_whitespace() {
1339            state.note_significant();
1340        }
1341        state.last_char_wordy = ch.is_ascii_alphanumeric() || ch == '_';
1342    }
1343
1344    // Don't forget the last statement (might not end with ;)
1345    let stmt = current.trim().to_string();
1346    if !stmt.is_empty() {
1347        statements.push(stmt);
1348    }
1349
1350    statements
1351}
1352
1353fn line_prefix_is_whitespace(sql: &str, pos: usize) -> bool {
1354    let line_start = sql[..pos].rfind('\n').map_or(0, |index| index + 1);
1355    sql[line_start..pos].chars().all(char::is_whitespace)
1356}
1357
1358/// Match applied database rows to local migrations for migration-table upgrades.
1359///
1360/// # Errors
1361///
1362/// Returns [`MigratorError::ExecutionError`] when one or more `applied_rows`
1363/// cannot be matched to any local migration by `created_at` or `hash`.
1364pub fn match_applied_migration_metadata(
1365    local_migrations: &[Migration],
1366    applied_rows: &[AppliedMigrationMetadata],
1367) -> Result<Vec<MatchedMigrationMetadata>, MigratorError> {
1368    use std::collections::HashMap;
1369
1370    let mut by_created_at = HashMap::<i64, Vec<&Migration>>::new();
1371    let mut by_hash = HashMap::<&str, &Migration>::new();
1372
1373    for migration in local_migrations {
1374        by_created_at
1375            .entry(migration.created_at())
1376            .or_default()
1377            .push(migration);
1378        by_hash.insert(migration.hash(), migration);
1379    }
1380
1381    let mut matched = Vec::with_capacity(applied_rows.len());
1382    let mut unmatched = Vec::new();
1383
1384    for row in applied_rows {
1385        let migration = match by_created_at.get(&row.created_at) {
1386            Some(candidates) if candidates.len() == 1 => Some(candidates[0]),
1387            Some(candidates) if candidates.len() > 1 => {
1388                candidates.iter().copied().find(|m| m.hash() == row.hash)
1389            }
1390            _ => by_hash.get(row.hash.as_str()).copied(),
1391        };
1392
1393        if let Some(migration) = migration {
1394            matched.push(MatchedMigrationMetadata {
1395                id: row.id,
1396                hash: row.hash.clone(),
1397                created_at: row.created_at,
1398                name: migration.name().to_string(),
1399            });
1400        } else {
1401            unmatched.push(format!(
1402                "[id: {:?}, created_at: {}, hash: {}]",
1403                row.id, row.created_at, row.hash
1404            ));
1405        }
1406    }
1407
1408    if unmatched.is_empty() {
1409        Ok(matched)
1410    } else {
1411        Err(MigratorError::ExecutionError(format!(
1412            "database contains applied migrations that do not match local migrations: {}",
1413            unmatched.join(", ")
1414        )))
1415    }
1416}
1417
1418fn escape_sql_string(value: &str) -> String {
1419    value.replace('\'', "''")
1420}
1421
1422/// Parse a starting `PostgreSQL` dollar-quote delimiter at `pos`.
1423///
1424/// Returns the full delimiter (e.g. "$$" or "$func$") when valid.
1425fn parse_dollar_tag_start(sql: &str, pos: usize) -> Option<&str> {
1426    if !sql[pos..].starts_with('$') {
1427        return None;
1428    }
1429
1430    let mut i = pos + 1;
1431    while i < sql.len() {
1432        let ch = sql[i..].chars().next()?;
1433        if ch == '$' {
1434            return Some(&sql[pos..=i]);
1435        }
1436        if ch.is_ascii_alphanumeric() || ch == '_' {
1437            i += ch.len_utf8();
1438            continue;
1439        }
1440        return None;
1441    }
1442
1443    None
1444}
1445
1446/// Parse timestamp from migration tag
1447///
1448/// Supports both V3 format (`YYYYMMDDHHMMSS_name`) and legacy format (`0000_name`)
1449pub(crate) fn parse_timestamp_from_tag(tag: &str) -> i64 {
1450    // Try to extract timestamp from beginning of tag (V3 format: YYYYMMDDHHMMSS)
1451    if let Some(prefix) = tag.get(0..14)
1452        && let Some(ts) = parse_timestamp_prefix_to_millis(prefix)
1453    {
1454        return ts;
1455    }
1456
1457    // Try legacy format (0000)
1458    if let Some(prefix) = tag.get(0..4)
1459        && let Ok(idx) = prefix.parse::<i64>()
1460    {
1461        // Convert index to a pseudo-timestamp for ordering
1462        return idx;
1463    }
1464
1465    // No timestamp or index prefix (e.g. `PrefixMode::None` tags): use a
1466    // stable sentinel so `created_at` is deterministic across processes;
1467    // name/hash matching identifies these rows instead.
1468    0
1469}
1470
1471/// Parse a `YYYYMMDDHHMMSS` timestamp prefix to UTC milliseconds.
1472fn parse_timestamp_prefix_to_millis(prefix: &str) -> Option<i64> {
1473    if prefix.len() != 14 || !prefix.chars().all(|ch| ch.is_ascii_digit()) {
1474        return None;
1475    }
1476
1477    let year = prefix[0..4].parse::<i32>().ok()?;
1478    let month = prefix[4..6].parse::<u32>().ok()?;
1479    let day = prefix[6..8].parse::<u32>().ok()?;
1480    let hour = prefix[8..10].parse::<u32>().ok()?;
1481    let minute = prefix[10..12].parse::<u32>().ok()?;
1482    let second = prefix[12..14].parse::<u32>().ok()?;
1483
1484    if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
1485        return None;
1486    }
1487
1488    let max_day = days_in_month(year, month);
1489    if day == 0 || day > max_day {
1490        return None;
1491    }
1492
1493    let days = days_from_civil(year, month, day)?;
1494    let day_secs = i64::from(hour) * 3_600 + i64::from(minute) * 60 + i64::from(second);
1495    let secs = days.checked_mul(86_400)?.checked_add(day_secs)?;
1496    secs.checked_mul(1_000)
1497}
1498
1499/// Days since Unix epoch (1970-01-01) from civil date, UTC.
1500///
1501/// Algorithm adapted from Howard Hinnant's civil calendar conversion.
1502fn days_from_civil(year: i32, month: u32, day: u32) -> Option<i64> {
1503    let m = i32::try_from(month).ok()?;
1504    let d = i32::try_from(day).ok()?;
1505
1506    let y = year - i32::from(m <= 2);
1507    let era = if y >= 0 { y } else { y - 399 } / 400;
1508    let yoe = y - era * 400;
1509    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
1510    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1511
1512    Some(i64::from(era) * 146_097 + i64::from(doe) - 719_468)
1513}
1514
1515const fn days_in_month(year: i32, month: u32) -> u32 {
1516    match month {
1517        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1518        4 | 6 | 9 | 11 => 30,
1519        2 if is_leap_year(year) => 29,
1520        2 => 28,
1521        _ => 0,
1522    }
1523}
1524
1525const fn is_leap_year(year: i32) -> bool {
1526    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1527}
1528
1529// =============================================================================
1530// Macro for embedding migrations
1531// =============================================================================
1532
1533/// Macro to create a vector of migrations from embedded SQL files
1534///
1535/// ```rust
1536/// # let _ = r####"
1537/// use drizzle_migrations::migrations;
1538///
1539/// let my_migrations = migrations![
1540///     ("20231220143052_init", include_str!("../drizzle/20231220143052_init/migration.sql")),
1541///     ("20231221093015_users", include_str!("../drizzle/20231221093015_users/migration.sql")),
1542/// ];
1543/// # "####;
1544/// ```
1545#[macro_export]
1546macro_rules! migrations {
1547    [$(($tag:expr, $sql:expr)),* $(,)?] => {
1548        vec![
1549            $(
1550                $crate::Migration::new($tag, $sql),
1551            )*
1552        ]
1553    };
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558    use super::{
1559        AppliedMigrationMetadata, Migration, Migrations, SqliteMigrationExecutionError,
1560        compute_hash, is_postgres_concurrent_index_statement, match_applied_migration_metadata,
1561        parse_timestamp_from_tag, split_on_semicolons, split_statements,
1562    };
1563    use crate::config::Tracking;
1564    use crate::dir::MigrationDir;
1565    use drizzle_types::Dialect;
1566
1567    #[test]
1568    fn sqlite_execution_lifts_foreign_key_pragmas_out_of_transactions() {
1569        let migration = Migration::new(
1570            "0001_rebuild",
1571            "PRAGMA foreign_keys = OFF;\n--> statement-breakpoint\nCREATE TABLE records (id INTEGER);\n--> statement-breakpoint\nPRAGMA foreign_keys=ON;",
1572        );
1573
1574        let execution = migration.sqlite_execution().expect("valid suspension");
1575        assert!(execution.suspends_foreign_keys());
1576        assert_eq!(
1577            execution.statements().collect::<Vec<_>>(),
1578            vec!["CREATE TABLE records (id INTEGER)"]
1579        );
1580    }
1581
1582    #[test]
1583    fn sqlite_execution_rejects_unbalanced_foreign_key_pragmas() {
1584        let missing_on = Migration::new("0001", "PRAGMA foreign_keys=OFF;");
1585        assert_eq!(
1586            missing_on.sqlite_execution().unwrap_err(),
1587            SqliteMigrationExecutionError::ForeignKeysOffWithoutOn
1588        );
1589
1590        let missing_off = Migration::new("0002", "PRAGMA foreign_keys=ON;");
1591        assert_eq!(
1592            missing_off.sqlite_execution().unwrap_err(),
1593            SqliteMigrationExecutionError::ForeignKeysOnWithoutOff
1594        );
1595
1596        let nested = Migration::new(
1597            "0003",
1598            "PRAGMA foreign_keys=OFF;\n--> statement-breakpoint\nPRAGMA foreign_keys=OFF;\n--> statement-breakpoint\nPRAGMA foreign_keys=ON;",
1599        );
1600        assert_eq!(
1601            nested.sqlite_execution().unwrap_err(),
1602            SqliteMigrationExecutionError::NestedForeignKeysOff
1603        );
1604
1605        let unsupported = Migration::new(
1606            "0004",
1607            "PRAGMA foreign_keys=disabled;\n--> statement-breakpoint\nPRAGMA foreign_keys=ON;",
1608        );
1609        assert_eq!(
1610            unsupported.sqlite_execution().unwrap_err(),
1611            SqliteMigrationExecutionError::UnsupportedForeignKeysPragma
1612        );
1613    }
1614
1615    #[test]
1616    fn sqlite_execution_accepts_parenthesized_and_commented_pragmas() {
1617        let migration = Migration::new(
1618            "0001_rebuild",
1619            "-- generated rebuild guard\nPRAGMA /* suspend enforcement */ main.'foreign_keys'(OFF);\n--> statement-breakpoint\nCREATE TABLE records (id INTEGER);\n--> statement-breakpoint\nPRAGMA \"main\".\"foreign_keys\" /* restore enforcement */ (ON);",
1620        );
1621
1622        let execution = migration.sqlite_execution().expect("valid suspension");
1623        assert!(execution.suspends_foreign_keys());
1624        assert_eq!(
1625            execution.statements().collect::<Vec<_>>(),
1626            vec!["CREATE TABLE records (id INTEGER)"]
1627        );
1628    }
1629
1630    #[test]
1631    fn migration_tracking_identifiers_are_escaped_per_dialect() {
1632        let sqlite = Migrations::with_tracking(
1633            Vec::new(),
1634            Dialect::SQLite,
1635            Tracking::new("migration\"records", None::<String>),
1636        );
1637        assert_eq!(sqlite.table_ident_sql(), "\"migration\"\"records\"");
1638        assert!(
1639            sqlite
1640                .create_table_sql()
1641                .starts_with("CREATE TABLE IF NOT EXISTS \"migration\"\"records\"")
1642        );
1643
1644        let postgres = Migrations::with_tracking(
1645            Vec::new(),
1646            Dialect::PostgreSQL,
1647            Tracking::new("migration\"records", Some("audit\"schema")),
1648        );
1649        assert_eq!(
1650            postgres.table_ident_sql(),
1651            "\"audit\"\"schema\".\"migration\"\"records\""
1652        );
1653        assert_eq!(
1654            postgres.create_schema_sql().as_deref(),
1655            Some("CREATE SCHEMA IF NOT EXISTS \"audit\"\"schema\";")
1656        );
1657
1658        let mysql = Migrations::with_tracking(
1659            Vec::new(),
1660            Dialect::MySQL,
1661            Tracking::new("migration`records", None::<String>),
1662        );
1663        assert_eq!(mysql.table_ident_sql(), "`migration``records`");
1664    }
1665
1666    #[test]
1667    fn split_handles_strings_and_comments() {
1668        let sql = "\
1669            CREATE TABLE users(id INTEGER, note TEXT DEFAULT 'a;b');\n\
1670            -- comment with ; should not split\n\
1671            CREATE INDEX users_id_idx ON users(id);\n\
1672            /* block ; comment */\n\
1673            CREATE TABLE posts(id INTEGER);\
1674        ";
1675
1676        let stmts = split_on_semicolons(sql);
1677        assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1678        assert_eq!(
1679            stmts[0],
1680            "CREATE TABLE users(id INTEGER, note TEXT DEFAULT 'a;b')"
1681        );
1682        assert_eq!(
1683            stmts[1],
1684            "-- comment with ; should not split\nCREATE INDEX users_id_idx ON users(id)"
1685        );
1686        assert_eq!(
1687            stmts[2],
1688            "/* block ; comment */\nCREATE TABLE posts(id INTEGER)"
1689        );
1690    }
1691
1692    #[test]
1693    fn split_handles_dollar_quoted_bodies() {
1694        let sql = "\
1695            CREATE FUNCTION f() RETURNS void AS $$\n\
1696            BEGIN\n\
1697              RAISE NOTICE 'x;y';\n\
1698            END;\n\
1699            $$ LANGUAGE plpgsql;\n\
1700            CREATE TABLE t(id INTEGER);\
1701        ";
1702
1703        let stmts = split_on_semicolons(sql);
1704        assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1705        assert_eq!(
1706            stmts[0],
1707            "CREATE FUNCTION f() RETURNS void AS $$\nBEGIN\nRAISE NOTICE 'x;y';\nEND;\n$$ LANGUAGE plpgsql"
1708        );
1709        assert_eq!(stmts[1], "CREATE TABLE t(id INTEGER)");
1710    }
1711
1712    #[test]
1713    fn split_handles_tagged_dollar_quotes() {
1714        let sql = "\
1715            DO $body$\n\
1716            BEGIN\n\
1717              PERFORM 1;\n\
1718            END;\n\
1719            $body$;\n\
1720            CREATE TABLE tagged(id INTEGER);\
1721        ";
1722
1723        let stmts = split_on_semicolons(sql);
1724        assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1725        assert_eq!(stmts[0], "DO $body$\nBEGIN\nPERFORM 1;\nEND;\n$body$");
1726        assert_eq!(stmts[1], "CREATE TABLE tagged(id INTEGER)");
1727    }
1728
1729    #[test]
1730    fn split_keeps_sqlite_trigger_bodies_intact() {
1731        let sql = "\
1732            CREATE TABLE logs(msg TEXT);\n\
1733            CREATE TRIGGER users_ai AFTER INSERT ON users FOR EACH ROW BEGIN\n\
1734              INSERT INTO logs(msg) VALUES ('added;removed');\n\
1735              UPDATE counters SET n = n + 1 WHERE id = 1;\n\
1736            END;\n\
1737            CREATE INDEX logs_msg_idx ON logs(msg);\
1738        ";
1739
1740        let stmts = split_statements(sql);
1741        assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1742        assert_eq!(stmts[0], "CREATE TABLE logs(msg TEXT)");
1743        assert!(stmts[1].starts_with("CREATE TRIGGER users_ai"));
1744        assert!(
1745            stmts[1].ends_with("END"),
1746            "trigger body truncated: {}",
1747            stmts[1]
1748        );
1749        assert!(stmts[1].contains("VALUES ('added;removed');"));
1750        assert!(stmts[1].contains("WHERE id = 1;"));
1751        assert_eq!(stmts[2], "CREATE INDEX logs_msg_idx ON logs(msg)");
1752    }
1753
1754    #[test]
1755    fn split_trigger_body_with_case_end_stays_intact() {
1756        let sql = "\
1757            CREATE TRIGGER t1 BEFORE UPDATE ON t WHEN (new.n > old.n) BEGIN\n\
1758              UPDATE t SET status = CASE WHEN new.n > 0 THEN 'pos' ELSE 'neg' END;\n\
1759              DELETE FROM audit WHERE id = old.id;\n\
1760            END;\n\
1761            CREATE TABLE afterwards(id INTEGER);\
1762        ";
1763
1764        let stmts = split_statements(sql);
1765        assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1766        assert!(stmts[0].starts_with("CREATE TRIGGER t1"));
1767        assert!(
1768            stmts[0].ends_with("END"),
1769            "trigger body truncated: {}",
1770            stmts[0]
1771        );
1772        assert!(stmts[0].contains("ELSE 'neg' END;"));
1773        assert_eq!(stmts[1], "CREATE TABLE afterwards(id INTEGER)");
1774    }
1775
1776    #[test]
1777    fn split_keeps_begin_atomic_bodies_intact() {
1778        let sql = "\
1779            CREATE FUNCTION add_one(x int) RETURNS int LANGUAGE SQL BEGIN ATOMIC\n\
1780              SELECT x + 1;\n\
1781            END;\n\
1782            CREATE TABLE t(id INTEGER);\
1783        ";
1784
1785        let stmts = split_statements(sql);
1786        assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1787        assert!(stmts[0].starts_with("CREATE FUNCTION add_one"));
1788        assert!(
1789            stmts[0].ends_with("END"),
1790            "atomic body truncated: {}",
1791            stmts[0]
1792        );
1793        assert!(stmts[0].contains("SELECT x + 1;"));
1794        assert_eq!(stmts[1], "CREATE TABLE t(id INTEGER)");
1795    }
1796
1797    #[test]
1798    fn split_plain_begin_transaction_still_splits() {
1799        let sql = "BEGIN;\nUPDATE t SET a = 1;\nCOMMIT;";
1800
1801        let stmts = split_statements(sql);
1802        assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1803        assert_eq!(stmts[0], "BEGIN");
1804        assert_eq!(stmts[1], "UPDATE t SET a = 1");
1805        assert_eq!(stmts[2], "COMMIT");
1806    }
1807
1808    #[test]
1809    fn split_markers_and_trigger_bodies_coexist() {
1810        let sql = "\
1811            CREATE TABLE users(id INTEGER);\n\
1812            --> statement-breakpoint\n\
1813            CREATE TRIGGER trg AFTER DELETE ON users BEGIN\n\
1814              INSERT INTO audit(msg) VALUES ('gone');\n\
1815            END;\n\
1816            --> statement-breakpoint\n\
1817            CREATE TABLE audit(msg TEXT);\
1818        ";
1819
1820        let stmts = split_statements(sql);
1821        assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1822        assert!(stmts[1].starts_with("CREATE TRIGGER trg"));
1823        assert!(
1824            stmts[1].ends_with("END"),
1825            "trigger body truncated: {}",
1826            stmts[1]
1827        );
1828    }
1829
1830    #[test]
1831    fn breakpoints_split_only_at_top_level_marker_lines() {
1832        let sql = r#"
1833            CREATE TABLE notes(value TEXT DEFAULT '--> statement-breakpoint');
1834            -- ordinary comment containing --> statement-breakpoint
1835            CREATE FUNCTION marker_text() RETURNS text AS $$
1836            BEGIN
1837              RETURN '--> statement-breakpoint';
1838            END;
1839            $$ LANGUAGE plpgsql;
1840            --> statement-breakpoint
1841            CREATE TABLE users(id INTEGER);
1842        "#;
1843
1844        let statements = split_statements(sql);
1845        assert_eq!(statements.len(), 3, "unexpected split: {statements:?}");
1846        assert!(statements[1].contains("ordinary comment containing"));
1847        assert!(statements[1].contains("RETURN '--> statement-breakpoint'"));
1848        assert_eq!(statements[2], "CREATE TABLE users(id INTEGER)");
1849    }
1850
1851    #[test]
1852    fn hash_is_stable_for_same_input() {
1853        let a = compute_hash("CREATE TABLE users(id INTEGER);");
1854        let b = compute_hash("CREATE TABLE users(id INTEGER);");
1855        let c = compute_hash("CREATE TABLE users(id INTEGER PRIMARY KEY);");
1856
1857        assert_eq!(a, b);
1858        assert_ne!(a, c);
1859        assert_eq!(a.len(), 64);
1860    }
1861
1862    #[test]
1863    fn hash_matches_known_value() {
1864        let hash = compute_hash("CREATE TABLE users(id INTEGER);");
1865        assert_eq!(
1866            hash,
1867            "238b0b8f98ac8bb3155ac1081ad6a3ce07cfba14eeaa6beeebf2161091265fcc"
1868        );
1869    }
1870
1871    #[test]
1872    fn concurrent_index_detection_is_token_aware() {
1873        assert!(is_postgres_concurrent_index_statement(
1874            "CREATE INDEX CONCURRENTLY users_email ON users (email)"
1875        ));
1876        assert!(is_postgres_concurrent_index_statement(
1877            "CREATE UNIQUE INDEX CONCURRENTLY users_email ON users (email)"
1878        ));
1879        assert!(is_postgres_concurrent_index_statement(
1880            "DROP INDEX CONCURRENTLY users_email"
1881        ));
1882        assert!(!is_postgres_concurrent_index_statement(
1883            "SELECT 'CREATE INDEX CONCURRENTLY hidden in text'"
1884        ));
1885    }
1886
1887    #[test]
1888    fn postgres_advisory_lock_key_is_stable_per_tracking_table() {
1889        let first = Migrations::with_tracking(
1890            Vec::new(),
1891            Dialect::PostgreSQL,
1892            Tracking::new("migrations", Some("audit")),
1893        );
1894        let same = first.clone();
1895        let different = Migrations::with_tracking(
1896            Vec::new(),
1897            Dialect::PostgreSQL,
1898            Tracking::new("other_migrations", Some("audit")),
1899        );
1900
1901        assert_eq!(
1902            first.postgres_advisory_lock_key(),
1903            same.postgres_advisory_lock_key()
1904        );
1905        assert_ne!(
1906            first.postgres_advisory_lock_key(),
1907            different.postgres_advisory_lock_key()
1908        );
1909    }
1910
1911    #[test]
1912    fn parse_timestamp_tag_matches_drizzle_orm_millis() {
1913        let created_at = parse_timestamp_from_tag("20230331141203_test");
1914        assert_eq!(created_at, 1_680_271_923_000);
1915    }
1916
1917    #[test]
1918    fn pending_is_set_difference_by_folder_name() {
1919        // Mirrors drizzle-orm beta.19 `getMigrationsToRun`: two migrations in
1920        // the same wall-second must both run if only one has been applied.
1921        let set = Migrations::new(
1922            vec![
1923                super::Migration::with_hash(
1924                    "20230331141203_alpha",
1925                    "hash_a",
1926                    1_680_271_923_000,
1927                    vec!["A".into()],
1928                ),
1929                super::Migration::with_hash(
1930                    "20230331141203_beta",
1931                    "hash_b",
1932                    1_680_271_923_000,
1933                    vec!["B".into()],
1934                ),
1935                super::Migration::with_hash(
1936                    "20230331141500_gamma",
1937                    "hash_c",
1938                    1_680_272_100_000,
1939                    vec!["C".into()],
1940                ),
1941            ],
1942            Dialect::SQLite,
1943        );
1944
1945        let applied_names = vec!["20230331141203_alpha".to_string()];
1946        let pending: Vec<_> = set
1947            .pending(&applied_names)
1948            .map(|m| m.tag().to_string())
1949            .collect();
1950
1951        assert_eq!(
1952            pending,
1953            vec![
1954                "20230331141203_beta".to_string(),
1955                "20230331141500_gamma".to_string()
1956            ],
1957            "beta shares a created_at with alpha but must still run"
1958        );
1959        assert!(set.has_pending(&applied_names));
1960    }
1961
1962    #[test]
1963    fn pending_skips_already_applied_out_of_order() {
1964        // Upstream behavior: a later migration being applied first (e.g. after
1965        // a branch merge) does not cause earlier pending migrations to be
1966        // skipped.
1967        let set = Migrations::new(
1968            vec![
1969                super::Migration::with_hash(
1970                    "20240101010101_feature_a",
1971                    "hash_a",
1972                    1_704_070_861_000,
1973                    vec!["A".into()],
1974                ),
1975                super::Migration::with_hash(
1976                    "20240102010101_feature_b",
1977                    "hash_b",
1978                    1_704_157_261_000,
1979                    vec!["B".into()],
1980                ),
1981            ],
1982            Dialect::SQLite,
1983        );
1984
1985        let applied_names = vec!["20240102010101_feature_b".to_string()];
1986        let pending: Vec<_> = set
1987            .pending(&applied_names)
1988            .map(|m| m.tag().to_string())
1989            .collect();
1990
1991        assert_eq!(pending, vec!["20240101010101_feature_a".to_string()]);
1992    }
1993
1994    #[test]
1995    fn applied_names_sql_selects_only_non_null_rows() {
1996        let set = Migrations::new(Vec::new(), Dialect::PostgreSQL);
1997        let sql = set.applied_names_sql();
1998        assert!(sql.contains("\"name\" IS NOT NULL"));
1999        assert!(sql.contains("ORDER BY id"));
2000        // PostgreSQL sets use schema-qualified identifiers by default.
2001        assert!(sql.contains("\"drizzle\".\"__drizzle_migrations\""));
2002    }
2003
2004    #[test]
2005    fn applied_records_sql_exposes_hash_and_dirty_flag() {
2006        let set = Migrations::new(Vec::new(), Dialect::PostgreSQL);
2007        let sql = set.applied_records_sql();
2008        assert!(sql.contains("\"hash\""));
2009        assert!(sql.contains("(\"applied_at\" IS NULL) AS dirty"));
2010        // Unlike applied_names_sql, dirty rows are included so integrity
2011        // checks can report them.
2012        assert!(!sql.contains("\"applied_at\" IS NOT NULL"));
2013
2014        let mysql = Migrations::new(Vec::new(), Dialect::MySQL);
2015        let sql = mysql.applied_records_sql();
2016        assert!(sql.contains("`hash`"));
2017        assert!(sql.contains("(`applied_at` IS NULL) AS dirty"));
2018    }
2019
2020    fn sample_migration() -> super::Migration {
2021        super::Migration::with_hash(
2022            "20230331141203_test",
2023            "abc123",
2024            1_680_271_923_000,
2025            vec!["CREATE TABLE users(id INTEGER PRIMARY KEY)".to_string()],
2026        )
2027    }
2028
2029    #[test]
2030    fn applied_names_sql_excludes_dirty_rows() {
2031        for dialect in [Dialect::SQLite, Dialect::PostgreSQL, Dialect::MySQL] {
2032            let set = Migrations::new(Vec::new(), dialect);
2033            let applied = set.applied_names_sql();
2034            let dirty = set.dirty_names_sql();
2035
2036            if dialect == Dialect::MySQL {
2037                assert!(applied.contains("`applied_at` IS NOT NULL"), "{applied}");
2038                assert!(dirty.contains("`applied_at` IS NULL"), "{dirty}");
2039                assert!(dirty.contains("`name` IS NOT NULL"), "{dirty}");
2040            } else {
2041                assert!(applied.contains("\"applied_at\" IS NOT NULL"), "{applied}");
2042                assert!(dirty.contains("\"applied_at\" IS NULL"), "{dirty}");
2043                assert!(dirty.contains("\"name\" IS NOT NULL"), "{dirty}");
2044            }
2045            assert!(dirty.contains("ORDER BY id"));
2046        }
2047    }
2048
2049    #[test]
2050    fn two_phase_tracking_sql_marks_then_clears_dirty() {
2051        let migration = sample_migration();
2052        let set = Migrations::new(vec![migration.clone()], Dialect::SQLite);
2053
2054        let started = set.record_migration_started_sql(&migration);
2055        assert!(started.starts_with("INSERT INTO"));
2056        assert!(
2057            started.contains("'20230331141203_test', NULL)"),
2058            "phase 1 must write applied_at NULL explicitly: {started}"
2059        );
2060
2061        let finished = set.record_migration_finished_sql(&migration);
2062        assert!(finished.starts_with("UPDATE"));
2063        assert!(finished.contains("\"applied_at\" = CURRENT_TIMESTAMP"));
2064        assert!(
2065            finished.contains("\"applied_at\" IS NULL"),
2066            "phase 3 must only clear a still-dirty row: {finished}"
2067        );
2068
2069        let cleared = set.clear_migration_started_sql(&migration);
2070        assert!(cleared.starts_with("DELETE FROM"));
2071        assert!(cleared.contains("\"applied_at\" IS NULL"));
2072    }
2073
2074    #[test]
2075    fn two_phase_tracking_sql_quotes_per_dialect() {
2076        let migration = sample_migration();
2077
2078        let postgres = Migrations::new(vec![migration.clone()], Dialect::PostgreSQL);
2079        assert!(
2080            postgres
2081                .record_migration_started_sql(&migration)
2082                .contains("\"drizzle\".\"__drizzle_migrations\"")
2083        );
2084
2085        let mysql = Migrations::with_tracking(
2086            vec![migration.clone()],
2087            Dialect::MySQL,
2088            Tracking::new("__drizzle_migrations", None::<String>),
2089        );
2090        let started = mysql.record_migration_started_sql(&migration);
2091        assert!(started.contains("`hash`"), "{started}");
2092        assert!(started.contains("NULL)"), "{started}");
2093        assert!(
2094            mysql
2095                .record_migration_finished_sql(&migration)
2096                .contains("`applied_at` = CURRENT_TIMESTAMP")
2097        );
2098    }
2099
2100    #[test]
2101    fn started_row_is_not_reported_as_applied() {
2102        // The started/finished pair is the only difference between "pending",
2103        // "dirty" and "applied", so the predicates must be exact complements.
2104        let set = Migrations::new(Vec::new(), Dialect::SQLite);
2105        assert_ne!(set.applied_names_sql(), set.dirty_names_sql());
2106        assert!(!set.applied_names_sql().contains("IS NULL ORDER"));
2107    }
2108
2109    #[test]
2110    fn interrupted_migration_error_is_none_when_clean() {
2111        let set = Migrations::new(Vec::new(), Dialect::SQLite);
2112        assert!(
2113            set.interrupted_migration_error::<String>(&[]).is_none(),
2114            "no dirty rows means no error"
2115        );
2116    }
2117
2118    #[test]
2119    fn interrupted_migration_error_names_migration_and_recovery() {
2120        let set = Migrations::new(Vec::new(), Dialect::SQLite);
2121        let error = set
2122            .interrupted_migration_error(&["20230331141203_test"])
2123            .expect("dirty row must produce an error");
2124        let text = error.to_string();
2125
2126        assert!(text.contains("`20230331141203_test`"), "{text}");
2127        assert!(text.contains("interrupted mid-apply"), "{text}");
2128        assert!(text.contains("NULL `applied_at`"), "{text}");
2129        assert!(text.contains("drizzle migrate --repair"), "{text}");
2130        assert!(text.contains("migrate_with_repair"), "{text}");
2131        assert!(
2132            text.contains("UPDATE \"__drizzle_migrations\" SET"),
2133            "{text}"
2134        );
2135        assert!(
2136            text.contains("DELETE FROM \"__drizzle_migrations\""),
2137            "{text}"
2138        );
2139        assert!(matches!(
2140            error,
2141            super::MigratorError::InterruptedMigration(_)
2142        ));
2143    }
2144
2145    #[test]
2146    fn interrupted_migration_error_pluralizes_and_lists_all() {
2147        let set = Migrations::new(Vec::new(), Dialect::SQLite);
2148        let text = set
2149            .interrupted_migration_error(&["a_one", "b_two"])
2150            .expect("dirty rows")
2151            .to_string();
2152        assert!(text.contains("migrations `a_one`, `b_two` were"), "{text}");
2153    }
2154
2155    #[test]
2156    fn backfill_metadata_sql_sets_applied_at_from_created_at() {
2157        let row = super::MatchedMigrationMetadata {
2158            id: Some(7),
2159            hash: "abc".to_string(),
2160            created_at: 1_680_271_923_000,
2161            name: "20230331141203_test".to_string(),
2162        };
2163
2164        let sqlite =
2165            Migrations::new(Vec::new(), Dialect::SQLite).backfill_migration_metadata_sql(&row);
2166        assert!(
2167            sqlite.contains("\"name\" = '20230331141203_test'"),
2168            "{sqlite}"
2169        );
2170        assert!(
2171            sqlite.contains("\"applied_at\" = datetime(1680271923000 / 1000, 'unixepoch')"),
2172            "legacy rows must not look dirty: {sqlite}"
2173        );
2174        assert!(sqlite.contains("\"id\" = 7"), "{sqlite}");
2175        assert!(!sqlite.contains("= NULL"), "{sqlite}");
2176
2177        let postgres =
2178            Migrations::new(Vec::new(), Dialect::PostgreSQL).backfill_migration_metadata_sql(&row);
2179        assert!(postgres.contains("to_timestamp("), "{postgres}");
2180        assert!(!postgres.contains("= NULL"), "{postgres}");
2181    }
2182
2183    #[test]
2184    fn backfill_metadata_sql_falls_back_to_hash_when_id_is_missing() {
2185        let row = super::MatchedMigrationMetadata {
2186            id: None,
2187            hash: "ab'c".to_string(),
2188            created_at: 12,
2189            name: "tag".to_string(),
2190        };
2191        let sql =
2192            Migrations::new(Vec::new(), Dialect::SQLite).backfill_migration_metadata_sql(&row);
2193        assert!(sql.contains("\"created_at\" = 12"), "{sql}");
2194        assert!(sql.contains("\"hash\" = 'ab''c'"), "{sql}");
2195    }
2196
2197    #[test]
2198    fn record_migration_sql_includes_name_and_applied_at() {
2199        let migration = super::Migration::with_hash(
2200            "20230331141203_test",
2201            "abc123",
2202            1_680_271_923_000,
2203            vec!["CREATE TABLE users(id INTEGER PRIMARY KEY)".to_string()],
2204        );
2205        let set = Migrations::new(vec![migration.clone()], Dialect::SQLite);
2206
2207        let sql = set.record_migration_sql(&migration);
2208        assert!(sql.contains("\"name\""));
2209        assert!(sql.contains("\"applied_at\""));
2210        assert!(sql.contains("20230331141203_test"));
2211    }
2212
2213    #[test]
2214    fn match_applied_metadata_prefers_hash_when_created_at_collides() {
2215        let migrations = vec![
2216            super::Migration::with_hash(
2217                "20230331141203_alpha",
2218                "hash_a",
2219                1_680_271_923_000,
2220                vec!["A".to_string()],
2221            ),
2222            super::Migration::with_hash(
2223                "20230331141203_beta",
2224                "hash_b",
2225                1_680_271_923_000,
2226                vec!["B".to_string()],
2227            ),
2228        ];
2229
2230        let matched = match_applied_migration_metadata(
2231            &migrations,
2232            &[AppliedMigrationMetadata {
2233                id: Some(1),
2234                hash: "hash_b".to_string(),
2235                created_at: 1_680_271_923_000,
2236            }],
2237        )
2238        .expect("match metadata");
2239
2240        assert_eq!(matched[0].name, "20230331141203_beta");
2241    }
2242
2243    #[test]
2244    fn match_applied_metadata_errors_for_unmatched_rows() {
2245        let migrations = vec![super::Migration::with_hash(
2246            "20230331141203_alpha",
2247            "hash_a",
2248            1_680_271_923_000,
2249            vec!["A".to_string()],
2250        )];
2251
2252        let err = match_applied_migration_metadata(
2253            &migrations,
2254            &[AppliedMigrationMetadata {
2255                id: Some(9),
2256                hash: "missing_hash".to_string(),
2257                created_at: 1_680_271_924_000,
2258            }],
2259        )
2260        .expect_err("should reject unmatched metadata");
2261
2262        assert!(err.to_string().contains("do not match local migrations"));
2263    }
2264
2265    #[test]
2266    fn from_dir_discovers_v3_migration_without_snapshot_file() {
2267        let dir = tempfile::tempdir().expect("tempdir");
2268        let migration_dir = dir.path().join("20230331141203_test");
2269        std::fs::create_dir_all(&migration_dir).expect("create migration dir");
2270        std::fs::write(
2271            migration_dir.join("migration.sql"),
2272            "CREATE TABLE users(id INTEGER PRIMARY KEY);",
2273        )
2274        .expect("write migration.sql");
2275
2276        let migrations = MigrationDir::new(dir.path())
2277            .discover()
2278            .expect("load migrations");
2279        assert_eq!(migrations.len(), 1);
2280        assert_eq!(migrations[0].created_at(), 1_680_271_923_000);
2281    }
2282
2283    #[test]
2284    fn from_dir_prefers_v3_when_both_formats_present() {
2285        let dir = tempfile::tempdir().expect("tempdir");
2286
2287        let mut journal = crate::journal::Journal::new(Dialect::SQLite);
2288        journal.add_entry("0000_journal_first".to_string(), true);
2289        journal
2290            .save(&dir.path().join("meta").join("_journal.json"))
2291            .expect("write journal");
2292
2293        std::fs::write(
2294            dir.path().join("0000_journal_first.sql"),
2295            "CREATE TABLE from_journal(id INTEGER PRIMARY KEY);",
2296        )
2297        .expect("write legacy migration file");
2298
2299        // V3 migration should be preferred over legacy journal metadata when both are present.
2300        let v3_dir = dir.path().join("20240101010101_v3_extra");
2301        std::fs::create_dir_all(&v3_dir).expect("create v3 dir");
2302        std::fs::write(
2303            v3_dir.join("migration.sql"),
2304            "CREATE TABLE from_v3(id INTEGER PRIMARY KEY);",
2305        )
2306        .expect("write v3 migration.sql");
2307
2308        let migrations = MigrationDir::new(dir.path())
2309            .discover()
2310            .expect_err("legacy journal should be rejected");
2311        assert!(
2312            migrations
2313                .to_string()
2314                .contains("old drizzle-kit migration folders")
2315        );
2316    }
2317
2318    #[test]
2319    fn from_dir_rejects_legacy_journal_when_no_v3_dirs() {
2320        let dir = tempfile::tempdir().expect("tempdir");
2321
2322        let mut journal = crate::journal::Journal::new(Dialect::SQLite);
2323        journal.add_entry("0000_journal_first".to_string(), true);
2324        journal
2325            .save(&dir.path().join("meta").join("_journal.json"))
2326            .expect("write journal");
2327
2328        std::fs::write(
2329            dir.path().join("0000_journal_first.sql"),
2330            "CREATE TABLE from_journal(id INTEGER PRIMARY KEY);",
2331        )
2332        .expect("write legacy migration file");
2333        let err = MigrationDir::new(dir.path())
2334            .discover()
2335            .expect_err("legacy journal should be rejected");
2336        assert!(
2337            err.to_string()
2338                .contains("old drizzle-kit migration folders")
2339        );
2340    }
2341}