SQLite bindings for Xi apps — pure Xi, no glue C.
The library binds the system sqlite3 directly through Xi's extern "C" FFI
(link "sqlite3") and exposes everything through interfaces resolved by
Xi's DI container: depend on sqlite.SQLite (connections and queries),
sqlite.RowReader (typed access to result rows), or sqlite.ColumnDecoder
(how columns become values), and the bundled implementations are injected
automatically — or rebind any of them in your module App / module Test.
Every fallible call returns T!, never aborts.
Copy or vendor the src/ contents — sqlite.xi plus the
sqlite/ folder — into your project and import the umbrella
file (always that one; it loads the parts in the required order):
import "vendor/sqlite.xi" // wherever you placed it
Requires the SQLite library on the host (preinstalled on macOS; libsqlite3-dev
on Debian/Ubuntu).
Keep SQL out of your entry: put it in a store class behind a repository
interface, and let the entry depend on the abstraction only.
examples/demo.xi is the full version of this sketch:
import "std/log.xi"
import "vendor/sqlite.xi"
type Note = { id: Integer, title: String, stars: Number }
type Notes = { items: List<Note> }
type NoteSession = { db: sqlite.Database }
interface NoteRepository {
producer open(path: String) -> NoteSession!
producer add(s: NoteSession, title: String, stars: Number) -> Integer!
producer list(s: NoteSession) -> Notes!
producer close(s: NoteSession) -> Bool!
}
// all SQL lives here; the rest of the app never sees it
class NoteSQLiteStore implements NoteRepository {
deps { sql: sqlite.SQLite, reader: sqlite.RowReader }
producer open(path: String) -> NoteSession! {
let db = sql.open(path)?
let made = sql.exec(db, "create table if not exists notes (id integer primary key, title text not null, stars real not null)")
if isErr(made) { return err(made.err) }
return ok(NoteSession { db: db })
}
producer list(s: NoteSession) -> Notes! {
let rows = sql.query(s.db, "select id, title, stars from notes order by id")
if isErr(rows) { return err(rows.err) }
let notes = empty List<Note>
for row in rows.value.items {
notes.push(Note {
id: reader.intAt(row, "id", 0),
title: reader.textAt(row, "title", ""),
stars: reader.numberAt(row, "stars", 0.0)
})
}
return ok(Notes { items: notes })
}
// ... add/close elided
}
async entry (logger: Logger, notes: NoteRepository) main(args: String[]) -> Integer {
let opened = notes.open("app.db")
if isErr(opened) { logger.error(opened.err) return 1 }
let session = opened.value
let added = notes.add(session, "hi", 5.0)
if isErr(added) { logger.error(added.err) return 1 }
let all = notes.list(session)
if isErr(all) { logger.error(all.err) return 1 }
for n in all.value.items { logger.info("#" + n.id + " " + n.title) }
let closed = notes.close(session)
if isErr(closed) { logger.error(closed.err) return 1 }
return 0
}
module App {}
Overriding: every seam is an interface, so swapping behavior is one bind —
fake the whole database in tests (module Test { bind sqlite.SQLite -> FakeSQLite }), swap persistence (bind NoteRepository -> InMemoryNotes), or
change column decoding for every query (bind sqlite.ColumnDecoder -> MyDecoder). Tests can also inject the real thing per test block:
test "queries" (sql: sqlite.SQLite, reader: sqlite.RowReader) { ... }, as
tests/sqlite_test.xi does.
| Method | Returns | Notes |
|---|---|---|
open(path) |
Database! |
creates the file if missing; ":memory:" for in-memory |
close(db) |
Bool! |
errs if statements are still open |
exec(db, sql) |
Bool! |
one or more ;-separated statements, no result rows |
query(db, sql) |
Rows! |
typed rows; iterate rows.items |
queryJson(db, sql) |
String! |
rows as a JSON array of objects |
lastInsertId(db) |
Integer |
rowid of the last insert |
changes(db) |
Integer |
rows affected by the last statement |
Each Row maps column names to typed Values (IntValue, RealValue,
TextValue, NullValue). Missing columns and type mismatches yield the
fallback you pass:
reader.intAt(row, "id", 0) // Integer
reader.numberAt(row, "price", 0.0) // Number (integers widen)
reader.textAt(row, "title", "") // String
reader.isNull(row, "deleted_at") // Bool
reader.hasColumn(row, "id") // Bool
SystemSQLite runs every result column through this; rebind it to change how
all queries decode. BLOB columns are surfaced as text (sqlite's cast of the
raw bytes); select hex(col) in SQL when you need a stable binary encoding.
src/sqlite.xi— umbrella file; the one apps importsrc/sqlite/api.xi—namespace sqlite: the data types (Database,Value,Row,Rows), the interfaces (SQLite,RowReader,ColumnDecoder), and the Value-handling defaults (TypedRowReader,TypedColumnDecoder)src/sqlite/system.xi—SystemSQLite, the stock implementation over libsqlite3src/sqlite/ffi.xi— rawextern "C"binding (internal)examples/demo.xi— layered demo app:./scripts/run-demo.shtests/sqlite_test.xi— test suite:./scripts/run-tests.sh
Types, interfaces, and the Value-matching classes share api.xi because a Xi
namespace cannot span files; the parts deliberately don't import each other
(Xi resolves imports by literal path and would load a file reached via two
spellings twice), so always import the umbrella sqlite.xi.
./scripts/run-demo.sh # builds with xc, runs build/sqlite-demo
./scripts/run-tests.sh # xi test tests/sqlite_test.xiExpected demo output:
[info] inserted up to id 2
[info] #1 hello from Xi (4.5 stars)
[info] #2 no C bridge needed (5 stars)