hanki

sqlite

stdlib/extra/sqlite.hk: a secure-modern-strict SQLite library.

A pure-Hanki face over the sys native SQLite seam (HANKI.md §4, §17): no function here bears an @intrinsic; they delegate to the raw sys.sqlite_*! primitives, and the [db] effect bubbles up automatically. One exception to the [db]-only surface: backup_to! writes a caller-chosen path and charges [db, fs_write], which puts the file write in front of the capability gate. Runs on both tiers (bytecode and LLVM AOT).

Secure-modern-strict by default

open_path! / open_memory! apply a hardened, modern default profile that most SQLite wrappers leave off. open_with! takes an explicit OpenOptions to override any of it.

settingour defaultraw SQLitewhy we deviate
busy_timeout_ms50000wait a writer out in place of failing at once
foreign_keysONOFFenforce declared references
walONrollbackconcurrent readers + a writer; sticky, + sidecars
synchronous_fullOFF (NORMAL)FULLNORMAL is the safe, fast WAL companion
strict_sqlONOFF (DQS)kill the "typo"-matches-a-string footgun
trusted_schemaOFFONharden against hostile schema in db files
defensiveONOFFreject the writable-schema corruption paths
journal_size_limit64 MiB-1cap the WAL/journal sidecar growth

The security lockdown (no ATTACH, no URI filenames, no extension loading) is always on in the native seam and is no option at all; see sys.sqlite_open!.

Using it

use sqlite
open sys

def demo!() -> Result<int, DbError> [db]
  match sqlite.open_memory!()
    Err(e) -> Err(e)
    Ok(conn) ->
      match conn.batch!("CREATE TABLE t(a INTEGER, b TEXT)")
        Err(e) -> Err(e)
        Ok(_) -> conn.execute!("INSERT INTO t VALUES (?, ?)", [sqlite.v(1), sqlite.v("x")])
      end
  end
end

Parameters bind through the ToValue trait and the v(x) sugar (conn.query!("... WHERE id > ?", [sqlite.v(0)])), or by constructing DbValues directly ([Int(1), Text("x")]).

Guidance

Search text from a user needs match_all or match_phrase (below). Binding it as a parameter stops it being read as SQL, but an FTS5 MATCH operand is then read as an FTS5 query: milk OR bread becomes a boolean OR, title:x a column filter, mil* a prefix search, and a lone " an outright error. match_all turns a line of words into a query requiring every word, each matched literally, which is the search-box case; match_phrase quotes the text into one literal consecutive phrase.

FTS5 and table confinement do not compose. An FTS5 query reads PRAGMA data_version internally, and with_allowed_tables / confine_tables! deny every pragma, and a MATCH on a confined connection fails with SQLite's authorization error however many shadow tables the allow-list names. Search and untrusted-SQL confinement therefore want separate connections.

Full-text search over a table whose primary key is TEXT wants the standalone form below and no external-content form. External content (content=, content_rowid=) assumes an integer rowid, and a table with a TEXT primary key still has a hidden integer one, which lets content_rowid pointed at the TEXT column look as though it works and corrupt later when the hidden rowids and the index drift. The standalone table stores the key itself, UNINDEXED so it is stored but never matched:

CREATE VIRTUAL TABLE notes_fts USING fts5(
  id UNINDEXED, title, body, tokenize='porter unicode61');
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
  INSERT INTO notes_fts(id, title, body)
    VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
  DELETE FROM notes_fts WHERE id = old.id;
END;
CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN
  DELETE FROM notes_fts WHERE id = old.id;
  INSERT INTO notes_fts(id, title, body)
    VALUES (new.id, new.title, new.body);
END;

When the table and the index disagree (triggers added after rows already existed, or a bulk edit made with triggers off), rebuild by hand. The standalone form has no rebuild command; that spelling is external-content syntax:

DELETE FROM notes_fts;
INSERT INTO notes_fts(id, title, body)
  SELECT id, title, body FROM notes;

No hermetic test block belongs here: this is an effectful face over the native seam, and coverage is end-to-end through the CLI integration tests and example programs (the net/fs precedent).

OpenOptions

struct OpenOptions
  read_only: bool
  create: bool
  busy_timeout_ms: int
  foreign_keys: bool
  wal: bool
  synchronous_full: bool
  strict_sql: bool
  trusted_schema: bool
  defensive: bool
  journal_size_limit: int
  engine_limits: bool
  cell_size_check: bool
  secure_delete: bool
  allowed_tables: Option<List<string>>
end

The connection configuration open_with! applies after the native secure-open. Build one from OpenOptions.defaults() and the with_* updaters; the fields are public for direct construction too.

impl OpenOptions

defaults

def defaults() -> OpenOptions

The secure-modern-strict defaults (see the module header table).

OpenOptions.defaults().read_only => false
OpenOptions.defaults().foreign_keys => true
OpenOptions.defaults().busy_timeout_ms => 5000
OpenOptions.defaults().strict_sql => true

withreadonly

def with_read_only(self, val: bool) -> OpenOptions

Open the database without write access (create is then ignored).

OpenOptions.defaults().with_read_only(true).read_only => true

with_create

def with_create(self, val: bool) -> OpenOptions

Create the file if it is absent (write mode only).

OpenOptions.defaults().with_create(false).create => false

withbusytimeout_ms

def with_busy_timeout_ms(self, val: int) -> OpenOptions

Milliseconds to wait for a locked database before failing.

OpenOptions.defaults().with_busy_timeout_ms(1000).busy_timeout_ms => 1000

withforeignkeys

def with_foreign_keys(self, val: bool) -> OpenOptions

Enforce declared foreign-key references.

OpenOptions.defaults().with_foreign_keys(false).foreign_keys => false

with_wal

def with_wal(self, val: bool) -> OpenOptions

Use write-ahead logging (concurrent readers + a writer). Sticky and it creates -wal/-shm sidecar files; ignored for :memory:.

OpenOptions.defaults().with_wal(false).wal => false

withsynchronousfull

def with_synchronous_full(self, val: bool) -> OpenOptions

synchronous=FULL (maximum durability) in place of the WAL-companion NORMAL.

OpenOptions.defaults().with_synchronous_full(true).synchronous_full => true

withstrictsql

def with_strict_sql(self, val: bool) -> OpenOptions

Reject double-quoted string literals (DQS off) in DDL and DML.

OpenOptions.defaults().with_strict_sql(false).strict_sql => false

withtrustedschema

def with_trusted_schema(self, val: bool) -> OpenOptions

Trust schema-defined functions/views from the database file.

OpenOptions.defaults().with_trusted_schema(true).trusted_schema => true

with_defensive

def with_defensive(self, val: bool) -> OpenOptions

Reject the writable-schema corruption paths (SQLITE_DBCONFIG_DEFENSIVE).

OpenOptions.defaults().with_defensive(false).defensive => false

withjournalsize_limit

def with_journal_size_limit(self, val: int) -> OpenOptions

Cap the WAL/journal sidecar size in bytes.

OpenOptions.defaults().with_journal_size_limit(0).journal_size_limit => 0

withenginelimits

def with_engine_limits(self, val: bool) -> OpenOptions

Apply the engine-limit reductions for running untrusted SQL (the SQLite security doc's recommended sqlite3_limit profile plus a parser-depth cap). Bounds what the engine will accept: statement length, expression depth, VDBE program size and the rest, on a different axis from the run-level --max-steps/--max-bytes budgets, which bound the Hanki side and say nothing about the C engine. The hardened() preset is this plus cell_size_check.

OpenOptions.defaults().with_engine_limits(true).engine_limits => true

withcellsize_check

def with_cell_size_check(self, val: bool) -> OpenOptions

PRAGMA cell_size_check=ON: extra per-page sanity checks against a corrupted or hostile database file, at some read cost.

OpenOptions.defaults().with_cell_size_check(true).cell_size_check => true

withsecuredelete

def with_secure_delete(self, val: bool) -> OpenOptions

PRAGMA secure_delete=ON: overwrite deleted content with zeros so it cannot be recovered from the file, at some write cost.

OpenOptions.defaults().with_secure_delete(true).secure_delete => true

withallowedtables

def with_allowed_tables(self, tables: List<string>) -> OpenOptions

Confine the connection to the named tables (the static untrusted-SQL policy, applied as the last open step): reads and writes touch only these tables; SELECT, scalar functions, CTE recursion, and transaction/savepoint control stay allowed; all DDL, pragmas, and schema reads are denied, this face's own pragma-backed conveniences (busy_timeout!, optimize!, integrity_check!) included, which leaves these options as the place to configure the connection and never a later call. One-way: nothing removes an installed policy. Pairs with with_engine_limits: the limits bound how much untrusted SQL can make the engine do, the policy bounds what it may touch, and the pair is the untrusted-SQL story. Denying pragmas also rules out FTS5, which reads PRAGMA data_version on every query: a MATCH here fails with the authorization error however many shadow tables the list names, and full-text search therefore wants its own unconfined connection. @no-doctest: Option-of-list equality is clearer end-to-end; covered by CLI tests

hardened

def hardened() -> OpenOptions

The untrusted-SQL preset: defaults() plus the engine-limit profile and cell_size_check. The limits bound how much hostile SQL can make the engine do and never what it may touch; table-level confinement is a separate concern. Use the preset in place of with_engine_limits(true) alone: the preset is the documented profile.

OpenOptions.hardened().engine_limits => true
OpenOptions.hardened().cell_size_check => true
OpenOptions.hardened().foreign_keys => true

onoff

def _on_off(b: bool) -> string

syncmode

def _sync_mode(full: bool) -> string

optpragma

def _opt_pragma(enabled: bool, p: string) -> string

pragmascript

def _pragma_script(opts: OpenOptions) -> string

cell_size_check goes first: the WAL conversion below walks pages of the (possibly hostile) file, and the per-page checks must already be on.

SqliteConn

SqliteConn, or sqlite.SqliteConn, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.

hardenedlimits!

def _hardened_limits!(conn: SqliteConn) -> Result<(), DbError> [db]

The SQLite security doc's recommended sqlite3_limit reductions for untrusted SQL (plus a parser-depth cap, SQLite 3.53+), keyed by sys.sqlite_limit!'s category numbering: LENGTH 1MB, SQL_LENGTH 100KB, COLUMN 100, EXPR_DEPTH 10, COMPOUND_SELECT 3, VDBE_OP 25000, FUNCTION_ARG 8, ATTACHED 0, LIKE_PATTERN_LENGTH 50, VARIABLE_NUMBER 10, TRIGGER_DEPTH 10, PARSER_DEPTH 100.

applylimits!

def _apply_limits!(conn: SqliteConn, opts: OpenOptions) -> Result<(), DbError> [db]

applypolicy!

def _apply_policy!(conn: SqliteConn, opts: OpenOptions) -> Result<(), DbError> [db]

The limits apply last, and still inside open_with!: every statement that runs before them is face-authored, which leaves no caller SQL able to reach an un-limited connection. Any step added here must preserve that.

_configure!

def _configure!(conn: SqliteConn, opts: OpenOptions) -> Result<(), DbError> [db]

open_with!

def open_with!(path: string, opts: OpenOptions) -> Result<SqliteConn, DbError> [db]

Open the database at path with an explicit OpenOptions. The native secure-open runs first, then the options are applied (defensive/strict via db-config, the rest via pragmas). @no-doctest: opens a real database handle; mints a native resource

open_path!

def open_path!(path: string) -> Result<SqliteConn, DbError> [db]

Open the database at path with the secure-modern-strict defaults(). (Named open_path! and never open!: open is a keyword.) @no-doctest: opens a real database handle; mints a native resource

open_memory!

def open_memory!() -> Result<SqliteConn, DbError> [db]

Open an anonymous in-memory database with the defaults() (WAL is a no-op for :memory:). @no-doctest: opens a real database handle; mints a native resource

_stmt!

def _stmt!(conn: SqliteConn, sql: string) -> Result<(), DbError> [db]

identfirst?

def _ident_first?(c: u8) -> bool

identrest?

def _ident_rest?(c: u8) -> bool

isident?

def _is_ident?(name: string) -> bool

A counting loop over the bytes: per-byte recursion would consume call stack proportional to the (caller-supplied) name's length on the AOT tier.

savepointname_ok?

def _savepoint_name_ok?(name: string) -> bool

A savepoint name reaches SQL text directly (savepoint names cannot be bound parameters), which leaves it the one caller-supplied string in this face that could inject SQL: only a bare ASCII identifier passes. The hanki_ prefix is reserved for the library's auto-savepoints, checked case-insensitively, because SQLite matches savepoint names case-insensitively (HANKI_TX would alias hanki_tx).

checkedsavepoint!

def _checked_savepoint!(conn: SqliteConn, verb: string, name: string) -> Result<(), DbError> [db]

txrun!

def _tx_run!<T>(conn: SqliteConn, begin_sql: string, commit_sql: string, rollback_sql: string, f: (SqliteConn) -> Result<T, DbError> [e]) -> Result<T, DbError> [db, e]

The rollback leg runs via sqlite_batch! so the savepoint unwind can be two statements; its error is discarded in both cases (f's error wins).

impl SqliteConn

batch!

def batch!(self, sql: string) -> Result<(), DbError> [db]

Run a multi-statement SQL script (migrations, pragma bundles) with no parameters. @no-doctest: runs SQL against a native handle

execute!

def execute!(self, sql: string, params: List<DbValue>) -> Result<int, DbError> [db]

Run one non-query statement with positional params, returning the number of rows changed. Multi-statement SQL is Err(MultiStatement). @no-doctest: runs SQL against a native handle

query!

def query!(self, sql: string, params: List<DbValue>) -> Result<DbRows, DbError> [db]

Run a query with positional params, returning every row materialized. @no-doctest: runs SQL against a native handle

create_function!

def create_function!(self, name: string, f: (List<DbValue>) -> Result<DbValue, string>) -> Result<(), DbError> [db]

Register name as a scalar SQL function backed by the pure Hanki function f: callable from any SQL this connection runs, receiving the call's arguments as one List<DbValue> and returning the result value or Err(message) to fail the statement with that SQL error. Deterministic by its signature (purity is enforced by the parameter's empty effect row), and SQLite may therefore use it in indexes and generated columns. The connection owns f for its own lifetime. @no-doctest: registers against a native handle

execute_named!

def execute_named!(self, sql: string, params: Map<string, DbValue>) -> Result<int, DbError> [db]

execute! with named parameters: each :name / @name / $name parameter in the SQL binds params.get(name); map keys bear no prefix, whichever form the SQL spells. Coverage is checked both ways: a statement name missing from the map, a map key the statement never uses, or a positional ? slot is Err(BindName(name)), which fails a typo loudly in place of binding an unannounced NULL. An over-i64 Int among the values is Err(IntOutOfRange(i)) with i indexing the map's iteration order (deterministic but unspecified). keys() and values() walk the same trie in the same hash order, and the parallel lists pair index-for-index. @no-doctest: runs SQL against a native handle

query_named!

def query_named!(self, sql: string, params: Map<string, DbValue>) -> Result<DbRows, DbError> [db]

query! with named parameters (see execute_named! for the binding contract). @no-doctest: runs SQL against a native handle

lastinsertrowid!

def last_insert_rowid!(self) -> Result<int, DbError> [db]

The rowid of the most recent successful INSERT on this connection. @no-doctest: reads native connection state

changes!

def changes!(self) -> Result<int, DbError> [db]

Rows changed by the most recent statement on this connection. @no-doctest: reads native connection state

in_transaction!

def in_transaction!(self) -> Result<bool, DbError> [db]

Whether the connection is inside an explicit transaction (the inverse of SQLite's autocommit). @no-doctest: reads native connection state

busy_timeout!

def busy_timeout!(self, ms: int) -> Result<(), DbError> [db]

Set the busy-wait timeout in milliseconds for this connection. @no-doctest: mutates native connection state

begin!

def begin!(self) -> Result<(), DbError> [db]

Begin a deferred transaction (the write lock is taken lazily). @no-doctest: mutates native connection state

begin_immediate!

def begin_immediate!(self) -> Result<(), DbError> [db]

Begin a transaction that takes the write lock at once: the write-intent form that avoids a mid-transaction upgrade deadlock. @no-doctest: mutates native connection state

commit!

def commit!(self) -> Result<(), DbError> [db]

Commit the current transaction. @no-doctest: mutates native connection state

rollback!

def rollback!(self) -> Result<(), DbError> [db]

Roll the current transaction back. @no-doctest: mutates native connection state

savepoint!

def savepoint!(self, name: string) -> Result<(), DbError> [db]

Create a savepoint named name, a nestable partial-rollback point, usable inside or outside a transaction. The name must be a bare ASCII identifier; the hanki_ prefix is reserved for the library's own auto-savepoints (case-insensitively, since SQLite matches savepoint names without case). Anything else is rejected before reaching SQL. @no-doctest: mutates native connection state

release!

def release!(self, name: string) -> Result<(), DbError> [db]

Release (commit) the most recent savepoint named name, folding its changes into the enclosing transaction. Savepoints created after it are released with it. Same name validation as savepoint!. @no-doctest: mutates native connection state

rollback_to!

def rollback_to!(self, name: string) -> Result<(), DbError> [db]

Roll back to the most recent savepoint named name: changes since it are undone, savepoints created after it are cancelled, the savepoint itself remains on the stack (release! it to remove), and the enclosing transaction remains open. Same name validation as savepoint!. @no-doctest: mutates native connection state

with_tx!

def with_tx!<T>(self, f: (SqliteConn) -> Result<T, DbError> [e]) -> Result<T, DbError> [db, e]

Run f inside a transaction; composes with itself. At top level it is BEGIN IMMEDIATECOMMIT/ROLLBACK; called inside an open transaction it scopes an auto-savepoint instead (SQLite rejects a nested BEGIN), so an inner Err rolls back only the inner work and the outer transaction remains open. One fixed auto-savepoint name (hanki_tx) suffices at every depth: RELEASE and ROLLBACK TO match the most recent savepoint with the name, and the auto-savepoints are strictly last-in-first-out. An Ok result commits/releases its level (a failure there is surfaced as Err); an Err result rolls its level back (best-effort) and returns f's error.

Composition caveats, each a consequence of savepoint nesting:

@no-doctest: runs SQL against a native handle

confine_tables!

def confine_tables!(self, tables: List<string>) -> Result<(), DbError> [db]

Confine this connection to the named tables: the after-setup form of with_allowed_tables for the :memory: workflow: open unconfined, run the schema, then confine before evaluating untrusted SQL (a confined connection cannot create its own tables). Once-only: a second confinement, which could widen the set, is Err(Misuse). Inherits with_allowed_tables' incompatibility with FTS5 too. @no-doctest: mutates native connection config

stream!

def stream!(self, sql: string, params: List<DbValue>) -> Result<Cursor, DbError> [db]

Begin a streaming query: rows are read one at a time through the returned Cursor (next!/columns!/close!), which scans a result larger than memory with bounded heap where query! materializes everything. One stream per connection (a second stream! while one is open is Err(Misuse)); ordinary execute!/query! calls interleave freely with an open stream. Same binding errors as execute!. @no-doctest: runs SQL against a native handle

backup_to!

def backup_to!(self, dest_path: string) -> Result<int, DbError> [db, fs_write]

Online-backup this database into the file at dest_path (created or replaced), returning the page count copied: the safe way to snapshot a live database (a plain file copy of an open database can tear). Charges [fs_write] on top of [db]: this is the one sqlite call that writes a caller-chosen path, which puts it in front of the capability gate (--allow db --deny fs_write refuses it). A long backup is aborted by actor.shutdown!; writes to the source while it runs restart the copy (SQLite semantics). @no-doctest: writes a real file from a native handle

integrity_check!

def integrity_check!(self) -> Result<(), DbError> [db]

PRAGMA integrity_check: Ok(()) when the database reports ok, otherwise Err(Other(0, msgs)) carrying the joined problem messages. @no-doctest: runs SQL against a native handle

optimize!

def optimize!(self) -> Result<(), DbError> [db]

PRAGMA optimize: let SQLite update its query-planner statistics. Cheap; also run best-effort by close!. @no-doctest: mutates native connection state

close!

def close!(self) -> Result<(), DbError> [db]

Optimize best-effort, then close the connection (releasing the handle). Idempotent; dropping the last handle also closes a connection. @no-doctest: side-effecting close; mints/uses a native resource

firsttext

def _first_text(row: List<DbValue>) -> string

joinmsg

def _join_msg(acc: string, row: List<DbValue>) -> string

integrityresult

def _integrity_result(rows: DbRows) -> Result<(), DbError>

Cursor

struct Cursor
  conn: SqliteConn
end

A row cursor over an open stream, a plain value wrapping its connection. The stream state sits natively inside the connection's own resource cell (no separate native resource), which leaves no close-ordering hazard, and holding the conn makes a Cursor as actor-confined as the connection itself. One stream per connection; see SqliteConn.stream!.

impl Cursor

columns!

def columns!(self) -> Result<List<string>, DbError> [db]

The stream's column names. @no-doctest: reads native connection state

next!

def next!(self) -> Result<Option<List<DbValue>>, DbError> [db]

Read the next row: Ok(Some(cells)), or Ok(None) once exhausted (the stream closes itself). Any error also closes the stream: restart it, don't resume. After exhaustion, close, or conn.close!, further reads are Err(Misuse). @no-doctest: runs SQL against a native handle

close!

def close!(self) -> Result<(), DbError> [db]

Close the stream early, releasing its statement. Idempotent while the connection is open. @no-doctest: mutates native connection state

fold!

def fold!<T>(self, init: T, f: (T, List<DbValue>) -> T [e]) -> Result<T, DbError> [db, e]

Drain the stream through f, one row at a time with bounded heap: acc = f(acc, row) per row, Ok(final acc) at exhaustion, the first error short-circuited (the stream is closed in both cases). The accumulating form is the useful one: a per-row action holding no state would drop everything it read. @no-doctest: runs SQL against a native handle

Row

struct Row
  columns: List<string>
  values: List<DbValue>
end

One row of a query result: the shared column names plus this row's cells in column order. Built on demand by DbRows.get.

impl DbRows

length

prop length(self) -> int

The number of result rows.

DbRows(columns=["a"], rows=[[Int(1)], [Int(2)]]).length => 2

get

def get(self, i: int) -> Option<Row>

The row at i (0-based), or None when out of range.

DbRows(columns=["a"], rows=[[Int(9)]]).get(0).unwrap_or(_empty_row()).get(0).unwrap_or(Null).as_int => Some(9)

first

prop first(self) -> Option<Row>

The first row, or None when the result is empty.

DbRows(columns=["a"], rows=[]).first.map(|_| true).unwrap_or(false) => false

map

def map<T>(self, f: (Row) -> T) -> List<T>

Every row, mapped through f.

This is the member the index loop was standing in for, and it removes more than lines: a loop over get(i) has to match an Option that cannot be None, the index having come from length. Mapping never reaches that branch, and no caller has to write one.

DbRows(columns=["n"], rows=[[Int(1)], [Int(2)]]).map(|r| r.get(0).unwrap_or(Null).as_int.unwrap_or(0)) => [1, 2]

to_list

def to_list(self) -> List<Row>

Every row as a List<Row> - the bridge to List's own combinators, so filtering or folding a result does not have to spell itself map(|r| r).

DbRows(columns=["n"], rows=[[Int(1)], [Int(2)]]).to_list().length => 2

each!

def each!(self, f: (Row) -> () [e]) -> () [e]

Perform f once per row, in order. The [e] row solves to whatever f performs, which suits a result that is printed or written and never collected. @no-doctest: returns (); f's effect is the outcome, and there is nothing to assert

emptyrow

def _empty_row() -> Row

impl Row

get

def get(self, i: int) -> Option<DbValue>

The value at column index i (0-based), or None when out of range.

Row(columns=["a", "b"], values=[Int(1), Text("x")]).get(1).unwrap_or(Null).as_text => Some("x")

col

def col(self, name: string) -> Option<DbValue>

The value in column name, or None when absent. On a joined result with duplicate column names the first match wins (linear scan).

Row(columns=["a", "b"], values=[Int(1), Text("x")]).col("b").unwrap_or(Null).as_text => Some("x")

int

def int(self, name: string) -> Option<int>

The Int in column name, or None. An absent column, a SQL NULL, and a value of another kind all collapse to None, the terse read for a query whose columns the caller already knows. The req_int / opt_int pair below separates those three cases when the difference matters.

Row(columns=["n"], values=[Int(7)]).int("n") => Some(7)
Row(columns=["n"], values=[Text("7")]).int("n") => None
Row(columns=["n"], values=[Int(7)]).int("gone") => None

real

def real(self, name: string) -> Option<f64>

The Real in column name, or None; absent, NULL, and wrong-kind collapse as in int.

Row(columns=["x"], values=[Real(1.5)]).real("x").map(|v| v > 1.0) => Some(true)
Row(columns=["x"], values=[Int(2)]).real("x").map(|v| v > 1.0) => None

text

def text(self, name: string) -> Option<string>

The Text in column name, or None; absent, NULL, and wrong-kind collapse as in int.

Row(columns=["s"], values=[Text("hi")]).text("s") => Some("hi")
Row(columns=["s"], values=[Null]).text("s") => None

blob

def blob(self, name: string) -> Option<bytes>

The Blob in column name, or None; absent, NULL, and wrong-kind collapse as in int.

Row(columns=["b"], values=[Blob("hi".to_bytes())]).blob("b").map(|b| b.length) => Some(2)
Row(columns=["b"], values=[Null]).blob("b").map(|b| b.length) => None

bool

def bool(self, name: string) -> Option<bool>

The boolean in column name (SQLite's 0 / 1), or None; absent, NULL, and wrong-kind collapse as in int.

Row(columns=["ok"], values=[Int(1)]).bool("ok") => Some(true)
Row(columns=["ok"], values=[Int(2)]).bool("ok") => None

impl DbValue

as_int

prop as_int(self) -> Option<int>

The Int payload, or None for any other kind. No coercion: a Real is not as_int-able.

Int(5).as_int => Some(5)
Text("5").as_int => None

as_real

prop as_real(self) -> Option<f64>

The Real payload, or None for any other kind.

Real(1.5).as_real.map(|x| x > 1.0).unwrap_or(false) => true
Int(1).as_real.map(|x| x > 0.0).unwrap_or(false) => false

as_text

prop as_text(self) -> Option<string>

The Text payload, or None for any other kind.

Text("hi").as_text => Some("hi")
Int(1).as_text => None

as_blob

prop as_blob(self) -> Option<bytes>

The Blob payload, or None for any other kind. @no-doctest: bytes literals are not =>-comparable in a doctest

as_bool

prop as_bool(self) -> Option<bool>

A boolean from an Int of 0 or 1, else None. Strict: only the canonical encodings map.

Int(1).as_bool => Some(true)
Int(0).as_bool => Some(false)
Int(2).as_bool => None

null?

prop null?(self) -> bool

Whether this is the SQL NULL.

Null.null? => true
Int(0).null? => false

ToValue

trait ToValue

A value that can be bound as a SQLite parameter. int/i64 bind as Int, f64 as Real, string as Text, bytes as Blob, bool as Int 0/1, and Option<T> binds None as SQL NULL.

to_value

def to_value(self) -> DbValue

Converts the value into the DbValue a parameter slot binds.

@no-doctest: trivial ToValue constructor; exercised through v

impl ToValue<int>

to_value

def to_value(self) -> DbValue

Binds as a SQLite integer.

@no-doctest: trivial ToValue constructor; exercised through v

impl ToValue<i64>

to_value

def to_value(self) -> DbValue

Binds as a SQLite integer.

@no-doctest: trivial ToValue constructor; exercised through v

impl ToValue<f64>

to_value

def to_value(self) -> DbValue

Binds as a SQLite real.

@no-doctest: trivial ToValue constructor; exercised through v

impl ToValue<string>

to_value

def to_value(self) -> DbValue

Binds as SQLite text.

@no-doctest: trivial ToValue constructor; exercised through v

impl ToValue<bytes>

to_value

def to_value(self) -> DbValue

Binds as a SQLite blob.

@no-doctest: trivial ToValue constructor; exercised through v

impl ToValue<bool>

to_value

def to_value(self) -> DbValue

Binds as the SQLite integer 1 or 0 - SQLite has no boolean type.

@no-doctest: trivial ToValue constructor; exercised through v

impl<T: ToValue> ToValue<Option<T>>

to_value

def to_value(self) -> DbValue

Binds None as SQL NULL, Some(x) as x's own binding.

@no-doctest: trivial ToValue constructor; exercised through v

v

def v<T: ToValue>(x: T) -> DbValue

Bind-parameter sugar: conn.query!("... WHERE id > ?", [v(0)]). Wraps any ToValue as a DbValue.

v(5).as_int => Some(5)
v("x").as_text => Some("x")
v(true).as_int => Some(1)

match_phrase

def match_phrase(text: string) -> string

Arbitrary text as an FTS5 query that matches it as one literal phrase.

Binding a parameter stops the text being read as SQL, and that is where most people stop. An FTS5 MATCH operand is then read as an FTS5 query, which has a syntax of its own. A search box wired straight to notes MATCH ? gives milk OR bread a boolean OR the user never asked for, reads title:milk as a column filter, mil* as a prefix search, NEAR(a b) as proximity, and fails outright on a lone ", which turns a quote typed into a search box into an error and no search. None of that is an injection into your database; all of it is the wrong search.

Wrapping the text in double quotes (each interior one doubled) makes it a phrase: every character is a term to find, no character is an operator. Pass the result as an ordinary bound parameter. This quotes for FTS5 and never for SQL, and is not a substitute for binding.

A multi-word search box wants match_all, one definition down: it splits the line into words and requires every one, where this function matches the whole line as a single consecutive phrase.

match_phrase("milk") => "\"milk\""
match_phrase("milk OR bread") => "\"milk OR bread\""
match_phrase("say \"hi\"") => "\"say \"\"hi\"\"\""
match_phrase("") => "\"\""

match_all

def match_all(text: string) -> string

A line of words as typed into a search box, as an FTS5 query requiring every word: the search-box function, where match_phrase is the single-phrase one. Words come from splitting on runs of whitespace (so consecutive spaces produce nothing), each word is quoted through match_phrase so none of its characters are operators, and the joiner is the explicit AND, juxtaposition looking equivalent without being so, since FTS5 reads adjacent terms in some positions as an implicit phrase.

Empty input gives the empty string, and an empty FTS5 query is an error and no empty result set: the caller must skip the MATCH when this returns "". That is by design, since whether no words means "match nothing" or "match everything" is the caller's policy, and this function only builds the query.

match_all("milk bread") => "\"milk\" AND \"bread\""
match_all("milk") => "\"milk\""
match_all("  milk   bread ") => "\"milk\" AND \"bread\""
match_all("milk OR bread") => "\"milk\" AND \"OR\" AND \"bread\""
match_all("title:milk NEAR(a b)") => "\"title:milk\" AND \"NEAR(a\" AND \"b)\""
match_all("\"") => "\"\"\"\""
match_all("") => ""

MapError

type MapError
  MissingColumn(string)
  WrongType(string, string, string)
  Custom(string)
end

How a Row -> typed value mapping failed. WrongType names the column, the expected kind, and the actual kind (see value_kind); Custom bears a domain impl's own message (e.g. a parse failure). Renders via Display; the doctests below render it through map_err to leave a Result comparable.

impl Display<MapError>

to_string

def to_string(self) -> string

Renders the mapping failure as a one-line message naming the column.

MissingColumn("id").to_string() => "missing column: id"
WrongType("age", "int", "text").to_string() => "column age: expected int, got text"

value_kind

def value_kind(v: DbValue) -> string

The storage-class name of a cell, for WrongType diagnostics.

value_kind(Int(5)) => "int"
value_kind(Null) => "null"
value_kind(Text("x")) => "text"

impl Row

Typed, by-name column extraction. req_* demands the column is present and demands the expected kind; opt_* maps a SQL NULL to None but still requires the column to be present: an absent column is MissingColumn, distinct from a present NULL.

req_int

def req_int(self, col: string) -> Result<int, MapError>

Reads col as a required integer; absent or wrong-typed is an error.

Row(columns=["id"], values=[Int(7)]).req_int("id").map_err(|e| e.to_string()) => Ok(7)
Row(columns=["id"], values=[Text("x")]).req_int("id").map_err(|e| e.to_string()) => Err("column id: expected int, got text")
Row(columns=["id"], values=[Int(7)]).req_int("age").map_err(|e| e.to_string()) => Err("missing column: age")

req_real

def req_real(self, col: string) -> Result<f64, MapError>

Reads col as a required real; absent or wrong-typed is an error.

@no-doctest: f64 is not =>-comparable in a doctest

req_text

def req_text(self, col: string) -> Result<string, MapError>

Reads col as required text; absent or wrong-typed is an error.

Row(columns=["name"], values=[Text("ada")]).req_text("name").map_err(|e| e.to_string()) => Ok("ada")
Row(columns=["name"], values=[Int(1)]).req_text("name").map_err(|e| e.to_string()) => Err("column name: expected text, got int")

req_blob

def req_blob(self, col: string) -> Result<bytes, MapError>

Reads col as a required blob; absent or wrong-typed is an error.

@no-doctest: bytes are not =>-comparable in a doctest

req_bool

def req_bool(self, col: string) -> Result<bool, MapError>

Reads col as a required boolean (SQLite 0/1).

Row(columns=["ok"], values=[Int(1)]).req_bool("ok").map_err(|e| e.to_string()) => Ok(true)
Row(columns=["ok"], values=[Int(2)]).req_bool("ok").map_err(|e| e.to_string()) => Err("column ok: expected bool, got int")

opt_int

def opt_int(self, col: string) -> Result<Option<int>, MapError>

Reads col as an integer, mapping SQL NULL to None; a missing column is still an error.

Row(columns=["n"], values=[Null]).opt_int("n").map_err(|e| e.to_string()) => Ok(None)
Row(columns=["n"], values=[Int(3)]).opt_int("n").map_err(|e| e.to_string()) => Ok(Some(3))
Row(columns=["n"], values=[Int(3)]).opt_int("gone").map_err(|e| e.to_string()) => Err("missing column: gone")

opt_real

def opt_real(self, col: string) -> Result<Option<f64>, MapError>

Reads col as a real, mapping SQL NULL to None; a missing column is still an error.

@no-doctest: f64 is not =>-comparable in a doctest

opt_text

def opt_text(self, col: string) -> Result<Option<string>, MapError>

Reads col as text, mapping SQL NULL to None; a missing column is still an error.

Row(columns=["s"], values=[Null]).opt_text("s").map_err(|e| e.to_string()) => Ok(None)
Row(columns=["s"], values=[Text("hi")]).opt_text("s").map_err(|e| e.to_string()) => Ok(Some("hi"))

opt_blob

def opt_blob(self, col: string) -> Result<Option<bytes>, MapError>

Reads col as a blob, mapping SQL NULL to None; a missing column is still an error.

@no-doctest: bytes are not =>-comparable in a doctest

opt_bool

def opt_bool(self, col: string) -> Result<Option<bool>, MapError>

Reads col as a boolean, mapping SQL NULL to None; a missing column is still an error.

Row(columns=["ok"], values=[Null]).opt_bool("ok").map_err(|e| e.to_string()) => Ok(None)
Row(columns=["ok"], values=[Int(0)]).opt_bool("ok").map_err(|e| e.to_string()) => Ok(Some(false))

FromRow

trait FromRow

Rebuild a typed value from a query Row. The hand-written pattern (which @derive(FromRow) generates verbatim) reads each field by column name and short-circuits on the first error:

use sqlite

impl sqlite.FromRow<User>
  def from_row(r: sqlite.Row) -> Result<User, sqlite.MapError>
    match r.req_int("id")
      Err(e) -> Err(e)
      Ok(id) ->
        match r.req_text("name")
          Err(e) -> Err(e)
          Ok(name) -> Ok(User(id=id, name=name))
        end
    end
  end
end

The convention the derive bakes in is field-name = column-name; renames, joins, and computed columns stay hand-written. Self.from_row(row) (or a bounded T.from_row(row)) dispatches to the impl.

from_row

def from_row(r: Row) -> Result<Self, MapError>

Rebuilds the type from a query row, by column name.

@no-doctest: exercised by the scalar impls below and the pattern above

impl FromRow<int>

A single-column result (e.g. SELECT count(*)) reads column 0.

from_row

def from_row(r: Row) -> Result<int, MapError>

Reads column 0 as an integer, for the single-column scalar query.

int.from_row(Row(columns=["c"], values=[Int(42)])).map_err(|e| e.to_string()) => Ok(42)
int.from_row(Row(columns=["c"], values=[Text("x")])).map_err(|e| e.to_string()) => Err("column 0: expected int, got text")

impl FromRow<f64>

from_row

def from_row(r: Row) -> Result<f64, MapError>

Reads column 0 as a real, for the single-column scalar query.

@no-doctest: f64 is not =>-comparable in a doctest

impl FromRow<string>

from_row

def from_row(r: Row) -> Result<string, MapError>

Reads column 0 as text, for the single-column scalar query.

string.from_row(Row(columns=["c"], values=[Text("hi")])).map_err(|e| e.to_string()) => Ok("hi")

impl FromRow<bytes>

from_row

def from_row(r: Row) -> Result<bytes, MapError>

Reads column 0 as a blob, for the single-column scalar query.

@no-doctest: bytes are not =>-comparable in a doctest

impl FromRow<bool>

from_row

def from_row(r: Row) -> Result<bool, MapError>

Reads column 0 as a boolean, for the single-column scalar query.

bool.from_row(Row(columns=["c"], values=[Int(1)])).map_err(|e| e.to_string()) => Ok(true)