Skip to main content

uqa_storage/sqlite/
btree_index.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Persistent backing for logical `btree` value indexes.
8//!
9//! The engine still uses its in-memory [`crate::BTreeIndex`] for query-time
10//! scans, but the compact `(table, field, doc_id, value)` rows live in `SQLite`.
11//! Reopening an engine hydrates the B-tree from these rows instead of parsing
12//! every full document again. Writes replace the affected postings in the
13//! active `SQLite` transaction as the document mutation.
14
15use std::collections::BTreeMap;
16
17use rusqlite::{params, OptionalExtension};
18use serde::{Deserialize, Serialize};
19use uqa_core::{ArrayValue, DecimalValue, DocId, TemporalValue, Value};
20
21use super::{ManagedConnection, Result, SQLiteError};
22
23fn encode_doc_id(doc_id: DocId) -> Result<i64> {
24    i64::try_from(doc_id).map_err(|_| {
25        SQLiteError::StorageBackend(format!(
26            "document id {doc_id} does not fit in SQLite INTEGER"
27        ))
28    })
29}
30
31fn decode_doc_id(doc_id: i64) -> Result<DocId> {
32    DocId::try_from(doc_id).map_err(|_| {
33        SQLiteError::StorageBackend(format!(
34            "invalid negative document id {doc_id} in persisted B-tree index"
35        ))
36    })
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type", content = "value")]
41enum StoredValue {
42    Null,
43    Bool(bool),
44    Int(i64),
45    /// IEEE-754 bits preserve NaN payloads, infinities, and signed zero.
46    Float(u64),
47    Str(String),
48    FixedChar(String),
49    Bytes(Vec<u8>),
50    Temporal(TemporalValue),
51    Decimal(DecimalValue),
52    Json(String),
53    JsonB(String),
54    Array(ArrayValue),
55    List(Vec<StoredValue>),
56    Row(Vec<StoredValue>),
57    Record(Vec<(String, StoredValue)>),
58    Map(BTreeMap<String, StoredValue>),
59}
60
61impl From<&Value> for StoredValue {
62    fn from(value: &Value) -> Self {
63        match value {
64            Value::Null => Self::Null,
65            Value::Bool(value) => Self::Bool(*value),
66            Value::Int(value) => Self::Int(*value),
67            Value::Float(value) => Self::Float(value.to_bits()),
68            Value::Str(value) => Self::Str(value.clone()),
69            Value::FixedChar(value) => Self::FixedChar(value.clone()),
70            Value::Bytes(value) => Self::Bytes(value.clone()),
71            Value::Temporal(value) => Self::Temporal(value.clone()),
72            Value::Decimal(value) => Self::Decimal(value.clone()),
73            Value::Json(value) => Self::Json(value.clone()),
74            Value::JsonB(value) => Self::JsonB(value.clone()),
75            Value::Array(value) => Self::Array(value.clone()),
76            Value::List(values) => Self::List(values.iter().map(Self::from).collect()),
77            Value::Row(values) => Self::Row(values.iter().map(Self::from).collect()),
78            Value::Record(fields) => Self::Record(
79                fields
80                    .iter()
81                    .map(|(name, value)| (name.clone(), Self::from(value)))
82                    .collect(),
83            ),
84            Value::Map(values) => Self::Map(
85                values
86                    .iter()
87                    .map(|(key, value)| (key.clone(), Self::from(value)))
88                    .collect(),
89            ),
90        }
91    }
92}
93
94impl StoredValue {
95    fn into_value(self) -> Value {
96        match self {
97            Self::Null => Value::Null,
98            Self::Bool(value) => Value::Bool(value),
99            Self::Int(value) => Value::Int(value),
100            Self::Float(bits) => Value::Float(f64::from_bits(bits)),
101            Self::Str(value) => Value::Str(value),
102            Self::FixedChar(value) => Value::FixedChar(value),
103            Self::Bytes(value) => Value::Bytes(value),
104            Self::Temporal(value) => Value::Temporal(value),
105            Self::Decimal(value) => Value::Decimal(value),
106            Self::Json(value) => Value::Json(value),
107            Self::JsonB(value) => Value::JsonB(value),
108            Self::Array(value) => Value::Array(value),
109            Self::List(values) => Value::List(values.into_iter().map(Self::into_value).collect()),
110            Self::Row(values) => Value::Row(values.into_iter().map(Self::into_value).collect()),
111            Self::Record(fields) => Value::Record(
112                fields
113                    .into_iter()
114                    .map(|(name, value)| (name, value.into_value()))
115                    .collect(),
116            ),
117            Self::Map(values) => Value::Map(
118                values
119                    .into_iter()
120                    .map(|(key, value)| (key, value.into_value()))
121                    .collect(),
122            ),
123        }
124    }
125}
126
127fn encode_value(value: &Value) -> Result<String> {
128    Ok(serde_json::to_string(&StoredValue::from(value))?)
129}
130
131fn decode_value(encoded: &str) -> Result<Value> {
132    Ok(serde_json::from_str::<StoredValue>(encoded)?.into_value())
133}
134
135#[derive(Clone)]
136pub struct SQLiteBTreeIndexStore {
137    conn: ManagedConnection,
138}
139
140impl SQLiteBTreeIndexStore {
141    pub fn new(conn: ManagedConnection) -> Self {
142        Self { conn }
143    }
144
145    pub fn fields(&self, table: &str) -> Result<Vec<String>> {
146        self.conn.with(|conn| {
147            let mut stmt = conn.prepare_cached(
148                "SELECT field FROM _btree_indexes
149                 WHERE table_name = ?1
150                 ORDER BY field",
151            )?;
152            let rows = stmt.query_map([table], |row| row.get::<_, String>(0))?;
153            let mut fields = Vec::new();
154            for row in rows {
155                fields.push(row?);
156            }
157            Ok(fields)
158        })
159    }
160
161    pub fn repairs(&self) -> Result<Vec<(String, String)>> {
162        self.conn.with(|conn| {
163            let mut stmt = conn.prepare_cached(
164                "SELECT table_name, field FROM _btree_index_repairs ORDER BY table_name, field",
165            )?;
166            let rows = stmt.query_map([], |row| {
167                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
168            })?;
169            let mut repairs = Vec::new();
170            for row in rows {
171                repairs.push(row?);
172            }
173            Ok(repairs)
174        })
175    }
176
177    pub fn clear_repair(&self, table: &str, field: &str) -> Result<()> {
178        self.conn.with(|conn| {
179            conn.execute(
180                "DELETE FROM _btree_index_repairs
181                 WHERE table_name = ?1 AND field = ?2",
182                params![table, field],
183            )?;
184            Ok(())
185        })
186    }
187
188    /// Load a complete persisted index. `None` means this field has not been
189    /// built yet and the engine must backfill it from the document store once.
190    pub fn load(&self, table: &str, field: &str) -> Result<Option<Vec<(DocId, Value)>>> {
191        self.conn.with(|conn| {
192            let exists = conn
193                .prepare_cached(
194                    "SELECT 1 FROM _btree_indexes
195                     WHERE table_name = ?1 AND field = ?2",
196                )?
197                .query_row(params![table, field], |row| row.get::<_, i64>(0))
198                .optional()?
199                .is_some();
200            if !exists {
201                return Ok(None);
202            }
203
204            let mut stmt = conn.prepare_cached(
205                "SELECT doc_id, value_json
206                 FROM _btree_index_entries
207                 WHERE table_name = ?1 AND field = ?2
208                 ORDER BY doc_id",
209            )?;
210            let mut rows = stmt.query(params![table, field])?;
211            let mut values = Vec::new();
212            while let Some(row) = rows.next()? {
213                let doc_id = decode_doc_id(row.get::<_, i64>(0)?)?;
214                let encoded = row.get::<_, String>(1)?;
215                values.push((doc_id, decode_value(&encoded)?));
216            }
217            Ok(Some(values))
218        })
219    }
220
221    /// Atomically replace the complete persisted posting set and mark it built.
222    pub fn replace(&self, table: &str, field: &str, values: &[(DocId, Value)]) -> Result<()> {
223        self.replace_many(table, &[(field, values)])
224    }
225
226    /// Apply a sparse structural repair while retaining every valid posting.
227    /// All ids and values are encoded before opening the savepoint so a range
228    /// or serialization error cannot leave half of the delta applied.
229    pub fn repair(
230        &self,
231        table: &str,
232        field: &str,
233        stale_doc_ids: &[DocId],
234        missing: &[(DocId, Value)],
235    ) -> Result<()> {
236        let stale_doc_ids = stale_doc_ids
237            .iter()
238            .map(|doc_id| encode_doc_id(*doc_id))
239            .collect::<Result<Vec<_>>>()?;
240        let missing = missing
241            .iter()
242            .map(|(doc_id, value)| Ok((encode_doc_id(*doc_id)?, encode_value(value)?)))
243            .collect::<Result<Vec<_>>>()?;
244        self.conn.with_mut(|conn| {
245            let tx = conn.savepoint()?;
246            tx.execute(
247                "INSERT OR IGNORE INTO _btree_indexes (table_name, field)
248                 VALUES (?1, ?2)",
249                params![table, field],
250            )?;
251            {
252                let mut delete = tx.prepare_cached(
253                    "DELETE FROM _btree_index_entries
254                     WHERE table_name = ?1 AND field = ?2 AND doc_id = ?3",
255                )?;
256                for doc_id in &stale_doc_ids {
257                    delete.execute(params![table, field, doc_id])?;
258                }
259            }
260            {
261                let mut insert = tx.prepare_cached(
262                    "INSERT INTO _btree_index_entries
263                       (table_name, field, doc_id, value_json)
264                     VALUES (?1, ?2, ?3, ?4)
265                     ON CONFLICT (table_name, field, doc_id)
266                     DO UPDATE SET value_json = excluded.value_json",
267                )?;
268                for (doc_id, value_json) in &missing {
269                    insert.execute(params![table, field, doc_id, value_json])?;
270                }
271            }
272            tx.commit()?;
273            Ok(())
274        })
275    }
276
277    /// Atomically replace several complete posting sets for one table. Repair
278    /// paths commonly rebuild every indexed column together; one savepoint and
279    /// one set of prepared statements avoids repeating `SQLite` setup per field.
280    pub fn replace_many(&self, table: &str, indexes: &[(&str, &[(DocId, Value)])]) -> Result<()> {
281        let encoded = indexes
282            .iter()
283            .map(|(field, values)| {
284                let values = values
285                    .iter()
286                    .map(|(doc_id, value)| Ok((encode_doc_id(*doc_id)?, encode_value(value)?)))
287                    .collect::<Result<Vec<_>>>()?;
288                Ok((*field, values))
289            })
290            .collect::<Result<Vec<_>>>()?;
291        self.conn.with_mut(|conn| {
292            let tx = conn.savepoint()?;
293            {
294                let mut delete = tx.prepare_cached(
295                    "DELETE FROM _btree_index_entries
296                     WHERE table_name = ?1 AND field = ?2",
297                )?;
298                let mut mark = tx.prepare_cached(
299                    "INSERT OR IGNORE INTO _btree_indexes (table_name, field)
300                     VALUES (?1, ?2)",
301                )?;
302                let mut insert = tx.prepare_cached(
303                    "INSERT INTO _btree_index_entries
304                       (table_name, field, doc_id, value_json)
305                     VALUES (?1, ?2, ?3, ?4)",
306                )?;
307                for (field, values) in &encoded {
308                    delete.execute(params![table, field])?;
309                    mark.execute(params![table, field])?;
310                    for (doc_id, value_json) in values {
311                        insert.execute(params![table, field, doc_id, value_json])?;
312                    }
313                }
314            }
315            tx.commit()?;
316            Ok(())
317        })
318    }
319
320    /// Apply one document write to every persisted field currently loaded by
321    /// the engine. A replacement uses the `(table, field, doc_id)` primary key,
322    /// so updates never need a separate old-value delete.
323    pub fn apply_write(
324        &self,
325        table: &str,
326        doc_id: DocId,
327        values: Option<&BTreeMap<String, Value>>,
328    ) -> Result<()> {
329        let doc_id = encode_doc_id(doc_id)?;
330        let encoded = values
331            .map(|values| {
332                values
333                    .iter()
334                    .map(|(field, value)| Ok((field.clone(), encode_value(value)?)))
335                    .collect::<Result<Vec<_>>>()
336            })
337            .transpose()?;
338        self.conn.with_mut(|conn| {
339            let tx = conn.savepoint()?;
340            match encoded.as_ref() {
341                Some(values) => {
342                    let mut stmt = tx.prepare_cached(
343                        "INSERT INTO _btree_index_entries
344                           (table_name, field, doc_id, value_json)
345                         SELECT ?1, ?2, ?3, ?4
346                         WHERE EXISTS (
347                             SELECT 1 FROM _btree_indexes
348                             WHERE table_name = ?1 AND field = ?2
349                         )
350                         ON CONFLICT (table_name, field, doc_id)
351                         DO UPDATE SET value_json = excluded.value_json",
352                    )?;
353                    for (field, value_json) in values {
354                        stmt.execute(params![table, field, doc_id, value_json])?;
355                    }
356                }
357                None => {
358                    tx.execute(
359                        "DELETE FROM _btree_index_entries
360                         WHERE table_name = ?1 AND doc_id = ?2",
361                        params![table, doc_id],
362                    )?;
363                }
364            }
365            tx.commit()?;
366            Ok(())
367        })
368    }
369
370    pub fn drop_index(&self, table: &str, field: &str) -> Result<()> {
371        self.conn.with_mut(|conn| {
372            let tx = conn.savepoint()?;
373            tx.execute(
374                "DELETE FROM _btree_index_entries
375                 WHERE table_name = ?1 AND field = ?2",
376                params![table, field],
377            )?;
378            tx.execute(
379                "DELETE FROM _btree_indexes
380                 WHERE table_name = ?1 AND field = ?2",
381                params![table, field],
382            )?;
383            tx.commit()?;
384            Ok(())
385        })
386    }
387
388    /// TRUNCATE keeps the index definitions but removes every posting.
389    pub fn clear_table(&self, table: &str) -> Result<()> {
390        self.conn.with(|conn| {
391            conn.execute(
392                "DELETE FROM _btree_index_entries WHERE table_name = ?1",
393                params![table],
394            )?;
395            Ok(())
396        })
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::sqlite::Catalog;
404
405    fn store() -> SQLiteBTreeIndexStore {
406        let conn = ManagedConnection::open_in_memory().unwrap();
407        let _catalog = Catalog::open(conn.clone()).unwrap();
408        conn.with(|connection| {
409            connection.execute_batch(
410                "INSERT INTO _documents (table_name, doc_id, body)
411                 VALUES ('messages', 1, '{}'), ('messages', 2, '{}');",
412            )?;
413            Ok(())
414        })
415        .unwrap();
416        SQLiteBTreeIndexStore::new(conn)
417    }
418
419    #[test]
420    fn tagged_values_round_trip_without_untagged_serde_ambiguity() {
421        let values = vec![
422            Value::Null,
423            Value::Bool(true),
424            Value::Int(7),
425            Value::Float(-0.0),
426            Value::Float(f64::NAN),
427            Value::Str("seven".into()),
428            Value::FixedChar("seven   ".into()),
429            Value::Bytes(vec![1, 2, 3]),
430            Value::Json("{\"b\":2,\"a\":1}".into()),
431            Value::JsonB("{\"a\": 1, \"b\": 2}".into()),
432            Value::List(vec![Value::Int(1), Value::Int(2)]),
433            Value::Map(BTreeMap::from([("k".into(), Value::Str("v".into()))])),
434        ];
435        for value in values {
436            let decoded = decode_value(&encode_value(&value).unwrap()).unwrap();
437            match (&value, &decoded) {
438                (Value::Float(left), Value::Float(right)) if left.is_nan() => {
439                    assert!(right.is_nan());
440                }
441                _ => assert_eq!(decoded, value),
442            }
443        }
444    }
445
446    #[test]
447    fn replace_load_write_delete_and_clear_round_trip() {
448        let store = store();
449        store
450            .replace(
451                "messages",
452                "public_id",
453                &[(1, Value::Str("m1".into())), (2, Value::Null)],
454            )
455            .unwrap();
456        assert_eq!(
457            store.load("messages", "public_id").unwrap(),
458            Some(vec![(1, Value::Str("m1".into())), (2, Value::Null)])
459        );
460        assert_eq!(store.fields("messages").unwrap(), vec!["public_id"]);
461
462        store
463            .apply_write(
464                "messages",
465                2,
466                Some(&BTreeMap::from([
467                    ("public_id".into(), Value::Str("m2".into())),
468                    ("not_built".into(), Value::Int(9)),
469                ])),
470            )
471            .unwrap();
472        store.apply_write("messages", 1, None).unwrap();
473        assert_eq!(
474            store.load("messages", "public_id").unwrap(),
475            Some(vec![(2, Value::Str("m2".into()))])
476        );
477
478        store.clear_table("messages").unwrap();
479        assert_eq!(store.load("messages", "public_id").unwrap(), Some(vec![]));
480        store.drop_index("messages", "public_id").unwrap();
481        assert_eq!(store.load("messages", "public_id").unwrap(), None);
482    }
483
484    #[test]
485    fn sparse_repair_preserves_valid_postings() {
486        let store = store();
487        store
488            .replace(
489                "messages",
490                "public_id",
491                &[(1, Value::Str("m1".into())), (2, Value::Str("m2".into()))],
492            )
493            .unwrap();
494        let row_id_before: i64 = store
495            .conn
496            .with(|conn| {
497                Ok(conn.query_row(
498                    "SELECT rowid FROM _btree_index_entries
499                     WHERE table_name = 'messages'
500                       AND field = 'public_id' AND doc_id = 2",
501                    [],
502                    |row| row.get(0),
503                )?)
504            })
505            .unwrap();
506        store
507            .conn
508            .with(|conn| {
509                conn.execute(
510                    "INSERT INTO _documents (table_name, doc_id, body)
511                     VALUES ('messages', 3, '{}')",
512                    [],
513                )?;
514                Ok(())
515            })
516            .unwrap();
517
518        store
519            .repair(
520                "messages",
521                "public_id",
522                &[1],
523                &[(3, Value::Str("m3".into()))],
524            )
525            .unwrap();
526
527        assert_eq!(
528            store.load("messages", "public_id").unwrap(),
529            Some(vec![
530                (2, Value::Str("m2".into())),
531                (3, Value::Str("m3".into()))
532            ])
533        );
534        let row_id_after: i64 = store
535            .conn
536            .with(|conn| {
537                Ok(conn.query_row(
538                    "SELECT rowid FROM _btree_index_entries
539                     WHERE table_name = 'messages'
540                       AND field = 'public_id' AND doc_id = 2",
541                    [],
542                    |row| row.get(0),
543                )?)
544            })
545            .unwrap();
546        assert_eq!(row_id_after, row_id_before);
547    }
548
549    #[test]
550    fn out_of_range_document_ids_fail_before_replacing_existing_entries() {
551        let store = store();
552        store
553            .replace("messages", "public_id", &[(1, Value::Str("m1".into()))])
554            .unwrap();
555
556        let error = store
557            .replace(
558                "messages",
559                "public_id",
560                &[(u64::MAX, Value::Str("overflow".into()))],
561            )
562            .unwrap_err();
563        assert!(error.to_string().contains("does not fit in SQLite INTEGER"));
564        assert_eq!(
565            store.load("messages", "public_id").unwrap(),
566            Some(vec![(1, Value::Str("m1".into()))])
567        );
568
569        let error = store
570            .apply_write(
571                "messages",
572                u64::MAX,
573                Some(&BTreeMap::from([(
574                    "public_id".into(),
575                    Value::Str("overflow".into()),
576                )])),
577            )
578            .unwrap_err();
579        assert!(error.to_string().contains("does not fit in SQLite INTEGER"));
580    }
581
582    #[test]
583    fn negative_persisted_document_id_is_reported_as_corruption() {
584        let store = store();
585        store
586            .replace("messages", "public_id", &[(1, Value::Str("m1".into()))])
587            .unwrap();
588        store
589            .conn
590            .with(|conn| {
591                conn.execute(
592                    "INSERT INTO _documents (table_name, doc_id, body)
593                     VALUES ('messages', -1, '{}')",
594                    [],
595                )?;
596                conn.execute(
597                    "INSERT INTO _btree_index_entries
598                       (table_name, field, doc_id, value_json)
599                     VALUES (?1, ?2, ?3, ?4)",
600                    params![
601                        "messages",
602                        "public_id",
603                        -1_i64,
604                        encode_value(&Value::Str("corrupt".into()))?
605                    ],
606                )?;
607                Ok(())
608            })
609            .unwrap();
610
611        let error = store.load("messages", "public_id").unwrap_err();
612        assert!(error
613            .to_string()
614            .contains("invalid negative document id -1"));
615    }
616}