`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,
},
}
Primary Key (`pk`)
The pk block defines the name and type of the primary key. Three types are supported natively:
| Type | SQL (Postgres / MySQL) | Auto-increment |
|---|---|---|
i32 | SERIAL / INT AUTO_INC | ✅ Yes (default) |
i64 | BIGSERIAL / BIGINT AUTO_INC | ✅ Yes |
uuid | UUID / VARCHAR(36) | ❌ No |
UUID Example:
model! {
Log,
table: "logs",
pk: id => uuid,
fields: { ... }
}
Link with the User model and the big-pk feature
If you use i64 for your user model (the entity linked to the session), you must enable the big-pk feature in your Cargo.toml so that the global Pk alias used by the framework is synchronized:
[dependencies]
runique = { version = "2.1.1-alpha.1", features = ["big-pk"] }
Warning: The
uuidtype for the globalPkalias (authentication) is not yet natively supported in the current version.
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: [Demarrage, Web, Database, Security, Admin, Autres],
},
fields: {
theme: enum(SectionTheme) [nullable],
},
}
Backing types:
| Syntax | DB storage |
|---|---|
EnumName: [A, B] | Auto-detected from DB_ENGINE: native Enum on PostgreSQL, VARCHAR on MySQL/SQLite |
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 through PivotTable via via_field, | ❌ (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.