commit 924a2d919032142e97c129583274c7073ea8c3da
parent 190a3391f526379823270b8b2927884d367ef668
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Thu, 24 Sep 2026 17:03:35 -0300
sqlite3: bind SQLite over the vendored amalgamation
Scripts get a real database without installing one: the archive is compiled
from sqlite3/vendor by `just sqlite` and linked in, so ldd on a built script
names no libsqlite3.
Values are bound, never interpolated, which is the whole reason to bind the C
API rather than shell out to the sqlite3 CLI. bind refuses an argument list
that does not match the statement's parameter count, because SQLite would
otherwise store NULL for the ones left unset. text and blob clone what they
read: SQLite frees its copy on the next step and reuses the memory from its
own pool, so a stale pointer yields the next row's bytes rather than
crashing, and AddressSanitizer cannot see it.
foreign import resolves the archive relative to the package directory, which
is why lib/ sits there rather than in build/. odin check never opens it, so
`just check` still type-checks linux, darwin and windows from one machine
with nothing built.
Diffstat:
7 files changed, 1158 insertions(+), 10 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1 +1,2 @@
build/
+sqlite3/lib/
diff --git a/README.md b/README.md
@@ -1,8 +1,9 @@
# jm — Odin for scripts
A collection of small packages and one runner that make Odin comfortable for
-the scripts Python and bash usually get. Everything builds on `core:`; the
-only linked dependency is libcurl through `vendor:curl`.
+the scripts Python and bash usually get. Everything builds on `core:`. The
+only system library is libcurl through `vendor:curl`; SQLite is vendored and
+linked statically, so a script that uses it still installs nothing.
```odin
#!/usr/bin/env odin-run
@@ -39,6 +40,8 @@ binary.
| `timefmt` | strftime `format`, `local`, `parse`; `iso`, `stamp`, `date`, `duration` |
| `debug` | guard-byte debug allocator (origin: sonar, which now imports this copy) |
| `flow` | `width`, `each`, `manage`: lock-free worker pools where each worker owns one state slot and the caller merges afterwards |
+| `tar` | `read`, `extract`: `git archive` output without a tar program |
+| `sqlite3` | `open`, `exec`, `exec_args`, `query`/`next`, `prepare`, `transact` over a statically linked SQLite |
`tools/odin-run` is the runner. Every package reads on its own; the doc
comment at the top of each file is the reference.
@@ -81,8 +84,38 @@ the cached binary.
just build debug odin-run just release optimised odin-run
just test all package tests just check 3-target type-check
just install odin-run -> ~/.local/bin (BINDIR overrides)
-just example run examples/hello.odin just clean
+just sqlite compile the vendored SQLite just clean
+just example run examples/hello.odin
```
`just install` bakes this checkout's path into the runner as the `jm`
collection root; `ODIN_RUN_COLLECTION` overrides it.
+
+## SQLite
+
+`sqlite3/vendor/` holds the SQLite **3.53.4** amalgamation (`sqlite3.c` and
+`sqlite3.h`, source id `bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc`),
+taken from sqlite.org and verified against the SHA3-256 that page publishes.
+SQLite is public domain, so vendoring it carries no licence obligation.
+
+`just sqlite` compiles it once into `sqlite3/lib/sqlite3.a`, which is
+gitignored and rebuilt when the amalgamation changes. `foreign import`
+resolves that archive relative to the package directory. `odin check` never
+opens a foreign import, so `just check` still type-checks all three targets on
+one machine with no archive built.
+
+The compile options are sqlite.org's recommended set, with three deliberate
+departures, all of them in the justfile:
+
+- `SQLITE_THREADSAFE=1`, not the recommended `0`. `jm:flow` exists, and a
+ connection per worker has to be safe.
+- `SQLITE_OMIT_AUTOINIT` is **not** set, though it is recommended. With it, any
+ call made before `sqlite3_initialize` is a segfault rather than an error.
+- `SQLITE_ENABLE_FTS5` is added, for a full-text index, and
+ `SQLITE_OMIT_LOAD_EXTENSION` keeps the link from needing libdl.
+
+One trap is worth knowing even though the package handles it: the bytes behind
+a text or blob column are freed on the next `step`, and SQLite reuses its own
+pool rather than returning them to libc, so reading a stale pointer yields the
+*next* row's data instead of crashing. AddressSanitizer cannot see it. That is
+why `text` and `blob` clone into the allocator the query was given.
diff --git a/examples/hello.odin b/examples/hello.odin
@@ -11,10 +11,11 @@ import "jm:http"
import "jm:path"
import "jm:prelude"
import "jm:sh"
+import "jm:sqlite3"
import "jm:timefmt"
must :: prelude.must
-die :: prelude.die
+die :: prelude.die
main :: proc() {
context = prelude.init()
@@ -34,12 +35,36 @@ main :: proc() {
scratch := must(path.temp_dir("hello-"))
defer path.remove_all(scratch)
note := path.join(scratch, "notes", "today.txt")
- must(path.write(note, fmt.tprintf("written %s\n", timefmt.iso(time.now(), context.temp_allocator))))
+ must(
+ path.write(
+ note,
+ fmt.tprintf("written %s\n", timefmt.iso(time.now(), context.temp_allocator)),
+ ),
+ )
for line in must(path.read_lines(note)) {
fmt.println("note:", line)
}
fmt.println("files:", len(must(path.walk(scratch))))
+ // SQLite: a database in the scratch tree, written and read back.
+ db := must(sqlite3.open(path.join(scratch, "hello.db")))
+ defer sqlite3.close(&db)
+ must(sqlite3.exec(db, `CREATE TABLE note(at TEXT, body TEXT)`))
+ must(
+ sqlite3.exec_args(
+ db,
+ `INSERT INTO note VALUES (?, ?)`,
+ timefmt.iso(time.now(), context.temp_allocator),
+ "it isn't interpolated",
+ ),
+ )
+ rows := must(sqlite3.query(db, `SELECT at, body FROM note`))
+ defer sqlite3.finish(&rows)
+ for sqlite3.next(&rows) {
+ fmt.println("note:", sqlite3.text(rows, 0), sqlite3.text(rows, 1))
+ }
+ fmt.println("sqlite:", sqlite3.version())
+
// HTTP: skipped without HELLO_URL so the example runs offline.
if url := prelude.env("HELLO_URL"); url != "" {
res := must(http.get(url, {timeout = 10 * time.Second}))
diff --git a/justfile b/justfile
@@ -4,6 +4,7 @@
# just release optimised odin-run -> build/release/odin-run
# just test run every package's tests
# just check type-check every package for linux, darwin and windows
+# just sqlite compile the vendored SQLite amalgamation into sqlite3/lib
# just install release odin-run into ~/.local/bin with this checkout baked in
# just example compile and run examples/hello.odin through the collection
# just clean remove build/
@@ -13,7 +14,20 @@ root := justfile_directory()
flags := "-vet -strict-style -collection:jm=" + root
exe := if os() == "windows" { ".exe" } else { "" }
bindir := env("BINDIR", home_directory() / ".local" / "bin")
-packages := "prelude sh http path timefmt debug flow tar"
+packages := "prelude sh http path timefmt debug flow tar sqlite3"
+cc := env("CC", "cc")
+sqlite_lib := if os() == "windows" { "sqlite3/lib/sqlite3.lib" } else { "sqlite3/lib/sqlite3.a" }
+
+# SQLite compile-time options. sqlite.org's recommended set for 3.53.4, with
+# three deliberate changes: THREADSAFE=1 rather than 0, so a connection per
+# jm:flow worker is safe; OMIT_AUTOINIT left off, because omitting it makes
+# any call before sqlite3_initialize a segfault; and FTS5 compiled in for a
+# full-text index. OMIT_LOAD_EXTENSION keeps the build from needing libdl.
+sqlite_defines := "-DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_DEFAULT_MEMSTATUS=0 " + \
+ "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1 -DSQLITE_LIKE_DOESNT_MATCH_BLOBS " + \
+ "-DSQLITE_MAX_EXPR_DEPTH=0 -DSQLITE_OMIT_DECLTYPE -DSQLITE_OMIT_DEPRECATED " + \
+ "-DSQLITE_OMIT_PROGRESS_CALLBACK -DSQLITE_OMIT_SHARED_CACHE -DSQLITE_STRICT_SUBTYPE=1 " + \
+ "-DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_MATH_FUNCTIONS"
targets := "linux_amd64 darwin_arm64 windows_amd64"
# `just` alone lists the recipes.
@@ -30,8 +44,30 @@ release:
mkdir -p build/release
{{odin}} build tools/odin-run -o:speed {{flags}} -define:JM_COLLECTION={{root}} -out:build/release/odin-run{{exe}}
+# The archive lands in sqlite3/lib rather than build/ because foreign import
+# resolves relative to the package directory. `just check` never needs it:
+# odin check does not open a foreign import, which is how one machine
+# type-checks all three targets without building for any of them.
+
+# Compile the vendored SQLite amalgamation into sqlite3/lib if it is stale
+[unix]
+sqlite:
+ @mkdir -p sqlite3/lib
+ @if [ ! -f {{sqlite_lib}} ] || [ sqlite3/vendor/sqlite3.c -nt {{sqlite_lib}} ]; then \
+ echo "{{cc}} sqlite3 amalgamation -> {{sqlite_lib}}"; \
+ {{cc}} -O2 -fPIC -c sqlite3/vendor/sqlite3.c -o sqlite3/lib/sqlite3.o {{sqlite_defines}}; \
+ ar rcs {{sqlite_lib}} sqlite3/lib/sqlite3.o; \
+ fi
+
+[windows]
+sqlite:
+ @if (!(Test-Path {{sqlite_lib}}) -or (Get-Item sqlite3/vendor/sqlite3.c).LastWriteTime -gt (Get-Item {{sqlite_lib}}).LastWriteTime) { \
+ cl /nologo /O2 /c sqlite3/vendor/sqlite3.c /Fosqlite3/lib/sqlite3.obj {{sqlite_defines}}; \
+ lib /nologo /OUT:{{sqlite_lib}} sqlite3/lib/sqlite3.obj \
+ }
+
# Run every package's tests
-test:
+test: sqlite
mkdir -p build/test
for p in {{packages}}; do {{odin}} test $p {{flags}} -out:build/test/$p{{exe}} || exit 1; done
@@ -49,9 +85,9 @@ install: release
cp build/release/odin-run{{exe}} {{bindir}}/odin-run{{exe}}
# Compile and run the example script
-example: build
+example: build sqlite
ODIN_RUN_VERBOSE=1 build/debug/odin-run{{exe}} examples/hello.odin
-# Remove build/
+# Remove build/ and the compiled SQLite archive
clean:
- rm -rf build
+ rm -rf build sqlite3/lib
diff --git a/sqlite3/ffi.odin b/sqlite3/ffi.odin
@@ -0,0 +1,81 @@
+/*
+The raw C API: the subset of sqlite3.h the wrapper needs, under the C names,
+exported so a caller that needs an interface the wrapper does not cover can
+reach for it. The wrapper in sqlite3.odin is what scripts should use. The
+declarations track the vendored header, sqlite3.h 3.53.4.
+
+The library is the amalgamation under vendor/, compiled into lib/ by the
+justfile's `sqlite` recipe, which is also where the compile-time options and
+the reason for each are written down. The archive path below is relative to
+this directory, which is what puts lib/ here rather than in build/; the
+README's SQLite section records that and what `just check` does without it.
+*/
+package sqlite3
+
+import "core:c"
+
+when ODIN_OS == .Windows {
+ foreign import lib "lib/sqlite3.lib"
+} else {
+ @(extra_linker_flags = "-lpthread -lm")
+ foreign import lib "lib/sqlite3.a"
+}
+
+// Connection is sqlite3, the open database handle. SQLite owns the memory.
+Connection :: struct {}
+
+// Statement is sqlite3_stmt, one compiled statement. SQLite owns the memory.
+Statement :: struct {}
+
+// TRANSIENT tells a bind call to copy the bytes it was handed, because Odin
+// owns them and may free or move them before the statement runs.
+TRANSIENT :: rawptr(~uintptr(0))
+
+// Flags for sqlite3_open_v2.
+OPEN_READONLY :: 0x00000001
+OPEN_READWRITE :: 0x00000002
+OPEN_CREATE :: 0x00000004
+OPEN_URI :: 0x00000040
+
+// Column storage classes, as sqlite3_column_type reports them.
+TYPE_INTEGER :: 1
+TYPE_FLOAT :: 2
+TYPE_TEXT :: 3
+TYPE_BLOB :: 4
+TYPE_NULL :: 5
+
+@(default_calling_convention = "c")
+foreign lib {
+ sqlite3_libversion :: proc() -> cstring ---
+ sqlite3_errstr :: proc(code: c.int) -> cstring ---
+
+ sqlite3_open_v2 :: proc(filename: cstring, db: ^^Connection, flags: c.int, vfs: cstring) -> c.int ---
+ sqlite3_close_v2 :: proc(db: ^Connection) -> c.int ---
+ sqlite3_errmsg :: proc(db: ^Connection) -> cstring ---
+ sqlite3_extended_errcode :: proc(db: ^Connection) -> c.int ---
+ sqlite3_busy_timeout :: proc(db: ^Connection, ms: c.int) -> c.int ---
+ sqlite3_changes64 :: proc(db: ^Connection) -> i64 ---
+ sqlite3_last_insert_rowid :: proc(db: ^Connection) -> i64 ---
+
+ sqlite3_prepare_v2 :: proc(db: ^Connection, sql: [^]u8, n: c.int, stmt: ^^Statement, tail: ^[^]u8) -> c.int ---
+ sqlite3_finalize :: proc(stmt: ^Statement) -> c.int ---
+ sqlite3_step :: proc(stmt: ^Statement) -> c.int ---
+ sqlite3_reset :: proc(stmt: ^Statement) -> c.int ---
+ sqlite3_clear_bindings :: proc(stmt: ^Statement) -> c.int ---
+ sqlite3_bind_parameter_count :: proc(stmt: ^Statement) -> c.int ---
+
+ sqlite3_bind_null :: proc(stmt: ^Statement, i: c.int) -> c.int ---
+ sqlite3_bind_int64 :: proc(stmt: ^Statement, i: c.int, v: i64) -> c.int ---
+ sqlite3_bind_double :: proc(stmt: ^Statement, i: c.int, v: f64) -> c.int ---
+ sqlite3_bind_text :: proc(stmt: ^Statement, i: c.int, v: [^]u8, n: c.int, free: rawptr) -> c.int ---
+ sqlite3_bind_blob :: proc(stmt: ^Statement, i: c.int, v: rawptr, n: c.int, free: rawptr) -> c.int ---
+
+ sqlite3_column_count :: proc(stmt: ^Statement) -> c.int ---
+ sqlite3_column_name :: proc(stmt: ^Statement, col: c.int) -> cstring ---
+ sqlite3_column_type :: proc(stmt: ^Statement, col: c.int) -> c.int ---
+ sqlite3_column_bytes :: proc(stmt: ^Statement, col: c.int) -> c.int ---
+ sqlite3_column_int64 :: proc(stmt: ^Statement, col: c.int) -> i64 ---
+ sqlite3_column_double :: proc(stmt: ^Statement, col: c.int) -> f64 ---
+ sqlite3_column_text :: proc(stmt: ^Statement, col: c.int) -> [^]u8 ---
+ sqlite3_column_blob :: proc(stmt: ^Statement, col: c.int) -> rawptr ---
+}
diff --git a/sqlite3/sqlite3.odin b/sqlite3/sqlite3.odin
@@ -0,0 +1,569 @@
+/*
+Package sqlite3 is SQLite for scripts. The amalgamation is vendored and linked
+statically, so a built script needs no system library and no shared object at
+runtime. The README's SQLite section records the version, the compile-time
+options and how to check a build for a stray libsqlite3.
+
+ db := must(sqlite3.open("notes.db"))
+ defer sqlite3.close(&db)
+
+ must(sqlite3.exec(db, `CREATE TABLE IF NOT EXISTS note(id INTEGER PRIMARY KEY, body TEXT)`))
+ must(sqlite3.exec_args(db, `INSERT INTO note(body) VALUES (?)`, "it isn't quoted by hand"))
+
+ rows := must(sqlite3.query(db, `SELECT id, body FROM note WHERE body LIKE ?`, "%isn't%"))
+ defer sqlite3.finish(&rows)
+ for sqlite3.next(&rows) {
+ fmt.println(sqlite3.integer(rows, 0), sqlite3.text(rows, 1))
+ }
+
+Values are always bound, never interpolated into the SQL: a parameter holding
+an apostrophe, a newline or a NUL byte round-trips unchanged, and there is no
+string-building path for an injection to travel down.
+
+Memory: text and blob columns are cloned into the allocator the query was
+given, because SQLite frees its own copy on the next step. Everything else is
+a value.
+
+Threads: the justfile's `sqlite` recipe sets SQLITE_THREADSAFE=1, which is
+SQLite's serialized mode. One Db is not guarded here, so a Db shared across
+jm:flow workers needs the caller's own mutex, or a connection per worker.
+*/
+package sqlite3
+
+import "core:c"
+import "core:fmt"
+import "core:mem"
+import "core:strings"
+import "core:time"
+
+// Code is a SQLite primary result code. Ok, Row and Done are not failures.
+Code :: enum i32 {
+ Ok = 0,
+ Error = 1,
+ Internal = 2,
+ Perm = 3,
+ Abort = 4,
+ Busy = 5,
+ Locked = 6,
+ No_Mem = 7,
+ Read_Only = 8,
+ Interrupt = 9,
+ Io = 10,
+ Corrupt = 11,
+ Not_Found = 12,
+ Full = 13,
+ Cant_Open = 14,
+ Protocol = 15,
+ Empty = 16,
+ Schema = 17,
+ Too_Big = 18,
+ Constraint = 19,
+ Mismatch = 20,
+ Misuse = 21,
+ No_Lfs = 22,
+ Auth = 23,
+ Format = 24,
+ Range = 25,
+ Not_A_Db = 26,
+ Notice = 27,
+ Warning = 28,
+ Row = 100,
+ Done = 101,
+}
+
+// Fault is a failed call: the result code and the message the connection gave
+// for it, which names the constraint or the file rather than restating the
+// code. The text is cloned, so it outlives the next call.
+Fault :: struct {
+ code: Code,
+ text: string,
+}
+
+// Error is nil when a call succeeded, so `or_return` and prelude.must both
+// work on it.
+Error :: union {
+ Fault,
+}
+
+// Type is a column's storage class in the row the cursor is on.
+Type :: enum {
+ Null,
+ Integer,
+ Real,
+ Text,
+ Blob,
+}
+
+// Value is one bound parameter. A string binds as TEXT, []byte as BLOB, nil
+// as NULL, and bool as the integer 0 or 1, which is how SQLite stores it.
+Value :: union {
+ i64,
+ f64,
+ bool,
+ string,
+ []byte,
+}
+
+// Db is an open connection.
+Db :: struct {
+ handle: ^Connection,
+ allocator: mem.Allocator,
+}
+
+// Stmt is one compiled statement, and the row cursor query hands back. The
+// column readers below are valid after next has returned true.
+Stmt :: struct {
+ db: ^Connection,
+ handle: ^Statement,
+ allocator: mem.Allocator,
+ // Set when the last step failed, and returned by finish.
+ err: Error,
+}
+
+// Opts tunes how open opens the database.
+Opts :: struct {
+ // Open an existing database for reading only. Nothing is created.
+ read_only: bool,
+ // Do not create the database if it is missing; fail with Cant_Open.
+ no_create: bool,
+ // Read path as a file: URI, so query parameters like ?mode=ro apply.
+ uri: bool,
+ // Switch the database to write-ahead logging after opening. An in-memory
+ // database keeps the mode it had; see the tests for both cases.
+ wal: bool,
+ // How long a write blocked by another connection waits before Busy.
+ busy_timeout: time.Duration,
+ // Run these statements right after opening, before anything else. A
+ // pragma the whole connection needs belongs here.
+ on_open: string,
+}
+
+// MEMORY opens a private in-memory database that is discarded on close.
+MEMORY :: ":memory:"
+
+// open opens the database at path, creating it unless Opts says otherwise.
+// Pass MEMORY for a scratch database that never touches the disk.
+open :: proc(
+ path: string,
+ opts := Opts{},
+ allocator := context.allocator,
+) -> (
+ db: Db,
+ err: Error,
+) {
+ flags := c.int(OPEN_READWRITE | OPEN_CREATE)
+ if opts.read_only {
+ flags = OPEN_READONLY
+ } else if opts.no_create {
+ flags = OPEN_READWRITE
+ }
+ if opts.uri {
+ flags |= OPEN_URI
+ }
+ handle: ^Connection
+ name := strings.clone_to_cstring(path, context.temp_allocator)
+ code := Code(sqlite3_open_v2(name, &handle, flags, nil))
+ if code != .Ok {
+ // open_v2 hands back a handle even on failure, so the message can be
+ // read off it; closing it is the caller's job, done here.
+ err = fault(handle, code, allocator)
+ sqlite3_close_v2(handle)
+ return {}, err
+ }
+ db = Db {
+ handle = handle,
+ allocator = allocator,
+ }
+ if opts.busy_timeout > 0 {
+ ms := c.int(time.duration_milliseconds(opts.busy_timeout))
+ sqlite3_busy_timeout(handle, ms)
+ }
+ if opts.wal {
+ // SQLite refuses WAL for an in-memory database and keeps the mode it
+ // had. Nothing else here depends on the mode, so the result is not
+ // worth failing the open over.
+ _ = exec(db, "PRAGMA journal_mode = WAL")
+ }
+ if opts.on_open != "" {
+ if err = exec(db, opts.on_open); err != nil {
+ sqlite3_close_v2(handle)
+ return {}, err
+ }
+ }
+ return db, nil
+}
+
+// close closes the connection and zeroes db. An open transaction is rolled
+// back. A Stmt that was never finished keeps the connection alive as a
+// zombie until it is, so finish every statement before closing: that is what
+// sqlite3_close_v2 defers on, and the file stays open until it happens.
+close :: proc(db: ^Db) -> Error {
+ if db == nil || db.handle == nil {
+ return nil
+ }
+ code := Code(sqlite3_close_v2(db.handle))
+ err := code == .Ok ? nil : fault(db.handle, code, db.allocator)
+ db^ = {}
+ return err
+}
+
+// exec runs sql for its effect and discards any rows. The text may hold
+// several statements separated by semicolons, which is what a schema is, and
+// each runs in turn. It binds nothing: use exec_args to pass values.
+exec :: proc(db: Db, sql: string) -> Error {
+ rest := sql
+ for {
+ stmt, tail, err := prepare_one(db, rest)
+ if err != nil {
+ return err
+ }
+ if stmt.handle == nil {
+ // Only whitespace or a comment was left.
+ return nil
+ }
+ for {
+ code := Code(sqlite3_step(stmt.handle))
+ if code == .Row {
+ continue
+ }
+ if code != .Done {
+ sqlite3_finalize(stmt.handle)
+ return fault(db.handle, code, db.allocator)
+ }
+ break
+ }
+ sqlite3_finalize(stmt.handle)
+ rest = tail
+ if strings.trim_space(rest) == "" {
+ return nil
+ }
+ }
+}
+
+// exec_args runs one statement with args bound to its ? parameters and
+// discards any rows. It is the write half of query.
+exec_args :: proc(db: Db, sql: string, args: ..Value) -> Error {
+ stmt := query(db, sql, ..args) or_return
+ for next(&stmt) {}
+ return finish(&stmt)
+}
+
+// query compiles sql, binds args to its ? parameters in order, and returns
+// the cursor to step with next. Only the first statement in sql is run.
+query :: proc(
+ db: Db,
+ sql: string,
+ args: ..Value,
+ allocator := context.allocator,
+) -> (
+ stmt: Stmt,
+ err: Error,
+) {
+ stmt, _, err = prepare_one(db, sql, allocator)
+ if err != nil {
+ return {}, err
+ }
+ if stmt.handle == nil {
+ return {}, Fault{code = .Error, text = strings.clone("no statement in sql", allocator)}
+ }
+ if err = bind(&stmt, ..args); err != nil {
+ sqlite3_finalize(stmt.handle)
+ return {}, err
+ }
+ return stmt, nil
+}
+
+// prepare compiles sql for repeated use: bind, step and reset it, then finish
+// it once. Binding one statement many times is how a batch of inserts should
+// be written, since the SQL is parsed once.
+prepare :: proc(db: Db, sql: string, allocator := context.allocator) -> (stmt: Stmt, err: Error) {
+ stmt, _, err = prepare_one(db, sql, allocator)
+ if err != nil {
+ return {}, err
+ }
+ if stmt.handle == nil {
+ return {}, Fault{code = .Error, text = strings.clone("no statement in sql", allocator)}
+ }
+ return stmt, nil
+}
+
+// bind sets the statement's parameters, numbered from 1 in the order given.
+// Passing a different count than the statement declares is a Range fault,
+// caught here rather than leaving a parameter silently NULL.
+bind :: proc(stmt: ^Stmt, args: ..Value) -> Error {
+ want := int(sqlite3_bind_parameter_count(stmt.handle))
+ if want != len(args) {
+ return Fault {
+ code = .Range,
+ text = fmt.aprintf(
+ "statement takes %d parameters, got %d",
+ want,
+ len(args),
+ allocator = stmt.allocator,
+ ),
+ }
+ }
+ for arg, i in args {
+ idx := c.int(i + 1)
+ code: Code
+ switch v in arg {
+ case i64:
+ code = Code(sqlite3_bind_int64(stmt.handle, idx, v))
+ case f64:
+ code = Code(sqlite3_bind_double(stmt.handle, idx, v))
+ case bool:
+ code = Code(sqlite3_bind_int64(stmt.handle, idx, v ? 1 : 0))
+ case string:
+ // raw_data of an empty string is nil, which binds NULL rather
+ // than the empty string, so hand SQLite a valid pointer instead.
+ p := len(v) > 0 ? raw_data(v) : ([^]u8)(&empty_byte)
+ code = Code(sqlite3_bind_text(stmt.handle, idx, p, c.int(len(v)), TRANSIENT))
+ case []byte:
+ p := len(v) > 0 ? rawptr(raw_data(v)) : rawptr(&empty_byte)
+ code = Code(sqlite3_bind_blob(stmt.handle, idx, p, c.int(len(v)), TRANSIENT))
+ case:
+ code = Code(sqlite3_bind_null(stmt.handle, idx))
+ }
+ if code != .Ok {
+ return fault(stmt.db, code, stmt.allocator)
+ }
+ }
+ return nil
+}
+
+// next advances to the next row and reports whether one arrived. A failure
+// stops the loop and is kept on the statement for finish to return, so the
+// common read loop needs no error check of its own.
+next :: proc(stmt: ^Stmt) -> bool {
+ if stmt.handle == nil || stmt.err != nil {
+ return false
+ }
+ code := Code(sqlite3_step(stmt.handle))
+ switch code {
+ case .Row:
+ return true
+ case .Done:
+ return false
+ case .Ok,
+ .Error,
+ .Internal,
+ .Perm,
+ .Abort,
+ .Busy,
+ .Locked,
+ .No_Mem,
+ .Read_Only,
+ .Interrupt,
+ .Io,
+ .Corrupt,
+ .Not_Found,
+ .Full,
+ .Cant_Open,
+ .Protocol,
+ .Empty,
+ .Schema,
+ .Too_Big,
+ .Constraint,
+ .Mismatch,
+ .Misuse,
+ .No_Lfs,
+ .Auth,
+ .Format,
+ .Range,
+ .Not_A_Db,
+ .Notice,
+ .Warning:
+ stmt.err = fault(stmt.db, code, stmt.allocator)
+ return false
+ }
+ return false
+}
+
+// reset rewinds a prepared statement for its next use and clears its
+// bindings, so a stale parameter cannot leak into the following row.
+reset :: proc(stmt: ^Stmt) -> Error {
+ if stmt.handle == nil {
+ return nil
+ }
+ stmt.err = nil
+ if code := Code(sqlite3_reset(stmt.handle)); code != .Ok {
+ return fault(stmt.db, code, stmt.allocator)
+ }
+ sqlite3_clear_bindings(stmt.handle)
+ return nil
+}
+
+// finish releases the statement and returns whatever failure stopped it.
+// Calling it twice is safe.
+finish :: proc(stmt: ^Stmt) -> Error {
+ if stmt.handle == nil {
+ return stmt.err
+ }
+ err := stmt.err
+ code := Code(sqlite3_finalize(stmt.handle))
+ if err == nil && code != .Ok {
+ err = fault(stmt.db, code, stmt.allocator)
+ }
+ stmt.handle = nil
+ return err
+}
+
+// transact runs body between BEGIN and COMMIT, rolls back if body fails, and
+// returns body's error. user is passed through untouched, since Odin has no
+// closures to capture it.
+transact :: proc(db: Db, body: proc(db: Db, user: rawptr) -> Error, user: rawptr = nil) -> Error {
+ exec(db, "BEGIN") or_return
+ if err := body(db, user); err != nil {
+ // The rollback's own failure would hide why the work failed, so the
+ // body's error is the one returned.
+ _ = exec(db, "ROLLBACK")
+ return err
+ }
+ return exec(db, "COMMIT")
+}
+
+// column_count reports how many columns the current row has.
+column_count :: proc(stmt: Stmt) -> int {
+ return int(sqlite3_column_count(stmt.handle))
+}
+
+// name is the column's name in the result set, cloned into the statement's
+// allocator.
+name :: proc(stmt: Stmt, col: int) -> string {
+ n := sqlite3_column_name(stmt.handle, c.int(col))
+ return n == nil ? "" : strings.clone_from_cstring(n, stmt.allocator)
+}
+
+// integer reads the column as an integer. A NULL or a non-numeric text reads
+// as 0, which is SQLite's own conversion.
+integer :: proc(stmt: Stmt, col: int) -> i64 {
+ return sqlite3_column_int64(stmt.handle, c.int(col))
+}
+
+// real reads the column as a float.
+real :: proc(stmt: Stmt, col: int) -> f64 {
+ return sqlite3_column_double(stmt.handle, c.int(col))
+}
+
+// boolean reads the column as a truth value: non-zero is true.
+boolean :: proc(stmt: Stmt, col: int) -> bool {
+ return sqlite3_column_int64(stmt.handle, c.int(col)) != 0
+}
+
+// text reads the column as a string, cloned into the statement's allocator
+// because SQLite frees its copy at the next step.
+text :: proc(stmt: Stmt, col: int) -> string {
+ n := int(sqlite3_column_bytes(stmt.handle, c.int(col)))
+ p := sqlite3_column_text(stmt.handle, c.int(col))
+ if p == nil || n == 0 {
+ return ""
+ }
+ return strings.clone(string(p[:n]), stmt.allocator)
+}
+
+// blob reads the column's bytes, cloned into the statement's allocator for
+// the same reason text is.
+blob :: proc(stmt: Stmt, col: int) -> []byte {
+ n := int(sqlite3_column_bytes(stmt.handle, c.int(col)))
+ p := sqlite3_column_blob(stmt.handle, c.int(col))
+ if p == nil || n == 0 {
+ return nil
+ }
+ out := make([]byte, n, stmt.allocator)
+ mem.copy(raw_data(out), p, n)
+ return out
+}
+
+// type_of is how SQLite is storing the column in this row. A column has no
+// one type in SQLite, so the same column can read back differently row to row.
+type_of :: proc(stmt: Stmt, col: int) -> Type {
+ switch int(sqlite3_column_type(stmt.handle, c.int(col))) {
+ case TYPE_INTEGER:
+ return .Integer
+ case TYPE_FLOAT:
+ return .Real
+ case TYPE_TEXT:
+ return .Text
+ case TYPE_BLOB:
+ return .Blob
+ case TYPE_NULL:
+ return .Null
+ }
+ return .Null
+}
+
+// is_null reports whether the column holds NULL, which the readers above
+// cannot tell apart from 0 or the empty string.
+is_null :: proc(stmt: Stmt, col: int) -> bool {
+ return type_of(stmt, col) == .Null
+}
+
+// changes is how many rows the last INSERT, UPDATE or DELETE touched.
+changes :: proc(db: Db) -> i64 {
+ return sqlite3_changes64(db.handle)
+}
+
+// last_id is the rowid the last INSERT on this connection assigned.
+last_id :: proc(db: Db) -> i64 {
+ return sqlite3_last_insert_rowid(db.handle)
+}
+
+// version is the SQLite version compiled in, such as "3.53.4".
+version :: proc() -> string {
+ return string(sqlite3_libversion())
+}
+
+// empty_byte backs the pointer handed to bind for an empty string or blob,
+// so SQLite sees a valid address with length 0 rather than NULL.
+@(private)
+empty_byte: u8
+
+// prepare_one compiles the first statement in sql and returns what followed
+// it. A handle of nil with no error means sql held no statement.
+@(private)
+prepare_one :: proc(
+ db: Db,
+ sql: string,
+ allocator := context.allocator,
+) -> (
+ stmt: Stmt,
+ tail: string,
+ err: Error,
+) {
+ stmt = Stmt {
+ db = db.handle,
+ allocator = allocator,
+ }
+ if len(sql) == 0 {
+ return stmt, "", nil
+ }
+ rest: [^]u8
+ code := Code(
+ sqlite3_prepare_v2(db.handle, raw_data(sql), c.int(len(sql)), &stmt.handle, &rest),
+ )
+ if code != .Ok {
+ return {}, "", fault(db.handle, code, allocator)
+ }
+ if rest != nil {
+ // rest points inside sql, so the remainder is the slice from there to
+ // the end rather than a new string.
+ used := int(uintptr(rest) - uintptr(raw_data(sql)))
+ tail = sql[used:]
+ }
+ return stmt, tail, nil
+}
+
+// fault builds the error for code, preferring the connection's message over
+// the generic text for the code.
+@(private)
+fault :: proc(db: ^Connection, code: Code, allocator: mem.Allocator) -> Error {
+ msg: cstring
+ if db != nil {
+ msg = sqlite3_errmsg(db)
+ }
+ if msg == nil {
+ msg = sqlite3_errstr(c.int(code))
+ }
+ text := msg == nil ? "" : strings.clone_from_cstring(msg, allocator)
+ return Fault{code = code, text = text}
+}
diff --git a/sqlite3/sqlite3_test.odin b/sqlite3/sqlite3_test.odin
@@ -0,0 +1,403 @@
+package sqlite3
+
+import "core:os"
+import "core:path/filepath"
+import "core:slice"
+import "core:testing"
+
+@(test)
+version_is_vendored :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ testing.expect_value(t, version(), "3.53.4")
+
+ // The version alone would match a system library that happens to agree,
+ // so check the options the justfile's recipe sets.
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ rows, qerr := query(db, `PRAGMA compile_options`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ opts: map[string]bool
+ for next(&rows) {
+ opts[text(rows, 0)] = true
+ }
+ testing.expect(t, opts["DQS=0"], "the archive must be the one just sqlite built")
+ testing.expect(
+ t,
+ opts["THREADSAFE=1"],
+ "threadsafe is why the recipe departs from the recommended set",
+ )
+ testing.expect(t, opts["ENABLE_FTS5"], "FTS5 is compiled in for a full-text index")
+ testing.expect(
+ t,
+ opts["OMIT_LOAD_EXTENSION"],
+ "load_extension is omitted so the link needs no libdl",
+ )
+ testing.expect(
+ t,
+ !opts["OMIT_AUTOINIT"],
+ "autoinit stays on, so no call can precede initialize",
+ )
+}
+
+// TRANSIENT is what every bind call passes, and it is the reason a caller may
+// free or overwrite its buffer the moment bind returns.
+@(test)
+bind_copies_the_caller_s_bytes :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE t(v TEXT)`), nil)
+
+ buf := make([]byte, 5, context.temp_allocator)
+ copy(buf, "alpha")
+ stmt, perr := prepare(db, `INSERT INTO t VALUES (?)`)
+ testing.expect_value(t, perr, nil)
+ testing.expect_value(t, bind(&stmt, string(buf)), nil)
+ // Overwrite the buffer after binding but before stepping.
+ copy(buf, "OMEGA")
+ testing.expect(t, !next(&stmt), "an insert returns no row")
+ testing.expect_value(t, finish(&stmt), nil)
+
+ rows, qerr := query(db, `SELECT v FROM t`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows))
+ testing.expect_value(t, text(rows, 0), "alpha")
+}
+
+@(test)
+memory_round_trip :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+
+ testing.expect_value(t, exec(db, `CREATE TABLE note(id INTEGER PRIMARY KEY, body TEXT)`), nil)
+ testing.expect_value(t, exec_args(db, `INSERT INTO note(body) VALUES (?)`, "hello"), nil)
+ testing.expect_value(t, changes(db), 1)
+ testing.expect_value(t, last_id(db), 1)
+
+ rows, qerr := query(db, `SELECT id, body FROM note`)
+ testing.expect_value(t, qerr, nil)
+ testing.expect(t, next(&rows), "one row expected")
+ testing.expect_value(t, integer(rows, 0), 1)
+ testing.expect_value(t, text(rows, 1), "hello")
+ testing.expect(t, !next(&rows), "only one row expected")
+ testing.expect_value(t, finish(&rows), nil)
+}
+
+// A bound value survives unchanged through the characters that break SQL
+// built by hand: an apostrophe, a quote, a semicolon, a backslash, a tab and
+// a newline.
+@(test)
+quotes_round_trip :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE t(v TEXT)`), nil)
+
+ awkward := []string {
+ "name-says-what-it-isn't",
+ `he said "quoted"`,
+ "; DROP TABLE t; --",
+ "back\\slash and \ttab",
+ "line\nbreak",
+ "",
+ }
+ for v in awkward {
+ testing.expect_value(t, exec_args(db, `INSERT INTO t(v) VALUES (?)`, v), nil)
+ }
+ rows, qerr := query(db, `SELECT v FROM t ORDER BY rowid`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ i := 0
+ for next(&rows) {
+ testing.expect_value(t, text(rows, 0), awkward[i])
+ // An empty string must stay text, not become NULL.
+ testing.expect(t, !is_null(rows, 0), "empty string must not be NULL")
+ i += 1
+ }
+ testing.expect_value(t, i, len(awkward))
+
+ // The table survived the statement that tried to drop it.
+ count, cerr := query(db, `SELECT count(*) FROM t`)
+ testing.expect_value(t, cerr, nil)
+ defer finish(&count)
+ testing.expect(t, next(&count))
+ testing.expect_value(t, integer(count, 0), i64(len(awkward)))
+}
+
+@(test)
+value_types_round_trip :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE v(i INT, r REAL, b BLOB, f INT, n TEXT)`), nil)
+
+ raw := []byte{0, 1, 2, 0xff, 0}
+ testing.expect_value(
+ t,
+ exec_args(db, `INSERT INTO v VALUES (?, ?, ?, ?, ?)`, i64(-7), 2.5, raw, true, nil),
+ nil,
+ )
+ rows, qerr := query(db, `SELECT i, r, b, f, n FROM v`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows))
+ testing.expect_value(t, integer(rows, 0), -7)
+ testing.expect_value(t, real(rows, 1), 2.5)
+ testing.expect(t, slice.equal(blob(rows, 2), raw), "blob must survive its NUL bytes")
+ testing.expect_value(t, boolean(rows, 3), true)
+ testing.expect(t, is_null(rows, 4), "nil must bind as NULL")
+ testing.expect_value(t, column_count(rows), 5)
+ testing.expect_value(t, name(rows, 1), "r")
+}
+
+@(test)
+errors_carry_the_message :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+
+ _, serr := query(db, `SELECT nope FROM missing_table`)
+ fault, ok := serr.(Fault)
+ testing.expect(t, ok, "a bad query must fault")
+ testing.expect_value(t, fault.code, Code.Error)
+ // The point of the error type: the text names the table, not just a code.
+ testing.expect_value(t, fault.text, "no such table: missing_table")
+
+ testing.expect_value(t, exec(db, `CREATE TABLE u(v TEXT UNIQUE)`), nil)
+ testing.expect_value(t, exec_args(db, `INSERT INTO u VALUES (?)`, "x"), nil)
+ cerr := exec_args(db, `INSERT INTO u VALUES (?)`, "x")
+ cfault, cok := cerr.(Fault)
+ testing.expect(t, cok, "a duplicate must fault")
+ testing.expect_value(t, cfault.code, Code.Constraint)
+ testing.expect_value(t, cfault.text, "UNIQUE constraint failed: u.v")
+}
+
+@(test)
+bind_count_is_checked :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE t(a, b)`), nil)
+
+ short := exec_args(db, `INSERT INTO t VALUES (?, ?)`, i64(1))
+ fault, ok := short.(Fault)
+ testing.expect(t, ok, "a short argument list must fault")
+ testing.expect_value(t, fault.code, Code.Range)
+ testing.expect_value(t, fault.text, "statement takes 2 parameters, got 1")
+
+ // What that guard is worth: bind the same statement through the raw API,
+ // leaving the second parameter unset, and SQLite stores NULL without
+ // complaint. bind refuses the call instead of writing that row.
+ stmt, perr := prepare(db, `INSERT INTO t VALUES (?, ?)`)
+ testing.expect_value(t, perr, nil)
+ testing.expect_value(t, Code(sqlite3_bind_int64(stmt.handle, 1, 1)), Code.Ok)
+ testing.expect(t, !next(&stmt), "an insert returns no row")
+ testing.expect_value(t, finish(&stmt), nil)
+
+ rows, qerr := query(db, `SELECT a, b FROM t`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows), "the unguarded insert wrote a row")
+ testing.expect_value(t, type_of(rows, 0), Type.Integer)
+ testing.expect_value(t, type_of(rows, 1), Type.Null)
+}
+
+@(test)
+exec_runs_every_statement :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+
+ schema := `
+ CREATE TABLE a(x INT);
+ CREATE TABLE b(y INT);
+ INSERT INTO a VALUES (1);
+ INSERT INTO b VALUES (2);
+ `
+ testing.expect_value(t, exec(db, schema), nil)
+ rows, qerr := query(db, `SELECT a.x + b.y FROM a, b`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows))
+ testing.expect_value(t, integer(rows, 0), 3)
+}
+
+@(test)
+prepared_statement_is_reused :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE n(v INT)`), nil)
+
+ stmt, perr := prepare(db, `INSERT INTO n VALUES (?)`)
+ testing.expect_value(t, perr, nil)
+ for i in 0 ..< 100 {
+ testing.expect_value(t, bind(&stmt, i64(i)), nil)
+ testing.expect(t, !next(&stmt), "an insert returns no row")
+ testing.expect_value(t, reset(&stmt), nil)
+ }
+ testing.expect_value(t, finish(&stmt), nil)
+
+ sum, qerr := query(db, `SELECT count(*), sum(v) FROM n`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&sum)
+ testing.expect(t, next(&sum))
+ testing.expect_value(t, integer(sum, 0), 100)
+ testing.expect_value(t, integer(sum, 1), 4950)
+}
+
+@(test)
+file_survives_reopen :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ dir, derr := os.make_directory_temp(temp, "jm-sqlite3-*", context.temp_allocator)
+ testing.expect(t, derr == nil)
+ defer os.remove_all(dir)
+ path, _ := filepath.join({dir, "notes.db"}, context.temp_allocator)
+
+ {
+ db, err := open(path, Opts{wal = true})
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE note(body TEXT)`), nil)
+ testing.expect_value(t, transact(db, write_three), nil)
+ }
+ testing.expect(t, os.is_file(path), "the database file must exist")
+
+ db, err := open(path, Opts{no_create = true})
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ rows, qerr := query(db, `SELECT body FROM note ORDER BY rowid`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ got: [dynamic]string
+ for next(&rows) {
+ append(&got, text(rows, 0))
+ }
+ testing.expect_value(t, len(got), 3)
+ testing.expect_value(t, got[0], "one")
+ testing.expect_value(t, got[2], "three's")
+}
+
+@(private = "file")
+write_three :: proc(db: Db, user: rawptr) -> Error {
+ for body in ([]string{"one", "two", "three's"}) {
+ exec_args(db, `INSERT INTO note(body) VALUES (?)`, body) or_return
+ }
+ return nil
+}
+
+@(test)
+transaction_rolls_back :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE t(v TEXT UNIQUE)`), nil)
+
+ ferr := transact(db, write_then_fail)
+ fault, ok := ferr.(Fault)
+ testing.expect(t, ok, "the body's failure must be returned")
+ testing.expect_value(t, fault.code, Code.Constraint)
+
+ // Nothing the body wrote before it failed may survive.
+ rows, qerr := query(db, `SELECT count(*) FROM t`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows))
+ testing.expect_value(t, integer(rows, 0), 0)
+}
+
+@(private = "file")
+write_then_fail :: proc(db: Db, user: rawptr) -> Error {
+ exec_args(db, `INSERT INTO t VALUES (?)`, "dup") or_return
+ exec_args(db, `INSERT INTO t VALUES (?)`, "dup") or_return
+ return nil
+}
+
+@(test)
+read_only_refuses_writes :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ dir, derr := os.make_directory_temp(temp, "jm-sqlite3-ro-*", context.temp_allocator)
+ testing.expect(t, derr == nil)
+ defer os.remove_all(dir)
+ path, _ := filepath.join({dir, "ro.db"}, context.temp_allocator)
+
+ {
+ db, err := open(path)
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ testing.expect_value(t, exec(db, `CREATE TABLE t(v INT)`), nil)
+ }
+
+ db, err := open(path, Opts{read_only = true})
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ werr := exec_args(db, `INSERT INTO t VALUES (?)`, i64(1))
+ fault, ok := werr.(Fault)
+ testing.expect(t, ok, "a write to a read-only database must fault")
+ testing.expect_value(t, fault.code, Code.Read_Only)
+}
+
+@(test)
+missing_file_faults :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ path, _ := filepath.join({temp, "jm-sqlite3-does-not-exist.db"}, context.temp_allocator)
+ _, err := open(path, Opts{no_create = true})
+ fault, ok := err.(Fault)
+ testing.expect(t, ok, "opening a missing database without create must fault")
+ testing.expect_value(t, fault.code, Code.Cant_Open)
+ testing.expect_value(t, fault.text, "unable to open database file")
+ testing.expect(t, !os.exists(path), "no_create must not create the file")
+}
+
+// Opts.wal on an in-memory database leaves the mode alone: SQLite will not
+// put :memory: into WAL.
+@(test)
+memory_ignores_wal :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ db, err := open(MEMORY, Opts{wal = true})
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+
+ rows, qerr := query(db, `PRAGMA journal_mode`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows))
+ testing.expect_value(t, text(rows, 0), "memory")
+}
+
+// And a file-backed database does take WAL, which is the other half of the
+// option's doc.
+@(test)
+file_takes_wal :: proc(t: ^testing.T) {
+ context.allocator = context.temp_allocator
+ temp := os.temp_directory(context.temp_allocator) or_else ""
+ dir, derr := os.make_directory_temp(temp, "jm-sqlite3-wal-*", context.temp_allocator)
+ testing.expect(t, derr == nil)
+ defer os.remove_all(dir)
+ path, _ := filepath.join({dir, "wal.db"}, context.temp_allocator)
+
+ db, err := open(path, Opts{wal = true})
+ testing.expect_value(t, err, nil)
+ defer close(&db)
+ rows, qerr := query(db, `PRAGMA journal_mode`)
+ testing.expect_value(t, qerr, nil)
+ defer finish(&rows)
+ testing.expect(t, next(&rows))
+ testing.expect_value(t, text(rows, 0), "wal")
+}