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