Skip to main content

nodedb_lite/index/
catalog.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Secondary-index definitions: what `CREATE INDEX` declared, persisted in
4//! `Namespace::Meta` and loaded at open.
5
6use nodedb_query::SqlExpr;
7use nodedb_types::value::Value;
8
9use crate::error::LiteError;
10
11use super::key;
12
13/// The engine whose rows an index covers.
14///
15/// Encoded as its discriminant: do not reorder the variants.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
17#[msgpack(c_enum)]
18pub enum IndexEngine {
19    /// Schemaless documents held in the CRDT store.
20    Document = 0,
21    /// Strict collections. Declared and persisted; entries are not built yet.
22    Strict = 1,
23    /// Key-value collections. Declared and persisted; entries are not built yet.
24    KeyValue = 2,
25}
26
27/// A partial-index predicate: the `WHERE` body as written and its parsed form.
28///
29/// Only rows for which the expression evaluates to exactly `true` are indexed.
30/// NULL, `false`, a non-boolean result and an evaluation error all exclude the
31/// row, as a Postgres partial index does.
32#[derive(Debug, Clone, zerompk::ToMessagePack, zerompk::FromMessagePack)]
33pub struct IndexPredicate {
34    /// The SQL text. The planner parses it to decide whether a query's WHERE
35    /// clause entails the predicate.
36    pub sql: String,
37    /// The parsed expression the write path evaluates per row.
38    pub expr: SqlExpr,
39}
40
41impl IndexPredicate {
42    /// Parse the `WHERE` body of a `CREATE INDEX`.
43    pub fn parse(sql: &str) -> Result<Self, LiteError> {
44        let (expr, _deps) = nodedb_query::expr_parse::parse_generated_expr(sql).map_err(|e| {
45            LiteError::BadRequest {
46                detail: format!(
47                    "CREATE INDEX: partial-index predicate '{sql}' does not parse: {e}"
48                ),
49            }
50        })?;
51        Ok(Self {
52            sql: sql.to_string(),
53            expr,
54        })
55    }
56
57    /// Whether `row` belongs in the index.
58    pub fn matches(&self, row: &Value) -> bool {
59        matches!(self.expr.eval(row), Ok(Value::Bool(true)))
60    }
61}
62
63/// One secondary index.
64#[derive(Debug, Clone, zerompk::ToMessagePack, zerompk::FromMessagePack)]
65pub struct IndexDef {
66    /// Index name, unique across the database.
67    pub name: String,
68    /// Collection the index covers.
69    pub collection: String,
70    /// Canonical JSON path of the indexed field, e.g. `$.email` or `$.a.b`.
71    pub path: String,
72    /// Two rows may not share an indexed value.
73    pub unique: bool,
74    /// String values are indexed and probed lowercased.
75    pub case_insensitive: bool,
76    /// An array value is indexed once per element.
77    pub is_array: bool,
78    /// Partial-index predicate, or `None` for a full index.
79    pub predicate: Option<IndexPredicate>,
80    /// The engine whose rows the index covers.
81    pub engine: IndexEngine,
82}
83
84impl IndexDef {
85    /// The field as the planner and `CREATE INDEX` spell it: the canonical
86    /// path, with `[]` appended for an array index.
87    pub fn field_spec(&self) -> String {
88        field_spec(&self.path, self.is_array)
89    }
90
91    /// Prefix of every entry of this index.
92    pub(crate) fn entry_prefix(&self) -> Vec<u8> {
93        key::index_prefix(&self.collection, &self.name)
94    }
95
96    /// The planner's view of this index. Only document indexes drive query
97    /// rewrites: strict and key-value indexes hold no entries yet.
98    pub(crate) fn planner_spec(&self) -> nodedb_sql::types::IndexSpec {
99        nodedb_sql::types::IndexSpec {
100            name: self.name.clone(),
101            field: self.field_spec(),
102            unique: self.unique,
103            case_insensitive: self.case_insensitive,
104            state: nodedb_sql::types::IndexState::Ready,
105            predicate: self.predicate.as_ref().map(|p| p.sql.clone()),
106        }
107    }
108
109    /// Serialized form stored under [`key::def_key`].
110    pub(crate) fn encode(&self) -> Result<Vec<u8>, LiteError> {
111        zerompk::to_msgpack_vec(self).map_err(|e| LiteError::Serialization {
112            detail: format!("encode index definition '{}': {e}", self.name),
113        })
114    }
115
116    /// Decode a stored definition.
117    pub(crate) fn decode(bytes: &[u8]) -> Result<Self, LiteError> {
118        zerompk::from_msgpack(bytes).map_err(|e| LiteError::Corrupted {
119            detail: format!("stored index definition does not decode: {e}"),
120        })
121    }
122}
123
124/// Split a `CREATE INDEX` field into its canonical path and array flag.
125///
126/// `email` and `$.email` both name `$.email`. A trailing `[]` (`tags[]`)
127/// declares an array index over the elements of `$.tags`.
128pub fn canonical_field(field: &str) -> (String, bool) {
129    let (field, is_array) = match field.strip_suffix("[]") {
130        Some(inner) => (inner, true),
131        None => (field, false),
132    };
133    let path = if field.starts_with('$') {
134        field.to_string()
135    } else {
136        format!("$.{field}")
137    };
138    (path, is_array)
139}
140
141/// A field as the planner spells it: the canonical path, with `[]` appended
142/// for an array index.
143pub fn field_spec(path: &str, is_array: bool) -> String {
144    if is_array {
145        format!("{path}[]")
146    } else {
147        path.to_string()
148    }
149}
150
151/// The name `CREATE INDEX` gives an index it was not given a name for.
152pub fn default_index_name(collection: &str, field: &str) -> String {
153    format!("idx_{collection}_{field}")
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn fields_canonicalize_to_json_paths() {
162        assert_eq!(canonical_field("email"), ("$.email".into(), false));
163        assert_eq!(canonical_field("$.a.b"), ("$.a.b".into(), false));
164        assert_eq!(canonical_field("tags[]"), ("$.tags".into(), true));
165    }
166
167    #[test]
168    fn a_definition_round_trips_with_its_predicate() {
169        let def = IndexDef {
170            name: "idx".into(),
171            collection: "users".into(),
172            path: "$.email".into(),
173            unique: true,
174            case_insensitive: true,
175            is_array: false,
176            predicate: Some(IndexPredicate::parse("active = true").expect("parse")),
177            engine: IndexEngine::Document,
178        };
179        let decoded = IndexDef::decode(&def.encode().expect("encode")).expect("decode");
180        assert_eq!(decoded.name, "idx");
181        assert!(decoded.unique && decoded.case_insensitive);
182        assert_eq!(decoded.engine, IndexEngine::Document);
183        let predicate = decoded.predicate.expect("predicate");
184        assert_eq!(predicate.sql, "active = true");
185        let mut row = std::collections::HashMap::new();
186        row.insert("active".to_string(), Value::Bool(true));
187        assert!(predicate.matches(&Value::Object(row)));
188    }
189}