Skip to main content

lora_executor/executor/
helpers.rs

1//! Cross-cutting executor helpers used by both the read-only and the
2//! mutable executor (and, via `pub(crate)` re-exports through
3//! `super::mod`, by the streaming pull pipeline in `crate::pull`).
4//!
5//! Roughly four groups:
6//!
7//! 1. Row-set primitives: `dedup_rows` / `dedup_rows_by_vars` for
8//!    UNION / DISTINCT, [`compute_aggregate_expr`] for the buffered
9//!    aggregation path, [`compare_sort_item`] for the buffered Sort
10//!    operator. The streaming pipeline in `crate::pull` calls
11//!    [`compute_aggregate_expr`] when the streamable-fold fast-path
12//!    classifier rejects a projection.
13//! 2. Label / property scans: [`scan_node_ids_for_label_groups`],
14//!    [`indexed_node_property_candidates`],
15//!    [`node_matches_label_groups`], [`node_matches_property_filter`],
16//!    [`label_group_candidates_prefiltered`]. Both NodeByLabelScan and
17//!    NodeByPropertyScan share these helpers across the buffered and
18//!    streaming pipelines.
19//! 3. Path construction: [`build_path_value`] for `PathBuild`,
20//!    [`variable_length_expand`] (the buffered BFS path used as the
21//!    fallback under non-streaming variable-length expansions) and
22//!    [`filter_shortest_paths`] for SHORTEST PATH.
23//! 4. Value classification: [`value_matches_property_value`] (used
24//!    by every property prefilter), [`hydrate_node_record`] /
25//!    [`hydrate_relationship_record`] (single-record hydration), and
26//!    the [`GroupValueKey`] dedup / group key.
27//!
28//! Also hosts the small `eval_properties_expr`, `eval_aggregate_arg_values`,
29//! `eval_first_or_null`, `dedup_values`, `as_f64_lossy`,
30//! `compare_values_for_sort`, `compare_values_total`,
31//! `single_label_hint`, `property_lookup_values`, `type_rank`,
32//! `flatten_label_groups` private helpers, and the
33//! `MAX_VAR_LEN_HOPS` cap on unbounded variable-length expansion.
34
35use std::cmp::Ordering;
36use std::collections::{BTreeMap, BTreeSet};
37use std::time::Instant;
38
39use lora_analyzer::symbols::VarId;
40use lora_analyzer::{ResolvedExpr, ResolvedSortItem};
41use lora_ast::{Direction, RangeLiteral};
42use lora_compiler::physical::ProjectionExec;
43use lora_store::{GraphStorage, NodeId, Properties, PropertyValue};
44
45use crate::errors::{value_kind, ExecResult, ExecutorError};
46use crate::eval::{eval_expr, eval_expr_result, eval_truthy_result, EvalContext};
47use crate::value::{lora_value_to_property, LoraPath, LoraValue, Row};
48
49/// Deadline guard. Returns `QueryTimeout` once the deadline has
50/// elapsed; both executors call this every operator-level recursion
51/// step and from inside per-row inner loops.
52#[inline]
53pub(super) fn check_deadline_at(deadline: Instant) -> ExecResult<()> {
54    if Instant::now() >= deadline {
55        Err(ExecutorError::QueryTimeout)
56    } else {
57        Ok(())
58    }
59}
60
61pub(super) fn filter_rows_checked<S: GraphStorage>(
62    input_rows: Vec<Row>,
63    predicate: &ResolvedExpr,
64    eval_ctx: &EvalContext<'_, S>,
65) -> ExecResult<Vec<Row>> {
66    let mut out = Vec::with_capacity(input_rows.len());
67    for row in input_rows {
68        if eval_truthy_result(predicate, &row, eval_ctx).map_err(ExecutorError::RuntimeError)? {
69            out.push(row);
70        }
71    }
72    Ok(out)
73}
74
75pub(super) fn project_rows_checked<S: GraphStorage>(
76    input_rows: Vec<Row>,
77    op: &ProjectionExec,
78    eval_ctx: &EvalContext<'_, S>,
79) -> ExecResult<Vec<Row>> {
80    let mut out = Vec::with_capacity(input_rows.len());
81
82    for row in input_rows {
83        if op.include_existing {
84            let mut projected = row;
85            for item in &op.items {
86                let value = eval_expr_result(&item.expr, &projected, eval_ctx)
87                    .map_err(ExecutorError::RuntimeError)?;
88                projected.insert_named(item.output, item.name.clone(), value);
89            }
90            out.push(projected);
91        } else {
92            let mut projected = Row::new();
93            for item in &op.items {
94                let value = eval_expr_result(&item.expr, &row, eval_ctx)
95                    .map_err(ExecutorError::RuntimeError)?;
96                projected.insert_named(item.output, item.name.clone(), value);
97            }
98            out.push(projected);
99        }
100    }
101
102    Ok(if op.distinct {
103        dedup_rows_by_vars(out)
104    } else {
105        out
106    })
107}
108
109pub(super) fn properties_to_value_map(props: &Properties) -> LoraValue {
110    let mut map = BTreeMap::new();
111    for (k, v) in props.iter() {
112        map.insert(k.clone(), LoraValue::from(v));
113    }
114    LoraValue::Map(map)
115}
116
117/// Dedup rows that share the same schema (same VarId set). Compares rows by
118/// a Vec<GroupValueKey> keyed on VarId iteration order — avoids the per-row
119/// column-name String clones of `dedup_rows`. Used by DISTINCT projection.
120pub(crate) fn dedup_rows_by_vars(rows: Vec<Row>) -> Vec<Row> {
121    let mut seen: BTreeSet<Vec<GroupValueKey>> = BTreeSet::new();
122    let mut out = Vec::new();
123
124    for row in rows {
125        let key: Vec<GroupValueKey> = row
126            .iter()
127            .map(|(_, val)| GroupValueKey::from_value(val))
128            .collect();
129        if seen.insert(key) {
130            out.push(row);
131        }
132    }
133
134    out
135}
136
137/// Dedup rows using named entries so rows with different VarIds but the same
138/// column name + value are collapsed. Needed for UNION where each branch has
139/// its own VarIds.
140pub(crate) fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
141    let mut seen: BTreeSet<Vec<(String, GroupValueKey)>> = BTreeSet::new();
142    let mut out = Vec::new();
143
144    for row in rows {
145        let key: Vec<(String, GroupValueKey)> = row
146            .iter_named()
147            .map(|(_, name, val)| (name.into_owned(), GroupValueKey::from_value(val)))
148            .collect();
149        if seen.insert(key) {
150            out.push(row);
151        }
152    }
153
154    out
155}
156
157pub(super) fn eval_properties_expr<S: GraphStorage>(
158    expr: &ResolvedExpr,
159    row: &Row,
160    storage: &S,
161    params: &BTreeMap<String, LoraValue>,
162) -> ExecResult<Properties> {
163    let eval_ctx = EvalContext { storage, params };
164
165    match eval_expr(expr, row, &eval_ctx) {
166        LoraValue::Map(map) => {
167            let mut out = Properties::new();
168            for (k, v) in map {
169                let prop = lora_value_to_property(v)
170                    .map_err(|e| ExecutorError::RuntimeError(e.to_string()))?;
171                out.insert(k, prop);
172            }
173            Ok(out)
174        }
175        other => Err(ExecutorError::ExpectedPropertyMap {
176            found: value_kind(&other),
177        }),
178    }
179}
180
181pub(crate) fn compute_aggregate_expr<S: GraphStorage>(
182    expr: &ResolvedExpr,
183    rows: &[Row],
184    eval_ctx: &EvalContext<'_, S>,
185) -> ExecResult<LoraValue> {
186    match expr {
187        ResolvedExpr::Function {
188            name,
189            distinct,
190            args,
191        } => {
192            let func = name.to_ascii_lowercase();
193
194            match func.as_str() {
195                "count" => {
196                    if args.is_empty() {
197                        return Ok(LoraValue::Int(rows.len() as i64));
198                    }
199
200                    let mut values = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?;
201                    values.retain(|v| !matches!(v, LoraValue::Null));
202
203                    if *distinct {
204                        values = dedup_values(values);
205                    }
206
207                    Ok(LoraValue::Int(values.len() as i64))
208                }
209
210                "collect" => {
211                    if args.is_empty() {
212                        return Ok(LoraValue::List(Vec::new()));
213                    }
214
215                    let mut values = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?;
216
217                    if *distinct {
218                        values = dedup_values(values);
219                    }
220
221                    Ok(LoraValue::List(values))
222                }
223
224                "sum" => {
225                    if args.is_empty() {
226                        return Ok(LoraValue::Null);
227                    }
228
229                    let mut values = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?;
230
231                    if *distinct {
232                        values = dedup_values(values);
233                    }
234
235                    let nums = values
236                        .into_iter()
237                        .filter_map(as_f64_lossy)
238                        .collect::<Vec<_>>();
239
240                    if nums.is_empty() {
241                        Ok(LoraValue::Null)
242                    } else if nums.iter().all(|n| n.fract() == 0.0) {
243                        Ok(LoraValue::Int(nums.iter().sum::<f64>() as i64))
244                    } else {
245                        Ok(LoraValue::Float(nums.iter().sum::<f64>()))
246                    }
247                }
248
249                "avg" => {
250                    if args.is_empty() {
251                        return Ok(LoraValue::Null);
252                    }
253
254                    let mut values = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?;
255
256                    if *distinct {
257                        values = dedup_values(values);
258                    }
259
260                    let nums = values
261                        .into_iter()
262                        .filter_map(as_f64_lossy)
263                        .collect::<Vec<_>>();
264
265                    if nums.is_empty() {
266                        Ok(LoraValue::Null)
267                    } else {
268                        Ok(LoraValue::Float(
269                            nums.iter().sum::<f64>() / nums.len() as f64,
270                        ))
271                    }
272                }
273
274                "min" => {
275                    if args.is_empty() {
276                        return Ok(LoraValue::Null);
277                    }
278
279                    let mut values = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?;
280                    values.retain(|v| !matches!(v, LoraValue::Null));
281
282                    if *distinct {
283                        values = dedup_values(values);
284                    }
285
286                    Ok(values
287                        .into_iter()
288                        .min_by(compare_values_total)
289                        .unwrap_or(LoraValue::Null))
290                }
291
292                "max" => {
293                    if args.is_empty() {
294                        return Ok(LoraValue::Null);
295                    }
296
297                    let mut values = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?;
298                    values.retain(|v| !matches!(v, LoraValue::Null));
299
300                    if *distinct {
301                        values = dedup_values(values);
302                    }
303
304                    Ok(values
305                        .into_iter()
306                        .max_by(compare_values_total)
307                        .unwrap_or(LoraValue::Null))
308                }
309
310                "stdev" | "stdevp" => {
311                    if args.is_empty() {
312                        return Ok(LoraValue::Null);
313                    }
314
315                    let nums: Vec<f64> = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?
316                        .into_iter()
317                        .filter_map(as_f64_lossy)
318                        .collect();
319
320                    let is_population = func == "stdevp";
321
322                    if nums.is_empty() || (!is_population && nums.len() < 2) {
323                        return Ok(LoraValue::Float(0.0));
324                    }
325
326                    let mean = nums.iter().sum::<f64>() / nums.len() as f64;
327                    let variance_sum: f64 = nums.iter().map(|x| (x - mean).powi(2)).sum();
328                    let denom = if is_population {
329                        nums.len() as f64
330                    } else {
331                        (nums.len() - 1) as f64
332                    };
333                    Ok(LoraValue::Float((variance_sum / denom).sqrt()))
334                }
335
336                "percentilecont" => {
337                    if args.len() < 2 {
338                        return Ok(LoraValue::Null);
339                    }
340
341                    let Some(first) = rows.first() else {
342                        return Ok(LoraValue::Null);
343                    };
344
345                    let percentile = eval_expr_result(&args[1], first, eval_ctx)
346                        .map_err(ExecutorError::RuntimeError)?
347                        .as_f64()
348                        .unwrap_or(0.5);
349                    let mut nums: Vec<f64> = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?
350                        .into_iter()
351                        .filter_map(as_f64_lossy)
352                        .collect();
353
354                    if nums.is_empty() {
355                        return Ok(LoraValue::Null);
356                    }
357
358                    nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
359
360                    let index = percentile * (nums.len() - 1) as f64;
361                    let lower = index.floor() as usize;
362                    let upper = index.ceil() as usize;
363                    let fraction = index - lower as f64;
364
365                    if lower == upper || upper >= nums.len() {
366                        Ok(LoraValue::Float(nums[lower]))
367                    } else {
368                        Ok(LoraValue::Float(
369                            nums[lower] * (1.0 - fraction) + nums[upper] * fraction,
370                        ))
371                    }
372                }
373
374                "percentiledisc" => {
375                    if args.len() < 2 {
376                        return Ok(LoraValue::Null);
377                    }
378
379                    let Some(first) = rows.first() else {
380                        return Ok(LoraValue::Null);
381                    };
382
383                    let percentile = eval_expr_result(&args[1], first, eval_ctx)
384                        .map_err(ExecutorError::RuntimeError)?
385                        .as_f64()
386                        .unwrap_or(0.5);
387                    let mut nums: Vec<f64> = eval_aggregate_arg_values(&args[0], rows, eval_ctx)?
388                        .into_iter()
389                        .filter_map(as_f64_lossy)
390                        .collect();
391
392                    if nums.is_empty() {
393                        return Ok(LoraValue::Null);
394                    }
395
396                    nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
397
398                    let index = (percentile * (nums.len() - 1) as f64).round() as usize;
399                    let index = index.min(nums.len() - 1);
400                    Ok(LoraValue::Float(nums[index]))
401                }
402
403                _ => eval_first_or_null(expr, rows, eval_ctx),
404            }
405        }
406
407        _ => eval_first_or_null(expr, rows, eval_ctx),
408    }
409}
410
411fn eval_aggregate_arg_values<S: GraphStorage>(
412    expr: &ResolvedExpr,
413    rows: &[Row],
414    eval_ctx: &EvalContext<'_, S>,
415) -> ExecResult<Vec<LoraValue>> {
416    rows.iter()
417        .map(|row| eval_expr_result(expr, row, eval_ctx).map_err(ExecutorError::RuntimeError))
418        .collect()
419}
420
421fn eval_first_or_null<S: GraphStorage>(
422    expr: &ResolvedExpr,
423    rows: &[Row],
424    eval_ctx: &EvalContext<'_, S>,
425) -> ExecResult<LoraValue> {
426    match rows.first() {
427        Some(row) => eval_expr_result(expr, row, eval_ctx).map_err(ExecutorError::RuntimeError),
428        None => Ok(LoraValue::Null),
429    }
430}
431
432pub(crate) fn compare_sort_item<S: GraphStorage>(
433    item: &ResolvedSortItem,
434    a: &Row,
435    b: &Row,
436    eval_ctx: &EvalContext<'_, S>,
437) -> Ordering {
438    let av = eval_expr(&item.expr, a, eval_ctx);
439    let bv = eval_expr(&item.expr, b, eval_ctx);
440
441    let ascending = matches!(item.direction, lora_ast::SortDirection::Asc);
442    compare_values_for_sort(&av, &bv, ascending)
443}
444
445fn dedup_values(values: Vec<LoraValue>) -> Vec<LoraValue> {
446    let mut seen: BTreeSet<GroupValueKey> = BTreeSet::new();
447    let mut out = Vec::new();
448
449    for value in values {
450        let key = GroupValueKey::from_value(&value);
451        if seen.insert(key) {
452            out.push(value);
453        }
454    }
455
456    out
457}
458
459fn as_f64_lossy(v: LoraValue) -> Option<f64> {
460    match v {
461        LoraValue::Int(i) => Some(i as f64),
462        LoraValue::Float(f) => Some(f),
463        _ => None,
464    }
465}
466
467fn compare_values_for_sort(a: &LoraValue, b: &LoraValue, ascending: bool) -> Ordering {
468    let ord = match (a, b) {
469        (LoraValue::Null, LoraValue::Null) => Ordering::Equal,
470        (LoraValue::Null, _) => Ordering::Greater,
471        (_, LoraValue::Null) => Ordering::Less,
472        _ => compare_values_total(a, b),
473    };
474
475    if ascending {
476        ord
477    } else {
478        ord.reverse()
479    }
480}
481
482fn compare_values_total(a: &LoraValue, b: &LoraValue) -> Ordering {
483    use LoraValue::*;
484
485    match (a, b) {
486        (Bool(x), Bool(y)) => x.cmp(y),
487        (Int(x), Int(y)) => x.cmp(y),
488        (Float(x), Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
489        (Int(x), Float(y)) => (*x as f64).partial_cmp(y).unwrap_or(Ordering::Equal),
490        (Float(x), Int(y)) => x.partial_cmp(&(*y as f64)).unwrap_or(Ordering::Equal),
491        (String(x), String(y)) => x.cmp(y),
492        (Binary(x), Binary(y)) => x.segments().cmp(y.segments()),
493        (Node(x), Node(y)) => x.cmp(y),
494        (Relationship(x), Relationship(y)) => x.cmp(y),
495        (Date(x), Date(y)) => x.cmp(y),
496        (DateTime(x), DateTime(y)) => x.cmp(y),
497        (Duration(x), Duration(y)) => x.cmp(y),
498        (Vector(x), Vector(y)) => x.to_key_string().cmp(&y.to_key_string()),
499        _ => type_rank(a)
500            .cmp(&type_rank(b))
501            .then_with(|| format!("{a:?}").cmp(&format!("{b:?}"))),
502    }
503}
504
505pub fn value_matches_property_value(expected: &LoraValue, actual: &PropertyValue) -> bool {
506    match (expected, actual) {
507        (LoraValue::Null, PropertyValue::Null) => true,
508        (LoraValue::Bool(a), PropertyValue::Bool(b)) => a == b,
509        (LoraValue::Int(a), PropertyValue::Int(b)) => a == b,
510        (LoraValue::Float(a), PropertyValue::Float(b)) => a == b,
511        (LoraValue::Int(a), PropertyValue::Float(b)) => (*a as f64) == *b,
512        (LoraValue::Float(a), PropertyValue::Int(b)) => *a == (*b as f64),
513        (LoraValue::String(a), PropertyValue::String(b)) => a == b,
514        (LoraValue::Binary(a), PropertyValue::Binary(b)) => a == b,
515
516        (LoraValue::List(xs), PropertyValue::List(ys)) => {
517            xs.len() == ys.len()
518                && xs
519                    .iter()
520                    .zip(ys.iter())
521                    .all(|(x, y)| value_matches_property_value(x, y))
522        }
523
524        (LoraValue::Map(xm), PropertyValue::Map(ym)) => xm.iter().all(|(k, xv)| {
525            ym.get(k)
526                .map(|yv| value_matches_property_value(xv, yv))
527                .unwrap_or(false)
528        }),
529
530        (LoraValue::Date(a), PropertyValue::Date(b)) => a == b,
531        (LoraValue::DateTime(a), PropertyValue::DateTime(b)) => a == b,
532        (LoraValue::LocalDateTime(a), PropertyValue::LocalDateTime(b)) => a == b,
533        (LoraValue::Time(a), PropertyValue::Time(b)) => a == b,
534        (LoraValue::LocalTime(a), PropertyValue::LocalTime(b)) => a == b,
535        (LoraValue::Duration(a), PropertyValue::Duration(b)) => a == b,
536        (LoraValue::Point(a), PropertyValue::Point(b)) => a == b,
537        (LoraValue::Vector(a), PropertyValue::Vector(b)) => a == b,
538
539        _ => false,
540    }
541}
542
543pub(crate) fn node_matches_property_filter<S: GraphStorage>(
544    storage: &S,
545    node_id: NodeId,
546    labels: &[Vec<String>],
547    key: &str,
548    expected: &LoraValue,
549) -> bool {
550    storage
551        .with_node(node_id, |node| {
552            node_matches_label_groups(&node.labels, labels)
553                && node
554                    .properties
555                    .get(key)
556                    .map(|actual| value_matches_property_value(expected, actual))
557                    .unwrap_or(false)
558        })
559        .unwrap_or(false)
560}
561
562fn single_label_hint(labels: &[Vec<String>]) -> Option<&str> {
563    if labels.len() == 1 && labels[0].len() == 1 {
564        Some(labels[0][0].as_str())
565    } else {
566        None
567    }
568}
569
570fn property_lookup_values(expected: &LoraValue) -> Option<Vec<PropertyValue>> {
571    let property = lora_value_to_property(expected.clone()).ok()?;
572    let mut values = vec![property.clone()];
573
574    match property {
575        PropertyValue::Int(i) => {
576            values.push(PropertyValue::Float(i as f64));
577        }
578        PropertyValue::Float(f)
579            if f.is_finite()
580                && f.fract() == 0.0
581                && f >= i64::MIN as f64
582                && f <= i64::MAX as f64 =>
583        {
584            values.push(PropertyValue::Int(f as i64));
585        }
586        _ => {}
587    }
588
589    Some(values)
590}
591
592pub(crate) struct NodePropertyCandidates {
593    pub(crate) ids: Vec<NodeId>,
594    pub(crate) prefiltered: bool,
595}
596
597pub(crate) fn indexed_node_property_candidates<S: GraphStorage>(
598    storage: &S,
599    labels: &[Vec<String>],
600    key: &str,
601    expected: &LoraValue,
602) -> NodePropertyCandidates {
603    let Some(values) = property_lookup_values(expected) else {
604        return NodePropertyCandidates {
605            ids: scan_node_ids_for_label_groups(storage, labels),
606            prefiltered: false,
607        };
608    };
609
610    let label_hint = single_label_hint(labels);
611    let mut seen = BTreeSet::new();
612    let mut out = Vec::new();
613    for value in values {
614        for id in storage.find_node_ids_by_property(label_hint, key, &value) {
615            if seen.insert(id) {
616                out.push(id);
617            }
618        }
619    }
620    NodePropertyCandidates {
621        ids: out,
622        prefiltered: labels.is_empty() || label_hint.is_some(),
623    }
624}
625
626/// Build a LoraPath from the node and relationship variables currently in a row.
627///
628/// For variable-length relationships (stored as a List of Relationship values),
629/// intermediate nodes are reconstructed from the storage by walking the
630/// relationship chain.
631pub(crate) fn build_path_value<S: GraphStorage>(
632    row: &Row,
633    node_vars: &[VarId],
634    rel_vars: &[VarId],
635    storage: &S,
636) -> LoraValue {
637    let mut raw_nodes = Vec::new();
638    let mut rels = Vec::new();
639    let mut has_var_len = false;
640
641    for &nv in node_vars {
642        match row.get(nv) {
643            Some(LoraValue::Node(id)) => raw_nodes.push(*id),
644            Some(LoraValue::List(items)) => {
645                for item in items {
646                    if let LoraValue::Node(id) = item {
647                        raw_nodes.push(*id);
648                    }
649                }
650            }
651            _ => {}
652        }
653    }
654
655    for &rv in rel_vars {
656        match row.get(rv) {
657            Some(LoraValue::Relationship(id)) => rels.push(*id),
658            Some(LoraValue::List(items)) => {
659                has_var_len = true;
660                for item in items {
661                    if let LoraValue::Relationship(id) = item {
662                        rels.push(*id);
663                    }
664                }
665            }
666            _ => {}
667        }
668    }
669
670    // For variable-length paths, reconstruct the full node sequence from the
671    // relationship chain. raw_nodes typically only has [start, end] but the
672    // path needs all intermediate nodes as well.
673    let nodes = if has_var_len && !rels.is_empty() && raw_nodes.len() == 2 {
674        let start = raw_nodes[0];
675        let mut ordered = Vec::with_capacity(rels.len() + 1);
676        ordered.push(start);
677        let mut current = start;
678        for &rel_id in &rels {
679            if let Some((src, dst)) = storage.relationship_endpoints(rel_id) {
680                let next = if src == current { dst } else { src };
681                ordered.push(next);
682                current = next;
683            }
684        }
685        ordered
686    } else {
687        raw_nodes
688    };
689
690    LoraValue::Path(LoraPath { nodes, rels })
691}
692
693fn type_rank(v: &LoraValue) -> u8 {
694    match v {
695        LoraValue::Null => 0,
696        LoraValue::Bool(_) => 1,
697        LoraValue::Int(_) | LoraValue::Float(_) => 2,
698        LoraValue::String(_) => 3,
699        LoraValue::Binary(_) => 4,
700        LoraValue::Date(_) => 5,
701        LoraValue::DateTime(_) => 6,
702        LoraValue::LocalDateTime(_) => 7,
703        LoraValue::Time(_) => 8,
704        LoraValue::LocalTime(_) => 9,
705        LoraValue::Duration(_) => 10,
706        LoraValue::Point(_) => 11,
707        LoraValue::Vector(_) => 12,
708        LoraValue::List(_) => 13,
709        LoraValue::Map(_) => 14,
710        LoraValue::Node(_) => 15,
711        LoraValue::Relationship(_) => 16,
712        LoraValue::Path(_) => 17,
713    }
714}
715
716/// Check whether a node's labels satisfy all label groups.
717/// Each group is a disjunction (OR): the node must have at least one label
718/// from the group.  Groups are conjunctive (AND): all groups must be satisfied.
719pub(crate) fn node_matches_label_groups(node_labels: &[String], groups: &[Vec<String>]) -> bool {
720    groups
721        .iter()
722        .all(|group| group.iter().any(|l| node_labels.iter().any(|nl| nl == l)))
723}
724
725/// Scan the graph for candidate node IDs matching the label groups. Uses the
726/// label index for the pick-first-label phase and avoids cloning NodeRecords.
727pub(crate) fn scan_node_ids_for_label_groups<S: GraphStorage>(
728    storage: &S,
729    groups: &[Vec<String>],
730) -> Vec<NodeId> {
731    if groups.is_empty() {
732        storage.all_node_ids()
733    } else if groups.len() == 1 && groups[0].len() == 1 {
734        storage.node_ids_by_label(&groups[0][0])
735    } else if groups.len() == 1 && groups[0].len() > 1 {
736        let mut seen = BTreeSet::new();
737        let mut out = Vec::new();
738        for label in &groups[0] {
739            for id in storage.node_ids_by_label(label) {
740                if seen.insert(id) {
741                    out.push(id);
742                }
743            }
744        }
745        out
746    } else {
747        storage.node_ids_by_label(&groups[0][0])
748    }
749}
750
751pub(crate) fn label_group_candidates_prefiltered(groups: &[Vec<String>]) -> bool {
752    groups.len() <= 1
753}
754
755pub(crate) fn hydrate_node_record(node: &lora_store::NodeRecord) -> LoraValue {
756    let mut map = BTreeMap::new();
757    map.insert("kind".to_string(), LoraValue::String("node".to_string()));
758    map.insert("id".to_string(), LoraValue::Int(node.id as i64));
759    map.insert(
760        "labels".to_string(),
761        LoraValue::List(
762            node.labels
763                .iter()
764                .map(|s| LoraValue::String(s.clone()))
765                .collect(),
766        ),
767    );
768    map.insert(
769        "properties".to_string(),
770        properties_to_value_map(&node.properties),
771    );
772    LoraValue::Map(map)
773}
774
775pub(crate) fn hydrate_relationship_record(rel: &lora_store::RelationshipRecord) -> LoraValue {
776    let mut map = BTreeMap::new();
777    map.insert(
778        "kind".to_string(),
779        LoraValue::String("relationship".to_string()),
780    );
781    map.insert("id".to_string(), LoraValue::Int(rel.id as i64));
782    map.insert("startId".to_string(), LoraValue::Int(rel.src as i64));
783    map.insert("endId".to_string(), LoraValue::Int(rel.dst as i64));
784    map.insert("type".to_string(), LoraValue::String(rel.rel_type.clone()));
785    map.insert(
786        "properties".to_string(),
787        properties_to_value_map(&rel.properties),
788    );
789    LoraValue::Map(map)
790}
791
792/// Flatten label groups into a simple Vec<String> (for CREATE/MERGE where
793/// disjunction doesn't apply — all labels are created).
794pub(super) fn flatten_label_groups(groups: &[Vec<String>]) -> Vec<String> {
795    groups.iter().flat_map(|g| g.iter().cloned()).collect()
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
799pub(crate) enum GroupValueKey {
800    Null,
801    Bool(bool),
802    Int(i64),
803    Float(String),
804    String(String),
805    Binary(Vec<Vec<u8>>),
806    List(Vec<GroupValueKey>),
807    Map(Vec<(String, GroupValueKey)>),
808    Node(u64),
809    Relationship(u64),
810}
811
812impl GroupValueKey {
813    pub(crate) fn from_value(v: &LoraValue) -> Self {
814        match v {
815            LoraValue::Null => Self::Null,
816            LoraValue::Bool(x) => Self::Bool(*x),
817            LoraValue::Int(x) => Self::Int(*x),
818            LoraValue::Float(x) => Self::Float(x.to_string()),
819            LoraValue::String(x) => Self::String(x.clone()),
820            LoraValue::Binary(x) => Self::Binary(x.segments().to_vec()),
821            LoraValue::List(xs) => Self::List(xs.iter().map(Self::from_value).collect()),
822            LoraValue::Map(m) => Self::Map(
823                m.iter()
824                    .map(|(k, v)| (k.clone(), Self::from_value(v)))
825                    .collect(),
826            ),
827            LoraValue::Node(id) => Self::Node(*id),
828            LoraValue::Relationship(id) => Self::Relationship(*id),
829            LoraValue::Path(_) => Self::Null,
830            // Temporal types: use their string representation as group key
831            LoraValue::Date(d) => Self::String(d.to_string()),
832            LoraValue::DateTime(dt) => Self::String(dt.to_string()),
833            LoraValue::LocalDateTime(dt) => Self::String(dt.to_string()),
834            LoraValue::Time(t) => Self::String(t.to_string()),
835            LoraValue::LocalTime(t) => Self::String(t.to_string()),
836            LoraValue::Duration(dur) => Self::String(dur.to_string()),
837            LoraValue::Point(p) => Self::String(p.to_string()),
838            LoraValue::Vector(v) => Self::String(format!("vector:{}", v.to_key_string())),
839        }
840    }
841}
842
843/// Compute effective (min_hops, max_hops) from a `RangeLiteral`.
844///
845/// Lora semantics:
846/// - `*`       → 1..∞   (start=None, end=None)
847/// - `*2..5`   → 2..5   (start=Some(2), end=Some(5))
848/// - `*..3`    → 1..3   (start=None, end=Some(3))
849/// - `*2..`    → 2..∞   (start=Some(2), end=None)
850/// - `*3`      → 3..3   (start=Some(3), end=None, no dots → exactly 3)
851/// - `*0..1`   → 0..1
852///
853/// For unbounded upper, we cap at `MAX_VAR_LEN_HOPS` to prevent runaway.
854const MAX_VAR_LEN_HOPS: u64 = 100;
855
856pub(crate) fn resolve_range(range: &RangeLiteral) -> (u64, u64) {
857    let min_hops = range.start.unwrap_or(1);
858    let max_hops = range.end.unwrap_or(MAX_VAR_LEN_HOPS);
859    (min_hops, max_hops)
860}
861
862/// An entry produced during BFS variable-length expansion.
863pub(crate) struct VarLenResult {
864    /// The destination node at the end of this path.
865    pub(crate) dst_node_id: NodeId,
866    /// The relationship IDs traversed (in order).
867    pub(crate) rel_ids: Vec<u64>,
868}
869
870/// Perform variable-length expansion from `start_node_id` following
871/// relationships of the given `types` and `direction`, collecting all
872/// reachable nodes at hop distances in `[min_hops, max_hops]`.
873///
874/// Uses BFS with relationship-uniqueness per path (each path does not
875/// reuse the same relationship, but may revisit nodes).
876pub(crate) fn variable_length_expand<S: GraphStorage>(
877    storage: &S,
878    start_node_id: NodeId,
879    direction: Direction,
880    types: &[String],
881    min_hops: u64,
882    max_hops: u64,
883) -> Vec<VarLenResult> {
884    let mut results = Vec::new();
885
886    // Each frontier entry: (current_node_id, relationships_used_so_far)
887    let mut frontier: Vec<(NodeId, Vec<u64>)> = vec![(start_node_id, Vec::new())];
888
889    for depth in 1..=max_hops {
890        // On the final hop we don't need to build next_frontier at all; every
891        // path gets recorded and then the loop terminates. Avoids one full
892        // pass of Vec clones on deep traversals.
893        let is_last_hop = depth == max_hops;
894        let mut next_frontier: Vec<(NodeId, Vec<u64>)> = Vec::new();
895
896        for (current_node, rels_used) in &frontier {
897            // ID-only expand avoids cloning full records/properties for every
898            // neighbour on every hop.
899            for (rel_id, neighbor_id) in storage.expand_ids(*current_node, direction, types) {
900                // Relationship-uniqueness: skip if this relationship was already
901                // traversed on this particular path.
902                if rels_used.contains(&rel_id) {
903                    continue;
904                }
905
906                if is_last_hop {
907                    // Terminal hop: just record the result. Allocate rel_ids
908                    // once (no duplicate clone) by extending a fresh copy.
909                    if depth >= min_hops {
910                        let mut rel_ids = Vec::with_capacity(rels_used.len() + 1);
911                        rel_ids.extend_from_slice(rels_used);
912                        rel_ids.push(rel_id);
913                        results.push(VarLenResult {
914                            dst_node_id: neighbor_id,
915                            rel_ids,
916                        });
917                    }
918                    continue;
919                }
920
921                let mut new_rels = Vec::with_capacity(rels_used.len() + 1);
922                new_rels.extend_from_slice(rels_used);
923                new_rels.push(rel_id);
924
925                if depth >= min_hops {
926                    results.push(VarLenResult {
927                        dst_node_id: neighbor_id,
928                        rel_ids: new_rels.clone(),
929                    });
930                }
931
932                next_frontier.push((neighbor_id, new_rels));
933            }
934        }
935
936        if is_last_hop || next_frontier.is_empty() {
937            break;
938        }
939
940        frontier = next_frontier;
941    }
942
943    // Handle min_hops == 0: include the start node itself at depth 0.
944    if min_hops == 0 {
945        results.insert(
946            0,
947            VarLenResult {
948                dst_node_id: start_node_id,
949                rel_ids: Vec::new(),
950            },
951        );
952    }
953
954    results
955}
956
957/// Filter rows to keep only shortest paths.
958/// `all` = false → keep one shortest path; `all` = true → keep all shortest.
959pub(crate) fn filter_shortest_paths(rows: Vec<Row>, path_var: VarId, all: bool) -> Vec<Row> {
960    if rows.is_empty() {
961        return rows;
962    }
963
964    // Compute path length for each row
965    let lengths: Vec<usize> = rows
966        .iter()
967        .map(|row| match row.get(path_var) {
968            Some(LoraValue::Path(p)) => p.rels.len(),
969            _ => usize::MAX,
970        })
971        .collect();
972
973    let min_len = lengths.iter().copied().min().unwrap_or(usize::MAX);
974
975    let mut result: Vec<Row> = rows
976        .into_iter()
977        .zip(lengths.iter())
978        .filter(|(_, len)| **len == min_len)
979        .map(|(row, _)| row)
980        .collect();
981
982    if !all && result.len() > 1 {
983        result.truncate(1);
984    }
985
986    result
987}