• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25626413047

10 May 2026 10:31AM UTC coverage: 66.053% (+0.2%) from 65.897%
25626413047

push

github

web-flow
feat(engine): Phase 11.1 multi-connection foundation (SQLR-22) (#122)

First slice of Phase 11 (concurrent writes via MVCC + BEGIN CONCURRENT).
Refactors Connection from owning Database by value to holding
Arc<Mutex<Database>>, so multiple Connection handles can address the
same engine state from inside one process.

Connection::connect() mints a sibling handle that shares the backing
Database. Connection is now Send + Sync and can be moved across
threads without an outer Mutex<Connection>. The pager's existing
process-level flock and per-database mutex still serialize commits;
true multi-writer throughput on disjoint rows lands with
BEGIN CONCURRENT in 11.4.

Connection::database() / database_mut() return MutexGuard<'_, Database>;
internal call sites in src/ask, sqlrite-mcp tools, and the
Python/Node/WASM SDK shims bind the guard to a local first.

Six new tests in src/connection.rs:
- connect_shares_underlying_database
- connect_shares_file_backed_database
- handle_count_reflects_live_handles
- threaded_writers_serialize_cleanly (8 threads × 25 INSERTs)
- prep_cache_is_per_handle
- connection_is_send_and_sync (compile-time Send+Sync check)

Docs sweep: roadmap gets a Phase 11 section linking to
concurrent-writes-plan.md (the plan-doc internally numbers
sub-phases as "Phase 10.x" — that's its working title from before
Phase 10 = benchmarks shipped). New design-decisions entry 12a.
embedding.md gains a "Sharing one database across threads" section.

No file-format change. 579/579 workspace tests pass. No new clippy
warnings. fmt clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

91 of 110 new or added lines in 6 files covered. (82.73%)

1 existing line in 1 file now uncovered.

9468 of 14334 relevant lines covered (66.05%)

1.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.13
/src/connection.rs
1
//! Public `Connection` / `Statement` / `Rows` / `Row` API (Phase 5a + SQLR-23).
2
//!
3
//! This is the stable surface external consumers bind against — Rust
4
//! callers use it directly, language SDKs (Python, Node.js, Go) bind
5
//! against the C FFI wrapper over these same types in Phase 5b, and
6
//! the WASM build in Phase 5g re-exposes them via `wasm-bindgen`.
7
//!
8
//! The shape mirrors `rusqlite` / Python's `sqlite3` so users
9
//! familiar with either can pick it up immediately:
10
//!
11
//! ```no_run
12
//! use sqlrite::Connection;
13
//!
14
//! let mut conn = Connection::open("foo.sqlrite")?;
15
//! conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")?;
16
//! conn.execute("INSERT INTO users (name) VALUES ('alice')")?;
17
//!
18
//! let mut stmt = conn.prepare("SELECT id, name FROM users")?;
19
//! let mut rows = stmt.query()?;
20
//! while let Some(row) = rows.next()? {
21
//!     let id: i64 = row.get(0)?;
22
//!     let name: String = row.get(1)?;
23
//!     println!("{id}: {name}");
24
//! }
25
//! # Ok::<(), sqlrite::SQLRiteError>(())
26
//! ```
27
//!
28
//! **Relationship to the internal engine.** A `Connection` owns a
29
//! `Database` (which owns a `Pager` for file-backed connections).
30
//! `execute` and `query` go through the same `process_command`
31
//! pipeline the REPL uses, just with typed row return instead of
32
//! pre-rendered tables. The internal `Database` / `Pager` stay
33
//! accessible via `sqlrite::sql::...` for the engine's own tests
34
//! and for the desktop app — but those paths aren't considered
35
//! stable API.
36
//!
37
//! # Prepared statements & parameter binding (SQLR-23)
38
//!
39
//! `Connection::prepare` parses the SQL once and stashes the AST on
40
//! the returned `Statement`. Subsequent calls to `Statement::query` /
41
//! `Statement::run` execute against the cached AST without re-running
42
//! sqlparser. Bound versions ([`Statement::query_with_params`] /
43
//! [`Statement::execute_with_params`]) accept a `&[Value]` slice that is
44
//! substituted into the cached AST at execute time — including
45
//! `Value::Vector(...)` for HNSW-eligible KNN queries, where binding
46
//! the query vector skips per-iter lexing of the 4 KB bracket-array
47
//! literal.
48
//!
49
//! [`Connection::prepare_cached`] adds a small per-connection LRU
50
//! (default cap 16) so a hot SQL string is parsed exactly once across
51
//! every call, not once per `prepare()`. Matches the rusqlite pattern.
52

53
use std::collections::VecDeque;
54
use std::path::Path;
55
use std::sync::{Arc, Mutex, MutexGuard};
56

57
use crate::sql::dialect::SqlriteDialect;
58
use sqlparser::ast::Statement as AstStatement;
59
use sqlparser::parser::Parser;
60

61
use crate::error::{Result, SQLRiteError};
62
use crate::sql::db::database::Database;
63
use crate::sql::db::table::Value;
64
use crate::sql::executor::execute_select_rows;
65
use crate::sql::pager::{AccessMode, open_database_with_mode, save_database};
66
use crate::sql::params::{rewrite_placeholders, substitute_params};
67
use crate::sql::parser::select::SelectQuery;
68
use crate::sql::process_ast_with_render;
69

70
/// Default capacity of the per-connection prepared-statement plan cache.
71
/// Matches rusqlite's default; tweak with [`Connection::set_prepared_cache_capacity`].
72
const DEFAULT_PREP_CACHE_CAP: usize = 16;
73

74
/// A handle to a SQLRite database. Opens a file or an in-memory DB;
75
/// drop it to close. Every mutating statement auto-saves (except inside
76
/// an explicit `BEGIN`/`COMMIT` block — see [Transactions](#transactions)).
77
///
78
/// ## Transactions
79
///
80
/// ```no_run
81
/// # use sqlrite::Connection;
82
/// let mut conn = Connection::open("foo.sqlrite")?;
83
/// conn.execute("BEGIN")?;
84
/// conn.execute("INSERT INTO users (name) VALUES ('alice')")?;
85
/// conn.execute("INSERT INTO users (name) VALUES ('bob')")?;
86
/// conn.execute("COMMIT")?;
87
/// # Ok::<(), sqlrite::SQLRiteError>(())
88
/// ```
89
///
90
/// ## Multiple connections (Phase 10.1)
91
///
92
/// `Connection` is a thin handle over an `Arc<Mutex<Database>>`. Call
93
/// [`Connection::connect`] to mint a sibling handle that shares the
94
/// same backing `Database` — typically one per worker thread. Today
95
/// every operation still serializes through the single mutex (and the
96
/// pager's exclusive flock between processes), so the headline
97
/// behaviour change is that callers can hold and address the same DB
98
/// from more than one thread without wrapping the whole `Connection`
99
/// in a `Mutex` themselves. `BEGIN CONCURRENT` and snapshot-isolated
100
/// reads land in subsequent Phase 10 sub-phases.
101
///
102
/// `Connection` is `Send + Sync`. The recommended pattern is one
103
/// connection per thread (clone via `connect()`); statements still
104
/// borrow `&mut Connection`, so a single connection isn't suitable
105
/// for true concurrent statement execution.
106
pub struct Connection {
107
    /// Shared engine state. Mints sibling connections via
108
    /// [`Connection::connect`] without copying the in-memory tables
109
    /// or the long-lived pager.
110
    inner: Arc<Mutex<Database>>,
111
    /// SQLR-23 — small SQL→cached-plan LRU. Keyed by the verbatim SQL
112
    /// string the caller passed to `prepare_cached`. Stored as a
113
    /// `VecDeque` rather than a HashMap+linked-list because the
114
    /// expected capacity is small (default 16) — linear scan is fine
115
    /// and the implementation stays dependency-free.
116
    ///
117
    /// Per-connection (not shared with sibling handles) — each thread
118
    /// gets its own LRU so cache-mutation never crosses a thread
119
    /// boundary.
120
    prep_cache: VecDeque<(String, Arc<CachedPlan>)>,
121
    prep_cache_cap: usize,
122
}
123

124
impl Connection {
125
    /// Opens (or creates) a database file for read-write access.
126
    ///
127
    /// If the file doesn't exist, an empty one is materialized with the
128
    /// current format version. Takes an exclusive advisory lock on the
129
    /// file and its `-wal` sidecar; returns `Err` if either is already
130
    /// locked by another process.
131
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
1✔
132
        let path = path.as_ref();
2✔
133
        let db_name = path
×
134
            .file_stem()
135
            .and_then(|s| s.to_str())
3✔
136
            .unwrap_or("db")
137
            .to_string();
138
        let db = if path.exists() {
3✔
139
            open_database_with_mode(path, db_name, AccessMode::ReadWrite)?
2✔
140
        } else {
141
            // Fresh file: materialize on disk and keep the attached
142
            // pager. Setting `source_path` before `save_database` lets
143
            // its `same_path` branch create the pager and stash it
144
            // back on the Database — no reopen needed (and trying to
145
            // reopen here would hit the file's own lock).
146
            let mut fresh = Database::new(db_name);
1✔
147
            fresh.source_path = Some(path.to_path_buf());
2✔
148
            save_database(&mut fresh, path)?;
1✔
149
            fresh
1✔
150
        };
151
        Ok(Self::wrap(db))
2✔
152
    }
153

154
    /// Opens an existing database file for read-only access. Takes a
155
    /// shared advisory lock, so multiple read-only connections can
156
    /// coexist on the same file; any open writer excludes them.
157
    /// Mutating statements return `cannot execute: database is opened
158
    /// read-only`.
159
    pub fn open_read_only<P: AsRef<Path>>(path: P) -> Result<Self> {
1✔
160
        let path = path.as_ref();
2✔
161
        let db_name = path
×
162
            .file_stem()
163
            .and_then(|s| s.to_str())
3✔
164
            .unwrap_or("db")
165
            .to_string();
166
        let db = open_database_with_mode(path, db_name, AccessMode::ReadOnly)?;
1✔
167
        Ok(Self::wrap(db))
2✔
168
    }
169

170
    /// Opens a transient in-memory database. No file is touched and no
171
    /// locks are taken; state lives for the lifetime of the
172
    /// `Connection` and is discarded on drop.
173
    pub fn open_in_memory() -> Result<Self> {
1✔
174
        Ok(Self::wrap(Database::new("memdb".to_string())))
1✔
175
    }
176

177
    fn wrap(db: Database) -> Self {
1✔
178
        Self {
179
            inner: Arc::new(Mutex::new(db)),
1✔
180
            prep_cache: VecDeque::new(),
1✔
181
            prep_cache_cap: DEFAULT_PREP_CACHE_CAP,
182
        }
183
    }
184

185
    /// Phase 10.1 — mints another `Connection` sharing the same
186
    /// backing `Database`. Hand the returned handle to a separate
187
    /// thread to address the same in-memory tables and persistent
188
    /// pager from there.
189
    ///
190
    /// The new handle starts with an empty prepared-statement cache
191
    /// (caches are per-handle, by design). Inherits the parent's
192
    /// `prepare_cached` capacity. Concurrent operations still
193
    /// serialize through the engine's internal lock and the pager's
194
    /// existing single-writer rule — a true multi-writer story
195
    /// arrives with `BEGIN CONCURRENT` in Phase 10.4.
196
    ///
197
    /// ```no_run
198
    /// # use sqlrite::Connection;
199
    /// let mut primary = Connection::open("foo.sqlrite")?;
200
    /// let secondary = primary.connect();
201
    /// std::thread::spawn(move || {
202
    ///     let mut conn = secondary;
203
    ///     conn.execute("INSERT INTO t (x) VALUES (1)").unwrap();
204
    /// })
205
    /// .join()
206
    /// .unwrap();
207
    /// # Ok::<(), sqlrite::SQLRiteError>(())
208
    /// ```
209
    pub fn connect(&self) -> Self {
1✔
210
        Self {
211
            inner: Arc::clone(&self.inner),
1✔
212
            prep_cache: VecDeque::new(),
1✔
213
            prep_cache_cap: self.prep_cache_cap,
1✔
214
        }
215
    }
216

217
    /// Phase 10.1 — number of `Connection` handles currently sharing
218
    /// this database (this handle plus every live `connect()`
219
    /// descendant). Useful for diagnostics and tests; no semantic
220
    /// guarantee beyond that.
221
    pub fn handle_count(&self) -> usize {
1✔
222
        Arc::strong_count(&self.inner)
1✔
223
    }
224

225
    /// Locks the shared `Database` and returns the guard. Internal
226
    /// helper — every public method that needs `&mut Database` calls
227
    /// this. The lock is released when the guard drops, so callers
228
    /// must keep the guard alive for the duration of the engine call
229
    /// (typically by binding it to a local).
230
    fn lock(&self) -> MutexGuard<'_, Database> {
1✔
231
        // `unwrap` propagates a panic from another thread that held
232
        // the lock — there's no engine-level recovery story for a
233
        // poisoned `Database` (the in-memory tables would be in an
234
        // unknown state), so failing fast is the right behaviour.
235
        self.inner
1✔
236
            .lock()
237
            .unwrap_or_else(|e| panic!("sqlrite: database mutex poisoned: {e}"))
1✔
238
    }
239

240
    /// Parses and executes one SQL statement. For DDL (`CREATE TABLE`,
241
    /// `CREATE INDEX`), DML (`INSERT`, `UPDATE`, `DELETE`) and
242
    /// transaction control (`BEGIN`, `COMMIT`, `ROLLBACK`). Returns
243
    /// the status message the engine produced (e.g.
244
    /// `"INSERT Statement executed."`).
245
    ///
246
    /// For `SELECT`, `execute` works but discards the row data and
247
    /// just returns the rendered status — use [`Connection::prepare`]
248
    /// and [`Statement::query`] to iterate typed rows.
249
    pub fn execute(&mut self, sql: &str) -> Result<String> {
1✔
250
        let mut db = self.lock();
1✔
251
        crate::sql::process_command(sql, &mut db)
2✔
252
    }
253

254
    /// Prepares a statement for repeated execution or row iteration.
255
    /// SQLR-23: the SQL is parsed once at prepare time (sqlparser walk
256
    /// plus placeholder rewriting), and the resulting AST is cached
257
    /// on the [`Statement`] for re-execution without further parsing.
258
    ///
259
    /// Use [`Statement::query`] / [`Statement::run`] for unbound
260
    /// execution, or [`Statement::query_with_params`] /
261
    /// [`Statement::execute_with_params`] to substitute `?`
262
    /// placeholders.
263
    pub fn prepare<'c>(&'c mut self, sql: &str) -> Result<Statement<'c>> {
1✔
264
        let plan = Arc::new(CachedPlan::compile(sql)?);
1✔
265
        Ok(Statement { conn: self, plan })
1✔
266
    }
267

268
    /// Same as [`Connection::prepare`], but consults a small
269
    /// per-connection LRU first. SQLR-23 — for hot statements
270
    /// (the body of an INSERT loop, a frequently-rerun lookup) the
271
    /// sqlparser walk is amortized to once across the connection's
272
    /// lifetime, not once per `prepare()`.
273
    ///
274
    /// Default cache capacity is 16; tune with
275
    /// [`Connection::set_prepared_cache_capacity`].
276
    pub fn prepare_cached<'c>(&'c mut self, sql: &str) -> Result<Statement<'c>> {
1✔
277
        // Lookup-or-insert. Found entries are also moved to the back
278
        // (most-recently-used) so capacity-eviction runs LRU.
279
        let plan = if let Some(pos) = self.prep_cache.iter().position(|(k, _)| k == sql) {
4✔
280
            let (k, v) = self.prep_cache.remove(pos).unwrap();
1✔
281
            self.prep_cache.push_back((k, Arc::clone(&v)));
1✔
282
            v
1✔
283
        } else {
284
            let plan = Arc::new(CachedPlan::compile(sql)?);
1✔
285
            self.prep_cache
1✔
286
                .push_back((sql.to_string(), Arc::clone(&plan)));
2✔
287
            while self.prep_cache.len() > self.prep_cache_cap {
1✔
288
                self.prep_cache.pop_front();
2✔
289
            }
290
            plan
1✔
291
        };
292
        Ok(Statement { conn: self, plan })
1✔
293
    }
294

295
    /// SQLR-23 — sets the maximum number of cached prepared plans
296
    /// (matches `prepare_cached`'s default 16). Reducing below the
297
    /// current size evicts the oldest entries; setting to 0 disables
298
    /// caching but `prepare_cached` still works (it just always
299
    /// re-parses).
300
    pub fn set_prepared_cache_capacity(&mut self, cap: usize) {
1✔
301
        self.prep_cache_cap = cap;
1✔
302
        while self.prep_cache.len() > cap {
1✔
303
            self.prep_cache.pop_front();
×
304
        }
305
    }
306

307
    /// SQLR-23 — current number of plans held by the prepared-statement
308
    /// cache. Useful for tests / introspection; not load-bearing for
309
    /// the public API.
310
    pub fn prepared_cache_len(&self) -> usize {
1✔
311
        self.prep_cache.len()
1✔
312
    }
313

314
    /// Returns `true` while a `BEGIN … COMMIT/ROLLBACK` block is open
315
    /// against this connection.
316
    pub fn in_transaction(&self) -> bool {
1✔
317
        self.lock().in_transaction()
1✔
318
    }
319

320
    /// Returns the current auto-VACUUM threshold (SQLR-10). After a
321
    /// page-releasing DDL (DROP TABLE / DROP INDEX / ALTER TABLE DROP
322
    /// COLUMN) commits, the engine compacts the file in place if the
323
    /// freelist exceeds this fraction of `page_count`. New connections
324
    /// default to `Some(0.25)` (SQLite parity); `None` means the
325
    /// trigger is disabled. See [`Connection::set_auto_vacuum_threshold`].
326
    pub fn auto_vacuum_threshold(&self) -> Option<f32> {
1✔
327
        self.lock().auto_vacuum_threshold()
1✔
328
    }
329

330
    /// Sets the auto-VACUUM threshold (SQLR-10). `Some(t)` with `t` in
331
    /// `0.0..=1.0` arms the trigger; `None` disables it. Values outside
332
    /// `0.0..=1.0` (or NaN / infinite) return a typed error rather than
333
    /// silently saturating. The setting is per-database runtime state —
334
    /// closing the last connection to a database drops it; new
335
    /// connections start at the default `Some(0.25)`.
336
    ///
337
    /// Calling this on an in-memory or read-only database is allowed
338
    /// (it just won't fire — there's nothing to compact / no writes
339
    /// will reach the trigger).
340
    pub fn set_auto_vacuum_threshold(&mut self, threshold: Option<f32>) -> Result<()> {
1✔
341
        self.lock().set_auto_vacuum_threshold(threshold)
1✔
342
    }
343

344
    /// Returns `true` if the connection was opened read-only. Mutating
345
    /// statements on a read-only connection return a typed error.
346
    pub fn is_read_only(&self) -> bool {
1✔
347
        self.lock().is_read_only()
1✔
348
    }
349

350
    /// Escape hatch for advanced callers — locks the shared `Database`
351
    /// and hands back the guard. Not part of the stable API; will move
352
    /// or change as Phase 10's MVCC sub-phases land.
353
    ///
354
    /// Bind the guard to a local before calling functions that take
355
    /// `&Database`:
356
    ///
357
    /// ```no_run
358
    /// # use sqlrite::Connection;
359
    /// # fn use_db(_d: &sqlrite::Database) {}
360
    /// let conn = Connection::open_in_memory()?;
361
    /// let db = conn.database();
362
    /// use_db(&db);
363
    /// # Ok::<(), sqlrite::SQLRiteError>(())
364
    /// ```
365
    #[doc(hidden)]
366
    pub fn database(&self) -> MutexGuard<'_, Database> {
1✔
367
        self.lock()
1✔
368
    }
369

370
    #[doc(hidden)]
NEW
371
    pub fn database_mut(&mut self) -> MutexGuard<'_, Database> {
×
NEW
372
        self.lock()
×
373
    }
374
}
375

376
impl std::fmt::Debug for Connection {
377
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
NEW
378
        let db = self.lock();
×
379
        f.debug_struct("Connection")
×
NEW
380
            .field("in_transaction", &db.in_transaction())
×
NEW
381
            .field("read_only", &db.is_read_only())
×
NEW
382
            .field("tables", &db.tables.len())
×
UNCOV
383
            .field("prep_cache_len", &self.prep_cache.len())
×
NEW
384
            .field("handles", &Arc::strong_count(&self.inner))
×
385
            .finish()
386
    }
387
}
388

389
/// SQLR-23 — the parse-once-execute-many representation. Built by
390
/// `CachedPlan::compile` (sqlparser walk + placeholder rewriting +
391
/// SELECT narrowing) and shared between every `Statement` that hits
392
/// the same SQL string in `prepare_cached`.
393
#[derive(Debug)]
394
struct CachedPlan {
395
    /// Original SQL — kept for diagnostic output.
396
    #[allow(dead_code)]
397
    sql: String,
398
    /// AST after `?` → `?N` placeholder rewriting. Cloned per execute
399
    /// so the substitution pass leaves the cached copy intact.
400
    ast: AstStatement,
401
    /// Total `?` placeholder count in the source SQL. Strict bind
402
    /// validation in `query_with_params` / `execute_with_params`
403
    /// uses this.
404
    param_count: usize,
405
    /// SELECT narrowing — cached so `query()` doesn't redo the
406
    /// `SelectQuery::new` walk for unbound SELECTs. `None` for
407
    /// non-SELECT statements.
408
    select: Option<SelectQuery>,
409
}
410

411
impl CachedPlan {
412
    fn compile(sql: &str) -> Result<Self> {
1✔
413
        let dialect = SqlriteDialect::new();
1✔
414
        let mut ast = Parser::parse_sql(&dialect, sql).map_err(SQLRiteError::from)?;
1✔
415
        let Some(mut stmt) = ast.pop() else {
2✔
416
            return Err(SQLRiteError::General("no statement to prepare".to_string()));
×
417
        };
418
        if !ast.is_empty() {
2✔
419
            return Err(SQLRiteError::General(
1✔
420
                "prepare() accepts a single statement; found more than one".to_string(),
1✔
421
            ));
422
        }
423
        let param_count = rewrite_placeholders(&mut stmt);
2✔
424
        let select = match &stmt {
1✔
425
            AstStatement::Query(_) => Some(SelectQuery::new(&stmt)?),
2✔
426
            _ => None,
1✔
427
        };
428
        Ok(Self {
1✔
429
            sql: sql.to_string(),
1✔
430
            ast: stmt,
1✔
431
            param_count,
×
432
            select,
1✔
433
        })
434
    }
435
}
436

437
/// A prepared statement bound to a specific connection lifetime.
438
///
439
/// SQLR-23 — `Statement` carries the parsed AST (parsed exactly once
440
/// at prepare time), not just the raw SQL. `query` / `run` execute
441
/// against the cached AST; `query_with_params` / `execute_with_params`
442
/// clone the AST and substitute `?` placeholders before dispatch.
443
pub struct Statement<'c> {
444
    conn: &'c mut Connection,
445
    plan: Arc<CachedPlan>,
446
}
447

448
impl std::fmt::Debug for Statement<'_> {
449
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
450
        f.debug_struct("Statement")
×
451
            .field("sql", &self.plan.sql)
×
452
            .field("param_count", &self.plan.param_count)
×
453
            .field(
454
                "kind",
455
                &match self.plan.select {
×
456
                    Some(_) => "Select",
×
457
                    None => "Other",
×
458
                },
459
            )
460
            .finish()
461
    }
462
}
463

464
impl<'c> Statement<'c> {
465
    /// Number of `?` placeholders detected in the source SQL. Strict
466
    /// arity validation: passing a slice of a different length to
467
    /// `query_with_params` / `execute_with_params` returns a typed
468
    /// error.
469
    pub fn parameter_count(&self) -> usize {
1✔
470
        self.plan.param_count
1✔
471
    }
472

473
    /// Executes a prepared non-query statement. Equivalent to
474
    /// [`Connection::execute`] — included for parity with the
475
    /// typed-row `query()` so callers who want `Statement::run` /
476
    /// `Statement::query` symmetry get it.
477
    ///
478
    /// Errors if the prepared SQL contains `?` placeholders — use
479
    /// [`Statement::execute_with_params`] for those.
480
    pub fn run(&mut self) -> Result<String> {
1✔
481
        if self.plan.param_count > 0 {
1✔
482
            return Err(SQLRiteError::General(format!(
1✔
483
                "statement has {} `?` placeholder(s); call execute_with_params()",
×
484
                self.plan.param_count
1✔
485
            )));
486
        }
487
        let ast = self.plan.ast.clone();
×
NEW
488
        let mut db = self.conn.lock();
×
NEW
489
        process_ast_with_render(ast, &mut db).map(|o| o.status)
×
490
    }
491

492
    /// SQLR-23 — executes a prepared non-SELECT statement after binding
493
    /// `?` placeholders to `params` (positional, in source order).
494
    ///
495
    /// Use this for parameterized INSERT / UPDATE / DELETE — the
496
    /// substitution clones the cached AST, fills in the `?` slots
497
    /// from `params`, and dispatches without re-running sqlparser.
498
    /// For SELECT, prefer [`Statement::query_with_params`].
499
    pub fn execute_with_params(&mut self, params: &[Value]) -> Result<String> {
1✔
500
        self.check_arity(params)?;
1✔
501
        let mut ast = self.plan.ast.clone();
1✔
502
        if !params.is_empty() {
2✔
503
            substitute_params(&mut ast, params)?;
2✔
504
        }
505
        let mut db = self.conn.lock();
2✔
506
        process_ast_with_render(ast, &mut db).map(|o| o.status)
3✔
507
    }
508

509
    /// Runs a SELECT and returns a [`Rows`] iterator over typed rows.
510
    /// Errors if the prepared statement isn't a SELECT.
511
    ///
512
    /// SQLR-23 — uses the SELECT narrowing cached at prepare time;
513
    /// no per-call sqlparser walk. Errors if the prepared SQL
514
    /// contains `?` placeholders — use [`Statement::query_with_params`]
515
    /// for those.
516
    pub fn query(&self) -> Result<Rows> {
1✔
517
        if self.plan.param_count > 0 {
1✔
518
            return Err(SQLRiteError::General(format!(
1✔
519
                "statement has {} `?` placeholder(s); call query_with_params()",
×
520
                self.plan.param_count
1✔
521
            )));
522
        }
523
        let Some(sq) = self.plan.select.as_ref() else {
2✔
524
            return Err(SQLRiteError::General(
1✔
525
                "query() only works on SELECT statements; use run() for DDL/DML".to_string(),
1✔
526
            ));
527
        };
528
        let db = self.conn.lock();
1✔
529
        let result = execute_select_rows(sq.clone(), &db)?;
2✔
530
        Ok(Rows {
1✔
531
            columns: result.columns,
1✔
532
            rows: result.rows.into_iter(),
1✔
533
        })
534
    }
535

536
    /// SQLR-23 — runs a SELECT and returns a [`Rows`] iterator after
537
    /// binding `?` placeholders to `params`. Positional, source-order
538
    /// indexing — `params[0]` is `?1`, `params[1]` is `?2`, etc.
539
    ///
540
    /// Vector parameters (`Value::Vector(...)`) substitute as the
541
    /// in-band bracket-array shape the executor recognizes, so a
542
    /// bound query vector still triggers the HNSW probe optimizer
543
    /// (Phase 7d.2 KNN shortcut).
544
    pub fn query_with_params(&self, params: &[Value]) -> Result<Rows> {
1✔
545
        self.check_arity(params)?;
1✔
546
        if self.plan.select.is_none() {
1✔
547
            return Err(SQLRiteError::General(
×
548
                "query_with_params() only works on SELECT statements; use execute_with_params() \
×
549
                 for DDL/DML"
×
550
                    .to_string(),
×
551
            ));
552
        }
553
        // Re-narrow against the substituted AST. The narrow walk is
554
        // cheap (it pulls projection/WHERE/ORDER BY into typed
555
        // structs), and rerunning it ensures the substituted literals
556
        // (e.g. a bracket-array vector) flow through `SelectQuery`.
557
        let mut ast = self.plan.ast.clone();
1✔
558
        if !params.is_empty() {
2✔
559
            substitute_params(&mut ast, params)?;
2✔
560
        }
561
        let sq = SelectQuery::new(&ast)?;
2✔
562
        let db = self.conn.lock();
2✔
563
        let result = execute_select_rows(sq, &db)?;
2✔
564
        Ok(Rows {
1✔
565
            columns: result.columns,
1✔
566
            rows: result.rows.into_iter(),
1✔
567
        })
568
    }
569

570
    fn check_arity(&self, params: &[Value]) -> Result<()> {
1✔
571
        if params.len() != self.plan.param_count {
1✔
572
            return Err(SQLRiteError::General(format!(
2✔
573
                "expected {} parameter{}, got {}",
×
574
                self.plan.param_count,
1✔
575
                if self.plan.param_count == 1 { "" } else { "s" },
2✔
576
                params.len()
1✔
577
            )));
578
        }
579
        Ok(())
1✔
580
    }
581

582
    /// Column names this statement will produce, in projection order.
583
    /// `None` for non-SELECT statements.
584
    pub fn column_names(&self) -> Option<Vec<String>> {
×
585
        match &self.plan.select {
×
586
            Some(_) => {
×
587
                // We can't know the concrete column list without
588
                // running the query (it depends on the table schema
589
                // and the projection). Callers who need it up front
590
                // should call query() and inspect Rows::columns.
591
                None
×
592
            }
593
            None => None,
×
594
        }
595
    }
596
}
597

598
/// Iterator of typed [`Row`] values produced by a `SELECT` query.
599
///
600
/// Today `Rows` is backed by an eager `Vec<Vec<Value>>` — the cursor
601
/// abstraction in Phase 5a's follow-up will swap this for a lazy
602
/// walker that streams rows off the B-Tree without materializing
603
/// them upfront. The `Rows::next` API is designed for that: it
604
/// returns `Result<Option<Row>>` rather than `Option<Result<Row>>`,
605
/// so a mid-stream I/O error surfaces cleanly.
606
pub struct Rows {
607
    columns: Vec<String>,
608
    rows: std::vec::IntoIter<Vec<Value>>,
609
}
610

611
impl std::fmt::Debug for Rows {
612
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
613
        f.debug_struct("Rows")
×
614
            .field("columns", &self.columns)
×
615
            .field("remaining", &self.rows.len())
×
616
            .finish()
617
    }
618
}
619

620
impl Rows {
621
    /// Column names in projection order.
622
    pub fn columns(&self) -> &[String] {
1✔
623
        &self.columns
1✔
624
    }
625

626
    /// Advances to the next row. Returns `Ok(None)` when the query is
627
    /// exhausted, `Ok(Some(row))` otherwise, `Err(_)` on an I/O or
628
    /// decode failure (relevant once Phase 5a's cursor work lands —
629
    /// today this is always `Ok(_)`).
630
    pub fn next(&mut self) -> Result<Option<Row<'_>>> {
1✔
631
        Ok(self.rows.next().map(|values| Row {
4✔
632
            columns: &self.columns,
1✔
633
            values,
1✔
634
        }))
635
    }
636

637
    /// Collects every remaining row into a `Vec<Row>`. Convenient for
638
    /// small result sets; avoid on large queries — that's what the
639
    /// streaming [`Rows::next`] API is for.
640
    pub fn collect_all(mut self) -> Result<Vec<OwnedRow>> {
1✔
641
        let mut out = Vec::new();
2✔
642
        while let Some(r) = self.next()? {
2✔
643
            out.push(r.to_owned_row());
2✔
644
        }
645
        Ok(out)
1✔
646
    }
647
}
648

649
/// A single row borrowed from a [`Rows`] iterator. Lives only as long
650
/// as the iterator; call `Row::to_owned_row` to detach it if you need
651
/// to keep it past the next `next()` call.
652
pub struct Row<'r> {
653
    columns: &'r [String],
654
    values: Vec<Value>,
655
}
656

657
impl<'r> Row<'r> {
658
    /// Value at column index `idx`. Returns a clean error if out of
659
    /// bounds or the type conversion fails.
660
    pub fn get<T: FromValue>(&self, idx: usize) -> Result<T> {
3✔
661
        let v = self.values.get(idx).ok_or_else(|| {
5✔
662
            SQLRiteError::General(format!(
1✔
663
                "column index {idx} out of bounds (row has {} columns)",
×
664
                self.values.len()
1✔
665
            ))
666
        })?;
667
        T::from_value(v)
3✔
668
    }
669

670
    /// Value at column named `name`. Case-sensitive.
671
    pub fn get_by_name<T: FromValue>(&self, name: &str) -> Result<T> {
2✔
672
        let idx = self
4✔
673
            .columns
×
674
            .iter()
2✔
675
            .position(|c| c == name)
6✔
676
            .ok_or_else(|| SQLRiteError::General(format!("no column named '{name}' in row")))?;
2✔
677
        self.get(idx)
2✔
678
    }
679

680
    /// Column names for this row.
681
    pub fn columns(&self) -> &[String] {
×
682
        self.columns
×
683
    }
684

685
    /// Detaches from the parent `Rows` iterator. Useful when you want
686
    /// to keep rows past the next `Rows::next()` call.
687
    pub fn to_owned_row(&self) -> OwnedRow {
1✔
688
        OwnedRow {
689
            columns: self.columns.to_vec(),
1✔
690
            values: self.values.clone(),
1✔
691
        }
692
    }
693
}
694

695
/// A row detached from the `Rows` iterator — owns its data, no
696
/// borrow ties it to the parent iterator.
697
#[derive(Debug, Clone)]
698
pub struct OwnedRow {
699
    pub columns: Vec<String>,
700
    pub values: Vec<Value>,
701
}
702

703
impl OwnedRow {
704
    pub fn get<T: FromValue>(&self, idx: usize) -> Result<T> {
2✔
705
        let v = self.values.get(idx).ok_or_else(|| {
2✔
706
            SQLRiteError::General(format!(
×
707
                "column index {idx} out of bounds (row has {} columns)",
×
708
                self.values.len()
×
709
            ))
710
        })?;
711
        T::from_value(v)
2✔
712
    }
713

714
    pub fn get_by_name<T: FromValue>(&self, name: &str) -> Result<T> {
×
715
        let idx = self
×
716
            .columns
×
717
            .iter()
×
718
            .position(|c| c == name)
×
719
            .ok_or_else(|| SQLRiteError::General(format!("no column named '{name}' in row")))?;
×
720
        self.get(idx)
×
721
    }
722
}
723

724
/// Conversion from SQLRite's internal [`Value`] enum into a typed Rust
725
/// value. Implementations cover the common built-ins — `i64`, `f64`,
726
/// `String`, `bool`, and `Option<T>` for nullable columns. Extend on
727
/// demand.
728
pub trait FromValue: Sized {
729
    fn from_value(v: &Value) -> Result<Self>;
730
}
731

732
impl FromValue for i64 {
733
    fn from_value(v: &Value) -> Result<Self> {
1✔
734
        match v {
1✔
735
            Value::Integer(n) => Ok(*n),
1✔
736
            Value::Null => Err(SQLRiteError::General(
×
737
                "expected Integer, got NULL".to_string(),
×
738
            )),
739
            other => Err(SQLRiteError::General(format!(
×
740
                "cannot convert {other:?} to i64"
×
741
            ))),
742
        }
743
    }
744
}
745

746
impl FromValue for f64 {
747
    fn from_value(v: &Value) -> Result<Self> {
×
748
        match v {
×
749
            Value::Real(f) => Ok(*f),
×
750
            Value::Integer(n) => Ok(*n as f64),
×
751
            Value::Null => Err(SQLRiteError::General("expected Real, got NULL".to_string())),
×
752
            other => Err(SQLRiteError::General(format!(
×
753
                "cannot convert {other:?} to f64"
×
754
            ))),
755
        }
756
    }
757
}
758

759
impl FromValue for String {
760
    fn from_value(v: &Value) -> Result<Self> {
1✔
761
        match v {
1✔
762
            Value::Text(s) => Ok(s.clone()),
1✔
763
            Value::Null => Err(SQLRiteError::General("expected Text, got NULL".to_string())),
×
764
            other => Err(SQLRiteError::General(format!(
×
765
                "cannot convert {other:?} to String"
×
766
            ))),
767
        }
768
    }
769
}
770

771
impl FromValue for bool {
772
    fn from_value(v: &Value) -> Result<Self> {
×
773
        match v {
×
774
            Value::Bool(b) => Ok(*b),
×
775
            Value::Integer(n) => Ok(*n != 0),
×
776
            Value::Null => Err(SQLRiteError::General("expected Bool, got NULL".to_string())),
×
777
            other => Err(SQLRiteError::General(format!(
×
778
                "cannot convert {other:?} to bool"
×
779
            ))),
780
        }
781
    }
782
}
783

784
/// Nullable columns: `Option<T>` maps `NULL → None` and everything else
785
/// through the inner type's `FromValue` impl.
786
impl<T: FromValue> FromValue for Option<T> {
787
    fn from_value(v: &Value) -> Result<Self> {
1✔
788
        match v {
1✔
789
            Value::Null => Ok(None),
1✔
790
            other => Ok(Some(T::from_value(other)?)),
×
791
        }
792
    }
793
}
794

795
/// Identity impl so `row.get::<_, Value>(0)` works when you want
796
/// untyped access.
797
impl FromValue for Value {
798
    fn from_value(v: &Value) -> Result<Self> {
×
799
        Ok(v.clone())
×
800
    }
801
}
802

803
#[cfg(test)]
804
mod tests {
805
    use super::*;
806

807
    fn tmp_path(name: &str) -> std::path::PathBuf {
1✔
808
        let mut p = std::env::temp_dir();
1✔
809
        let pid = std::process::id();
2✔
810
        let nanos = std::time::SystemTime::now()
2✔
811
            .duration_since(std::time::UNIX_EPOCH)
1✔
812
            .map(|d| d.as_nanos())
3✔
813
            .unwrap_or(0);
814
        p.push(format!("sqlrite-conn-{pid}-{nanos}-{name}.sqlrite"));
1✔
815
        p
1✔
816
    }
817

818
    fn cleanup(path: &std::path::Path) {
1✔
819
        let _ = std::fs::remove_file(path);
1✔
820
        let mut wal = path.as_os_str().to_owned();
1✔
821
        wal.push("-wal");
1✔
822
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
823
    }
824

825
    #[test]
826
    fn in_memory_roundtrip() {
3✔
827
        let mut conn = Connection::open_in_memory().unwrap();
1✔
828
        conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER);")
1✔
829
            .unwrap();
830
        conn.execute("INSERT INTO users (name, age) VALUES ('alice', 30);")
1✔
831
            .unwrap();
832
        conn.execute("INSERT INTO users (name, age) VALUES ('bob', 25);")
1✔
833
            .unwrap();
834

835
        let stmt = conn.prepare("SELECT id, name, age FROM users;").unwrap();
1✔
836
        let mut rows = stmt.query().unwrap();
2✔
837
        assert_eq!(rows.columns(), &["id", "name", "age"]);
2✔
838
        let mut collected: Vec<(i64, String, i64)> = Vec::new();
1✔
839
        while let Some(row) = rows.next().unwrap() {
2✔
840
            collected.push((
1✔
841
                row.get::<i64>(0).unwrap(),
2✔
842
                row.get::<String>(1).unwrap(),
1✔
843
                row.get::<i64>(2).unwrap(),
2✔
844
            ));
845
        }
846
        assert_eq!(collected.len(), 2);
2✔
847
        assert!(collected.iter().any(|(_, n, a)| n == "alice" && *a == 30));
3✔
848
        assert!(collected.iter().any(|(_, n, a)| n == "bob" && *a == 25));
3✔
849
    }
850

851
    #[test]
852
    fn file_backed_persists_across_connections() {
3✔
853
        let path = tmp_path("persist");
1✔
854
        {
855
            let mut c1 = Connection::open(&path).unwrap();
2✔
856
            c1.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT);")
1✔
857
                .unwrap();
858
            c1.execute("INSERT INTO items (label) VALUES ('one');")
1✔
859
                .unwrap();
860
        }
861
        {
862
            let mut c2 = Connection::open(&path).unwrap();
1✔
863
            let stmt = c2.prepare("SELECT label FROM items;").unwrap();
2✔
864
            let mut rows = stmt.query().unwrap();
2✔
865
            let first = rows.next().unwrap().expect("one row");
2✔
866
            assert_eq!(first.get::<String>(0).unwrap(), "one");
2✔
867
            assert!(rows.next().unwrap().is_none());
1✔
868
        }
869
        cleanup(&path);
1✔
870
    }
871

872
    #[test]
873
    fn read_only_connection_rejects_writes() {
3✔
874
        let path = tmp_path("ro_reject");
1✔
875
        {
876
            let mut c = Connection::open(&path).unwrap();
2✔
877
            c.execute("CREATE TABLE t (id INTEGER PRIMARY KEY);")
1✔
878
                .unwrap();
879
            c.execute("INSERT INTO t (id) VALUES (1);").unwrap();
1✔
880
        } // writer drops → releases exclusive lock
1✔
881

882
        let mut ro = Connection::open_read_only(&path).unwrap();
1✔
883
        assert!(ro.is_read_only());
2✔
884
        let err = ro.execute("INSERT INTO t (id) VALUES (2);").unwrap_err();
1✔
885
        assert!(format!("{err}").contains("read-only"));
2✔
886
        cleanup(&path);
1✔
887
    }
888

889
    #[test]
890
    fn transactions_work_through_connection() {
3✔
891
        let mut conn = Connection::open_in_memory().unwrap();
1✔
892
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, x INTEGER);")
1✔
893
            .unwrap();
894
        conn.execute("INSERT INTO t (x) VALUES (1);").unwrap();
1✔
895

896
        conn.execute("BEGIN;").unwrap();
1✔
897
        assert!(conn.in_transaction());
1✔
898
        conn.execute("INSERT INTO t (x) VALUES (2);").unwrap();
1✔
899
        conn.execute("ROLLBACK;").unwrap();
1✔
900
        assert!(!conn.in_transaction());
1✔
901

902
        let stmt = conn.prepare("SELECT x FROM t;").unwrap();
2✔
903
        let rows = stmt.query().unwrap().collect_all().unwrap();
2✔
904
        assert_eq!(rows.len(), 1);
2✔
905
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 1);
1✔
906
    }
907

908
    #[test]
909
    fn get_by_name_works() {
3✔
910
        let mut conn = Connection::open_in_memory().unwrap();
1✔
911
        conn.execute("CREATE TABLE t (a INTEGER, b TEXT);").unwrap();
2✔
912
        conn.execute("INSERT INTO t (a, b) VALUES (42, 'hello');")
1✔
913
            .unwrap();
914

915
        let stmt = conn.prepare("SELECT a, b FROM t;").unwrap();
1✔
916
        let mut rows = stmt.query().unwrap();
2✔
917
        let row = rows.next().unwrap().unwrap();
2✔
918
        assert_eq!(row.get_by_name::<i64>("a").unwrap(), 42);
2✔
919
        assert_eq!(row.get_by_name::<String>("b").unwrap(), "hello");
1✔
920
    }
921

922
    #[test]
923
    fn null_column_maps_to_none() {
3✔
924
        let mut conn = Connection::open_in_memory().unwrap();
1✔
925
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT);")
1✔
926
            .unwrap();
927
        // id INTEGER PRIMARY KEY autoincrements; `note` is left unspecified.
928
        conn.execute("INSERT INTO t (id) VALUES (1);").unwrap();
1✔
929

930
        let stmt = conn.prepare("SELECT id, note FROM t;").unwrap();
1✔
931
        let mut rows = stmt.query().unwrap();
2✔
932
        let row = rows.next().unwrap().unwrap();
2✔
933
        assert_eq!(row.get::<i64>(0).unwrap(), 1);
2✔
934
        // note is NULL → Option<String> resolves to None.
935
        assert_eq!(row.get::<Option<String>>(1).unwrap(), None);
1✔
936
    }
937

938
    #[test]
939
    fn prepare_rejects_multiple_statements() {
3✔
940
        let mut conn = Connection::open_in_memory().unwrap();
1✔
941
        let err = conn.prepare("SELECT 1; SELECT 2;").unwrap_err();
2✔
942
        assert!(format!("{err}").contains("single statement"));
2✔
943
    }
944

945
    #[test]
946
    fn query_on_non_select_errors() {
3✔
947
        let mut conn = Connection::open_in_memory().unwrap();
1✔
948
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY);")
1✔
949
            .unwrap();
950
        let stmt = conn.prepare("INSERT INTO t VALUES (1);").unwrap();
1✔
951
        let err = stmt.query().unwrap_err();
2✔
952
        assert!(format!("{err}").contains("SELECT"));
2✔
953
    }
954

955
    /// SQLR-10: fresh connections expose the SQLite-parity 25% default,
956
    /// the setter validates its input, and `None` opts out cleanly.
957
    #[test]
958
    fn auto_vacuum_threshold_default_and_setter() {
3✔
959
        let mut conn = Connection::open_in_memory().unwrap();
1✔
960
        assert_eq!(
1✔
961
            conn.auto_vacuum_threshold(),
2✔
962
            Some(0.25),
963
            "fresh connection should ship with the SQLite-parity default"
964
        );
965

966
        conn.set_auto_vacuum_threshold(None).unwrap();
2✔
967
        assert_eq!(conn.auto_vacuum_threshold(), None);
1✔
968

969
        conn.set_auto_vacuum_threshold(Some(0.5)).unwrap();
1✔
970
        assert_eq!(conn.auto_vacuum_threshold(), Some(0.5));
1✔
971

972
        // Out-of-range values must be rejected with a typed error and
973
        // must not stomp the previously-set value.
974
        let err = conn.set_auto_vacuum_threshold(Some(1.5)).unwrap_err();
1✔
975
        assert!(
×
976
            format!("{err}").contains("auto_vacuum_threshold"),
3✔
977
            "expected typed range error, got: {err}"
978
        );
979
        assert_eq!(
1✔
980
            conn.auto_vacuum_threshold(),
1✔
981
            Some(0.5),
982
            "rejected setter call must not mutate the threshold"
983
        );
984
    }
985

986
    #[test]
987
    fn index_out_of_bounds_errors_cleanly() {
4✔
988
        let mut conn = Connection::open_in_memory().unwrap();
1✔
989
        conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY);")
1✔
990
            .unwrap();
991
        conn.execute("INSERT INTO t (a) VALUES (1);").unwrap();
1✔
992
        let stmt = conn.prepare("SELECT a FROM t;").unwrap();
1✔
993
        let mut rows = stmt.query().unwrap();
2✔
994
        let row = rows.next().unwrap().unwrap();
2✔
995
        let err = row.get::<i64>(99).unwrap_err();
2✔
996
        assert!(format!("{err}").contains("out of bounds"));
2✔
997
    }
998

999
    // -----------------------------------------------------------------
1000
    // SQLR-23 — prepared-statement plan cache + parameter binding
1001
    // -----------------------------------------------------------------
1002

1003
    #[test]
1004
    fn parameter_count_reflects_question_marks() {
3✔
1005
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1006
        conn.execute("CREATE TABLE t (a INTEGER, b TEXT);").unwrap();
2✔
1007
        let stmt = conn.prepare("SELECT a, b FROM t WHERE a = ?").unwrap();
1✔
1008
        assert_eq!(stmt.parameter_count(), 1);
2✔
1009
        let stmt = conn
1✔
1010
            .prepare("SELECT a, b FROM t WHERE a = ? AND b = ?")
1011
            .unwrap();
1012
        assert_eq!(stmt.parameter_count(), 2);
2✔
1013
        let stmt = conn.prepare("SELECT a FROM t").unwrap();
1✔
1014
        assert_eq!(stmt.parameter_count(), 0);
2✔
1015
    }
1016

1017
    #[test]
1018
    fn query_with_params_binds_scalars() {
3✔
1019
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1020
        conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT);")
1✔
1021
            .unwrap();
1022
        conn.execute("INSERT INTO t (a, b) VALUES (1, 'alice');")
1✔
1023
            .unwrap();
1024
        conn.execute("INSERT INTO t (a, b) VALUES (2, 'bob');")
1✔
1025
            .unwrap();
1026
        conn.execute("INSERT INTO t (a, b) VALUES (3, 'carol');")
1✔
1027
            .unwrap();
1028

1029
        let stmt = conn.prepare("SELECT b FROM t WHERE a = ?").unwrap();
1✔
1030
        let rows = stmt
1031
            .query_with_params(&[Value::Integer(2)])
1✔
1032
            .unwrap()
1033
            .collect_all()
1034
            .unwrap();
1035
        assert_eq!(rows.len(), 1);
2✔
1036
        assert_eq!(rows[0].get::<String>(0).unwrap(), "bob");
1✔
1037
    }
1038

1039
    #[test]
1040
    fn execute_with_params_binds_insert_values() {
3✔
1041
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1042
        conn.execute("CREATE TABLE t (a INTEGER, b TEXT);").unwrap();
2✔
1043

1044
        let mut stmt = conn.prepare("INSERT INTO t (a, b) VALUES (?, ?)").unwrap();
1✔
1045
        stmt.execute_with_params(&[Value::Integer(7), Value::Text("hi".into())])
1✔
1046
            .unwrap();
1047
        stmt.execute_with_params(&[Value::Integer(8), Value::Text("yo".into())])
1✔
1048
            .unwrap();
1049

1050
        let stmt = conn.prepare("SELECT a, b FROM t").unwrap();
1✔
1051
        let rows = stmt.query().unwrap().collect_all().unwrap();
2✔
1052
        assert_eq!(rows.len(), 2);
2✔
1053
        assert!(
×
1054
            rows.iter()
2✔
1055
                .any(|r| r.get::<i64>(0).unwrap() == 7 && r.get::<String>(1).unwrap() == "hi")
3✔
1056
        );
1057
        assert!(
×
1058
            rows.iter()
2✔
1059
                .any(|r| r.get::<i64>(0).unwrap() == 8 && r.get::<String>(1).unwrap() == "yo")
3✔
1060
        );
1061
    }
1062

1063
    #[test]
1064
    fn arity_mismatch_returns_clean_error() {
3✔
1065
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1066
        conn.execute("CREATE TABLE t (a INTEGER, b TEXT);").unwrap();
2✔
1067
        let stmt = conn
1✔
1068
            .prepare("SELECT * FROM t WHERE a = ? AND b = ?")
1069
            .unwrap();
1070
        let err = stmt.query_with_params(&[Value::Integer(1)]).unwrap_err();
2✔
1071
        assert!(format!("{err}").contains("expected 2 parameter"));
2✔
1072
    }
1073

1074
    #[test]
1075
    fn run_and_query_reject_when_placeholders_present() {
3✔
1076
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1077
        conn.execute("CREATE TABLE t (a INTEGER);").unwrap();
2✔
1078
        let mut stmt_select = conn.prepare("SELECT a FROM t WHERE a = ?").unwrap();
1✔
1079
        let err = stmt_select.query().unwrap_err();
2✔
1080
        assert!(format!("{err}").contains("query_with_params"));
2✔
1081
        let err = stmt_select.run().unwrap_err();
1✔
1082
        assert!(format!("{err}").contains("execute_with_params"));
2✔
1083
    }
1084

1085
    #[test]
1086
    fn null_param_compares_against_null() {
3✔
1087
        // a = NULL is *false* in SQL three-valued logic; binding NULL
1088
        // must match SQLite's behavior so callers can rely on the same
1089
        // semantics.
1090
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1091
        conn.execute("CREATE TABLE t (a INTEGER);").unwrap();
2✔
1092
        conn.execute("INSERT INTO t (a) VALUES (1);").unwrap();
1✔
1093
        let stmt = conn.prepare("SELECT a FROM t WHERE a = ?").unwrap();
1✔
1094
        let rows = stmt
1095
            .query_with_params(&[Value::Null])
1✔
1096
            .unwrap()
1097
            .collect_all()
1098
            .unwrap();
1099
        assert_eq!(rows.len(), 0);
2✔
1100
    }
1101

1102
    #[test]
1103
    fn vector_param_substitutes_through_select() {
3✔
1104
        // Non-HNSW path: a small VECTOR table + brute-force ORDER BY
1105
        // exercises the substitution into the ORDER BY expression
1106
        // and the bracket-array shape eval_expr_scope expects.
1107
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1108
        conn.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, e VECTOR(3));")
1✔
1109
            .unwrap();
1110
        conn.execute("INSERT INTO v (id, e) VALUES (1, [1.0, 0.0, 0.0]);")
1✔
1111
            .unwrap();
1112
        conn.execute("INSERT INTO v (id, e) VALUES (2, [0.0, 1.0, 0.0]);")
1✔
1113
            .unwrap();
1114
        conn.execute("INSERT INTO v (id, e) VALUES (3, [0.0, 0.0, 1.0]);")
1✔
1115
            .unwrap();
1116

1117
        let stmt = conn
1✔
1118
            .prepare("SELECT id FROM v ORDER BY vec_distance_l2(e, ?) ASC LIMIT 1")
1119
            .unwrap();
1120
        let rows = stmt
1121
            .query_with_params(&[Value::Vector(vec![1.0, 0.0, 0.0])])
2✔
1122
            .unwrap()
1123
            .collect_all()
1124
            .unwrap();
1125
        assert_eq!(rows.len(), 1);
1✔
1126
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 1);
1✔
1127
    }
1128

1129
    #[test]
1130
    fn prepare_cached_reuses_plans() {
4✔
1131
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1132
        conn.execute("CREATE TABLE t (a INTEGER);").unwrap();
2✔
1133
        for n in 1..=3 {
1✔
1134
            conn.execute(&format!("INSERT INTO t (a) VALUES ({n});"))
3✔
1135
                .unwrap();
1136
        }
1137

1138
        // First call populates the cache; second hits the same entry.
1139
        let _ = conn.prepare_cached("SELECT a FROM t WHERE a = ?").unwrap();
1✔
1140
        let _ = conn.prepare_cached("SELECT a FROM t WHERE a = ?").unwrap();
1✔
1141
        assert_eq!(conn.prepared_cache_len(), 1);
1✔
1142

1143
        // Distinct SQL widens the cache.
1144
        let _ = conn.prepare_cached("SELECT a FROM t").unwrap();
1✔
1145
        assert_eq!(conn.prepared_cache_len(), 2);
1✔
1146
    }
1147

1148
    #[test]
1149
    fn prepare_cached_evicts_when_over_capacity() {
3✔
1150
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1151
        conn.execute("CREATE TABLE t (a INTEGER);").unwrap();
2✔
1152
        conn.set_prepared_cache_capacity(2);
1✔
1153
        let _ = conn.prepare_cached("SELECT a FROM t").unwrap();
1✔
1154
        let _ = conn.prepare_cached("SELECT a FROM t WHERE a = ?").unwrap();
1✔
1155
        assert_eq!(conn.prepared_cache_len(), 2);
1✔
1156
        // Third distinct SQL evicts the oldest entry (the FROM-only SELECT).
1157
        let _ = conn.prepare_cached("SELECT a FROM t WHERE a > ?").unwrap();
1✔
1158
        assert_eq!(conn.prepared_cache_len(), 2);
1✔
1159
    }
1160

1161
    /// SQLR-23 — the headline VECTOR-binding case. With an HNSW index
1162
    /// attached, the optimizer hook recognizes
1163
    /// `ORDER BY vec_distance_l2(col, ?) LIMIT k` even when the second
1164
    /// arg is a bound parameter, because substitution lowers
1165
    /// `Value::Vector` into the same bracket-array shape an inline
1166
    /// `[…]` literal produces. Self-query: querying for one of the
1167
    /// corpus's own vectors must return that vector as the nearest.
1168
    #[test]
1169
    fn vector_bind_through_hnsw_optimizer() {
3✔
1170
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1171
        conn.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, e VECTOR(4));")
1✔
1172
            .unwrap();
1173
        let corpus: [(i64, [f32; 4]); 5] = [
1✔
1174
            (1, [1.0, 0.0, 0.0, 0.0]),
1✔
1175
            (2, [0.0, 1.0, 0.0, 0.0]),
1✔
1176
            (3, [0.0, 0.0, 1.0, 0.0]),
1✔
1177
            (4, [0.0, 0.0, 0.0, 1.0]),
1✔
1178
            (5, [0.5, 0.5, 0.5, 0.5]),
1✔
1179
        ];
1180
        for (id, vec) in corpus {
2✔
1181
            conn.execute(&format!(
2✔
1182
                "INSERT INTO v (id, e) VALUES ({id}, [{}, {}, {}, {}]);",
1183
                vec[0], vec[1], vec[2], vec[3]
1✔
1184
            ))
1185
            .unwrap();
1186
        }
1187
        conn.execute("CREATE INDEX v_hnsw ON v USING hnsw (e);")
1✔
1188
            .unwrap();
1189

1190
        let stmt = conn
1✔
1191
            .prepare("SELECT id FROM v ORDER BY vec_distance_l2(e, ?) ASC LIMIT 1")
1192
            .unwrap();
1193
        // Query with id=3's vector — expect id=3 back.
1194
        let rows = stmt
1195
            .query_with_params(&[Value::Vector(vec![0.0, 0.0, 1.0, 0.0])])
2✔
1196
            .unwrap()
1197
            .collect_all()
1198
            .unwrap();
1199
        assert_eq!(rows.len(), 1);
1✔
1200
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 3);
1✔
1201

1202
        // Query with id=1's vector — expect id=1.
1203
        let rows = stmt
1204
            .query_with_params(&[Value::Vector(vec![1.0, 0.0, 0.0, 0.0])])
1✔
1205
            .unwrap()
1206
            .collect_all()
1207
            .unwrap();
1208
        assert_eq!(rows.len(), 1);
1✔
1209
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 1);
1✔
1210
    }
1211

1212
    /// SQLR-28 — cosine probe: an HNSW index built `WITH (metric =
1213
    /// 'cosine')` must serve `ORDER BY vec_distance_cosine(col, [...])`
1214
    /// from the graph. Self-query: querying for one of the corpus's
1215
    /// own vectors must come back as the nearest under cosine
1216
    /// distance.
1217
    #[test]
1218
    fn cosine_self_query_through_hnsw_optimizer() {
3✔
1219
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1220
        conn.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, e VECTOR(4));")
1✔
1221
            .unwrap();
1222
        let corpus: [(i64, [f32; 4]); 5] = [
1✔
1223
            (1, [1.0, 0.0, 0.0, 0.0]),
1✔
1224
            (2, [0.0, 1.0, 0.0, 0.0]),
1✔
1225
            (3, [0.0, 0.0, 1.0, 0.0]),
1✔
1226
            (4, [0.0, 0.0, 0.0, 1.0]),
1✔
1227
            (5, [0.5, 0.5, 0.5, 0.5]),
1✔
1228
        ];
1229
        for (id, vec) in corpus {
2✔
1230
            conn.execute(&format!(
2✔
1231
                "INSERT INTO v (id, e) VALUES ({id}, [{}, {}, {}, {}]);",
1232
                vec[0], vec[1], vec[2], vec[3]
1✔
1233
            ))
1234
            .unwrap();
1235
        }
1236
        conn.execute("CREATE INDEX v_hnsw ON v USING hnsw (e) WITH (metric = 'cosine');")
1✔
1237
            .unwrap();
1238

1239
        // Self-query for id=2's vector — expected nearest under cosine
1240
        // distance is id=2 itself (cos distance 0).
1241
        let rows = conn
1✔
1242
            .prepare("SELECT id FROM v ORDER BY vec_distance_cosine(e, [0.0, 1.0, 0.0, 0.0]) ASC LIMIT 1")
1243
            .unwrap()
1244
            .query_with_params(&[])
1✔
1245
            .unwrap()
1246
            .collect_all()
1247
            .unwrap();
1248
        assert_eq!(rows.len(), 1);
1✔
1249
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 2);
1✔
1250
    }
1251

1252
    /// SQLR-28 — dot probe: same shape as the cosine test, but the
1253
    /// index is built `WITH (metric = 'dot')` and the query uses
1254
    /// `vec_distance_dot`. Confirms the third metric variant lights up
1255
    /// the graph shortcut, not just l2 / cosine.
1256
    #[test]
1257
    fn dot_self_query_through_hnsw_optimizer() {
3✔
1258
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1259
        conn.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, e VECTOR(3));")
1✔
1260
            .unwrap();
1261
        // Data: distinguishable magnitudes so the dot metric resolves
1262
        // a clear winner. `vec_distance_dot(a, b) = -(a·b)` — smaller
1263
        // (more negative) is closer.
1264
        let corpus: [(i64, [f32; 3]); 4] = [
1✔
1265
            (1, [1.0, 0.0, 0.0]),
1✔
1266
            (2, [2.0, 0.0, 0.0]),
1✔
1267
            (3, [0.0, 1.0, 0.0]),
1✔
1268
            (4, [0.0, 0.0, 1.0]),
1✔
1269
        ];
1270
        for (id, vec) in corpus {
2✔
1271
            conn.execute(&format!(
2✔
1272
                "INSERT INTO v (id, e) VALUES ({id}, [{}, {}, {}]);",
1273
                vec[0], vec[1], vec[2]
1✔
1274
            ))
1275
            .unwrap();
1276
        }
1277
        conn.execute("CREATE INDEX v_hnsw ON v USING hnsw (e) WITH (metric = 'dot');")
1✔
1278
            .unwrap();
1279

1280
        // Query [3, 0, 0]: dot products are 3, 6, 0, 0 → distances
1281
        // -3, -6, 0, 0. id=2 has the smallest (most negative) distance.
1282
        let rows = conn
1✔
1283
            .prepare("SELECT id FROM v ORDER BY vec_distance_dot(e, [3.0, 0.0, 0.0]) ASC LIMIT 1")
1284
            .unwrap()
1285
            .query_with_params(&[])
1✔
1286
            .unwrap()
1287
            .collect_all()
1288
            .unwrap();
1289
        assert_eq!(rows.len(), 1);
1✔
1290
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 2);
1✔
1291
    }
1292

1293
    /// SQLR-28 — metric mismatch must NOT take the graph shortcut.
1294
    /// An L2-built index queried with `vec_distance_cosine` falls
1295
    /// through to brute-force, which still returns the correct
1296
    /// answer. We confirm the answer is correct; the slow-path
1297
    /// behaviour itself is implicit (no error, no panic, no wrong
1298
    /// result), which is the user-visible contract that matters.
1299
    #[test]
1300
    fn metric_mismatch_falls_back_to_brute_force() {
3✔
1301
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1302
        conn.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, e VECTOR(2));")
1✔
1303
            .unwrap();
1304
        let half_sqrt2 = std::f32::consts::FRAC_1_SQRT_2;
1✔
1305
        let corpus: [(i64, [f32; 2]); 3] = [
1✔
1306
            (1, [1.0, 0.0]),
1✔
1307
            (2, [half_sqrt2, half_sqrt2]),
1✔
1308
            (3, [0.0, 1.0]),
1✔
1309
        ];
1310
        for (id, vec) in corpus {
2✔
1311
            conn.execute(&format!(
2✔
1312
                "INSERT INTO v (id, e) VALUES ({id}, [{}, {}]);",
1313
                vec[0], vec[1]
1✔
1314
            ))
1315
            .unwrap();
1316
        }
1317
        // Default L2 index — no WITH clause.
1318
        conn.execute("CREATE INDEX v_hnsw_l2 ON v USING hnsw (e);")
1✔
1319
            .unwrap();
1320

1321
        // Query with cosine. Index can't help; brute-force still
1322
        // returns the correct nearest by cosine: id=1 (cos dist 0).
1323
        let rows = conn
1✔
1324
            .prepare("SELECT id FROM v ORDER BY vec_distance_cosine(e, [1.0, 0.0]) ASC LIMIT 1")
1325
            .unwrap()
1326
            .query_with_params(&[])
1✔
1327
            .unwrap()
1328
            .collect_all()
1329
            .unwrap();
1330
        assert_eq!(rows.len(), 1);
1✔
1331
        assert_eq!(rows[0].get::<i64>(0).unwrap(), 1);
1✔
1332
    }
1333

1334
    /// SQLR-28 — a typo in the metric name must error at CREATE INDEX
1335
    /// time. Falling back to L2 silently is the bug we're fixing here,
1336
    /// not the behaviour to preserve.
1337
    #[test]
1338
    fn unknown_metric_name_is_rejected() {
3✔
1339
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1340
        conn.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, e VECTOR(2));")
1✔
1341
            .unwrap();
1342
        let err = conn
1343
            .execute("CREATE INDEX bad ON v USING hnsw (e) WITH (metric = 'cosin');")
1344
            .unwrap_err();
1345
        let msg = format!("{err}");
2✔
1346
        assert!(msg.contains("unknown HNSW metric"), "got: {msg}");
2✔
1347
    }
1348

1349
    /// SQLR-28 — WITH options on a non-HNSW index must error rather
1350
    /// than be silently ignored. An option that has no effect on the
1351
    /// resulting index is a footgun.
1352
    #[test]
1353
    fn with_metric_on_btree_is_rejected() {
3✔
1354
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1355
        conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT);")
1✔
1356
            .unwrap();
1357
        let err = conn
1358
            .execute("CREATE INDEX bad ON t (b) WITH (metric = 'cosine');")
1359
            .unwrap_err();
1360
        let msg = format!("{err}");
2✔
1361
        assert!(msg.contains("doesn't support any options"), "got: {msg}");
2✔
1362
    }
1363

1364
    // -----------------------------------------------------------------
1365
    // Phase 10.1 — multi-connection foundation
1366
    // -----------------------------------------------------------------
1367

1368
    /// `connect()` mints a sibling handle that shares the backing
1369
    /// `Database`. Writes through one are visible through the other —
1370
    /// the headline behavioural change for Phase 10.1.
1371
    #[test]
1372
    fn connect_shares_underlying_database() {
3✔
1373
        let mut a = Connection::open_in_memory().unwrap();
1✔
1374
        let mut b = a.connect();
1✔
1375
        assert_eq!(a.handle_count(), 2);
2✔
1376

1377
        a.execute("CREATE TABLE shared (id INTEGER PRIMARY KEY, label TEXT);")
1✔
1378
            .unwrap();
1379
        a.execute("INSERT INTO shared (label) VALUES ('via-a');")
1✔
1380
            .unwrap();
1381
        b.execute("INSERT INTO shared (label) VALUES ('via-b');")
1✔
1382
            .unwrap();
1383

1384
        let stmt = b.prepare("SELECT label FROM shared;").unwrap();
1✔
1385
        let mut labels: Vec<String> = stmt
1386
            .query()
1387
            .unwrap()
1388
            .collect_all()
1389
            .unwrap()
1390
            .into_iter()
1391
            .map(|r| r.get::<String>(0).unwrap())
3✔
1392
            .collect();
1393
        labels.sort();
2✔
1394
        assert_eq!(labels, vec!["via-a".to_string(), "via-b".to_string()]);
1✔
1395
    }
1396

1397
    /// Dropping a sibling decrements the handle count without
1398
    /// disturbing the surviving connections.
1399
    #[test]
1400
    fn handle_count_reflects_live_handles() {
3✔
1401
        let primary = Connection::open_in_memory().unwrap();
1✔
1402
        assert_eq!(primary.handle_count(), 1);
2✔
1403
        let s1 = primary.connect();
1✔
1404
        let s2 = primary.connect();
2✔
1405
        assert_eq!(primary.handle_count(), 3);
2✔
1406
        drop(s1);
1✔
1407
        assert_eq!(primary.handle_count(), 2);
1✔
1408
        drop(s2);
1✔
1409
        assert_eq!(primary.handle_count(), 1);
1✔
1410
    }
1411

1412
    /// Multi-thread INSERT/COMMIT against the same in-memory DB. Today
1413
    /// the per-`Database` mutex serializes commits — this test proves
1414
    /// the locking holds without panics or data loss when N threads
1415
    /// race for the writer. Phase 10.4's `BEGIN CONCURRENT` will lift
1416
    /// the serialization for disjoint-row workloads; until then the
1417
    /// guarantee is "no panic, every commit lands."
1418
    #[test]
1419
    fn threaded_writers_serialize_cleanly() {
3✔
1420
        use std::thread;
1421

1422
        let primary = Connection::open_in_memory().unwrap();
1✔
1423
        // Set up the shared schema before spawning so every worker
1424
        // sees the table.
1425
        {
1426
            let mut p = primary.connect();
1✔
1427
            p.execute("CREATE TABLE log (id INTEGER PRIMARY KEY, who TEXT, n INTEGER);")
1✔
1428
                .unwrap();
1429
        }
1430

1431
        const THREADS: usize = 8;
1432
        const PER_THREAD: usize = 25;
1433

1434
        let handles: Vec<_> = (0..THREADS)
1435
            .map(|tid| {
2✔
1436
                let mut conn = primary.connect();
1✔
1437
                thread::spawn(move || {
3✔
1438
                    for n in 0..PER_THREAD {
3✔
1439
                        let sql = format!("INSERT INTO log (who, n) VALUES ('t{tid}', {n});");
5✔
1440
                        conn.execute(&sql).expect("insert under contention");
7✔
1441
                    }
1442
                })
1443
            })
1444
            .collect();
1445

1446
        for h in handles {
2✔
1447
            h.join().expect("worker panicked");
2✔
1448
        }
1449

1450
        // Every write must have landed exactly once — count rows by
1451
        // probing the table directly so we don't depend on a SELECT
1452
        // COUNT(*) implementation.
1453
        let db = primary.database();
1✔
1454
        let table = db.get_table("log".to_string()).unwrap();
2✔
1455
        assert_eq!(
1✔
1456
            table.rowids().len(),
2✔
1457
            THREADS * PER_THREAD,
1✔
1458
            "expected every threaded INSERT to commit",
1459
        );
1460
    }
1461

1462
    /// `connect()` over a file-backed database produces sibling
1463
    /// handles that hit the same on-disk pager. Auto-save through one
1464
    /// must be visible through the other without a re-open.
1465
    #[test]
1466
    fn connect_shares_file_backed_database() {
3✔
1467
        let path = tmp_path("connect_file");
1✔
1468
        let mut primary = Connection::open(&path).unwrap();
2✔
1469
        primary
1470
            .execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT);")
1471
            .unwrap();
1472

1473
        let mut sibling = primary.connect();
1✔
1474
        sibling.execute("INSERT INTO t (v) VALUES ('hi');").unwrap();
2✔
1475

1476
        let stmt = primary.prepare("SELECT v FROM t;").unwrap();
1✔
1477
        let rows = stmt.query().unwrap().collect_all().unwrap();
2✔
1478
        assert_eq!(rows.len(), 1);
2✔
1479
        assert_eq!(rows[0].get::<String>(0).unwrap(), "hi");
1✔
1480

1481
        drop(sibling);
1✔
1482
        drop(primary);
1✔
1483
        cleanup(&path);
1✔
1484
    }
1485

1486
    /// Prepared-statement caches are per-handle, by design — sharing
1487
    /// a mutable LRU across threads would require an extra lock for
1488
    /// no real win (each worker prepares its own hot SQL).
1489
    #[test]
1490
    fn prep_cache_is_per_handle() {
3✔
1491
        let mut a = Connection::open_in_memory().unwrap();
1✔
1492
        a.execute("CREATE TABLE t (a INTEGER);").unwrap();
2✔
1493
        let mut b = a.connect();
1✔
1494

1495
        let _ = a.prepare_cached("SELECT a FROM t").unwrap();
2✔
1496
        let _ = a.prepare_cached("SELECT a FROM t").unwrap();
1✔
1497
        assert_eq!(a.prepared_cache_len(), 1);
1✔
1498
        // The sibling's cache is untouched.
1499
        assert_eq!(b.prepared_cache_len(), 0);
1✔
1500
        let _ = b.prepare_cached("SELECT a FROM t").unwrap();
1✔
1501
        assert_eq!(b.prepared_cache_len(), 1);
1✔
1502
    }
1503

1504
    /// Static check: `Connection` is `Send + Sync`. Required so it can
1505
    /// be moved across threads (or wrapped in `Arc`) without a typestate
1506
    /// adapter — the headline contract Phase 10.1 puts in place.
1507
    #[test]
1508
    fn connection_is_send_and_sync() {
3✔
1509
        fn assert_send<T: Send>() {}
1✔
1510
        fn assert_sync<T: Sync>() {}
1✔
1511
        assert_send::<Connection>();
1✔
1512
        assert_sync::<Connection>();
1✔
1513
    }
1514

1515
    #[test]
1516
    fn prepare_cached_executes_the_same_as_prepare() {
3✔
1517
        let mut conn = Connection::open_in_memory().unwrap();
1✔
1518
        conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT);")
1✔
1519
            .unwrap();
1520
        let mut ins = conn
1✔
1521
            .prepare_cached("INSERT INTO t (a, b) VALUES (?, ?)")
1522
            .unwrap();
1523
        ins.execute_with_params(&[Value::Integer(1), Value::Text("alpha".into())])
1✔
1524
            .unwrap();
1525
        ins.execute_with_params(&[Value::Integer(2), Value::Text("beta".into())])
1✔
1526
            .unwrap();
1527

1528
        let stmt = conn.prepare_cached("SELECT b FROM t WHERE a = ?").unwrap();
1✔
1529
        let rows = stmt
1530
            .query_with_params(&[Value::Integer(2)])
1✔
1531
            .unwrap()
1532
            .collect_all()
1533
            .unwrap();
1534
        assert_eq!(rows.len(), 1);
2✔
1535
        assert_eq!(rows[0].get::<String>(0).unwrap(), "beta");
1✔
1536
    }
1537
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc