From 84f62b450ed917b31732b24149f5d0707400ba0d Mon Sep 17 00:00:00 2001 From: Joao Henrique Machado Silva Date: Fri, 8 May 2026 23:03:29 +0200 Subject: [PATCH] feat(engine): PRAGMA dispatcher + auto_vacuum knob (SQLR-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first SQL-level PRAGMA, exposing the SQLR-10 auto-VACUUM threshold to SDK / FFI / MCP consumers (which can't reach Connection::set_auto_vacuum_threshold directly). New src/sql/pragma.rs pre-tokenizes PRAGMA statements before sqlparser sees them, so we accept bare OFF / NONE — sqlparser-rs's pragma-value parser only allows numbers and quoted strings, which would silently reject the classic SQLite idiom. Reuses Database::set_auto_vacuum_threshold for range validation so out-of-range values surface as a typed error instead of saturating. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + docs/architecture.md | 1 + docs/pager.md | 2 +- docs/supported-sql.md | 13 +- src/sql/mod.rs | 9 + src/sql/pager/mod.rs | 163 ++++++++++++++ src/sql/pragma.rs | 514 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 src/sql/pragma.rs diff --git a/README.md b/README.md index ff3ad52..3a0bb45 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ sqlrite> DELETE FROM users WHERE age < 30; | `UPDATE` | Multi-column `SET`; `WHERE`; UNIQUE + type enforcement; arithmetic in assignments (`SET age = age + 1`) | | `DELETE` | `WHERE` predicate or full-table delete | | `BEGIN` / `COMMIT` / `ROLLBACK` | Real transactions, snapshot-based; WAL-backed commit; single-level (no savepoints); auto-rollback if `COMMIT`'s disk write fails | +| `PRAGMA auto_vacuum` | Read (`PRAGMA auto_vacuum;`) returns the trigger threshold as a single-row result set; set (`PRAGMA auto_vacuum = 0.5;` / `= OFF;` / `= NONE;`) tunes or disables auto-VACUUM at the SQL layer for SDK / FFI / MCP consumers | Expressions in `WHERE` and `UPDATE`'s `SET` RHS: diff --git a/docs/architecture.md b/docs/architecture.md index 032e971..85771ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,6 +104,7 @@ The engine never depends on the SDK crates; the SDK crates each depend on the en | [`src/sql/hnsw.rs`](../src/sql/hnsw.rs) | Standalone HNSW algorithm — insert / search / layer assignment / beam search. Phase 7d.1. | | [`src/sql/fts/`](../src/sql/fts/) | Full-text search — standalone tokenizer, BM25 scorer, and in-memory `PostingList` inverted index. Wired into the executor via the `fts_match` / `bm25_score` scalar functions and the `try_fts_probe` optimizer hook. Phase 8a-8b; persistence in 8c. See [`docs/fts.md`](fts.md). | | [`src/sql/json.rs`](../src/sql/json.rs) | JSON column type + path-extraction functions (`json_extract`, `json_type`, `json_array_length`, `json_object_keys`). Phase 7e. | +| [`src/sql/pragma.rs`](../src/sql/pragma.rs) | `PRAGMA` dispatcher (SQLR-13). `try_parse_pragma` peeks at the SQL token stream before sqlparser sees it and routes any `PRAGMA …` shape to `execute_pragma`. First pragma wired up: `auto_vacuum` (read + set, with `OFF` / `NONE` to disable). Add new pragmas as a single arm in `execute_pragma`. | | [`src/sql/pager/`](../src/sql/pager/) | On-disk file format and I/O — see [file-format.md](file-format.md) and [pager.md](pager.md) for details. WAL + checkpointer + shared/exclusive lock modes (Phase 4a-4e) live here. | ## Flow of a SQL statement diff --git a/docs/pager.md b/docs/pager.md index 9c58425..1c5e72c 100644 --- a/docs/pager.md +++ b/docs/pager.md @@ -205,7 +205,7 @@ Format-version side effect: a save that produces a non-empty freelist promotes t After SQLR-6, the file still required a manual `VACUUM;` to actually shrink — the freelist absorbed orphan pages but the high-water mark stayed put. SQLR-10 adds a heuristic that fires `vacuum_database` automatically after a page-releasing DDL (`DROP TABLE`, `DROP INDEX`, `ALTER TABLE DROP COLUMN`) when the freelist exceeds a configurable fraction of `page_count`. -Configuration lives on `Database::auto_vacuum_threshold: Option` and is exposed at the connection level via `Connection::set_auto_vacuum_threshold` / `auto_vacuum_threshold`. Defaults: `Some(0.25)` (SQLite parity at 25%); pass `None` to opt out per connection. The threshold is per-`Connection` runtime state and is not persisted in the file header — every reopen starts at the default. A SQL-level `PRAGMA auto_vacuum` is tracked separately (out of scope for SQLR-10). +Configuration lives on `Database::auto_vacuum_threshold: Option` and is exposed at the connection level via `Connection::set_auto_vacuum_threshold` / `auto_vacuum_threshold`, and via SQL through `PRAGMA auto_vacuum` (SQLR-13 — see [`src/sql/pragma.rs`](../src/sql/pragma.rs)). Defaults: `Some(0.25)` (SQLite parity at 25%); pass `None` (or `PRAGMA auto_vacuum = OFF`) to opt out per connection. The threshold is per-`Connection` runtime state and is not persisted in the file header — every reopen starts at the default. The trigger lives at the end of [`process_command_with_render`](../src/sql/mod.rs), immediately after the auto-save. Order matters: the freelist isn't accurate until the bottom-up rebuild runs during save, so we save first, then check the ratio. The check itself is `freelist::should_auto_vacuum(pager, threshold)`, which: diff --git a/docs/supported-sql.md b/docs/supported-sql.md index 54beadf..022c94c 100644 --- a/docs/supported-sql.md +++ b/docs/supported-sql.md @@ -551,7 +551,16 @@ conn.set_auto_vacuum_threshold(Some(0.5))?; // fire only when freelist > 50% conn.set_auto_vacuum_threshold(None)?; // disable entirely (manual VACUUM only) ``` -The setting is per-`Connection` runtime state — it's not persisted in the file header, so every reopen starts at the default `Some(0.25)`. A SQL-level `PRAGMA auto_vacuum` knob is on the roadmap but not yet implemented (SDK consumers currently configure it via the per-binding glue or fall back to the default). +…or via SQL (SQLR-13), which is the path SDK / FFI / MCP consumers reach for since they can't call the Rust setter directly: + +```sql +PRAGMA auto_vacuum; -- read; renders a single-row result set +PRAGMA auto_vacuum = 0.5; -- arm the trigger at 50% +PRAGMA auto_vacuum = 0; -- arm at 0% (compact on any released page) +PRAGMA auto_vacuum = OFF; -- disable; equivalent: NONE, 'OFF', 'NONE' +``` + +Out-of-range values (anything outside `0.0..=1.0`, `NaN`, `±∞`) and unknown identifiers like `WAL` / `FULL` are rejected with a typed error — the trigger never silently saturates or falls back to a default. The setting is per-`Connection` runtime state — it's not persisted in the file header, so every reopen starts at the default `Some(0.25)`. --- @@ -621,7 +630,7 @@ For context when you hit `NotImplemented`. See [Roadmap](roadmap.md) for when th ### Session / schema - Multiple attached databases (`ATTACH DATABASE`, `DETACH DATABASE`) -- `PRAGMA` statements beyond what the parser accepts (none currently executed) +- `PRAGMA` statements other than `auto_vacuum` (SQLR-13). The dispatcher is in place — adding a pragma is a single arm in `execute_pragma`. `journal_mode`, `synchronous`, `cache_size`, etc. are not yet wired up - `REPLACE INTO`, `INSERT OR IGNORE`, `INSERT OR REPLACE` (conflict-resolution clauses) --- diff --git a/src/sql/mod.rs b/src/sql/mod.rs index abd0ae3..ef4dc11 100644 --- a/src/sql/mod.rs +++ b/src/sql/mod.rs @@ -7,6 +7,7 @@ pub mod hnsw; pub mod pager; pub mod params; pub mod parser; +pub mod pragma; // pub mod tokenizer; use parser::create::CreateQuery; @@ -89,6 +90,14 @@ pub fn process_command(query: &str, db: &mut Database) -> Result { /// to stdout.** The REPL is responsible for printing whatever it wants /// from the returned struct. pub fn process_command_with_render(query: &str, db: &mut Database) -> Result { + // SQLR-13 — intercept `PRAGMA` before sqlparser sees it. sqlparser's + // pragma-value parser rejects bare `OFF` / `NONE` (and other classic + // SQLite pragma idioms), so we tokenize and dispatch ourselves. Non- + // PRAGMA input falls through to the regular dispatcher unchanged. + if let Some(stmt) = pragma::try_parse_pragma(query)? { + return pragma::execute_pragma(stmt, db); + } + let dialect = SqlriteDialect::new(); let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?; diff --git a/src/sql/pager/mod.rs b/src/sql/pager/mod.rs index 38e4beb..ae6eea1 100644 --- a/src/sql/pager/mod.rs +++ b/src/sql/pager/mod.rs @@ -3889,6 +3889,169 @@ mod tests { assert_eq!(db.auto_vacuum_threshold(), None); } + // --------------------------------------------------------------- + // SQLR-13 — `PRAGMA auto_vacuum` SQL-level coverage. Mirrors the + // SQLR-10 setter tests above, but routed through SQL so SDK / FFI + // / MCP consumers (which can't reach the Rust setter directly) + // get the same guarantees. + // --------------------------------------------------------------- + + /// `PRAGMA auto_vacuum = N;` set + `PRAGMA auto_vacuum;` read + /// round-trip the threshold, observable via `auto_vacuum_threshold`. + #[test] + fn pragma_auto_vacuum_set_and_read_via_sql() { + let mut db = Database::new("t".to_string()); + + let resp = process_command("PRAGMA auto_vacuum = 0.5;", &mut db).expect("set"); + assert!( + resp.contains("PRAGMA"), + "set form should produce a PRAGMA status, got: {resp}" + ); + assert_eq!(db.auto_vacuum_threshold(), Some(0.5)); + + // Read form — status mentions a returned row. + let resp = process_command("PRAGMA auto_vacuum;", &mut db).expect("read"); + assert!(resp.contains("1 row"), "expected a 1-row read, got: {resp}"); + } + + /// `PRAGMA auto_vacuum = OFF;` (bare identifier — sqlparser's own + /// pragma-value parser would reject this, the SQLR-13 dispatcher + /// must accept it) and `= NONE;` both disable the trigger. So does + /// the quoted form `'OFF'`. + #[test] + fn pragma_auto_vacuum_off_disables_trigger() { + for raw in ["OFF", "off", "NONE", "none", "'OFF'", "'NONE'"] { + let mut db = Database::new("t".to_string()); + assert_eq!(db.auto_vacuum_threshold(), Some(0.25)); + + let stmt = format!("PRAGMA auto_vacuum = {raw};"); + process_command(&stmt, &mut db) + .unwrap_or_else(|e| panic!("`{stmt}` should disable: {e}")); + assert_eq!( + db.auto_vacuum_threshold(), + None, + "`{stmt}` should clear the threshold" + ); + } + } + + /// Out-of-range numeric values surface as a typed error via the + /// shared `set_auto_vacuum_threshold` validator — no silent + /// saturation. Mirrors the SQLR-10 setter coverage. + #[test] + fn pragma_auto_vacuum_rejects_out_of_range_via_sql() { + let mut db = Database::new("t".to_string()); + for bad in ["-0.01", "1.01", "1.5"] { + let stmt = format!("PRAGMA auto_vacuum = {bad};"); + let err = process_command(&stmt, &mut db).unwrap_err(); + assert!( + format!("{err}").contains("auto_vacuum_threshold"), + "expected range error for `{stmt}`, got: {err}" + ); + } + // Default survives all the rejected sets. + assert_eq!(db.auto_vacuum_threshold(), Some(0.25)); + } + + /// Junk strings (anything that isn't a number or `OFF`/`NONE`) are + /// rejected at parse time with a typed error, not silently treated + /// as "disable". + #[test] + fn pragma_auto_vacuum_rejects_unknown_strings_via_sql() { + let mut db = Database::new("t".to_string()); + let err = process_command("PRAGMA auto_vacuum = WAL;", &mut db).unwrap_err(); + assert!( + format!("{err}").contains("OFF/NONE"), + "expected OFF/NONE-style error, got: {err}" + ); + // Default unaffected. + assert_eq!(db.auto_vacuum_threshold(), Some(0.25)); + } + + /// Pragmas SQLRite doesn't know about return `NotImplemented` — + /// not a generic parser error. Future pragmas plug in here. + #[test] + fn pragma_unknown_returns_not_implemented() { + let mut db = Database::new("t".to_string()); + let err = process_command("PRAGMA journal_mode = WAL;", &mut db).unwrap_err(); + assert!( + matches!(err, SQLRiteError::NotImplemented(_)), + "unknown pragma must surface NotImplemented, got: {err:?}" + ); + } + + /// Setting the threshold via SQL must produce identical behavior to + /// the Rust setter on the actual auto-VACUUM trigger: `= 0.99` + /// suppresses, `= OFF` disables, default fires. Sanity-checks that + /// `process_command_with_render`'s pre-parse step doesn't desync + /// the in-memory state from the file. + #[test] + fn pragma_auto_vacuum_drives_real_trigger() { + // Sub-case A — `PRAGMA auto_vacuum = OFF;` keeps file at HWM. + { + let path = tmp_path("av_pragma_off"); + let mut db = auto_vacuum_setup(&path); + process_command("PRAGMA auto_vacuum = OFF;", &mut db).expect("disable via PRAGMA"); + assert_eq!(db.auto_vacuum_threshold(), None); + + let pages_before = db.pager.as_ref().unwrap().header().page_count; + process_command("DROP TABLE bloat;", &mut db).expect("drop"); + let pages_after = db.pager.as_ref().unwrap().header().page_count; + assert_eq!( + pages_after, pages_before, + "PRAGMA-driven OFF must keep page_count at the HWM" + ); + cleanup(&path); + } + + // Sub-case B — high threshold via PRAGMA suppresses the + // trigger on a single drop. + { + let path = tmp_path("av_pragma_high"); + let mut db = auto_vacuum_setup(&path); + process_command("PRAGMA auto_vacuum = 0.99;", &mut db).expect("set high"); + assert_eq!(db.auto_vacuum_threshold(), Some(0.99)); + + let pages_before = db.pager.as_ref().unwrap().header().page_count; + process_command("DROP TABLE bloat;", &mut db).expect("drop"); + let pages_after = db.pager.as_ref().unwrap().header().page_count; + assert_eq!( + pages_after, pages_before, + "high PRAGMA threshold must suppress the trigger" + ); + cleanup(&path); + } + + // Sub-case C — re-arm via PRAGMA after disable: the trigger + // fires again on the next page-releasing DDL. + { + let path = tmp_path("av_pragma_rearm"); + let mut db = auto_vacuum_setup(&path); + process_command("PRAGMA auto_vacuum = OFF;", &mut db).unwrap(); + // Drop with the trigger off — pages land on the freelist + // but the file stays at HWM. + process_command("DROP TABLE bloat;", &mut db).unwrap(); + let pages_after_off_drop = db.pager.as_ref().unwrap().header().page_count; + assert!(db.pager.as_ref().unwrap().header().freelist_head != 0); + + // Re-arm via PRAGMA, then drop one more thing — the + // accumulated freelist still exceeds 25%, so auto-VACUUM + // fires. + process_command("PRAGMA auto_vacuum = 0.25;", &mut db).expect("re-arm"); + process_command("CREATE INDEX idx_keep_n ON keep (n);", &mut db).unwrap(); + process_command("DROP INDEX idx_keep_n;", &mut db).expect("drop index"); + + let pages_after_rearm = db.pager.as_ref().unwrap().header().page_count; + assert!( + pages_after_rearm < pages_after_off_drop, + "re-armed PRAGMA must let auto-VACUUM fire: was {pages_after_off_drop}, \ + now {pages_after_rearm}" + ); + assert_eq!(db.pager.as_ref().unwrap().header().freelist_head, 0); + cleanup(&path); + } + } + /// VACUUM modifiers (FULL, REINDEX, table targets, …) are rejected /// with NotImplemented — only bare `VACUUM;` is supported. #[test] diff --git a/src/sql/pragma.rs b/src/sql/pragma.rs new file mode 100644 index 0000000..957708a --- /dev/null +++ b/src/sql/pragma.rs @@ -0,0 +1,514 @@ +//! SQL-level `PRAGMA` dispatcher (SQLR-13). +//! +//! sqlparser-rs already produces a `Statement::Pragma` AST variant, but +//! its pragma-value parser is narrow: only numbers, single/double quoted +//! strings, and `?` placeholders are accepted. Bare identifiers like +//! `OFF` / `NONE` (which SQLite has historically accepted in PRAGMA +//! position) get rejected before the dispatcher ever sees them. +//! +//! We bypass that constraint by intercepting `PRAGMA` statements before +//! `Parser::parse_sql` runs: peek the first non-whitespace token, and +//! if it's the `PRAGMA` keyword, route through this module's tokenizer +//! pass instead. Non-PRAGMA input falls straight through (returns +//! `Ok(None)`). +//! +//! This is the first SQL pragma SQLRite ships. The dispatcher is a +//! single function with a `match` on pragma name; switch to a registry +//! when the second pragma lands. + +use prettytable::{Cell as PrintCell, Row as PrintRow, Table as PrintTable}; +use sqlparser::dialect::SQLiteDialect; +use sqlparser::keywords::Keyword; +use sqlparser::tokenizer::{Token, Tokenizer}; + +use crate::error::{Result, SQLRiteError}; +use crate::sql::CommandOutput; +use crate::sql::db::database::Database; + +/// Parsed pragma value. +/// +/// We distinguish between bare identifiers and quoted strings so the +/// per-pragma handler can reject ambiguous shapes (e.g. a future numeric +/// pragma can refuse `'42'` while accepting `42`). Numbers are kept as +/// the raw lexeme so the handler can pick its own integer / float +/// parsing strategy. +#[derive(Debug, Clone, PartialEq)] +pub enum PragmaValue { + /// Numeric literal (with an optional leading `-` folded into the lexeme). + Number(String), + /// Bare identifier, e.g. `OFF` / `NONE` / `WAL`. + Identifier(String), + /// Quoted string literal, e.g. `'OFF'` or `"NONE"`. + String(String), +} + +/// One parsed `PRAGMA` statement. `value` is `None` for the read form +/// (`PRAGMA name;`). +#[derive(Debug, Clone, PartialEq)] +pub struct PragmaStatement { + pub name: String, + pub value: Option, +} + +/// Returns `Ok(Some(stmt))` when `sql` is a `PRAGMA` statement, +/// `Ok(None)` otherwise. Errors only when the input *is* shaped like +/// `PRAGMA …` but malformed — that path used to surface as a sqlparser +/// `ParserError`; with this module taking over, the error becomes a +/// typed `SQLRiteError::General` so SDK consumers see a stable shape. +pub fn try_parse_pragma(sql: &str) -> Result> { + let dialect = SQLiteDialect {}; + let tokens = Tokenizer::new(&dialect, sql) + .tokenize() + .map_err(|e| SQLRiteError::General(format!("PRAGMA tokenize error: {e}")))?; + + let mut iter = tokens + .into_iter() + .filter(|t| !matches!(t, Token::Whitespace(_))) + .peekable(); + + // First non-whitespace token must be the PRAGMA keyword. Anything + // else means this isn't ours — let sqlparser take over. + match iter.peek() { + Some(Token::Word(w)) if w.keyword == Keyword::PRAGMA => { + iter.next(); + } + _ => return Ok(None), + } + + let name = match iter.next() { + Some(Token::Word(w)) => w.value, + Some(other) => { + return Err(SQLRiteError::General(format!( + "PRAGMA: expected pragma name, got {other:?}" + ))); + } + None => { + return Err(SQLRiteError::General( + "PRAGMA: missing pragma name".to_string(), + )); + } + }; + + let value = match iter.peek() { + None | Some(Token::SemiColon) => None, + Some(Token::Eq) => { + iter.next(); + Some(read_pragma_value(&mut iter)?) + } + Some(Token::LParen) => { + iter.next(); + let v = read_pragma_value(&mut iter)?; + match iter.next() { + Some(Token::RParen) => {} + Some(other) => { + return Err(SQLRiteError::General(format!( + "PRAGMA: expected ')' to close parenthesised value, got {other:?}" + ))); + } + None => { + return Err(SQLRiteError::General( + "PRAGMA: expected ')' to close parenthesised value".to_string(), + )); + } + } + Some(v) + } + Some(other) => { + return Err(SQLRiteError::General(format!( + "PRAGMA: expected '=', '(', ';' or end of statement after name, got {other:?}" + ))); + } + }; + + // Optional terminating semicolon. Anything after that is a multi- + // statement string, which the regular dispatcher already rejects; + // mirror that policy here so PRAGMA isn't a sneaky bypass. + if matches!(iter.peek(), Some(Token::SemiColon)) { + iter.next(); + } + if let Some(extra) = iter.next() { + return Err(SQLRiteError::General(format!( + "PRAGMA: unexpected trailing content {extra:?}" + ))); + } + + Ok(Some(PragmaStatement { name, value })) +} + +fn read_pragma_value(iter: &mut std::iter::Peekable) -> Result +where + I: Iterator, +{ + // `PRAGMA name = -0.5;` / `PRAGMA name = -1;` — fold a leading sign + // into the number lexeme so the handler's parse() sees it as one + // token. The setter validates the range; we just preserve the + // sign here. + let mut neg = false; + let first = iter.next().ok_or_else(|| { + SQLRiteError::General("PRAGMA: missing value after '=' or '('".to_string()) + })?; + + let tok = if matches!(first, Token::Minus) { + neg = true; + iter.next() + .ok_or_else(|| SQLRiteError::General("PRAGMA: missing value after '-'".to_string()))? + } else { + first + }; + + Ok(match tok { + Token::Number(s, _) => { + if neg { + PragmaValue::Number(format!("-{s}")) + } else { + PragmaValue::Number(s) + } + } + Token::SingleQuotedString(s) | Token::DoubleQuotedString(s) => { + if neg { + return Err(SQLRiteError::General( + "PRAGMA: unary '-' is only valid in front of a number".to_string(), + )); + } + PragmaValue::String(s) + } + Token::Word(w) => { + if neg { + return Err(SQLRiteError::General( + "PRAGMA: unary '-' is only valid in front of a number".to_string(), + )); + } + PragmaValue::Identifier(w.value) + } + other => { + return Err(SQLRiteError::General(format!( + "PRAGMA: unsupported value token {other:?}" + ))); + } + }) +} + +/// Dispatch a parsed `PRAGMA` statement against the database. New +/// pragmas plug in here. +pub fn execute_pragma(stmt: PragmaStatement, db: &mut Database) -> Result { + match stmt.name.to_ascii_lowercase().as_str() { + "auto_vacuum" => pragma_auto_vacuum(stmt.value, db), + other => Err(SQLRiteError::NotImplemented(format!( + "PRAGMA '{other}' is not supported" + ))), + } +} + +/// `PRAGMA auto_vacuum;` (read) or `PRAGMA auto_vacuum = N | OFF | NONE;` +/// (write). Reuses [`Database::set_auto_vacuum_threshold`] so the range +/// validation lives in exactly one place. +fn pragma_auto_vacuum(value: Option, db: &mut Database) -> Result { + match value { + None => { + // Read form: render as a single-row, single-column result + // set so `Connection::execute` (and the REPL) produce + // SQLite-shaped output. SDK callers driving a typed-row API + // would normally use `Connection::prepare` for this — but + // PRAGMA reads aren't on the prepared-statement path yet, + // so for now consumers parse the rendered table or call + // `Connection::auto_vacuum_threshold` directly. + let mut t = PrintTable::new(); + t.add_row(PrintRow::new(vec![PrintCell::new("auto_vacuum")])); + let cell_value = match db.auto_vacuum_threshold() { + Some(v) => format!("{v}"), + None => "OFF".to_string(), + }; + t.add_row(PrintRow::new(vec![PrintCell::new(&cell_value)])); + Ok(CommandOutput { + status: "PRAGMA auto_vacuum executed. 1 row returned.".to_string(), + rendered: Some(t.to_string()), + }) + } + Some(v) => { + let new_threshold = parse_auto_vacuum_target(&v)?; + db.set_auto_vacuum_threshold(new_threshold)?; + Ok(CommandOutput { + status: "PRAGMA auto_vacuum executed.".to_string(), + rendered: None, + }) + } + } +} + +/// Maps a PRAGMA value to the threshold argument expected by +/// `Database::set_auto_vacuum_threshold`. `OFF` and `NONE` (bare or +/// quoted, case-insensitive) disable the trigger; numeric values pass +/// through to the setter for range validation. +fn parse_auto_vacuum_target(value: &PragmaValue) -> Result> { + match value { + PragmaValue::Identifier(s) | PragmaValue::String(s) => { + match s.to_ascii_lowercase().as_str() { + "off" | "none" => Ok(None), + _ => Err(SQLRiteError::General(format!( + "PRAGMA auto_vacuum: expected a number in 0.0..=1.0 or OFF/NONE, got '{s}'" + ))), + } + } + PragmaValue::Number(s) => { + let f: f32 = s.parse().map_err(|_| { + SQLRiteError::General(format!("PRAGMA auto_vacuum: '{s}' is not a valid number")) + })?; + Ok(Some(f)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_parse_pragma_returns_none_for_non_pragma() { + assert!(try_parse_pragma("SELECT 1;").unwrap().is_none()); + assert!( + try_parse_pragma("CREATE TABLE t (id INTEGER);") + .unwrap() + .is_none() + ); + // Empty / whitespace / comment-only inputs aren't pragmas. + assert!(try_parse_pragma("").unwrap().is_none()); + assert!(try_parse_pragma(" \n\t ").unwrap().is_none()); + assert!(try_parse_pragma("-- hello\n").unwrap().is_none()); + } + + #[test] + fn try_parse_pragma_read_form() { + let stmt = try_parse_pragma("PRAGMA auto_vacuum;").unwrap().unwrap(); + assert_eq!(stmt.name, "auto_vacuum"); + assert_eq!(stmt.value, None); + + // Trailing whitespace / no semicolon. + let stmt = try_parse_pragma(" PRAGMA auto_vacuum ").unwrap().unwrap(); + assert_eq!(stmt.name, "auto_vacuum"); + assert_eq!(stmt.value, None); + + // Case-insensitive PRAGMA keyword. + let stmt = try_parse_pragma("pragma auto_vacuum;").unwrap().unwrap(); + assert_eq!(stmt.name, "auto_vacuum"); + } + + #[test] + fn try_parse_pragma_eq_number() { + let stmt = try_parse_pragma("PRAGMA auto_vacuum = 0.5;") + .unwrap() + .unwrap(); + assert_eq!(stmt.name, "auto_vacuum"); + assert_eq!(stmt.value, Some(PragmaValue::Number("0.5".to_string()))); + + let stmt = try_parse_pragma("PRAGMA auto_vacuum = 0;") + .unwrap() + .unwrap(); + assert_eq!(stmt.value, Some(PragmaValue::Number("0".to_string()))); + + // Negative — surfaces from the setter as a range error, but + // tokenization should round-trip the sign. + let stmt = try_parse_pragma("PRAGMA auto_vacuum = -0.1;") + .unwrap() + .unwrap(); + assert_eq!(stmt.value, Some(PragmaValue::Number("-0.1".to_string()))); + } + + #[test] + fn try_parse_pragma_eq_identifier() { + let stmt = try_parse_pragma("PRAGMA auto_vacuum = OFF;") + .unwrap() + .unwrap(); + assert_eq!(stmt.value, Some(PragmaValue::Identifier("OFF".to_string()))); + + let stmt = try_parse_pragma("PRAGMA auto_vacuum = none;") + .unwrap() + .unwrap(); + assert_eq!( + stmt.value, + Some(PragmaValue::Identifier("none".to_string())) + ); + } + + #[test] + fn try_parse_pragma_eq_string() { + // Single-quoted strings are unambiguous string literals. + let stmt = try_parse_pragma("PRAGMA auto_vacuum = 'OFF';") + .unwrap() + .unwrap(); + assert_eq!(stmt.value, Some(PragmaValue::String("OFF".to_string()))); + + // SQLite's tokenizer treats `"NONE"` as a delimited identifier + // (not a string literal) — it surfaces here as `Identifier`. + // Both shapes funnel through `parse_auto_vacuum_target`'s + // case-insensitive OFF/NONE arm, so the user-visible behavior + // is identical. + let stmt = try_parse_pragma("PRAGMA auto_vacuum = \"NONE\";") + .unwrap() + .unwrap(); + assert_eq!( + stmt.value, + Some(PragmaValue::Identifier("NONE".to_string())) + ); + } + + #[test] + fn try_parse_pragma_paren_form() { + let stmt = try_parse_pragma("PRAGMA auto_vacuum(0.5);") + .unwrap() + .unwrap(); + assert_eq!(stmt.value, Some(PragmaValue::Number("0.5".to_string()))); + + let stmt = try_parse_pragma("PRAGMA auto_vacuum (OFF);") + .unwrap() + .unwrap(); + assert_eq!(stmt.value, Some(PragmaValue::Identifier("OFF".to_string()))); + } + + #[test] + fn try_parse_pragma_rejects_malformed() { + assert!(try_parse_pragma("PRAGMA;").is_err()); + assert!(try_parse_pragma("PRAGMA = 0.5;").is_err()); + assert!(try_parse_pragma("PRAGMA auto_vacuum =;").is_err()); + assert!(try_parse_pragma("PRAGMA auto_vacuum (0.5;").is_err()); + // Multi-statement is rejected here just like the regular path. + assert!(try_parse_pragma("PRAGMA auto_vacuum; SELECT 1;").is_err()); + // `--` is a binary minus on a string token, which we reject. + assert!(try_parse_pragma("PRAGMA auto_vacuum = -'OFF';").is_err()); + } + + #[test] + fn parse_auto_vacuum_target_disables_on_off_or_none() { + for raw in ["OFF", "off", "Off", "NONE", "none"] { + assert_eq!( + parse_auto_vacuum_target(&PragmaValue::Identifier(raw.to_string())).unwrap(), + None + ); + assert_eq!( + parse_auto_vacuum_target(&PragmaValue::String(raw.to_string())).unwrap(), + None + ); + } + } + + #[test] + fn parse_auto_vacuum_target_passes_numbers_through() { + assert_eq!( + parse_auto_vacuum_target(&PragmaValue::Number("0.5".to_string())).unwrap(), + Some(0.5_f32) + ); + assert_eq!( + parse_auto_vacuum_target(&PragmaValue::Number("0".to_string())).unwrap(), + Some(0.0_f32) + ); + // Out-of-range numbers parse OK at this layer; the setter + // validates the range. + assert_eq!( + parse_auto_vacuum_target(&PragmaValue::Number("1.5".to_string())).unwrap(), + Some(1.5_f32) + ); + } + + #[test] + fn parse_auto_vacuum_target_rejects_unknown_strings() { + let err = + parse_auto_vacuum_target(&PragmaValue::Identifier("WAL".to_string())).unwrap_err(); + assert!(format!("{err}").contains("OFF/NONE")); + } + + #[test] + fn execute_pragma_unknown_returns_not_implemented() { + let mut db = Database::new("t".to_string()); + let err = execute_pragma( + PragmaStatement { + name: "journal_mode".to_string(), + value: None, + }, + &mut db, + ) + .unwrap_err(); + assert!(matches!(err, SQLRiteError::NotImplemented(_))); + } + + #[test] + fn execute_pragma_auto_vacuum_set_and_read() { + let mut db = Database::new("t".to_string()); + + // Set to 0.5, read returns 0.5 in the rendered cell. + let out = execute_pragma( + PragmaStatement { + name: "auto_vacuum".to_string(), + value: Some(PragmaValue::Number("0.5".to_string())), + }, + &mut db, + ) + .unwrap(); + assert!(out.rendered.is_none()); + assert_eq!(db.auto_vacuum_threshold(), Some(0.5)); + + let out = execute_pragma( + PragmaStatement { + name: "auto_vacuum".to_string(), + value: None, + }, + &mut db, + ) + .unwrap(); + let rendered = out.rendered.expect("read form must render rows"); + assert!(rendered.contains("auto_vacuum")); + assert!(rendered.contains("0.5")); + + // Disable via OFF (bare identifier). + execute_pragma( + PragmaStatement { + name: "auto_vacuum".to_string(), + value: Some(PragmaValue::Identifier("OFF".to_string())), + }, + &mut db, + ) + .unwrap(); + assert_eq!(db.auto_vacuum_threshold(), None); + + // Read after OFF — rendered cell shows OFF, not a number. + let out = execute_pragma( + PragmaStatement { + name: "auto_vacuum".to_string(), + value: None, + }, + &mut db, + ) + .unwrap(); + let rendered = out.rendered.unwrap(); + assert!(rendered.contains("OFF")); + } + + #[test] + fn execute_pragma_auto_vacuum_rejects_out_of_range() { + let mut db = Database::new("t".to_string()); + let err = execute_pragma( + PragmaStatement { + name: "auto_vacuum".to_string(), + value: Some(PragmaValue::Number("1.5".to_string())), + }, + &mut db, + ) + .unwrap_err(); + assert!(format!("{err}").contains("auto_vacuum_threshold")); + + // Default survived the rejected set. + assert_eq!(db.auto_vacuum_threshold(), Some(0.25)); + } + + #[test] + fn execute_pragma_auto_vacuum_rejects_negative() { + let mut db = Database::new("t".to_string()); + let err = execute_pragma( + PragmaStatement { + name: "auto_vacuum".to_string(), + value: Some(PragmaValue::Number("-0.1".to_string())), + }, + &mut db, + ) + .unwrap_err(); + assert!(format!("{err}").contains("auto_vacuum_threshold")); + } +}