ReferenceEdit on GitHub

Type mappings#

What each field type becomes under Table, Form and Resource: the column, the input, the parse, and what an absent submission means. The types are in the API: Field, Form, Resource, Column, PgType.

Field types#

Scala typeColumn: Postgres typeField: input typeabsent submissionparse failure
Stringtexttextrequired
Intintegernumberrequired"is not a number"
Longbigintnumberrequired"is not a number"
Doublenonenumberrequired"is not a number"
Booleanbooleancheckboxfalse
BigDecimalnumeric(19,4)none
UUIDuuidtextrequired"is not an id"
Id[T]uuid, primary key when named idtextrequired"is not an id"
Ref[T]uuid, a foreign key to T's table, column named <field>_idnone
LocalDatedatedaterequired"is not a date"
LocalDateTimenonedatetime-localrequired"is not a date and time"
Instanttimestamptzdatetime-localrequired"is not a date and time"
Array[Byte]byteanone
Option[A]A's type, nullableA's inputNone; empty text is also NoneA's
Password (eezo-auth)written by you, Column[String].imap(Password.stored)(_.value)password, hashed on read, never shownrequired"is longer than the 72 bytes bcrypt reads"
Password.Plainnonepassword, kept as typedrequiredsame

"Required" means the field reports "is required" when its key is missing from the body. A checkbox's value is true for anything but an empty string or false. Numbers are trimmed before parsing. A Field whose column is "none" works in a form and can't be a column of a table; a Column whose input is "none" can be stored and can't appear in a form.

The key is never rendered as an input. Form.parse takes it from the caller: a minted one on create, the path parameter on update.

Labels and names#

An input's name is the field's exact Scala label. Its label text is humanised: publishedOn becomes "Published on", with no dictionary. A table's name is the class name in snake case, and so is each column's. A resource's path is the snake-cased class name pluralised: +s, a consonant before y becomes ies, a sibilant takes es. blog_post mounts at /blog_posts.

What a model has to be#

derivationrequires
Tablea case class, one parameter list, no type parameters, a field id: Id[Model], a Column for every field type
Forma case class with a Field for every field type; no key needed
ResourceForm, plus a field id: Id[Model]; Actions defaults to all seven

The seven routes#

actionroutestore callon success
IndexGET /booksall()200
NewGET /books/new200
ShowGET /books/:idfind(key)200, or 404
EditGET /books/:id/editfind(key)200, or 404
CreatePOST /booksinsert(key, row)303 to show, or to the index when Show isn't mounted; 422 on a rejected form
UpdatePUT /books/:idupdate(key, row)303 to show; 404 when the row is gone; 422 on a rejected form
DestroyDELETE /books/:iddelete(key)303 to the index; 404 when the row is gone

A :id that isn't a UUID is a 400. PUT and DELETE arrive as a POST with a hidden _method field, which the form and the delete button carry.

Subtracting an action#

object Book {
  given Actions[Book] = Actions.except(Action.Destroy)   // or Actions.only(...)
}

Subtracting a role removes its route and every control that pointed at it: no Delete button without Destroy, no Edit link without Edit, no "New" link without New. Subtracting Show sends a successful write to the index. Subtracting Create or Update while keeping New or Edit mounts a form page whose submit answers 405, and boot warns about it unless a handwritten route serves the target.

Your own types#

A form field:

given Field[Isbn] = Field.of("text")(_.value)(text =>
  Isbn.parse(text).toRight("is not an ISBN")
)

Three arguments: the input type, the value as text, and text to either a value or the message the user sees. Override absent for a type that has a meaning when the key is missing.

A column:

given Column[Isbn] = Column[String].imap(Isbn.unsafe)(_.value)

imap reuses the underlying type's Postgres type and JDBC calls. withType(PgType.Varchar(13)) changes the column type, and withCheck(Check.MaxLen(13)) adds a check constraint. The checks are MinLen, MaxLen, Positive, Between and Raw, where Raw("{} ~ '^[0-9]+$'") puts the quoted column name at {}.

The case class is the source of truth is why the three are separate. Migrations is what a Column's type and checks become in SQL.