std.sql

Embedded SQLite, compiled into the binary. Nothing to install.

import std.sql

db = sql.open("app.db")             # or ":memory:"

sql.exec(db, "create table if not exists users (id integer primary key, name text)")
sql.exec(db, "insert into users (name) values (?)", ["Ada"])

rows = sql.query(db, "select * from users where name = ?", ["Ada"])
one  = sql.query_one(db, "select count(*) as n from users")

sql.transaction(db, fn() {
    sql.exec(db, "insert into users (name) values (?)", ["Bob"])
    sql.exec(db, "insert into users (name) values (?)", ["Cy"])
})

sql.close(db)

:memory: is pure; a file database needs fs:write.

Always use parameters

sql.query(db, "select * from users where name = ?", [name])   # correct
sql.query(db, "select * from users where name = '{name}'")    # injection

The second form is a SQL injection, and interpolation makes it easy to write by accident precisely because interpolation is so natural in this language. A parameter is never parsed as SQL, whatever it contains.

Results

query returns a list of maps keyed by column name. query_one returns the first row, or null - so it is one of the nullable lookups, and absence is expected:

row = sql.query_one(db, "select * from users where id = ?", [id])
if row == null { return not_found() }

Transactions

sql.transaction(db, fn) commits when the function returns and rolls back if it raises. That is the whole reason to use it rather than issuing begin and commit yourself: an error between the two would otherwise leave the transaction open.

sql { ... } blocks

A block form for literal SQL, so multi-statement schema setup reads as SQL rather than as a string.

Concurrency

SQLite is a single file with a writer lock. Handlers running on the HTTP worker pool will contend for writes, and a long-held write transaction blocks others.

Keep write transactions short. For read-heavy work SQLite is excellent; for write-heavy multi-process work, it is the wrong shape - use postgres or mysql.

Errors

Failures raise { kind: "sql", message } - constraint violations included, which is often the useful signal:

try { sql.exec(db, "insert into users (email) values (?)", [e]) }
catch (err) { match get(err, "kind") { "sql" => already_taken() _ => error(err) } }

Why it is embedded

One binary, no service, no connection string, no apt install. A program that needs a database has one, which is the same reasoning as a file being a program.