Dix minutes d'un PostgreSQL vide à un schéma typé, ses requêtes préparées, une application qui les appelle, et une migration quand le fichier change. Chaque commande ici est celle que la suite de tests exécute.
Keal, le toolchain — geneacta.github.io/keal donne les trois commandes ; KealSql veut Keal 1.3.0 ou plus récent — la version dont le chargeur lit un import de .kealsql.
PostgreSQL 14 ou plus avec ses outils clients sur le chemin : psql, createdb, pg_config. Les fonctions stockées demandent les en-têtes serveur et un compilateur C (postgresql-server-dev-NN sur Debian et Ubuntu) ; le client, les en-têtes de libpq (libpq-dev).
Et KealSql lui-même :
git clone https://github.com/geneacta/kealsql cd kealsql keal fetch tests/run.sh
keal fetch récupère le lexer de Keal, que KealSql importe depuis le commit épinglé dans keal.toml. tests/run.sh lance la suite : si PostgreSQL est sur la machine, elle démarre un serveur privé dans un répertoire temporaire — sans root, sans configuration — et y exécute chaque cas.
Un fichier .kealsql tient un schéma et les requêtes qui vont avec. Voici le blog que la suite utilise :
enum Status { Draft, Published }
table User {
id: Id
name: Slug
unique email: String
bio: String?
}
table Post {
id: Id
cascade author: RefId<User>
editor: RefId<User>?
title: String
status: Status
created: Timestamp
// A method on the row, for the programs that read it.
func headline(): String { "${this.title} [${this.status}]" }
}
func byAuthor(name: String): List<(Int, String)> {
from(Post as p)
.where(p.author.name == name)
.where(status == Published)
.orderBy(created.desc)
.select(p.id, p.title)
}
func editorOf(post: Int): String? {
from(Post).where(id == post).select(editor?.name).first()
}
func drafts(): Int {
from(Post).where(status == Draft).count()
}
func publish(post: Int): Post {
update(Post).where(id == post).set(status = Published)
}
proc forget(user: RefId<User>) {
delete(Post).where(author == user)
}Trois choses à voir. Id est une clé primaire serial et Slug un texte unique non nul — les deux clés d'une table, chacune une fois. RefId<User> est une référence, et RefId<User>? une référence optionnelle, ce qui dit ce qui se passe à la suppression (SET NULL) et comment un chemin à travers elle se lit (?., une jointure gauche). Et une requête commence à from et finit à select : l'ordre d'évaluation, pour qu'au select le checker connaisse déjà chaque colonne.
Le compilateur imprime du SQL — le schéma en instructions CREATE, puis un PREPARE par func ou proc :
keal src/main.keal blog.kealsql > blog.sql
CREATE TABLE "user" ( id serial PRIMARY KEY, name text UNIQUE NOT NULL, email text UNIQUE NOT NULL, bio text ); CREATE TABLE post ( id serial PRIMARY KEY, author integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, editor integer REFERENCES "user"(id) ON DELETE SET NULL, title text NOT NULL, status status NOT NULL, created timestamp NOT NULL ); -- func byAuthor(name: String): List<(Int, String)> PREPARE by_author(text) AS SELECT p.id, p.title FROM post AS p JOIN "user" AS author ON p.author = author.id WHERE author.name = $1 AND p.status = 'Published' ORDER BY p.created DESC; -- func editorOf(post: Int): String? PREPARE editor_of(integer) AS SELECT editor.name FROM post LEFT JOIN "user" AS editor ON post.editor = editor.id WHERE post.id = $1 LIMIT 1;
Le chemin à travers editor?. est devenu un LEFT JOIN, et le résultat déclaré String? dit qu'un billet sans éditeur répond null plutôt que de disparaître. C'est à cela que le checker tient chaque requête.
createdb fait une base vide ; le fichier compilé y fait tout le reste :
createdb blog psql -d blog -f blog.sql
Les CREATE s'exécutent une fois et restent. Les PREPARE vivent le temps de la session qui les exécute — psql ici — donc charger le fichier dans une session ultérieure les prépare à nouveau, et les CREATE échoueraient alors sur des tables qui existent. Pour la première fois c'est exactement ce qu'il faut ; ensuite, --migrate ci-dessous est la façon dont le schéma change, et le client prépare ses propres instructions.
Dans la même session psql, les requêtes sont des instructions préparées, appelées par leur nom avec leurs paramètres dans l'ordre :
psql -d blog -f blog.sql
blog=> EXECUTE by_author('ada');
blog=> EXECUTE editor_of(2);id|title 1|Hello (1 row) name bob (1 row) name (1 row)
editor_of(2) répond une ligne qui tient null : le billet existe, son éditeur non — la jointure gauche que le ?. demandait. Les noms sont ceux du fichier, en snake_case.
Un programme Keal importe le .kealsql lui-même. Le chargeur de Keal fait écrire par kealsql un module à côté — .kealsql/blog.client.keal, régénéré dès que le fichier est plus récent — où chaque requête est une méthode sur une connexion, avec les mêmes types : les lignes sont des records, first() répond un T?, une requête qui échoue est une exception portant le nom de la requête. Renommez une colonne dans le fichier et le programme cesse de compiler :
// An application over tests/cases/blog.kealsql. The import is the .kealsql
// itself: Keal's loader has the compiler write .kealsql/blog.client.keal and
// reads that. The suite builds it with libpq, runs it against a fresh
// database holding blog.sql, and holds its output to blog_app.out.
import "./blog.kealsql"
// A database made from the code: missing, then empty, then there.
val made = createBlog("", "client_blog_made")
println("made: ${made.drafts()} draft(s) in a new database")
made.close()
val again = createBlog("", "client_blog_made") // already there: nothing to do
println("again: ${again.drafts()} draft(s)")
again.close()
val db = connectBlog("") // PG* in the environment says where
proc show(label: String, posts: List<ByAuthorRow>) {
println("${label}: ${posts.size} post(s)")
for (p in posts) { println(" #${p.id} ${p.title}") }
}
db.begin()
show("ada before", db.byAuthor("ada"))
println("drafts: ${db.drafts()}")
val published = db.publish(2)
println("published: ${published.title} (${published.status})")
println("headline: ${published.headline()}")
println("drafts now: ${db.drafts()}")
show("ada after", db.byAuthor("ada"))
val editor = db.editorOf(1)
println("editor of #1: ${editor ?: "none"}")
println("editor of #2: ${db.editorOf(2) ?: "none"}")
db.rollback()
println("drafts after rollback: ${db.drafts()}")
try {
db.publish(999)
println("unreachable")
} catch (e) {
println("caught: ${e}")
}
db.forget(2)
println("posts left: ${db.drafts() + db.byAuthor("ada").size}")
db.close()made: 0 draft(s) in a new database again: 0 draft(s) ada before: 1 post(s) #1 Hello drafts: 1 published: Draft one (Published) headline: Draft one [Published] drafts now: 0 ada after: 2 post(s) #2 Draft one #1 Hello editor of #1: bob editor of #2: none drafts after rollback: 1 caught: publish: expected one row, got 0 posts left: 2
keal build src/main.keal -o kealsql # once: the compiler the import runs export KEALSQL=$PWD/kealsql keal build app.keal -I$(pg_config --includedir) -lpq -o app PGDATABASE=blog ./app
createBlog(conninfo, dbname) crée la base si elle manque et son schéma si elle ne tient aucune des tables ; connectBlog(conninfo) ouvre une base qui existe. La chaîne de connexion est celle de libpq ; vide, elle laisse tout à l'environnement PG* et à ~/.pgpass. Compilez le programme contre libpq, avec kealsql sur le chemin (ou nommé par KEALSQL) :
Changez le fichier — disons un bio: String? sur User, et Post renommé en Article — et demandez la différence avec la base vivante :
keal src/main.keal --migrate blog.kealsql --db blog > migration.sql cat migration.sql psql -1 -d blog -f migration.sql keal src/main.keal --migrate blog.kealsql --db blog # -- nothing to do: the database matches the declaration
ALTER TABLE post RENAME TO article; ALTER TABLE "user" ADD COLUMN bio text; -- DESTRUCTIVE, held back (pass --destructive to emit it): every row of `legacy` is lost -- DROP TABLE legacy; -- 1 statement held back
La migration est imprimée pour que vous la lisiez, jamais appliquée par le compilateur. Les étapes additives viennent telles quelles. Les destructives — une colonne supprimée, un type rétréci, un nouveau NOT NULL — sont retenues en commentaires qui nomment ce qu'elles coûteraient, jusqu'à --destructive. Un renommage n'est jamais deviné : renamed(old) dans le fichier le dit, et une fois la base renommée l'annotation est une note que vous pouvez laisser.
Appliquez-la en une transaction, et redemandez : la réponse doit être nothing to do.
Une stored func est du Keal que PostgreSQL exécute comme fonction LANGUAGE C ; un trigger est la même chose sur chaque ligne écrite. Leurs corps peuvent appeler les requêtes du fichier, typées, via SPI :
// A URL-safe form of a name: letters and digits, one dash between runs.
stored pure func slugify(s: String): String {
var out = ""
var dash = false
for (c in s.toLower()) {
if ((c >= "a" and c <= "z") or (c >= "0" and c <= "9")) {
out += c
dash = false
} else if (not dash and out != "") {
out += "-"
dash = true
}
}
if (out.endsWith("-")) { out = out.take(out.length - 1) }
return out
}
trigger normalizeSku on Product before insert {
return row.with(sku = row.sku.toUpper(), note = row.note ?: "new")
}keal src/main.keal --plkeal build/ blog.kealsql sh build/build.sh keal src/main.keal --lib $PWD/build/blog.so blog.kealsql | psql -d blog
--plkeal écrit un programme Keal et un build.sh qui en fait une bibliothèque partagée contre les en-têtes serveur ; le SQL du fichier porte alors les CREATE FUNCTION, en nommant la bibliothèque par --lib :
Les docs : le design et ses raisons, la grammaire avec ce que chaque construction compile, et la carte du dépôt du README. examples/shop.kealsql est un vrai fichier — six tables, une vingtaine de requêtes — que la suite compile, charge et exécute sur des données.