chickadee » orm

ORM

A simple ORM (Object-Relational Mapping) for CHICKEN Scheme with support for models, migrations, and relationships.

Description

The orm egg is a lightweight ORM for CHICKEN Scheme built around a define-model macro that introspects an existing table at runtime and generates a full set of CRUD functions. It features a backend-agnostic database layer, a small migration system with a companion CLI runner, has-many relationships, and automatic kebab-case ↔ snake_case name conversion.

The egg bundles three modules so the lightweight, pure-Scheme pieces install as one unit:

A database backend is installed separately as its own egg, because each pulls heavy, mutually-exclusive dependencies:

Requirements

Installation

chicken-install orm
chicken-install orm-db-sqlite   ; if using SQLite
chicken-install orm-db-rqlite   ; if using rqlite

Basic Usage

(import orm-db orm-db-sqlite orm)

;; Configure and connect to a database
(db/backend (sqlite3-backend))
(db/path "myapp.db")
(db/connect)

;; Define a model bound to an existing table
(define-model users)

;; Query
(q/run (users/q))                 ; => #(((id . 1) (name . "Alice") ...) ...)
(q/run (q/by-id (users/q) 1))     ; => ((id . 1) (name . "Alice") ...)

Naming Conventions

The ORM automatically converts between Scheme's kebab-case and SQL's snake_case:

Data Representations

API: Database Layer (orm-db)

The orm-db module defines a backend-agnostic interface using CHICKEN parameters. A backend is an alist mapping the symbols connect, close, query, and execute (and optionally batch) to procedures; the backend eggs provide constructors (sqlite3-backend, rqlite-backend) that return such an alist.

db/backend #!optional backendparameter

Get or set the current backend alist. Must be set before connecting.

db/path #!optional pathparameter

Get or set the database path or connection string. Must be set before connecting.

db/connection #!optional connparameter

Get or set the current (opaque, backend-specific) connection. Normally managed by db/connect / db/close.

db/connectprocedure

Open a connection using the current backend and path. Errors if either is unset.

(db/backend (sqlite3-backend))
(db/path "myapp.db")
(db/connect)
db/closeprocedure

Close the current connection (no-op if none is open).

db/query ssql-or-string #!optional paramsprocedure

Run a SELECT. ssql-or-string is either a raw SQL string or an ssql S-expression form; params is a list bound to ? placeholders. Returns a vector of alists.

(db/execute ssql-or-string [params] [out-key])procedure

Run an INSERT/UPDATE/DELETE/DDL statement. out-key selects which value the backend returns (default rows_affected; e.g. last_insert_rowid).

db/batch statementsprocedure

Run several read statements and return a list of row vectors, one per statement, in order. statements is a list of (ssql-or-string . params) pairs. If the backend provides a batch entry ((conn statements) -> list of row vectors), it is used, so a backend like rqlite can send everything in one request. Otherwise db/batch falls back to calling the backend's query once per statement. An empty list returns '() without touching the backend. Batches are for reads only. q/run-list and q/run-all are built on it.

(db/batch '(("SELECT * FROM users WHERE id = ?" 1)
            ("SELECT COUNT(*) AS n FROM posts")))
; => (#(((id . 1) ...)) #(((n . 12))))

API: Models (define-model)

(define-model name [scope: model-scope])syntax

Defines a model bound to the existing table name. The macro introspects the table schema at runtime (via PRAGMA table_info) and generates the following functions. A live connection must exist when the generated functions are first called.

(define-model users)

An optional model scope supplies a mandatory query condition and a row preparation callback:

(define current-account-id (make-parameter #f))

(define account-scope
  (make-model-scope
   (lambda ()
     (let ((account-id (current-account-id)))
       (unless account-id (error "account scope required"))
       (values '(= account-id ?) (list account-id))))
   (lambda (row)
     (alist-update 'account-id (current-account-id) row))))

(define-model invoices scope: account-scope)

The condition callback takes no arguments and returns two values: an ssql condition and its placeholder-value list. The ORM places this condition before any caller condition whenever a users/q descriptor is run (it is applied at run time, not when the descriptor is built), and includes it in the same SQL WHERE for save, update, and delete. This makes scoped writes atomic rather than a check followed by an unscoped primary-key mutation.

The optional write callback receives and returns a row alist. It runs before create and save; update delegates to a scoped lookup by id and save. If omitted, it defaults to the identity function. Scope callbacks should signal an error when required context is unavailable. A configured scope that returns #f as its condition is rejected. Raw db/query and db/execute calls bypass model scopes.

The model's before-* lifecycle hooks run before the scope's write callback, so the scope is always the last writer and a hook cannot overwrite a scope-injected column. See "API: Lifecycle Hooks" below.

For a model named users, the generated functions are listed below. Each example shows the SQL that is produced (the SQLite dialect; rqlite renders identically). Assume users has columns id, name, email.

users/qprocedure

Return a query descriptor for the model: a value describing a SELECT over all of the table's columns, with the model's scope attached. Building and refining a descriptor never touches the database; run it with q/run (or batch several with q/run-all). See "API: Queries (q/)" below for the builders.

(q/run (users/q))
;; SQL: SELECT id, name, email FROM users

(q/run (q/limit (q/order (users/q) '(desc created-at)) 10))
;; SQL: SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT 10

(q/run (q/by-id (users/q) 1))   ; => ((id . 1) ...) or #f
;; SQL: SELECT id, name, email FROM users WHERE (id = ?) LIMIT 1

(q/run (q/count (q/where (users/q) '(= status ?) "active")))   ; => 15
;; SQL: SELECT COUNT(*) AS _count FROM users WHERE (status = ?)

The column list comes from the introspected schema. On a scoped model, the scope condition is ANDed in front of the caller's conditions when the descriptor is run, inside whatever parameterize is active at that point. A scope that cannot produce its condition signals an error (fails closed).

users/create row-alistprocedure

Insert a new row and return it (re-fetched by rowid). Fires before-create, then the scope's write callback, then the INSERT, then after-create. It does not fire the save hooks.

(users/create '((name . "Charlie") (email . "charlie@example.com")))
; => ((id . 3) (name . "Charlie") ...)
;; SQL: INSERT INTO users (name, email) VALUES (?, ?)
;;      then re-fetched: SELECT ... FROM users WHERE (rowid = ?) LIMIT 1

Pairs whose value is '() are dropped, so only supplied columns are inserted.

users/save row-alistprocedure

Update an existing row, matched by primary key, and return the fresh row. Sets updated_at to CURRENT_TIMESTAMP and ignores created-at / updated-at in the input.

Fires before-save, then the scope's write callback, then the UPDATE, then after-save. If the scoped UPDATE matches nothing, save returns #f: before-save has already run, but after-save is skipped and a warning is logged.

(let* ((user (q/run (q/by-id (users/q) 1)))
       (updated (alist-update 'name "Alicia" user)))
  (users/save updated))
;; SQL: UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE (id = ?)
;;      then re-fetched: SELECT ... FROM users WHERE (id = ?) LIMIT 1

Only non-primary-key, non-timestamp columns appear in the SET list; updated_at is always set to CURRENT_TIMESTAMP.

users/update id updatesprocedure

Convenience wrapper: find the row by id, apply the updates alist, and save. Returns the updated row, or #f if the id is not found. Because it delegates to users/save, it fires the save hooks exactly once. There is no separate before-update event.

(users/update 1 '((name . "Alicia") (status . "inactive")))
(users/update 999 '((name . "Nobody")))   ; => #f
users/delete row-alistprocedure

Delete the row identified by the primary key in row-alist. Returns #t.

Fires before-delete, then the DELETE, then after-delete. Both receive the row the before-hooks returned, and the primary key for the DELETE is taken from that same value. The return value is #t regardless of how many rows matched, so after-delete fires on a no-op delete as well. The scope's write callback is not applied here.

(let ((user (q/run (q/by-id (users/q) 1))))
  (users/delete user))
;; SQL: DELETE FROM users WHERE (id = ?)
users/columnsprocedure

Return the table's column metadata.

users/pkeyprocedure

Return the primary key column name(s) as a list.

users/hooksprocedure

Return the model's model-hooks registry. This is what makes hooks registrable from a different module than the one holding the define-model, and it is what the model/hook macro expands into.

(model-hooks-add! (users/hooks) 'before-create (lambda (row) row))

API: Queries (q/)

Model reads are values. users/q returns a query descriptor; the q/ builders take a descriptor as their first argument and return a new one (descriptors are immutable, so a base query can be shared and refined freely). Nothing is rendered, scoped or sent until a runner (q/run, q/run-list, q/run-all) executes it.

Because the descriptor always comes first, the builders thread naturally with a -> macro. The orm egg does not provide or depend on one: use -> from an egg such as clojurist, or nest the calls.

;; with a -> macro
(q/run (-> (users/q)
           (q/where '(= status ?) "active")
           (q/order '(desc created-at))
           (q/limit 10)))

;; the same, nested
(q/run (q/limit (q/order (q/where (users/q) '(= status ?) "active")
                         '(desc created-at))
                10))
make-model-query table columns-thunk scopeprocedure

Create a bare descriptor for table (a symbol), where columns-thunk returns the column specs (e.g. users/columns) and scope is a model-scope or #f. define-model generates users/q from this; you rarely call it directly.

model-query? xprocedure

Return #t if x is a query descriptor.

q/where q condition value ...procedure

Add an ssql condition with ? placeholders, bound positionally to the values. Several q/where calls AND together, and their values are bound in call order. Model field names in the condition are converted to snake_case.

(q/where (users/q) '(= status ?) "active")
;; WHERE (status = ?)

(q/where (q/where (users/q) '(= status ?) "active") '(> age ?) 18)
;; WHERE ((status = ?) AND (age > ?))   params: ("active" 18)

(q/where (users/q) '(between age ? ?) 18 65)
(q/where (users/q) '(like name ?) "%alice%")
(q/where (users/q) '(is deleted-at ?) 'null)
;; WHERE (deleted_at IS ?)

Comparison operators: =, <>, <, >, <=, >=, like, is, and anything else ssql supports.

q/order q orderprocedure

Set the ordering. order is a field, (asc field) or (desc field); kebab-case model fields are converted to snake_case.

(q/order (users/q) 'name)               ; ORDER BY name
(q/order (users/q) '(desc created-at))  ; ORDER BY created_at DESC
q/limit q nprocedure
q/offset q nprocedure

Set LIMIT / OFFSET.

q/first qprocedure

Run as a single row: the first match as an alist, or #f when nothing matches. Sets the limit to 1.

q/count qprocedure

Run as the integer number of matching rows (0 when none). Order, limit and offset are ignored.

(q/run (q/count (q/limit (users/q) 5)))
;; SQL: SELECT COUNT(*) AS _count FROM users
q/then q procprocedure

Post-process the final value: proc receives what the descriptor would otherwise return (row vector, row or #f, or count). Several q/then calls compose in order. Because the transformation travels with the descriptor, it also applies inside a batch.

(q/run (q/then (q/count (users/q)) zero?))   ; => #t on an empty table

(q/run (q/then (users/q)
               (lambda (rows) (map (lambda (r) (alist-ref 'name r))
                                   (vector->list rows)))))
; => ("Alice" "Bob")
q/by-id q idprocedure

Shorthand for (q/first (q/where q '(= id ?) id)).

q/run qprocedure

Render and run a single descriptor with db/query, returning a vector of row alists (kebab-case keys), a single row or #f (q/first), or an integer (q/count), after any q/then procedures.

q/run-list qsprocedure

Run a list of descriptors and return a list of their results, in order. Every descriptor is rendered first, so a scope error in any of them aborts the call before anything is sent. The statements then go to the database in a single db/batch, which is one round trip on backends with native batching (rqlite) and one query per statement elsewhere (SQLite).

q/run-all q ...procedure

Like q/run-list, but takes the descriptors as arguments and returns their results as multiple values.

(receive (user posts unread)
    (q/run-all (q/by-id (users/q) uid)
               (q/limit (q/where (posts/q) '(= published ?) 1) 10)
               (q/count (q/where (notifications/q) '(= user-id ?) uid)))
  ...)   ; one HTTP request on rqlite

Migrating from 0.0.12

Version 0.0.13 removed the immediate read functions in favour of descriptors:

Note that where/find/count took their values as a list; q/where takes them as separate arguments.

API: Lifecycle Hooks (model/hook)

Lifecycle hooks are additive, multi-subscriber callbacks attached to a model. Unlike a model scope, which is a single security policy, any number of hooks may be registered per event, by any module that can call (users/hooks).

The six events are before-create, after-create, before-save, after-save, before-delete and after-delete.

Hooks do not cascade. Unlike ActiveRecord, where before_save also runs on create, an event here fires only for its own operation. To run one body on both paths, list both events.

before-* hooks are a left fold: each is row -> row, they run in registration order, and each receives the previous one's output. A before-* hook that returns a non-list signals an error. after-* hooks are observers: they receive the persisted row and their return values are discarded.

A hook that calls error aborts the operation.

Ordering per operation:

create:  before-create hooks (in order)
           -> scope write callback
           -> INSERT
           -> re-fetch (scoped)
           -> after-create hooks       [skipped + warning if re-fetch is #f]

save:    before-save hooks (in order)
           -> scope write callback
           -> UPDATE ... WHERE pk AND scope
           -> re-fetch (scoped)
           -> after-save hooks         [skipped + warning if re-fetch is #f]

delete:  before-delete hooks (in order)
           -> DELETE ... WHERE pk AND scope   [pk taken from the hooks' result]
           -> after-delete hooks       [always run; delete always returns #t]

Because there are no transactions and the scope check rides in the WHERE clause, a before-* hook's side effects have already happened even when the write ultimately affects zero rows.

(model/hook table (event row) body ...)syntax

Register a hook on table for event, binding the incoming row alist to row in body. The head may also be ((event ...) row), which registers one shared closure against each listed event.

(model/hook users (before-create row)
  (alist-update 'slug (slugify (alist-ref 'name row)) row))

(model/hook users (after-delete row)
  (audit-log! 'deleted row))

(model/hook users ((before-create before-save) row)
  (normalize-email row))

Expands to a model-hooks-add! call per event against (table/hooks). The event symbol cannot be checked at expansion time, so an unknown event is reported by model-hooks-add! when the registering module is loaded.

Registration happens as a side effect of loading the form, and define-model binds a fresh registry, so reloading a file containing both a define-model and its model/hook forms re-registers each hook exactly once rather than accumulating duplicates.

make-model-hooksprocedure

Return a new, empty model-hooks registry. define-model calls this for you; it is exported mainly for testing.

model-hooks? xprocedure

Return #t if x is a model-hooks registry.

model-hooks-add! hooks event procprocedure

Append proc to event's hook list. Appending means registration order is run order. Signals an error for an invalid registry, an unknown event, or a non-procedure.

model-hooks-ref hooks eventprocedure

Return the list of procedures registered for event, or '().

model-hooks-clear! hooks #!optional eventprocedure

Remove the hooks for event, or all hooks when event is omitted. Primarily useful for test isolation, since a model's registry is global mutable state shared across a test file.

model-hook-eventsprocedure

Return the list of supported event symbols.

model-hook-event? eventprocedure

Return #t if event is one of the supported events.

run-before-hooks hooks event rowprocedure

Fold row through event's hooks and return the result. When hooks is #f, returns row unchanged. Signals an error if any hook returns a non-list.

run-after-hooks hooks event rowprocedure

Run event's hooks for effect and return row. When row is #f, the hooks are skipped and a warning is logged, since that indicates a write that landed outside the model's scope. When hooks is #f, this is a no-op.

API: Relationships (model/has-many)

(model/has-many parent child)syntax

Declare a has-many relationship between two existing models. Assumes the convention that the child table has a <singular-parent>-id foreign key column (e.g. posts.user_id → users.id).

(define-model users)
(define-model posts)
(model/has-many users posts)   ; posts.user_id -> users.id

This generates three functions:

users/posts/q parent-rowprocedure

Return a query descriptor for the child rows belonging to parent-row. Refine it with the q/ builders and run it with q/run, or batch it with q/run-all.

(let ((user (q/run (q/by-id (users/q) 1))))
  (q/run (users/posts/q user)))
;; SQL: SELECT id, user_id, title FROM posts WHERE (user_id = ?)

;; refined with q/
(let ((user (q/run (q/by-id (users/q) 1))))
  (q/run (q/limit (q/where (users/posts/q user) '(= published ?) 1) 5)))
;; SQL: SELECT ... FROM posts WHERE ((user_id = ?) AND (published = ?)) LIMIT 5
posts/users/q child-rowprocedure

Return a q/first descriptor for the parent row of child-row, so it runs to the parent alist or #f. If the child's foreign key is missing or NULL, the result is still a descriptor (one that matches nothing), so it can be batched like any other.

(let ((post (q/run (q/by-id (posts/q) 1))))
  (q/run (posts/users/q post)))   ; => ((id . 1) (name . "Alice") ...)
;; SQL: SELECT id, name, email FROM users WHERE (id = ?) LIMIT 1
users/add-posts parent-row child-rowprocedure

Associate child-row with parent-row by setting the foreign key, and save the child (issuing the same UPDATE posts SET ... WHERE (id = ?) as posts/save).

API: Migrations

A migration registers an up procedure and a down procedure under a name. Migrations are applied in registration order; the current version is tracked in a schema_migrations table that is created automatically.

model/migration name up-proc down-procprocedure

Register a migration. name is a string (e.g. "001-create-users"); up-proc and down-proc are zero-argument thunks.

(model/migration "001-create-users"
  (lambda ()
    (model/schema/create-table 'users
      '(id integer (primary-key #t) (autoincrement #t))
      '(name text (not-null #t))
      '(email text (unique #t))
      '(created-at datetime (default CURRENT_TIMESTAMP))
      '(updated-at datetime (default CURRENT_TIMESTAMP))))
  (lambda ()
    (model/schema/drop-table 'users)))

(model/migration "002-add-status-to-users"
  (lambda () (model/schema/add-columns 'users '(status text (default "active"))))
  (lambda () (model/schema/drop-columns 'users 'status)))
model/migrate #!optional target-versionprocedure

Apply migrations. With no argument, migrate up to the latest registered migration. With a target name, migrate up or down to that version, applying the necessary up/down procedures in order.

(model/migrate)                    ; up to latest
(model/migrate "001-create-users") ; up or down to this version
model/rollback-all!procedure

Roll back every applied migration, returning the schema to a clean state.

Running Migrations from the CLI

The egg installs an orm-migrate program that runs migrations without a driver script. Point it at a migrations file — a plain Scheme file containing (model/migration ...) forms (no imports needed; orm is already in scope) — and choose the backend at runtime.

# Apply all migrations up to the latest
orm-migrate -b sqlite -path myapp.db -f migrations.scm

# Migrate up or down to a specific version
orm-migrate -b sqlite -path myapp.db -f migrations.scm -m 001-create-users

# Roll everything back to a clean state
orm-migrate -b sqlite -path myapp.db -f migrations.scm --rollback

# rqlite: -path is the HTTP connection string (keep credentials off disk)
orm-migrate -b rqlite -path "https://user:pass@host:4001" -f migrations.scm

Flags:

The chosen backend egg (orm-db-sqlite or orm-db-rqlite) is imported dynamically at runtime, so it must be installed, but the orm egg keeps no static dependency on either.

API: Schema Helpers

These helpers generate and run DDL; they are normally called from inside migration thunks.

model/schema/create-table table-name column-spec ...procedure

Create a table. Each column-spec is (name type option ...).

(model/schema/create-table 'posts
  '(id integer (primary-key #t) (autoincrement #t))
  '(user-id integer (foreign-key users id))
  '(title text (not-null #t))
  '(body text)
  '(published boolean (default #f))
  '(created-at datetime (default CURRENT_TIMESTAMP)))
model/schema/drop-table table-nameprocedure

Drop a table.

model/schema/add-columns table-name column-spec ...procedure

Add one or more columns. Honors the same options as create-table.

(model/schema/add-columns 'posts
  '(slug text)
  '(view-count integer (default 0)))
model/schema/drop-columns table-name column-name ...procedure

Drop one or more columns (subject to the backend's ALTER TABLE support).

column-spec->sql spec #!optional alter?procedure

Render a single column spec to its SQL fragment. Exposed for reuse; create-table and add-columns build on it.

Column Options

| primary-key | (primary-key #t) | Mark as primary key | | autoincrement | (autoincrement #t) | Auto-increment (integers) | | not-null | (not-null #t) | NOT NULL constraint | | unique | (unique #t) | UNIQUE constraint | | default | (default 0) | Default value | | foreign-key | (foreign-key users id) | Foreign key reference |

Column Types

Supported types: integer, text, string, real, float, blob, datetime, boolean.

API: Helper Functions

row-ref/default key row #!optional defaultprocedure

Read key from row, returning default when the value is SQL NULL (the symbol null). Signals an error if the key is absent. default is #f when omitted.

(row-ref/default 'name user)            ; => "Alice"
(row-ref/default 'nickname user "N/A")  ; => "N/A" if NULL
row-metadata row #!optional defaultprocedure

For a row with a metadata TEXT column holding an s-expression alist, parse and return that alist (or default, default '(), when NULL or unparseable).

(row-metadata user)   ; => ((theme . "dark") (language . "en"))
row-metadata-set! row alistprocedure

Write alist to the row's metadata column and return the updated row alist.

(row-metadata-set! user '((theme . "light")))
symbol->db-column symprocedure
db-column->symbol sym-or-stringprocedure

Convert names between Scheme kebab-case and SQL snake_case.

(symbol->db-column 'created-at)   ; => created_at
(db-column->symbol 'created_at)   ; => created-at
(db-column->symbol "created_at")  ; => created-at

The module also exports the lower-level helpers load-table-metadata, register-model!, get-model-metadata, convert-result-keys, convert-results-vector, get-primary-key-columns, build-pk-where, and map-field-names->columns, used internally by the generated functions.

API: Testing (orm-test)

The orm-test module — bundled in the orm egg, no separate install — provides a mock backend for testing code that uses orm-db without a real database.

make-mock-backendprocedure

Returns two values: a backend alist (compatible with db/backend) and a spy procedure for inspecting and controlling the mock.

(import orm-db orm-test)

(receive (backend spy) (make-mock-backend)
  (db/backend backend)
  (db/path "ignored")
  (db/connect)

  ;; Configure responses
  (spy 'on-query (list (vector '((id . 1) (name . "Alice")))))

  (q/run (users/q))  ; => #(((id . 1) (name . "Alice")))
  (spy 'queries))    ; => ((<ssql form> ()))

The spy procedure responds to these messages:

  • (spy 'queries) — list of (sql params) pairs from all queries
  • (spy 'executions) — list of (sql params out-key) from all executions
  • (spy 'batches): list of statement lists, one per db/batch call (each statement is (sql . params)). Batched statements are also answered by the query handler and recorded in queries, so a test can assert "3 reads, 1 batch"
  • (spy 'on-query responses-or-proc) — set query responses: a list (consumed in order, last item repeats) or a (lambda (sql params) ...)
  • (spy 'on-execute responses-or-proc) — set execute responses: a list or a (lambda (sql params out-key) ...)
  • (spy 'reset!): clear recorded queries, executions and batches
;; Static list of responses (last item repeats forever)
(spy 'on-query (list
  (vector '((id . 1) (name . "Alice")))   ; first query
  (vector)))                               ; all subsequent queries

;; Dynamic responses
(spy 'on-query (lambda (sql params)
  (if (string-contains sql "users")
      (vector '((id . 1) (name . "Alice")))
      (vector))))

Complete Example

(import orm-db orm-db-sqlite orm)

;; 1. Connect
(db/backend (sqlite3-backend))
(db/path "blog.db")
(db/connect)

;; 2. Migrate the schema
(model/migration "001-init"
  (lambda ()
    (model/schema/create-table 'users
      '(id integer (primary-key #t) (autoincrement #t))
      '(name text (not-null #t))
      '(updated-at datetime (default CURRENT_TIMESTAMP)))
    (model/schema/create-table 'posts
      '(id integer (primary-key #t) (autoincrement #t))
      '(user-id integer (foreign-key users id))
      '(title text (not-null #t))
      '(updated-at datetime (default CURRENT_TIMESTAMP))))
  (lambda ()
    (model/schema/drop-table 'posts)
    (model/schema/drop-table 'users)))

(model/migrate)

;; 3. Define models and a relationship
(define-model users)
(define-model posts)
(model/has-many users posts)

;; 4. Use them
(define alice (users/create '((name . "Alice"))))
(users/add-posts alice (posts/create `((title . "Hello") (user-id . ,(alist-ref 'id alice)))))

(q/run (users/posts/q alice))   ; => #(((id . 1) (title . "Hello") ...))

;; several independent reads in one batch
(receive (n latest)
    (q/run-all (q/count (users/q))
               (q/first (q/order (posts/q) '(desc id))))
  (print n " users, latest post: " (alist-ref 'title latest)))

(db/close)

License

Copyright © 2026 Rolando Abarca. Released under the BSD-3-Clause license.

Repository

Part of the chicken-orm-egg project.

Contents »