models and migrations
a senro app keeps its data in Postgres. this guide covers where the schema comes from, how a model is tied to a table, the policies that guard each write, and the rules that decide whether a record is fit to keep.
migrations are the schema
an app's migrations are a list of values in one module, oldest first. each is built from a fixed set of changes, models/create_table and models/add_column here, so a migration written a year ago cannot break when a model changes today.
import "../../models"
# The guestbook's schema, as the changes that built it, oldest first. The
# models derivation folds these into the schema the app is compiled against.
pub all = [
signed
joined
emailed
confirmable
counted
lockable
tracked
arrived
arrived_before
arrived_from
arrived_from_before
pictured
]
signed = models/create_table "signatures" [id name message]
id = models/column "id" "serial"
joined = models/create_table "accounts" [id name digest]
# The address a reset link is sent to.
emailed = models/add_column "accounts" email
# When the address was confirmed, or empty until it is.
confirmable = models/add_column "accounts" confirmed
# How many wrong passwords in a row the account has had, and when too many
# of them locked it, or empty while it is open.
counted = models/add_column "accounts" failures
lockable = models/add_column "accounts" locked
# How many times the account has signed in, and when and from where the
# latest sign-in and the one before it came, as Devise's trackable keeps them.
tracked = models/add_column "accounts" sign_in_count
arrived = models/add_column "accounts" current_sign_in_at
arrived_before = models/add_column "accounts" last_sign_in_at
arrived_from = models/add_column "accounts" current_sign_in_ip
arrived_from_before = models/add_column "accounts" last_sign_in_ip
# The key of the photo a signer chose, or empty for an entry without one.
pictured = models/add_column "signatures" photo
name = models/column "name" "text"
message = models/column "message" "text"
digest = models/column "digest" "text"
email = models/column "email" "text"
confirmed = models/column "confirmed" "text"
failures = models/column "failures" "integer not null default 0"
locked = models/column "locked" "text not null default ''"
sign_in_count = models/column "sign_in_count" "integer not null default 0"
current_sign_in_at = models/column "current_sign_in_at" blank_text
last_sign_in_at = models/column "last_sign_in_at" blank_text
current_sign_in_ip = models/column "current_sign_in_ip" blank_text
last_sign_in_ip = models/column "last_sign_in_ip" blank_text
photo = models/column "photo" blank_text
blank_text = "text not null default ''"
derive models/tables folds the list into the schema while the app compiles. there is no schema file to keep in step, and no command to regenerate one. the entry runs models/migrated db app migrations/all as it starts. it runs the migrations the database has not yet run, in order, and records each in a senro_migrations table under the app's name and its place in the list, beside the SQL it ran, as Rails records them in schema_migrations. a migration runs once, and one whose SQL has changed since it ran stops the app, naming it, so a new change goes at the end of the list. each change does nothing when its work is already done, so a database made before migrations were recorded takes the list safely the first time. then it runs models/compared db schema, which reads the live tables and refuses to serve if a column's name, type or nullness differs or a table is missing. defaults and constraints are not compared.
a table that lists models/timestamps among its columns gets created_at and updated_at, the two columns Rails' t.timestamps adds. each is timestamptz not null default now(), so Postgres sets both when a row is made. models/updated sets updated_at on every change it writes, and models/created and models/updated drop a form's value for either, so a request cannot backdate a row. the two are also models/created_at and models/updated_at, one at a time, which is how the scaffold ends every table it makes.
notes = models/create_table "notes" (list/concat [id body] models/timestamps)
models/add_unique_index table column gives a column a unique index, as Rails' add_index with unique: true does, and generate authentication gives users.email one. the derivation reads the index from the migrations, or from a unique index or constraint over one column in schema.sql, and the model names the column. models/created then inserts with on conflict do nothing, so when two requests race for one address, the one that loses answers models/invalid with "That is taken." on the field rather than an error. models/updated asks before it writes.
accounts_handle = models/add_unique_index "accounts" "handle"
a schema that needs SQL the fold cannot read, a trigger for instance, is checked in as pg_dump --schema-only output, in schema.sql beside the entry. the schema is then read from its CREATE TABLE blocks instead of the migrations.
a model is a module named for its table
a module named for a table holds the table's model, as a Rails model is named for its table. the derivation declares the record in it, with a field for each column in the table's order: the guestbook's signatures module gets signature, and its functions read s.name and s.message as they read any record's fields. the record is checked like code someone wrote, so a field the table lacks is an error where it is read. kanso derived prints the declaration with the rest of what the derivation writes:
# in signatures, by models/tables
pub type signature
id
name
message
photo
a module may declare the record itself, to leave a column out of it. every field it names must then be a column. a misspelled field stops the build, with the error at the type.
pub type note
bdoy
pub fn shown n
n.bdoy
error[derive]: `bdoy` is not a column of `notes`
--> ./notes.kso:1:10
1 | pub type note
^
a type named after no table is refused the same way. for each model the derivation writes a reader into the model's module, signature_from row, which builds a record from a row a query answered and takes each field from the column of the same name. it writes signature_attributes s too, the record as a map from each field's name to its value, as Rails' attributes gives one. web/json writes that map as a JSON object. a module that declares signature_attributes itself keeps its own, so a model can leave a field out of what it shows.
policies
a model's module says who may change its rows. create?, update? and destroy? each take the viewer and the record and answer true or false. the guestbook lets anyone sign and only the signer remove:
import "../../models"
import "std/text"
# A signature is a row of the `signatures` table. This module is named for
# the table, so senro/models declares `signature` here with a field for each
# column, and writes `signature_from`, which builds one from a row.
#
# Only the person who signed an entry may remove it. The models derivation
# reads this policy and writes `signature_destroyed`, which asks it before it
# deletes anything, so there is no other way to remove a signature.
pub fn destroy? viewer s
s.name == viewer
# Anyone may sign the book.
pub fn create? _ _
true
# A signature needs a name and a message, and a message short enough to
# read at a glance.
pub fn problems s
models/problems [
(models/rule "name" (text/trim s.name == "") "Say who you are.")
(models/rule "message" (text/trim s.message == "") "Say something.")
(models/rule "message" (length s.message > 280) "Keep it under 280.")
]
the derivation describes each model as a value, signature_model, and the writes take it. models/created viewer db model fields reads the fields into a record, asks create?, checks the rules, and inserts only when both pass. models/updated and models/destroyed read the row first, ask their policy, and write only on a yes, answering web/denied or web/absent otherwise. a model with no such policy cannot be written this way, so writes default closed.
reads default open. a module may declare visible?, and models/shown viewer model rows keeps the records a query answered that the viewer may see; without it every row shows. a missing read policy can show a row but never change one.
rules
problems takes a record and answers models/problems of a list of models/rule field broken message. models/created and models/updated check it, the second on the record as the change leaves it. senro writes the rules a Rails model reaches for first. models/present field value is presence: true, and a value of nothing but spaces is as blank as none. models/at_most field n value and models/at_least field n value bound a value's length in characters, as length: { maximum:, minimum: } does. models/one_of field choices value is inclusion: { in: choices }, and the value must equal a choice whole, so a select can post only what the app knows. models/email_address field value asks for one @ with something on each side of it and no spaces. a record that breaks a rule comes back as models/invalid, holding each broken field's message and the record as given, so the action can answer the form again with both. the guestbook's entries/create does that with a 422:
import "../../models"
import "../../pg"
import "../../web"
import "./removal"
import "./signatures"
import "./views"
import "std/list"
# A signature is made through its model, whose rules decide whether it is
# fit to keep. It is signed with the name of the account signed in. The
# fields go to Postgres as parameters, so what a visitor types is stored as
# text and never read as SQL.
pub fn create req db
viewer = web/viewer req secret
f = put (web/form req.body) "name" viewer
models/created viewer db signatures/signature_model f
.> (w -> signed req db w)
# A signature the rules turned back: the page again, answered 422, with the
# form as the visitor filled it in and what to fix.
fn signed req db w:models/invalid
s = web/session req secret
form = filled w.record.message w.problems
pg/query db newest [] .> (r -> shown req r.rows s form 422)
# A signature kept. The answer sends the browser back to the list, so
# reloading it does not sign the book again.
fn signed _ _ _
effect (web/noticed (web/redirect "/") "Thank you for signing.")
queries
a query is SQL with numbered parameters, sent through pg/query db sql args. what a visitor types goes to Postgres as a parameter and is never read as SQL. senro speaks Postgres's wire protocol in a client written in kanso, logs in with SCRAM-SHA-256, and secures the connection with TLS when pg/connect_tls asks for it.
two queries go through the model instead. models/found viewer db model id reads a record by its id and answers none when no row has it or the model's visible? hides it from the viewer, as Rails' find_by(id:) answers nil. models/where viewer db model field value answers the records whose field holds the value, oldest first, that the viewer may see. a post's comments are models/where viewer db comment/comment_model "post_id" id. the field goes into the SQL, so the derivation checks a field written out against the model's columns while the app compiles, and a field passed in a variable that names no column is an error when the call runs.
import "../../../models"
import "./comment"
import "./post"
import "std/list"
pub fn post db id
models/found "" db post/post_model id .> (p -> said db (titled p))
fn titled none
"no post"
fn titled p
"post: {p.title}"
pub fn comments viewer db id
models/where viewer db comment/comment_model "post_id" id
.> (cs -> said db "as [{viewer}]: {bodies cs}")
fn bodies cs
list/to_list (list/map cs (c -> c.body))
pub fn unread db
field = "author_id"
models/where "" db comment/comment_model field 1
.> (cs -> said db "read {length cs}")
.? (e -> said db "refused: {e.reason}")
fn said db line
print line .> (_ -> effect db)
a foreign key reads both ways
a foreign key the migrations add writes that models/where for the app, once in each direction, as Rails' has_many and belongs_to do. when pages.note_id holds a row of notes, the pages module gets of_note viewer db note, the pages of a note that the viewer may see, and the notes module gets of_page viewer db page, the note a page belongs to. each reads the other record's field by name, note.id or page.note_id, so neither module imports the other, and an app can import either one into the other without a cycle. a module that already declares a function of that name keeps its own. so does one that declares a pub function or record named as the parameter. an app that keeps a schema.sql gets its keys from the dump's FOREIGN KEY ... REFERENCES constraints instead.
the modules in spec/db/associated hold one helper each and nothing about the other table. the derivation writes the rest:
import "../../../models"
import "./notes"
import "./pages"
import "std/list"
pub fn pages_of db id
models/found "" db notes/note_model id
.> (n -> pages/of_note "" db n)
.> (ps -> said db "note {id}: {list/to_list (list/map ps pages/bodied)}")
pub fn note_of db id
models/found "" db pages/page_model id
.> (p -> notes/of_page "" db p)
.> (n -> said db "page {id}: {notes/titled n}")
fn said db line
print line .> (_ -> effect db)
note 1: ["a" "b"]
note 2: ["c"]
page 2: note: second
a page at a time
a list that may grow is read a page at a time, as Pagy reads one for a Rails app. models/paged db sql params number per runs the query with a limit and an offset after its order by, and answers a models/page: the rows on the page, its number, and whether another page follows. it asks for one row more than a page holds to answer that last question, and leaves that row off. models/page_number reads the page a query string asks for, and anything that is not a whole number of 1 or more is the first page, so a link that has been edited by hand still shows a list.
# Page `number` of what `sql` selects, `per` rows to a page, as Pagy reads
# one for a Rails app. The query is asked for one row more than a page
# holds, which says whether another page follows, and that row is not shown.
# `sql` ends where a limit may follow it, after its `order by`.
pub fn paged db sql params number per
n = length params
limited = "{sql} limit ${n + 1} offset ${n + 2}"
asked = list/concat params ["{per + 1}" "{(number - 1) * per}"]
pg/query db limited asked .> (r -> effect (page_of r.rows number per))
fn page_of rows number per
on_it = list/to_list (list/take rows per)
page on_it number (length rows > per)
# The page a query string asks for: its whole number when that is 1 or
# more, and 1 for anything else, a missing `page` among them.
pub fn page_number asked
at_least_one (text/to_int asked)
fn at_least_one none
1
fn at_least_one n
if (n < 1) 1 n
senro's spec pages through five rows two at a time, asks for a page past the end, and reads six asked-for pages, of which only 2 is a page number:
page 1: a b, more: true
page 3: e, more: false
page 4: , more: false
page 2: d e, more: false
asked 2,,x,0,-3,2.5: 2 1 1 1 1 1
a scaffold's index lists its rows this way, 25 to a page, as the generators guide shows.
a resource that lists another's records and one that shows its parent would import each other, and kanso refuses an import cycle. keeping each model in a module of its own, apart from the resource's actions, avoids it: both resources import both models, as the guestbook's entries imports its signatures model.