Models and AST (`model!`)

`model!` DSL & AST

`model! { ... }` DSL: expected structure

The parser expects a strict structure:

  1. model name,
  2. table: "...",
  3. pk: id => i32|i64|uuid,
  4. enums: { ... } (optional),
  5. fields: { ... },
  6. relations: { ... } (optional),
  7. meta: { ... } (optional).

Concrete example:

use runique::prelude::*;

model! {
    User,
    table: "users",
    pk: id => i32,
    fields: {
        username: String [required, max_len(150), unique],
        email: String [required, unique],
        password: String [required],
        is_active: bool,
        team_id: i32 [required],
        created_at: datetime [auto_now],
    },
    relations: {
        has_many: Post,
        belongs_to: Team via team_id,
    },
}

Internal AST (what is parsed)

The DSL is converted into an internal Model AST structure, including:

  • name, table, pk
  • fields: Vec<FieldDef>
  • relations: Vec<RelationDef>
  • meta: Option<MetaDef>

Supported types

  • text: String, text, char, varchar(n), var_binary(n)
  • numeric: i8/i16/i32/i64/u32/u64/f32/f64, decimal(p,s), decimal
  • date/time: date, time, datetime, timestamp, timestamp_tz, interval
  • other: bool, uuid, json, json_binary, binary(n), binary, blob, enum(EnumName), inet, cidr, mac_address

Enums — enums: { ... } + enum(EnumName)

Enums are declared in a separate enums: { ... } block, then referenced in fields via enum(EnumName).

model! {
    DocSection,
    table: "doc_section",
    pk: id => i32,
    enums: {
        SectionTheme: pg [Demarrage, Web, Database, Security, Admin, Autres],
    },
    fields: {
        theme: enum(SectionTheme) [nullable],
    },
}

Backing types:

SyntaxDB storage
EnumName: [A, B]String (value = variant name)
EnumName: pg [A, B]Native PostgreSQL Enum
EnumName: i32 [A, B]Integer
EnumName: i64 [A, B]BigInteger

DB values: variants are stored exactly as written in the DSL — no automatic transformation. Declaring [Demarrage, Web] stores "Demarrage" and "Web". Declaring [demarrage, web] stores "demarrage" and "web".

Variant syntaxes:

SyntaxDB valueDisplay label (admin)
Variantvariant name as writtenvariant name
Variant = "order""order""order"
Variant = ("order", "Display order")"order""Display order"
enums: {
    SortMode: [
        Manual,
        Alphabetical = "alpha",
        ByDate = ("date", "By creation date"),
    ],
},

The tuple form = ("db_value", "Label") lets you store a short value in the database while displaying a readable label in the admin form.

FromStr accepts all three forms case-insensitively: the variant name, the DB value, and the label (if present) all resolve to the same variant.

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

{# Correct — matches the stored value "Web" #}
{% for section in sections | filter(attribute="theme", value="Web") %}

{# Incorrect — will never match if the DB contains "Web" #}
{% for section in sections | filter(attribute="theme", value="web") %}

The Tera filter built-in is strictly case-sensitive.

Field options

  • required, nullable, unique, readonly
  • max_len(n), min_len(n), max(n), min(n), max_f(n), min_f(n)
  • auto_now, auto_now_update
  • label(...), help(...), select_as(...)
  • file(kind), file(kind, "upload/path") — file field (see below)

label(...)

By default, the admin form generates a field label from its snake_case name (sort_orderSort order). The label(...) option sets a custom label:

model! {
    Chapter,
    table: "chapters",
    pk: id => i32,
    fields: {
        title: String [required, label("Chapter title")],
        sort_order: i32 [label("Display order")],
        is_published: bool [label("Published")],
    },
}

The label is used in the admin edit form (<label> tag) and in column headers when the field is declared in list_display. It has no effect on the generated migration or SeaORM entity.

File fields — file()

A String field can be declared as a file field using the file() option. The auto-generated form (AdminForm) will then use a FileField instead of a TextField.

model! {
    Article,
    table: "articles",
    pk: id => i32,
    fields: {
        title: String [required],

        // image — explicit directory
        image: String [file(image, "media/articles")],

        // document — auto directory from MEDIA_ROOT + field name
        file: String [file(document)],

        // any file type
        attachment: String [file(any, "media/uploads")],
    },
}

Available kinds:

ValueDefault allowed extensionsMaps to
imagejpg jpeg png gif webp avifFileField::image()
documentpdf doc docx txt odtFileField::document()
anyno filterFileField::any()

Upload path:

SyntaxDestination
file(image, "media/articles")media/articles/ (exact path)
file(image){MEDIA_ROOT}/{field_name}/ (reads MEDIA_ROOT from .env)

Invalid files are deleted from disk if validation fails. The destination directory is created automatically on the first valid upload.

Relations

Relations are declared in an optional relations: { ... } block after fields.

SyntaxDB constraintDescription
belongs_to: Model via fk_field,FOREIGN KEY generatedForeign key to model.id
belongs_to: Model via fk_field [cascade],ON DELETE CASCADEFK with on_delete cascade
belongs_to: Model via fk_field [cascade, restrict],FK with on_delete + on_update
has_many: Model,❌ (code only)1-N relation
has_one: Model,❌ (code only)1-1 relation
many_to_many: Model via pivot_table,❌ (code only)N-N relation

Available FK actions: cascade, restrict, set_null, set_default (default: no_action).

belongs_to automatically generates a FOREIGN KEY in the migration. The FK column (fk_field) must be declared in fields.

Meta

The meta block is reserved for future versions (ordering, verbose_name, etc.). It is parsed without error but currently ignored.