generators
senro's generators write the files an app starts from, as Rails' do. new writes an app that serves a home page, and scaffold adds a resource to it: a table, a model, the seven actions and three views. this guide runs both and reads what they write. the result is examples/notebook in senro's repository. senro's specs write it again on every run and compare the two, and they serve it on both engines and make, change and remove a note through it, so the files on this page are what the generators write today.
running them
the generators run from senro's own directory, and take the app's path relative to it. the files they write import senro by climbing back up that path, one .. for each directory it names, so an app two directories down imports ../../models. an app kept outside senro's repository names senro by its repository instead, as in import "kanso-lang/senro/models", and kanso install fetches it.
$ kanso run generate -- new examples/notebook
create examples/notebook/main.kso
create examples/notebook/home.kso
create examples/notebook/migrations.kso
create examples/notebook/views/layout.kata
create examples/notebook/views/home.kata
$ kanso run generate -- scaffold examples/notebook note title:text pages:int cover:attachment
create examples/notebook/notes.kso
create examples/notebook/views/notes_index.kata
create examples/notebook/views/note_shown.kata
create examples/notebook/views/note_form.kata
change examples/notebook/migrations.kso
change examples/notebook/main.kso
$ kanso run generate -- scaffold examples/notebook page body:text note:references
create examples/notebook/pages.kso
create examples/notebook/views/pages_index.kata
create examples/notebook/views/page_shown.kata
create examples/notebook/views/page_form.kata
change examples/notebook/migrations.kso
change examples/notebook/main.kso
each line names a file and what happened to it. scaffold changes two files that new wrote: it adds the migration to the end of the migrations list, and the resource's import to the entry. it writes nothing over a file that is there already, and it stops before writing anything at all when a field is not one it can read.
$ kanso run generate -- scaffold examples/notebook note title:text
generate: examples/notebook/notes.kso is there already
$ kanso run generate -- scaffold examples/notebook book title:words
generate: cannot read `title:words`: a field is name:text, name:int, name:references or name:attachment
a field is name:text, name:int, name:references or name:attachment. the first two are the kinds a posted form's value reads back as without a parser of its own. the third nests the resource under another, as described below under a resource under another. the fourth is a file, kept as the uploads guide describes, and its column holds the key the file was kept under. a text column takes the value as it was typed. Postgres reads an integer column's value from its digits and refuses any other, so the scaffold gives each int field a rule, below, that turns a value Postgres would refuse into a problem shown on the form. :uniq after a kind, as in email:text:uniq, adds a unique index on the column, as Rails' generator reads it, and the models guide describes what a write answers when a value is taken. a field's name is a lowercase letter and then letters, digits and underscores, at most 24 of them, so that every line the scaffold writes fits in 80 columns.
the entry
new writes an entry that connects, migrates and serves. it reads the database from the environment as libpq does, so the same app runs on a laptop and on a host that sets PGHOST and the rest. its migrations are recorded under the app's name, which is the last part of its path.
# The database comes from PGHOST, PGPORT, PGUSER, PGDATABASE and
# PGPASSWORD, as libpq reads them, so the app runs wherever its host
# sets them.
connected = pg/from_env
the rest of the entry new writes runs the migrations on that session, compares the database with the schema the app derives, closes it, and ends with web/serve 3000 routes connected. each scaffold adds an import of the resource it made, among the app's own imports and in order, so the entry reads as if it had been written by hand. the whole entry, as it stands after accounts are added, is shown below under accounts and signing in.
the migration
import "../../models"
# The app's schema, as the changes that built it, oldest first.
pub all = [notes_made pages_made pages_to_notes users_made users_email_unique]
notes_made = models/create_table "notes" [
notes_id
notes_title
notes_pages
notes_cover
models/created_at
models/updated_at
]
notes_id = models/column "id" "serial primary key"
notes_title = models/column "title" "text"
notes_pages = models/column "pages" "integer"
notes_cover = models/column "cover" "text not null default ''"
pages_made = models/create_table "pages" [
pages_id
pages_body
pages_note_id
models/created_at
models/updated_at
]
pages_to_notes = models/add_foreign_key "pages" "note_id" "notes"
pages_id = models/column "id" "serial primary key"
pages_body = models/column "body" "text"
pages_note_id = models/column "note_id" "integer not null"
users_made = models/create_table "users" [
users_id
users_email
users_digest
models/created_at
models/updated_at
]
users_email_unique = models/add_unique_index "users" "email"
users_id = models/column "id" "serial primary key"
users_email = models/column "email" "text"
users_digest = models/column "digest" "text"
each column is a value named for its table, so two tables that both have a title never declare the same name. scaffold appends notes_made to all, and a later change goes after it, as pages_made and users_made do; the models guide says how the list is run and recorded. each table ends with models/created_at and models/updated_at, as a Rails scaffold ends with t.timestamps, and users_email_unique is the index email:text:uniq asks for.
the resource
the resource is one module, named for its table, and the models guide says how that gives it its record: the derivation declares note here with a field for each column of notes, so the module never lists them. the three policies let anyone write, which suits a first run and nothing after it. problems holds the model's rules; the scaffold writes one for each int field, and models/whole_number is broken by anything an integer column would refuse.
import "../../models"
import "../../pg"
import "../../web"
import "./views"
import "std/list"
import "std/net/http"
import "std/text"
# A note is a row of the `notes` table. This module is named for the table,
# so the models derivation declares `note` here with a field for each
# column, and writes `note_from`, which builds one from a row, and
# `note_model`, which the writes below go through.
#
# Anyone may make, change and remove a note. The models module asks these
# before it writes; narrow them to the visitors who should.
pub fn create? _ _
true
pub fn update? _ _
true
pub fn destroy? _ _
true
# What makes a note fit to keep: each number is one its column takes.
pub fn problems r
models/problems [(models/whole_number "pages" r.pages)]
model = note_model
the seven actions are the ones a Rails scaffold writes, and the routing guide says how each name places it. reads go through two queries over the model's columns, note_columns, which the derivation declares from the table, so a column a later migration adds reaches both queries with no edit. writes go through the model, so the policies and the model's rules are asked before anything is written.
pub fn index req db
number = models/page_number (web/value (web/query req) "page")
asked = models/paged db listed [] number per_page
asked .> (p -> effect (listing req p))
# How many rows a page of the list shows.
per_page = 25
columns = text/join note_columns ", "
listed = "select {columns} from notes order by id"
found = "select {columns} from notes where id = $1::integer"
pub fn show req db id:int
pg/query db found ["{id}"] .> (r -> effect (shown req (list/first r.rows)))
pub fn new req _
effect (form req "/notes" "POST" blank {} 200)
pub fn create req db
given = sent (web/form req.body)
models/created "" db model given .> (w -> effect (made req w))
pub fn edit req db id:int
pg/query db found ["{id}"]
.> (r -> effect (editing req id (list/first r.rows)))
pub fn update req db id:int
given = picked (web/form req.body)
models/updated "" db model "{id}" given .> (w -> effect (changed req id w))
pub fn destroy _ db id:int
models/destroyed "" db model "{id}" .> (w -> effect (gone w))
index shows a page of notes at a time. models/page_number reads the page the query string asks for, and anything that is not a whole number of 1 or more reads as the first page. models/paged runs the listing's query with a limit and an offset, asking for one row more than per_page so that it knows whether another page follows, as Pagy does for a Rails app. the list links the page before and the page after, each only when there is one.
fn listing req p
every = list/to_list (list/map p.rows note_from)
mapped = list/to_list (list/map every note_attributes)
return web/json 200 mapped if web/wants_json? req
page = views/notes_index_view every p.number p.more
views/answer_as req 200 (views/layout_view "Notes" page)
takes rows number more
header
h1 Notes
p.lede
a href="/notes/new" New note
ul.notes
for r in rows
li
a href="/notes/{r.id}" = r.title
nav.pages
if number > 1
a href="/notes?page={number - 1}" Previous page
if more
a href="/notes?page={number + 1}" Next page
the list and the page for one note answer JSON when it is asked for, as a Rails scaffold's do. /notes.json answers the page's notes, each as note_attributes gives it, and /notes/3.json answers one. the routing guide says how a request asks.
fn shown req row
record = note_from row
attributes = note_attributes record
return web/json 200 attributes if web/wants_json? req
page = views/note_shown_view record
views/answer_as req 200 (views/layout_view "Note" page)
senro's spec makes a note, asks for it as JSON, changes it, and asks for the list the same way. ids and times change from run to run, so the spec prints each id as N and each time as T:
it as json: {"cover":"","created_at":"T","id":N,"pages":12,"title":"First","updated_at":"T"}
the list as json: [{"cover":"","created_at":"T","id":N,"pages":13,"title":"Renamed","updated_at":"T"}]
a form with a file in it is sent as multipart/form-data, and senro keeps the file and hands the action its key in the field's place. a file input left empty sends an empty value, and update leaves it out of the change, so a note keeps its cover when it is edited without choosing another. create takes every field, an empty cover among them.
fn picked f
without_empty_files (sent f) ["cover"]
fn sent f
{
"cover":(web/value f "cover")
"pages":(web/value f "pages")
"title":(web/value f "title")
}
fn without_empty_files given names
list/fold names given (m n -> if (m[n] == "") (delete m n) m)
what each write answers decides the response. a record that breaks a rule comes back as models/invalid, and the form is shown again with what was typed and a 422. a write a policy refuses is a web/denied, and a row that is not there is a web/absent; web/refusal answers them with a 403 and a 404. anything else is the written row, and the action redirects to it.
fn made req w:models/invalid
form req "/notes" "POST" (filled w.record) w.problems 422
fn made _ w:web/denied
web/refusal w
fn made _ w
web/redirect "/notes/{w.id}"
fn changed req id w:models/invalid
form req "/notes/{id}" "PATCH" (filled w.record) w.problems 422
fn changed _ _ w:web/denied
web/refusal w
fn changed _ _ w:web/absent
web/refusal w
fn changed _ id _
web/redirect "/notes/{id}"
fn gone w:web/denied
web/refusal w
fn gone w:web/absent
web/refusal w
fn gone _
web/redirect "/notes"
the views
three templates: the list, a note, and the form that makes and changes one. the form takes the path it is sent to and the method, so new sends it to /notes as a POST and edit to the note's own path as a PATCH, which the hidden _method field carries. each field shows the problem a rule found with it, if there was one.
takes path method fields problems
header
h1 Note
p.lede
a href="/notes" All notes
form method="post" action=path enctype="multipart/form-data"
input type="hidden" name="_method" value=method
label
| title
input name="title" value=fields["title"]
if problems["title"] != none
p.problem = problems["title"]
label
| pages
input name="pages" value=fields["pages"]
if problems["pages"] != none
p.problem = problems["pages"]
label
| cover
input type="file" name="cover"
if fields["cover"] != ""
p.kept Leave it empty to keep the file it has.
button Save
a resource under another
a field written name:references nests the resource under another, as Rails' generator reads it. the third command above makes pages that belong to a note. the migration gains two changes: pages_made makes the table with a note_id column that may not be empty, and pages_to_notes, written with models/add_foreign_key, has Postgres refuse a page whose note is not there and remove a note's pages along with the note.
each of the resource's actions takes the note's id right after the database, which is how the routing guide places a nested resource: pages/index answers GET /notes/:note_id/pages, and pages/show GET /notes/:note_id/pages/:id.
# A row of `pages` belongs to a note, so each action takes the note's id and
# the routes nest under it: `index` answers GET /notes/:note_id/pages. A row
# is found only under its own note, and a note that is not there has none to
# list or make.
pub fn index req db note_id:int
number = models/page_number (web/value (web/query req) "page")
asked = models/paged db listed ["{note_id}"] number per_page
listing_of = (p -> effect (listing req note_id p))
within db note_id (asked .> listing_of)
listed = """
select {columns} from pages
where note_id = $1::integer order by id
"""
found = """
select {columns} from pages
where id = $1::integer and note_id = $2::integer
"""
parent = "select id from notes where id = $1::integer"
pub fn show req db note_id:int id:int
pg/query db found ["{id}" "{note_id}"]
.> (r -> effect (shown req (list/first r.rows)))
# `then` when the note is there, and a 404 when it is not.
fn within db id then
pg/query db parent ["{id}"] .> (r -> held (list/first r.rows) then)
fn held none _
effect http/not_found
fn held _ then
then
a page is looked up by its own id and its note's together, so /notes/1/pages/7 finds nothing when page 7 belongs to note 2. listing a note's pages and making one first ask whether the note is there, and answer 404 when it is not.
the note's resource must be there before the pages are generated, a resource nests under one parent, and a reference's name is at most 12 characters, so the lines the scaffold writes still fit in 80 columns.
$ kanso run generate -- scaffold examples/notebook chapter body:text volume:references
generate: examples/notebook has no volumes to nest chapters under
$ kanso run generate -- scaffold examples/notebook book note:references page:references
generate: cannot read `page:references`: a resource nests under one parent
accounts and signing in
generate authentication adds accounts to an app, as Rails 8's authentication generator does, with a sign-up page as Devise's registerable adds. it writes four modules and three views, appends a users table to the migrations, and changes the entry.
$ kanso run generate -- authentication examples/notebook
create examples/notebook/password_mailer.kso
create examples/notebook/registration.kso
create examples/notebook/resets.kso
create examples/notebook/session.kso
create examples/notebook/views/door.kata
create examples/notebook/views/reset_asked.kata
create examples/notebook/views/reset_chosen.kata
change examples/notebook/migrations.kso
change examples/notebook/main.kso
a user is an address and a digest of the password. registration makes one, at /registration/new, and signs it in. session signs in at /session/new and out with DELETE /session, and resets mails a link that sets a new password. these are the guestbook's modules with less in them: no names, no confirmation, no lock. the sessions guide explains each part, and the files are the app's own to change.
# The key that signs every session. `generate authentication` made it at
# random. Keep it out of any repository the public can read; changing it
# signs every visitor out.
pub secret = "made at random by generate authentication"
pub without_sign_in = ["create" "new"]
# What a sign-in rests on: the digest the account keeps now. A new password
# changes it, so a password reset signs out every session made before it.
pub signed_against = (db email -> pg/query db digest_by [email] .> kept_of)
digest_by = "select digest from users where email = $1"
the secret that signs every session is 32 random bytes the generator made when it ran, so two apps never share one. keep it out of any repository the public can read; changing it signs every visitor out.
import "../../jobs"
import "../../kata"
import "../../mail"
import "../../models"
import "../../pg"
import "../../routes"
import "../../web"
import "./home"
import "./migrations"
import "./notes"
import "./pages"
import "./registration"
import "./resets"
import "./session"
import "./views"
derive kata/views
derive models/tables
derive routes/rest
# The database comes from PGHOST, PGPORT, PGUSER, PGDATABASE and
# PGPASSWORD, as libpq reads them, so the app runs wherever its host
# sets them.
connected = pg/from_env
# Every action asks for a sign-in unless its module lists it in
# `without_sign_in`, and a worker beside the server sends the mail the app
# queues.
signed = web/requiring_sign_in routes session/secret session/signed_against
serving = web/serve 3000 signed connected
connected
.> (db -> models/migrated db "notebook" migrations/all)
.> (db -> models/compared db schema)
.> (db -> pg/close db)
.> (_ -> jobs/beside connected "mail" mail/performers serving)
the entry now serves its routes through web/requiring_sign_in, so every action asks for a sign-in unless its module lists it in without_sign_in. that includes the home page and the notes, as in a Rails 8 app after the generator runs. a visitor who has not signed in is sent to /session/new, and back to the page they asked for once they have. jobs/beside runs a worker next to the server that sends the reset letters password_mailer queues; the background jobs guide covers it.
senro's spec serves the generated app on both engines and uses it as a visitor would: it makes an account, works with notes and pages, signs out, and resets the password through the mail the app writes.
after generating
three things are worth doing before an app goes anywhere. narrow the policies to the visitors who should write. add the model's other rules to problems, as the models guide describes, such as a title that may not be blank. and replace the home page, which says only that the app exists.