Skip to content

Create and edit records

form is the region that writes one row. It renders a control per field, validates the submission, and writes it in one transaction.

page member "Member" {
route: /members/{member}
new_route: /members/new
access: board
form of members {
fields: first_name, last_name, email, fee_group, joined_on
actions: save, delete
success: "Member saved."
}
}

One page serves both addresses. On /members/new the form is empty and a save inserts. On /members/42 the form is filled and a save updates.

actions: decides which buttons exist. A delete renders as a separate confirmation that is two deliberate clicks away, and it needs no JavaScript.

A save is a POST, then a redirect, then a GET. The message survives the redirect, so a reload never repeats a write.

Before anything is written, the engine checks the submission against the column types and against every rule the table declares. If a check fails, the form renders again with the exact text the person typed and a message on each field that failed.

The form does not reformat the other forty fields while it rejects three. A person has to find their own mistake.

set writes a column from the request rather than from a control. The column gets no control at all, so a request cannot supply it.

page signup "Sign up" {
route: /events/{event}/signup
access: member
form of event_signups {
fields: note
actions: save
set event = :route.event
set person = :user.person_id
}
}

A hidden field can be posted by anyone. A set is not readable from the request at all, which is the difference that matters.

deny when refuses the whole form when a condition holds.

form of event_signups {
fields: note
actions: save
set event = :route.event
deny when count(event_signups.event) >= event.capacity
"This event is fully booked."
}

The message replaces the form on a GET, so nobody fills in a form that cannot be saved. On a POST the same rule is checked again inside the write’s own transaction. Two people who press save at the same moment cannot both pass a capacity check.

A table can declare rules that the engine evaluates against the row that a save produces.

table events "Events" {
display: name
name text required "Name"
starts_at datetime required "Starts"
ends_at datetime "Ends"
capacity int "Capacity" { min: 1 }
check ends_at is empty or ends_at >= starts_at
"The end must not be before the start."
require capacity when kind = limited
"A limited event needs a capacity."
unique(name, starts_at)
"An event with this name already exists on that date."
}

Each rule carries the sentence a person reads. A database constraint that fires is mapped back to the author’s message, so a reader never sees the constraint name.

A ref field offers the rows that exist. Sooner or later the row somebody needs does not exist yet, and they have a half-filled form on screen.

Name the page that creates one:

form of events {
fields: title, category
field category { add_via: admin_category }
}

The engine draws a control beside the dropdown. The reader opens it, fills in the new category, and saves. The category becomes the selected option, and what they had typed is still there.

Three things this does not change, and they are the reason it is one line:

  • The named page decides who may create a row. A reader that page does not admit sees no control. Do not write a second rule here.
  • The named page decides what a save does — its fields, its checks, its refusals. The refusal appears in the dialog, in the words that page would have used.
  • The save is validated the same way. The option the browser adds carries a row id, and the form checks every id it receives against the rows that reader may choose.

With JavaScript turned off the control is a link to the create page. The reader makes the row there and comes back. Tell your users nothing about this: it is the walk they take today.

See add_via: for the compile errors.

Mark a column sensitive, then grant it in the rule of each role that can use it. A form renders a control for a granted column only. See Control who sees what.

The engine owns some columns and a request can never supply one, even if it names it:

  • the primary key.
  • the audit columns created_at, created_by, updated_at, updated_by.
  • a slug and an unguessable.
  • the lifecycle state column.
  • the metadata columns of an img.

A column like this renders as a value and not as a control. A request that names one anyway is ignored, and the engine writes a log line naming the column and the reason.