Makemigrations Internals
The runique makemigrations command bridges your Rust entities (model!{}, extend!{}) and the database schema. It compares your declarations with the last recorded state and writes the SeaORM migrations that take the database from one to the other.
The generation pipeline
Pass 1: reading the DSL
makemigrations reads the src/entities/*.rs files without compiling them, with the same parser as the model!{} macro: the runique_dsl crate. A model the macro refuses is refused by the CLI, at the same place and with the same message:
- an error (unknown type or attribute, unknown foreign key action, nullability rule brokenβ¦) stops the command with the file, line and column;
- a file that isn't valid Rust, or that declares two
model!{}, is an error β never a file silently skipped; - only attributes decide: a column's name (
created_at,cache_keyβ¦) has no effect.
belongs_to targets are then resolved the way the macro does: by module name (the target.rs file) or by framework table. The foreign key references the target's real table and real primary key; an unresolved target is an error. Every belongs_to column gets an index idx_<table>_<column> (unless it is already unique or leads an index of meta), and every list field (multichoice / checkbox) its table {table}_{field}.
Pass 2: diff and snapshots
Runique keeps the last state of each table in migration/src/snapshots/, as a migration file read back on every run. The diff detects:
- added or dropped tables and columns;
- column renames through
[renamed_from: "old"]: aRENAME COLUMNinstead of aDROP+ADD(without that hint, the non-interactive tool can't guess the intent); - changes of type, nullability, uniqueness, default value and length (
max_length); - added, dropped or renamed enum values (a rename is one operation);
- added or dropped foreign keys and indexes.
A snapshot written before 3.0 has no column lengths: it takes the model's once, without producing a migration, and is then rewritten with them.
Pass 3: generation
The diff becomes SeaQuery statements (Table::create(), Table::alter(), Index::create()β¦) written to new files migration/src/m<timestamp>_*.rs, registered in the Migrator of lib.rs.
- Order: new tables are sorted so a referenced table is created before the tables referencing it; the order is deterministic.
- Foreign keys: declared in the
CREATE TABLE. For a cycle of new tables referencing each other, the key that closes the cycle stays in theCREATE TABLEon SQLite (which only checks a key when rows are written), and is added by anALTER TABLEon Postgres and MySQL, once the target table exists. - Framework tables:
eihwaz_users, sessions, the admin tables and reset tokens have their migrations in Runique;makemigrationsputs them first inlib.rs. Aneihwaz_*table declared insrc/entities/is never recreated β extend it withextend!{}.
One file, every engine
A migration file is written once and must apply on any engine. What only concerns one engine is therefore chosen at run time, inside the file, rather than at generation:
- Enums:
CREATE TYPE β¦ AS ENUMon Postgres only;VARCHARelsewhere. A value rename becomesALTER TYPE β¦ RENAME VALUEon Postgres and anUPDATEof the data elsewhere. - Keys of a cycle: see above.
auto_now/auto_now_update: no trigger. The entity fills these columns itself (ActiveModelBehavior::before_save), the same way on every engine; the migration only addsDEFAULT CURRENT_TIMESTAMP.- Column modification on SQLite: SQLite can't modify an existing column; the file says so in a comment and applies the change on the other engines.
Atomic commit & destructive guard
The passes above only compute an in-memory plan β nothing is written until the full plan (model!{} changes plus extend!{} changes) is assembled and validated:
-
Destructive guard: blocked unless
makemigrations --forceis passed:DROP COLUMN;- column type change;
nullable β not null;- length shrink (smaller
max_length, or a length added to a column that had none); - foreign key removal;
- adding an
ON DELETE CASCADEkey on an existing table.
The check covers both
model!{}andextend!{}changes.Exception β column type change:
--forceunblocks the command, but never generates the realALTERfor a type change (String β Decimal, etc.). The generated file only contains a// Manual migration required.comment β write it yourself. The other destructive categories generate the real SQL as soon as--forceis passed. This exception is deliberate: a generic type conversion has no reliable cross-engine cast rule (Postgres needs an explicitUSING, MariaDB casts silently without error on an invalid value, SQLite doesn't supportALTER COLUMN TYPEat all). -
Single commit: creating directories, writing files, registering in
lib.rsand positioning the framework migrations run under one rollback. On a write error, the generated files are removed and the existing snapshots andlib.rsare restored to their previous state.
Why customized snapshots?
Runique doesn't rely only on the database state (which can drift). By keeping snapshots of the DSL state, the framework guarantees your Admin forms always match your model declarations, even before the migrations are applied.
extend!{} logic
When you use extend! { table: "eihwaz_users", ... }, makemigrations:
- Identifies the targeted framework table.
- Stores the extension in a dedicated snapshot folder.
- Generates an
ALTER TABLEinstead of aCREATE TABLE.
Concrete examples
Renaming a column without data loss
Renaming a field directly produces a DROP + ADD β data lost. The renamed_from hint tells the non-interactive tool what you mean:
model! {
Employee,
table: "employees",
pk: id => i32,
{
// before: job_title: text,
title: text [renamed_from: "job_title"],
}
}
makemigrations then emits ALTER TABLE employees RENAME COLUMN job_title TO title (PostgreSQL, MySQL/MariaDB, SQLite). The attribute is a migration-only directive: no effect on the generated entity or form. Safeguard: if the old column still exists in the snapshot (stale hint), no rename is emitted.
Extending a framework table with extend!{}
To add columns to eihwaz_users (or eihwaz_groupes) without touching the framework:
use runique::prelude::*;
extend! {
table: "eihwaz_users",
fields: {
bio: textarea [nullable],
avatar: image [nullable, upload_to: "avatars/"],
website: url [nullable],
is_verified: bool [default: false],
}
}
On the next makemigrations, these fields become an ALTER TABLE eihwaz_users ADD COLUMN β¦ (never a CREATE TABLE). extend!{} fields follow the same types, options and nullability rules as model!{}, renamed_from included.
Generating, applying, rolling back
# Detect the diff and write the migration files
runique makemigrations
# Destructive changes are blocked by default. To allow them:
runique makemigrations --force
# NB: for a column type change, --force only unblocks the run β the
# generated file still just has a "Manual migration required." comment,
# never a real ALTER (see the note above).
# Custom paths (defaults: src/entities and migration/src)
runique makemigrations --entities src/entities --migrations migration/src
# Apply the generated migrations
runique migration up # or: sea-orm-cli migrate up
# Roll back the last N / see the state
sea-orm-cli migrate down -n 1
sea-orm-cli migrate status
β Architecture | Models β