Schema and migrations
Schema builder, Migrator, CLI
Schema builder
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?; // boolColumn types
| Method | MySQL | Oracle |
|---|---|---|
id() | id BIGINT UNSIGNED AUTO_INCREMENT primary key | id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY primary key |
big_increments(n) | as id(), named n | as id(), named n |
big_integer, integer, small_integer | BIGINT, INT, SMALLINT | NUMBER(19), NUMBER(10), NUMBER(5) |
foreign_id(n) | BIGINT UNSIGNED | NUMBER(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, LONGTEXT | CLOB |
double(n) | DOUBLE | BINARY_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) | JSON | CLOB + CHECK (n IS JSON) |
binary(n) | LONGBLOB | BLOB |
uuid(n) | CHAR(36) | CHAR(36) |
ulid(n) | CHAR(26) | CHAR(26 CHAR) |
timestamps() | nullable created_at, updated_at datetimes | same |
soft_deletes() | nullable deleted_at datetime | same |
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().
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
}).awaituse_current()works ondatetimeanddatecolumns: MySQL(UTC_TIMESTAMP(6))/(UTC_DATE()), OracleSYS_EXTRACT_UTC(SYSTIMESTAMP)(truncated for dates). Laravel’sCURRENT_TIMESTAMPwould follow the session time zone instead.use_current_on_update()works ondatetimecolumns: MySQLON UPDATE CURRENT_TIMESTAMP(6)(session time zone). Oracle has noON UPDATE, so it is ignored there; models withtimestampssetupdated_atthemselves.default_rawis not portable; write it per database if they differ.- Comments: MySQL writes them into the column definition and
ALTER TABLE ... COMMENT, Oracle runsCOMMENT 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():
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
}).awaitState 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
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.
// 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
}// 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]
}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:
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
migrationstable. - Each migration is recorded right after it succeeds. DDL auto-commits, so a migration failing halfway leaves its partial changes.
migrateandrollbacktake 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:
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 foreverCLI
Migrations are compiled into your app, so the commands run from a small binary of your own:
// 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
}cargo run --bin rustique -- migrate| Command | Effect |
|---|---|
migrate [--seed] | run pending migrations, then the seeder |
migrate:rollback [--step n] | roll back the last batch, or the last n migrations |
migrate:reset --force | roll back every migration |
migrate:fresh --force [--seed] | drop every table, run every migration, then the seeder |
migrate:status | list migrations and their batch |
db:seed | run 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.
rustique-orm = { version = "0.3", features = ["schema-check"] }cargo run --bin rustique -- schema:snapshotschema: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(exceptStringon Oracle, which readsNULLas""). For a nullable column that never holdsNULL, as in many legacy schemas, mark the field#[rustique(not_null)]; - a column type the field type cannot read, such as a
varcharintoi64or adecimal(10,2)intoi64; - a key that a relation attribute names (
foreign_key,local_key,owner_key,parent_key,related_key, the pivot keys,pivotcolumns,pivot_timestamps, thethroughkeys) and the{name}_type/{name}_idcolumns of morph relations andmorph_tofields, 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): forbelongs_tothe one on the model’s table, forbelongs_to_manythe one on the pivot table, forhas_one/has_manythe single table whose foreign key references the model’s table, forhas_*_throughthe same twice (thethroughtable references the model’s, the related table thethroughone). 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 (BlogPostisblog_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.
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.
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:
cargo run --bin rustique -- schema:snapshot --name billing --env BILLING_DATABASE_URLThat 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;
upturns the database into the snapshot,downturns it back into what the database is now;- when they match it prints
No changes: ...and writes nothing. A migration of only// TODOcomments 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:
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 cascadeThe 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:
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:
upgets 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),downnothing. Uncomment the drop if you mean it, and write itsdownyourself. - A renamed column comes out as a drop plus an add, with a
// TODO: rename?comment when a dropped column has the same type; userename_columninstead to keep the data. - Types no
Blueprintmethod declares (mediumint,enum(...),NUMBER(*,0), …), dropping a primary key and changing an auto-increment column become// TODOcomments. - Where two
Blueprinttypes share SQL, the first one wins: on Oraclejson(with itsIS JSONcheck) andlong_textcome back astext, andtimeasstring(name, 18); on both auuidcomes back aschar(name, 36). - Check constraints, column and table comments, views and Oracle sequences are not compared;
schema:snapshotlists base tables only. - Snapshots written before
make:migration --diffexisted lack defaults and keys and are refused; write them again withschema:snapshot.