sqlrite/sql/db/database.rs
1use crate::error::{Result, SQLRiteError};
2use crate::mvcc::{JournalMode, MvStore, MvccClock};
3use crate::sql::db::table::Table;
4use crate::sql::pager::pager::{AccessMode, Pager};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9/// Snapshot of the mutable in-memory state taken at `BEGIN` time so
10/// `ROLLBACK` can restore it. See `begin_transaction`, `rollback_transaction`.
11/// `tables` is deep-cloned (the `Table::deep_clone` helper reallocates
12/// the `Arc<Mutex<_>>` row storage so snapshot and live state don't
13/// share a map).
14#[derive(Debug)]
15pub struct TxnSnapshot {
16 pub(crate) tables: HashMap<String, Table>,
17}
18
19/// Default fraction of free pages that triggers an auto-VACUUM after
20/// a page-releasing DDL (DROP TABLE / DROP INDEX / ALTER TABLE DROP
21/// COLUMN). Matches SQLite's classic 25% heuristic. Override per
22/// connection with [`Database::set_auto_vacuum_threshold`] (or
23/// `Connection::set_auto_vacuum_threshold`); pass `None` to disable.
24pub const DEFAULT_AUTO_VACUUM_THRESHOLD: f32 = 0.25;
25
26/// The database is represented by this structure.assert_eq!
27#[derive(Debug)]
28pub struct Database {
29 /// Name of this database. (schema name, not filename)
30 pub db_name: String,
31 /// HashMap of tables in this database
32 pub tables: HashMap<String, Table>,
33 /// If `Some`, every committing SQL statement auto-flushes the DB to
34 /// this path. `None` → transient in-memory mode (the default; the
35 /// REPL only enters persistent mode after `.open FILE`).
36 pub source_path: Option<PathBuf>,
37 /// Long-lived pager attached when the database is file-backed. Keeps
38 /// an in-memory snapshot of every page so auto-saves can diff
39 /// against the last-committed state and skip rewriting unchanged
40 /// pages. `None` means "in-memory only" or "not yet opened".
41 pub pager: Option<Pager>,
42 /// Active transaction state (Phase 4f). `Some` between `BEGIN` and
43 /// the matching `COMMIT` / `ROLLBACK`. While set:
44 /// - auto-save is suppressed (mutations stay in-memory)
45 /// - nested `BEGIN` is rejected
46 /// - `ROLLBACK` restores `tables` from the snapshot
47 pub txn: Option<TxnSnapshot>,
48 /// Auto-VACUUM trigger (SQLR-10). After a page-releasing DDL
49 /// (DROP TABLE / DROP INDEX / ALTER TABLE DROP COLUMN) commits and
50 /// flushes, if the freelist exceeds this fraction of `page_count`
51 /// the engine quietly compacts the file. `None` disables the
52 /// trigger; defaults to `Some(DEFAULT_AUTO_VACUUM_THRESHOLD)`
53 /// (SQLite parity at 25%). Per-connection runtime state — not
54 /// persisted across reopens.
55 pub auto_vacuum_threshold: Option<f32>,
56 /// Phase 11.3 — current journal mode for the database. Default
57 /// is [`JournalMode::Wal`] (every pre-Phase-11 caller). Toggled
58 /// by `PRAGMA journal_mode = mvcc | wal`. The setting is
59 /// per-database (every `Connection` to this `Database` observes
60 /// the same value) — see the open question in
61 /// [`docs/concurrent-writes-plan.md`](../../../docs/concurrent-writes-plan.md)
62 /// §8 for the per-connection vs. per-database trade-off; v0
63 /// picked per-database for simplicity.
64 pub journal_mode: JournalMode,
65 /// Phase 11.3 — process-wide MVCC clock. Shared between every
66 /// `Connection` to this `Database` (and 11.4's `MvStore`).
67 /// Seeded from the WAL header's `clock_high_water` at open
68 /// time so timestamps don't repeat across reopens. Allocated
69 /// here even in `JournalMode::Wal` so `PRAGMA journal_mode =
70 /// mvcc` doesn't require lazy-creating the clock.
71 pub mvcc_clock: Arc<MvccClock>,
72 /// Phase 11.3 — in-memory version index. Allocated on every
73 /// `Database::new` so the toggle to MVCC mode doesn't require
74 /// a re-init step. Empty until 11.4 wires the commit path to
75 /// publish row versions; reads still go through the legacy
76 /// path until then.
77 pub mv_store: MvStore,
78}
79
80impl Database {
81 /// Creates an empty in-memory `Database`.
82 ///
83 /// # Examples
84 ///
85 /// ```
86 /// use sqlrite::Database;
87 /// let mut db = Database::new("my_db".to_string());
88 /// ```
89 pub fn new(db_name: String) -> Self {
90 let mvcc_clock = Arc::new(MvccClock::new(0));
91 let mv_store = MvStore::new(Arc::clone(&mvcc_clock));
92 Database {
93 db_name,
94 tables: HashMap::new(),
95 source_path: None,
96 pager: None,
97 txn: None,
98 auto_vacuum_threshold: Some(DEFAULT_AUTO_VACUUM_THRESHOLD),
99 journal_mode: JournalMode::default(),
100 mvcc_clock,
101 mv_store,
102 }
103 }
104
105 /// Phase 11.3 — current journal mode. Toggled by `PRAGMA
106 /// journal_mode = mvcc | wal`. `Wal` (the default) keeps every
107 /// pre-Phase-11 caller's behaviour; `Mvcc` opts the database
108 /// into MVCC + `BEGIN CONCURRENT` (Phase 11.4 wires this end-to-
109 /// end; today the toggle is observable but the read/write
110 /// paths don't change).
111 pub fn journal_mode(&self) -> JournalMode {
112 self.journal_mode
113 }
114
115 /// Phase 11.3 — switch the database's journal mode. `Wal → Mvcc`
116 /// is unconditional in v0 (no in-flight transactions to drain
117 /// because nothing publishes versions yet). `Mvcc → Wal` is
118 /// rejected if `mv_store` carries any committed versions —
119 /// switching back would silently strand them. v0 keeps this
120 /// strict; the loosening (and the discard-versions path) lands
121 /// when 11.4 starts populating the store.
122 pub fn set_journal_mode(&mut self, mode: JournalMode) -> Result<()> {
123 if self.journal_mode == mode {
124 return Ok(());
125 }
126 if mode == JournalMode::Wal && self.mv_store.total_versions() > 0 {
127 return Err(SQLRiteError::General(
128 "PRAGMA journal_mode: cannot switch back to 'wal' while \
129 the MVCC store holds committed versions"
130 .to_string(),
131 ));
132 }
133 self.journal_mode = mode;
134 Ok(())
135 }
136
137 /// Phase 11.3 — the shared MVCC logical clock. Returned by
138 /// reference (not cloned) because callers typically just read
139 /// `now()` / `tick()` against the same `Arc` `Database` already
140 /// holds.
141 pub fn mvcc_clock(&self) -> &Arc<MvccClock> {
142 &self.mvcc_clock
143 }
144
145 /// Phase 11.3 — the in-memory version index. Read-only access
146 /// is enough for 11.3's tests; 11.4 grows the commit-path
147 /// helpers into typed methods on `Database` rather than mutating
148 /// this directly.
149 pub fn mv_store(&self) -> &MvStore {
150 &self.mv_store
151 }
152
153 /// Returns the current auto-VACUUM threshold, or `None` if disabled.
154 /// See [`Database::set_auto_vacuum_threshold`] for semantics.
155 pub fn auto_vacuum_threshold(&self) -> Option<f32> {
156 self.auto_vacuum_threshold
157 }
158
159 /// Sets the auto-VACUUM threshold (SQLR-10). `Some(t)` with `t` in
160 /// `0.0..=1.0` arms the trigger: after a page-releasing DDL
161 /// commits, if the freelist exceeds `t * page_count` the engine
162 /// runs a full-file compact. `None` disables the trigger. Values
163 /// outside `0.0..=1.0` (or NaN / infinite) return a typed error
164 /// rather than silently saturating.
165 pub fn set_auto_vacuum_threshold(&mut self, threshold: Option<f32>) -> Result<()> {
166 if let Some(t) = threshold {
167 if !t.is_finite() || !(0.0..=1.0).contains(&t) {
168 return Err(SQLRiteError::General(format!(
169 "auto_vacuum_threshold must be in 0.0..=1.0, got {t}"
170 )));
171 }
172 }
173 self.auto_vacuum_threshold = threshold;
174 Ok(())
175 }
176
177 /// Returns true if the database contains a table with the specified key as a table name.
178 ///
179 pub fn contains_table(&self, table_name: String) -> bool {
180 self.tables.contains_key(&table_name)
181 }
182
183 /// Returns an immutable reference of `sql::db::table::Table` if the database contains a
184 /// table with the specified key as a table name.
185 ///
186 pub fn get_table(&self, table_name: String) -> Result<&Table> {
187 if let Some(table) = self.tables.get(&table_name) {
188 Ok(table)
189 } else {
190 Err(SQLRiteError::General(String::from("Table not found.")))
191 }
192 }
193
194 /// Returns an mutable reference of `sql::db::table::Table` if the database contains a
195 /// table with the specified key as a table name.
196 ///
197 pub fn get_table_mut(&mut self, table_name: String) -> Result<&mut Table> {
198 if let Some(table) = self.tables.get_mut(&table_name) {
199 Ok(table)
200 } else {
201 Err(SQLRiteError::General(String::from("Table not found.")))
202 }
203 }
204
205 /// Returns `true` if this database is attached to a file and that
206 /// file was opened in [`AccessMode::ReadOnly`]. In-memory databases
207 /// (no pager) and read-write file-backed databases both return
208 /// `false`. Callers use this to reject mutating SQL at the
209 /// dispatcher level so the in-memory tables don't drift away from
210 /// disk on a would-be INSERT / UPDATE / DELETE.
211 pub fn is_read_only(&self) -> bool {
212 self.pager
213 .as_ref()
214 .is_some_and(|p| p.access_mode() == AccessMode::ReadOnly)
215 }
216
217 /// Returns `true` while a `BEGIN … COMMIT`/`ROLLBACK` block is open.
218 pub fn in_transaction(&self) -> bool {
219 self.txn.is_some()
220 }
221
222 /// Starts a transaction: snapshots every table deep-cloned so that
223 /// a later `rollback_transaction` can restore the pre-BEGIN state.
224 /// Nested transactions are rejected — explicit savepoints are not
225 /// on this phase's roadmap. Errors on a read-only database.
226 pub fn begin_transaction(&mut self) -> Result<()> {
227 if self.in_transaction() {
228 return Err(SQLRiteError::General(
229 "cannot BEGIN: a transaction is already open".to_string(),
230 ));
231 }
232 if self.is_read_only() {
233 return Err(SQLRiteError::General(
234 "cannot BEGIN: database is opened read-only".to_string(),
235 ));
236 }
237 let snapshot = TxnSnapshot {
238 tables: self
239 .tables
240 .iter()
241 .map(|(k, v)| (k.clone(), v.deep_clone()))
242 .collect(),
243 };
244 self.txn = Some(snapshot);
245 Ok(())
246 }
247
248 /// Drops the transaction snapshot and returns it for the caller to
249 /// discard. The in-memory `tables` state is the new committed state;
250 /// the caller is responsible for flushing to disk via the pager.
251 /// Errors if no transaction is open.
252 pub fn commit_transaction(&mut self) -> Result<()> {
253 if self.txn.is_none() {
254 return Err(SQLRiteError::General(
255 "cannot COMMIT: no transaction is open".to_string(),
256 ));
257 }
258 self.txn = None;
259 Ok(())
260 }
261
262 /// Restores `tables` from the transaction snapshot and clears it.
263 /// Errors if no transaction is open.
264 pub fn rollback_transaction(&mut self) -> Result<()> {
265 let Some(snapshot) = self.txn.take() else {
266 return Err(SQLRiteError::General(
267 "cannot ROLLBACK: no transaction is open".to_string(),
268 ));
269 };
270 self.tables = snapshot.tables;
271 Ok(())
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::sql::dialect::SqlriteDialect;
279 use crate::sql::parser::create::CreateQuery;
280 use sqlparser::parser::Parser;
281
282 #[test]
283 fn new_database_create_test() {
284 let db_name = String::from("my_db");
285 let db = Database::new(db_name.to_string());
286 assert_eq!(db.db_name, db_name);
287 }
288
289 #[test]
290 fn contains_table_test() {
291 let db_name = String::from("my_db");
292 let mut db = Database::new(db_name.to_string());
293
294 let query_statement = "CREATE TABLE contacts (
295 id INTEGER PRIMARY KEY,
296 first_name TEXT NOT NULL,
297 last_name TEXT NOT NULl,
298 email TEXT NOT NULL UNIQUE
299 );";
300 let dialect = SqlriteDialect::new();
301 let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
302 if ast.len() > 1 {
303 panic!("Expected a single query statement, but there are more then 1.")
304 }
305 let query = ast.pop().unwrap();
306
307 let create_query = CreateQuery::new(&query).unwrap();
308 let table_name = &create_query.table_name;
309 db.tables
310 .insert(table_name.to_string(), Table::new(create_query));
311
312 assert!(db.contains_table("contacts".to_string()));
313 }
314
315 #[test]
316 fn get_table_test() {
317 let db_name = String::from("my_db");
318 let mut db = Database::new(db_name.to_string());
319
320 let query_statement = "CREATE TABLE contacts (
321 id INTEGER PRIMARY KEY,
322 first_name TEXT NOT NULL,
323 last_name TEXT NOT NULl,
324 email TEXT NOT NULL UNIQUE
325 );";
326 let dialect = SqlriteDialect::new();
327 let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
328 if ast.len() > 1 {
329 panic!("Expected a single query statement, but there are more then 1.")
330 }
331 let query = ast.pop().unwrap();
332
333 let create_query = CreateQuery::new(&query).unwrap();
334 let table_name = &create_query.table_name;
335 db.tables
336 .insert(table_name.to_string(), Table::new(create_query));
337
338 let table = db.get_table(String::from("contacts")).unwrap();
339 assert_eq!(table.columns.len(), 4);
340
341 let table = db.get_table_mut(String::from("contacts")).unwrap();
342 table.last_rowid += 1;
343 assert_eq!(table.columns.len(), 4);
344 assert_eq!(table.last_rowid, 1);
345 }
346}