`model!` DSL & AST
`model! { ... }` DSL: expected structure
The parser expects a strict structure:
- model name,
table: "...",pk: id => i32|i64|uuid,enums: { ... }(optional),fields: { ... },relations: { ... }(optional),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,pkfields: 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:
| Syntax | DB 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:
| Syntax | DB value | Display label (admin) |
|---|---|---|
Variant | variant name as written | variant 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,readonlymax_len(n),min_len(n),max(n),min(n),max_f(n),min_f(n)auto_now,auto_now_updatelabel(...),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_order → Sort 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:
| Value | Default allowed extensions | Maps to |
|---|---|---|
image | jpg jpeg png gif webp avif | FileField::image() |
document | pdf doc docx txt odt | FileField::document() |
any | no filter | FileField::any() |
Upload path:
| Syntax | Destination |
|---|---|
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.
| Syntax | DB constraint | Description |
|---|---|---|
belongs_to: Model via fk_field, | ✅ FOREIGN KEY generated | Foreign key to model.id |
belongs_to: Model via fk_field [cascade], | ✅ ON DELETE CASCADE | FK 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_toautomatically generates aFOREIGN KEYin the migration. The FK column (fk_field) must be declared infields.
Meta
The
metablock is reserved for future versions (ordering, verbose_name, etc.). It is parsed without error but currently ignored.