Skip to main content

laterite_core/
migration.rs

1//! Portable, reversible migrations.
2//!
3//! A migration is a Rust unit implementing [`Migration`]: a stable `name`, an
4//! `up`, and an optional `down` (absent means the migration is irreversible).
5//! DDL is written with the `sea-query` schema builder through the [`Schema`]
6//! handle, which renders for whichever backend the deployment runs on, so one
7//! migration serves Postgres, MySQL, and SQLite. Raw SQL stays available as an
8//! escape hatch, and [`SqlMigration`] wraps a pure-SQL up/down pair.
9//!
10//! A module exposes an ordered [`MigrationSet`] (its version history). The
11//! runner applies pending migrations ([`run`]) and reverses them ([`rollback`],
12//! [`reset`]), tracking what is applied by `(module_id, name)` in a single
13//! `laterite_migrations` table. Queries run over `sqlx::Any`, so the same runner
14//! drives every supported backend.
15
16use async_trait::async_trait;
17use sea_query::{
18    ColumnDef, Expr, Iden, Index, MysqlQueryBuilder, PostgresQueryBuilder, Query,
19    QueryStatementWriter, SchemaStatementBuilder, SqliteQueryBuilder, Table,
20};
21use sqlx::{AnyConnection, AnyPool};
22
23use crate::error::{CoreError, CoreResult};
24
25/// The database backend a deployment runs on. Selects the `sea-query` renderer
26/// so migrations and the runner emit SQL the target understands.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DbBackend {
29    Postgres,
30    Mysql,
31    Sqlite,
32}
33
34impl DbBackend {
35    /// Infers the backend from a connection URL scheme
36    /// (`postgres://`, `mysql://`, `sqlite:`).
37    pub fn from_url(url: &str) -> CoreResult<Self> {
38        if url.starts_with("postgres") {
39            Ok(Self::Postgres)
40        } else if url.starts_with("mysql") {
41            Ok(Self::Mysql)
42        } else if url.starts_with("sqlite") {
43            Ok(Self::Sqlite)
44        } else {
45            Err(CoreError::Config(format!(
46                "unrecognised database URL scheme: {url}"
47            )))
48        }
49    }
50
51    /// The backend's lowercase name, as used in connection URLs.
52    pub fn name(&self) -> &'static str {
53        match self {
54            Self::Postgres => "postgres",
55            Self::Mysql => "mysql",
56            Self::Sqlite => "sqlite",
57        }
58    }
59}
60
61/// A portable boolean column, stored as a 0/1 integer. Use this in a migration
62/// instead of `ColumnDef::new(name).boolean()`: `sqlx::Any` cannot decode a
63/// SQLite `boolean` column, so integer is the representation that works on every
64/// backend. Bind and read the value as a normal `bool` through the query layer
65/// (see `crate::query`). Chain `.not_null()`, `.default(0)`, and so on as usual.
66pub fn bool_col<T: sea_query::IntoIden>(name: T) -> ColumnDef {
67    ColumnDef::new(name).integer().to_owned()
68}
69
70/// The length of a [`key_col`], generous enough for UUIDs, codes, usernames,
71/// emails, and token hashes while staying within MySQL's index-length limit.
72pub const KEY_LEN: u32 = 255;
73
74/// A portable key column: a bounded `varchar` rather than `text`. Use this in a
75/// migration for any column that participates in a key, index, or foreign key
76/// (ids, codes, tokens, and columns named in an `Index`): MySQL cannot index a
77/// `text` column without a prefix length, so a plain `text` id or unique column
78/// fails there. A bounded string keys cleanly on every backend. It holds the
79/// same UTF-8 strings a `text` column would, so application code is unchanged.
80/// Chain `.not_null()`, `.primary_key()`, `.unique_key()`, and so on as usual.
81pub fn key_col<T: sea_query::IntoIden>(name: T) -> ColumnDef {
82    ColumnDef::new(name).string_len(KEY_LEN).to_owned()
83}
84
85fn schema_sql<S: SchemaStatementBuilder>(backend: DbBackend, stmt: &S) -> String {
86    match backend {
87        DbBackend::Postgres => stmt.build(PostgresQueryBuilder),
88        DbBackend::Mysql => stmt.build(MysqlQueryBuilder),
89        DbBackend::Sqlite => stmt.build(SqliteQueryBuilder),
90    }
91}
92
93fn query_sql<Q: QueryStatementWriter>(backend: DbBackend, stmt: &Q) -> String {
94    match backend {
95        DbBackend::Postgres => stmt.to_string(PostgresQueryBuilder),
96        DbBackend::Mysql => stmt.to_string(MysqlQueryBuilder),
97        DbBackend::Sqlite => stmt.to_string(SqliteQueryBuilder),
98    }
99}
100
101/// The handle a migration uses to change the schema. Renders `sea-query`
102/// statements for the running backend, with a raw-SQL escape hatch.
103pub struct Schema<'c> {
104    conn: &'c mut AnyConnection,
105    backend: DbBackend,
106}
107
108impl Schema<'_> {
109    pub fn backend(&self) -> DbBackend {
110        self.backend
111    }
112
113    /// Runs a `sea-query` schema statement (`Table::create/alter/drop`,
114    /// `Index::create`, ...). Takes the statement by value and renders it to SQL
115    /// before awaiting, so the (non-`Send`) statement is not held across the
116    /// await point.
117    pub async fn exec<S: SchemaStatementBuilder>(&mut self, stmt: S) -> CoreResult<()> {
118        let sql = schema_sql(self.backend, &stmt);
119        drop(stmt);
120        sqlx::query(&sql).execute(&mut *self.conn).await?;
121        Ok(())
122    }
123
124    /// Runs raw SQL, for anything the builder does not express. Portability is
125    /// the caller's responsibility.
126    pub async fn raw(&mut self, sql: &str) -> CoreResult<()> {
127        sqlx::query(sql).execute(&mut *self.conn).await?;
128        Ok(())
129    }
130}
131
132/// One migration: a stable name, an `up`, and an optional `down`.
133#[async_trait(?Send)]
134pub trait Migration: Send + Sync {
135    /// The stable name recorded in the tracking table. Never change it once the
136    /// migration has shipped.
137    fn name(&self) -> &str;
138
139    /// Applies the migration.
140    async fn up(&self, schema: &mut Schema<'_>) -> CoreResult<()>;
141
142    /// Reverses the migration. The default marks it irreversible; the runner
143    /// fills in the module id. Override to make a migration reversible.
144    async fn down(&self, _schema: &mut Schema<'_>) -> CoreResult<()> {
145        Err(CoreError::Irreversible {
146            module: String::new(),
147            name: self.name().to_string(),
148        })
149    }
150}
151
152/// A migration whose up and down are raw SQL strings. `down` is optional; its
153/// absence makes the migration irreversible.
154pub struct SqlMigration {
155    name: String,
156    up: String,
157    down: Option<String>,
158}
159
160impl SqlMigration {
161    pub fn new(name: impl Into<String>, up: impl Into<String>) -> Self {
162        Self {
163            name: name.into(),
164            up: up.into(),
165            down: None,
166        }
167    }
168
169    pub fn reversible(mut self, down: impl Into<String>) -> Self {
170        self.down = Some(down.into());
171        self
172    }
173}
174
175#[async_trait(?Send)]
176impl Migration for SqlMigration {
177    fn name(&self) -> &str {
178        &self.name
179    }
180
181    async fn up(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
182        schema.raw(&self.up).await
183    }
184
185    async fn down(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
186        match &self.down {
187            Some(sql) => schema.raw(sql).await,
188            None => Err(CoreError::Irreversible {
189                module: String::new(),
190                name: self.name.clone(),
191            }),
192        }
193    }
194}
195
196/// A module's ordered migrations: the version history for `module_id`.
197pub struct MigrationSet {
198    pub module_id: &'static str,
199    pub migrations: Vec<Box<dyn Migration>>,
200}
201
202impl MigrationSet {
203    pub fn new(module_id: &'static str, migrations: Vec<Box<dyn Migration>>) -> Self {
204        Self {
205            module_id,
206            migrations,
207        }
208    }
209}
210
211/// Declares a module's migration set from one-file migrations.
212///
213/// Each migration lives in its own file under the module's `migrations/`
214/// directory, named `m<name>.rs` and exposing a `pub struct Migration` that
215/// implements [`Migration`]. This macro, invoked in the directory's `mod.rs`,
216/// lists them in apply order: it declares each file as a submodule and generates
217/// the module's `MODULE_ID` constant and its `migrations()` function returning the
218/// ordered [`MigrationSet`]. The list is the version history; append to its end,
219/// never reorder or rename a shipped entry.
220///
221/// The `m` prefix lets a name start with a digit (a bare `0001_...` is not a
222/// valid Rust identifier). Scaffold new entries with `lat make:migration`.
223///
224/// The `module_id` is the module's stable identity: a lowercase `vendor.package`
225/// code mirroring the crate name (crate `acme-blog`, id `acme.blog`). It
226/// namespaces applied-migration history, so keep it stable and never adopt
227/// another module's.
228///
229/// ```ignore
230/// // src/migrations/mod.rs
231/// laterite_core::migration_set! {
232///     module_id: "acme.blog",
233///     m0001_create_posts,
234///     m0002_create_comments,
235/// }
236/// ```
237#[macro_export]
238macro_rules! migration_set {
239    (module_id: $id:literal, $($m:ident,)*) => {
240        $( mod $m; )*
241
242        /// The stable migration namespace for this module. Never change it once
243        /// migrations have shipped: applied history is keyed on it.
244        pub const MODULE_ID: &str = $id;
245
246        /// This module's migrations, in apply order.
247        pub fn migrations() -> $crate::MigrationSet {
248            $crate::MigrationSet::new(
249                MODULE_ID,
250                ::std::vec![ $( ::std::boxed::Box::new($m::Migration), )* ],
251            )
252        }
253    };
254}
255
256#[derive(Iden)]
257enum LateriteMigrations {
258    Table,
259    ModuleId,
260    Name,
261}
262
263async fn ensure_tracking_table(pool: &AnyPool, backend: DbBackend) -> CoreResult<()> {
264    let stmt = Table::create()
265        .table(LateriteMigrations::Table)
266        .if_not_exists()
267        .col(
268            ColumnDef::new(LateriteMigrations::ModuleId)
269                .string_len(255)
270                .not_null(),
271        )
272        .col(
273            ColumnDef::new(LateriteMigrations::Name)
274                .string_len(255)
275                .not_null(),
276        )
277        .primary_key(
278            Index::create()
279                .col(LateriteMigrations::ModuleId)
280                .col(LateriteMigrations::Name),
281        )
282        .to_owned();
283    sqlx::query(&schema_sql(backend, &stmt))
284        .execute(pool)
285        .await?;
286    Ok(())
287}
288
289async fn is_applied(
290    pool: &AnyPool,
291    backend: DbBackend,
292    module_id: &str,
293    name: &str,
294) -> CoreResult<bool> {
295    let stmt = Query::select()
296        .column(LateriteMigrations::Name)
297        .from(LateriteMigrations::Table)
298        .and_where(Expr::col(LateriteMigrations::ModuleId).eq(module_id))
299        .and_where(Expr::col(LateriteMigrations::Name).eq(name))
300        .limit(1)
301        .to_owned();
302    let found: Option<String> = sqlx::query_scalar(&query_sql(backend, &stmt))
303        .fetch_optional(pool)
304        .await?;
305    Ok(found.is_some())
306}
307
308/// The names applied for a module, in application order.
309pub async fn applied(
310    pool: &AnyPool,
311    backend: DbBackend,
312    module_id: &str,
313) -> CoreResult<Vec<String>> {
314    ensure_tracking_table(pool, backend).await?;
315    let stmt = Query::select()
316        .column(LateriteMigrations::Name)
317        .from(LateriteMigrations::Table)
318        .and_where(Expr::col(LateriteMigrations::ModuleId).eq(module_id))
319        .order_by(LateriteMigrations::Name, sea_query::Order::Asc)
320        .to_owned();
321    let names: Vec<String> = sqlx::query_scalar(&query_sql(backend, &stmt))
322        .fetch_all(pool)
323        .await?;
324    Ok(names)
325}
326
327/// Applies every pending migration across the given sets, in listed order and,
328/// within a set, in declared order. Each migration runs in its own transaction.
329pub async fn run(pool: &AnyPool, backend: DbBackend, sets: &[MigrationSet]) -> CoreResult<()> {
330    ensure_tracking_table(pool, backend).await?;
331    for set in sets {
332        for migration in &set.migrations {
333            if is_applied(pool, backend, set.module_id, migration.name()).await? {
334                continue;
335            }
336            let mut tx = pool.begin().await?;
337            {
338                let mut schema = Schema {
339                    conn: &mut tx,
340                    backend,
341                };
342                migration.up(&mut schema).await?;
343            }
344            let insert = Query::insert()
345                .into_table(LateriteMigrations::Table)
346                .columns([LateriteMigrations::ModuleId, LateriteMigrations::Name])
347                .values_panic([set.module_id.into(), migration.name().into()])
348                .to_owned();
349            sqlx::query(&query_sql(backend, &insert))
350                .execute(&mut *tx)
351                .await?;
352            tx.commit().await?;
353        }
354    }
355    Ok(())
356}
357
358/// Reverses the last `steps` applied migrations of one module, most recent
359/// first. An irreversible migration in the way stops the rollback.
360pub async fn rollback(
361    pool: &AnyPool,
362    backend: DbBackend,
363    set: &MigrationSet,
364    steps: usize,
365) -> CoreResult<()> {
366    ensure_tracking_table(pool, backend).await?;
367    let mut done = 0;
368    for migration in set.migrations.iter().rev() {
369        if done >= steps {
370            break;
371        }
372        if !is_applied(pool, backend, set.module_id, migration.name()).await? {
373            continue;
374        }
375        let mut tx = pool.begin().await?;
376        {
377            let mut schema = Schema {
378                conn: &mut tx,
379                backend,
380            };
381            migration.down(&mut schema).await.map_err(|e| match e {
382                CoreError::Irreversible { name, .. } => CoreError::Irreversible {
383                    module: set.module_id.to_string(),
384                    name,
385                },
386                other => other,
387            })?;
388        }
389        let delete = Query::delete()
390            .from_table(LateriteMigrations::Table)
391            .and_where(Expr::col(LateriteMigrations::ModuleId).eq(set.module_id))
392            .and_where(Expr::col(LateriteMigrations::Name).eq(migration.name()))
393            .to_owned();
394        sqlx::query(&query_sql(backend, &delete))
395            .execute(&mut *tx)
396            .await?;
397        tx.commit().await?;
398        done += 1;
399    }
400    Ok(())
401}
402
403/// Reverses every applied migration of one module.
404pub async fn reset(pool: &AnyPool, backend: DbBackend, set: &MigrationSet) -> CoreResult<()> {
405    rollback(pool, backend, set, set.migrations.len()).await
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[derive(Iden)]
413    enum Demo {
414        Table,
415        Id,
416    }
417
418    struct CreateDemo;
419
420    #[async_trait(?Send)]
421    impl Migration for CreateDemo {
422        fn name(&self) -> &str {
423            "0001_create_demo"
424        }
425        async fn up(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
426            schema
427                .exec(
428                    Table::create()
429                        .table(Demo::Table)
430                        .if_not_exists()
431                        .col(ColumnDef::new(Demo::Id).integer().not_null())
432                        .to_owned(),
433                )
434                .await
435        }
436        async fn down(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
437            schema
438                .exec(Table::drop().table(Demo::Table).to_owned())
439                .await
440        }
441    }
442
443    async fn sqlite_pool() -> AnyPool {
444        sqlx::any::install_default_drivers();
445        sqlx::any::AnyPoolOptions::new()
446            .max_connections(1)
447            .connect("sqlite::memory:")
448            .await
449            .unwrap()
450    }
451
452    #[tokio::test]
453    async fn applies_and_rolls_back_on_sqlite() {
454        let pool = sqlite_pool().await;
455        let backend = DbBackend::Sqlite;
456        let set = MigrationSet::new("test.demo", vec![Box::new(CreateDemo)]);
457
458        run(&pool, backend, std::slice::from_ref(&set))
459            .await
460            .unwrap();
461        // The table exists and rerunning is a no-op.
462        run(&pool, backend, std::slice::from_ref(&set))
463            .await
464            .unwrap();
465        sqlx::query("insert into demo (id) values (1)")
466            .execute(&pool)
467            .await
468            .unwrap();
469        assert_eq!(applied(&pool, backend, "test.demo").await.unwrap().len(), 1);
470
471        reset(&pool, backend, &set).await.unwrap();
472        // The table is gone and the tracking row is cleared.
473        assert!(sqlx::query("select count(*) from demo")
474            .fetch_one(&pool)
475            .await
476            .is_err());
477        assert!(applied(&pool, backend, "test.demo")
478            .await
479            .unwrap()
480            .is_empty());
481    }
482
483    #[tokio::test]
484    async fn irreversible_migration_reports_module_and_name() {
485        let pool = sqlite_pool().await;
486        let backend = DbBackend::Sqlite;
487        let set = MigrationSet::new(
488            "test.oneway",
489            vec![Box::new(SqlMigration::new(
490                "0001_make_t",
491                "create table t (id integer not null)",
492            ))],
493        );
494        run(&pool, backend, std::slice::from_ref(&set))
495            .await
496            .unwrap();
497        let err = rollback(&pool, backend, &set, 1).await.unwrap_err();
498        match err {
499            CoreError::Irreversible { module, name } => {
500                assert_eq!(module, "test.oneway");
501                assert_eq!(name, "0001_make_t");
502            }
503            other => panic!("expected Irreversible, got {other:?}"),
504        }
505    }
506}