Skip to main content

RecordForm

Derive Macro RecordForm 

Source
#[derive(RecordForm)]
{
    // Attributes available to this derive:
    #[form]
}
Expand description

Derive RecordForm for the typed value a resource’s form writes.

One field per model column the form writes, named and typed like the model’s field. A scalar (String, a TypedValue type, an Options enum, or an Option of one) binds the key its control posts; a #[form(embed)] field binds every key of an EmbeddedForm value and is written whole.

#[derive(tablo_core::RecordForm)]
#[form(model = User)]
pub struct UserForm {
    pub name: String,
    #[form(blank = "member")]
    pub role: String,
    #[form(blank = 0)]
    pub age: i64,
}

The derive also emits UserFormField, one variant per field in Pascal case, which Posted and Resource::validate_record’s FieldErrors key on and RecordForm::fields answers with each variant’s keys. Two fields whose names make one variant, such as foo_bar and foo__bar, fail to compile at the second. It emits UserFormControls, one control per field chosen from the field — a bool is a toggle, #[form(options = T)] a choice over T’s options, #[form(options)] a choice over the field type’s options (a multiple choice over a Vec of options), #[form(tags)] a multiple choice taking typed values, #[form(relationship = R)] a choice over R’s records (a multiple choice over a Vec of their keys), #[form(file)] a file field, #[form(embed)] the embedded value’s schema, #[form(repeat)] a repeater, and any other field a text field — with controls() handing them over, typed by the form so only a Schema<UserForm> places them, and RecordForm::control answering one field’s. A resource’s ResourceDef defaults its form to them; ResourceDef::form arranges or extends them instead, and a form renders the controls it does not place after the ones it does. The table and detail page read the model, not the form: see ModelColumns.

§Attributes

  • #[form(model = User)] on the struct: the model the form writes.
  • #[form(blank = <expr>)] on a scalar: the value an empty submission reads as.
  • #[form(optional)] on a String: an empty submission reads as "".
  • #[form(required)] on an Option: an empty submission is refused, not read as None, so a nullable column, such as a product’s slug, is filled on every write through the form.
  • #[form(options)] on an Options enum or an Option of one: a choice over its options. Without it, the field is a text field.
  • #[form(options = Status)]: a choice over Status::options().
  • #[form(options = Channel)] on a Vec<String>, or #[form(options)] on a Vec of an Options enum: a multiple choice, a checkbox per option, stored in the list column. The form posts the key once per checked box, after a hidden blank, so a submission with no box checked stores the empty list, its blank answer; an edit that does not post it keeps the stored list. A value the options do not offer is refused, and min_items and max_items bound the count.
  • #[form(tags)] on a Vec<String>: a tags field, a multiple choice taking the values the user types, stored in the list column like the above; #[form(tags, options = Topic)] suggests Topic::options().
  • #[form(relationship = AuthorResource)] on a foreign key: a choice over the source’s records, each labelled by its record_title. The source is any OptionSource, which every Resource is.
  • #[form(relationship = TagResource)] on a Vec of the source’s keys, named like the model’s #[has_many(via = joins.target)] field: a many-to-many field, a multiple choice whose records the write links through the join model, one row each. No record is its blank answer; an edit that does not post it keeps the record’s links.
  • #[form(file)] on a String: a file field.
  • #[form(embed)] on an EmbeddedForm value.
  • #[form(repeat)] on a #[document] list of FormInput items: a repeater, a row per item, which the browser adds, removes and moves. No rows is its blank answer.

A scalar’s blank answer is its blank, "" for an optional String, None for an Option not marked required, or false for a bool. A field with none is required: the panel renders its control required and the parse refuses an empty submission. No control declares presence.

A generic struct, a tuple struct, an empty struct, a Deferred<_> field, blank or optional on an Option, an embedded value, a multiple choice or a many-to-many field, optional on a type other than String, required on a type other than Option, and an unknown key are compile errors. So are a field the model lacks, a type the model’s field does not have, a scalar that is not a FormScalar, and a many-to-many field whose keys are not its source’s primary key.

§Many-to-many fields

  • The control is a multiple choice: a checkbox per record of the related resource, offered as a relationship choice’s options are, with the records the record links checked. .searchable() filters the boxes as the user types; without JavaScript every box shows.
  • The form posts the field’s key once per checked box, after a hidden blank, so a submission with no box checked links no record. Each key the record does not link yet must name a record the choice offers, and the write checks each again inside its transaction.
  • A create links the new record to each chosen record, one join row each. An edit adds the rows of the records it chose and deletes those of the ones it dropped; one that does not post the field keeps the links. An overridden create_record or update_record links only by delegating to write_create or write_update.
  • An edit unlinks only the records its form could offer: a linked record the related resource’s policy hides from the user, or one in another tenant, stays linked.
  • The choice offers at most 200 records. Past that it offers none, shows the ones the record links, and an edit keeps or drops those but links no other; attach records from a related table instead.
  • A declared table or detail page lists the linked records by their titles with RelationColumn::list::<TagResource>(relation!(Article.tags)).
  • A new link sets the two keys and nothing else, so mounting refuses a join model with another column that is neither nullable nor #[auto], or a key spanning several columns. A join model’s #[default] and #[update] expressions are not applied: a nullable column with one stays NULL.
  • Making the two keys the join model’s primary key, as #[key(article_id, tag_id)], has the database store each pair once, whatever two concurrent writes do. Mounting refuses a relationship’s multiple choice in a resource’s form that names no many-to-many field. In an action’s input, parse_list reads a multiple choice.

§Submitting

  • Validation runs in one round. The record form’s parse (required fields and typed values), email, relationships and uniqueness are checked together, and the form re-renders with every error inline, with status 200 and nothing written. validate_record needs the parsed form, so it runs only when every field parses. It returns FieldErrors<UserFormField>, keyed by the record form’s field enum (errors.add(UserFormField::Age, "Age must be zero or more")), so every error renders under its field’s control, or an embedded value’s first control. A rule that reads the database refuses from create_record or update_record instead, which render their WriteError::Refused errors the same way.
  • Unknown keys are refused. A POST carrying a key the form does not declare answers 400, so a client cannot write role or tenant_id. The CSRF token, the edit form’s record_version, the create form’s create_another and the file fields’ clear_ and keep_ keys are the exceptions.
  • A create lands on the list, or the page’s validated ?return= target, with the success flash. One posting create_another=1, as the create page’s second button does, lands on the create page instead, after the same checks and the same write, keeping ?return= and the query keys that seed a relationship control, such as a relation table’s owner.
  • An edit writes only what was posted. On an edit, a declared key missing from the submission keeps its stored value, and the update assigns only the fields the submission named, plus the model’s own #[update(..)] defaults and #[version] bump. An API client can post a single field. A control submitted empty is posted, and stores its field’s blank value.
  • A save another save overtook is refused. When the model has a #[version] field and derives ModelColumns, the edit form carries the version it opened on as record_version. A save whose version no longer matches the stored record writes nothing and re-renders the form with the submitted values, an alert above them, and the current version, so saving again overwrites the other save. A save that lands between the edit’s load and its write fails Toasty’s version condition and re-renders the same way where the database reports it so, as PostgreSQL and MySQL do; SQLite can refuse the transaction as busy instead, and Turso’s concurrent transactions conflict at commit, which both answer 500. A POST without record_version skips the first comparison, not the second; one whose record_version is no number answers 400. Only the edit form carries the version: actions, relation managers and the profile pages write without it, and the default Delete removes the record whatever its version. Mounting refuses a form field named record_version, create_another or csrf_token. Derive RecordForm for the typed value a resource’s form writes; documented on the tablo-core re-export.