Models and AST (`model!`)

`model!` DSL & `extend!`

Exposed macros

  • model! { ... } — declares a model (SeaORM entity + migrations + admin form)
  • extend! { ... } — adds columns to an existing framework table
  • #[form(...)] — links a Rust form to a model! (see Forms & concepts)

All available via use runique::prelude::*.


`model!` structure

The parser expects blocks in this strict order (optional blocks may be absent but not reordered):

model! {
    ModelName,              // 1. Name (PascalCase)
    table: "table_name",   // 2. SQL table name
    pk: field => type,     // 3. Primary key
    enums: { ... },        // 4. Optional — local enums
    { ... },               // 5. Fields — anonymous block, semantic types
    relations: { ... },    // 6. Optional — SeaORM relations
    meta: { ... },         // 7. Optional — constraints & ordering
}
model! {
    Article,
    table: "articles",
    pk: id => i32,
    {
        title:      text     [required, max_length: 150],
        content:    textarea [required],
        is_active:  bool     [default: true],
        created_at: datetime [auto_now],
    }
}

The field block is always an anonymous { ... } block, never prefixed with a fields: keyword. Don't confuse this with extend!{}, which does require fields: — two different macros, two different grammars (see below).


Primary key (`pk`)

pk: field_name => type
TypePostgres SQLMySQL SQLAuto-incrementCreation
i32SERIALINT AUTO_INCREMENT✅ YesDB sequence
i64BIGSERIALBIGINT AUTO_INCREMENT✅ YesDB sequence
uuidUUIDVARCHAR(36)❌ NoUuid::now_v7() in Rust
Pkalias i32/i64/Uuidsamedepends on typedepends on the active feature

The Pk alias defers to the application's global type, resolved by a Cargo feature — only one may be active at a time (compile_error! if two are declared together):

[dependencies]
# nothing declared → Pk = i32 (default)
runique = { version = "2.2.0", features = ["big-pk"] }    # Pk = i64
runique = { version = "2.2.0", features = ["pk-uuid"] }   # Pk = Uuid (generated via Uuid::now_v7())

Use big-pk when you expect more than ~2 billion rows in a table, or when you need to interoperate with an existing schema using BIGINT primary keys. Use pk-uuid for non-sequential identifiers (multi-tenant setups, client-side generation, exposing ids publicly without leaking row volume).

Pk on a regular field (not just the primary key)

The Pk keyword can also be used on an ordinary FK field, not only in pk: id => Pk. This is the recommended way to declare a column referencing another table's primary key: it then automatically follows the same feature, never drifting out of sync if you switch big-pk/pk-uuid later.

model! {
    Chapter,
    table: "chapters",
    pk: id => Pk,
    {
        course_id: Pk [required],   // automatically follows Course.id's type
        title:     text [required],
    },
    relations: {
        belongs_to: Course via course_id,
    }
}

Avoid: declaring an FK with a fixed type (course_id: int [required]) when the referenced table uses pk: id => Pk. It compiles and works as long as Pk is i32, but silently drifts the moment a feature (big-pk/pk-uuid) changes — the same bug class documented below for big-pk. Using Pk on the FK field removes the risk at the source, with no conversion code (.try_into()) to maintain.

Constraint when enabling big-pk/pk-uuid: every FK column pointing at a Pk primary key must stay consistent with it. The course_id: Pk form (above) guarantees this automatically; a column fixed as bigint/int/uuid must be updated manually if you switch feature afterward.

The big-pk/pk-uuid choice must be made before the first migration. Once migrations have been applied, switching mode is a breaking change: the database columns already have a concrete type, and changing the feature only changes the Rust type — the schema stays untouched. Switching after the fact requires a manual migration to ALTER every PK and FK column, risking truncation (big-pk → default) or total format incompatibility (pk-uuid ↔ any integer type). Pick one mode at project start.


Field types

DSL typeGenerated Rust typeSQL column
textStringVARCHAR(255) or VARCHAR(n) if max_length: n
charStringCHAR
emailStringVARCHAR(254) — validated format
passwordStringVARCHAR(255) — automatically hashed
richtextStringTEXT — HTML editor
textareaStringTEXT — multi-line
urlStringVARCHAR(255) — validated format
slugStringVARCHAR(255)
colorStringVARCHAR(255) — hex color
phoneStringVARCHAR(20) or VARCHAR(n) if max_length: n
i8i8TINYINT
i16i16SMALLINT
inti32INTEGER
biginti64BIGINT
u32u32INTEGER UNSIGNED
u64u64BIGINT UNSIGNED
f32f32FLOAT
floatf64DOUBLE
percentf64DOUBLE — stored as float
decimalDecimalDECIMAL
boolboolBOOLEAN
dateNaiveDateDATE
timeNaiveTimeTIME
datetimeNaiveDateTimeDATETIME
timestampNaiveDateTimeTIMESTAMP
timestamp_tzDateTime<Utc>TIMESTAMPTZ
uuidUuidUUID
Pki32/i64/Uuiddepends on the active feature — see above
jsonserde_json::ValueJSON
json_binaryserde_json::ValueJSON BINARY
binaryVec<u8>BINARY — size via max_length: n
var_binaryVec<u8>VARBINARY(n)max_length: n required
blobVec<u8>BLOB
ipStringINET
cidrStringCIDR
mac_addressStringMACADDR
intervalStringINTERVAL
imageStringVARCHAR(255) — file path
documentStringVARCHAR(255) — file path
fileStringVARCHAR(255) — file path
choiceEnumNameVARCHAR / native ENUM — requires enum(EnumName)
radioEnumNameSame as choice, different widget
checkboxEnumNameSame as choice, different widget

binary/var_binary reuse the max_length: n option (same mechanism as text + max_lengthVARCHAR(n)) — there is no separate inline binary(n) syntax.

Not available: inline decimal(precision, scale) (e.g. decimal(10, 2)) has no current equivalent — only plain decimal with no parameters is supported. Workaround: enforce precision/scale at the application-validation layer instead of in the schema.


Field options

In a [...] block, comma-separated, value after : when the option takes one:

username: text [required, max_length: 150, unique],
OptionDescription
requiredNOT NULL column + form validation
nullableNULL column — Rust type Option<T>
uniqueUNIQUE constraint
max_length: nMax length (validation + column size)
min_length: nMin length (validation)
min: nMin integer value (validation)
max: nMax integer value (validation)
min: n.0Min float value (validation)
max: n.0Max float value (validation)
default: valueSQL default value (true, 0, "draft", etc.)
auto_nowSet to NOW() on every INSERT — excluded from forms
auto_now_updateSet to NOW() on every UPDATE — excluded from forms
readonlyExcluded from the generated migration (column exists in Rust, not managed by derive_form)
label: "str"Custom label in admin forms
help: "str"Reserved — not yet wired to rendering
upload_to: "path"File field — upload directory
max_size: n MBFile field — max size (KB/MB/GB)
rows: ntextarea/richtext — widget height
step: nNumeric fields — widget step
fk(table.col, action)Foreign key constraint (see Relations)
enum(EnumName)Links the field to an enum declared in enums:
renamed_from: "x"Renames the column (see below)
skipExcluded from generated forms
no_hashpassword fields only — disables automatic hashing

readonly (new, DB-level) is distinct from #[form]'s own readonly (field_readonly(), disables a field in the HTML rendering of one specific form instance). readonly on the model field excludes the column from the generated migration; field_readonly() just disables a widget at runtime. Both can coexist.

auto_now / auto_now_update: excluded from admin_from_form and admin_partial_update. Their value is managed by the database only. They appear in Model and Column as Option<T>.

Renaming a column — renamed_from

Renaming a field without this option produces a DROP COLUMN + ADD COLUMNdata loss. The tool is non-interactive and cannot guess intent: you must state it explicitly.

// before:  job_title: text,
// after:
title: text [renamed_from: "job_title"],

makemigrations then emits ALTER TABLE … RENAME COLUMN job_title TO title (supported by PostgreSQL, MySQL/MariaDB and SQLite), with no data loss. The attribute is a migration-only directive: it has no effect on the generated entity or form. Guard: if the old column still exists in the snapshot (stale hint), no rename is emitted.

Works in both model!{} and extend!{}.


Enums

Declared in a separate enums: { ... } block, then referenced via enum(EnumName).

model! {
    Order,
    table: "orders",
    pk: id => i32,
    enums: {
        OrderStatus: [
            Pending    = ("pending",    "Pending"),
            InProgress = ("in_progress","In progress"),
            Delivered  = ("delivered",  "Delivered"),
            Cancelled  = ("cancelled",  "Cancelled"),
        ],
        Priority: i32 [Low = 0, Normal = 1, High = 2, Urgent = 9],
    },
    {
        status:   choice [enum(OrderStatus), required],
        priority: choice [enum(Priority), required],
    },
}

Four variant forms — never confuse these

Watch out, common trap: : and = do not do the same thing. A single symbol difference completely changes the behavior, with no compile error to warn you. Always check against this table, never guess by analogy.

SyntaxStored DB valueDisplayed label (Display)
Variant"Variant" (the name)"Variant" — falls back to the DB value
Variant: "Label""Variant" (unchanged)"Label"
Variant = "db_value""db_value""db_value" — falls back to the DB value, not Variant
Variant = ("db_value", "Label")"db_value""Label"

Rule summary:

  • : (colon) affects only the display — the stored value is always the variant name.
  • = alone (no parentheses) affects only the stored value — display falls back to it, never to the variant name.
  • = (a, b) sets both independently — the only form that lets a variant name, a DB value and a label all differ.

The label is purely cosmetic. It affects neither:

  • the actual storage (#[sea_orm(string_value = ...)] / #[sea_orm(num_value = ...)] always use the DB value, never the label),
  • nor parsing comparisons (FromStr compares against the DB value and the Rust variant name first — the label is only accepted as a fallback, and only when it differs from the other two).

Changing a label (Published: "Published"Published: "Live") therefore has no impact on stored data or on code comparing enum values.

The DB value is stored exactly as written. No automatic transformation.

Backing types

SyntaxDB storage
EnumName: [A, B]Native ENUM (Postgres) or VARCHAR (MySQL/SQLite)
EnumName: i32 [...]INTEGER= then sets the numeric value, not a string
EnumName: i64 [...]BIGINT — same

Generated methods

MethodReturnDescription
.to_string()StringDisplay label
.db_value()&'static str / i32 / i64Exact DB value
::from_str(s) / .parse()Result<Self, ()>Parse from DB value, label, or variant name
::iter()impl Iterator<Item = Self>Iterate over all variants
use sea_orm::Iterable;

let s = OrderStatus::Pending;
s.db_value()   // → "pending"
s.to_string()  // → "Pending"

// For a <select>
let options: Vec<(String, String)> = OrderStatus::iter()
    .map(|v| (v.db_value().to_string(), v.to_string()))
    .collect();

// Parse from a DB value
let status: Option<OrderStatus> = "pending".parse().ok();

In Tera templates, the comparison value must match exactly what is stored in the database (case-sensitive).


File fields

model! {
    Article,
    table: "articles",
    pk: id => i32,
    {
        image:      image    [upload_to: "media/articles"],
        attachment: document [upload_to: "docs/"],
        upload:     file     [upload_to: "media/uploads"],
    },
}
TypeAllowed extensions
imagejpg jpeg png gif webp avif
documentpdf doc docx txt odt
fileno filter

upload_to: is required for all three types. The path is relative to MEDIA_ROOT.


Relations

relations: {
    belongs_to: Model via fk_field,
    has_many: Model,
    has_many: Comments as user_comments,   // optional alias
    has_one: Profile as user_profile,
    many_to_many: Roles through UserRoles via self_id,
}
TypeDB constraintDescription
belongs_to❌ code onlyN-1 relation (SeaORM)
has_many❌ code only1-N relation
has_one❌ code only1-1 relation
many_to_many❌ code onlyN-N via pivot table

Actual FK constraint: the SQL FOREIGN KEY and its action (cascade, restrict, set_null, set_default) are declared on the fk(table.col, action) field option, not in the relations: block. The relations: block only generates SeaORM traits for object navigation.

Available FK actions on fk(...): cascade · restrict · set_null · set_default


Meta

meta: {
    ordering: [-created_at, title],
    unique_together: [(slug, lang)],
    indexes: [(lang, sort_order)],
    verbose_name: "Article",
    verbose_name_plural: "Articles",
}
KeySyntaxEffect
ordering[field, -field]Default sort order, - = DESC
unique_together[(col1, col2)]Multi-column UNIQUE constraint
indexes[(col1, col2)]Multi-column simple index
verbose_name"string"Singular name in the admin interface
verbose_name_plural"string"Plural name in the admin interface
abstracttrueAbstract model — no table generated

`extend!{}` — extending framework tables

Adds columns to a Runique table and generates a complete SeaORM entity for that table.

extend!{} produces two things:

  1. SQL schemamakemigrations detects the block and generates ALTER TABLE ADD COLUMN statements
  2. Full entityModel, Column, Entity, AdminForm, admin_from_form, admin_partial_update covering all columns of the table (base columns + extended columns)
// src/entities/user_profile.rs
use runique::prelude::*;

extend! {
    table: "eihwaz_users",
    fields: {
        bio:         textarea,
        avatar:      image  [upload_to: "avatars/"],
        website:     url,
        phone:       phone,
        birth_date:  date,
        is_verified: bool   [default: false],
    }
}

extend!{} always requires the fields: keyword before the field block — unlike model!{} (direct anonymous block). These are two different macros with two different grammars; don't carry syntax from one over to the other.

Allowed tables: eihwaz_users, eihwaz_groupes, eihwaz_sessions, eihwaz_users_groupes, eihwaz_groupes_droits. Any other name causes a compile-time error.

Fields in extend!{} use the same types and options as model! (including renamed_from). No relations: block inside extend!{} — relations are declared in the target model!{} with has_many(user_profile) etc.

Enums in extend!{}

extend!{} accepts an optional enums: { ... } block (between table: and fields:), identical to model!. A choice [enum(EnumName)] column generates the Rust enum type, the typed column and the populated ChoiceField:

extend! {
    table: "eihwaz_users",
    enums: {
        Seniority: [Junior="junior", Mid="mid", Senior="senior", Lead="lead"],
    },
    fields: {
        job_title: text,
        seniority: choice [enum(Seniority)],
    }
}

makemigrations emits the column (on PostgreSQL, a CREATE TYPE … AS ENUM; elsewhere a native VARCHAR/ENUM).

Full workflow

# 1. Declare the extension in src/entities/
# 2. Generate the migration
runique makemigrations

# 3. Apply
runique migrate

# 4. Register in admin!{} (src/admin.rs)
admin! {
    configure {
        users: { hidden: true }   // hides the builtin "Users" panel
    }
    user_profile: user_profile::Model => user_profile::AdminForm {
        title: "User profiles",
        list_display: [
            ["username", "User"],
            ["bio", "Bio"],
            ["is_verified", "Verified"],
        ],
    }
}

What is generated

SymbolDescription
ModelStruct with all columns (base + extended)
ColumnSeaORM column enum
EntityFull EntityTrait — usable with search!
AdminFormAdmin form covering all columns
admin_from_formBuilds an ActiveModel from form data
admin_partial_updateBuilds a partial ActiveModel for updates

Queries from views

The generated entity is a standard SeaORM EntityTraitsearch! works directly:

// All verified profiles
let profiles = search!(user_profile::Entity => is_verified eq true).fetch(&db).await?;

// Multi-column search
let results = search!(user_profile::Entity => or(username icontains q, bio icontains q)).fetch(&db).await?;

Relations targeting the extended entity

Other entities can point to the extended entity via the usual relations: block in model!{}:

model! {
    Article,
    table: "articles",
    pk: id => i32,
    { author_id: int [required] },
    relations: {
        belongs_to: user_profile::Model via author_id,
    }
}