This version is in beta. Some features may change before release.

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 typeBecomesValidationRender
ForeignKey<T> / forward OneToOne<T> / Option<ForeignKey<T>>ModelChoiceid parsed + existence-checked through the ORM<select> of (id, label) rows fetched from T
M2M<T>ModelMultiChoiceeach submitted id existence-checked in ONE batched querymulti-<select>; selected ids written to the junction table after insert
#[umbral(choices)] enumSelectmembership against the enum's compile-time values (no DB)<select> of the enum's VALUES/LABELS
ReverseSet<C> / reverse OneToOne<T> (#[sqlx(skip)])skippedn/aabsent 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.

Info
Reverse relations (`ReverseSet`, the `#[sqlx(skip)]` reverse `OneToOne`) are back-pointers, never user-submittable, so the derive skips them automatically. You do **not** need `#[umbral(noform)]` on them.

ForeignKey → ModelChoice

Code
rust
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

Code
rust
#[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

Code
rust
#[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, and M2M field 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: async FormValidate).
ormformsforeign-keymany-to-manychoicesvalidation