Créer une base, et l'utiliser.

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.

1. Ce qu'il faut

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 :

shell
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.

2. Écrire le fichier

Un fichier .kealsql tient un schéma et les requêtes qui vont avec. Voici le blog que la suite utilise :

blog.kealsql
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.

3. Le compiler

Le compilateur imprime du SQL — le schéma en instructions CREATE, puis un PREPARE par func ou proc :

shell▸ sql
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.

4. Créer la base

createdb fait une base vide ; le fichier compilé y fait tout le reste :

shell
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.

5. L'utiliser depuis psql

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 :

shell▶ psql
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.

6. L'utiliser depuis un programme

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 :

app.keal▶ PGDATABASE=blog ./app
// 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
shell
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) :

7. La faire évoluer

Changez le fichier — disons un bio: String? sur User, et Post renommé en Article — et demandez la différence avec la base vivante :

shell▶ migration.sql
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.

8. Exécuter du Keal dans le serveur

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 :

blog.kealsql
// 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")
}
shell
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 :

9. Et ensuite

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.