Models et AST (`model!`)

DSL `model!` & `extend!`

Macros exposées

  • model! { ... } — déclare un modèle (entité SeaORM + migrations + formulaire admin)
  • extend! { ... } — ajoute des colonnes à une table framework existante
  • #[form(...)] — lie un formulaire Rust à un model! (voir Formulaires & enjeux)

Toutes sont disponibles via use runique::prelude::*.


Structure du DSL `model!`

Le parseur attend les blocs dans cet ordre strict (les blocs optionnels peuvent être absents mais pas réordonnés) :

model! {
    NomModele,              // 1. Nom (PascalCase)
    table: "nom_table",     // 2. Nom de la table SQL
    pk: champ => type,      // 3. Clé primaire
    enums: { ... },         // 4. Optionnel — enums locaux
    { ... },                // 5. Champs — bloc anonyme, types sémantiques
    relations: { ... },     // 6. Optionnel — relations SeaORM
    meta: { ... },          // 7. Optionnel — contraintes & tri
}
model! {
    Article,
    table: "articles",
    pk: id => i32,
    {
        titre:      text      [required, max_length: 150],
        contenu:    textarea  [required],
        is_active:  bool      [default: true],
        created_at: datetime  [auto_now],
    }
}

Le bloc de champs est toujours un bloc anonyme { ... }, jamais précédé du mot-clé fields:. Ne pas confondre avec extend!{}, qui lui exige fields: — deux macros différentes, deux grammaires différentes (voir plus bas).


Clé primaire (`pk`)

pk: nom_champ => type
TypeSQL PostgresSQL MySQLAuto-incrémentCréation
i32SERIALINT AUTO_INCREMENT✅ Ouiséquence DB
i64BIGSERIALBIGINT AUTO_INCREMENT✅ Ouiséquence DB
uuidUUIDVARCHAR(36)❌ NonUuid::now_v7() côté Rust
Pkalias i32/i64/Uuididemselon le typeselon la feature active

L'alias Pk défère au type global de l'application, résolu par feature Cargo — une seule peut être active à la fois (compile_error! si deux sont déclarées ensemble) :

[dependencies]
# rien de déclaré → Pk = i32 (défaut)
runique = { version = "2.2.0", features = ["big-pk"] }    # Pk = i64
runique = { version = "2.2.0", features = ["pk-uuid"] }   # Pk = Uuid (généré via Uuid::now_v7())

Utilisez big-pk quand vous anticipez plus de ~2 milliards de lignes dans une table, ou pour interopérer avec un schéma existant utilisant des clés primaires BIGINT. Utilisez pk-uuid pour des identifiants non séquentiels (multi-tenant, génération côté client, exposition publique des ids sans révéler le volume de lignes).

Pk sur un champ normal (pas seulement la PK)

Le mot-clé Pk est aussi utilisable sur un champ FK ordinaire, pas uniquement dans pk: id => Pk. C'est la façon recommandée de déclarer une colonne qui référence la clé primaire d'une autre table : elle suit alors automatiquement la même feature, sans jamais se désynchroniser si vous changez big-pk/pk-uuid plus tard.

model! {
    Chapitre,
    table: "chapitre",
    pk: id => Pk,
    {
        cour_id: Pk [required],   // suit automatiquement le type de Cours.id
        titre:   text [required],
    },
    relations: {
        belongs_to: Cour via cour_id,
    }
}

À éviter : déclarer une FK avec un type figé (cour_id: int [required]) quand la table référencée utilise pk: id => Pk. Ça compile et fonctionne tant que Pk vaut i32, mais se désynchronise silencieusement dès qu'une feature (big-pk/pk-uuid) change — le même type de bug que documenté plus bas pour big-pk. Utiliser Pk sur le champ FK élimine le risque à la source, sans code de conversion (.try_into()) à maintenir.

Contrainte lors de l'activation de big-pk/pk-uuid : chaque colonne FK pointant vers une clé primaire Pk doit rester cohérente avec elle. La forme cour_id: Pk (ci-dessus) le garantit automatiquement ; une colonne figée en bigint/int/uuid doit être mise à jour manuellement si vous changez de feature après coup.

Le choix de big-pk/pk-uuid doit être fait avant la première migration. Une fois les migrations appliquées, basculer de mode est un changement cassant : les colonnes en base ont déjà un type concret, et changer la feature ne modifie que le type Rust — le schéma reste intact. Changer après coup nécessite une migration manuelle pour ALTER chaque colonne PK et FK, avec un risque de troncature (big-pk → défaut) ou d'incompatibilité totale de format (pk-uuid ↔ n'importe quel type entier). Choisissez un mode au démarrage du projet.


Types de champs

Type DSLType Rust généréColonne SQL
textStringVARCHAR(255) ou VARCHAR(n) si max_length: n
charStringCHAR
emailStringVARCHAR(254) — format validé
passwordStringVARCHAR(255) — haché automatiquement
richtextStringTEXT — éditeur HTML
textareaStringTEXT — multi-ligne
urlStringVARCHAR(255) — format validé
slugStringVARCHAR(255)
colorStringVARCHAR(255) — couleur hex
phoneStringVARCHAR(20) ou VARCHAR(n) si max_length: n
i8i8TINYINT
i16i16SMALLINT
inti32INTEGER
biginti64BIGINT
u32u32INTEGER UNSIGNED
u64u64BIGINT UNSIGNED
f32f32FLOAT
floatf64DOUBLE
percentf64DOUBLE — stocké comme float
decimalDecimalDECIMAL
boolboolBOOLEAN
dateNaiveDateDATE
timeNaiveTimeTIME
datetimeNaiveDateTimeDATETIME
timestampNaiveDateTimeTIMESTAMP
timestamp_tzDateTime<Utc>TIMESTAMPTZ
uuidUuidUUID
Pki32/i64/Uuidselon la feature — voir ci-dessus
jsonserde_json::ValueJSON
json_binaryserde_json::ValueJSON BINARY
binaryVec<u8>BINARY — taille via max_length: n
var_binaryVec<u8>VARBINARY(n)max_length: n requis
blobVec<u8>BLOB
ipStringINET
cidrStringCIDR
mac_addressStringMACADDR
intervalStringINTERVAL
imageStringVARCHAR(255) — chemin de fichier
documentStringVARCHAR(255) — chemin de fichier
fileStringVARCHAR(255) — chemin de fichier
choiceEnumNameVARCHAR / ENUM natif — requiert enum(NomEnum)
radioEnumNameIdem choice, widget différent
checkboxEnumNameIdem choice, widget différent

binary/var_binary réutilisent l'option max_length: n (même mécanisme que text + max_lengthVARCHAR(n)) — il n'existe pas de syntaxe binary(n) inline séparée.

Non disponible : decimal(precision, scale) inline (ex. decimal(10, 2)) n'a pas d'équivalent actuel — seul decimal sans paramètres est supporté. Contournement : appliquer la précision/l'échelle côté validation applicative plutôt que dans le schéma.


Options de champ

Dans un bloc [...], séparées par des virgules, valeur après : quand l'option en prend une :

username: text [required, max_length: 150, unique],
OptionDescription
requiredColonne NOT NULL + validation formulaire
nullableColonne NULL — type Rust Option<T>
uniqueContrainte UNIQUE
max_length: nLongueur max (validation + taille de colonne)
min_length: nLongueur min (validation)
min: nValeur min entière (validation)
max: nValeur max entière (validation)
min: n.0Valeur min flottante (validation)
max: n.0Valeur max flottante (validation)
default: valeurValeur par défaut SQL (true, 0, "draft", etc.)
auto_nowAssigné à NOW() à chaque INSERT — exclu des formulaires
auto_now_updateAssigné à NOW() à chaque UPDATE — exclu des formulaires
readonlyExclu de la migration générée (colonne existe côté Rust, non gérée par derive_form)
label: "str"Libellé personnalisé dans les formulaires admin
help: "str"Réservé — pas encore branché au rendu
upload_to: "path"Champ fichier — dossier d'upload
max_size: n MBChamp fichier — taille max (KB/MB/GB)
rows: ntextarea/richtext — hauteur du widget
step: nChamps numériques — pas du widget
fk(table.col, action)Contrainte clé étrangère (voir Relations)
enum(NomEnum)Lie le champ à un enum déclaré dans enums:
renamed_from: "x"Renomme la colonne (voir plus bas)
skipExclu des formulaires générés
no_hashChamps password uniquement — désactive le hachage automatique

readonly (nouveau, DB-level) est distinct du readonly de #[form] (field_readonly(), désactive un champ dans le rendu HTML d'une instance de formulaire précise). readonly sur le champ du modèle exclut la colonne de la migration générée ; field_readonly() désactive juste un widget au runtime. Les deux peuvent coexister.

auto_now / auto_now_update : ces champs sont exclus de admin_from_form et d'admin_partial_update. Leur valeur est gérée uniquement par la base. Ils apparaissent dans Model et Column comme Option<T>.

Renommer une colonne — renamed_from

Renommer un champ sans cette option produit un DROP COLUMN + ADD COLUMNperte de données. L'outil étant non interactif, il ne peut pas deviner l'intention : il faut le signaler explicitement.

// avant :  job_title: text,
// après :
title: text [renamed_from: "job_title"],

makemigrations génère alors un ALTER TABLE … RENAME COLUMN job_title TO title (supporté par PostgreSQL, MySQL/MariaDB et SQLite), sans perte de données. L'attribut est une directive de migration uniquement : il n'a aucun effet sur l'entité ou le formulaire générés. Garde-fou : si l'ancienne colonne existe toujours dans le snapshot (hint périmé), aucun rename n'est émis.

Fonctionne aussi bien dans model!{} que dans extend!{}.


Enums

Les enums se déclarent dans un bloc enums: { ... } distinct des champs, puis sont référencés via enum(NomEnum).

model! {
    Commande,
    table: "commandes",
    pk: id => i32,
    enums: {
        StatutCommande: [
            EnAttente  = ("en_attente",  "En attente"),
            EnCours    = ("en_cours",    "En cours"),
            Livree     = ("livree",      "Livrée"),
            Annulee    = ("annulee",     "Annulée"),
        ],
        Priorite: i32 [Basse = 0, Normale = 1, Haute = 2, Urgente = 9],
    },
    {
        statut:   choice [enum(StatutCommande), required],
        priorite: choice [enum(Priorite), required],
    },
}

Quatre formes de variant — à ne jamais confondre

Attention, piège fréquent : : et = ne font pas la même chose. Une seule différence de symbole change complètement le comportement, sans erreur de compilation pour vous avertir. Toujours vérifier sur ce tableau, ne pas deviner par analogie.

SyntaxeValeur stockée en DBLibellé affiché (Display)
Variant"Variant" (le nom)"Variant" — retombe sur la valeur DB
Variant: "Libellé""Variant" (inchangé)"Libellé"
Variant = "valeur_db""valeur_db""valeur_db" — retombe sur la valeur DB, pas sur Variant
Variant = ("valeur_db", "Libellé")"valeur_db""Libellé"

Résumé de la règle :

  • : (deux-points) ne touche que l'affichage — la valeur stockée reste toujours le nom du variant.
  • = seul (sans parenthèses) ne touche que la valeur stockée — l'affichage retombe dessus, jamais sur le nom du variant.
  • = (a, b) fixe les deux indépendamment — c'est la seule forme qui permet un nom de variant, une valeur DB et un libellé tous différents.

Le libellé est purement cosmétique. Il n'affecte :

  • ni le stockage réel (#[sea_orm(string_value = ...)] / #[sea_orm(num_value = ...)] utilisent toujours la valeur DB, jamais le libellé),
  • ni la comparaison de parsing (FromStr compare en priorité contre la valeur DB et le nom du variant Rust — le libellé n'est accepté qu'en repli, seulement s'il diffère des deux autres).

Changer un libellé (Published: "Publié"Published: "Mis en ligne") n'a donc aucun impact sur les données stockées ni sur le code qui compare des valeurs d'enum.

La valeur DB est stockée exactement telle qu'écrite. Aucune transformation automatique.

Types de backing

SyntaxeStockage DB
NomEnum: [A, B]ENUM natif (Postgres) ou VARCHAR (MySQL/SQLite)
NomEnum: i32 [...]INTEGER= fixe alors la valeur numérique, pas une chaîne
NomEnum: i64 [...]BIGINT — idem

Méthodes générées

MéthodeRetourDescription
.to_string()StringLibellé d'affichage
.db_value()&'static str / i32 / i64Valeur exacte en base
::from_str(s) / .parse()Result<Self, ()>Parsing depuis valeur DB, libellé, ou nom variant
::iter()impl Iterator<Item = Self>Itération sur tous les variants
use sea_orm::Iterable;

let s = StatutCommande::EnAttente;
s.db_value()   // → "en_attente"
s.to_string()  // → "En attente"

// Pour un <select>
let options: Vec<(String, String)> = StatutCommande::iter()
    .map(|v| (v.db_value().to_string(), v.to_string()))
    .collect();

// Parser depuis une valeur DB
let statut: Option<StatutCommande> = "en_attente".parse().ok();

Dans les templates Tera, la valeur de comparaison doit correspondre exactement à ce qui est stocké en base (sensible à la casse).


Champs fichier

model! {
    Article,
    table: "articles",
    pk: id => i32,
    {
        image:        image    [upload_to: "media/articles"],
        fichier:      document [upload_to: "docs/"],
        piece_jointe: file     [upload_to: "media/uploads"],
    },
}
TypeExtensions autorisées
imagejpg jpeg png gif webp avif
documentpdf doc docx txt odt
fileaucun filtre

upload_to: est obligatoire pour les trois types. Le chemin est relatif à MEDIA_ROOT.


Relations

relations: {
    belongs_to: Model via champ_fk,
    has_many: Model,
    has_many: Comments as user_comments,   // alias optionnel
    has_one: Profile as user_profile,
    many_to_many: Roles through UserRoles via self_id,
}
TypeContrainte DBDescription
belongs_to❌ code seulRelation N-1 (SeaORM)
has_many❌ code seulRelation 1-N
has_one❌ code seulRelation 1-1
many_to_many❌ code seulRelation N-N via pivot

Contrainte FK réelle : la contrainte SQL FOREIGN KEY et son action (cascade, restrict, set_null, set_default) sont déclarées sur l'option fk(table.col, action) du champ, pas dans le bloc relations:. Le bloc relations: génère uniquement les traits SeaORM pour la navigation objet.

Actions FK disponibles sur l'option fk(...) : cascade · restrict · set_null · set_default


Meta

meta: {
    ordering: [-created_at, titre],
    unique_together: [(slug, lang)],
    indexes: [(lang, sort_order)],
    verbose_name: "Article",
    verbose_name_plural: "Articles",
}
CléSyntaxeEffet
ordering[champ, -champ]Tri par défaut, - = DESC
unique_together[(col1, col2)]Contrainte UNIQUE multi-colonnes
indexes[(col1, col2)]Index simple multi-colonnes
verbose_name"chaîne"Nom singulier dans l'interface admin
verbose_name_plural"chaîne"Nom pluriel dans l'interface admin
abstracttrueModèle abstrait — aucune table générée

`extend!{}` — extension des tables framework

Ajoute des colonnes à une table Runique et génère une entité SeaORM complète sur cette table.

extend!{} produit deux choses :

  1. Schema SQLmakemigrations détecte le bloc et génère des instructions ALTER TABLE ADD COLUMN
  2. Entité complèteModel, Column, Entity, AdminForm, admin_from_form, admin_partial_update couvrant toutes les colonnes de la table (colonnes de base + colonnes étendues)
// 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],
    }
}

extend!{} exige toujours le mot-clé fields: avant le bloc de champs — contrairement à model!{} (bloc anonyme direct). Ce sont deux macros différentes, deux grammaires différentes ; ne pas transposer la syntaxe de l'une à l'autre.

Tables autorisées : eihwaz_users, eihwaz_groupes, eihwaz_sessions, eihwaz_users_groupes, eihwaz_groupes_droits. Tout autre nom provoque une erreur à la compilation.

Les champs déclarés dans extend!{} utilisent les mêmes types et options que model! (y compris renamed_from). Pas de bloc relations: dans extend!{} — les relations se déclarent dans model!{} cible avec has_many(user_profile) etc.

Enums dans extend!{}

extend!{} accepte un bloc enums: { ... } optionnel (entre table: et fields:), identique à celui de model!. La colonne choice [enum(NomEnum)] génère le type Rust enum, la colonne typée et le ChoiceField peuplé :

extend! {
    table: "eihwaz_users",
    enums: {
        Seniority: [Junior="junior", Mid="mid", Senior="senior", Lead="lead"],
    },
    fields: {
        job_title: text,
        seniority: choice [enum(Seniority)],
    }
}

makemigrations émet la colonne (sur PostgreSQL, un CREATE TYPE … AS ENUM ; ailleurs, un VARCHAR/ENUM natif).

Workflow complet

# 1. Déclarer l'extension dans src/entities/
# 2. Générer la migration
runique makemigrations

# 3. Appliquer
runique migrate

# 4. Enregistrer dans admin!{} (src/admin.rs)
admin! {
    configure {
        users: { hidden: true }   // masque le panel builtin "Utilisateurs"
    }
    user_profile: user_profile::Model => user_profile::AdminForm {
        title: "Profils utilisateurs",
        list_display: [
            ["username", "Utilisateur"],
            ["bio", "Bio"],
            ["is_verified", "Vérifié"],
        ],
    }
}

Ce qui est généré

SymboleDescription
ModelStruct avec toutes les colonnes (base + étendues)
ColumnEnum SeaORM pour les colonnes
EntityEntityTrait complet — utilisable avec search!
AdminFormFormulaire admin couvrant toutes les colonnes
admin_from_formConstruit un ActiveModel depuis les données du formulaire
admin_partial_updateConstruit un ActiveModel partiel pour la mise à jour

Requêtes depuis les vues

L'entité générée est un EntityTrait SeaORM standard — search! fonctionne directement :

// Tous les profils vérifiés
let profiles = search!(user_profile::Entity => is_verified eq true).fetch(&db).await?;

// Recherche multi-colonnes
let results = search!(user_profile::Entity => or(username icontains q, bio icontains q)).fetch(&db).await?;

Relations vers l'entité étendue

D'autres entités peuvent pointer vers l'entité étendue via le bloc relations: habituel de model!{} :

model! {
    Article,
    table: "articles",
    pk: id => i32,
    { auteur_id: int [required] },
    relations: {
        belongs_to: user_profile::Model via auteur_id,
    }
}