This version is in beta. Some features may change before release.

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

Code
bash
# 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.json

The 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 becomes pub <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'::varchar string is unquoted to active; 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 to BIGSERIAL on Postgres and INTEGER … AUTOINCREMENT on SQLite.
  • Native Postgres enums (CREATE TYPE ... AS ENUM): a column of a native enum type — which information_schema reports only as the opaque USER-DEFINED — is recovered from pg_enum into 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 as TEXT + a CHECK (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 an M2M<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-i64 owner PK spells out the parent-PK generic (M2M<Software, i32>). The recovered junction lands in the initial migration as a CreateM2MTable.

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 (the Model trait requires them — a ForeignKey<T> needs T: 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):

--frameworkFK columnOther columnsExtras
djangoauthor_id → authorsnake_case, keptapp-prefix strip (--with-table-names), auth_user → AuthUser, M2M fold
rails (activerecord)author_id → authorsnake_case, keptM2M fold
laravel (eloquent)author_id → authorsnake_case, keptM2M fold
prisma (typeorm)authorId → authorfirstName → first_nameM2M 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:

Code
bash
cargo run -- inspectdb ./django.sqlite3 --framework django --output plugins/imported
Code
bash
cargo run -- inspectdb ./django.sqlite3 --framework django --output plugins/imported
Code
rust
// 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 reads pub author: ForeignKey<Author> and traverses as post.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 named author (umbral maps the field name to the column). Composite unique_together / index groups that referenced author_id are rewritten to author in 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's auth_user is not re-declared; umbral-auth already provides AuthUser on the same table. FKs to it render ForeignKey<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:

Code
bash
cargo run -- inspectdb ./django.sqlite3 --framework django --with-table-names --output plugins/imported
Code
rust
#[umbral(table = "blog_post")] // ← real table preserved
pub 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, so ForeignKey<Author> lines up with struct Author.
  • Collision safety. If two apps have a category table, both stripping to Category would 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:

Code
txt
plugins/imported/
├── models.rs # one #[derive(Model)] per table
└── migrations/
└── app/
└── 0001_initial.json # one CreateTable per table

Wire 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:

Code
bash
cargo run -- inspectdb --output plugins/imported --mark-applied

The 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.

Info

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_update actions. The FK reference is recovered; the cascade action defaults to NoAction rather 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 Plugin impl. Today the output is a flat models.rs you 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.