Forms learn relations
#[derive(Form)] on a model with ForeignKey, OneToOne, M2M, and choices fields. FK/O2O become a ModelChoice, M2M a ModelMultiChoice, choices a Select, and reverse relations are auto-skipped.
Forms learn relations
#[derive(umbral::forms::Form)] reads the same model struct your migrations come from, so a model with relation fields becomes a submittable form with no parallel struct. The derive classifies each relation field and wires the validation + render path through the ORM.
| Field type | Becomes | Validation | Render |
|---|---|---|---|
ForeignKey<T> / forward OneToOne<T> / Option<ForeignKey<T>> | ModelChoice | id parsed + existence-checked through the ORM | <select> of (id, label) rows fetched from T |
M2M<T> | ModelMultiChoice | each submitted id existence-checked in ONE batched query | multi-<select>; selected ids written to the junction table after insert |
#[umbral(choices)] enum | Select | membership against the enum's compile-time values (no DB) | <select> of the enum's VALUES/LABELS |
ReverseSet<C> / reverse OneToOne<T> (#[sqlx(skip)]) | skipped | n/a | absent from fields() |
FormValidate::validate and render_html are async: FK/M2M existence checks and <select> option fetches resolve through the ambient pool. validate is where a relation is verified before insert - a miss is a field-keyed error and no row is written; render_html only reads option rows for display.
ForeignKey → ModelChoice
use umbral::orm::{ForeignKey, Model};use umbral::forms::FormValidate; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "author")]struct Author { pub id: i64, pub name: String } #[derive(Debug, Clone, Default, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model, umbral::forms::Form)]#[umbral(table = "book")]struct Book { #[umbral(primary_key)] pub id: i64, #[form(required, length(min = 1, max = 200))] pub title: String, pub author: ForeignKey<Author>, // → a ModelChoice} // `author` is parsed into a typed ForeignKey, existence-checked against// the `author` table, then `create()` persists the row.let book = Book::validate(&data).await?;let created = Book::objects().create(book).await?;#[form(label_field = "name")] overrides the column used for option labels; the default is the target's first non-PK text column.
M2M → ModelMultiChoice
#[derive(Debug, Clone, Default, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model, umbral::forms::Form)]#[umbral(table = "article")]struct Article { #[umbral(primary_key)] pub id: i64, #[form(required, length(min = 1, max = 200))] pub title: String, #[sqlx(skip)] #[serde(skip)] pub tags: umbral::orm::M2M<Tag>, // → a ModelMultiChoice} // The submitted tag ids are verified in ONE batched query and staged on// the M2M field; create() writes them to the junction table after the// parent insert, atomically. A single bad id fails validation before// any row is inserted.let article = Article::validate(&data).await?;let created = Article::objects().create(article).await?;Choices → Select
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, umbral::orm::Choices, serde::Serialize, serde::Deserialize)]#[choices(rename_all = "lowercase")]enum Mood { #[default] Happy, Sad, Neutral } #[derive(Debug, Default, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model, umbral::forms::Form)]#[umbral(table = "note")]struct Note { pub id: i64, pub body: String, #[umbral(choices)] pub mood: Mood, // → a Select; options from the enum's VALUES/LABELS}A non-member value is a field-keyed error; a valid value decodes straight back into the enum. Option<EnumChoices> drops Required and renders a leading empty option.
See also
- Relationships: the
ForeignKey,OneToOne, andM2Mfield types themselves. - Column types:
#[umbral(choices)]enums via#[derive(Choices)]. - Design spec:
docs/superpowers/specs/2026-06-11-orm-relations-forms-and-joins-design.md(Part 1: field classification; Part 2: asyncFormValidate).