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