Rindle docs and package mapSkip to main content

rindle_sqlite/
sqlite.rs

1//! Phase 2 — the **SQLite leaf**: a zero-copy lending [`RowStream`] over a real
2//! `rusqlite` cursor. This is the hardest backend for the canonical value model
3//! (`value.rs`) to satisfy, and the reason the model is designed *here* rather
4//! than in the abstract: a SQLite statement has **no row** — only a prepared
5//! statement, a `step`, and `column_*(i)` accessors whose text/blob pointers are
6//! valid *only until the next `step`/`reset`*. The borrow checker turns a
7//! use-after-step into a **compile error**, and forces an owned copy at exactly
8//! the points the JS already copies.
9//!
10//! What this proves (the spike's exit criteria for the SQLite side):
11//!
12//! 1. **Zero copy on the transient path, including strings.** `RowRef::col(i)`
13//!    is a `rusqlite` `get_ref` — a `ValueRef` borrowing the step buffer — mapped
14//!    to a `Value<'_>` with **no allocation** (`Str`/`Json` are `&[u8]` straight
15//!    into SQLite's buffer). A filtered/pass-through scan that reads, compares,
16//!    and drops rows allocates **nothing per row** (proven by the counting
17//!    allocator in `tests/sqlite_zero_copy.rs`).
18//! 2. **`to_owned_row()` is the one forced per-row copy**, at the Node boundary —
19//!    the single point where a value must outlive its step. Same trait method
20//!    the memory backend implements as an `Arc` bump (`btree.rs`); here it copies.
21//! 3. **Step fallibility resolved (handoff decision 1 / `05` OQ-9):** `next_row`
22//!    stays infallible (uniform with the never-erroring memory backend); a
23//!    `sqlite3_step` error is **parked** on the stream and **re-raised at the
24//!    first owning boundary** via [`SqliteRowStream::take_error`] — NEVER mapped
25//!    to silent end-of-stream.
26//! 4. **RAII statement cleanup (Primitive #2):** dropping the stream drops the
27//!    `Rows` cursor (rusqlite resets the statement), so the next write does not
28//!    hit "database is busy". [`StmtGuard`] makes that release observable — and
29//!    is where a real prepared-statement pool returns its `PooledStmt`.
30
31use std::cell::Cell;
32use std::rc::Rc;
33
34use rusqlite::types::ValueRef;
35
36use rindle::value::{ColId, OwnedRow, RowRef, RowStream, Value};
37use rindle::RindleError;
38
39/// Per-column type tag, resolved once from the schema at build time
40/// (foundations §4). Makes value conversion ty-directed: an INTEGER storage
41/// class becomes `Int` or `Bool` depending on the column, TEXT becomes `Str` or
42/// (unparsed) `Json`. The hot path never inspects a column *name*.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum ColType {
45    Int,
46    Float,
47    Bool,
48    Text,
49    Json,
50}
51
52/// A parked error, surfaced at the first owning/dyn boundary. Carries either a
53/// `sqlite3_step` failure or a value-conversion failure; the production
54/// `RindleError` (foundations §10) wraps the same set.
55#[derive(Debug)]
56pub enum SqliteError {
57    /// A `sqlite3_step` error (I/O, corruption, an expression-evaluation error
58    /// such as integer overflow) raised while advancing the cursor.
59    Step(rusqlite::Error),
60    /// A vended value cannot be represented (`UnsupportedValueError`): currently
61    /// a `number`-column integer that cannot round-trip through `f64` without
62    /// precision loss. UTF-8 failures are surfaced directly as
63    /// [`RindleError::InvalidUtf8`] at the row-materialization boundary.
64    UnsupportedValue(String),
65}
66
67impl From<SqliteError> for RindleError {
68    fn from(value: SqliteError) -> RindleError {
69        match value {
70            SqliteError::Step(source) => RindleError::sqlite("step", source),
71            SqliteError::UnsupportedValue(message) => RindleError::unsupported_value(message),
72        }
73    }
74}
75
76/// True when widening `i` to `f64` and narrowing it back preserves the exact
77/// integer. This deliberately accepts sparse, exactly representable integers above
78/// 2^53 (such as 2^54) while rejecting adjacent values that would lose precision.
79///
80/// The explicit upper-bound check handles `i64::MAX`: it rounds up to 2^63 as an
81/// `f64`, then Rust's saturating float-to-integer cast would otherwise appear to
82/// round-trip back to `i64::MAX`. `i64::MIN` is exactly -2^63 and is accepted.
83#[inline]
84pub fn is_exact_f64_integer(i: i64) -> bool {
85    let widened = i as f64;
86    widened < i64::MAX as f64 && widened as i64 == i
87}
88
89/// Build `SELECT <c0>, <c1>, … FROM <table>` projecting the declared columns in
90/// `ColId` order. Result-row order == `columns` order == `ColId`, which is what
91/// makes `RowRef::col(i)` an O(1) array index (`05` §8.1). (The full
92/// constraint/start/filter `WHERE` lowering is `05` §4.4; out of scope here —
93/// the spike threads its own predicates.)
94pub fn select_sql(table: &str, columns: &[&str]) -> String {
95    let mut sql = String::from("SELECT ");
96    for (i, c) in columns.iter().enumerate() {
97        if i > 0 {
98            sql.push_str(", ");
99        }
100        sql.push_str(c);
101    }
102    sql.push_str(" FROM ");
103    sql.push_str(table);
104    sql
105}
106
107/// RAII cursor accounting (Primitive #2). Mirrors the JS `finally`/`.return()`
108/// that resets+returns the prepared statement so the **next write** doesn't hit
109/// "database is busy". `rusqlite::Rows` already resets the statement on `Drop`;
110/// this guard makes the release *observable* (and is where a real pool would
111/// return its `PooledStmt`). Decrements its counter on `Drop` — on normal end,
112/// early `break`, `?`, or early return — for free, no `finally` needed. This is a
113/// `sqlite`-only (native server) path: run the server with the `release-server`
114/// profile (`panic = "unwind"`), where `Drop` also runs on panic; under a plain
115/// `release` build (`panic = "abort"`) a panic aborts the process and no destructor
116/// runs, so panic balance holds only in unwinding builds (`release-server`,
117/// `cargo test`). See WS02.
118pub struct StmtGuard(Rc<Cell<i64>>);
119
120impl StmtGuard {
121    pub fn new(open: Rc<Cell<i64>>) -> StmtGuard {
122        open.set(open.get() + 1);
123        StmtGuard(open)
124    }
125}
126
127impl Drop for StmtGuard {
128    fn drop(&mut self) {
129        self.0.set(self.0.get() - 1);
130    }
131}
132
133/// The leaf row stream: a live rusqlite cursor (`Rows`) plus the per-column type
134/// tags. Implements the foundations lending [`RowStream`]; each `next_row`
135/// reborrows `self`, so the returned [`SqliteRow`] is invalidated by the next
136/// call — the SQLite cursor contract, now a **compile-time** invariant.
137///
138/// `'s` is the lifetime of the prepared statement the cursor borrows. The
139/// statement is owned **elsewhere** (the caller / a pool), which sidesteps the
140/// self-referential-struct problem (`05` §13 Q1): a field borrowing another
141/// field of the same struct is not expressible in safe Rust, so the statement
142/// lives outside and the stream borrows it for `'s`.
143///
144/// Field order is load-bearing: `rows` (the cursor) is declared before `_guard`
145/// so it drops first — the cursor is released *before* the "returned to pool"
146/// guard fires.
147pub struct SqliteRowStream<'s> {
148    rows: rusqlite::Rows<'s>,
149    types: &'s [ColType],
150    /// A step error parked here by `next_row` (which must return `Option`),
151    /// re-raised at the owning boundary via [`Self::take_error`]. NEVER swallowed.
152    parked_err: Option<SqliteError>,
153    /// RAII release accounting (Primitive #2); `None` when the caller doesn't
154    /// track open cursors.
155    _guard: Option<StmtGuard>,
156}
157
158impl<'s> SqliteRowStream<'s> {
159    /// Wrap a `rusqlite::Rows` cursor (from `stmt.query(params)`) and its column
160    /// type tags.
161    pub fn new(rows: rusqlite::Rows<'s>, types: &'s [ColType]) -> SqliteRowStream<'s> {
162        SqliteRowStream {
163            rows,
164            types,
165            parked_err: None,
166            _guard: None,
167        }
168    }
169
170    /// Same, but with a Primitive-#2 release guard (the open-cursor counter is
171    /// decremented when this stream drops, including on early termination).
172    pub fn guarded(
173        rows: rusqlite::Rows<'s>,
174        types: &'s [ColType],
175        guard: StmtGuard,
176    ) -> SqliteRowStream<'s> {
177        SqliteRowStream {
178            rows,
179            types,
180            parked_err: None,
181            _guard: Some(guard),
182        }
183    }
184
185    /// Re-raise the parked step error at the owning boundary. The node-stream /
186    /// `fetch` layer calls this after the scan drains (where the result type is
187    /// already fallible — `09`); a leftover `Some` here means the scan hit a
188    /// `sqlite3_step` error that must propagate, NOT a clean end-of-stream.
189    pub fn take_error(&mut self) -> Option<SqliteError> {
190        self.parked_err.take()
191    }
192}
193
194impl<'s> RowStream for SqliteRowStream<'s> {
195    type Row<'a>
196        = SqliteRow<'a>
197    where
198        Self: 'a;
199
200    fn next_row(&mut self) -> Option<Self::Row<'_>> {
201        // `Rows::next` advances the cursor and returns `Result<Option<&Row>>`;
202        // the `&Row` borrows the step buffers (and so cannot outlive the next
203        // `next_row`). Conversion is LAZY (per-cell, in `col`), so a pass-through
204        // scan converts/copies nothing.
205        //
206        // A `sqlite3_step` error must NOT be silently mapped to end-of-stream
207        // (the JS `iterate()` generator throws, and `#fetch`'s `finally` still
208        // closes the cursor). Since `RowStream::next_row` returns `Option`, the
209        // error is PARKED on `self` and re-raised at the owning boundary
210        // (`take_error`). DO NOT `.ok()?` it.
211        match self.rows.next() {
212            Ok(Some(row)) => Some(SqliteRow {
213                row,
214                types: self.types,
215            }),
216            Ok(None) => None,
217            Err(e) => {
218                self.parked_err = Some(SqliteError::Step(e));
219                None
220            }
221        }
222    }
223}
224
225/// One borrowed SQLite row. `col(i)` reads column `i` lazily and **zero-copy**:
226/// a `Str`/`Json` cell is a `&[u8]` pointing straight into SQLite's step buffer
227/// (no allocation, no UTF-8 validation — bytewise `BINARY` compare on the hot
228/// path; validate at the `to_owned` escape).
229pub struct SqliteRow<'a> {
230    row: &'a rusqlite::Row<'a>,
231    types: &'a [ColType],
232}
233
234impl<'a> SqliteRow<'a> {
235    /// Wrap a borrowed rusqlite row + its column type tags. Used by the
236    /// production owning cursor (`table_source::OwnedSqliteRows`), which — unlike
237    /// the borrow-the-caller's-statement [`SqliteRowStream`] — owns the whole
238    /// conn→stmt→cursor chain and constructs each `SqliteRow` itself.
239    #[inline]
240    pub fn new(row: &'a rusqlite::Row<'a>, types: &'a [ColType]) -> SqliteRow<'a> {
241        SqliteRow { row, types }
242    }
243}
244
245impl RowRef for SqliteRow<'_> {
246    fn col(&self, c: ColId) -> Value<'_> {
247        // `get_ref_unwrap` is INFALLIBLE by construction: the SELECT projects
248        // exactly `types.len()` columns in ColId order, and `c < types.len()` is
249        // a build-time invariant (foundations §3.1), so the only `get_ref` error
250        // (column-index-out-of-range) cannot occur. A bad `c` is a builder bug,
251        // not a runtime error path.
252        //
253        // NB: a returned `Str`/`Json` borrows the step buffer — valid only until
254        // the next `next_row` (the lending contract, enforced by the lifetime).
255        match (self.types[c], self.row.get_ref_unwrap(c)) {
256            (_, ValueRef::Null) => Value::Null,
257            (ColType::Bool, ValueRef::Integer(i)) => Value::Bool(i != 0),
258            (ColType::Int, ValueRef::Integer(i)) => Value::Int(i),
259            (ColType::Float, ValueRef::Real(f)) => Value::Float(f),
260            // A `number`-typed column stored as INTEGER widens to f64. The owning
261            // production cursor checks lossless round-tripping before constructing
262            // this row; this primitive's borrowed test cursor cannot park an error
263            // from its infallible `col(&self)` method.
264            (ColType::Float, ValueRef::Integer(i)) => Value::Float(i as f64),
265            (ColType::Text, ValueRef::Text(b)) => Value::Str(b), // zero-copy bytes
266            (ColType::Json, ValueRef::Text(b)) => Value::Json(b), // unparsed, borrowed
267            // A (ty, storage-class) pair the schema forbids is a builder bug; we
268            // pass it through deterministically rather than silently coerce.
269            (_, ValueRef::Text(b)) | (_, ValueRef::Blob(b)) => Value::Str(b),
270            (_, ValueRef::Integer(i)) => Value::Int(i),
271            (_, ValueRef::Real(f)) => Value::Float(f),
272        }
273    }
274
275    fn len(&self) -> usize {
276        self.types.len()
277    }
278
279    /// Fallible forced per-row copy — **one flat allocation** for the whole row
280    /// (`205-FLAT-ROW-SINGLE-BUFFER-DESIGN.md`): the two-pass builder sizes the
281    /// buffer, validates TEXT/JSON UTF-8 (same eager timing as the old per-cell
282    /// `try_to_owned`), then writes cells in place. Invalid bytes become a runtime
283    /// error instead of a panic; lossy integer-to-f64 conversions are parked before
284    /// this point by the owning SQLite cursor.
285    fn try_to_owned_row(&self) -> Result<OwnedRow, RindleError> {
286        OwnedRow::try_from_row_ref(self)
287    }
288
289    /// Infallible compatibility wrapper for direct tests and memory-equivalent
290    /// trait users. Production SQLite source code uses [`Self::try_to_owned_row`].
291    fn to_owned_row(&self) -> OwnedRow {
292        self.try_to_owned_row()
293            .expect("SQLite row could not be materialized")
294    }
295}
296
297#[cfg(test)]
298mod exact_f64_integer_tests {
299    use super::is_exact_f64_integer;
300
301    #[test]
302    fn accepts_exact_sparse_values_and_rejects_precision_loss() {
303        for value in [
304            i64::MIN,
305            -(1_i64 << 54),
306            -(1_i64 << 53),
307            0,
308            1_i64 << 53,
309            1_i64 << 54,
310            i64::MAX - 1023,
311        ] {
312            assert!(
313                is_exact_f64_integer(value),
314                "expected {value} to round-trip"
315            );
316        }
317
318        for value in [
319            i64::MIN + 1,
320            -((1_i64 << 53) + 1),
321            (1_i64 << 53) + 1,
322            i64::MAX,
323        ] {
324            assert!(
325                !is_exact_f64_integer(value),
326                "expected {value} to lose precision"
327            );
328        }
329    }
330}