Skip to main content

inillucent_sqlite_reader/
lib.rs

1//! A read-only reader for SQLite 3 database files.
2//!
3//! Invariant: this crate opens a file and never writes to it. `DatabaseOptions`
4//! is set read-only, no journal is attached, and there is no code path here that
5//! calls a mutating pager method. A migration that damaged its source would be
6//! worse than one that failed.
7//!
8//! ## Why it exists after file-format compatibility stopped being a goal
9//!
10//! The rearchitecture plan drops SQLite file-format compatibility as
11//! a requirement, but two things still need to read a SQLite file:
12//!
13//! - **The correctness gate.** The differential harness compares inillucent against
14//!   SQLite 3.53.4 executing the same SQL on the same *logical* data. With the
15//!   shared file gone, the inillucent side gets its data by importing the SQLite
16//!   fixture through this reader. Same rows, different bytes.
17//! - **Migration.** Every database the previous tickets produced is in SQLite
18//!   format, and `inillucent-migrate` has to be able to read one.
19//!
20//! It is the read half of `inillucent-storage` with a narrow interface in front of
21//! it, which is exactly what the TDD's component triage says survives that
22//! crate's deletion. This crate reuses `inillucent-storage`'s pager and b-tree
23//! cursor rather than duplicating them, so there is one page decoder in the
24//! workspace rather than two that can disagree.
25//!
26//! **This crate is not the only thing holding `inillucent-storage` up, and saying
27//! so here was wrong.** `inillucent-catalog` reaches for the same pager and the
28//! same cursor in `load.rs`, `ddl.rs`, `analyze.rs` and `rebuild.rs`, on the
29//! shipping read path rather than on an import path. Deleting
30//! `inillucent-storage` therefore means re-pointing `inillucent-catalog` too,
31//! which is task-1816 Phase 5's job and not this crate's. Anybody reading this
32//! header to find out what stands between the workspace and that deletion needs
33//! both names.
34//!
35//! ## What it does not do
36//!
37//! No SQL, no planner, no write path, no journal recovery beyond what opening a
38//! clean file needs, and no attempt to be fast: an import runs once per fixture
39//! and its cost is not on any measured path.
40
41#![forbid(unsafe_code)]
42#![deny(missing_docs)]
43#![deny(clippy::indexing_slicing)]
44#![deny(clippy::unwrap_used)]
45#![deny(clippy::expect_used)]
46#![deny(clippy::panic)]
47#![cfg_attr(
48    test,
49    allow(
50        clippy::expect_used,
51        clippy::indexing_slicing,
52        clippy::panic,
53        clippy::unwrap_used
54    )
55)]
56
57use std::path::PathBuf;
58use std::sync::Arc;
59
60use inillucent_base::error::{corrupt, misuse};
61use inillucent_base::ids::PageId;
62use inillucent_base::limits::Limits;
63use inillucent_base::DbResult;
64use inillucent_storage::cursor::BTreeCursor;
65use inillucent_storage::pager::Pager;
66use inillucent_transaction::recovery::{open_database, DatabaseOptions};
67use inillucent_tree::datum::{Datum, OwnedDatum};
68use inillucent_value::record::{FieldSpan, KeyInfo, RecordRef};
69use inillucent_value::Value;
70use inillucent_vfs::path::DbPath;
71use inillucent_vfs::{OsVfs, Vfs};
72
73/// One row of `sqlite_schema`.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct SchemaObject {
76    /// `table`, `index`, `view` or `trigger`.
77    pub kind: String,
78    /// The object's name.
79    pub name: String,
80    /// The table the object belongs to; for a table, its own name.
81    pub table: String,
82    /// The root page, or 0 for an object with no b-tree.
83    pub root: u32,
84    /// The `CREATE` statement the object was declared with.
85    pub sql: String,
86}
87
88impl SchemaObject {
89    /// Returns the column names the `CREATE TABLE` statement declares.
90    ///
91    /// A deliberately small parser: it takes the text between the outermost
92    /// parentheses, splits on commas that are not inside parentheses, and reads
93    /// the first identifier of each part. That is enough for the fixtures and
94    /// for the tables `inillucent-migrate` has to read, and it refuses rather than
95    /// guesses on anything it does not recognise - a table constraint
96    /// (`PRIMARY KEY (...)`, `UNIQUE (...)`, `FOREIGN KEY`, `CHECK`) is skipped
97    /// rather than mistaken for a column.
98    ///
99    /// A general answer needs the real parser in `inillucent-sql`, and this crate
100    /// deliberately sits below it so that a migration tool does not drag the
101    /// front end in. Phase 4 revisits this when DDL import needs types and
102    /// constraints as well as names.
103    pub fn column_names(&self) -> DbResult<Vec<String>> {
104        let open = self
105            .sql
106            .find('(')
107            .ok_or_else(|| corrupt(format!("{} has no column list", self.name)))?;
108        let close = self
109            .sql
110            .rfind(')')
111            .ok_or_else(|| corrupt(format!("{} has no column list", self.name)))?;
112        if close <= open {
113            return Err(corrupt(format!(
114                "{}'s column list is inside out",
115                self.name
116            )));
117        }
118        let body = self.sql.get(open.saturating_add(1)..close).unwrap_or("");
119        let mut names = Vec::new();
120        let mut depth = 0i32;
121        let mut part = String::new();
122        for character in body.chars() {
123            match character {
124                '(' => {
125                    depth = depth.saturating_add(1);
126                    part.push(character);
127                }
128                ')' => {
129                    depth = depth.saturating_sub(1);
130                    part.push(character);
131                }
132                ',' if depth == 0 => {
133                    push_column_name(&part, &mut names);
134                    part.clear();
135                }
136                _ => part.push(character),
137            }
138        }
139        push_column_name(&part, &mut names);
140        if names.is_empty() {
141            return Err(corrupt(format!("{} declares no columns", self.name)));
142        }
143        Ok(names)
144    }
145}
146
147/// The keywords that begin a table constraint rather than a column.
148const TABLE_CONSTRAINTS: [&str; 6] = [
149    "primary",
150    "unique",
151    "check",
152    "foreign",
153    "constraint",
154    "exclude",
155];
156
157/// Adds one column-definition fragment's name to the list, if it is a column.
158///
159/// @param part - one comma-separated fragment of the column list
160/// @param names - the list being built
161fn push_column_name(part: &str, names: &mut Vec<String>) {
162    let trimmed = part.trim();
163    let Some(first) = trimmed.split_whitespace().next() else {
164        return;
165    };
166    if TABLE_CONSTRAINTS
167        .iter()
168        .any(|keyword| first.eq_ignore_ascii_case(keyword))
169    {
170        return;
171    }
172    let cleaned = first.trim_matches(|c| c == '"' || c == '`' || c == '[' || c == ']');
173    if cleaned.is_empty() {
174        return;
175    }
176    names.push(cleaned.to_string());
177}
178
179/// An open SQLite file, held for reading.
180pub struct SqliteFile {
181    pager: Pager,
182    limits: Limits,
183}
184
185impl SqliteFile {
186    /// Opens a SQLite database file read-only and starts a read transaction.
187    ///
188    /// @param path - the database file
189    pub fn open(path: PathBuf) -> DbResult<SqliteFile> {
190        let vfs: Arc<dyn Vfs> = Arc::new(OsVfs::new());
191        let options = DatabaseOptions {
192            writable: false,
193            ..DatabaseOptions::default()
194        };
195        let mut pager = open_database(vfs, &DbPath::new(path), options)?;
196        pager.begin_read()?;
197        Ok(SqliteFile {
198            pager,
199            limits: Limits::default(),
200        })
201    }
202
203    /// Returns the file's page size in bytes.
204    pub fn page_size(&self) -> u32 {
205        self.pager.page_size().bytes()
206    }
207
208    /// Returns the number of pages in the file.
209    pub fn page_count(&self) -> u32 {
210        self.pager.page_count()
211    }
212
213    /// Returns the file's catalog, with indexes attached to their tables.
214    ///
215    /// This goes through `inillucent-catalog`'s own loader rather than parsing the
216    /// schema again here. There is one schema reader in the workspace and this
217    /// is not a second one: an index's key columns, its collations and its
218    /// descending flags all come from parsing `CREATE INDEX` against the
219    /// table it indexes, and a fixture import that got any of them wrong would
220    /// build a tree in an order the executor then assumes wrongly.
221    ///
222    /// @param name - the name to attach the database under, normally `main`
223    pub fn catalog(
224        &mut self,
225        name: &[u8],
226    ) -> DbResult<inillucent_catalog::snapshot::DatabaseCatalog> {
227        inillucent_catalog::load::load_database_catalog(&mut self.pager, name, 0)
228    }
229
230    /// Returns every row of `sqlite_schema`.
231    ///
232    /// **Reads the file's own header encoding, not a fixed one.** This and the
233    /// two record readers below used to build every `RecordRef` with
234    /// `TextEncoding::Utf8` regardless of what the file's header at offset 56
235    /// declared, so a UTF-16LE or UTF-16BE fixture came back with every text
236    /// field decoded as if it were UTF-8: two bytes per character, so ASCII
237    /// text like `alpha` read back as `a\0l\0p\0h\0a\0`. `Pager::text_encoding`
238    /// already parses that header field correctly - `cursor.rs`, `mutate.rs`
239    /// and `schema.rs` in `inillucent-storage` all read it before decoding a
240    /// record - this crate simply never asked.
241    pub fn schema(&mut self) -> DbResult<Vec<SchemaObject>> {
242        let root = PageId::from_persisted(1)?;
243        let mut cursor = BTreeCursor::table(root);
244        let mut payload: Vec<u8> = Vec::with_capacity(512);
245        let mut fields: Vec<FieldSpan> = Vec::with_capacity(8);
246        let mut out = Vec::new();
247        let encoding = self.pager.text_encoding();
248        let mut more = cursor.first(&mut self.pager)?;
249        while more {
250            cursor.payload_into(&mut self.pager, &self.limits, &mut payload)?;
251            let header_len = RecordRef::parse_into(&payload, &self.limits, &mut fields)?;
252            let record = RecordRef::with_fields(&payload, &fields, header_len, encoding);
253            out.push(SchemaObject {
254                kind: text_at(&record, 0)?,
255                name: text_at(&record, 1)?,
256                table: text_at(&record, 2)?,
257                root: u32::try_from(integer_at(&record, 3)?)
258                    .map_err(|_| corrupt("a root page that is not a page number"))?,
259                sql: text_at(&record, 4)?,
260            });
261            more = cursor.next(&mut self.pager)?;
262        }
263        Ok(out)
264    }
265
266    /// Returns one named schema object.
267    ///
268    /// @param kind - `table` or `index`
269    /// @param name - the object's name
270    pub fn object(&mut self, kind: &str, name: &str) -> DbResult<SchemaObject> {
271        self.schema()?
272            .into_iter()
273            .find(|object| object.kind == kind && object.name == name)
274            .ok_or_else(|| misuse(format!("no {kind} named {name} in this file")))
275    }
276
277    /// Reads every row of a table b-tree.
278    ///
279    /// The rowid is prepended as column 0, which is what a rowid-clustered tree
280    /// in the new format holds: SQLite stores the rowid in the cell key rather
281    /// than in the record, and an `INTEGER PRIMARY KEY` column's record field is
282    /// NULL because of it. Prepending makes the row the new engine's shape.
283    ///
284    /// @param root - the table's root page
285    /// @param columns - how many record fields the table declares
286    pub fn read_table(&mut self, root: u32, columns: usize) -> DbResult<Vec<Vec<OwnedDatum>>> {
287        let root = PageId::from_persisted(root)?;
288        let mut cursor = BTreeCursor::table(root);
289        let mut payload: Vec<u8> = Vec::with_capacity(512);
290        let mut fields: Vec<FieldSpan> = Vec::with_capacity(16);
291        let mut out = Vec::new();
292        let encoding = self.pager.text_encoding();
293        let mut more = cursor.first(&mut self.pager)?;
294        while more {
295            let rowid = cursor.rowid()?;
296            cursor.payload_into(&mut self.pager, &self.limits, &mut payload)?;
297            let header_len = RecordRef::parse_into(&payload, &self.limits, &mut fields)?;
298            let record = RecordRef::with_fields(&payload, &fields, header_len, encoding);
299            let mut row = Vec::with_capacity(columns.saturating_add(1));
300            row.push(OwnedDatum::Int(rowid));
301            for index in 0..columns {
302                row.push(owned_from_record(&record, index)?);
303            }
304            out.push(row);
305            more = cursor.next(&mut self.pager)?;
306        }
307        Ok(out)
308    }
309
310    /// Reads every entry of an index b-tree.
311    ///
312    /// An index entry's record is the indexed columns followed by the rowid, so
313    /// the returned row is already the new format's index-tree row shape and no
314    /// column is prepended.
315    ///
316    /// @param root - the index's root page
317    /// @param columns - how many fields an entry holds, rowid included
318    pub fn read_index(&mut self, root: u32, columns: usize) -> DbResult<Vec<Vec<OwnedDatum>>> {
319        let root = PageId::from_persisted(root)?;
320        // A full walk never compares, so plain binary ordering over the key
321        // columns is enough to build the cursor.
322        let mut cursor = BTreeCursor::index(root, KeyInfo::binary(columns));
323        let mut payload: Vec<u8> = Vec::with_capacity(512);
324        let mut fields: Vec<FieldSpan> = Vec::with_capacity(16);
325        let mut out = Vec::new();
326        let encoding = self.pager.text_encoding();
327        let mut more = cursor.first(&mut self.pager)?;
328        while more {
329            cursor.payload_into(&mut self.pager, &self.limits, &mut payload)?;
330            let header_len = RecordRef::parse_into(&payload, &self.limits, &mut fields)?;
331            let record = RecordRef::with_fields(&payload, &fields, header_len, encoding);
332            let mut row = Vec::with_capacity(columns);
333            for index in 0..columns {
334                row.push(owned_from_record(&record, index)?);
335            }
336            out.push(row);
337            more = cursor.next(&mut self.pager)?;
338        }
339        Ok(out)
340    }
341}
342
343/// Converts one record field into an owned value.
344///
345/// @param record - the decoded record
346/// @param index - which field to convert
347fn owned_from_record(record: &RecordRef<'_>, index: usize) -> DbResult<OwnedDatum> {
348    Ok(match record.value(index)? {
349        Value::Null => OwnedDatum::Null,
350        Value::Integer(number) => OwnedDatum::Int(number),
351        Value::Real(number) => OwnedDatum::Real(number),
352        Value::Text(text) => OwnedDatum::Text(text.utf8_bytes().into_owned()),
353        Value::Blob(blob) => OwnedDatum::Blob(blob.raw().to_vec()),
354    })
355}
356
357/// Returns one record field as a string.
358///
359/// @param record - the decoded record
360/// @param index - which field to read
361fn text_at(record: &RecordRef<'_>, index: usize) -> DbResult<String> {
362    match record.value(index)? {
363        Value::Text(text) => Ok(String::from_utf8_lossy(&text.utf8_bytes()).into_owned()),
364        Value::Null => Ok(String::new()),
365        other => Err(corrupt(format!(
366            "expected text in schema field {index}, found {:?}",
367            other.storage_class()
368        ))),
369    }
370}
371
372/// Returns one record field as an integer.
373///
374/// @param record - the decoded record
375/// @param index - which field to read
376fn integer_at(record: &RecordRef<'_>, index: usize) -> DbResult<i64> {
377    match record.value(index)? {
378        Value::Integer(number) => Ok(number),
379        Value::Null => Ok(0),
380        other => Err(corrupt(format!(
381            "expected an integer in schema field {index}, found {:?}",
382            other.storage_class()
383        ))),
384    }
385}
386
387/// Borrows an owned row, for handing to the tree builder.
388///
389/// @param row - the owned row
390pub fn borrow(row: &[OwnedDatum]) -> Vec<Datum<'_>> {
391    row.iter().map(OwnedDatum::borrow).collect()
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn object(sql: &str) -> SchemaObject {
399        SchemaObject {
400            kind: "table".to_string(),
401            name: "t".to_string(),
402            table: "t".to_string(),
403            root: 2,
404            sql: sql.to_string(),
405        }
406    }
407
408    /// The fixture's own `CREATE TABLE` yields its five columns in order.
409    #[test]
410    fn the_fixture_schema_parses() {
411        let names = object(
412            "CREATE TABLE main_table(id INTEGER PRIMARY KEY, key INTEGER NOT NULL, \
413             category INTEGER NOT NULL, label TEXT NOT NULL, payload BLOB)",
414        )
415        .column_names()
416        .unwrap();
417        assert_eq!(names, ["id", "key", "category", "label", "payload"]);
418    }
419
420    /// A table constraint is skipped rather than read as a column.
421    #[test]
422    fn table_constraints_are_not_columns() {
423        let names = object(
424            "CREATE TABLE t(a INTEGER, b TEXT, PRIMARY KEY (a, b), \
425             FOREIGN KEY (b) REFERENCES u(x), CHECK (a > 0))",
426        )
427        .column_names()
428        .unwrap();
429        assert_eq!(names, ["a", "b"]);
430    }
431
432    /// A type with its own parentheses does not end the column early.
433    #[test]
434    fn parenthesised_types_stay_in_one_column() {
435        let names = object("CREATE TABLE t(a VARCHAR(20), b DECIMAL(10, 2), c INT)")
436            .column_names()
437            .unwrap();
438        assert_eq!(names, ["a", "b", "c"]);
439    }
440
441    /// Quoted identifiers are unquoted.
442    #[test]
443    fn quoted_identifiers_are_unquoted() {
444        let names = object("CREATE TABLE t(\"a b\" INTEGER, `c` TEXT, [d] BLOB)")
445            .column_names()
446            .unwrap();
447        assert_eq!(names, ["a", "c", "d"]);
448    }
449
450    /// A statement with no column list is refused rather than guessed at.
451    #[test]
452    fn a_missing_column_list_is_refused() {
453        assert!(object("CREATE TABLE t").column_names().is_err());
454        assert!(object("CREATE TABLE t)(").column_names().is_err());
455        assert!(object("CREATE TABLE t()").column_names().is_err());
456    }
457}