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}