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
52/// A portable boolean column, stored as a 0/1 integer. Use this in a migration
53/// instead of `ColumnDef::new(name).boolean()`: `sqlx::Any` cannot decode a
54/// SQLite `boolean` column, so integer is the representation that works on every
55/// backend. Bind and read the value as a normal `bool` through the query layer
56/// (see `crate::query`). Chain `.not_null()`, `.default(0)`, and so on as usual.
57pub fn bool_col<T: sea_query::IntoIden>(name: T) -> ColumnDef {
58    ColumnDef::new(name).integer().to_owned()
59}
60
61/// The length of a [`key_col`], generous enough for UUIDs, codes, usernames,
62/// emails, and token hashes while staying within MySQL's index-length limit.
63pub const KEY_LEN: u32 = 255;
64
65/// A portable key column: a bounded `varchar` rather than `text`. Use this in a
66/// migration for any column that participates in a key, index, or foreign key
67/// (ids, codes, tokens, and columns named in an `Index`): MySQL cannot index a
68/// `text` column without a prefix length, so a plain `text` id or unique column
69/// fails there. A bounded string keys cleanly on every backend. It holds the
70/// same UTF-8 strings a `text` column would, so application code is unchanged.
71/// Chain `.not_null()`, `.primary_key()`, `.unique_key()`, and so on as usual.
72pub fn key_col<T: sea_query::IntoIden>(name: T) -> ColumnDef {
73    ColumnDef::new(name).string_len(KEY_LEN).to_owned()
74}
75
76fn schema_sql<S: SchemaStatementBuilder>(backend: DbBackend, stmt: &S) -> String {
77    match backend {
78        DbBackend::Postgres => stmt.build(PostgresQueryBuilder),
79        DbBackend::Mysql => stmt.build(MysqlQueryBuilder),
80        DbBackend::Sqlite => stmt.build(SqliteQueryBuilder),
81    }
82}
83
84fn query_sql<Q: QueryStatementWriter>(backend: DbBackend, stmt: &Q) -> String {
85    match backend {
86        DbBackend::Postgres => stmt.to_string(PostgresQueryBuilder),
87        DbBackend::Mysql => stmt.to_string(MysqlQueryBuilder),
88        DbBackend::Sqlite => stmt.to_string(SqliteQueryBuilder),
89    }
90}
91
92/// The handle a migration uses to change the schema. Renders `sea-query`
93/// statements for the running backend, with a raw-SQL escape hatch.
94pub struct Schema<'c> {
95    conn: &'c mut AnyConnection,
96    backend: DbBackend,
97}
98
99impl Schema<'_> {
100    pub fn backend(&self) -> DbBackend {
101        self.backend
102    }
103
104    /// Runs a `sea-query` schema statement (`Table::create/alter/drop`,
105    /// `Index::create`, ...). Takes the statement by value and renders it to SQL
106    /// before awaiting, so the (non-`Send`) statement is not held across the
107    /// await point.
108    pub async fn exec<S: SchemaStatementBuilder>(&mut self, stmt: S) -> CoreResult<()> {
109        let sql = schema_sql(self.backend, &stmt);
110        drop(stmt);
111        sqlx::query(&sql).execute(&mut *self.conn).await?;
112        Ok(())
113    }
114
115    /// Runs raw SQL, for anything the builder does not express. Portability is
116    /// the caller's responsibility.
117    pub async fn raw(&mut self, sql: &str) -> CoreResult<()> {
118        sqlx::query(sql).execute(&mut *self.conn).await?;
119        Ok(())
120    }
121}
122
123/// One migration: a stable name, an `up`, and an optional `down`.
124#[async_trait(?Send)]
125pub trait Migration: Send + Sync {
126    /// The stable name recorded in the tracking table. Never change it once the
127    /// migration has shipped.
128    fn name(&self) -> &str;
129
130    /// Applies the migration.
131    async fn up(&self, schema: &mut Schema<'_>) -> CoreResult<()>;
132
133    /// Reverses the migration. The default marks it irreversible; the runner
134    /// fills in the module id. Override to make a migration reversible.
135    async fn down(&self, _schema: &mut Schema<'_>) -> CoreResult<()> {
136        Err(CoreError::Irreversible {
137            module: String::new(),
138            name: self.name().to_string(),
139        })
140    }
141}
142
143/// A migration whose up and down are raw SQL strings. `down` is optional; its
144/// absence makes the migration irreversible.
145pub struct SqlMigration {
146    name: String,
147    up: String,
148    down: Option<String>,
149}
150
151impl SqlMigration {
152    pub fn new(name: impl Into<String>, up: impl Into<String>) -> Self {
153        Self {
154            name: name.into(),
155            up: up.into(),
156            down: None,
157        }
158    }
159
160    pub fn reversible(mut self, down: impl Into<String>) -> Self {
161        self.down = Some(down.into());
162        self
163    }
164}
165
166#[async_trait(?Send)]
167impl Migration for SqlMigration {
168    fn name(&self) -> &str {
169        &self.name
170    }
171
172    async fn up(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
173        schema.raw(&self.up).await
174    }
175
176    async fn down(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
177        match &self.down {
178            Some(sql) => schema.raw(sql).await,
179            None => Err(CoreError::Irreversible {
180                module: String::new(),
181                name: self.name.clone(),
182            }),
183        }
184    }
185}
186
187/// A module's ordered migrations: the version history for `module_id`.
188pub struct MigrationSet {
189    pub module_id: &'static str,
190    pub migrations: Vec<Box<dyn Migration>>,
191}
192
193impl MigrationSet {
194    pub fn new(module_id: &'static str, migrations: Vec<Box<dyn Migration>>) -> Self {
195        Self {
196            module_id,
197            migrations,
198        }
199    }
200}
201
202#[derive(Iden)]
203enum LateriteMigrations {
204    Table,
205    ModuleId,
206    Name,
207}
208
209async fn ensure_tracking_table(pool: &AnyPool, backend: DbBackend) -> CoreResult<()> {
210    let stmt = Table::create()
211        .table(LateriteMigrations::Table)
212        .if_not_exists()
213        .col(
214            ColumnDef::new(LateriteMigrations::ModuleId)
215                .string_len(255)
216                .not_null(),
217        )
218        .col(
219            ColumnDef::new(LateriteMigrations::Name)
220                .string_len(255)
221                .not_null(),
222        )
223        .primary_key(
224            Index::create()
225                .col(LateriteMigrations::ModuleId)
226                .col(LateriteMigrations::Name),
227        )
228        .to_owned();
229    sqlx::query(&schema_sql(backend, &stmt))
230        .execute(pool)
231        .await?;
232    Ok(())
233}
234
235async fn is_applied(
236    pool: &AnyPool,
237    backend: DbBackend,
238    module_id: &str,
239    name: &str,
240) -> CoreResult<bool> {
241    let stmt = Query::select()
242        .column(LateriteMigrations::Name)
243        .from(LateriteMigrations::Table)
244        .and_where(Expr::col(LateriteMigrations::ModuleId).eq(module_id))
245        .and_where(Expr::col(LateriteMigrations::Name).eq(name))
246        .limit(1)
247        .to_owned();
248    let found: Option<String> = sqlx::query_scalar(&query_sql(backend, &stmt))
249        .fetch_optional(pool)
250        .await?;
251    Ok(found.is_some())
252}
253
254/// The names applied for a module, in application order.
255pub async fn applied(
256    pool: &AnyPool,
257    backend: DbBackend,
258    module_id: &str,
259) -> CoreResult<Vec<String>> {
260    ensure_tracking_table(pool, backend).await?;
261    let stmt = Query::select()
262        .column(LateriteMigrations::Name)
263        .from(LateriteMigrations::Table)
264        .and_where(Expr::col(LateriteMigrations::ModuleId).eq(module_id))
265        .order_by(LateriteMigrations::Name, sea_query::Order::Asc)
266        .to_owned();
267    let names: Vec<String> = sqlx::query_scalar(&query_sql(backend, &stmt))
268        .fetch_all(pool)
269        .await?;
270    Ok(names)
271}
272
273/// Applies every pending migration across the given sets, in listed order and,
274/// within a set, in declared order. Each migration runs in its own transaction.
275pub async fn run(pool: &AnyPool, backend: DbBackend, sets: &[MigrationSet]) -> CoreResult<()> {
276    ensure_tracking_table(pool, backend).await?;
277    for set in sets {
278        for migration in &set.migrations {
279            if is_applied(pool, backend, set.module_id, migration.name()).await? {
280                continue;
281            }
282            let mut tx = pool.begin().await?;
283            {
284                let mut schema = Schema {
285                    conn: &mut tx,
286                    backend,
287                };
288                migration.up(&mut schema).await?;
289            }
290            let insert = Query::insert()
291                .into_table(LateriteMigrations::Table)
292                .columns([LateriteMigrations::ModuleId, LateriteMigrations::Name])
293                .values_panic([set.module_id.into(), migration.name().into()])
294                .to_owned();
295            sqlx::query(&query_sql(backend, &insert))
296                .execute(&mut *tx)
297                .await?;
298            tx.commit().await?;
299        }
300    }
301    Ok(())
302}
303
304/// Reverses the last `steps` applied migrations of one module, most recent
305/// first. An irreversible migration in the way stops the rollback.
306pub async fn rollback(
307    pool: &AnyPool,
308    backend: DbBackend,
309    set: &MigrationSet,
310    steps: usize,
311) -> CoreResult<()> {
312    ensure_tracking_table(pool, backend).await?;
313    let mut done = 0;
314    for migration in set.migrations.iter().rev() {
315        if done >= steps {
316            break;
317        }
318        if !is_applied(pool, backend, set.module_id, migration.name()).await? {
319            continue;
320        }
321        let mut tx = pool.begin().await?;
322        {
323            let mut schema = Schema {
324                conn: &mut tx,
325                backend,
326            };
327            migration.down(&mut schema).await.map_err(|e| match e {
328                CoreError::Irreversible { name, .. } => CoreError::Irreversible {
329                    module: set.module_id.to_string(),
330                    name,
331                },
332                other => other,
333            })?;
334        }
335        let delete = Query::delete()
336            .from_table(LateriteMigrations::Table)
337            .and_where(Expr::col(LateriteMigrations::ModuleId).eq(set.module_id))
338            .and_where(Expr::col(LateriteMigrations::Name).eq(migration.name()))
339            .to_owned();
340        sqlx::query(&query_sql(backend, &delete))
341            .execute(&mut *tx)
342            .await?;
343        tx.commit().await?;
344        done += 1;
345    }
346    Ok(())
347}
348
349/// Reverses every applied migration of one module.
350pub async fn reset(pool: &AnyPool, backend: DbBackend, set: &MigrationSet) -> CoreResult<()> {
351    rollback(pool, backend, set, set.migrations.len()).await
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[derive(Iden)]
359    enum Demo {
360        Table,
361        Id,
362    }
363
364    struct CreateDemo;
365
366    #[async_trait(?Send)]
367    impl Migration for CreateDemo {
368        fn name(&self) -> &str {
369            "0001_create_demo"
370        }
371        async fn up(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
372            schema
373                .exec(
374                    Table::create()
375                        .table(Demo::Table)
376                        .if_not_exists()
377                        .col(ColumnDef::new(Demo::Id).integer().not_null())
378                        .to_owned(),
379                )
380                .await
381        }
382        async fn down(&self, schema: &mut Schema<'_>) -> CoreResult<()> {
383            schema
384                .exec(Table::drop().table(Demo::Table).to_owned())
385                .await
386        }
387    }
388
389    async fn sqlite_pool() -> AnyPool {
390        sqlx::any::install_default_drivers();
391        sqlx::any::AnyPoolOptions::new()
392            .max_connections(1)
393            .connect("sqlite::memory:")
394            .await
395            .unwrap()
396    }
397
398    #[tokio::test]
399    async fn applies_and_rolls_back_on_sqlite() {
400        let pool = sqlite_pool().await;
401        let backend = DbBackend::Sqlite;
402        let set = MigrationSet::new("test.demo", vec![Box::new(CreateDemo)]);
403
404        run(&pool, backend, std::slice::from_ref(&set))
405            .await
406            .unwrap();
407        // The table exists and rerunning is a no-op.
408        run(&pool, backend, std::slice::from_ref(&set))
409            .await
410            .unwrap();
411        sqlx::query("insert into demo (id) values (1)")
412            .execute(&pool)
413            .await
414            .unwrap();
415        assert_eq!(applied(&pool, backend, "test.demo").await.unwrap().len(), 1);
416
417        reset(&pool, backend, &set).await.unwrap();
418        // The table is gone and the tracking row is cleared.
419        assert!(sqlx::query("select count(*) from demo")
420            .fetch_one(&pool)
421            .await
422            .is_err());
423        assert!(applied(&pool, backend, "test.demo")
424            .await
425            .unwrap()
426            .is_empty());
427    }
428
429    #[tokio::test]
430    async fn irreversible_migration_reports_module_and_name() {
431        let pool = sqlite_pool().await;
432        let backend = DbBackend::Sqlite;
433        let set = MigrationSet::new(
434            "test.oneway",
435            vec![Box::new(SqlMigration::new(
436                "0001_make_t",
437                "create table t (id integer not null)",
438            ))],
439        );
440        run(&pool, backend, std::slice::from_ref(&set))
441            .await
442            .unwrap();
443        let err = rollback(&pool, backend, &set, 1).await.unwrap_err();
444        match err {
445            CoreError::Irreversible { module, name } => {
446                assert_eq!(module, "test.oneway");
447                assert_eq!(name, "0001_make_t");
448            }
449            other => panic!("expected Irreversible, got {other:?}"),
450        }
451    }
452}