Skip to main content

uqa_operators/
hierarchical.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Hierarchical / nested-document operators (Definitions 5.3.1-5.3.5,
8//! Paper 1).
9//!
10//! `PathSegment` traversal works against the same document shape the
11//! engine already uses: a `Document` is a `BTreeMap<String, Value>`,
12//! and `Value::Map` / `Value::List` thread through the path.
13//!
14//! Paths are dotted strings: `metadata.author` or `orders.0.amount`.
15//! A bare segment (no digit) selects a map key; a numeric segment
16//! indexes into a list.
17
18use std::sync::Arc;
19
20use uqa_core::{
21    IndexStats, PathExpr, PathSegment, Payload, PostingEntry, PostingList, Predicate, Value,
22};
23use uqa_storage::{document_store::Document, StorageBackendError, StorageBackendResult};
24
25use crate::base::{missing_backend, ExecutionContext, Operator, OperatorResult};
26use crate::primitive::FilterOperator;
27
28/// Parse a dotted path: each numeric segment becomes an index, every
29/// other segment a key lookup.
30pub fn parse_path(path: &str) -> PathExpr {
31    path.split('.')
32        .map(|seg| match seg.parse::<usize>() {
33            Ok(n) => PathSegment::Index(n),
34            Err(_) => PathSegment::Key(seg.to_string()),
35        })
36        .collect()
37}
38
39/// Evaluate a path against a document, descending through `Value::Map`
40/// and `Value::List`. Returns `None` if any segment fails to resolve.
41pub fn eval_path(doc: &Document, path: &[PathSegment]) -> Option<Value> {
42    let mut current: Value = match path.first()? {
43        PathSegment::Key(k) => doc.get(k)?.clone(),
44        PathSegment::Index(_) => return None,
45    };
46    for seg in path.iter().skip(1) {
47        current = match (current, seg) {
48            (Value::Map(m), PathSegment::Key(k)) => m.get(k)?.clone(),
49            (Value::List(items), PathSegment::Index(i)) => items.get(*i)?.clone(),
50            (Value::List(items), PathSegment::Key(k)) => {
51                // Map a key over a list of maps: collect the key from each
52                // element so downstream callers see a list at the leaf.
53                let collected: Vec<Value> = items
54                    .into_iter()
55                    .filter_map(|v| match v {
56                        Value::Map(m) => m.get(k).cloned(),
57                        _ => None,
58                    })
59                    .collect();
60                Value::List(collected)
61            }
62            _ => return None,
63        };
64    }
65    Some(current)
66}
67
68/// Project `paths` out of `doc` into a flat map keyed by the dotted
69/// path string. Numeric path segments render as
70/// their integer literal).
71pub fn project_paths(
72    doc: &Document,
73    paths: &[PathExpr],
74) -> std::collections::BTreeMap<String, Value> {
75    let mut out = std::collections::BTreeMap::new();
76    for path in paths {
77        let key = path
78            .iter()
79            .map(|seg| match seg {
80                PathSegment::Key(k) => k.clone(),
81                PathSegment::Index(i) => i.to_string(),
82            })
83            .collect::<Vec<_>>()
84            .join(".");
85        let value = eval_path(doc, path).unwrap_or(Value::Null);
86        out.insert(key, value);
87    }
88    out
89}
90
91/// Unnest an array at `path` into a sequence of synthesised
92/// documents. Each
93/// emitted document is a clone of the source merged with two
94/// metadata fields:
95///
96/// * `<path>._unnested` -- the array element value.
97/// * `_unnest_index`    -- the element's 0-based index.
98pub fn unnest_array(doc: &Document, path: &[PathSegment]) -> StorageBackendResult<Vec<Document>> {
99    let resolved = eval_path(doc, path);
100    let Some(Value::List(items)) = resolved else {
101        return Ok(Vec::new());
102    };
103    let path_key = path
104        .iter()
105        .map(|seg| match seg {
106            PathSegment::Key(k) => k.clone(),
107            PathSegment::Index(i) => i.to_string(),
108        })
109        .collect::<Vec<_>>()
110        .join(".");
111    let unnest_key = format!("{path_key}._unnested");
112    items
113        .into_iter()
114        .enumerate()
115        .map(|(idx, item)| {
116            let mut nested = doc.clone();
117            nested.insert(unnest_key.clone(), item);
118            nested.insert(
119                "_unnest_index".to_string(),
120                Value::Int(i64::try_from(idx).map_err(|_| {
121                    StorageBackendError::Other(format!(
122                        "unnest index {idx} exceeds the Value::Int range"
123                    ))
124                })?),
125            );
126            Ok(nested)
127        })
128        .collect()
129}
130
131// -------------------------------------------------------------------------
132// PathFilter
133// -------------------------------------------------------------------------
134
135/// Filter documents by whether `path`'s value (or any element if the
136/// resolved value is a list) matches `predicate`.
137pub struct PathFilterOperator {
138    pub path: PathExpr,
139    pub predicate: Predicate,
140    pub source: Option<Arc<dyn Operator>>,
141}
142
143impl PathFilterOperator {
144    pub fn new(path: PathExpr, predicate: Predicate, source: Option<Arc<dyn Operator>>) -> Self {
145        Self {
146            path,
147            predicate,
148            source,
149        }
150    }
151}
152
153impl Operator for PathFilterOperator {
154    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
155        let Some(doc_store) = ctx.document_store.as_ref() else {
156            return Err(missing_backend("document-store", "path filter"));
157        };
158        let candidates: Vec<u64> = match &self.source {
159            Some(src) => src.execute(ctx)?.doc_ids().collect(),
160            None => doc_store.doc_ids()?,
161        };
162        let mut entries: Vec<PostingEntry> = Vec::new();
163        for doc_id in candidates {
164            let doc = doc_store.get(doc_id)?.ok_or_else(|| {
165                StorageBackendError::Other(format!(
166                    "path filter candidate {doc_id} is missing from the document store"
167                ))
168            })?;
169            let Some(value) = eval_path(&doc, &self.path) else {
170                if self.predicate.is_null_aware() && self.predicate.evaluate(None) {
171                    entries.push(PostingEntry::new(doc_id, Payload::default()));
172                }
173                continue;
174            };
175            let matched = match &value {
176                Value::List(items) => items.iter().any(|v| self.predicate.evaluate(Some(v))),
177                other => self.predicate.evaluate(Some(other)),
178            };
179            if matched {
180                entries.push(PostingEntry::new(doc_id, Payload::default()));
181            }
182        }
183        entries.sort_by_key(|e| e.doc_id);
184        Ok(PostingList::from_sorted_unchecked(entries))
185    }
186
187    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
188        match &self.source {
189            Some(src) => src.cost_estimate(stats),
190            None => stats.total_docs as f64,
191        }
192    }
193}
194
195// -------------------------------------------------------------------------
196// PathProject
197// -------------------------------------------------------------------------
198
199/// Project documents to the named paths: each path's resolved value
200/// lands in the output entry's `payload.fields` keyed on its dotted
201/// representation. Documents that fail to resolve a path keep the
202/// missing key absent.
203pub struct PathProjectOperator {
204    pub paths: Vec<PathExpr>,
205    pub source: Arc<dyn Operator>,
206}
207
208impl PathProjectOperator {
209    pub fn new(paths: Vec<PathExpr>, source: Arc<dyn Operator>) -> Self {
210        Self { paths, source }
211    }
212}
213
214impl Operator for PathProjectOperator {
215    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
216        let source_pl = self.source.execute(ctx)?;
217        let Some(doc_store) = ctx.document_store.as_ref() else {
218            return Err(missing_backend("document-store", "path projection"));
219        };
220        let mut entries: Vec<PostingEntry> = Vec::new();
221        for entry in source_pl.entries() {
222            let doc = doc_store.get(entry.doc_id)?.ok_or_else(|| {
223                StorageBackendError::Other(format!(
224                    "path projection candidate {} is missing from the document store",
225                    entry.doc_id
226                ))
227            })?;
228            let mut fields = entry.payload.fields.clone();
229            for path in &self.paths {
230                if let Some(value) = eval_path(&doc, path) {
231                    fields.insert(path_key(path), value);
232                }
233            }
234            entries.push(PostingEntry::new(
235                entry.doc_id,
236                Payload {
237                    positions: entry.payload.positions.clone(),
238                    score: entry.payload.score,
239                    fields,
240                },
241            ));
242        }
243        Ok(PostingList::from_sorted_unchecked(entries))
244    }
245
246    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
247        self.source.cost_estimate(stats)
248    }
249}
250
251fn path_key(path: &[PathSegment]) -> String {
252    let mut parts = Vec::with_capacity(path.len());
253    for seg in path {
254        match seg {
255            PathSegment::Key(k) => parts.push(k.clone()),
256            PathSegment::Index(i) => parts.push(i.to_string()),
257        }
258    }
259    parts.join(".")
260}
261
262// -------------------------------------------------------------------------
263// PathAggregate
264// -------------------------------------------------------------------------
265
266#[derive(Debug, Clone, Copy)]
267pub enum AggregationKind {
268    Sum,
269    Avg,
270    Min,
271    Max,
272    Count,
273}
274
275/// Aggregate a numeric path across nested arrays per-document. The
276/// payload's score becomes the aggregate value; `_path_aggregate` and
277/// `_path_aggregate_path` carry the value and the dotted path for
278/// downstream consumers.
279pub struct PathAggregateOperator {
280    pub path: PathExpr,
281    pub agg: AggregationKind,
282    pub source: Option<Arc<dyn Operator>>,
283}
284
285impl PathAggregateOperator {
286    pub fn new(path: PathExpr, agg: AggregationKind, source: Option<Arc<dyn Operator>>) -> Self {
287        Self { path, agg, source }
288    }
289}
290
291impl Operator for PathAggregateOperator {
292    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
293        let Some(doc_store) = ctx.document_store.as_ref() else {
294            return Err(missing_backend("document-store", "path aggregation"));
295        };
296        let candidates: Vec<u64> = match &self.source {
297            Some(src) => src.execute(ctx)?.doc_ids().collect(),
298            None => doc_store.doc_ids()?,
299        };
300        let mut entries: Vec<PostingEntry> = Vec::new();
301        for doc_id in candidates {
302            let doc = doc_store.get(doc_id)?.ok_or_else(|| {
303                StorageBackendError::Other(format!(
304                    "path aggregate candidate {doc_id} is missing from the document store"
305                ))
306            })?;
307            let value = eval_path(&doc, &self.path);
308            let mut numeric: Vec<f64> = Vec::new();
309            match value {
310                Some(Value::List(items)) => {
311                    for v in items {
312                        if let Some(number) = value_as_f64(&v)? {
313                            numeric.push(number);
314                        }
315                    }
316                }
317                Some(other) => {
318                    if let Some(number) = value_as_f64(&other)? {
319                        numeric.push(number);
320                    }
321                }
322                None => {}
323            }
324            let result = aggregate(self.agg, &numeric)?;
325            let mut fields = std::collections::BTreeMap::new();
326            fields.insert(
327                "_path_aggregate_path".into(),
328                Value::Str(path_key(&self.path)),
329            );
330            fields.insert("_path_aggregate".into(), Value::Float(result));
331            entries.push(PostingEntry::new(
332                doc_id,
333                Payload {
334                    positions: Vec::new(),
335                    score: result,
336                    fields,
337                },
338            ));
339        }
340        entries.sort_by_key(|e| e.doc_id);
341        Ok(PostingList::from_sorted_unchecked(entries))
342    }
343
344    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
345        match &self.source {
346            Some(src) => src.cost_estimate(stats),
347            None => stats.total_docs as f64,
348        }
349    }
350}
351
352fn value_as_f64(value: &Value) -> StorageBackendResult<Option<f64>> {
353    let numeric = match value {
354        Value::Null => return Ok(None),
355        Value::Int(number) => *number as f64,
356        Value::Float(number) => *number,
357        Value::Bool(boolean) => {
358            if *boolean {
359                1.0
360            } else {
361                0.0
362            }
363        }
364        _ => {
365            return Err(StorageBackendError::Other(format!(
366                "path aggregation requires numeric values, got {value:?}"
367            )))
368        }
369    };
370    if !numeric.is_finite() {
371        return Err(StorageBackendError::Other(
372            "path aggregation requires finite numeric values".to_string(),
373        ));
374    }
375    Ok(Some(numeric))
376}
377
378fn aggregate(kind: AggregationKind, values: &[f64]) -> StorageBackendResult<f64> {
379    if values.is_empty() {
380        return Ok(0.0);
381    }
382    let result = match kind {
383        AggregationKind::Sum => values.iter().sum(),
384        AggregationKind::Avg => values.iter().sum::<f64>() / values.len() as f64,
385        AggregationKind::Min => values.iter().copied().fold(f64::INFINITY, f64::min),
386        AggregationKind::Max => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
387        AggregationKind::Count => values.len() as f64,
388    };
389    if !result.is_finite() {
390        return Err(StorageBackendError::Other(
391            "path aggregation overflowed the finite numeric range".to_string(),
392        ));
393    }
394    Ok(result)
395}
396
397// -------------------------------------------------------------------------
398// UnifiedFilter
399// -------------------------------------------------------------------------
400
401/// Dispatch between [`FilterOperator`] (flat field) and
402/// [`PathFilterOperator`] (dotted path). The decision depends purely
403/// on whether `field_expr` contains `.`.
404pub struct UnifiedFilterOperator {
405    pub field_expr: String,
406    pub predicate: Predicate,
407    pub source: Option<Arc<dyn Operator>>,
408}
409
410impl UnifiedFilterOperator {
411    pub fn new(
412        field_expr: impl Into<String>,
413        predicate: Predicate,
414        source: Option<Arc<dyn Operator>>,
415    ) -> Self {
416        Self {
417            field_expr: field_expr.into(),
418            predicate,
419            source,
420        }
421    }
422}
423
424impl Operator for UnifiedFilterOperator {
425    fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
426        if self.field_expr.contains('.') {
427            let path = parse_path(&self.field_expr);
428            let inner = PathFilterOperator::new(path, self.predicate.clone(), self.source.clone());
429            inner.execute(ctx)
430        } else {
431            let inner = FilterOperator::new(
432                self.field_expr.clone(),
433                self.predicate.clone(),
434                self.source.clone(),
435            );
436            inner.execute(ctx)
437        }
438    }
439
440    fn cost_estimate(&self, stats: &IndexStats) -> f64 {
441        match &self.source {
442            Some(src) => src.cost_estimate(stats),
443            None => stats.total_docs as f64,
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn parse_dotted_path() {
454        let p = parse_path("orders.0.amount");
455        assert_eq!(
456            p,
457            vec![
458                PathSegment::Key("orders".into()),
459                PathSegment::Index(0),
460                PathSegment::Key("amount".into()),
461            ]
462        );
463    }
464
465    #[test]
466    fn eval_path_descends_map_then_list_then_key() {
467        let mut doc: Document = std::collections::BTreeMap::new();
468        let mut order = std::collections::BTreeMap::new();
469        order.insert("amount".into(), Value::Int(7));
470        doc.insert("orders".into(), Value::List(vec![Value::Map(order)]));
471        let v = eval_path(&doc, &parse_path("orders.0.amount")).unwrap();
472        assert_eq!(v, Value::Int(7));
473    }
474
475    #[test]
476    fn eval_path_maps_key_over_list_of_maps() {
477        let mut doc: Document = std::collections::BTreeMap::new();
478        let mut o1 = std::collections::BTreeMap::new();
479        o1.insert("amount".into(), Value::Int(7));
480        let mut o2 = std::collections::BTreeMap::new();
481        o2.insert("amount".into(), Value::Int(11));
482        doc.insert(
483            "orders".into(),
484            Value::List(vec![Value::Map(o1), Value::Map(o2)]),
485        );
486        let v = eval_path(&doc, &parse_path("orders.amount")).unwrap();
487        assert_eq!(v, Value::List(vec![Value::Int(7), Value::Int(11)]));
488    }
489}