derive_form 1.1.34

Procedural macros for Runique web framework - automatic form generation from models
Documentation

derive_form

Procedural macros for the Runique web framework.

Exposes two macros:

  • model!(...) — DSL to declare a SeaORM model and generate its schema
  • #[form(...)] — attribute macro to generate a form struct from a model schema

model!(...)

Declares a database model and generates the corresponding SeaORM entity, ActiveModel, relations, and a schema() function used by #[form(...)].

Syntax

model! {
    ModelName,
    table: "table_name",
    pk: field_name => pk_type,
    fields: {
        field: Type [option1, option2, ...],
        ...
    }
}

Minimal example

use runique::prelude::*;

model! {
    Post,
    table: "posts",
    pk: id => i32,
    fields: {
        title:   String [required, max_len(255)],
        content: String [required],
        slug:    String [required, unique],
        views:   i32    [required, default(0)],
        published: bool [required, default(false)],
        created_at: datetime [auto_now],
        updated_at: datetime [auto_now_update],
    }
}

Primary key types

Syntax Column type Notes
pk: id => i32 INTEGER (32-bit) Auto-increment by default
pk: id => i64 BIGINT (64-bit) Auto-increment by default
pk: id => uuid UUID No auto-increment
// Integer PK (most common)
model! {
    Article,
    table: "articles",
    pk: id => i32,
    fields: { title: String [required] }
}

// UUID PK
model! {
    Session,
    table: "sessions",
    pk: token => uuid,
    fields: { user_id: i32 [required] }
}

Field types

Type SQL type Example usage
String VARCHAR(255) name: String [required, max_len(100)]
text TEXT bio: text [nullable]
i8 TINYINT score: i8 [required]
i16 SMALLINT rank: i16 [required]
i32 INTEGER count: i32 [required, default(0)]
i64 BIGINT views: i64 [required, default(0)]
f32 FLOAT rating: f32 [nullable]
f64 DOUBLE price: f64 [required]
bool BOOLEAN is_active: bool [required, default(true)]
date DATE birth_date: date [nullable]
time TIME start_time: time [nullable]
datetime DATETIME created_at: datetime [auto_now]
timestamp TIMESTAMP expires_at: timestamp [nullable]
uuid UUID token: uuid [required, unique]
json JSON metadata: json [nullable]
blob BLOB data: blob [nullable]
model! {
    Profile,
    table: "profiles",
    pk: id => i32,
    fields: {
        username:   String   [required, max_len(50), unique],
        bio:        text     [nullable],
        age:        i16      [nullable, min(0), max(150)],
        score:      f64      [required, default(0.0)],
        is_active:  bool     [required, default(true)],
        birth_date: date     [nullable],
        avatar_url: String   [nullable],
        metadata:   json     [nullable],
        token:      uuid     [required, unique],
        created_at: datetime [auto_now],
        updated_at: datetime [auto_now_update],
    }
}

Field options

Option Description
required Column is NOT NULL
nullable Column accepts NULL
unique UNIQUE constraint
index Create a database index
default(value) Default value — default(0), default(true), default("draft")
max_len(n) Max string length (validation + VARCHAR(n))
min_len(n) Min string length (validation)
max(n) / max_f(n) Max integer / float value (validation)
min(n) / min_f(n) Min integer / float value (validation)
auto_now Set to NOW() on INSERT (timestamps)
auto_now_update Set to NOW() on UPDATE (timestamps)
fk(table.col, action) Foreign key — see section below
label("...") Display label for generated forms
help("...") Help text for generated forms
model! {
    Product,
    table: "products",
    pk: id => i32,
    fields: {
        name:        String [required, max_len(200), label("Product name")],
        description: text   [nullable, help("Detailed product description")],
        price:       f64    [required, min_f(0.0)],
        stock:       i32    [required, default(0), min(0)],
        sku:         String [required, max_len(50), unique],
        is_active:   bool   [required, default(true)],
        created_at:  datetime [auto_now],
        updated_at:  datetime [auto_now_update],
    }
}

Foreign keys

Use the fk(table.column, action) option to declare a foreign key.

Actions: cascade, set_null, restrict, set_default, no_action

model! {
    Comment,
    table: "comments",
    pk: id => i32,
    fields: {
        // FK → posts.id, delete comment when post is deleted
        post_id:    i32    [required, fk(posts.id, cascade)],
        // FK → users.id, set null when user is deleted
        author_id:  i32    [nullable, fk(users.id, set_null)],
        content:    text   [required],
        created_at: datetime [auto_now],
    }
}

Complete example — blog application

use runique::prelude::*;

// ── Users ──────────────────────────────────────────────────────
model! {
    User,
    table: "users",
    pk: id => i32,
    fields: {
        username:   String   [required, max_len(150), unique],
        email:      String   [required, unique],
        password:   String   [required, max_len(128)],
        is_active:  bool     [required, default(true)],
        is_staff:   bool     [required, default(false)],
        created_at: datetime [auto_now],
        updated_at: datetime [auto_now_update],
    }
}

// ── Categories ─────────────────────────────────────────────────
model! {
    Category,
    table: "categories",
    pk: id => i32,
    fields: {
        name: String [required, max_len(100), unique],
        slug: String [required, max_len(100), unique],
    }
}

// ── Posts ──────────────────────────────────────────────────────
model! {
    Post,
    table: "posts",
    pk: id => i32,
    fields: {
        title:       String   [required, max_len(255)],
        slug:        String   [required, unique, max_len(255)],
        content:     text     [required],
        excerpt:     String   [nullable, max_len(500)],
        author_id:   i32      [required, fk(users.id, cascade)],
        category_id: i32      [nullable, fk(categories.id, set_null)],
        is_published: bool    [required, default(false)],
        views:       i64      [required, default(0)],
        created_at:  datetime [auto_now],
        updated_at:  datetime [auto_now_update],
    }
}

// ── Comments ───────────────────────────────────────────────────
model! {
    Comment,
    table: "comments",
    pk: id => i32,
    fields: {
        post_id:    i32  [required, fk(posts.id, cascade)],
        author_id:  i32  [nullable, fk(users.id, set_null)],
        content:    text [required],
        is_approved: bool [required, default(false)],
        created_at: datetime [auto_now],
    }
}

impl_objects! — ORM manager (Django-style)

Activate the objects manager on a model to get Django-style queries:

use runique::prelude::*;

model! {
    Post,
    table: "posts",
    pk: id => i32,
    fields: {
        title:        String [required],
        is_published: bool   [required, default(false)],
        views:        i64    [required, default(0)],
        author_id:    i32    [required, fk(users.id, cascade)],
        created_at:   datetime [auto_now],
    }
}

impl_objects!(Entity);
// In a handler:
async fn posts_handler(ctx: Request) -> Response {
    let db = ctx.db();

    // All posts
    let all = Entity::objects.all().all(db).await.unwrap();

    // Filter: published posts, sorted by views descending
    let published = Entity::objects
        .filter(Column::IsPublished.eq(true))
        .order_by_desc(Column::Views)
        .limit(10)
        .all(db)
        .await
        .unwrap();

    // Exclude drafts
    let visible = Entity::objects
        .exclude(Column::IsPublished.eq(false))
        .all(db)
        .await
        .unwrap();

    // Count
    let total = Entity::objects.count(db).await.unwrap();

    // Get by ID — returns Err if not found
    let post = Entity::objects.get(db, 1).await.unwrap();

    // Get by ID — returns None if not found
    let maybe = Entity::objects.get_optional(db, 99).await.unwrap();

    // Get or auto-404
    let post_or_404 = Entity::objects
        .get_or_404(db, 1, &ctx, "Post not found")
        .await
        .unwrap();

    ctx.render("posts/list.html", context! { posts: published, total })
}

#[form(...)]

Generates a form struct from the schema produced by model!.

use runique::prelude::*;

model! {
    Post,
    table: "posts",
    pk: id => i32,
    fields: {
        title:        String [required, max_len(255), label("Title")],
        content:      text   [required, label("Content")],
        excerpt:      String [nullable, max_len(500)],
        is_published: bool   [required, default(false)],
        created_at:   datetime [auto_now],
        updated_at:   datetime [auto_now_update],
    }
}

// Only expose title, content, excerpt, is_published — auto_now fields excluded
#[form(schema = post_schema, fields = [title, content, excerpt, is_published])]
pub struct PostForm;

#[form] parameters

Parameter Required Description
schema yes Path to the schema function generated by model!
fields no Whitelist — only include these fields
exclude no Blacklist — exclude these fields

fields and exclude are mutually exclusive. The primary key is always excluded.

Using the form in a handler

pub async fn create_post(
    mut req: Request,
    Prisme(mut form): Prisme<PostForm>,
) -> impl IntoResponse {
    if form.is_valid().await {
        form.save(&req.engine.db).await.ok();
        return Redirect::to("/posts").into_response();
    }

    ctx.render("posts/new.html", context! { form })
}

License

MIT — part of the Runique project.