Skip to content

Repository files navigation

ORM Egg

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

Eggs

This repository contains three eggs:

  • orm - The ORM itself (models, migrations, relationships). Bundles three modules:
    • orm - the ORM (models, migrations, relationships)
    • orm-db - abstract database interface with pluggable backends
    • orm-test - mock backend for testing ORM code without a real database
  • orm-db-sqlite - SQLite3 backend
  • orm-db-rqlite - rqlite (HTTP-based distributed SQLite) backend

The backends are separate eggs because each pulls heavy, mutually-exclusive dependencies — install only the one you need. Module names are unchanged: (import orm-db) and (import orm-test) work as before.

Installation

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

Quick Start

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

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

;; Define a model (table must already exist)
(define-model users)

;; Query all users
(users/all)  ; => #(((id . 1) (name . "Alice") ...) ...)

;; Find a specific user
(users/find '(= id ?) '(1))  ; => ((id . 1) (name . "Alice") ...)

Database Setup

Configuring the Backend

(import orm-db orm-db-sqlite)

;; Set the backend (must be done before connecting)
(db/backend (sqlite3-backend))

;; Set the database path
(db/path "path/to/database.db")

;; Connect
(db/connect)

;; When done, close the connection
(db/close)

Defining Models

The define-model macro creates a model bound to an existing database table. It automatically introspects the table schema and generates CRUD functions.

(define-model users)

This generates the following functions:

Function Description
users/all Get all rows
users/find Find a single row
users/where Query with conditions
users/count Count matching rows
users/create Insert a new row
users/save Update an existing row
users/update Find and update by ID
users/delete Delete a row
users/columns Get column metadata
users/pkey Get primary key column(s)
users/hooks Get the model's lifecycle hook registry

Mandatory Model Scopes

A model may declare an application-defined scope that the ORM automatically applies to every generated CRUD operation:

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

(define account-scope
  (make-model-scope
   ;; Return a mandatory condition and its placeholder values.
   (lambda ()
     (let ((account-id (current-account-id)))
       (unless account-id (error "account scope required"))
       (values '(= account-id ?) (list account-id))))
   ;; Prepare rows before create/save (update delegates to save).
   (lambda (row)
     (alist-update 'account-id (current-account-id) row))))

(define-model invoices scope: account-scope)

The scope condition is combined before caller conditions for all, find, where, and count. It is also included directly in the WHERE clause for save, update, and delete, so a primary key from another scope cannot be modified. The write callback runs before create and save, allowing the application to stamp or validate ownership fields.

Scope callbacks should fail when required context is missing. A configured scope that returns no condition is rejected rather than falling back to an unscoped query. Direct db/query and db/execute calls bypass model scopes and remain the application's responsibility.

Lifecycle Hooks

Where a scope is a single security policy, lifecycle hooks are ordinary application behavior: derive a slug before an insert, bust a cache after an update, write an audit row after a delete. Any number of hooks may be registered per event, and any module holding (users/hooks) can add one.

(define-model users)

(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))

;; one body, several events
(model/hook users ((before-create before-save) row)
  (normalize-email row))

There are six events:

Event Fires on
before-create / after-create users/create
before-save / after-save users/save and users/update
before-delete / after-delete users/delete

Hooks do not cascade. This is the biggest difference from ActiveRecord: in Rails, before_save also runs on create. Here it does not. users/create fires only the create hooks. If you want a hook on both paths, list both events explicitly, as in the third example above. users/update delegates to users/save, so it fires the save hooks exactly once.

before-* hooks are a left fold: each one is row -> row, they run in registration order, and each is fed the previous one's output. Returning something that is not an alist is an error. after-* hooks are observers: they receive the persisted row and their return values are ignored, so a hook that forgets to return the row cannot corrupt what create or save hands back.

Hooks run before the scope's write procedure. The scope is the security boundary and must be the last writer, so a before-create hook can never clobber a scope-injected account-id. A hook that needs that value should read the application parameter the scope reads, rather than the row.

A hook that calls error aborts the whole operation. There is no separate "cancel" protocol.

Four sharp edges worth knowing:

  1. before-* hooks fire even when the write affects zero rows. There are no transactions, and the scope check rides in the UPDATE's WHERE, so a before-save side effect has already happened by the time save returns #f.
  2. after-* hooks are skipped when the post-write re-fetch returns #f, which is what an out-of-scope write looks like. The ORM logs a warning on that path rather than failing silently.
  3. after-delete fires even on a no-op delete. users/delete ignores rows_affected and always returns #t.
  4. before-delete is a fold like the others, so a hook that rewrites id will delete a different row: the primary key for the DELETE comes from whatever the hooks returned.

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
  • Results are returned with kebab-case keys

Querying

Get All Rows

;; Get all users
(users/all)  ; => #(((id . 1) (name . "Alice")) ((id . 2) (name . "Bob")))

;; With limit
(users/all limit: 10)

;; With ordering
(users/all order: 'name)              ; ascending by name
(users/all order: '(desc created-at)) ; descending by created_at

Find a Single Row

;; Find by condition (returns single alist or #f)
(users/find '(= id ?) '(1))
; => ((id . 1) (name . "Alice") (email . "alice@example.com"))

(users/find '(= email ?) '("bob@example.com"))
; => ((id . 2) (name . "Bob") (email . "bob@example.com"))

;; Returns #f if not found
(users/find '(= id ?) '(999))  ; => #f

Query with Conditions

;; Basic equality
(users/where '(= status ?) '("active"))

;; Multiple conditions
(users/where '(and (= status ?) (> age ?)) '("active" 18))

;; With limit and order
(users/where '(= status ?) '("active") limit: 10 order: '(desc created-at))

;; Comparison operators: =, <>, <, >, <=, >=
(users/where '(>= age ?) '(21))

;; LIKE queries
(users/where '(like name ?) '("%alice%"))

;; NULL checks
(users/where '(is deleted-at ?) '(null))

Count Rows

;; Count all
(users/count)  ; => 42

;; Count with condition
(users/count '(= status ?) '("active"))  ; => 15

Creating and Updating

Create a New Row

(users/create '((name . "Charlie") (email . "charlie@example.com")))
; => ((id . 3) (name . "Charlie") (email . "charlie@example.com") ...)

The create function returns the newly created row (fetched by rowid). It fires before-create (before the scope's write procedure) and then after-create. It does not fire the save hooks.

Save (Update) an Existing Row

(let ((user (users/find '(= id ?) '(1))))
  ;; Modify the user
  (let ((updated-user (alist-update 'name "Alicia" user)))
    ;; Save changes
    (users/save updated-user)))

The save function:

  • Updates based on primary key
  • Automatically sets updated_at to CURRENT_TIMESTAMP
  • Ignores created-at and updated-at in the input
  • Returns the fresh row from the database
  • Fires before-save (before the scope's write procedure) and then after-save

users/update delegates to users/save, so it fires the save hooks once, not twice. If the scoped UPDATE matches no rows, before-save has already run but after-save is skipped.

Update by ID

;; Convenience wrapper: find by ID, apply updates, save
(users/update 1 '((name . "Alicia") (status . "inactive")))
; => ((id . 1) (name . "Alicia") (status . "inactive") ...)

;; Returns #f if ID not found
(users/update 999 '((name . "Nobody")))  ; => #f

Deleting

(let ((user (users/find '(= id ?) '(1))))
  (users/delete user))  ; => #t

delete fires before-delete and then after-delete, both receiving the row the before-hooks returned. It always returns #t, even when no row matched, so after-delete fires on a no-op delete too.

Migrations

The ORM includes a simple migration system for managing schema changes.

Defining Migrations

(model/migration "001-create-users"
  ;; Up migration
  (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))))
  ;; Down migration
  (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)))

Running Migrations

;; Run all pending migrations
(model/migrate)

;; Migrate to a specific version
(model/migrate "001-create-users")

;; Roll back all migrations
(model/rollback-all!)

Running Migrations from the CLI

The orm egg installs an orm-migrate program that runs migrations without writing a driver script. Point it at a migrations file — a plain Scheme file containing (model/migration ...) forms (no imports needed; orm is in scope) — and select 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
Flag Description
-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 backend egg (orm-db-sqlite or orm-db-rqlite) is imported dynamically, so it must be installed, but the orm egg keeps no static dependency on either.

Schema Helpers

;; Create a table
(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)))

;; Drop a table
(model/schema/drop-table 'posts)

;; Add columns
(model/schema/add-columns 'posts
  '(slug text)
  '(view-count integer (default 0)))

;; Drop columns (limited SQLite support)
(model/schema/drop-columns 'posts 'slug 'view-count)

Column Options

Option Example Description
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

Relationships

Has-Many Relationships

(define-model users)
(define-model posts)

;; Define the relationship (posts.user_id -> users.id)
(model/has-many users posts)

This generates:

Function Description
users/posts Get all posts for a user
posts/users Get the user for a post
users/add-posts Associate a post with a user
;; Get all posts for a user
(let ((user (users/find '(= id ?) '(1))))
  (users/posts user))  ; => #(((id . 1) (title . "Post 1") ...) ...)

;; Get the user for a post
(let ((post (posts/find '(= id ?) '(1))))
  (posts/users post))  ; => ((id . 1) (name . "Alice") ...)

;; With additional conditions
(let ((user (users/find '(= id ?) '(1))))
  (users/posts user '(= published ?) '(#t)))

Helper Functions

row-ref/default

Get a value from a row, treating SQL NULL as a default:

(let ((user (users/find '(= id ?) '(1))))
  (row-ref/default 'name user)           ; => "Alice"
  (row-ref/default 'nickname user "N/A") ; => "N/A" if NULL
  (row-ref/default 'missing user))       ; => error: key doesn't exist

row-metadata / row-metadata-set!

For tables with a metadata TEXT column storing s-expressions:

;; Read metadata (returns alist, or default if NULL/invalid)
(let ((user (users/find '(= id ?) '(1))))
  (row-metadata user))  ; => ((theme . "dark") (language . "en"))

;; Set metadata (returns updated row alist)
(let ((user (users/find '(= id ?) '(1))))
  (row-metadata-set! user '((theme . "light"))))

Name Conversion

;; Scheme symbol to DB column (kebab-case -> snake_case)
(symbol->db-column 'created-at)  ; => created_at

;; DB column to Scheme symbol (snake_case -> kebab-case)
(db-column->symbol 'created_at)  ; => created-at
(db-column->symbol "created_at") ; => created-at

Testing with orm-test

The orm-test module (bundled in the orm egg) provides a mock database backend for testing code that uses orm-db without requiring a real database connection. It's installed automatically with orm — no separate install needed.

make-mock-backend

make-mock-backend returns two values: a backend (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)
  ;; Use the mock backend instead of a real one
  (db/backend backend)
  (db/path "ignored")
  (db/connect)

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

  ;; Now any query will return the configured response
  (users/all)  ; => #(((id . 1) (name . "Alice")))

  ;; Inspect what SQL was executed
  (spy 'queries)  ; => (("SELECT ..." ()))
  )

Spy Messages

Message Arguments Description
queries none Returns list of (sql params) pairs from all queries
executions none Returns list of (sql params out-key) from all executions
on-query responses-or-proc Set query responses: a list (consumed in order, last repeats) or a (lambda (sql params) ...)
on-execute responses-or-proc Set execute responses: a list or a (lambda (sql params out-key) ...)
reset! none Clear recorded queries and executions

Configuring Responses

Responses can be a list (each call consumes the next item; the last item repeats forever) or a procedure for dynamic responses:

;; Static list of responses
(spy 'on-query (list
  (vector '((id . 1) (name . "Alice")))   ; first query returns this
  (vector)))                                ; all subsequent queries return empty

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

History

v0.0.12

  • Added lifecycle hooks: (model/hook users (before-create row) ...) registers additive, multi-subscriber callbacks on a model.
  • Six events: before-create/after-create, before-save/after-save, before-delete/after-delete. Hooks do not cascade, so create fires only the create hooks (unlike ActiveRecord). A multi-event head registers one closure against several events.
  • before-* hooks are a left fold (row -> row, in registration order); after-* hooks are observers whose return values are ignored.
  • Hooks run before the scope's write procedure, so a hook can never clobber a scope-injected column.
  • define-model now also generates (users/hooks), which exposes the registry so another module can register hooks.
  • New exports: make-model-hooks, model-hooks?, model-hooks-add!, model-hooks-ref, model-hooks-clear!, model-hook-event?, model-hook-events, run-before-hooks, run-after-hooks, model/hook.
  • Models that register no hooks are unchanged.

v0.0.11

  • Added mandatory model scopes: (define-model name scope: my-scope) attaches an application-defined policy that the ORM applies to every generated CRUD function.
  • New exports: make-model-scope, model-scope?, apply-model-scope-condition, apply-model-scope-write.
  • The scope condition is combined into the same WHERE clause used by save, update, and delete, so a primary key from another scope cannot be mutated.
  • The optional write callback runs before create and save, letting the application stamp or validate ownership fields.
  • A configured scope that returns no condition is rejected rather than silently falling back to an unscoped query.
  • Models without scope: are unchanged.

License

BSD-3-Clause

About

simple ORM layer for CHICKEN scheme

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages