Models and AST (`model!`)

`model!` DSL & `extend!`

`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
    fields: { ... },       // 5. Fields (v1 or v2 syntax)
    relations: { ... },    // 6. Optional — SeaORM relations
    meta: { ... },         // 7. Optional — constraints & ordering
}

Two field syntaxes

Syntax v1 — explicit SQL types (named fields: block):

model! {
    Article,
    table: "articles",
    pk: id => i32,
    fields: {
        title:      String   [required, max_len(150)],
        content:    text     [required],
        is_active:  bool,
        created_at: datetime [auto_now],
    },
}

Syntax v2 — semantic types (anonymous { ... } block):

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],
    }
}

In syntax v2, the form_fields: block is ignored — semantic types already carry widget information.


Primary key (`pk`)

pk: field_name => type
TypePostgres SQLMySQL SQLAuto-incrementCreation
i32SERIALINT AUTO_INCREMENT✅ YesDB sequence
i64BIGSERIALBIGINT AUTO_INCREMENT✅ YesDB sequence
uuidUUIDVARCHAR(36)❌ NoUuid::new_v4() in Rust
Pkalias i32 or i64same✅ Yesdepends on big-pk feature

The Pk alias resolves to i32 by default, or i64 if the big-pk feature is enabled:

[dependencies]
runique = { version = "2.1.5", features = ["big-pk"] }

Use big-pk when you expect more than ~2 billion rows in a table, or when you need to interoperate with an existing schema that uses BIGINT primary keys.

Constraints when enabling big-pk:

  • Every FK column pointing to a Pk primary key must also be declared bigint, otherwise you get a type mismatch at compile time:
derive_form! {
    Order {
        fields: {
            user_id: bigint [required]   // must match users.id which is Pk (i64)
        }
    }
}
  • The admin daemon generates parse::<Pk>() by default in admin.rs, so the generated code automatically follows the feature — no manual adjustment needed.

  • Seed files and any handwritten code that assigns entity.id (a Pk) to an i32 FK field must use .try_into().unwrap() or change the FK column to bigint.

big-pk must be decided before the first migration. Once migrations have been applied, switching between big-pk and the default (i32) is a breaking change: the database columns are already INT or BIGINT, and changing the feature flag alone only changes the Rust type — the schema stays untouched. Switching after the fact requires a manual migration to ALTER every PK and FK column, and risks data truncation if existing IDs exceed i32::MAX. Pick one mode at project start and keep it.


Field types — syntax v1

Directly declared SQL types:

DSL typeGenerated Rust typeSQL column
StringStringVARCHAR(255)
textStringTEXT
charStringCHAR
varchar(n)StringVARCHAR(n)
i8i32TINYINT
i16i32SMALLINT
i32i32INTEGER
i64i64BIGINT
u32u32INTEGER UNSIGNED
u64u64BIGINT UNSIGNED
f32f32FLOAT
f64f64DOUBLE
decimalDecimalDECIMAL
decimal(p, s)DecimalDECIMAL(p, s)
boolboolBOOLEAN
dateNaiveDateDATE
timeNaiveTimeTIME
datetimeNaiveDateTimeDATETIME
timestampNaiveDateTimeTIMESTAMP
timestamp_tzNaiveDateTimeTIMESTAMPTZ
uuidUuidUUID
jsonserde_json::ValueJSON
json_binaryserde_json::ValueJSON BINARY
binaryVec<u8>BINARY
binary(n)Vec<u8>BINARY(n)
var_binary(n)Vec<u8>VARBINARY(n)
blobVec<u8>BLOB
inetStringINET
cidrStringCIDR
mac_addressStringMACADDR
intervalStringINTERVAL
enum(EnumName)EnumNameINTEGER / ENUM / VARCHAR

Field types — syntax v2 (semantic)

Automatically converted to SQL types:

Semantic typeGenerated SQLNotes
textVARCHAR(255) or VARCHAR(n) if max_length: n
emailVARCHAR(254)Validated email format
passwordVARCHAR(255)Automatically hashed
richtextTEXTHTML editor
textareaTEXTMulti-line
urlVARCHAR(255)Validated URL format
slugVARCHAR(255)
colorVARCHAR(255)Hex color
ipINET
phoneVARCHAR(20) or VARCHAR(n) if max_length: n<input type="tel">
intINTEGER
bigintBIGINT
floatDOUBLE
decimalDECIMAL
percentDOUBLEStored as float
boolBOOLEAN
dateDATE
timeTIME
datetimeDATETIME
uuidUUID
jsonTEXT
imageVARCHAR(255)Stores file path
documentVARCHAR(255)Stores file path
fileVARCHAR(255)Stores file path
choiceVARCHAR / native ENUMRequires enum(EnumName)
radioSame as choiceDifferent widget, same SQL
checkboxSame as choiceDifferent widget, same SQL

Field options — syntax v1

In a [...] block, comma-separated:

username: String [required, max_len(150), unique],
OptionDescription
requiredNOT NULL column + form validation
nullableNULL column — Rust type Option<T>
uniqueUNIQUE constraint
indexSimple index (non-unique)
default(value)SQL default value (true, 0, "draft", etc.)
max_len(n)Max length (validation + VARCHAR(n))
min_len(n)Min length (validation)
max(n)Max integer value (validation)
min(n)Min integer value (validation)
max_f(n)Max float value
min_f(n)Min float value
auto_nowSet to NOW() on every INSERT — excluded from forms
auto_now_updateSet to NOW() on every UPDATE — excluded from forms
readonlyExcluded from generated forms
select_as(str)SQL alias in SELECTs
label("str")Custom label in admin forms
help("str")Help text (reserved)
fk(table.col, action)Foreign key constraint (see Relations)
file(kind)File field — image, document, any
file(kind, "path")File field with explicit upload directory
max_size(n)Max upload size — n KB, n MB, n GB

Field options — syntax v2

Using : instead of () for values:

username: text [required, max_length: 150, unique],
v2 optionv1 equivalentNotes
requiredrequired
nullablenullable
uniqueunique
max_length: nmax_len(n)
min_length: nmin_len(n)
min: nmin(n)
max: nmax(n)
min: n.0min_f(n)
max: n.0max_f(n)
default: valuedefault(value)
auto_nowauto_now
auto_now_updateauto_now_update
upload_to: "path"file(kind, "path")
max_size: n MBmax_size(n MB)
rows: nv2 only (textarea)
step: nv2 only (numeric fields)
fk(table.col, action)fk(table.col, action)
enum(EnumName)enum(EnumName)
skipreadonly
no_hashpassword fields only

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


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

SyntaxDB valueDisplay label (Display)
Variant"Variant""Variant"
Variant: "Label""Variant""Label"
Variant = "db_value""db_value""db_value"
Variant = ("db_value", "Label")"db_value""Label"

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
EnumName: i64 [...]BIGINT

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();

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

`label` and `help`

By default, the label is generated from the snake_case field name (sort_orderSort order). The label(...) option overrides it:

fields: {
    title:        text [required, label("Article title")],
    sort_order:   i32  [label("Display order")],
    is_published: bool [label("Published")],
},

label and help are v1 only options — not available in the v2 anonymous block.

The label applies to the admin form and column headers in list_display. It has no effect on migrations.


`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],
    }
}

Allowed tables: eihwaz_users, eihwaz_groupes, eihwaz_droits, 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 the v2 syntax of model!. No relations: block inside extend!{} — relations are declared in the target model!{} with has_many(user_profile) etc.

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,
    }
}