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.
| setting | our default | raw SQLite | why we deviate |
|---|---|---|---|
busy_timeout_ms | 5000 | 0 | wait a writer out in place of failing at once |
foreign_keys | ON | OFF | enforce declared references |
wal | ON | rollback | concurrent readers + a writer; sticky, + sidecars |
synchronous_full | OFF (NORMAL) | FULL | NORMAL is the safe, fast WAL companion |
strict_sql | ON | OFF (DQS) | kill the "typo"-matches-a-string footgun |
trusted_schema | OFF | ON | harden against hostile schema in db files |
defensive | ON | OFF | reject the writable-schema corruption paths |
journal_size_limit | 64 MiB | -1 | cap 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
- Actor-confined connection. A
SqliteConnis a native resource: it is not sendable across actors and must bemoved into aspawn(HANKI.md §4). The idiom is a DB-owner actor that owns the connection in its state and answers query/mutation messages. - Rows are plain data.
DbRows/Row/DbValueare ordinary values, and a query result can therefore be sent to another actor: read on the owner, hand the rows off for processing. - Big results.
query!materializes every row; for results larger than memory,conn.stream!(sql, params)reads row-at-a-time through aCursorwith bounded heap (one stream per connection). STRICTtables. For new schemas preferCREATE TABLE ... STRICT, which has the column types enforced, complementingstrict_sql.- One statement per call.
execute!/query!run a single statement; usebatch!for multi-statement scripts (migrations, pragma bundles). - Untrusted SQL.
open_with!(path, OpenOptions.hardened())applies the SQLite security doc's engine-limit profile pluscell_size_checkfor executing SQL you did not write. The threat model splits in two. A hostile database file is substantially covered by the on-by-default profile (defensive,trusted_schemaoff, both overridable where the native lockdown is not);cell_size_checkadds paid per-page sanity checks on top and is bundled intohardened(). Hostile SQL is what the engine limits bound: they cap what SQLite itself will accept, while the run-level--max-steps/--max-bytesbudgets cap only the Hanki side; neither implies the other. - Full-text search (FTS5). Compiled into the bundled engine, and needing no flag and no wrapper:
CREATE VIRTUAL TABLE notes USING fts5(title, body)throughbatch!, thenWHERE notes MATCH ?throughquery!, withrankordering andbm25/snippet/highlightavailable like any other SQL function. Two things about it bear on a search box.
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 IMMEDIATE … COMMIT/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:
- The nested form inherits the enclosing transaction's lock state: inside a deferred
begin!, a write infupgrades the transaction mid-way and can failBusyunder concurrency; open write transactions withbegin_immediate!(or an outerwith_tx!, which does). - Explicit savepoints inside
fcompose (an unreleased one is subsumed when the level commits), but transaction-control SQL that crosses the level, such asrollback_to!of a savepoint taken outside thewith_tx!, or a rawCOMMIT/ROLLBACK/SAVEPOINT hanki_txthroughexecute!/batch!, breaks the pairing; thehanki_reservation is enforced at the savepoint verbs only. - A throw escaping
fleaves the transaction open, and a laterwith_tx!cannot tell that leaked transaction from an intended enclosing one, and it nests inside it. Recover within_transaction!androllback!before transacting again.
@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)