v0.3 GitHub

Schema and migrations

Schema builder, Migrator, CLI

Schema builder

rust
use rustique::Schema;

Schema::create(&db, "users", |t| {
    t.id();
    t.string("email", 255).unique();
    t.string("name", 100);
    t.boolean("active").default(true);
    t.foreign_id("country_id").constrained("countries").cascade_on_delete();
    t.json("settings").nullable();
    t.timestamps();
    t.soft_deletes();
}).await?;

Schema::table(&db, "users", |t| {
    t.text("bio").nullable();
    t.rename_column("name", "full_name");
    t.drop_column("legacy");
}).await?;

Schema::drop(&db, "users").await?;
Schema::drop_if_exists(&db, "users").await?;
Schema::rename(&db, "users", "members").await?;
Schema::has_table(&db, "users").await?;            // bool
Schema::has_column(&db, "users", "email").await?;  // bool

Column types

MethodMySQLOracle
id()id BIGINT UNSIGNED AUTO_INCREMENT primary keyid NUMBER(19) GENERATED BY DEFAULT AS IDENTITY primary key
big_increments(n)as id(), named nas id(), named n
big_integer, integer, small_integerBIGINT, INT, SMALLINTNUMBER(19), NUMBER(10), NUMBER(5)
foreign_id(n)BIGINT UNSIGNEDNUMBER(19)
boolean(n)TINYINT(1)NUMBER(1) + CHECK (n IN (0, 1))
string(n, len)VARCHAR(len)VARCHAR2(len CHAR)
char(n, len)CHAR(len)CHAR(len CHAR)
text(n), long_text(n)TEXT, LONGTEXTCLOB
double(n)DOUBLEBINARY_DOUBLE
decimal(n, p, s)DECIMAL(p, s)NUMBER(p, s)
date(n), datetime(n)DATE, DATETIME(6)DATE, TIMESTAMP(6)
time(n)TIME(6)VARCHAR2(18 CHAR): no time type, so HH:MM:SS[.f] text, read back as NaiveTime
json(n)JSONCLOB + CHECK (n IS JSON)
binary(n)LONGBLOBBLOB
uuid(n)CHAR(36)CHAR(36)
ulid(n)CHAR(26)CHAR(26 CHAR)
timestamps()nullable created_at, updated_at datetimessame
soft_deletes()nullable deleted_at datetimesame

Run Blueprint::create("t").to_sql(Dialect::MySql) to see the exact DDL.

Column modifiers

nullable(), default(value), default_raw("sql"), use_current(), use_current_on_update(), comment("text"), unsigned(), primary(), unique(), index(), constrained("table"), cascade_on_delete(), null_on_delete(), change().

rust
rustique::Schema::create(&db, "events", |t| {
    t.id();
    t.datetime("happened_at").use_current();          // UTC now, even with `rustique::set_timezone`
    t.string("code", 36).default_raw("(UUID())");     // SQL as written; MySQL needs parentheses around expressions
    t.string("name", 100).comment("shown in reports");
    t.comment("Audit events");                        // table comment
}).await
  • use_current() works on datetime and date columns: MySQL (UTC_TIMESTAMP(6)) / (UTC_DATE()), Oracle SYS_EXTRACT_UTC(SYSTIMESTAMP) (truncated for dates). Laravel’s CURRENT_TIMESTAMP would follow the session time zone instead.
  • use_current_on_update() works on datetime columns: MySQL ON UPDATE CURRENT_TIMESTAMP(6) (session time zone). Oracle has no ON UPDATE, so it is ignored there; models with timestamps set updated_at themselves.
  • default_raw is not portable; write it per database if they differ.
  • Comments: MySQL writes them into the column definition and ALTER TABLE ... COMMENT, Oracle runs COMMENT ON COLUMN / COMMENT ON TABLE.

Changing columns

In Schema::table, change() turns a column declaration into a change of the existing column, like Laravel’s ->change():

rust
rustique::Schema::table(&db, "users", |t| {
    t.string("name", 100).nullable().change();   // widen and allow NULL
    t.integer("votes").default(0).change();      // NOT NULL with a default
}).await

State the whole definition: a modifier you leave out is removed (no nullable() means NOT NULL, no default drops the default). unique() / index() / constrained() add a new index or key. MySQL runs ALTER TABLE ... MODIFY. Oracle runs a PL/SQL block that modifies type and default, then switches NULL / NOT NULL only when it differs, since Oracle rejects a no-op change. Primary keys and auto-increment columns cannot be changed this way, and Oracle cannot change some types in place (VARCHAR2 to CLOB, for example).

Indexes and foreign keys

rust
t.primary(&["order_no", "line_no"]);
t.unique(&["email", "tenant_id"]).name("users_email_tenant");
t.index(&["created_at"]);
t.foreign(&["order_no"], "orders", &["no"]).cascade_on_delete();

t.drop_index("users_email_unique");
t.drop_foreign("posts_user_id_foreign");

Default names follow Laravel: {table}_{columns}_{unique|index|foreign}.

Migrations

A migration is a module with up and down. Its name is the module name; migrations run in listed order.

rust
// src/migrations/m2026_10_10_000001_create_users_table.rs
use rustique::{Db, Schema};

pub async fn up(db: &Db) -> rustique::Result<()> {
    Schema::create(db, "users", |t| {
        t.id();
        t.string("email", 255).unique();
        t.timestamps();
    })
    .await
}

pub async fn down(db: &Db) -> rustique::Result<()> {
    Schema::drop(db, "users").await
}
rust
// src/migrations/mod.rs
mod m2026_10_10_000001_create_users_table;

pub fn all() -> Vec<rustique::Migration> {
    rustique::migrations![m2026_10_10_000001_create_users_table]
}
rust
let migrator = rustique::Migrator::new(migrations::all());
migrator.migrate(&db).await?;      // names that ran
migrator.rollback(&db).await?;     // rolls back the last batch
migrator.rollback_steps(&db, 2).await?;   // the last 2 migrations, across batches (--step=2)
migrator.reset(&db).await?;        // rolls back everything
migrator.fresh(&db).await?;        // drops every table, then migrates; `down` does not run
migrator.status(&db).await?;       // Vec<MigrationStatus { name, batch }>

fresh drops every table of the database (MySQL) or schema (Oracle), not only those the migrations created, through Schema::drop_all_tables; views and Oracle sequences stay. It is for development and tests.

Seeders

Register one seeder function, Laravel’s DatabaseSeeder; it calls whatever else should run, typically factories:

rust
use rustique::{BoxFuture, Db, Migrator};

fn seed(db: &Db) -> BoxFuture<'_, rustique::Result<()>> {
    Box::pin(async move {
        db.statement("INSERT INTO roles (name) VALUES ('admin')", []).await?;
        Ok(())
    })
}

let migrator = Migrator::new(Vec::new()).seeder(seed);
migrator.seed(&db).await?;
  • Tracked in a Laravel-compatible migrations table.
  • Each migration is recorded right after it succeeds. DDL auto-commits, so a migration failing halfway leaves its partial changes.
  • migrate and rollback take a database lock, so parallel deploys run one after another.
  • For a legacy schema, rename the table or lock, and stop waiting for the lock after a while:
rust
let migrator = rustique::Migrator::new(all())
    .table("schema_migrations")                       // default `migrations`
    .lock_name("schema_migrations_lock")              // MySQL GET_LOCK name / Oracle lock table
    .lock_timeout(std::time::Duration::from_secs(60)); // default: wait forever

CLI

Migrations are compiled into your app, so the commands run from a small binary of your own:

rust
// src/bin/rustique.rs
#[tokio::main]
async fn main() -> rustique::Result<()> {
    let migrator = rustique::Migrator::new(my_app::migrations::all());
    rustique::cli::run(&migrator, std::env::args().skip(1)).await
}
bash
cargo run --bin rustique -- migrate
CommandEffect
migrate [--seed]run pending migrations, then the seeder
migrate:rollback [--step n]roll back the last batch, or the last n migrations
migrate:reset --forceroll back every migration
migrate:fresh --force [--seed]drop every table, run every migration, then the seeder
migrate:statuslist migrations and their batch
db:seedrun the seeder
make:migration <name> [--path dir]write a migration file (default src/migrations). A create_<table>_table name gets a Schema::create skeleton
make:migration <name> --diff [--snapshot file]write a migration from the database to the schema snapshot (migrations from a schema diff)
schema:snapshot [--name n] [--path file]write the schema snapshot for the compile-time model check (default rustique-schema.txt; rustique-schema.n.txt with --name n)

The database URL comes from $DATABASE_URL, or the variable named by --env VAR. Never a command-line argument, where ps and shell history would show the password.

migrate:reset and migrate:fresh wipe the database, so without --force they stop with an error before connecting.

make:migration registers the module itself: it adds mod <module>; and the module to the migrations![...] list in the directory’s mod.rs. Without a mod.rs holding such a list it prints a hint and you add the lines yourself. Migrations, schema checks and migrate:status always run on the main database, never a read replica.

Compile-time schema check

With the cargo feature schema-check, #[rustique::model] checks each struct against a snapshot of the database schema. The build never connects to a database: the snapshot is a file you commit.

toml
rustique-orm = { version = "0.3", features = ["schema-check"] }
bash
cargo run --bin rustique -- schema:snapshot

schema:snapshot writes rustique-schema.txt with one line per column (table, column, null / not null, type, then the default and auto_increment when there are any) and one per primary key, index and foreign key, which make:migration --diff uses. Run it from the directory of the crate that defines the models: the macro reads rustique-schema.txt next to that crate’s Cargo.toml. Run it again after each migration; cargo rebuilds the models when the file changes.

Each model gets a compile error at the field for:

  • a table or column that is not in the snapshot;
  • a nullable column whose field is not an Option (except String on Oracle, which reads NULL as ""). For a nullable column that never holds NULL, as in many legacy schemas, mark the field #[rustique(not_null)];
  • a column type the field type cannot read, such as a varchar into i64 or a decimal(10,2) into i64;
  • a key that a relation attribute names (foreign_key, local_key, owner_key, parent_key, related_key, the pivot keys, pivot columns, pivot_timestamps, the through keys) and the {name}_type / {name}_id columns of morph relations and morph_to fields, when the snapshot has the table they belong in. The macro knows the related model only as a type, so it finds the related table through the snapshot’s foreign keys over the relation’s key columns (given or default): for belongs_to the one on the model’s table, for belongs_to_many the one on the pivot table, for has_one / has_many the single table whose foreign key references the model’s table, for has_*_through the same twice (the through table references the model’s, the related table the through one). When no foreign key matches, when several tables match, and for morph relations, it looks for the table under the snake_case plural of the type name (BlogPost is blog_posts) and skips the key when the snapshot has no such table. Keys left to their defaults are not checked, except the morph columns.

The check is skipped without the feature or without the file, so a fresh clone builds as before. Field types the check does not know, such as #[derive(rustique::Cast)] types and rustique::Value, are only checked for existence.

Column strings and raw SQL

Column strings for the query builder can go through rustique::col!, which expands to the string itself and, with a snapshot, fails to compile when the table is not in it, the table has no such column, or (for a lone col!("email")) no table has the column. col!("users.*") checks the table only.

rust
let query = User::query().where_(rustique::col!("users.email"), "=", "a@x.io");

#[rustique::scopes] blocks and relation scope = ... closures are checked as well, but only for "table.column" literals passed as the first argument of where_*, order_by*, group_by, select and add_select (or as an array to select), and only when the snapshot has that table. Lone column names are not checked there, since the closure may be for another model; neither are expressions, aliases or *_raw methods.

Raw SQL can go through rustique::sql!, which also expands to the string itself. With a snapshot, it fails to compile when a table after FROM, JOIN, INSERT INTO, MERGE INTO or a leading UPDATE is not in the snapshot, or when a table.column reference to one of those tables names a missing column.

rust
let rows = db
    .select(rustique::sql!("SELECT users.email FROM users WHERE users.id = ?"), [1.into()])
    .await?;

sql! reads SQL with a tiny tokenizer, not a parser, so it skips whatever it cannot be sure of rather than fail valid SQL: SQL containing WITH or a backslash, quoted and schema.table names, FROM inside a function call (EXTRACT(YEAR FROM d)), dual, table functions, aliases (u.id with FROM users u), unqualified columns, and every table after the first of FROM a, b.

Several databases or schemas

One snapshot describes one database (MySQL) or user (Oracle). For models of another one, write a named snapshot by pointing --env at that database:

bash
cargo run --bin rustique -- schema:snapshot --name billing --env BILLING_DATABASE_URL

That writes rustique-schema.billing.txt, which #[rustique::model(table = "invoices", schema = "billing")] reads instead of rustique-schema.txt. A model named table = "billing.invoices" reads it too, and looks for invoices there. Pass the name to the other macros as well: col!(schema = "billing", "invoices.total"), sql!(schema = "billing", "SELECT ...") and #[rustique::scopes(schema = "billing")]. Without that file, those models are not checked.

In CI

To fail CI on a stale snapshot, migrate a database, run schema:snapshot and check that git diff --exit-code rustique-schema.txt passes.

Migrations from a schema diff

make:migration <name> --diff writes a migration from the live database to the schema in the snapshot file (--snapshot file, default rustique-schema.txt):

  • the snapshot is the desired schema, the database is the current one;
  • up turns the database into the snapshot, down turns it back into what the database is now;
  • when they match it prints No changes: ... and writes nothing. A migration of only // TODO comments is still written, so you see them.

The desired schema is the snapshot rather than the models because a model only knows its column names, not their SQL types, lengths, defaults or indexes. So the workflow is: run schema:snapshot on a migrated database, commit the file, edit it to the schema you want, then run make:migration add_handle_to_users --diff against that database and review the result. Lines are tab-separated:

text
users	id	not null	bigint unsigned		auto_increment
users	handle	not null	varchar(255)	'anonymous'
users	primary	primary key	id
users	users_handle_unique	unique	handle
posts	posts_user_id_foreign	foreign key	user_id	users	id	cascade

The fields are: a column’s table, name, null / not null, type as the database reports it (varchar(255), VARCHAR2(255), NUMBER(19,0)), default as SQL, auto_increment or use_current_on_update; a key’s table, name (primary for primary keys), primary key / unique / index / foreign key, comma-separated columns, and for foreign keys the referenced table, its columns and cascade / set null / restrict.

The generated migration uses the schema builder:

rust
use rustique::{Db, Schema};

pub async fn up(db: &Db) -> rustique::Result<()> {
    Schema::create(db, "posts", |t| {
        t.big_increments("id");
        t.big_integer("user_id").unsigned();
        t.text("body").nullable();
    })
    .await?;
    Schema::table(db, "users", |t| {
        t.drop_index("users_email_unique");
        t.drop_column("name");
        t.string("email", 320).nullable().change();
        // TODO: rename? `t.rename_column("name", "handle");` would keep the data.
        t.string("handle", 255).default_raw("'anonymous'");
    })
    .await?;
    Schema::table(db, "posts", |t| {
        t.foreign(&["user_id"], "users", &["id"]).name("posts_user_id_foreign").cascade_on_delete();
    })
    .await?;
    Ok(())
}

It covers new tables (see below for tables missing from the snapshot), added, dropped and changed columns (type, nullability, default), and added and dropped indexes, unique indexes and foreign keys. New tables get their foreign keys in a second step, so tables may reference each other; tables the migration created are dropped in down, after losing their foreign keys. Defaults are copied as SQL (default_raw).

Review the file before running it:

  • A table that is in the database but not in the snapshot is never dropped: up gets a // TODO: "name" exists in the database but not in the snapshot. Drop it? Schema::drop(db, "name").await?; comment and nothing else (its foreign keys stay too), down nothing. Uncomment the drop if you mean it, and write its down yourself.
  • A renamed column comes out as a drop plus an add, with a // TODO: rename? comment when a dropped column has the same type; use rename_column instead to keep the data.
  • Types no Blueprint method declares (mediumint, enum(...), NUMBER(*,0), …), dropping a primary key and changing an auto-increment column become // TODO comments.
  • Where two Blueprint types share SQL, the first one wins: on Oracle json (with its IS JSON check) and long_text come back as text, and time as string(name, 18); on both a uuid comes back as char(name, 36).
  • Check constraints, column and table comments, views and Oracle sequences are not compared; schema:snapshot lists base tables only.
  • Snapshots written before make:migration --diff existed lack defaults and keys and are refused; write them again with schema:snapshot.