Skip to main content

sqlite_core/
attribution.rs

1//! Best-effort, **dependency-free** attribution primitives that reconnect a
2//! carved deleted row to the live table it most plausibly belonged to.
3//!
4//! A deleted row in free space has lost its hard table linkage (the b-tree that
5//! owned it no longer points at it). This module supplies the two structural
6//! facts the forensic layer needs to reattach it honestly:
7//!
8//! 1. [`column_names`] — a hand-rolled `CREATE TABLE` column-name extractor (no
9//!    SQL-parser dependency). It returns the declared column names when it can
10//!    parse them with confidence, or `None` so the caller falls back to generic
11//!    `c0..cN` rather than emit wrong names.
12//! 2. [`column_affinity`] — each column's declared *affinity* (file-format
13//!    §3.1), the shape used to match a freed row whose page linkage is gone.
14//!
15//! Pure string/structure work: panic-free, `forbid(unsafe)`, no new deps.
16
17/// A live (schema-present) table, as read from `sqlite_master`: its name,
18/// b-tree rootpage, and per-column shape used to attribute a freed row whose
19/// page linkage is gone.
20///
21/// `column_names` is `Some` only when the `CREATE TABLE` statement parsed with
22/// confidence AND its column count equals `affinities.len()`; otherwise `None`,
23/// so a caller falls back to generic `c0..cN` rather than risk wrong names. The
24/// affinities are always present (one per parsed column) and drive shape
25/// matching.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LiveTable {
28    /// Table name from `sqlite_master.name`.
29    pub name: String,
30    /// 1-based b-tree rootpage from `sqlite_master.rootpage`.
31    pub rootpage: u32,
32    /// Declared column names, or `None` when low-confidence (caller uses `c0..`).
33    pub column_names: Option<Vec<String>>,
34    /// Declared column affinity for each column, in column order.
35    pub affinities: Vec<Affinity>,
36    /// The table's `CREATE TABLE` statement (`sqlite_master.sql`), threaded so a
37    /// consumer can re-derive schema properties (e.g. `AUTOINCREMENT`-ness via
38    /// [`crate::is_autoincrement`]) without re-reading the schema. Empty when the
39    /// schema row carried no/invalid sql (a damaged schema).
40    pub create_sql: String,
41}
42
43/// Column type **affinity** as defined by the `SQLite` file format (§3.1, "Type
44/// Affinity"). Derived from a column's declared type by the documented
45/// substring rules, in priority order. A column with no declared type is
46/// `Blob` (SQLite's "BLOB or no datatype" affinity).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Affinity {
49    /// Declared type contains "INT" → INTEGER affinity.
50    Integer,
51    /// Declared type contains "CHAR", "CLOB", or "TEXT" → TEXT affinity.
52    Text,
53    /// Declared type contains "BLOB", or no declared type → BLOB affinity.
54    Blob,
55    /// Declared type contains "REAL", "FLOA", or "DOUB" → REAL affinity.
56    Real,
57    /// Anything else → NUMERIC affinity.
58    Numeric,
59}
60
61/// Compute a column's [`Affinity`] from its declared type string per the
62/// file-format §3.1 rules, applied in the documented priority order:
63/// INT → TEXT → BLOB/none → REAL → NUMERIC. Case-insensitive; an empty (no)
64/// declared type is [`Affinity::Blob`].
65#[must_use]
66pub fn column_affinity(declared_type: &str) -> Affinity {
67    let t = declared_type.to_ascii_uppercase();
68    if t.contains("INT") {
69        Affinity::Integer
70    } else if t.contains("CHAR") || t.contains("CLOB") || t.contains("TEXT") {
71        Affinity::Text
72    } else if t.is_empty() || t.contains("BLOB") {
73        Affinity::Blob
74    } else if t.contains("REAL") || t.contains("FLOA") || t.contains("DOUB") {
75        Affinity::Real
76    } else {
77        Affinity::Numeric
78    }
79}
80
81/// Best-effort extraction of `(column_name, declared_type)` for each column
82/// declared in a `CREATE TABLE` statement, **without** a SQL-parser dependency.
83///
84/// Algorithm (deliberately conservative — wrong names are worse than no names):
85/// 1. Take the outermost parenthesized list (first `(` to its matching `)`,
86///    tracking nesting and skipping over `'…'`, `"…"`, `` `…` ``, and `[…]`
87///    so a comma or paren inside a string/identifier/`CHECK(...)` never splits).
88/// 2. Split that list on **top-level** commas (depth 0).
89/// 3. For each part, read the first identifier — handling `"quoted"`,
90///    `` `backtick` ``, `[bracketed]`, and bare names. Skip a part whose first
91///    word is a table-level constraint keyword (`CONSTRAINT`, `PRIMARY`,
92///    `UNIQUE`, `CHECK`, `FOREIGN`, `KEY`) — those declare constraints, not
93///    columns.
94/// 4. The remaining tokens of the part (up to the next top-level comma) form the
95///    declared type; an empty remainder is no declared type.
96///
97/// Returns `None` when no parenthesized list is found or no column survives —
98/// the low-confidence signal the caller turns into a `c0..cN` fallback.
99#[must_use]
100pub fn column_defs(create_sql: &str) -> Option<Vec<(String, String)>> {
101    let inner = outermost_paren_body(create_sql)?;
102    let parts = split_top_level(inner);
103    let mut cols = Vec::new();
104    for part in parts {
105        let part = part.trim();
106        if part.is_empty() {
107            continue;
108        }
109        let mut tokens = ColumnTokens::new(part);
110        let Some(name) = tokens.next_identifier() else {
111            continue; // cov:unreachable: a non-empty part always has a leading token
112        };
113        if is_table_constraint_keyword(&name) {
114            continue;
115        }
116        let declared_type = tokens.rest_as_type();
117        cols.push((name, declared_type));
118    }
119    if cols.is_empty() {
120        None
121    } else {
122        Some(cols)
123    }
124}
125
126/// Just the column **names** from [`column_defs`] (the common need). `None` when
127/// the body cannot be parsed with confidence.
128#[must_use]
129pub fn column_names(create_sql: &str) -> Option<Vec<String>> {
130    Some(
131        column_defs(create_sql)?
132            .into_iter()
133            .map(|(n, _)| n)
134            .collect(),
135    )
136}
137
138/// The substring between the first top-level `(` and its matching `)`,
139/// respecting string/identifier quoting so a delimiter inside `'…'` / `"…"` /
140/// `` `…` `` / `[…]` does not affect nesting. `None` if unbalanced or absent.
141fn outermost_paren_body(sql: &str) -> Option<&str> {
142    let bytes = sql.as_bytes();
143    let mut i = 0usize;
144    let mut start = None;
145    let mut depth = 0i32;
146    let mut quote: Option<u8> = None;
147    while i < bytes.len() {
148        let c = bytes[i];
149        if let Some(q) = quote {
150            if c == q {
151                quote = None;
152            }
153            i += 1;
154            continue;
155        }
156        match c {
157            b'\'' | b'"' | b'`' => quote = Some(c),
158            b'[' => quote = Some(b']'),
159            b'(' => {
160                if start.is_none() {
161                    start = Some(i + 1);
162                    depth = 1;
163                } else {
164                    depth += 1;
165                }
166            }
167            b')' if start.is_some() => {
168                depth -= 1;
169                if depth == 0 {
170                    return sql.get(start?..i);
171                }
172            }
173            _ => {}
174        }
175        i += 1;
176    }
177    None
178}
179
180/// Split a column-list body on **top-level** commas (depth 0), respecting nested
181/// parens and the four quote styles so a comma inside `CHECK(a,b)` or a quoted
182/// string never splits a column.
183fn split_top_level(body: &str) -> Vec<&str> {
184    let bytes = body.as_bytes();
185    let mut parts = Vec::new();
186    let mut start = 0usize;
187    let mut depth = 0i32;
188    let mut quote: Option<u8> = None;
189    let mut i = 0usize;
190    while i < bytes.len() {
191        let c = bytes[i];
192        if let Some(q) = quote {
193            if c == q {
194                quote = None;
195            }
196            i += 1;
197            continue;
198        }
199        match c {
200            b'\'' | b'"' | b'`' => quote = Some(c),
201            b'[' => quote = Some(b']'),
202            b'(' => depth += 1,
203            b')' => depth = depth.saturating_sub(1),
204            b',' if depth == 0 => {
205                if let Some(p) = body.get(start..i) {
206                    parts.push(p);
207                }
208                start = i + 1;
209            }
210            _ => {}
211        }
212        i += 1;
213    }
214    if let Some(p) = body.get(start..) {
215        parts.push(p);
216    }
217    parts
218}
219
220/// Whether `word` (already extracted as a part's first identifier) names a
221/// table-level constraint clause rather than a column.
222fn is_table_constraint_keyword(word: &str) -> bool {
223    matches!(
224        word.to_ascii_uppercase().as_str(),
225        "CONSTRAINT" | "PRIMARY" | "UNIQUE" | "CHECK" | "FOREIGN" | "KEY"
226    )
227}
228
229/// A tiny cursor over one column-definition part that yields the leading
230/// identifier (quoted/bracketed/backtick/bare) and then the remaining tokens as
231/// the declared type.
232struct ColumnTokens<'a> {
233    rest: &'a str,
234}
235
236impl<'a> ColumnTokens<'a> {
237    fn new(part: &'a str) -> Self {
238        Self { rest: part }
239    }
240
241    /// Read and consume the leading identifier of the part, unescaping the
242    /// quote styles. `SQLite` tolerates single-quoted identifiers in
243    /// `CREATE TABLE` (seen in real fixtures), so `'…'` is unquoted like `"…"` /
244    /// `` `…` ``. `None` when the part has no identifier start.
245    fn next_identifier(&mut self) -> Option<String> {
246        let s = self.rest.trim_start();
247        let bytes = s.as_bytes();
248        let first = *bytes.first()?;
249        let (name, consumed) = match first {
250            b'"' | b'`' | b'\'' => read_quoted(s, char::from(first)),
251            b'[' => read_quoted(s, ']'),
252            _ => read_bare(s),
253        }?;
254        self.rest = s.get(consumed..).unwrap_or("");
255        Some(name)
256    }
257
258    /// The remainder of the part (after the column name), trimmed, as the
259    /// declared type. We keep the whole remainder so `VARCHAR(20)` and
260    /// `DOUBLE PRECISION` survive. An empty remainder means no declared type.
261    fn rest_as_type(&self) -> String {
262        self.rest.trim().to_string()
263    }
264}
265
266/// Read a quoted identifier opened by the first byte (closing delimiter
267/// `close`), returning `(unescaped_name, bytes_consumed_including_delimiters)`.
268/// A doubled closing delimiter (`""` inside `"…"`) is an escaped literal per
269/// SQL rules (not applicable to `[...]`).
270fn read_quoted(s: &str, close: char) -> Option<(String, usize)> {
271    let bytes = s.as_bytes();
272    let close_b = close as u8;
273    let mut name = String::new();
274    let mut i = 1usize; // skip opening delimiter
275    while i < bytes.len() {
276        let c = bytes[i];
277        if c == close_b {
278            if close_b != b']' && bytes.get(i + 1) == Some(&close_b) {
279                name.push(close);
280                i += 2;
281                continue;
282            }
283            return Some((name, i + 1));
284        }
285        name.push(char::from(c));
286        i += 1;
287    }
288    None // unterminated quote → low confidence
289}
290
291/// Read a bare (unquoted) identifier: every byte up to the first whitespace or
292/// `(`. Returns `(name, bytes_consumed)`.
293fn read_bare(s: &str) -> Option<(String, usize)> {
294    let bytes = s.as_bytes();
295    let mut i = 0usize;
296    while i < bytes.len() {
297        let c = bytes[i];
298        if c.is_ascii_whitespace() || c == b'(' {
299            break;
300        }
301        i += 1;
302    }
303    if i == 0 {
304        return None; // cov:unreachable: callers trim and check non-empty before this
305    }
306    Some((s.get(..i)?.to_string(), i))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn affinity_rules_match_spec_priority() {
315        assert_eq!(column_affinity("INTEGER"), Affinity::Integer);
316        assert_eq!(column_affinity("BIGINT"), Affinity::Integer);
317        assert_eq!(column_affinity("VARCHAR(255)"), Affinity::Text);
318        assert_eq!(column_affinity("CLOB"), Affinity::Text);
319        assert_eq!(column_affinity("TEXT"), Affinity::Text);
320        assert_eq!(column_affinity("BLOB"), Affinity::Blob);
321        assert_eq!(column_affinity(""), Affinity::Blob);
322        assert_eq!(column_affinity("REAL"), Affinity::Real);
323        assert_eq!(column_affinity("DOUBLE"), Affinity::Real);
324        assert_eq!(column_affinity("FLOAT"), Affinity::Real);
325        assert_eq!(column_affinity("NUMERIC"), Affinity::Numeric);
326        assert_eq!(column_affinity("DATETIME"), Affinity::Numeric);
327        assert_eq!(column_affinity("BOOLEAN"), Affinity::Numeric);
328    }
329
330    #[test]
331    fn plain_columns() {
332        let cols = column_names("CREATE TABLE t (id INTEGER, name TEXT, age INT)").unwrap();
333        assert_eq!(cols, vec!["id", "name", "age"]);
334    }
335
336    #[test]
337    fn quoted_bracketed_backtick_identifiers() {
338        let sql = r#"CREATE TABLE "My Tbl" ("first name" TEXT, [second] INTEGER, `third` BLOB)"#;
339        let cols = column_names(sql).unwrap();
340        assert_eq!(cols, vec!["first name", "second", "third"]);
341    }
342
343    #[test]
344    fn skips_table_level_constraints() {
345        let sql = "CREATE TABLE t (\
346            id INTEGER PRIMARY KEY, \
347            a TEXT, \
348            b REAL, \
349            PRIMARY KEY (id), \
350            UNIQUE (a), \
351            CONSTRAINT fk FOREIGN KEY (b) REFERENCES other(x), \
352            CHECK (a <> b))";
353        let cols = column_names(sql).unwrap();
354        assert_eq!(cols, vec!["id", "a", "b"]);
355    }
356
357    #[test]
358    fn check_constraint_with_commas_does_not_oversplit() {
359        let sql = "CREATE TABLE t (x INTEGER, y INTEGER, CHECK (x IN (1, 2, 3)))";
360        let cols = column_names(sql).unwrap();
361        assert_eq!(cols, vec!["x", "y"]);
362    }
363
364    #[test]
365    fn typed_columns_with_parenthesized_and_multiword_types() {
366        let sql = "CREATE TABLE t (a VARCHAR(20), b DOUBLE PRECISION, c DECIMAL(10,2))";
367        let defs = column_defs(sql).unwrap();
368        assert_eq!(defs[0], ("a".to_string(), "VARCHAR(20)".to_string()));
369        assert_eq!(defs[1], ("b".to_string(), "DOUBLE PRECISION".to_string()));
370        assert_eq!(defs[2], ("c".to_string(), "DECIMAL(10,2)".to_string()));
371        assert_eq!(column_affinity(&defs[0].1), Affinity::Text);
372        assert_eq!(column_affinity(&defs[1].1), Affinity::Real);
373        assert_eq!(column_affinity(&defs[2].1), Affinity::Numeric);
374    }
375
376    #[test]
377    fn no_parens_is_low_confidence_none() {
378        assert_eq!(column_names("CREATE TABLE t"), None);
379        assert_eq!(column_names("not even ddl"), None);
380    }
381
382    #[test]
383    fn unterminated_quote_yields_none() {
384        assert_eq!(column_names(r#"CREATE TABLE t ("oops)"#), None);
385    }
386
387    #[test]
388    fn empty_column_list_is_none() {
389        assert_eq!(column_names("CREATE TABLE t ()"), None);
390    }
391
392    #[test]
393    fn single_quoted_identifiers_are_unquoted() {
394        // SQLite tolerates single-quoted column names in CREATE TABLE (seen in
395        // the real nemetz 0D-01 fixture). The real names are id/name, not 'id'.
396        let sql = "CREATE TABLE users (\n\t'id' INT NOT NULL,\n\t'name' TEXT NULL)";
397        let cols = column_names(sql).unwrap();
398        assert_eq!(cols, vec!["id", "name"]);
399    }
400}