Models and AST (`model!`)

Link with forms & technical considerations

Link with forms via #[form(...)]

The #[form(...)] attribute macro expects:

  • schema = function_path (required)
  • fields = [..] (optional)
  • exclude = [..] (optional)
  • model = Entity (optional) — links the form to a SeaORM entity

It generates:

  • a struct containing form: Forms
  • impl ModelForm (schema(), fields(), exclude())
  • if model is present: impl FormEntity + pub const objects

The developer then writes impl RuniqueForm with impl_form_access!(model):

use runique::prelude::*;

#[form(schema = user_schema, fields = [username, email])]
pub struct UserForm;

impl RuniqueForm for UserForm {
    impl_form_access!(model);
}

With model — ORM access from the form

Adding model = Entity makes the form a direct entry point for ORM queries, without going through the entity type.

#[form(schema = user_schema, model = users::Entity)]
pub struct UserForm;

#[form(schema = blog_schema, model = blog::Entity)]
pub struct BlogForm;

#[form(schema = document_schema, model = document::Entity)]
pub struct DocumentForm;

This automatically generates:

impl FormEntity for UserForm {
    type Entity = users::Entity;
}
impl UserForm {
    pub const objects: Objects<users::Entity> = Objects::new();
}

Direct ORM access via the form:

// All records
let users = UserForm::objects.all().all(&db).await?;

// With filter
let user = UserForm::objects
    .filter(users::Column::Email.eq("alice@example.com"))
    .first(&db)
    .await?;

// Via the search! macro
let results = search!(@UserForm => Username = "alice").all(&db).await?;
let adults  = search!(@UserForm => Age >= 18).all(&db).await?;

// All search! operators are supported
let results = search!(@BlogForm =>
    Status = ("published" | "featured"),
    AuthorId = author_id,
)
.order_by_desc(blog::Column::CreatedAt)
.limit(10)
.all(&db)
.await?;

model is compatible with fields and exclude — they can be freely combined.

#[form(schema = user_schema, fields = [username, email], model = users::Entity)]
pub struct UserForm;

With business validation (clean)

Override clean directly in impl RuniqueForm — just like Django. #[async_trait] is only required when overriding an async method:

#[form(schema = user_schema, fields = [username, email, password])]
pub struct RegisterForm;

#[async_trait]
impl RuniqueForm for RegisterForm {
    impl_form_access!(model);

    async fn clean(&mut self) -> Result<(), StrMap> {
        let mut errors = StrMap::new();
        if self.get_string("username").len() < 3 {
            errors.insert("username".to_string(), "Minimum 3 characters".to_string());
        }
        if !self.get_string("email").contains('@') {
            errors.insert("email".to_string(), "Invalid email".to_string());
        }
        if errors.is_empty() { Ok(()) } else { Err(errors) }
    }
}

is_valid() automatically calls clean after structural validation. Returned errors are attached to fields and displayed inline in the template.


Technical considerations

Advantages

  • Single model/schema contract, centralized
  • Coherent generation of migrations + forms
  • Reduced duplication of field definitions
  • clean is the official trait override — uniform between manual and model-based forms

Points of attention

  • Strict DSL: a syntax error causes a macro build error
  • Misaligned fields/exclude with the schema can cause generation or runtime errors
  • #[async_trait] required on impl RuniqueForm only when overriding clean or clean_field

Known limitation — field override not yet supported

Overriding an individual field auto-generated by #[form(...)] or model! is not yet supported.

It is currently not possible to customize a single field (e.g. add .max_size_mb(5) or change the label) without rewriting the entire register_fields by hand, which defeats the purpose of the macro.

Current workaround: write a fully manual form and declare all fields explicitly.

// ❌ Not yet possible
#[form(schema = article_schema)]
pub struct ArticleForm;

impl RuniqueForm for ArticleForm {
    impl_form_access!(model);
    // override just the image field → impossible without rewriting everything
}

// ✅ Workaround: manual form
impl RuniqueForm for ArticleForm {
    impl_form_access!();
    fn register_fields(form: &mut Forms) {
        form.field(&TextField::text("title").label("Title").required());
        form.field(
            &FileField::image("image")
                .upload_to("media/articles")
                .max_size_mb(5),
        );
    }
}

This limitation will be addressed in v2.0 with the refactoring of the field system into widgets, which will allow declaring and overriding any field directly from the model.