Skip to main content

marsdb_graph/
index.rs

1//! Property indexes: `(label, property) -> node_ids` keyed by an
2//! order-preserving encoding of the property's value. See
3//! `marsdb_storage::tables::{INDEX_DEFS, PROPERTY_INDEX}` for the on-disk
4//! layout this module builds keys for.
5
6use std::collections::BTreeMap;
7
8use marsdb_storage::{ReadableMultimapTable, ReadableTable, Txn, WriteTransaction};
9use serde::{Deserialize, Serialize};
10
11use crate::error::GraphError;
12use crate::labels::{lookup_label_id, resolve_label};
13use crate::model::{NodeId, PropertyValue};
14use crate::props::{lookup_prop_id, resolve_prop};
15
16#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
17pub struct IndexDef {
18    pub unique: bool,
19}
20
21/// `label_id(4 bytes BE) ++ property_id(4 bytes BE)` — the key `INDEX_DEFS`
22/// uses, and the fixed prefix every `PROPERTY_INDEX` entry for this
23/// (label, property) pair starts with.
24fn index_prefix(label_id: u32, prop_id: u32) -> [u8; 8] {
25    let mut out = [0u8; 8];
26    out[0..4].copy_from_slice(&label_id.to_be_bytes());
27    out[4..8].copy_from_slice(&prop_id.to_be_bytes());
28    out
29}
30
31/// Order-preserving byte encoding of a single `PropertyValue`, for use as
32/// a `PROPERTY_INDEX` key suffix. Lexicographic byte comparison matches
33/// real value ordering *within one type* (needed for a future range scan,
34/// not used yet — MVP only does exact-match lookups) — a leading type tag
35/// keeps different types from ever comparing as equal or interleaving.
36/// `Duration` has no meaningful total order (see `PropertyValue::Duration`'s
37/// own doc comment) — its encoding is only guaranteed consistent for
38/// equality, not real ordering, which is fine since nothing orders by it.
39pub(crate) fn encode_index_value(v: &PropertyValue) -> Vec<u8> {
40    match v {
41        PropertyValue::Null => vec![0x00],
42        PropertyValue::Bool(b) => vec![0x01, u8::from(*b)],
43        // Flip the sign bit so two's-complement ordering becomes correct
44        // unsigned big-endian byte ordering (the standard trick: the most
45        // negative i64 maps to all-zero bytes, the most positive to
46        // all-one bytes).
47        PropertyValue::Int(i) => {
48            let mut out = vec![0x02];
49            out.extend_from_slice(&((*i as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
50            out
51        }
52        // Standard sortable-float transform: flip the sign bit for a
53        // non-negative float (so it sorts above all negatives), flip every
54        // bit for a negative float (so more-negative sorts lower).
55        PropertyValue::Float(f) => {
56            let bits = f.to_bits();
57            let sortable = if bits & 0x8000_0000_0000_0000 != 0 {
58                !bits
59            } else {
60                bits | 0x8000_0000_0000_0000
61            };
62            let mut out = vec![0x03];
63            out.extend_from_slice(&sortable.to_be_bytes());
64            out
65        }
66        // Raw UTF-8 bytes compare correctly by codepoint for ASCII and
67        // "close enough" (not full Unicode collation) in general -- same
68        // tradeoff most embedded databases make without pulling in ICU.
69        PropertyValue::String(s) => {
70            let mut out = vec![0x04];
71            out.extend_from_slice(s.as_bytes());
72            out
73        }
74        PropertyValue::Date(days) => {
75            let mut out = vec![0x05];
76            out.extend_from_slice(&((*days as i64 as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
77            out
78        }
79        PropertyValue::Duration {
80            months,
81            days,
82            seconds,
83            nanos,
84        } => {
85            let mut out = vec![0x06];
86            out.extend_from_slice(&months.to_be_bytes());
87            out.extend_from_slice(&days.to_be_bytes());
88            out.extend_from_slice(&seconds.to_be_bytes());
89            out.extend_from_slice(&nanos.to_be_bytes());
90            out
91        }
92        // Always non-negative by construction (`0..86_400_000_000_000`) --
93        // plain BE bytes already sort correctly, no sign-flip needed.
94        PropertyValue::LocalTime(nanos_of_day) => {
95            let mut out = vec![0x07];
96            out.extend_from_slice(&nanos_of_day.to_be_bytes());
97            out
98        }
99        // Keyed by the UTC-equivalent instant-of-day (`nanos_of_day -
100        // offset_seconds`), not the raw wall-clock fields -- matches
101        // `Time`'s own equality/ordering rule (see its doc comment), so
102        // two structurally-different `Time`s that represent the same
103        // instant correctly collapse to the same index key.
104        PropertyValue::Time {
105            nanos_of_day,
106            offset_seconds,
107        } => {
108            let instant = nanos_of_day - *offset_seconds as i64 * 1_000_000_000;
109            let mut out = vec![0x08];
110            out.extend_from_slice(&((instant as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
111            out
112        }
113        PropertyValue::LocalDateTime {
114            epoch_seconds,
115            nanos,
116        } => {
117            let mut out = vec![0x09];
118            out.extend_from_slice(&((*epoch_seconds as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
119            out.extend_from_slice(&nanos.to_be_bytes());
120            out
121        }
122        // `offset_seconds` deliberately excluded -- `DateTime`'s equality/
123        // ordering is instant-only (see its doc comment), same reasoning
124        // as `Time` above.
125        PropertyValue::DateTime {
126            epoch_seconds,
127            nanos,
128            ..
129        } => {
130            let mut out = vec![0x0A];
131            out.extend_from_slice(&((*epoch_seconds as u64) ^ 0x8000_0000_0000_0000).to_be_bytes());
132            out.extend_from_slice(&nanos.to_be_bytes());
133            out
134        }
135        // No real ordering across two lists is defined/needed (same
136        // "consistent for equality, not real ordering" carve-out
137        // `Duration` above already has) -- MVP indexing only does exact-
138        // match lookups. Each element's own encoding is length-prefixed
139        // so two different lists can never collide onto the same byte
140        // string (e.g. `["ab", "c"]` vs `["a", "bc"]`, which otherwise
141        // concatenate to visually-different but genuinely ambiguous byte
142        // runs once strings' own raw-UTF-8, non-length-prefixed encoding
143        // is stacked back to back).
144        PropertyValue::List(items) => {
145            let mut out = vec![0x0B];
146            for item in items {
147                let encoded = encode_index_value(item);
148                out.extend_from_slice(&(encoded.len() as u32).to_be_bytes());
149                out.extend_from_slice(&encoded);
150            }
151            out
152        }
153        // Never reaches here: `Map` is only ever constructed on the
154        // parameter-passing path (`PropertyValue`'s own doc comment), and
155        // nothing ever stores -- so nothing ever indexes -- a real node/
156        // edge property this way.
157        PropertyValue::Map(_) => {
158            unreachable!("PropertyValue::Map is never a real stored/indexed property value")
159        }
160    }
161}
162
163fn index_key(label_id: u32, prop_id: u32, value: &PropertyValue) -> Vec<u8> {
164    let mut out = index_prefix(label_id, prop_id).to_vec();
165    out.extend_from_slice(&encode_index_value(value));
166    out
167}
168
169/// Declares an index on `(label, prop)` and backfills it from every
170/// existing node carrying `label`. Errors (without creating the index) if
171/// `unique` is requested and two existing nodes already share a value.
172/// Idempotent by (label, prop) identity, not by `unique`-ness — calling
173/// this again on an already-indexed pair is an error, same as most
174/// databases' `CREATE INDEX` (no silent redefinition).
175pub fn create_index(
176    write_txn: &WriteTransaction,
177    label: &str,
178    prop: &str,
179    unique: bool,
180) -> Result<(), GraphError> {
181    let label_id = crate::labels::intern_label(write_txn, label)?;
182    let prop_id = crate::props::intern_prop(write_txn, prop)?;
183    let prefix = index_prefix(label_id, prop_id);
184    {
185        let defs = write_txn.open_table(marsdb_storage::tables::INDEX_DEFS)?;
186        if defs.get(prefix.as_slice())?.is_some() {
187            return Err(GraphError::CorruptData(format!(
188                "index on label {label:?} property {prop:?} already exists"
189            )));
190        }
191    }
192
193    // Backfill: walk every node with this label (via the existing
194    // NODE_LABEL_INDEX secondary index, not a full NODES scan) and index
195    // whatever value it currently has for `prop` (skipping nodes missing
196    // it entirely -- a missing property never appears in the index, same
197    // as `IS NULL`/absence being indistinguishable elsewhere in this
198    // codebase).
199    let label_index = write_txn.open_multimap_table(marsdb_storage::tables::NODE_LABEL_INDEX)?;
200    let node_ids: Vec<u64> = label_index
201        .get(label_id)?
202        .map(|entry| entry.map(|value| value.value()).map_err(GraphError::from))
203        .collect::<Result<Vec<_>, GraphError>>()?;
204    drop(label_index);
205    let mut entries: Vec<(Vec<u8>, u64)> = Vec::with_capacity(node_ids.len());
206    {
207        let nodes = write_txn.open_table(marsdb_storage::tables::NODES)?;
208        for node_id in &node_ids {
209            let Some(guard) = nodes.get(*node_id)? else {
210                continue;
211            };
212            let record: crate::encode::NodeRecord = crate::encode::decode(guard.value())?;
213            if let Some(value) = record.props.get(prop) {
214                entries.push((index_key(label_id, prop_id, value), *node_id));
215            }
216        }
217    }
218    if unique {
219        let mut seen = std::collections::HashSet::with_capacity(entries.len());
220        for (key, _) in &entries {
221            if !seen.insert(key.clone()) {
222                return Err(GraphError::UniqueConstraintViolation {
223                    label: label.to_string(),
224                    property: prop.to_string(),
225                });
226            }
227        }
228    }
229
230    {
231        let mut defs = write_txn.open_table(marsdb_storage::tables::INDEX_DEFS)?;
232        let encoded = postcard::to_allocvec(&IndexDef { unique })?;
233        defs.insert(prefix.as_slice(), encoded.as_slice())?;
234    }
235    {
236        let mut index = write_txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
237        for (key, node_id) in entries {
238            index.insert(key.as_slice(), node_id)?;
239        }
240    }
241    Ok(())
242}
243
244/// `None` means no index is declared on `(label, prop)`.
245pub fn lookup_index_def(txn: Txn, label: &str, prop: &str) -> Result<Option<IndexDef>, GraphError> {
246    let Some(label_id) = lookup_label_id(txn, label)? else {
247        return Ok(None);
248    };
249    let Some(prop_id) = lookup_prop_id(txn, prop)? else {
250        return Ok(None);
251    };
252    let prefix = index_prefix(label_id, prop_id);
253    let defs = txn.open_table(marsdb_storage::tables::INDEX_DEFS)?;
254    let found = defs
255        .get(prefix.as_slice())?
256        .map(|guard| guard.value().to_vec());
257    drop(defs);
258    match found {
259        Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)),
260        None => Ok(None),
261    }
262}
263
264/// Exact-match lookup: every node currently indexed under `(label, prop) =
265/// value`, up to `limit` of them if given. Caller (the planner, in a
266/// later change) is responsible for checking `lookup_index_def` first —
267/// this returns an empty result, not an error, if no such index exists
268/// (matching a genuinely-empty index would look the same, and this
269/// function has no way to tell those apart itself without the same
270/// lookup its caller likely already did). `limit` bounds the underlying
271/// multimap iterator itself (`.take(limit)` before collecting, not a
272/// truncate after) — same real fix this same class of bug needed for
273/// `NODE_LABEL_INDEX` (see `GraphStore::all_nodes_limited_in_txn`'s
274/// history): truncating *after* `collect()` would still walk every
275/// matching entry first, defeating the point of a `LIMIT` push-down.
276pub fn lookup_exact(
277    txn: Txn,
278    label: &str,
279    prop: &str,
280    value: &PropertyValue,
281    limit: Option<usize>,
282) -> Result<Vec<NodeId>, GraphError> {
283    let Some(label_id) = lookup_label_id(txn, label)? else {
284        return Ok(Vec::new());
285    };
286    let Some(prop_id) = lookup_prop_id(txn, prop)? else {
287        return Ok(Vec::new());
288    };
289    let key = index_key(label_id, prop_id, value);
290    let index = txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
291    let iter = index.get(key.as_slice())?;
292    let ids: Vec<NodeId> = match limit {
293        Some(limit) => iter
294            .take(limit)
295            .map(|entry| {
296                entry
297                    .map(|value| NodeId(value.value()))
298                    .map_err(GraphError::from)
299            })
300            .collect::<Result<Vec<_>, GraphError>>()?,
301        None => iter
302            .map(|entry| {
303                entry
304                    .map(|value| NodeId(value.value()))
305                    .map_err(GraphError::from)
306            })
307            .collect::<Result<Vec<_>, GraphError>>()?,
308    };
309    drop(index);
310    Ok(ids)
311}
312
313/// Cheap, exact cardinality of `(label, prop) = value` under a declared
314/// index — the stat the query planner uses to pick the most selective
315/// candidate when several indexed equality conjuncts are available for the
316/// same scan (see `marsdb_query::planner::apply_index_seeks`). O(1): redb's
317/// `MultimapValue::len()` reports a count it already tracks per key, so
318/// this never walks the matching entries themselves, unlike `lookup_exact`.
319/// Returns 0 if no such index/value exists (same "caller already checked
320/// `lookup_index_def`" contract as `lookup_exact`).
321pub fn match_count(
322    txn: Txn,
323    label: &str,
324    prop: &str,
325    value: &PropertyValue,
326) -> Result<u64, GraphError> {
327    let Some(label_id) = lookup_label_id(txn, label)? else {
328        return Ok(0);
329    };
330    let Some(prop_id) = lookup_prop_id(txn, prop)? else {
331        return Ok(0);
332    };
333    let key = index_key(label_id, prop_id, value);
334    let index = txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
335    let count = index.get(key.as_slice())?.len();
336    Ok(count)
337}
338
339/// Every declared index whose label is in `label_ids`, as `(label_id,
340/// prop_id, prop_name, IndexDef)`. `INDEX_DEFS` is scanned in full (not a
341/// prefix-range query — `TableHandle` only exposes `get`/`iter`, and the
342/// number of *declared indexes* is expected to be small, unlike node
343/// counts) and filtered in memory.
344fn indexes_for_labels(
345    txn: Txn,
346    label_ids: &[u32],
347) -> Result<Vec<(u32, u32, String, IndexDef)>, GraphError> {
348    let defs = match txn.open_table(marsdb_storage::tables::INDEX_DEFS) {
349        Ok(table) => table,
350        Err(marsdb_storage::StorageError::Table(redb::TableError::TableDoesNotExist(_))) => {
351            return Ok(Vec::new())
352        }
353        Err(e) => return Err(e.into()),
354    };
355    let mut out = Vec::new();
356    for entry in defs.iter()? {
357        let (key, value) = entry?;
358        let key_bytes = key.value();
359        let label_id = u32::from_be_bytes(
360            key_bytes[0..4]
361                .try_into()
362                .expect("index key prefix is 8 bytes"),
363        );
364        if !label_ids.contains(&label_id) {
365            continue;
366        }
367        let prop_id = u32::from_be_bytes(
368            key_bytes[4..8]
369                .try_into()
370                .expect("index key prefix is 8 bytes"),
371        );
372        let def: IndexDef = postcard::from_bytes(value.value())?;
373        let prop_name = resolve_prop(txn, prop_id)?;
374        out.push((label_id, prop_id, prop_name, def));
375    }
376    Ok(out)
377}
378
379/// Identifies one declared index, both by id (for the actual key/lookup)
380/// and by name (only needed for a `UniqueConstraintViolation`'s message).
381/// Bundled into one struct so `insert_entry` doesn't take 8 separate
382/// arguments (clippy's `too_many_arguments`, capped at 7).
383struct IndexTarget<'a> {
384    label_id: u32,
385    prop_id: u32,
386    label: &'a str,
387    prop: &'a str,
388}
389
390fn insert_entry(
391    write_txn: &WriteTransaction,
392    target: &IndexTarget<'_>,
393    value: &PropertyValue,
394    node_id: u64,
395    unique: bool,
396) -> Result<(), GraphError> {
397    let key = index_key(target.label_id, target.prop_id, value);
398    if unique {
399        let index = write_txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
400        let exists = index.get(key.as_slice())?.next().is_some();
401        drop(index);
402        if exists {
403            return Err(GraphError::UniqueConstraintViolation {
404                label: target.label.to_string(),
405                property: target.prop.to_string(),
406            });
407        }
408    }
409    let mut index = write_txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
410    index.insert(key.as_slice(), node_id)?;
411    Ok(())
412}
413
414fn remove_entry(
415    write_txn: &WriteTransaction,
416    label_id: u32,
417    prop_id: u32,
418    value: &PropertyValue,
419    node_id: u64,
420) -> Result<(), GraphError> {
421    let key = index_key(label_id, prop_id, value);
422    let mut index = write_txn.open_multimap_table(marsdb_storage::tables::PROPERTY_INDEX)?;
423    index.remove(key.as_slice(), node_id)?;
424    Ok(())
425}
426
427/// Inserts index entries for `node_id` into every declared index whose
428/// label is in `label_ids` and whose property `props` has a value for.
429/// Called on node creation (`label_ids` = every label the node was just
430/// given) and on `SET n:Label` (`label_ids` = just the one newly-added
431/// label — indexes on labels the node already had are untouched, since
432/// nothing about their entries changed).
433pub fn on_node_created(
434    write_txn: &WriteTransaction,
435    node_id: u64,
436    label_ids: &[u32],
437    props: &BTreeMap<String, PropertyValue>,
438) -> Result<(), GraphError> {
439    for (label_id, prop_id, prop_name, def) in indexes_for_labels(Txn::Write(write_txn), label_ids)?
440    {
441        if let Some(value) = props.get(&prop_name) {
442            let label = resolve_label(Txn::Write(write_txn), label_id)?;
443            let target = IndexTarget {
444                label_id,
445                prop_id,
446                label: &label,
447                prop: &prop_name,
448            };
449            insert_entry(write_txn, &target, value, node_id, def.unique)?;
450        }
451    }
452    Ok(())
453}
454
455/// Removes `node_id`'s index entries from every declared index whose label
456/// is in `label_ids` and whose property `props` (the values *before* this
457/// change) has a value for. Called on node deletion (`label_ids` = every
458/// label the node had) and on `REMOVE n:Label` (`label_ids` = just the one
459/// removed label).
460pub fn on_node_deleted(
461    write_txn: &WriteTransaction,
462    node_id: u64,
463    label_ids: &[u32],
464    props: &BTreeMap<String, PropertyValue>,
465) -> Result<(), GraphError> {
466    for (label_id, prop_id, prop_name, _def) in
467        indexes_for_labels(Txn::Write(write_txn), label_ids)?
468    {
469        if let Some(value) = props.get(&prop_name) {
470            remove_entry(write_txn, label_id, prop_id, value, node_id)?;
471        }
472    }
473    Ok(())
474}
475
476/// One property's value changed on an existing node (`SET n.prop = ..`/
477/// `REMOVE n.prop`) — removes the old index entry (if `old_value` is
478/// `Some` and an index covers `(label, prop)` for one of `label_ids`) and
479/// inserts the new one (if `new_value` is `Some`). `new_value: None`
480/// means the property was removed entirely, not set to `null` — a
481/// `PropertyValue::Null` value is still `Some(&PropertyValue::Null)` here
482/// and gets indexed like any other value (matches `create_index`'s own
483/// backfill, which only skips a property that's *absent*, not one whose
484/// value is `Null`).
485pub fn on_node_prop_changed(
486    write_txn: &WriteTransaction,
487    node_id: u64,
488    label_ids: &[u32],
489    prop: &str,
490    old_value: Option<&PropertyValue>,
491    new_value: Option<&PropertyValue>,
492) -> Result<(), GraphError> {
493    for (label_id, prop_id, prop_name, def) in indexes_for_labels(Txn::Write(write_txn), label_ids)?
494    {
495        if prop_name != prop {
496            continue;
497        }
498        if let Some(old) = old_value {
499            remove_entry(write_txn, label_id, prop_id, old, node_id)?;
500        }
501        if let Some(new) = new_value {
502            let label = resolve_label(Txn::Write(write_txn), label_id)?;
503            let target = IndexTarget {
504                label_id,
505                prop_id,
506                label: &label,
507                prop: &prop_name,
508            };
509            insert_entry(write_txn, &target, new, node_id, def.unique)?;
510        }
511    }
512    Ok(())
513}