inspectdb
Introspect an existing database into models and a 0001_initial migration.
inspectdb is the porting on-ramp. Point it at an existing SQLite database
and it generates a models.rs with one #[derive(Model)] struct per table,
plus a 0001_initial.json migration that recreates the schema. The output
drops into the same M5 migration loop - no parallel porting code path.
Run it
# Introspect the app's ambient database (UMBRAL_DATABASE_URL):cargo run -- inspectdb --output plugins/imported # ...or point it at a specific database — a URL or a SQLite file path:cargo run -- inspectdb ./legacy.sqlite3 --output plugins/imported# -> Inspected 12 table(s), 47 column(s)# -> Wrote plugins/imported/models.rs# -> Wrote plugins/imported/migrations/app/0001_initial.jsonThe optional positional database argument accepts a sqlite:// / postgres://
URL or a bare path to a SQLite file (opened read-only). Omit it to use the
app's ambient database.
What it recovers
Beyond columns and primary keys, inspectdb recovers:
- Foreign keys — a
<col>FK column becomespub <col>: ForeignKey<Target>, pointing at the referenced table's generated struct. - Single-column unique / index constraints —
#[umbral(unique)]/#[umbral(index)]on the field. - Composite (multi-column) indexes — a struct-level
#[umbral(unique_together = [["org_id", "user_id"]])]for a multi-column UNIQUE, and#[umbral(indexes = [["role", "joined"]])]for a multi-column plain index, columns in index order. - Column defaults — a constant DB default becomes
#[umbral(default = "...")](a'active'::varcharstring is unquoted toactive; a numeric or boolean default is kept verbatim). Sequence / function defaults (nextval(...),gen_random_uuid()) are dropped — umbral can't re-emit an arbitrary expression as a literal. - Auto timestamps — a
CURRENT_TIMESTAMP/now()default on a temporal column lifts to#[umbral(auto_now_add)], so a re-migrate re-emits the right per-backend default rather than the literal string. Under--framework django, Python-managed timestamps that leave no DB default are recovered by name:created*→auto_now_add,updated*/modified*→auto_now. - Autoincrement primary keys need no special handling: an integer PK is
emitted as a plain
pub id: i64, and the migration engine already lowers that toBIGSERIALon Postgres andINTEGER … AUTOINCREMENTon SQLite. - Native Postgres enums (
CREATE TYPE ... AS ENUM): a column of a native enum type — which information_schema reports only as the opaqueUSER-DEFINED— is recovered frompg_enuminto a generated#[derive(Choices)]enum, and the field renders as that enum with#[umbral(choices)]. Variant names are the PascalCase of the DB labels (PARTIALLY_PAID→PartiallyPaid), and an enum-level#[choices(rename_all = "SCREAMING_SNAKE_CASE")]maps each back to its label; a label that wouldn't round-trip that way pins its exact string with#[choices(value = "...")]. Columns sharing one DB enum type reuse a single generated enum. The type stores asTEXT+ aCHECK (col IN (...))in the initial migration, so a re-migrate rebuilds the closed set. (SQLite has no native enum type, so this is Postgres-only.) - Many-to-many (
--framework django): a Django join table (communities_community_software— a pure junction of two FK columns plus the surrogate PK) is folded into anM2M<Software>field on the owner (Community), and the join table is not emitted as a plain struct — umbral auto-generates its own junction. The owner is the FK target that prefixes the join-table name; the remainder is the field. A non-i64owner PK spells out the parent-PK generic (M2M<Software, i32>). The recovered junction lands in the initial migration as aCreateM2MTable.
These flow into the generated 0001_initial migration too, so a re-migrate
rebuilds them — defaults, autoincrement, and the per-backend timestamp default
included.
Self-referential foreign keys work: a parent_id column that points at its own
table generates pub parent_id: Option<ForeignKey<Category>> inside struct Category.
Generated models compile
The output is meant to build as-is against the Model trait, so inspectdb
handles the awkward cases a real schema throws at it:
- Every struct derives
serde::Serialize+serde::Deserialize(theModeltrait requires them — aForeignKey<T>needsT: DeserializeOwned). - A primary key not named
id(authtoken_token.key,django_session.session_key) is marked#[umbral(primary_key)]. - A column whose name is a Rust keyword (
type,match, …) is escaped to a valid identifier and bound to the real column:pub type_with#[sqlx(rename = "type")].
You will still need the transitive type crates the schema uses in your
Cargo.toml — rust_decimal, ipnetwork, mac_address, uuid, chrono —
which the generated fields name by their fully-qualified paths.
Framework-aware naming
--framework <name> undoes a source ORM's column conventions so the generated
models read idiomatically. It pairs with
transferdata --map (which undoes the same
conventions on the data side):
--framework | FK column | Other columns | Extras |
|---|---|---|---|
django | author_id → author | snake_case, kept | app-prefix strip (--with-table-names), auth_user → AuthUser, M2M fold |
rails (activerecord) | author_id → author | snake_case, kept | M2M fold |
laravel (eloquent) | author_id → author | snake_case, kept | M2M fold |
prisma (typeorm) | authorId → author | firstName → first_name | M2M fold |
Django, Rails and Laravel share the snake <field>_id FK convention; Prisma
uses camelCase, so every column is snake-cased. M2M folding (turning a
join table into an M2M<T> field) works for all four: Django/Rails/Laravel name
the through / join / pivot table <owner_table>_<field>, and Prisma's implicit
_ModelAToModelB (with A / B columns) is recognised by its shape. The
app-prefix strip and auth_user externalization stay Django-only
(they're Django conventions).
The Django example below shows the fullest case:
cargo run -- inspectdb ./django.sqlite3 --framework django --output plugins/importedcargo run -- inspectdb ./django.sqlite3 --framework django --output plugins/imported// blog_post table with author_id + category_id FK columns:pub struct BlogPost { // ← full struct name (round-trips) pub id: i64, pub title: String, #[umbral(index)] pub author: ForeignKey<Author>, // ← `_id` shed; target stripped #[umbral(index)] pub category: Option<ForeignKey<Category>>,}-
FK field names shed Django's
_id(author_id→author), so a foreign key readspub author: ForeignKey<Author>and traverses aspost.author— exactly how umbral models are written (examples/shop). No#[sqlx(rename)]is needed: inspectdb writes a fresh schema, so the new column is simply namedauthor(umbral maps the field name to the column). Compositeunique_together/ index groups that referencedauthor_idare rewritten toauthorin lockstep. Only real FK columns are touched, and only when the stripped name is free (no collision). The FK target struct is app-prefix-stripped too (ForeignKey<Author>). -
auth_user→ your user model. Django'sauth_useris not re-declared; umbral-auth already providesAuthUseron the same table. FKs to it renderForeignKey<AuthUser>, and the file opens with an import you can repoint at a custom user model:// If you use a CUSTOM user model, replace the line below with your own, // e.g. `use crate::models::MyUser as AuthUser;`. use umbral_auth::AuthUser;
By default the struct name is the full table name (blog_post → BlogPost),
which snake-cases straight back to the table — so no #[umbral(table)] macro is
emitted and the models stay clean.
--with-table-names: strip the app prefix off struct names
Add --with-table-names to also shed the <app>_ prefix from struct names,
preserving the real table with a #[umbral(table = "...")] macro:
cargo run -- inspectdb ./django.sqlite3 --framework django --with-table-names --output plugins/imported#[umbral(table = "blog_post")] // ← real table preservedpub struct Post { // ← `blog_` app prefix stripped pub id: i64, pub title: String, #[umbral(index)] pub author_id: ForeignKey<Author>,}- Struct names drop the
<app>_prefix (blog_post→Post), with#[umbral(table = "blog_post")]preserving the real table. FK targets resolve through the same names, soForeignKey<Author>lines up withstruct Author. - Collision safety. If two apps have a
categorytable, both stripping toCategorywould clash — so on a collision the colliding tables keep their full app-prefixed names (BlogCategory,StoreCategory).
The flag is off by default because the full struct name round-trips to its table
with no macro at all; opt in when you'd rather read Post than BlogPost and
don't mind the table macro. A struct name that still wouldn't round-trip (odd
table casing) gets its #[umbral(table)] macro either way, so generated models
always map to the right table.
Without --framework, column and table names are kept verbatim (struct BlogPost, pub author_id: ForeignKey<BlogAuthor>), which round-trips exactly
since umbral uses the field name as the column name.
The output:
plugins/imported/├── models.rs # one #[derive(Model)] per table└── migrations/ └── app/ └── 0001_initial.json # one CreateTable per tableWire each generated struct with App::builder().model::<T>() (or move them
into a real plugin once M7's plugin contract suits your project shape) and
the loop runs normally from there.
Marking the initial migration applied
When the target database already holds the tables you just introspected,
running the migration would fail on "table already exists". Pass
--mark-applied to record the row in umbral_migrations without running
the SQL:
cargo run -- inspectdb --output plugins/imported --mark-appliedThe next migrate is a no-op until you actually change a model.
inspectdb dispatches on the active backend: SQLite reads sqlite_master plus PRAGMA table_info, and Postgres reads information_schema (tables, columns, and the constraint joins that recover the primary key). Point the CLI at whichever backend UMBRAL_DATABASE_URL resolves to; the same --output shape comes out either way. See the Postgres backend page for the Postgres specifics.
The SQLite type catalogue
The columns inspectdb's SQLite path knows about today (the Postgres path additionally recovers JSONB, native arrays, and INET / CIDR / MACADDR; see the Postgres backend page):
Integers
SMALLINT, INTEGER, BIGINT and their aliases map to i16 / i32 / i64.
Floats
REAL → f32; DOUBLE / FLOAT8 → f64.
Booleans
BOOLEAN / BOOL → bool.
Text
TEXT, VARCHAR, CHAR, CLOB all map to String. VARCHAR(n) width is recorded as a comment.
Dates and times
DATE → NaiveDate, TIME → NaiveTime. A tz-aware TIMESTAMPTZ → DateTime<Utc>; a tz-less TIMESTAMP (Prisma's DateTime default) → NaiveDateTime, so the naive column decodes.
UUIDs
UUID → uuid::Uuid.
A JSON / JSONB-declared column maps to serde_json::Value, a BLOB / BYTEA column maps to a bytes field, and a NUMERIC / DECIMAL column maps to rust_decimal::Decimal on both backends. A SQL type still outside the catalogue (Postgres custom domains, exotic vendor types) makes inspectdb stop with an UnsupportedColumnType error naming the offending column. Either add a matching SqlType variant or edit the generated models.rs by hand.
umbral_migrations and every sqlite_* internal table are skipped automatically.
A table whose columns use an SQL type the catalogue doesn't know is not skipped -
inspectdb stops with an UnsupportedColumnType error, so a table is never silently dropped from the output.
Still deferred
- FK
on_delete/on_updateactions. The FK reference is recovered; the cascade action defaults toNoActionrather than reading the DB's rule. - Expression indexes (an index on
lower(email)rather than plain columns) and partial indexes — skipped, since umbral models column-name index groups. - Generated plugin crates with a
Pluginimpl. Today the output is a flatmodels.rsyou wire by hand. M7's full plugin shape ships the crate too.
Foreign-key + single-column unique/index detection and Postgres introspection
(introspect_pool_pg) have shipped; they are no longer on this list.
The full target shape lives in docs/specs/07-inspectdb.md.