cljg.data.cast
cljg.data.cast is cljgo’s one blessed data layer (ADR 0072). It is API-first and injection-safe by construction — the blessed form is a SQL string plus positional ? params, never string-concatenated values — and it behaves identically interpreted (cljgo dev) and AOT-compiled to a CGO_ENABLED=0 static binary. Two pure-Go drivers sit behind one API: modernc.org/sqlite (the zero-install default, ADR 0057) and pgx (production Postgres, ADR 0058) — a driver swap, not an API fork.
If you want the whole thing wired for you, cljgo generate resource scaffolds a migration + model + handlers + a green CRUD test over cljg.data.cast in one command — see the resource generator.
Connect
Section titled “Connect”(require '[cljg.data.cast :as db])
(def conn (db/connect {:driver :sqlite :database "app.db"})):driver—:sqlite(default) or:postgres.:database— the SQLite file path (default.dev/app.db), or":memory:"for a fresh, disposable database (tests).:url— the Postgres URL (or setAPP_DB_URL).
With no :driver, an APP_DB_URL starting postgres selects pgx; otherwise you get SQLite, zero install. (db/connect) with no args reads APP_DB_URL / the defaults. Close a pool with (db/close! conn).
;; the same code, Postgres — a driver swap, not a rewrite(db/connect {:driver :postgres :url "postgres://localhost/app"});; or, ambiently:(db/connect) ; APP_DB_URL=postgres://… → pgxcljg.data.cast normalizes placeholders (? → $n for Postgres) and column names (snake_case ↔ kebab-case) — but not SQL dialect (the ADR 0057 seam), so DDL stays yours.
Queries are parametrized SQL — a string plus variadic positional params — and rows come back as vectors of maps with snake_case columns turned into kebab-case keyword keys.
(db/query conn "select id, text, created_at from notes where id = ?" 7);; => [{:id 7 :text "hi" :created-at "2026-…"}]
(db/one conn "select * from notes where id = ?" 7) ; the first row, or nil(db/one! conn "select * from notes where id = ?" 7) ; or throws :cljg.data.cast/not-founddb/one! throws an ex-info tagged :cljg.data.cast/not-found — which the bri.web.http error funnel maps straight to a 404. That is the whole “row missing → 404” story: no if-let, no handler code.
db/exec! runs any parametrized write and returns {:rows-affected n :last-insert-id id}. The data-shaped helpers build the parametrized SQL from a map for you (kebab keys → snake columns):
(db/exec! conn "update notes set text = ? where id = ?" "hi again" 7)
(db/insert! conn :notes {:text "buy milk" :created-at (db/now)}) ; :last-insert-id(db/update! conn :notes {:text "…"} {:id 7}) ; set-map, where-map(db/delete! conn :notes {:id 7})(db/now) is the current UTC instant as an RFC3339 string — a portable created-at/updated-at value with no Java interop, identical across modes.
Transactions
Section titled “Transactions”db/tx runs (f tx-conn) in a transaction: commit on normal return, roll back on any throw (re-raised). The tx-conn drives the identical read/write verbs — a db and a tx are the same API.
(db/tx conn (fn [t] (db/insert! t :notes {:text "a"}) (db/insert! t :notes {:text "b"}))) ; both, or neitherdb/with-rollback runs (f tx-conn) in a transaction that is always rolled back — the per-test sandbox (the Ecto-Sandbox shape): exercise real writes inside a test and leave the database untouched.
(db/with-rollback conn (fn [t] (db/insert! t :notes {:text "scratch"}) (is (= 1 (count (db/query t "select * from notes"))))))Migrations
Section titled “Migrations”Migrations are files named <utc-timestamp>_<slug>.sql, applied forward-only in version order, each in its own transaction, tracked in a schema_migrations table — idempotent.
(db/migrate! conn "migrations") ; apply every pending file; returns the status(db/migrate-status conn "migrations") ; => {:applied ["2026…"] :pending ["2026…"]}-- migrations/20260724120000_create_notes.sqlCREATE TABLE notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, created_at TEXT NOT NULL);migrate! is the same call a deploy runs; running it twice is a no-op.
The delay pattern
Section titled “The delay pattern”Open the connection lazily. cljgo build’s discovery pass evaluates top-level forms (compile = eval, ADR 0002), so a connect at the top level would open a socket/file at build time. Wrap it in a delay (or a fn) so it forces once, on first use, at run time:
(def ^:private conn* (delay (let [c (db/connect {:database (:db-path cfg "notes.db")})] (db/migrate! c "migrations") c)))
(defn conn [] @conn*) ; opened + migrated on first callAPI at a glance
Section titled “API at a glance”| fn | does |
|---|---|
(db/connect opts) |
open a pool — :driver, :database/:url (or APP_DB_URL) |
(db/close! conn) |
close the pool |
(db/query conn sql & params) |
parametrized SELECT → vector of kebab-keyword maps |
(db/one conn sql & params) |
first row, or nil |
(db/one! conn sql & params) |
first row, or throws :cljg.data.cast/not-found (→ 404) |
(db/exec! conn sql & params) |
write → {:rows-affected :last-insert-id} |
(db/insert! conn table row) |
data-shaped INSERT (kebab→snake) |
(db/update! conn table set-map where-map) |
data-shaped UPDATE |
(db/delete! conn table where-map) |
data-shaped DELETE |
(db/tx conn f) |
transaction: commit on return, roll back on throw |
(db/with-rollback conn f) |
always-rolled-back tx (per-test sandbox) |
(db/migrate! conn dir) |
apply pending <utc>_<slug>.sql, forward-only |
(db/migrate-status conn dir) |
{:applied […] :pending […]} |
(db/now) |
current UTC instant, RFC3339 string |
Where next
Section titled “Where next”- The resource generator — scaffold a full CRUD slice over
cljg.data.castin one command - bri.web.http — the error funnel that turns
:cljg.data.cast/not-foundinto a 404 - bri.core.security — guarding the handlers that call your model
- bri.core.config — where
APP_DB_URLand the:dbkey come from - Deploy — ship the whole thing as one static binary
Questions or feedback on this page? Comment below with your GitHub account — comments are public and live in the project's GitHub Discussions.