Skip to main content

nodedb_lite/query/query_ops/
recursive_scan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! RecursiveScan — iterative fixed-point CTE scan over a single collection.
3//!
4//! Implements `WITH RECURSIVE cte AS (base UNION [ALL] recursive)` semantics
5//! where both anchor and recursive branches query the same physical collection.
6//! Each recursive iteration hash-joins the collection against the current
7//! working set via `join_link`, producing the next working set.
8
9use std::collections::{HashMap, HashSet};
10
11use nodedb_types::result::QueryResult;
12use nodedb_types::value::Value;
13
14use crate::error::LiteError;
15use crate::query::engine::LiteQueryEngine;
16use crate::query::query_ops::joins::common::{
17    apply_filters, decode_filters, maps_to_result, scan_collection,
18};
19use crate::storage::engine::StorageEngine;
20
21/// Inputs of a recursive collection scan.
22///
23/// The filter fields hold msgpack-encoded `MetadataFilter` bytes. An empty
24/// slice means no filter.
25pub struct RecursiveScanSpec<'a> {
26    pub collection: &'a str,
27    pub base_filters: &'a [u8],
28    pub recursive_filters: &'a [u8],
29    pub join_link: Option<&'a (String, String)>,
30    pub max_iterations: usize,
31    pub distinct: bool,
32    pub limit: usize,
33}
34
35/// Iterative fixed-point CTE scan over a collection.
36///
37/// Executes the base query once, then repeatedly extends the working set by
38/// joining the collection against it via `join_link`, stopping when no new
39/// rows emerge, the iteration cap is hit, or the `limit` is reached.
40pub async fn execute_recursive_scan<S: StorageEngine>(
41    engine: &LiteQueryEngine<S>,
42    spec: RecursiveScanSpec<'_>,
43) -> Result<QueryResult, LiteError> {
44    let RecursiveScanSpec {
45        collection,
46        base_filters,
47        recursive_filters,
48        join_link,
49        max_iterations,
50        distinct,
51        limit,
52    } = spec;
53    let base_parsed = decode_filters(base_filters)?;
54    let rec_parsed = decode_filters(recursive_filters)?;
55
56    // All rows in the collection (scanned once; re-filtered each iteration).
57    let full_collection = scan_collection(engine, collection).await?;
58
59    // Anchor: base case rows.
60    let anchor = apply_filters(full_collection.clone(), &base_parsed)?;
61
62    let effective_limit = if limit == 0 { usize::MAX } else { limit };
63    let max_iter = if max_iterations == 0 {
64        100
65    } else {
66        max_iterations
67    };
68
69    let mut accumulator: Vec<HashMap<String, Value>> = Vec::new();
70    let mut seen: HashSet<String> = HashSet::new();
71
72    let append_rows = |acc: &mut Vec<HashMap<String, Value>>,
73                       seen: &mut HashSet<String>,
74                       rows: Vec<HashMap<String, Value>>,
75                       dedup: bool,
76                       cap: usize|
77     -> Vec<HashMap<String, Value>> {
78        let mut new_working: Vec<HashMap<String, Value>> = Vec::new();
79        for row in rows {
80            if acc.len() >= cap {
81                break;
82            }
83            if dedup {
84                let key = row_key(&row);
85                if seen.contains(&key) {
86                    continue;
87                }
88                seen.insert(key);
89            }
90            new_working.push(row.clone());
91            acc.push(row);
92        }
93        new_working
94    };
95
96    let mut working_set = append_rows(
97        &mut accumulator,
98        &mut seen,
99        anchor,
100        distinct,
101        effective_limit,
102    );
103
104    for _ in 0..max_iter {
105        if working_set.is_empty() || accumulator.len() >= effective_limit {
106            break;
107        }
108
109        // Filter the full collection with recursive_filters.
110        let candidates = apply_filters(full_collection.clone(), &rec_parsed)?;
111
112        // Join candidates against working_set via join_link.
113        let next_rows = match join_link {
114            Some((coll_field, working_field)) => {
115                // Build lookup from working set: working_field value → present.
116                let working_vals: HashSet<String> = working_set
117                    .iter()
118                    .map(|r| value_key(r.get(working_field).unwrap_or(&Value::Null)))
119                    .collect();
120
121                candidates
122                    .into_iter()
123                    .filter(|row| {
124                        let v = value_key(row.get(coll_field).unwrap_or(&Value::Null));
125                        working_vals.contains(&v)
126                    })
127                    .collect()
128            }
129            None => candidates,
130        };
131
132        working_set = append_rows(
133            &mut accumulator,
134            &mut seen,
135            next_rows,
136            distinct,
137            effective_limit,
138        );
139    }
140
141    Ok(maps_to_result(accumulator))
142}
143
144/// Deterministic string identity for deduplication.
145fn row_key(row: &HashMap<String, Value>) -> String {
146    let mut pairs: Vec<(&String, &Value)> = row.iter().collect();
147    pairs.sort_by_key(|(k, _)| k.as_str());
148    pairs
149        .into_iter()
150        .map(|(k, v)| format!("{k}={}", value_key(v)))
151        .collect::<Vec<_>>()
152        .join(";")
153}
154
155fn value_key(v: &Value) -> String {
156    match v {
157        Value::Null => "null".to_string(),
158        Value::Bool(b) => format!("b:{b}"),
159        Value::Integer(n) => format!("i:{n}"),
160        Value::Float(f) => format!("f:{f}"),
161        Value::String(s) => format!("s:{s}"),
162        Value::Uuid(s) | Value::Ulid(s) => format!("id:{s}"),
163        Value::Bytes(b) => format!("by:{}", b.len()),
164        Value::Array(a) | Value::Set(a) => format!("a:{}", a.len()),
165        Value::Object(m) => format!("o:{}", m.len()),
166        _ => "other".to_string(),
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn node(id: i64, parent_id: i64) -> HashMap<String, Value> {
175        let mut m = HashMap::new();
176        m.insert("id".to_string(), Value::Integer(id));
177        m.insert("parent_id".to_string(), Value::Integer(parent_id));
178        m
179    }
180
181    /// Tree: root(id=1, parent_id=0), two children (2,1) and (3,1),
182    /// one grandchild (4,2).  Anchor = parent_id==0.  join_link = (parent_id, id).
183    /// Expected: all 4 nodes in accumulator after traversal.
184    #[test]
185    fn test_recursive_scan_tree_logic() {
186        let all_nodes = [node(1, 0), node(2, 1), node(3, 1), node(4, 2)];
187
188        // Simulate the iterative logic inline (no async needed).
189        let anchor: Vec<_> = all_nodes
190            .iter()
191            .filter(|r| r.get("parent_id") == Some(&Value::Integer(0)))
192            .cloned()
193            .collect();
194        assert_eq!(anchor.len(), 1); // root only
195
196        let join_link = ("parent_id".to_string(), "id".to_string());
197        let mut accumulator: Vec<HashMap<String, Value>> = anchor.clone();
198        let mut working_set: Vec<HashMap<String, Value>> = anchor;
199
200        for _ in 0..10 {
201            if working_set.is_empty() {
202                break;
203            }
204            let working_vals: std::collections::HashSet<String> = working_set
205                .iter()
206                .map(|r| value_key(r.get(&join_link.1).unwrap_or(&Value::Null)))
207                .collect();
208            let next: Vec<_> = all_nodes
209                .iter()
210                .filter(|r| {
211                    let v = value_key(r.get(&join_link.0).unwrap_or(&Value::Null));
212                    working_vals.contains(&v)
213                })
214                .cloned()
215                .collect();
216            // Exclude already-accumulated rows (distinct).
217            let already: std::collections::HashSet<String> =
218                accumulator.iter().map(row_key).collect();
219            let new_rows: Vec<_> = next
220                .into_iter()
221                .filter(|r| !already.contains(&row_key(r)))
222                .collect();
223            working_set = new_rows.clone();
224            accumulator.extend(new_rows);
225        }
226
227        assert_eq!(accumulator.len(), 4, "all 4 tree nodes reachable from root");
228    }
229}