Migrations

Migrations

On this page

Migrations are ordered, named, and idempotent. Each runs at most once, tracked in a rainier_migrations table, so migrate is safe to run on every boot.

Every migration declares two things: what it does, and how to undo it.

// src/database/migrations/mod.rs
use rainier_framework::database::Migrator;

pub fn all() -> Migrator {
    Migrator::new()
        .add(m0001_create_users::migration())
        .add(m0002_create_posts::migration())
        .add(m0003_index_posts_author::migration())
}

Rust does not autoload, so no directory scan discovers migrations: the order lives in all() and each migration lives in its own module:

src/database/migrations/
  mod.rs                        the ordered list
  m0001_create_users.rs         create_table, from the model's metadata
  m0002_create_posts.rs         …and a foreign key
  m0003_index_posts_author.rs   an index, from the builder
  m0004_add_post_search.rs      the exception: SQL that differs per engine
  m0005_normalise_emails.rs     a data migration that cannot be undone
  m0006_create_tags.rs          another table from a model
  m0007_create_post_tag.rs      a pivot: a table with no model
  m0008_posts_add_excerpt.rs    an alter, with a derived rollback

Each module exposes one pub fn migration() -> Step, so the name, the up and the down sit together and the running order is one file you can read. The m prefix is there because a Rust module cannot start with a digit.

For a small application the whole thing fits in one migrations.rs with chained builder calls; the split earns its keep once a migration has a #[cfg(test)] block of its own, which the interesting ones do.

cargo run -- migrate
cargo run -- migrate --pretend             # list what would run
cargo run -- migrate:rollback              # undo the last batch
cargo run -- migrate:rollback --pretend    # list what would be undone

The contract

Migration is a trait with three methods and no defaults:

pub trait Migration: Send + Sync + 'static {
    fn name(&self) -> &str;
    fn up(&self, dialect: Dialect) -> Vec<String>;
    fn down(&self, dialect: Dialect) -> Down;
}
pub enum Down {
    Statements(Vec<String>),
    Irreversible(String),   // …and why
}

The builder methods (create_table, raw, step) produce a Step, which is that trait implemented over a pair of closures. Implement the trait directly when a migration deserves a name and a file of its own:

pub struct BackfillSlugs;

impl Migration for BackfillSlugs {
    fn name(&self) -> &str {
        "0004_backfill_slugs"
    }

    fn up(&self, _: Dialect) -> Vec<String> {
        vec!["UPDATE posts SET slug = lower(title) WHERE slug IS NULL".into()]
    }

    fn down(&self, _: Dialect) -> Down {
        Down::irreversible("the original NULLs are not recoverable")
    }
}
Migrator::new().add(BackfillSlugs)

Why down is required

An optional down is a down that is usually missing — and by the time you need one, the migration that lacks it is months old and nobody remembers what it changed.

Requiring it costs one line, and forces the question at the only moment it is easy to answer: while you are writing the up and the schema is in your head.

The escape hatch is not an empty vector. It is Down::irreversible(reason), because "this cannot be undone" and "nobody wrote a down step" are different facts and only one of them is a bug:

An empty downDown::irreversible
A rollbacksucceeds, silently doing nothingrefuses, and prints the reason
Six months laterindistinguishable from an oversightsays which it was, and why
.raw_irreversible(
    "0005_drop_legacy_column",
    vec!["ALTER TABLE users DROP COLUMN legacy_token".into()],
    "the column's contents are gone; restore from a backup",
)

List them before a deploy — this is exactly the set a rollback will refuse:

migrator.irreversible(db.dialect());     // ["0005_drop_legacy_column"]

migrate prints the same list when a run applies one, so you learn about it at deploy time rather than at rollback time.

Names are permanent

Migrations run in the order listed and never re-run. A name is the identity of an applied migration, so renaming one makes it run again.

Treat an applied name as permanent. The numeric prefix is a convention that makes the order visible in the file, not something the framework parses.

DDL from the model

.create_table::<Post>("0002_create_posts")

create_table renders the DDL from the model's own #[orm] metadata, for the connection's dialect. The schema cannot drift from the struct that defines it — add a column to the struct and the table it creates has that column.

flowchart LR
    A["#[derive(Entity)]<br/>struct Post { … }"] --> B[entity metadata:<br/>table, columns, keys,<br/>indexes, foreign keys]
    B --> C{dialect}
    C -->|Sqlite| D[SQLite DDL]
    C -->|MySql| E[MySQL DDL]
    C -->|Postgres| F[Postgres DDL]

Its down is the matching DROP TABLE IF EXISTS, which you get without writing it. That is the honest inverse — and also why rolling back a create_table in production destroys data. The operation is destructive, not the implementation.

To see what it produces:

use rainier_framework::database::schema;

let ddl = schema::schema_ddl::<Post>(Dialect::Sqlite).join("\n");
assert!(ddl.contains("posts"));
assert!(ddl.contains("users"), "the foreign key should be declared");

That assertion in a test is worth writing: it catches a model change that would have altered the schema.

create_table is the right tool when a model describes the table. For anything else — a pivot, a join table, a table another system owns — and for changing a table that already exists, reach for the builder: the entity only describes the current shape, never the diff from the last one.

The schema builder

For a table no model describes — a pivot, a join table, something another system reads — describe it and let the builder render it:

Step::create("0007_create_post_tag", "post_tag", |table| {
    table.foreign_id("post_id").constrained_on("posts").cascade_on_delete();
    table.foreign_id("tag_id").constrained_on("tags").cascade_on_delete();

    table.primary(["post_id", "tag_id"]);
})

A schema builder. There is no SQL in it, and that is the point: the three engines disagree about nearly every line of a CREATE TABLE, and reconciling them is the job you are running a DBAL to avoid.

SQLiteMySQLPostgres
Auto-increment keyINTEGER PRIMARY KEY AUTOINCREMENTBIGINT AUTO_INCREMENTbigserial
Bytesblobblobbytea
JSONtextjsonjsonb
Fixed-point(no such type — real)decimal(p,s)decimal(p,s)
CREATE INDEX IF NOT EXISTSyesrejectedyes
Dropping an indexDROP INDEX iDROP INDEX i ON tDROP INDEX i
Renaming a tableALTER TABLE … RENAME TORENAME TABLEALTER TABLE … RENAME TO

Every row of that is a way a hand-written migration works on your laptop and fails in CI.

Columns

table.id();                              // big auto-increment `id`
table.big_increments("uid");
table.string("title");                   // VARCHAR(255)
table.string_len("excerpt", 500);
table.text("body");                      // unbounded — not indexable on MySQL
table.integer("hits");
table.big_integer("bytes");
table.unsigned_big_integer("size");
table.boolean("published");
table.double("ratio");
table.decimal("price", 8, 2);
table.timestamp("published_at");
table.date("born_on");
table.binary("thumbnail");
table.json("meta");

table.timestamps();                      // created_at + updated_at, nullable
table.soft_deletes();                    // deleted_at, nullable

Modifiers chain off any of them:

table.string("email").unique();
table.text("bio").nullable();
table.integer("logins").default(0);
table.string("slug").index();

Columns are NOT NULL unless they say otherwise. SQL's default is the other way round, and it is the wrong way round: nullability should be a decision somebody made rather than one they got by omission.

Keys and indexes

table.primary(["post_id", "tag_id"]);        // composite
table.index(["published", "created_at"]);
table.unique(["team_id", "slug"]);

table.foreign_id("author_id").constrained_on("users").cascade_on_delete();
table.foreign_id("editor_id").nullable().constrained_on("users").null_on_delete();

foreign_id is an unsigned 64-bit column and an index, which is what you almost always want — a foreign key you never query by is unusual.

Indexes are named {table}_{columns}_{kind}: posts_author_id_index, users_email_unique. Predictable rather than clever, because a rollback has to name the index it is dropping without being told. Override with .named("…") where an existing schema disagrees.

Altering a table

Step::table("0008_posts_add_excerpt", "posts", |table| {
    table.string_len("excerpt", 500).nullable();
    table.index(["author_id", "created_at"]);
})

A column added to a table that already holds rows must be nullable() or carry a default() — the existing rows have no value for it otherwise, and every engine refuses. The builder cannot know whether your table is empty, so it does not guess.

Also available: rename_column(from, to), drop_column(name), drop_index(columns), drop_unique(columns).

The rollback is derived

This is the part worth the whole feature. Step::table computes its own down from what changed:

ChangeReversal
add a columndrop it
create an indexdrop it
rename a columnrename it back
drop an indexcreate it again

In reverse order, so an index over a column goes before the column does.

A hand-written down is the half of a migration that goes stale first: add a second column six months later, forget the matching DROP COLUMN, and the rollback silently leaves it behind. A derived one cannot disagree with the up, because there is only one description.

Two changes genuinely cannot be undone, and say so rather than pretending: drop_column, because the type and the data are gone; and raw with no reverse, because nothing knows what it did. Both make the step irreversible with a reason naming the change, and the rollback refuses before it starts.

Other whole-table operations

Step::drop_table("0009_drop_legacy", "legacy_imports")   // irreversible
Step::rename_table("0010_rename", "posts", "articles")   // reverses itself

drop_table is irreversible on purpose: nothing here knows the shape of what it dropped, and a down that recreated an empty table would report success having restored nothing.

Raw SQL

The escape hatch, and it should feel like one. Before reaching for it, check that the builder cannot say what you mean — a migration with SQL in it is a migration that stops being portable.

Step::raw(
    "0005_normalise_emails",
    vec!["UPDATE users SET email = LOWER(email)".into()],
    vec![],
)

Legitimate here: this is data, not schema. UPDATE, backfills and one-off corrections have no schema builder to express them and are the same SQL everywhere.

Several statements in one migration run in order — and so do the down statements, so write those in the order they need to happen rather than as a mirror of the up.

A step per dialect

Sometimes the engines are not spelling one feature three ways; they have three different features. Full-text search is the honest example — a GIN index over a tsvector, an FTS5 virtual table, and a FULLTEXT index are not the same object. There is nothing to translate to, so the migration answers per dialect:

Step::new(
    "0004_add_search",
    |dialect| match dialect {
        Dialect::Postgres => vec![
            "CREATE INDEX posts_search ON posts USING gin (to_tsvector('english', body))".into(),
        ],
        Dialect::Sqlite => vec![
            "CREATE VIRTUAL TABLE posts_fts USING fts5(title, body)".into(),
        ],
        Dialect::MySql => vec![
            "CREATE FULLTEXT INDEX posts_search ON posts (title, body)".into(),
        ],
    },
    |dialect| match dialect {
        Dialect::Postgres => Down::statements(["DROP INDEX posts_search".to_string()]),
        Dialect::Sqlite => Down::statements(["DROP TABLE posts_fts".to_string()]),
        Dialect::MySql => Down::statements(["DROP INDEX posts_search ON posts".to_string()]),
    },
)

Match exhaustively, without a _ arm. A dialect added to the framework should break this and make you decide, rather than silently leaving one backend with no index until someone notices a slow query.

Returning an empty vector from up is a legal no-op for a backend that genuinely needs nothing, and the matching down is Down::statements([]) — not Down::irreversible, which is a refusal rather than a nothing.

Running them

Through the command:

cargo run -- migrate
cargo run -- migrate --pretend

Or in a provider's boot, which is what makes a fresh clone work with no setup step:

boot_provider!(async |self, app| {
    let migrator = app.resolve::<Migrator>()?;
    let database = app.resolve::<Database>()?;
    let applied = migrator.run(&database).await?;
    if !applied.is_empty() {
        tracing::info!(?applied, "migrations applied");
    }
    Ok(())
});

run returns the names it applied — empty when everything was already done.

migrator.applied(&db).await?;    // what has run
migrator.pending(&db).await?;    // what has not
migrator.names();                // what exists
migrator.len();
migrator.is_empty();

Batches

Everything one migrate applies shares a batch number, recorded next to the name. A batch is the unit a rollback takes back off — it is "the last deploy", not "the last migration".

flowchart TD
    subgraph ledger ["rainier_migrations"]
        R1["0001_create_users&nbsp;&nbsp;&nbsp;batch 1"]
        R2["0002_create_posts&nbsp;&nbsp;&nbsp;batch 1"]
        R3["0003_index_posts&nbsp;&nbsp;&nbsp;&nbsp;batch 2"]
        R4["0004_add_search&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;batch 2"]
    end

    ROLL["migrate:rollback"] --> R4
    R4 --> R3
    R3 --> STOP["stops — batch 1 is a<br/>separate deploy"]

    style STOP fill:#353,stroke:#6a6,color:#fff
cargo run -- migrate:rollback                # the last batch
cargo run -- migrate:rollback --batches=3    # the last three

Within a batch, steps are undone in reverse declaration order. That matters: dropping the table a foreign key points at before dropping the key fails on every backend that enforces them.

A rollback refuses before it starts

Both of these are checked across the whole range before any statement runs:

  • a step in range declared itself irreversible
  • a step in range is in the ledger but no longer in the migrator — usually because it was deleted from the code while still applied

Either one aborts the rollback with the name in the message and nothing executed. A half-rolled-back batch leaves the schema in a state no migration describes, which is harder to recover from than not having started.

Error: `0005_drop_legacy_column` cannot be rolled back: the column's contents
are gone; restore from a backup

migrate:rollback exits non-zero on that, so a deploy script that chains a rollback stops rather than carrying on against a schema it did not change.

Roll forward when you can

Rollback exists because sometimes you need it at three in the morning. It is still usually the second-best answer.

Rolling forward — a new migration that undoes what the last one did — runs through the same path everything else does, is tested by the same deploy, and leaves a history that reads as what actually happened. A down written months ago has been tested by nothing.

Use rollback in development, and in the narrow production case where the last deploy is minutes old and you know exactly what it changed.

Migrations for the queue and sessions

The database queue needs two tables, and the database session store one. They come as migrators you merge in:

use rainier_framework::queue::DatabaseQueue;
use rainier_framework::session::DatabaseSessionStore;

pub fn all() -> Migrator {
    Migrator::new()
        .create_table::<User>("0001_create_users")
        .create_table::<Post>("0002_create_posts")
        .merge(DatabaseQueue::migrations())
        .merge(DatabaseSessionStore::migrations())
}

merge appends another migrator's steps at the point you merge them in, down steps and all. That is how a component owning tables contributes them to the application's one migrator — which matters because migrate resolves a single Migrator from the container and would have no way to find a second one.

Rainier's migrator versus Rainier ORM's

Rainier ORM ships its own Migrator. Rainier has a separate one for a specific reason: Rainier ORM's boxes each step behind dyn, which erases auto traits, so its run future cannot be Send on stable.

The bound cannot simply be added — CreateTable implements Migration<X> for every X: Executor, so it would have to promise a Send future for every executor, which is unknowable generically without return-type notation (still unstable).

rainier_database::Migrator renders both directions synchronously and executes plain strings, so its future is Send and it can run inside a spawned task — which is where a provider's boot runs. That is also why up and down take a Dialect and return Vec<String> rather than taking a connection: a migration cannot query the database to decide what to do, and does not need to. See the Send story.

Testing them

Two assertions worth having, both cheap:

#[test]
fn every_migration_can_be_rolled_back() {
    // The day you need a rollback is not the day to discover a step opted out.
    assert!(all().irreversible(Dialect::Sqlite).is_empty());
}

#[tokio::test]
async fn the_schema_survives_a_round_trip() {
    let db = test_database().await;

    all().run(&db).await?;
    all().rollback(&db, 1).await?;
    all().run(&db).await?;      // fails if a `down` left something behind
}

The second is the one that catches a wrong down, because a down that forgets an index makes the next up fail on the duplicate.

Seeding

Seeders are ordinary code, not a framework concept:

// src/database/seeders.rs
pub async fn run(app: &Application, fresh: bool) -> Result<()> {
    let users = app.resolve::<UserRepository>()?;
    let posts = app.resolve::<PostRepository>()?;

    if fresh {
        posts.delete_matching(Criteria::new()).await?;
        users.delete_matching(Criteria::new()).await?;
    }

    let ada = users.create(User::new("[email protected]", "Ada", &hasher)).await?;
    posts.create_unique(Post::draft("Hello", "…", ada.id)).await?;

    Ok(())
}

Driven by a console command:

cargo run -- app:seed --fresh

Note that a seeder usually wants a repository without a dispatcher, so it does not fire hooks and queue a hundred welcome emails.

This page is the framework’s docs/migrations.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.