DSL `model!` & AST
DSL `model! { ... }` : structure attendue
Le parseur attend une structure stricte :
- nom du modèle,
table: "...",pk: id => i32|i64|uuid,enums: { ... }optionnel,fields: { ... },relations: { ... }optionnel,meta: { ... }optionnel.
Exemple concret :
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,
},
}
AST interne (ce qui est parsé)
La DSL est convertie en AST Model avec notamment :
name,table,pkfields: Vec<FieldDef>relations: Vec<RelationDef>meta: Option<MetaDef>
Types pris en charge
- texte :
String,text,char,varchar(n),var_binary(n) - numériques :
i8/i16/i32/i64/u32/u64/f32/f64,decimal(p,s),decimal - date/temps :
date,time,datetime,timestamp,timestamp_tz,interval - autres :
bool,uuid,json,json_binary,binary(n),binary,blob,enum(NomEnum),inet,cidr,mac_address
Enums — enums: { ... } + enum(NomEnum)
Les enums se déclarent dans un bloc enums: { ... } distinct des champs, puis sont référencés dans fields via enum(NomEnum).
model! {
DocSection,
table: "doc_section",
pk: id => i32,
enums: {
SectionTheme: pg [Demarrage, Web, Database, Security, Admin, Autres],
},
fields: {
theme: enum(SectionTheme) [nullable],
},
}
Types de backing :
| Syntaxe | Stockage DB |
|---|---|
NomEnum: [A, B] | String (valeur = nom du variant) |
NomEnum: pg [A, B] | Enum PostgreSQL natif |
NomEnum: i32 [A, B] | Integer |
NomEnum: i64 [A, B] | BigInteger |
Valeurs en base : les variants sont stockés exactement tels qu'écrits dans le DSL — aucune transformation automatique. Si vous déclarez [Demarrage, Web], la base contient "Demarrage" et "Web". Si vous déclarez [demarrage, web], la base contient "demarrage" et "web".
Syntaxes de variant :
| Syntaxe | Valeur DB | Libellé affiché (admin) |
|---|---|---|
Variant | nom du variant tel qu'écrit | nom du variant |
Variant = "ordre" | "ordre" | "ordre" |
Variant = ("ordre", "Ordre d'affichage") | "ordre" | "Ordre d'affichage" |
enums: {
SortMode: [
Manual,
Alphabetical = "alpha",
ByDate = ("date", "Par date de création"),
],
},
La forme tuple = ("valeur_db", "Libellé") permet de stocker une valeur courte en base tout en affichant un libellé lisible dans le formulaire admin.
FromStr accepte les trois formes en insensible à la casse : le nom du variant, la valeur DB, et le libellé (si présent) redirigent tous vers le même variant.
Dans les templates Tera, la valeur de comparaison doit correspondre exactement à ce qui est stocké en base (sensible à la casse) :
{# Correct — correspond à la valeur stockée "Web" #}
{% for section in sections | filter(attribute="theme", value="Web") %}
{# Incorrect — ne correspondra jamais si la base contient "Web" #}
{% for section in sections | filter(attribute="theme", value="web") %}
Le filtre Tera filter est strictement sensible à la casse.
Options de champ
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, "chemin/upload")— champ fichier (voir ci-dessous)
label(...)
Par défaut, le formulaire admin génère le libellé d'un champ à partir de son nom snake_case (sort_order → Sort order). L'option label(...) permet de définir un libellé personnalisé :
model! {
Chapitre,
table: "chapitres",
pk: id => i32,
fields: {
title: String [required, label("Titre du chapitre")],
sort_order: i32 [label("Ordre d'affichage")],
is_published: bool [label("Publié")],
},
}
Le libellé est utilisé dans le formulaire d'édition admin (balise <label>) et dans les en-têtes de colonnes si le champ est déclaré dans list_display. Il n'a aucun effet sur la migration ou l'entité SeaORM générée.
Champs fichier — file()
Un champ String peut être déclaré comme champ fichier avec l'option file(). Le formulaire auto-généré (AdminForm) utilisera alors un FileField au lieu d'un TextField.
model! {
Article,
table: "articles",
pk: id => i32,
fields: {
titre: String [required],
// image — dossier explicite
image: String [file(image, "media/articles")],
// document — dossier auto depuis MEDIA_ROOT + nom du champ
fichier: String [file(document)],
// tout type de fichier
piece_jointe: String [file(any, "media/uploads")],
},
}
Types disponibles :
| Valeur | Extensions autorisées par défaut | Correspondance |
|---|---|---|
image | jpg jpeg png gif webp avif | FileField::image() |
document | pdf doc docx txt odt | FileField::document() |
any | aucun filtre | FileField::any() |
Chemin d'upload :
| Syntaxe | Destination |
|---|---|
file(image, "media/articles") | media/articles/ (chemin exact) |
file(image) | {MEDIA_ROOT}/{nom_du_champ}/ (lit MEDIA_ROOT depuis .env) |
Les fichiers invalides sont supprimés du disque si la validation échoue. Le dossier de destination est créé automatiquement lors du premier upload valide.
Relations
Les relations sont déclarées dans un bloc relations: { ... } optionnel après fields.
| Syntaxe | Contrainte DB | Description |
|---|---|---|
belongs_to: Model via fk_field, | ✅ FOREIGN KEY générée | Clé étrangère vers model.id |
belongs_to: Model via fk_field [cascade], | ✅ ON DELETE CASCADE | FK avec on_delete cascade |
belongs_to: Model via fk_field [cascade, restrict], | ✅ | FK avec on_delete + on_update |
has_many: Model, | ❌ (code uniquement) | Relation 1-N |
has_one: Model, | ❌ (code uniquement) | Relation 1-1 |
many_to_many: Model via pivot_table, | ❌ (code uniquement) | Relation N-N |
Actions FK disponibles : cascade, restrict, set_null, set_default (défaut : no_action).
belongs_togénère automatiquement uneFOREIGN KEYdans la migration. La colonne FK (fk_field) doit être déclarée dansfields.
Meta
Le bloc
metaest réservé aux futures versions (ordering, verbose_name, etc.). Il est parsé sans erreur mais ignoré.