== 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:
* {{orm}} — the core ORM: models, migrations, relationships
* {{orm-db}} — the abstract, backend-agnostic database interface
* {{orm-test}} — a mock backend for testing ORM code without a real database
A database backend is installed separately as its own egg, because each pulls heavy, mutually-exclusive dependencies:
* [[orm-db-sqlite]] — SQLite3 backend
* [[orm-db-rqlite]] — rqlite (HTTP-based distributed SQLite) backend
=== Requirements
* CHICKEN Scheme 5.0 or later
* Dependencies: srfi-1, srfi-13, srfi-133, logger, sql-null
* A backend egg ([[orm-db-sqlite]] or [[orm-db-rqlite]]) to connect to a real database
=== 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
(users/all) ; => #(((id . 1) (name . "Alice") ...) ...)
(users/find '(= id ?) '(1)) ; => ((id . 1) (name . "Alice") ...)
=== Naming Conventions
The ORM automatically converts between Scheme's kebab-case and SQL's snake_case:
* Table name {{user-sessions}} maps to {{user_sessions}}
* Column {{created-at}} maps to {{created_at}}
* Query results are returned with kebab-case keys
=== Data Representations
* '''Query results''': a vector of alists, one alist per row
* '''Single row''': an alist with symbol keys
* '''SQL NULL''': represented as the symbol {{null}}
* '''Metadata columns''': a TEXT column storing an s-expression alist, read/written via {{row-metadata}} / {{row-metadata-set!}}
=== 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}} to procedures; the backend eggs provide constructors ({{sqlite3-backend}}, {{rqlite-backend}}) that return such an alist.
(db/backend [backend])
Get or set the current backend alist. Must be set before connecting.
(db/path [path])
Get or set the database path or connection string. Must be set before connecting.
(db/connection [conn])
Get or set the current (opaque, backend-specific) connection. Normally managed by {{db/connect}} / {{db/close}}.
(db/connect)
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/close)
Close the current connection (no-op if none is open).
(db/query ssql-or-string [params])
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])
Run an INSERT/UPDATE/DELETE/DDL statement. {{out-key}} selects which value the backend returns (default {{rows_affected}}; e.g. {{last_insert_rowid}}).
=== API: Models (define-model)
(define-model name)
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)
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/all #!key limit order offset)
Return all rows as a vector of alists.
(users/all)
;; SQL: SELECT id, name, email FROM users
(users/all limit: 10)
;; SQL: SELECT id, name, email FROM users LIMIT 10
(users/all order: 'name) ; ascending
;; SQL: SELECT id, name, email FROM users ORDER BY name
(users/all order: '(desc created-at)) ; descending
;; SQL: SELECT id, name, email FROM users ORDER BY created_at DESC
Note how the column list comes from the introspected schema, and kebab-case names in {{order}} are converted to snake_case.
(users/find [conditions] [values] #!key order)
Return a single row (alist) matching {{conditions}}, or {{#f}} if none. {{conditions}} is an {{ssql}} WHERE form with {{?}} placeholders, {{values}} the bound values.
(users/find '(= id ?) '(1))
;; SQL: SELECT id, name, email FROM users WHERE (id = ?) LIMIT 1
(users/find '(= email ?) '("bob@example.com"))
;; SQL: SELECT id, name, email FROM users WHERE (email = ?) LIMIT 1
(users/find '(= id ?) '(999)) ; => #f
{{find}} is just {{where}} with {{limit: 1}}, returning the single row or {{#f}}.
(users/where [conditions] [values] #!key limit order offset)
Return all rows matching {{conditions}} as a vector of alists.
(users/where '(= status ?) '("active"))
;; SQL: SELECT id, name, email, status FROM users WHERE (status = ?)
(users/where '(and (= status ?) (> age ?)) '("active" 18))
;; SQL: SELECT ... FROM users WHERE ((status = ?) AND (age > ?))
(users/where '(>= age ?) '(21))
;; SQL: SELECT ... FROM users WHERE (age >= ?)
(users/where '(like name ?) '("%alice%"))
;; SQL: SELECT ... FROM users WHERE (name LIKE ?)
(users/where '(is deleted-at ?) '(null))
;; SQL: SELECT ... FROM users WHERE (deleted_at IS ?)
(users/where '(= status ?) '("active") limit: 10 order: '(desc created-at))
;; SQL: SELECT ... FROM users WHERE (status = ?) ORDER BY created_at DESC LIMIT 10
Comparison operators: {{=}}, {{<>}}, {{<}}, {{>}}, {{<=}}, {{>=}}, {{like}}, {{is}}. The {{conditions}} form is an {{ssql}} expression; {{?}} placeholders are bound positionally from {{values}}.
(users/count [conditions] [values])
Return the number of matching rows (all rows if no conditions).
(users/count) ; => 42
;; SQL: SELECT COUNT(*) AS _count FROM users
(users/count '(= status ?) '("active")) ; => 15
;; SQL: SELECT COUNT(*) AS _count FROM users WHERE (status = ?)
(users/create row-alist)
Insert a new row and return it (re-fetched by rowid).
(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-alist)
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.
(let* ((user (users/find '(= id ?) '(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 updates)
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.
(users/update 1 '((name . "Alicia") (status . "inactive")))
(users/update 999 '((name . "Nobody"))) ; => #f
(users/delete row-alist)
Delete the row identified by the primary key in {{row-alist}}. Returns {{#t}}.
(let ((user (users/find '(= id ?) '(1))))
(users/delete user))
;; SQL: DELETE FROM users WHERE (id = ?)
(users/columns)
Return the table's column metadata.
(users/pkey)
Return the primary key column name(s) as a list.
=== API: Relationships (model/has-many)
(model/has-many parent child)
Declare a has-many relationship between two existing models. Assumes the convention that the child table has a {{-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 parent-row [conditions] [values] #!key limit order offset)
Return all child rows belonging to {{parent-row}}, optionally further filtered.
(let ((user (users/find '(= id ?) '(1))))
(users/posts user))
;; SQL: SELECT id, user_id, title FROM posts WHERE (user_id = ?)
;; with extra conditions
(let ((user (users/find '(= id ?) '(1))))
(users/posts user '(= published ?) '(#t)))
;; SQL: SELECT ... FROM posts WHERE ((user_id = ?) AND (published = ?))
(posts/users child-row)
Return the parent row for {{child-row}}.
(let ((post (posts/find '(= id ?) '(1))))
(posts/users post)) ; => ((id . 1) (name . "Alice") ...)
;; SQL: SELECT id, name, email FROM users WHERE (id = ?) LIMIT 1
(users/add-posts parent-row child-row)
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-proc)
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 [target-version])
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!)
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:
* {{-b}}, {{--backend}} — backend to use: {{sqlite}} or {{rqlite}} (required)
* {{-path}}, {{--path}} — database path / connection string (required)
* {{-f}}, {{--file}} — migrations file with {{(model/migration ...)}} forms (required)
* {{-m}}, {{--migration}} — target version; migrates up or down to it (default: latest)
* {{--rollback}} — roll back all migrations
* {{-h}}, {{--help}} — show usage
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 ...)
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-name)
Drop a table.
(model/schema/add-columns table-name column-spec ...)
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 ...)
Drop one or more columns (subject to the backend's {{ALTER TABLE}} support).
(column-spec->sql spec [alter?])
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 [default])
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 [default])
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 alist)
Write {{alist}} to the row's {{metadata}} column and return the updated row alist.
(row-metadata-set! user '((theme . "light")))
(symbol->db-column sym)
(db-column->symbol sym-or-string)
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-backend)
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")))))
(users/all) ; => #(((id . 1) (name . "Alice")))
(spy 'queries)) ; => (("SELECT ..." ()))
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 '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 and executions
;; 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)))))
(users/posts alice) ; => #(((id . 1) (title . "Hello") ...))
(db/close)
=== License
Copyright © 2026 Rolando Abarca. Released under the BSD-3-Clause license.
=== Repository
Part of the [[https://github.com/schematra/chicken-orm-egg|chicken-orm-egg]] project.