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}