Skip to main content

rushdb_query/
lib.rs

1//! Query planner and executor over the LMPG state.
2//!
3//! Turns a [`SearchRequest`] into an access path (`index_candidates`), executes
4//! it against a pinned snapshot, and falls back to a full scan whenever a
5//! predicate cannot be served exactly by the maintained indexes. Every index
6//! path is required to return exactly what the forced-scan path returns; the
7//! differential tests in `tests/planner_differential.rs` enforce that.
8use rushdb_lmpg::reader::try_access as access;
9use rushdb_lmpg::reconstruct;
10use std::sync::Arc;
11/// Caller-owned snapshot used for query execution and result shaping.
12pub type QuerySnapshot = Arc<rushdb_storage::ReadView>;
13use equality::{any_element, is_datetime, value_equals};
14use rushdb_lmpg::{Lmpg, LmpgError};
15use rushdb_types::model::{LogicalType, TypeState};
16use rushdb_types::value::{
17    infer_scalar, scalar_cmp as canonical_scalar_cmp, scalar_equal, Scalar, ID_FIELD,
18};
19use serde_json::{Map, Value as Json};
20use thiserror::Error;
21
22mod aggregate_budget;
23mod aggregate_catalog;
24mod aggregation;
25mod cycle;
26mod equality;
27mod field_types;
28pub use aggregation::MAX_AGGREGATE_BUCKETS;
29pub use aggregation::{
30    aggregate, aggregate_in_view, aggregate_to_rows, aggregate_to_rows_in_view,
31    aggregate_to_rows_in_view_with_budget, aggregate_with_cancel, aggregate_with_cancel_in_view,
32};
33#[cfg(test)]
34use aggregation::{eval_row_expr, exact_f64, minmax};
35use field_types::type_held;
36mod page;
37pub use page::effective_budget;
38mod planned;
39mod planner;
40mod point_fields;
41pub use point_fields::PointFields;
42mod property_predicate;
43pub use hex::hex_decode_16;
44pub use index_access::explain_index;
45pub use planned::{planned_candidates, PlannedCandidates};
46use planner::index_candidates_with_budget;
47pub use planner::{
48    explain_cardinality, explain_range_selectivity, explain_walk, unindexed_walks_total,
49    UNINDEXED_WALK_THRESHOLD,
50};
51pub use property_predicate::{matches_properties, parse_property_predicate};
52pub mod datetime;
53pub mod document;
54mod hex;
55mod index_access;
56mod scan;
57mod stats_cache;
58pub use document::{ParsedQuery, QueryDocumentError};
59
60/// A single-field predicate inside a `where` expression.
61#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
62pub enum FieldPred {
63    /// `$eq` equality; `$eq:null` is an explicit alias for the is-null state predicate.
64    Eq(String, Json),
65    /// `$ne` inequality; the field must be present for the predicate to match.
66    Ne(String, Json),
67    /// `$gt` numeric/datetime strictly-greater comparison.
68    Gt(String, f64),
69    /// `$gte` numeric/datetime greater-than-or-equal comparison.
70    Gte(String, f64),
71    /// `$lt` numeric/datetime strictly-less comparison.
72    Lt(String, f64),
73    /// `$lte` numeric/datetime less-than-or-equal comparison.
74    Lte(String, f64),
75    /// `$contains` case-insensitive substring match on a string.
76    Contains(String, String),
77    /// `$startsWith` case-insensitive prefix match on a string.
78    StartsWith(String, String),
79    /// `$exists` presence test; the flag selects present (`true`) or absent.
80    Exists(String, bool),
81    /// `$isNull`/`$eq:null` null-state test; the flag selects null (`true`) or non-null.
82    IsNull(String, bool),
83    /// `$in` membership against a set of literal values.
84    In(String, Vec<Json>),
85    /// `$nin` non-membership; the field must be present and match none of the values.
86    Nin(String, Vec<Json>),
87    /// `$endsWith` case-insensitive suffix match on a string.
88    EndsWith(String, String),
89    /// `$id` — match the record identity itself.
90    Id(String),
91    /// Datetime component predicate: `{ created: { $year: 2023 } }`.
92    /// `which` = 0..6 (year/month/day/hour/minute/second/millisecond).
93    DatetimeComponent {
94        /// Normalized property name the component is read from.
95        field: String,
96        /// Component selector: 0=year, 1=month, 2=day, 3=hour, 4=minute, 5=second, 6=millisecond.
97        which: u8,
98        /// Expected component value.
99        value: i64,
100    },
101    /// `$type` operator: "string" | "number" | "boolean" | "array" | "object"
102    Type(String, String),
103}
104
105/// Boolean expression tree evaluated against a reconstructed record.
106#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
107pub enum Expr {
108    /// Logical AND of every child.
109    And(Vec<Expr>),
110    /// Logical OR of every child.
111    Or(Vec<Expr>),
112    /// Logical XOR (`$xor`)
113    Xor(Box<Expr>, Box<Expr>),
114    /// Logical NOR (`$nor`)
115    Nor(Vec<Expr>),
116    /// Logical negation of the child.
117    Not(Box<Expr>),
118    /// A single field predicate.
119    Field(FieldPred),
120    /// Related-record traversal predicate (label-key blocks, `$relation`,
121    /// `hops`, `$cycle`). Evaluated existentially over reachable targets.
122    Related(RelatedExpr),
123}
124
125/// Traversal predicate mirroring the documented relationship syntax.
126#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
127pub struct RelatedExpr {
128    /// Labels an endpoint record must carry (empty = any label).
129    pub target_labels: Vec<String>,
130    /// Optional `$alias` binding for the endpoint, used by `$count`/`$sum`/`$collect`.
131    pub alias: Option<String>,
132    /// Relationship type filter; `None` matches every relation type.
133    pub rel_type: Option<String>,
134    /// Edge direction to traverse.
135    pub direction: Direction,
136    /// Variable-length traversal bounds `(min, max)`; absent = one hop.
137    pub hops: Option<(u64, u64)>,
138    /// `$cycle`: true when the traversal must return to the start record.
139    pub cycle: bool,
140    /// Predicates evaluated on the endpoint record (may nest further
141    /// `RelatedExpr` children for multi-level blocks).
142    pub filter: Option<Box<Expr>>,
143}
144
145/// A root-bound traversal branch declared under `related` or a `where` alias.
146#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
147pub struct RelatedSpec {
148    /// Alias the branch is bound to (without leading `$`).
149    pub alias: Option<String>,
150    /// Relationship type filter for the final step; `None` matches any type.
151    pub rel_type: Option<String>,
152    /// Edge direction for the final step.
153    pub direction: Direction,
154    /// Labels the final-step endpoint must carry (empty = any).
155    pub target_labels: Vec<String>,
156    /// The full traversal path from the root record to this spec's level
157    /// (ancestors first; the last step is the spec's own level). Each step
158    /// carries that level's label/rel-type/direction and endpoint predicates,
159    /// so alias `$count`/`$sum`/`$collect` match the reference: multi-hop
160    /// aliases traverse every level and apply each level's `where` filter.
161    pub steps: Vec<TraversalStep>,
162}
163
164/// One level of an alias traversal path (see [`RelatedSpec::steps`]).
165#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
166pub struct TraversalStep {
167    /// Labels the endpoint at this level must carry (empty = any label).
168    pub target_labels: Vec<String>,
169    /// Relationship type filter at this level; `None` matches any type.
170    pub rel_type: Option<String>,
171    /// Edge direction at this level.
172    pub direction: Direction,
173    /// Endpoint predicates evaluated on the record at this level (nested label
174    /// blocks are separate steps, not part of this filter).
175    pub filter: Option<Expr>,
176}
177
178/// `$collect` at the root binding grain (document 19): one declared step
179/// per level, batched typed-adjacency collection, optional uniqueness and
180/// local slice. Output attaches to each returned record under `alias`.
181///
182/// Two forms: **alias-based** (`from` in the docs) resolves `rel_type`/
183/// `direction`/`target_labels` from the matching `where` traversal alias, and
184/// projects whole records unless `fields` is set; **label-based** (`label` in
185/// the docs) traverses inline (direction defaults to outgoing), supports a
186/// `where` filter, an output-key→field projection map, and **nested** collects
187/// evaluated recursively on each collected record (hierarchical output).
188#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
189pub struct CollectSpec {
190    /// Output key the collected array is attached under.
191    pub alias: String,
192    /// Relationship type filter for direct-adjacency collects.
193    pub rel_type: Option<String>,
194    /// Edge direction for direct-adjacency collects.
195    pub direction: Direction,
196    /// Labels a collected record must carry (empty = any label).
197    pub target_labels: Vec<String>,
198    /// Alias-based (`from`) collects carry the full traversal path to the
199    /// alias, including each level's `where` predicates. Empty for
200    /// label-based collects (direct adjacency).
201    pub steps: Vec<TraversalStep>,
202    /// `(output_key, source_field)` projection; empty = whole record.
203    pub fields: Vec<(String, String)>,
204    /// Nested (hierarchical) collects evaluated on each collected record.
205    pub nested: Vec<CollectSpec>,
206    /// `where` filter evaluated on each collected record (label-based).
207    pub filter: Option<Expr>,
208    /// ORDER BY (field, desc) applied to the collected array.
209    pub order_by: Vec<(String, bool)>,
210    /// When true, deduplicate collected records by identity.
211    pub unique: bool,
212    /// Number of collected records to skip after ordering.
213    pub skip: u64,
214    /// Maximum number of collected records to return.
215    pub limit: Option<u64>,
216}
217
218/// Direction of an edge traversal.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220pub enum Direction {
221    /// Follow outgoing edges (root to target).
222    Outgoing,
223    /// Follow incoming edges (target to root).
224    Incoming,
225    /// Follow edges in either direction.
226    Both,
227}
228
229/// Calendar/clock unit for a fixed-width time bucket.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
231pub enum TimeUnit {
232    /// One-second buckets.
233    Seconds,
234    /// One-minute buckets.
235    Minutes,
236    /// One-hour buckets.
237    Hours,
238    /// One-day buckets.
239    Days,
240    /// One-week buckets.
241    Weeks,
242    /// One-month buckets (calendar-aligned).
243    Months,
244    /// Three-month buckets (calendar-aligned).
245    Quarters,
246    /// One-year buckets (calendar-aligned).
247    Years,
248}
249
250impl TimeUnit {
251    fn millis(self) -> i64 {
252        match self {
253            TimeUnit::Seconds => 1_000,
254            TimeUnit::Minutes => 60_000,
255            TimeUnit::Hours => 3_600_000,
256            TimeUnit::Days => 86_400_000,
257            TimeUnit::Weeks => 7 * 86_400_000,
258            TimeUnit::Months => 30 * 86_400_000,
259            TimeUnit::Quarters => 90 * 86_400_000,
260            TimeUnit::Years => 365 * 86_400_000,
261        }
262    }
263
264    /// Parse a time unit from its documented name (singular or plural).
265    ///
266    /// # Errors
267    ///
268    /// Returns [`crate::QueryDocumentError::Parse`] when `name` is not a
269    /// recognized time unit.
270    pub fn from_name(name: &str) -> Result<TimeUnit, crate::QueryDocumentError> {
271        Ok(match name {
272            "second" | "seconds" => TimeUnit::Seconds,
273            "minute" | "minutes" => TimeUnit::Minutes,
274            "hour" | "hours" => TimeUnit::Hours,
275            "day" | "days" => TimeUnit::Days,
276            "week" | "weeks" => TimeUnit::Weeks,
277            "month" | "months" => TimeUnit::Months,
278            "quarter" => TimeUnit::Quarters,
279            "year" | "years" => TimeUnit::Years,
280            other => {
281                return Err(crate::QueryDocumentError::Parse(format!(
282                    "unknown time unit `{other}`"
283                )));
284            }
285        })
286    }
287
288    pub(crate) fn unit_str(self) -> &'static str {
289        match self {
290            TimeUnit::Seconds => "seconds",
291            TimeUnit::Minutes => "minutes",
292            TimeUnit::Hours => "hours",
293            TimeUnit::Days => "days",
294            TimeUnit::Weeks => "weeks",
295            TimeUnit::Months => "months",
296            TimeUnit::Quarters => "quarters",
297            TimeUnit::Years => "years",
298        }
299    }
300}
301
302/// Fixed-size calendar bucket over an epoch-millisecond field. Non-datetime
303/// or missing input collapses to the documented MISSING state key.
304#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
305pub struct TimeBucketSpec {
306    /// Property whose epoch-millisecond value is bucketed.
307    pub field: String,
308    /// Calendar/clock unit of the bucket.
309    pub unit: TimeUnit,
310    /// Number of `unit`s per bucket (always at least 1).
311    pub size: u64,
312}
313
314/// Maximum accepted `$precision` for `$avg`. `f64` carries ~15 significant
315/// decimal digits; larger values would only amplify rounding noise (and
316/// `10f64.powi` overflows past ~308). Parsed values are clamped to this.
317pub const MAX_AVG_PRECISION: u32 = 15;
318
319impl TimeBucketSpec {
320    /// A bucket of exactly one `unit`.
321    pub fn fixed(field: impl Into<String>, unit: TimeUnit) -> Self {
322        TimeBucketSpec {
323            field: field.into(),
324            unit,
325            size: 1,
326        }
327    }
328
329    /// A bucket spanning `size` `unit`s.
330    ///
331    /// # Panics
332    ///
333    /// Panics when `size` is zero.
334    pub fn sized(field: impl Into<String>, unit: TimeUnit, size: u64) -> Self {
335        assert!(size > 0, "timeBucket size must be positive");
336        TimeBucketSpec {
337            field: field.into(),
338            unit,
339            size,
340        }
341    }
342
343    #[expect(
344        clippy::cast_possible_truncation,
345        reason = "epoch-ms is a bounded timestamp; the saturating float-to-int cast matches the reference calendar flooring"
346    )]
347    fn floor_millis(&self, epoch_ms: f64) -> i64 {
348        let ms = epoch_ms as i64;
349        // calendar-aware flooring for Months/Years (fixed-width would not
350        // align to calendar boundaries)
351
352        match self.unit {
353            TimeUnit::Months => rushdb_types::datetime::start_of_month(ms),
354            TimeUnit::Quarters => rushdb_types::datetime::start_of_quarter(ms),
355            TimeUnit::Years => rushdb_types::datetime::start_of_year(ms),
356            _ => {
357                // Defensive even though the parser rejects `size == 0`: a
358                // directly constructed spec must not divide by zero or wrap.
359                let size = i64::try_from(self.size).unwrap_or(i64::MAX).max(1);
360                let width = self.unit.millis().saturating_mul(size).max(1);
361                ms.div_euclid(width).saturating_mul(width)
362            }
363        }
364    }
365}
366
367/// Offset/limit pagination applied after matching and ordering.
368#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
369pub struct Pagination {
370    /// Number of matching roots to skip.
371    pub skip: u64,
372    /// Maximum number of matching roots to return; `None` = unbounded.
373    pub limit: Option<u64>,
374}
375
376/// A records-perspective search over one committed snapshot.
377#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
378pub struct SearchRequest {
379    /// Labels a root record must carry; empty matches every label.
380    #[serde(default)]
381    pub labels: Vec<String>,
382    /// Boolean predicate the root record must satisfy.
383    #[serde(default, rename = "where")]
384    pub where_: Option<Expr>,
385    /// Independent traversal branches; every spec filters existentially so
386    /// independent one-to-many branches never multiply the root grain.
387    #[serde(default)]
388    pub related: Vec<RelatedSpec>,
389    /// Per-root `$collect` output shapes attached to each returned record.
390    #[serde(default)]
391    pub collects: Vec<CollectSpec>,
392    /// Offset/limit applied after matching and ordering.
393    #[serde(default)]
394    pub pagination: Pagination,
395    /// ORDER BY (field, desc) applied after matching, before pagination.
396    #[serde(default)]
397    pub order_by: Vec<(String, bool)>,
398    /// Test/oracle switch: when true, the index access path is bypassed and
399    /// the executor performs a full scan, letting differential tests compare
400    /// the two paths on identical input.
401    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
402    pub force_scan: bool,
403}
404
405/// Errors returned by query planning and execution.
406#[derive(Debug, Error)]
407pub enum QueryError {
408    /// A storage read or snapshot error.
409    #[error("storage error: {0}")]
410    Storage(#[from] rushdb_storage::StorageError),
411    /// An engine (LMPG) error.
412    #[error("engine error: {0}")]
413    Engine(#[from] LmpgError),
414    /// A predicate could not be evaluated (e.g. divide by zero, unresolved ref).
415    #[error("invalid predicate on `{0}`")]
416    InvalidPredicate(String),
417    /// The cooperative cancellation probe requested a stop.
418    #[error("query cancelled")]
419    Cancelled,
420    /// A declared resource budget (e.g. distinct aggregate buckets) was exceeded.
421    #[error("resource exhausted: {0}")]
422    ResourceLimit(String),
423    /// A filter without a usable index would check more records than
424    /// `unindexed-walk-max-owners` allows.
425    #[error("unindexed walk refused: {0}")]
426    UnindexedWalk(String),
427}
428
429/// One page of reconstructed records plus the pre-pagination match total.
430#[derive(Debug, Clone)]
431pub struct Page {
432    /// Number of distinct roots matched before pagination.
433    pub total_matched: u64,
434    /// Reconstructed records in this page, in query order.
435    pub records: Vec<Json>,
436}
437
438/// Records-perspective execution over one committed snapshot. Missing
439/// property = no occurrence; explicit null = live payload-free occurrence.
440///
441/// # Errors
442///
443/// Propagates storage/engine read errors and predicate evaluation failures as
444/// [`QueryError`].
445pub fn find_records(lmpg: &Lmpg, req: &SearchRequest) -> Result<Page, QueryError> {
446    find_records_with_cancel(lmpg, req, &|| false)
447}
448
449/// Records-perspective execution with a cooperative cancellation probe.
450/// The probe is checked inside scan and reconstruction loops so a cancelled
451/// stream stops consuming CPU instead of merely discarding its response.
452///
453/// # Errors
454///
455/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
456/// propagates storage/engine read errors and predicate evaluation failures.
457pub fn find_records_with_cancel(
458    lmpg: &Lmpg,
459    req: &SearchRequest,
460    is_cancelled: &dyn Fn() -> bool,
461) -> Result<Page, QueryError> {
462    let snap = Arc::new(lmpg.store.read_view()?);
463    find_records_in_view(lmpg, req, is_cancelled, snap)
464}
465
466/// Every ordinal matching `req`, in match order (`order_by` applied).
467///
468/// Pagination is ignored: callers apply `skip`/`limit` themselves. The index
469/// bounded-page path is not used, so a delete can snapshot the full match set
470/// once instead of re-scanning after every page. Page JSON is not built. The
471/// working set is capped by `query-memory-max` ([`working_set_budget`]).
472///
473/// # Errors
474///
475/// Returns [`QueryError::Cancelled`] when the probe requests a stop,
476/// [`QueryError::ResourceLimit`] when the working set exceeds
477/// `query-memory-max`, and propagates storage/engine read errors and
478/// predicate evaluation failures.
479#[expect(
480    clippy::needless_pass_by_value,
481    reason = "callers hand over a freshly pinned view, matching find_records_in_view"
482)]
483pub fn matching_ordinals(
484    lmpg: &Lmpg,
485    req: &SearchRequest,
486    is_cancelled: &dyn Fn() -> bool,
487    snap: QuerySnapshot,
488) -> Result<Vec<u64>, QueryError> {
489    let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
490    if !snap.belongs_to(&lmpg.store) {
491        return Err(rushdb_storage::StorageError::InvalidRead(
492            "query view belongs to another Store".into(),
493        )
494        .into());
495    }
496    if is_cancelled() {
497        return Err(QueryError::Cancelled);
498    }
499    let mut full = req.clone();
500    full.pagination = Pagination::default();
501    Ok(run_matches(lmpg, &full, is_cancelled, &snap, usize::MAX, None)?.retained)
502}
503
504/// The first `keep` ordinals matching `req`, in match order: the same prefix
505/// [`matching_ordinals`] returns, without resolving the rest. A search with
506/// no predicate and no `order_by` stops after `keep` matches, so its cost
507/// does not grow with the label's size.
508///
509/// # Errors
510///
511/// As [`matching_ordinals`].
512#[expect(
513    clippy::needless_pass_by_value,
514    reason = "callers hand over a freshly pinned view, matching find_records_in_view"
515)]
516pub fn matching_ordinals_first(
517    lmpg: &Lmpg,
518    req: &SearchRequest,
519    is_cancelled: &dyn Fn() -> bool,
520    snap: QuerySnapshot,
521    keep: usize,
522) -> Result<Vec<u64>, QueryError> {
523    let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
524    if !snap.belongs_to(&lmpg.store) {
525        return Err(rushdb_storage::StorageError::InvalidRead(
526            "query view belongs to another Store".into(),
527        )
528        .into());
529    }
530    if is_cancelled() {
531        return Err(QueryError::Cancelled);
532    }
533    let mut full = req.clone();
534    full.pagination = Pagination::default();
535    Ok(run_matches(lmpg, &full, is_cancelled, &snap, keep, None)?.retained)
536}
537
538/// The matches a search keeps, and how many there are in all.
539struct Matches {
540    /// The first `keep` matches in query order.
541    retained: Vec<u64>,
542    /// Every match, retained or not.
543    total: u64,
544}
545
546/// One pass over the candidates in ascending ordinal order.
547///
548/// Keeps the first `keep` matches in query order and counts the rest
549/// without keeping them. Without `order_by` the pass stops once `keep`
550/// matches are held and the total is already known: from the caller (a
551/// cursor continuation on its pinned view), from counters (no predicate),
552/// or from the index plan's owner set when the index answered the whole
553/// predicate. With `order_by` it keeps the best `keep` rows seen so far.
554/// The working set (the candidate owner set, retained ordinals, ordering
555/// rows and the record being evaluated) is charged against
556/// `query-memory-max`.
557fn run_matches(
558    lmpg: &Lmpg,
559    req: &SearchRequest,
560    is_cancelled: &dyn Fn() -> bool,
561    snap: &QuerySnapshot,
562    keep: usize,
563    known_total: Option<u64>,
564) -> Result<Matches, QueryError> {
565    check_walk_budget(lmpg, snap, req)?;
566    let mut working = WorkingSet {
567        limit: working_set_budget(lmpg).map(|bytes| u64::try_from(bytes).unwrap_or(u64::MAX)),
568        charged: 0,
569    };
570    let CandidateSource {
571        mut candidates,
572        exact,
573        known,
574        predicate,
575    } = candidate_source(lmpg, req, is_cancelled, snap, known_total, &mut working)?;
576    let ctx = EvalCtx {
577        lmpg,
578        snap: snap.clone(),
579        is_cancelled: Some(is_cancelled),
580        cycles: crate::cycle::CycleGraphs::default(),
581        declared_types: std::cell::RefCell::default(),
582        record_types: std::cell::RefCell::default(),
583    };
584    let mut session = reconstruct::ReconstructSession::new(snap, lmpg.tenant);
585    let order_fields = if req.order_by.is_empty() {
586        None
587    } else {
588        PointFields::resolve(
589            snap,
590            lmpg.tenant,
591            req.order_by.iter().map(|(f, _)| f.as_str()),
592        )?
593    };
594    let predicate_fields = match &predicate {
595        Some(expr) => PointFields::for_predicate(snap, lmpg.tenant, expr)?,
596        None => None,
597    };
598    let mut retained: Vec<u64> = Vec::new();
599    // Ordered scans hold `(ordinal, row, row bytes)`, sorted and cut back to
600    // `keep` whenever they double, so memory follows `keep`, not the matches.
601    let mut ordered: Vec<OrderRow> = Vec::new();
602    let mut ordered_bytes: Vec<u64> = Vec::new();
603    let mut total: u64 = 0;
604    while let Some(ord) = candidates.next(is_cancelled)? {
605        if is_cancelled() {
606            return Err(QueryError::Cancelled);
607        }
608        if !exact
609            && !is_match(
610                &ctx,
611                req,
612                predicate.as_ref(),
613                predicate_fields.as_ref(),
614                &mut session,
615                &mut working,
616                ord,
617            )?
618        {
619            continue;
620        }
621        total += 1;
622        if req.order_by.is_empty() {
623            if retained.len() < keep {
624                working.charge(8, true)?;
625                retained.push(ord);
626            } else if known.is_some() {
627                break;
628            }
629            continue;
630        }
631        let row = read_row(order_fields.as_ref(), &mut session, snap, lmpg.tenant, ord)?;
632        let bytes =
633            serde_json::to_vec(&row).map_or(0, |v| u64::try_from(v.len()).unwrap_or(u64::MAX));
634        working.charge(bytes, true)?;
635        ordered.push((ord, row));
636        ordered_bytes.push(bytes);
637        if ordered.len() >= keep.saturating_mul(2).max(64) {
638            working.charged = working.charged.saturating_sub(cut_ordered(
639                &mut ordered,
640                &mut ordered_bytes,
641                req,
642                keep,
643            ));
644        }
645    }
646    if !req.order_by.is_empty() {
647        cut_ordered(&mut ordered, &mut ordered_bytes, req, keep);
648        retained = ordered.into_iter().map(|(ord, _)| ord).collect();
649    }
650    Ok(Matches {
651        retained,
652        total: known.unwrap_or(total),
653    })
654}
655
656/// The bytes a search holds at once, against `query-memory-max`.
657struct WorkingSet {
658    limit: Option<u64>,
659    charged: u64,
660}
661
662impl WorkingSet {
663    /// Admit `bytes`; `retained` bytes stay charged, others (a record that
664    /// only feeds the predicate) only need to fit in the remaining room.
665    fn charge(&mut self, bytes: u64, retained: bool) -> Result<(), QueryError> {
666        if let Some(limit) = self.limit {
667            if self.charged.saturating_add(bytes) > limit {
668                return Err(QueryError::ResourceLimit(format!(
669                    "query working set exceeded {limit} bytes"
670                )));
671            }
672            if retained {
673                self.charged += bytes;
674            }
675        }
676        Ok(())
677    }
678}
679
680/// Whether candidate `ord` is a live match: the record exists, `predicate`
681/// holds on it, and every relationship branch binds.
682fn is_match(
683    ctx: &EvalCtx<'_>,
684    req: &SearchRequest,
685    predicate: Option<&Expr>,
686    fields: Option<&PointFields>,
687    session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
688    working: &mut WorkingSet,
689    ord: u64,
690) -> Result<bool, QueryError> {
691    let (snap, tenant) = (&ctx.snap, ctx.lmpg.tenant);
692    if access::record_by_ord(snap, tenant, ord)?.is_none() {
693        return Ok(false);
694    }
695    if let Some(expr) = predicate {
696        let obj = read_row(fields, session, snap, tenant, ord)?;
697        let bytes =
698            serde_json::to_vec(&obj).map_or(0, |v| u64::try_from(v.len()).unwrap_or(u64::MAX));
699        working.charge(bytes, false)?;
700        if !eval_expr_ctx(ctx, expr, &obj)? {
701            return Ok(false);
702        }
703    }
704    for spec in &req.related {
705        if !related_exists(snap, tenant, ord, spec)? {
706            return Ok(false);
707        }
708    }
709    Ok(true)
710}
711
712/// The properties `predicate` reads of record `ord`, decoded through
713/// `reader` (which may meter the bytes): the predicate's own fields by
714/// name, or the whole record when it needs it (`$id`, a traversal, an
715/// array-typed property). A record the predicate cannot see is empty.
716fn filtered_row<R: rushdb_lmpg::reader::TryRead>(
717    reader: &R,
718    snap: &QuerySnapshot,
719    tenant: rushdb_types::ids::TenantId,
720    predicate: &Expr,
721    ord: u64,
722) -> Result<Map<String, Json>, QueryError> {
723    let mut session = reconstruct::ReconstructSession::new(reader, tenant);
724    let mut names = std::collections::BTreeSet::new();
725    let record = if point_fields::predicate_fields(predicate, &mut names) {
726        let selected: std::collections::HashSet<String> = names
727            .iter()
728            .flat_map(|name| [name.clone(), normalize(name)])
729            .collect();
730        session.reconstruct_record_selected(ord, &selected)?
731    } else {
732        session.reconstruct_record(ord)?
733    };
734    let _ = snap;
735    Ok(match record {
736        Some(Json::Object(map)) => map,
737        _ => Map::new(),
738    })
739}
740
741/// The properties `fields` names, by point reads, or the rebuilt record.
742fn read_row(
743    fields: Option<&PointFields>,
744    session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
745    snap: &QuerySnapshot,
746    tenant: rushdb_types::ids::TenantId,
747    ord: u64,
748) -> Result<Map<String, Json>, QueryError> {
749    match fields {
750        Some(fields) => fields.row(snap, tenant, ord),
751        None => Ok(session
752            .reconstruct_record(ord)?
753            .and_then(|record| match record {
754                Json::Object(map) => Some(map),
755                _ => None,
756            })
757            .unwrap_or_default()),
758    }
759}
760
761/// Where a search's candidates come from and what is left to check.
762struct CandidateSource<'v> {
763    candidates: scan::Candidates<'v>,
764    /// Every candidate is a live match: neither the predicate nor the
765    /// record's existence is checked per candidate.
766    exact: bool,
767    /// The total, when known without counting.
768    known: Option<u64>,
769    /// What is left to check per candidate: the conjuncts the owner set does
770    /// not answer, or the whole `where` on a walk.
771    predicate: Option<Expr>,
772}
773
774/// The index plan's owner set (charged to `working`), or a walk of the
775/// tenant's or labels' records.
776fn candidate_source<'v>(
777    lmpg: &Lmpg,
778    req: &SearchRequest,
779    is_cancelled: &dyn Fn() -> bool,
780    snap: &'v QuerySnapshot,
781    known_total: Option<u64>,
782    working: &mut WorkingSet,
783) -> Result<CandidateSource<'v>, QueryError> {
784    if let Some(plan) =
785        index_candidates_with_budget(lmpg, snap, req, working_set_budget(lmpg), is_cancelled)?
786    {
787        working.charge(
788            u64::try_from(plan.ords.serialized_size()).unwrap_or(u64::MAX),
789            true,
790        )?;
791        let known = known_total
792            .or(plan.known_total)
793            .or_else(|| plan.exact.then(|| plan.ords.len()));
794        return Ok(CandidateSource {
795            candidates: scan::Candidates::from_set(plan.ords),
796            exact: plan.exact,
797            known,
798            predicate: plan.residual,
799        });
800    }
801    let exact = req.where_.is_none() && req.related.is_empty();
802    let known = match known_total {
803        Some(total) => Some(total),
804        None if exact => Some(walk_total(lmpg, snap, &req.labels)?),
805        None => None,
806    };
807    Ok(CandidateSource {
808        candidates: scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?,
809        exact,
810        known,
811        predicate: req.where_.clone(),
812    })
813}
814
815/// One ordering row: a match's ordinal and the properties it is ordered by.
816type OrderRow = (u64, Map<String, Json>);
817
818/// Sort the ordering buffer and keep its best `keep` rows; returns the bytes
819/// of the rows dropped.
820fn cut_ordered(
821    ordered: &mut Vec<OrderRow>,
822    bytes: &mut Vec<u64>,
823    req: &SearchRequest,
824    keep: usize,
825) -> u64 {
826    let mut rows: Vec<(OrderRow, u64)> = ordered.drain(..).zip(bytes.drain(..)).collect();
827    // Candidates arrive in ascending ordinal order and the sort is stable,
828    // so ties keep that order across cuts, exactly as one sort of every
829    // match would.
830    sort_ordered_rows(&mut rows, req);
831    let freed = rows.iter().skip(keep).map(|(_, b)| *b).sum();
832    rows.truncate(keep);
833    for (row, b) in rows {
834        ordered.push(row);
835        bytes.push(b);
836    }
837    freed
838}
839
840/// Live records of the tenant (`labels` empty) or of `labels`, from counters.
841fn walk_total(lmpg: &Lmpg, snap: &QuerySnapshot, labels: &[String]) -> Result<u64, QueryError> {
842    if labels.is_empty() {
843        return Ok(rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records);
844    }
845    let distinct: std::collections::BTreeSet<&String> = labels.iter().collect();
846    let mut total = 0u64;
847    for label in distinct {
848        total = total.saturating_add(rushdb_lmpg::derived::label_count(
849            snap.as_ref(),
850            lmpg.tenant,
851            label,
852        )?);
853    }
854    Ok(total)
855}
856
857/// Execute against a caller-owned statement view, allowing response metadata
858/// and follow-up reconstruction to share the same committed version. The
859/// view must belong to `lmpg.store`; the caller owns authorization scope.
860///
861/// # Errors
862///
863/// Returns a storage error when `snap` belongs to a different store, and
864/// [`QueryError::Cancelled`] when the probe requests a stop. Storage/engine
865/// read errors and predicate evaluation failures are propagated.
866pub fn find_records_in_view(
867    lmpg: &Lmpg,
868    req: &SearchRequest,
869    is_cancelled: &dyn Fn() -> bool,
870    snap: QuerySnapshot,
871) -> Result<Page, QueryError> {
872    find_records_in_view_with_budget(lmpg, req, is_cancelled, snap, None)
873}
874
875/// Refuse a filter that has no usable index when the records it would check
876/// exceed `unindexed-walk-max-owners` or the request's own limit (SCALE-3,
877/// QRY-3; none by default, like PostgreSQL). The population is exact
878/// and read from counters before any work: the walked property's support,
879/// or, when no condition can be walked, every record of the labels.
880fn check_walk_budget(
881    lmpg: &Lmpg,
882    snap: &QuerySnapshot,
883    req: &SearchRequest,
884) -> Result<(), QueryError> {
885    let (Some(limit), Some(expr)) = (lmpg.limits().unindexed_walk_max_owners(), &req.where_) else {
886        return Ok(());
887    };
888    if explain_index(snap.as_ref(), lmpg.tenant, &req.labels, expr)?.is_some() {
889        return Ok(());
890    }
891    let walk = explain_walk(snap.as_ref(), lmpg.tenant, &req.labels, expr)?;
892    let (property, owners, labels) = if let Some(walk) = walk {
893        walk
894    } else {
895        // No condition can be walked: every record of the labels is checked.
896        let owners = if req.labels.is_empty() {
897            rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records
898        } else {
899            let mut owners = 0u64;
900            for label in &req.labels {
901                owners = owners.saturating_add(rushdb_lmpg::derived::label_count(
902                    snap.as_ref(),
903                    lmpg.tenant,
904                    label,
905                )?);
906            }
907            owners
908        };
909        (
910            "(no indexable condition)".to_owned(),
911            owners,
912            req.labels.clone(),
913        )
914    };
915    if owners <= limit {
916        return Ok(());
917    }
918    Err(QueryError::UnindexedWalk(format!(
919        "the filter on `{property}` would check {owners} records of [{}], over \
920         the unindexed-walk limit ({limit}); declare an ordered index on the property",
921        labels.join(", ")
922    )))
923}
924
925/// The query working-set cap: candidate and match ordinals, ordering rows
926/// and the record being evaluated. It is `query-memory-max` (`0` = off),
927/// tightened by the request,
928/// never the request's response byte budget, which caps only the returned
929/// page (READ-1: charging the working set against the 16 MiB response
930/// budget refused every read of a label over ~2M records).
931fn working_set_budget(lmpg: &Lmpg) -> Option<usize> {
932    lmpg.limits()
933        .query_memory_max_bytes()
934        .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
935}
936
937/// The caller's cancellation or the request's deadline, so a
938/// time cap stops every read that runs on the request's handle.
939pub(crate) fn cancel_probe<'a>(
940    lmpg: &Lmpg,
941    is_cancelled: &'a dyn Fn() -> bool,
942) -> impl Fn() -> bool + 'a {
943    let limits = lmpg.limits();
944    move || is_cancelled() || limits.expired()
945}
946
947/// Execute a search with an optional caller-owned response byte budget for
948/// the returned page. The working set (candidates, matches, ordering rows) is
949/// capped by `query-memory-max` instead ([`working_set_budget`]).
950///
951/// # Errors
952/// Returns [`QueryError`] when the view is invalid, cancellation is observed,
953/// storage is corrupt, or the working-byte budget is exceeded.
954#[allow(clippy::needless_pass_by_value)]
955pub fn find_records_in_view_with_budget(
956    lmpg: &Lmpg,
957    req: &SearchRequest,
958    is_cancelled: &dyn Fn() -> bool,
959    snap: QuerySnapshot,
960    work_budget: Option<usize>,
961) -> Result<Page, QueryError> {
962    find_page(lmpg, req, is_cancelled, &snap, work_budget, None)
963}
964
965/// Streaming execution: identical to [`find_records_in_view`]; kept as the
966/// name the differential tests compare against the forced-scan path.
967///
968/// # Errors
969///
970/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
971/// propagates storage/engine read errors and predicate evaluation failures.
972pub fn find_records_streaming(
973    lmpg: &Lmpg,
974    req: &SearchRequest,
975    is_cancelled: &dyn Fn() -> bool,
976    snap: QuerySnapshot,
977) -> Result<Page, QueryError> {
978    find_records_streaming_with_total(lmpg, req, is_cancelled, snap, None)
979}
980
981/// Page with a caller-supplied total (E6 cursor continuations). The total
982/// is only valid when the caller reuses the exact pinned snapshot and match
983/// set the total was computed from — the native cursor guarantees both, so
984/// continuations stop after their page instead of counting again. `None`
985/// computes it (ordinary search path).
986///
987/// # Errors
988///
989/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
990/// propagates storage/engine read errors and predicate evaluation failures.
991#[expect(
992    clippy::needless_pass_by_value,
993    reason = "the snapshot is accepted by value as part of the public API contract so callers can hand over a freshly pinned view"
994)]
995pub fn find_records_streaming_with_total(
996    lmpg: &Lmpg,
997    req: &SearchRequest,
998    is_cancelled: &dyn Fn() -> bool,
999    snap: QuerySnapshot,
1000    known_total: Option<u64>,
1001) -> Result<Page, QueryError> {
1002    find_page(lmpg, req, is_cancelled, &snap, None, known_total)
1003}
1004
1005/// The one records-page executor: match ([`run_matches`]), cut the page,
1006/// reconstruct it against the response budget.
1007fn find_page(
1008    lmpg: &Lmpg,
1009    req: &SearchRequest,
1010    is_cancelled: &dyn Fn() -> bool,
1011    snap: &QuerySnapshot,
1012    work_budget: Option<usize>,
1013    known_total: Option<u64>,
1014) -> Result<Page, QueryError> {
1015    let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
1016    if !snap.belongs_to(&lmpg.store) {
1017        return Err(rushdb_storage::StorageError::InvalidRead(
1018            "query view belongs to another Store".into(),
1019        )
1020        .into());
1021    }
1022    if is_cancelled() {
1023        return Err(QueryError::Cancelled);
1024    }
1025    let skip = usize::try_from(req.pagination.skip).unwrap_or(usize::MAX);
1026    let limit = usize::try_from(req.pagination.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX);
1027    let matches = run_matches(
1028        lmpg,
1029        req,
1030        is_cancelled,
1031        snap,
1032        skip.saturating_add(limit),
1033        known_total,
1034    )?;
1035    let page_roots: Vec<u64> = matches
1036        .retained
1037        .into_iter()
1038        .skip(skip)
1039        .take(limit)
1040        .collect();
1041    let budget = effective_budget(
1042        work_budget,
1043        lmpg.limits().query_memory_max_bytes().unwrap_or(0),
1044    );
1045    let records =
1046        page::reconstruct_page_with_budget(lmpg, req, is_cancelled, snap, page_roots, budget)?;
1047    Ok(Page {
1048        total_matched: matches.total,
1049        records,
1050    })
1051}
1052
1053/// Sort ordering rows by the declared query order. `Vec::sort_by` is
1054/// stable, so ties preserve candidate order and pagination remains repeatable.
1055fn sort_ordered_rows<T>(sortable: &mut [(OrderRow, T)], req: &SearchRequest) {
1056    for (field, desc) in req.order_by.iter().rev() {
1057        let field = normalize(field);
1058        sortable.sort_by(|((_, a), _), ((_, b), _)| {
1059            let va = a
1060                .iter()
1061                .find(|(k, _)| normalize(k) == field)
1062                .map(|(_, v)| v);
1063            let vb = b
1064                .iter()
1065                .find(|(k, _)| normalize(k) == field)
1066                .map(|(_, v)| v);
1067            let ord = match (va, vb) {
1068                (Some(x), Some(y)) => json_value_cmp(x, y),
1069                (None, Some(_)) => std::cmp::Ordering::Less,
1070                (Some(_), None) => std::cmp::Ordering::Greater,
1071                (None, None) => std::cmp::Ordering::Equal,
1072            };
1073            if *desc {
1074                ord.reverse()
1075            } else {
1076                ord
1077            }
1078        });
1079    }
1080}
1081
1082/// `(ordinal, raw record, projected record)` for one matching collected child.
1083type RelatedItem = (u64, Map<String, Json>, Map<String, Json>);
1084
1085fn collect_neighbors(
1086    lmpg: &Lmpg,
1087    snap: &QuerySnapshot,
1088    root: u64,
1089    spec: &CollectSpec,
1090    budget: Option<usize>,
1091    is_cancelled: &dyn Fn() -> bool,
1092) -> Result<Vec<u64>, QueryError> {
1093    Ok(if spec.steps.is_empty() {
1094        let edges: Vec<(String, u64, u64)> = match spec.direction {
1095            Direction::Outgoing => {
1096                access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
1097            }
1098            Direction::Incoming => {
1099                access::adj_in(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
1100            }
1101            Direction::Both => {
1102                let mut all = access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?;
1103                all.extend(access::adj_in(
1104                    snap,
1105                    lmpg.tenant,
1106                    root,
1107                    spec.rel_type.as_deref(),
1108                )?);
1109                all
1110            }
1111        };
1112        edges.into_iter().map(|(_, other, _)| other).collect()
1113    } else {
1114        traverse_alias(
1115            lmpg,
1116            snap,
1117            lmpg.tenant,
1118            root,
1119            &spec.steps,
1120            budget,
1121            is_cancelled,
1122        )?
1123    })
1124}
1125
1126fn order_collected_items(items: &mut Vec<RelatedItem>, spec: &CollectSpec) {
1127    // uniqueness by identity.
1128    if spec.unique {
1129        items.sort_by_key(|(o, _, _)| *o);
1130        items.dedup_by(|a, b| a.1.get(ID_FIELD) == b.1.get(ID_FIELD));
1131    } else {
1132        items.sort_by_key(|(o, _, _)| *o);
1133    }
1134    // order by collected output aliases (fall back to the raw field).
1135    if !spec.order_by.is_empty() {
1136        items.sort_by(|a, b| {
1137            for (field, desc) in &spec.order_by {
1138                let av = raw_field(&a.2, field).or_else(|| raw_field(&a.1, field));
1139                let bv = raw_field(&b.2, field).or_else(|| raw_field(&b.1, field));
1140                let ord = scalar_cmp(av, bv).unwrap_or(std::cmp::Ordering::Equal);
1141                if ord != std::cmp::Ordering::Equal {
1142                    return if *desc { ord.reverse() } else { ord };
1143                }
1144            }
1145            std::cmp::Ordering::Equal
1146        });
1147    }
1148}
1149
1150fn attach_collects_with_budget(
1151    lmpg: &Lmpg,
1152    snap: &QuerySnapshot,
1153    root: u64,
1154    mut record: Json,
1155    collects: &[CollectSpec],
1156    budget: Option<usize>,
1157    is_cancelled: &dyn Fn() -> bool,
1158) -> Result<Json, QueryError> {
1159    if collects.is_empty() {
1160        return Ok(record);
1161    }
1162    let mut retained = aggregate_budget::value_bytes(&record);
1163    aggregate_budget::check(budget, retained)?;
1164    let ctx = EvalCtx {
1165        lmpg,
1166        snap: snap.clone(),
1167        is_cancelled: None,
1168        cycles: crate::cycle::CycleGraphs::default(),
1169        declared_types: std::cell::RefCell::default(),
1170        record_types: std::cell::RefCell::default(),
1171    };
1172    if let Some(obj) = record.as_object_mut() {
1173        for spec in collects {
1174            // Alias-based (`from`) collects traverse the full path (multi-hop)
1175            // applying every level's `where` predicates; label-based collects
1176            // use direct adjacency.
1177            if is_cancelled() {
1178                return Err(QueryError::Cancelled);
1179            }
1180            let neighbors = collect_neighbors(
1181                lmpg,
1182                snap,
1183                root,
1184                spec,
1185                budget.map(|limit| limit.saturating_sub(retained)),
1186                is_cancelled,
1187            )?;
1188            // (ordinal, raw record, projected record) for every matching child.
1189            let base_bytes = retained;
1190            retained = retained.saturating_add(neighbors.capacity().saturating_mul(8));
1191            aggregate_budget::check(budget, retained)?;
1192            // A collect that projects `fields` decodes those, its filter's and
1193            // its ordering's properties only; one without returns whole children.
1194            let selected = point_fields::collect_selection(spec);
1195            let mut items: Vec<RelatedItem> = Vec::new();
1196            for other in neighbors {
1197                if is_cancelled() {
1198                    return Err(QueryError::Cancelled);
1199                }
1200                if !has_target_label(snap, lmpg.tenant, other, &spec.target_labels)? {
1201                    continue;
1202                }
1203                let Some(child) = aggregate_budget::collected_child(
1204                    snap,
1205                    lmpg.tenant,
1206                    other,
1207                    selected.as_ref(),
1208                    budget.map(|limit| limit.saturating_sub(retained)),
1209                )?
1210                else {
1211                    continue;
1212                };
1213                // label-based `where` filter on the collected level.
1214                if let Some(filter) = &spec.filter {
1215                    if !eval_expr_ctx(&ctx, filter, &child)? {
1216                        continue;
1217                    }
1218                }
1219                retained = retained
1220                    .saturating_add(aggregate_budget::object_bytes(&child).saturating_mul(3));
1221                aggregate_budget::check(budget, retained)?;
1222                let projected = match project_fields_map(Json::Object(child.clone()), &spec.fields)
1223                {
1224                    Json::Object(p) => p,
1225                    _ => child.clone(),
1226                };
1227                items.push((other, child, projected));
1228            }
1229            order_collected_items(&mut items, spec);
1230            // slice then recurse nested collects on the projected record.
1231            let start = usize::try_from(spec.skip)
1232                .unwrap_or(usize::MAX)
1233                .min(items.len());
1234            let end = spec.limit.map_or(items.len(), |l| {
1235                start
1236                    .saturating_add(usize::try_from(l).unwrap_or(usize::MAX))
1237                    .min(items.len())
1238            });
1239            let mut projected = Vec::new();
1240            for (other, raw, child) in items.into_iter().take(end).skip(start) {
1241                retained =
1242                    retained.saturating_sub(aggregate_budget::object_bytes(&raw).saturating_mul(3));
1243                let child = attach_collects_with_budget(
1244                    lmpg,
1245                    snap,
1246                    other,
1247                    Json::Object(child),
1248                    &spec.nested,
1249                    budget.map(|limit| limit.saturating_sub(retained)),
1250                    is_cancelled,
1251                )?;
1252                retained = retained.saturating_add(aggregate_budget::value_bytes(&child));
1253                aggregate_budget::check(budget, retained)?;
1254                projected.push(child);
1255            }
1256            let collected = Json::Array(projected);
1257            retained = base_bytes
1258                .saturating_add(spec.alias.len())
1259                .saturating_add(aggregate_budget::value_bytes(&collected));
1260            aggregate_budget::check(budget, retained)?;
1261            obj.insert(spec.alias.clone(), collected);
1262        }
1263    }
1264    Ok(record)
1265}
1266
1267/// Project a collected record into a `(output_key, source_field)` map; an
1268/// empty map keeps the whole record (alias-based default).
1269/// Whether a collected child carries one of the collect's target labels; an
1270/// empty target list accepts every child.
1271fn has_target_label(
1272    snap: &QuerySnapshot,
1273    tenant: rushdb_types::ids::TenantId,
1274    ordinal: u64,
1275    target_labels: &[String],
1276) -> Result<bool, QueryError> {
1277    if target_labels.is_empty() {
1278        return Ok(true);
1279    }
1280    Ok(access::record_by_ord(snap, tenant, ordinal)?
1281        .is_some_and(|header| target_labels.contains(&header.label)))
1282}
1283
1284fn project_fields_map(value: Json, fields: &[(String, String)]) -> Json {
1285    if fields.is_empty() {
1286        return value;
1287    }
1288    let mut out = Map::new();
1289    if let Json::Object(obj) = value {
1290        for (out_key, source) in fields {
1291            if let Some(v) = resolve_field(&obj, source) {
1292                out.insert(out_key.clone(), v);
1293            }
1294        }
1295    }
1296    Json::Object(out)
1297}
1298
1299/// Scalar ordering for collected-record sorting: numeric for numbers,
1300/// lexical for strings (ISO datetimes order correctly), else equal.
1301fn scalar_cmp(a: Option<&Json>, b: Option<&Json>) -> Option<std::cmp::Ordering> {
1302    let (Some(a), Some(b)) = (a, b) else {
1303        return None;
1304    };
1305    match (a, b) {
1306        (Json::Number(_), Json::Number(_)) => {
1307            let left = infer_scalar(a).map(|(_, scalar)| scalar);
1308            let right = infer_scalar(b).map(|(_, scalar)| scalar);
1309            left.as_ref()
1310                .zip(right.as_ref())
1311                .and_then(|(left, right)| canonical_scalar_cmp(left, right))
1312        }
1313        (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
1314        _ => None,
1315    }
1316}
1317
1318fn related_exists(
1319    snap: &QuerySnapshot,
1320    tenant: rushdb_types::ids::TenantId,
1321    ord: u64,
1322    spec: &RelatedSpec,
1323) -> Result<bool, QueryError> {
1324    let edges: Vec<(String, u64, u64)> = match spec.direction {
1325        Direction::Outgoing => access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?,
1326        Direction::Incoming => access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?,
1327        Direction::Both => {
1328            let mut all = access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?;
1329            all.extend(access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?);
1330            all
1331        }
1332    };
1333    if spec.target_labels.is_empty() {
1334        return Ok(!edges.is_empty());
1335    }
1336    for (_, other_ord, _) in edges {
1337        if access::record_by_ord(snap, tenant, other_ord)?
1338            .is_some_and(|h| spec.target_labels.contains(&h.label))
1339        {
1340            return Ok(true);
1341        }
1342    }
1343    Ok(false)
1344}
1345
1346/// Distinct related ordinals for a `$count` target that names a traversal
1347/// alias from `where`/`related` (e.g. `$count: "$battle"`). Returns `None`
1348/// when the target is not an alias, so callers fall back to field counting.
1349/// The alias's full traversal path is followed (multi-hop) and every level's
1350/// `where` predicates are applied, matching the reference binding semantics.
1351fn count_related(
1352    lmpg: &Lmpg,
1353    snap: &QuerySnapshot,
1354    tenant: rushdb_types::ids::TenantId,
1355    ord: u64,
1356    specs: &[RelatedSpec],
1357    alias: &str,
1358    budget: Option<usize>,
1359) -> Result<Option<Vec<u64>>, QueryError> {
1360    let Some(spec) = specs.iter().find(|s| {
1361        s.alias
1362            .as_deref()
1363            .is_some_and(|a| a.trim_start_matches('$') == alias)
1364    }) else {
1365        return Ok(None);
1366    };
1367    Ok(Some(traverse_alias(
1368        lmpg,
1369        snap,
1370        tenant,
1371        ord,
1372        &spec.steps,
1373        budget,
1374        &|| false,
1375    )?))
1376}
1377
1378/// Follow a traversal path from `ord` (root) through each step, applying the
1379/// step's target-label filter, rel type/direction, and endpoint predicates.
1380/// Returns the distinct ordinals bound at the final level.
1381fn traverse_alias(
1382    lmpg: &Lmpg,
1383    snap: &QuerySnapshot,
1384    tenant: rushdb_types::ids::TenantId,
1385    ord: u64,
1386    steps: &[TraversalStep],
1387    budget: Option<usize>,
1388    is_cancelled: &dyn Fn() -> bool,
1389) -> Result<Vec<u64>, QueryError> {
1390    if steps.is_empty() {
1391        return Ok(Vec::new());
1392    }
1393    let ctx = EvalCtx {
1394        lmpg,
1395        snap: snap.clone(),
1396        is_cancelled: Some(is_cancelled),
1397        cycles: crate::cycle::CycleGraphs::default(),
1398        declared_types: std::cell::RefCell::default(),
1399        record_types: std::cell::RefCell::default(),
1400    };
1401    let mut cur = vec![ord];
1402    for step in steps {
1403        if is_cancelled() {
1404            return Err(QueryError::Cancelled);
1405        }
1406        let mut next: Vec<u64> = Vec::new();
1407        for o in &cur {
1408            if is_cancelled() {
1409                return Err(QueryError::Cancelled);
1410            }
1411            let edges: Vec<(String, u64, u64)> = match step.direction {
1412                Direction::Outgoing => access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?,
1413                Direction::Incoming => access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?,
1414                Direction::Both => {
1415                    let mut all = access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?;
1416                    all.extend(access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?);
1417                    all
1418                }
1419            };
1420            for (_, other, _) in edges {
1421                if !step.target_labels.is_empty()
1422                    && !access::record_by_ord(snap, tenant, other)?
1423                        .is_some_and(|h| step.target_labels.contains(&h.label))
1424                {
1425                    continue;
1426                }
1427                if let Some(pred) = &step.filter {
1428                    let reader = aggregate_budget::RecordReader::new(
1429                        snap,
1430                        budget.map(|limit| {
1431                            limit.saturating_sub(
1432                                cur.len().saturating_add(next.len()).saturating_mul(32),
1433                            )
1434                        }),
1435                    );
1436                    let obj = filtered_row(&reader, snap, tenant, pred, other)?;
1437                    if !eval_expr_ctx(&ctx, pred, &obj)? {
1438                        continue;
1439                    }
1440                }
1441                aggregate_budget::check(
1442                    budget,
1443                    cur.len()
1444                        .saturating_add(next.len())
1445                        .saturating_add(1)
1446                        .saturating_mul(32),
1447                )?;
1448                next.push(other);
1449            }
1450        }
1451        next.sort_unstable();
1452        next.dedup();
1453        cur = next;
1454    }
1455    Ok(cur)
1456}
1457
1458/// Collect the `where`-block related aliases (as [`RelatedSpec`]s) so aggregate
1459/// `$count`/`$sum` targets can reference them. Aggregates keep `related` empty;
1460/// the aliases live inside the `Expr::Related` nodes of `where_`.
1461fn related_specs_from_where(expr: Option<&Expr>) -> Vec<RelatedSpec> {
1462    /// Split a level's filter into its endpoint predicates and the nested
1463    /// label-block children (each child becomes a further traversal step).
1464    fn split_level(filter: Option<&Expr>, children: &mut Vec<Expr>) -> Option<Expr> {
1465        let f = filter?;
1466        match f {
1467            Expr::And(items) => {
1468                let mut preds: Vec<Expr> = Vec::new();
1469                for item in items {
1470                    match item {
1471                        Expr::Related(_) => children.push(item.clone()),
1472                        other => preds.push(other.clone()),
1473                    }
1474                }
1475                match preds.len() {
1476                    0 => None,
1477                    1 => Some(preds.remove(0)),
1478                    _ => Some(Expr::And(preds)),
1479                }
1480            }
1481            Expr::Related(_) => {
1482                children.push(f.clone());
1483                None
1484            }
1485            other => Some(other.clone()),
1486        }
1487    }
1488
1489    fn walk(expr: &Expr, path: &mut Vec<TraversalStep>, out: &mut Vec<RelatedSpec>) {
1490        match expr {
1491            Expr::And(items) | Expr::Or(items) | Expr::Nor(items) => {
1492                for e in items {
1493                    walk(e, path, out);
1494                }
1495            }
1496            Expr::Xor(a, b) => {
1497                walk(a, path, out);
1498                walk(b, path, out);
1499            }
1500            Expr::Not(b) => {
1501                walk(b, path, out);
1502            }
1503            Expr::Related(rel) => {
1504                let mut children: Vec<Expr> = Vec::new();
1505                let filter = split_level(rel.filter.as_deref(), &mut children);
1506                let step = TraversalStep {
1507                    target_labels: rel.target_labels.clone(),
1508                    rel_type: rel.rel_type.clone(),
1509                    direction: rel.direction,
1510                    filter,
1511                };
1512                path.push(step);
1513                if let Some(alias) = &rel.alias {
1514                    out.push(RelatedSpec {
1515                        alias: Some(alias.trim_start_matches('$').to_string()),
1516                        rel_type: rel.rel_type.clone(),
1517                        direction: rel.direction,
1518                        target_labels: rel.target_labels.clone(),
1519                        steps: path.clone(),
1520                    });
1521                }
1522                for child in &children {
1523                    walk(child, path, out);
1524                }
1525                path.pop();
1526            }
1527            Expr::Field(_) => {}
1528        }
1529    }
1530    let mut specs = Vec::new();
1531    if let Some(e) = expr {
1532        let mut path: Vec<TraversalStep> = Vec::new();
1533        walk(e, &mut path, &mut specs);
1534    }
1535    specs
1536}
1537
1538/// Resolve `alias.field` values across a root's related records (the docs'
1539/// aggregate-over-alias semantics: `$sum: "$employee.salary"`). Returns `None`
1540/// when `field` is not an alias reference.
1541fn alias_field_values(
1542    lmpg: &Lmpg,
1543    snap: &QuerySnapshot,
1544    tenant: rushdb_types::ids::TenantId,
1545    ord: u64,
1546    specs: &[RelatedSpec],
1547    field: &str,
1548    budget: Option<usize>,
1549) -> Result<Option<Vec<Json>>, QueryError> {
1550    let Some((alias, prop)) = field.split_once('.') else {
1551        return Ok(None);
1552    };
1553    let Some(related) = count_related(lmpg, snap, tenant, ord, specs, alias, budget)? else {
1554        return Ok(None);
1555    };
1556    let mut retained = related.capacity().saturating_mul(8);
1557    aggregate_budget::check(budget, retained)?;
1558    let mut values = Vec::new();
1559    // Only the named property is decoded, by either spelling.
1560    let selected: std::collections::HashSet<String> =
1561        [prop.to_owned(), normalize(prop)].into_iter().collect();
1562    for other in related {
1563        let reader = aggregate_budget::RecordReader::new(
1564            snap,
1565            budget.map(|limit| limit.saturating_sub(retained)),
1566        );
1567        let mut session = reconstruct::ReconstructSession::new(&reader, tenant);
1568        if let Some(Json::Object(child)) = session.reconstruct_record_selected(other, &selected)? {
1569            if let Some(v) = resolve_field(&child, prop) {
1570                retained = retained.saturating_add(aggregate_budget::value_bytes(&v));
1571                aggregate_budget::check(budget, retained)?;
1572                values.push(v);
1573            }
1574        }
1575    }
1576    Ok(Some(values))
1577}
1578
1579/// Group-key sentinel emitted when a `groupBy`/timeBucket source field is
1580/// missing (distinct from an explicit JSON null).
1581pub const MISSING_SENTINEL: &str = "__missing__";
1582
1583/// One aggregation bucket carrying its typed group tuple, per-metric maps
1584/// and materialized derived outputs.
1585#[derive(Debug, Clone, Default, serde::Serialize)]
1586pub struct GroupBucket {
1587    /// One entry per `group_by` field; missing renders as the documented
1588    /// MISSING sentinel so explicit Null (`Json::Null`) stays distinct.
1589    pub keys: Map<String, Json>,
1590    /// Number of root records bound to this bucket.
1591    pub count_roots: u64,
1592    /// `$sum` outputs keyed by normalized output name.
1593    pub sums: Map<String, Json>,
1594    /// `$count` outputs: a JSON array holding each distinct counted value.
1595    pub counts: Map<String, Json>,
1596    /// `$min` outputs keyed by normalized output name.
1597    pub mins: Map<String, Json>,
1598    /// `$max` outputs keyed by normalized output name.
1599    pub maxs: Map<String, Json>,
1600    /// Materialized derived outputs keyed by normalized name.
1601    pub derived_outputs: std::collections::BTreeMap<String, Json>,
1602}
1603
1604/// Derived-output expression graph ($ref chains in document 19):
1605/// arithmetic over other named outputs, evaluated after all source metrics.
1606#[derive(Debug, Clone)]
1607pub enum OutputExpr {
1608    /// Reference to another named output.
1609    Ref(String),
1610    /// Raw property on the root/related record, evaluated per row.
1611    Field(String),
1612    /// Addition of two derived expressions.
1613    Add(Box<OutputExpr>, Box<OutputExpr>),
1614    /// Subtraction of the second expression from the first.
1615    Subtract(Box<OutputExpr>, Box<OutputExpr>),
1616    /// Multiplication of two derived expressions.
1617    Multiply(Box<OutputExpr>, Box<OutputExpr>),
1618    /// Division of `dividend` by `divisor`, rounded to `precision` decimals.
1619    Divide {
1620        /// Numerator expression.
1621        dividend: Box<OutputExpr>,
1622        /// Denominator expression.
1623        divisor: Box<OutputExpr>,
1624        /// Decimal places to round the quotient to.
1625        precision: u32,
1626    },
1627    /// A numeric literal.
1628    Literal(f64),
1629}
1630
1631/// A branch-grain aggregation request over distinct roots.
1632#[derive(Debug, Clone, Default)]
1633pub struct AggregateRequest {
1634    /// Labels a root record must carry; empty matches every label.
1635    pub labels: Vec<String>,
1636    /// Boolean predicate the root record must satisfy.
1637    pub where_: Option<Expr>,
1638    /// Independent traversal branches filtering the roots existentially.
1639    pub related: Vec<RelatedSpec>,
1640    /// Group key field names (`$record.field`-style without prefix).
1641    pub group_by: Vec<String>,
1642    /// Sum metrics keyed by output name -> source field.
1643    pub sums: Vec<(String, String)>,
1644    /// Distinct-root or distinct-entity counts keyed by output name;
1645    /// `"*"` counts root identity per document 19 semantics.
1646    pub counts: Vec<(String, String)>,
1647    /// Min/max aggregates keyed by output name -> source field.
1648    pub mins: Vec<(String, String)>,
1649    /// `$max` aggregates keyed by output name -> source field.
1650    pub maxs: Vec<(String, String)>,
1651    /// Calendar dimension added to group keys when present.
1652    pub time_bucket: Option<TimeBucketSpec>,
1653    /// Derived outputs evaluated after all source metrics (doc 19).
1654    pub outputs: Vec<(String, OutputExpr)>,
1655    /// Per-root math expressions summed into an alias ($sum over math).
1656    pub expr_sums: Vec<(String, OutputExpr)>,
1657    /// Per-root math expressions averaged into an alias ($avg over math).
1658    pub expr_avgs: Vec<(String, OutputExpr)>,
1659    /// Plain-field averages ($avg over a field ref): alias -> field.
1660    pub avg_terms: Vec<(String, String)>,
1661    /// Rounding precision per avg alias.
1662    pub avg_precision: std::collections::BTreeMap<String, u32>,
1663    /// Alias under which the time-bucket dimension is emitted.
1664    pub time_bucket_alias: Option<String>,
1665    /// Plain field projections in listing mode.
1666    pub plain_fields: Vec<(String, String)>,
1667    /// Per-root `$collect` output shapes attached to each aggregate row
1668    /// (the aggregate path mirrors the listing collect behavior).
1669    pub collects: Vec<CollectSpec>,
1670    /// `AggregateThenPaginate`: sort completed buckets by output name.
1671    pub order_by: Vec<(String, bool /* desc */)>,
1672    /// Number of completed buckets to skip after ordering.
1673    pub skip: u64,
1674    /// Maximum number of completed buckets to return; `None` = unbounded.
1675    pub limit: Option<u64>,
1676    /// Compiler-generated source metrics used only by derived outputs.
1677    pub hidden_outputs: std::collections::BTreeSet<String>,
1678}
1679
1680/// Traversal-capable evaluation context. Records-perspective execution
1681/// routes through this when the `where` tree contains `Expr::Related`.
1682pub struct EvalCtx<'a> {
1683    /// Engine handle used for tenant scope and store access.
1684    pub lmpg: &'a Lmpg,
1685    /// Pinned snapshot all traversal reads resolve against.
1686    pub snap: QuerySnapshot,
1687    /// Cooperative cancellation probe, when the caller supplied one.
1688    pub is_cancelled: Option<&'a dyn Fn() -> bool>,
1689    /// `$cycle` graphs built for this query.
1690    pub(crate) cycles: cycle::CycleGraphs,
1691    /// Declared types resolved for this query, by normalized name: a range
1692    /// predicate asks once per record (READ-1: two catalog reads per record
1693    /// and predicate).
1694    pub(crate) declared_types:
1695        std::cell::RefCell<std::collections::HashMap<String, Option<LogicalType>>>,
1696    /// The typed definitions `(pid, type)` of each name stored under more
1697    /// than one type (empty otherwise), by normalized name: a record of such
1698    /// a name is matched by the definition it holds, not the first one.
1699    pub(crate) record_types:
1700        std::cell::RefCell<std::collections::HashMap<String, Vec<(u64, LogicalType)>>>,
1701}
1702
1703impl<'a> EvalCtx<'a> {
1704    /// Open a context pinned to the store's current committed view.
1705    ///
1706    /// # Errors
1707    ///
1708    /// Propagates a [`rushdb_storage::StorageError`] if the read view cannot
1709    /// be opened.
1710    pub fn new(lmpg: &'a Lmpg) -> Result<Self, QueryError> {
1711        Ok(EvalCtx {
1712            lmpg,
1713            snap: Arc::new(lmpg.store.read_view()?),
1714            is_cancelled: None,
1715            cycles: cycle::CycleGraphs::default(),
1716            declared_types: std::cell::RefCell::default(),
1717            record_types: std::cell::RefCell::default(),
1718        })
1719    }
1720
1721    /// Poll the cancellation probe between traversal steps. A no-op when
1722    /// the caller supplied no probe.
1723    ///
1724    /// # Errors
1725    /// Returns [`QueryError::Cancelled`] when the probe requests a stop.
1726    pub fn check_cancelled(&self) -> Result<(), QueryError> {
1727        if self.is_cancelled.is_some_and(|probe| probe()) {
1728            Err(QueryError::Cancelled)
1729        } else {
1730            Ok(())
1731        }
1732    }
1733}
1734
1735fn eval_expr_ctx(
1736    ctx: &EvalCtx<'_>,
1737    expr: &Expr,
1738    obj: &Map<String, Json>,
1739) -> Result<bool, QueryError> {
1740    match expr {
1741        Expr::And(items) => {
1742            for item in items {
1743                if !eval_expr_ctx(ctx, item, obj)? {
1744                    return Ok(false);
1745                }
1746            }
1747            Ok(true)
1748        }
1749        Expr::Or(items) | Expr::Nor(items) => {
1750            let inverted = matches!(expr, Expr::Nor(_));
1751            for item in items {
1752                if eval_expr_ctx(ctx, item, obj)? {
1753                    return Ok(!inverted);
1754                }
1755            }
1756            Ok(inverted)
1757        }
1758        Expr::Xor(a, b) => Ok(eval_expr_ctx(ctx, a, obj)? != eval_expr_ctx(ctx, b, obj)?),
1759        Expr::Not(inner) => Ok(!eval_expr_ctx(ctx, inner, obj)?),
1760        Expr::Field(pred) => eval_pred(&|name| type_held(ctx, name, obj), pred, obj),
1761        Expr::Related(rel) => eval_related(ctx, obj, rel),
1762    }
1763}
1764
1765/// Traversal evaluation with hops + cycle support.
1766///
1767/// - normal (`hops`): existential reachability of an endpoint matching the
1768///   filter at any depth within bounds; edges may be reused across
1769///   different paths but a path never revisits a node.
1770/// - `$cycle`: trail semantics — each relationship edge is used at most once
1771///   per path, and the path must return to the start within bounds
1772///   (answered by [`cycle::CycleGraphs`]).
1773fn eval_related(
1774    ctx: &EvalCtx<'_>,
1775    root: &Map<String, Json>,
1776    rel: &RelatedExpr,
1777) -> Result<bool, QueryError> {
1778    let root_id = root.get(ID_FIELD).and_then(|v| v.as_str()).unwrap_or("");
1779    let start_ord = root_ord_of(ctx, root_id)?;
1780    if start_ord == u64::MAX {
1781        return Ok(false);
1782    }
1783    let (min_hops, max_hops) = rel.hops.unwrap_or((1, 1));
1784
1785    let neighbors = |node: u64| -> Result<Vec<(String, u64, u64)>, QueryError> {
1786        Ok(match rel.direction {
1787            Direction::Outgoing => {
1788                access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
1789            }
1790            Direction::Incoming => {
1791                access::adj_in(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
1792            }
1793            Direction::Both => {
1794                let mut all =
1795                    access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?;
1796                all.extend(access::adj_in(
1797                    &ctx.snap,
1798                    ctx.lmpg.tenant,
1799                    node,
1800                    rel.rel_type.as_deref(),
1801                )?);
1802                all
1803            }
1804        })
1805    };
1806
1807    // endpoint label check for normal traversal
1808    let endpoint_ok = |other: u64| -> Result<bool, QueryError> {
1809        if rel.target_labels.is_empty() {
1810            Ok(true)
1811        } else {
1812            Ok(access::record_by_ord(&ctx.snap, ctx.lmpg.tenant, other)?
1813                .is_some_and(|h| rel.target_labels.contains(&h.label)))
1814        }
1815    };
1816
1817    if rel.cycle {
1818        let cancel = || ctx.check_cancelled();
1819        return ctx
1820            .cycles
1821            .on_cycle(&ctx.snap, ctx.lmpg.tenant, rel, start_ord, &cancel);
1822    }
1823
1824    // normal existential traversal: BFS bounded by max_hops
1825    let mut frontier: Vec<u64> = vec![start_ord];
1826    let mut visited: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
1827    visited.insert(start_ord);
1828    let mut depth = 0u64;
1829
1830    while !frontier.is_empty() && depth < max_hops {
1831        ctx.check_cancelled()?;
1832        depth += 1;
1833        let mut next: Vec<u64> = Vec::new();
1834        for node in &frontier {
1835            for (_ty, other, _ro) in neighbors(*node)? {
1836                if !endpoint_ok(other)? {
1837                    continue;
1838                }
1839                if depth >= min_hops {
1840                    if let Some(filter) = &rel.filter {
1841                        if access::record_by_ord(&ctx.snap, ctx.lmpg.tenant, other)?.is_some() {
1842                            let target_obj =
1843                                filtered_row(&ctx.snap, &ctx.snap, ctx.lmpg.tenant, filter, other)?;
1844                            if eval_expr_ctx(ctx, filter, &target_obj)? {
1845                                return Ok(true);
1846                            }
1847                        }
1848                    } else {
1849                        return Ok(true);
1850                    }
1851                }
1852                if depth < max_hops && visited.insert(other) {
1853                    next.push(other);
1854                }
1855            }
1856        }
1857        frontier = next;
1858    }
1859    Ok(false)
1860}
1861
1862fn root_ord_of(ctx: &EvalCtx<'_>, id_hex: &str) -> Result<u64, QueryError> {
1863    let bytes = hex_decode_16(id_hex).unwrap_or([0u8; 16]);
1864    let id = rushdb_types::ids::RecordId(bytes);
1865    Ok(access::record_by_id(&ctx.snap, ctx.lmpg.tenant, &id)?.map_or(u64::MAX, |h| h.ordinal))
1866}
1867
1868/// The declared (catalog) type of a property name, when the caller has one.
1869pub(crate) type DeclaredType<'a> =
1870    dyn Fn(&str) -> Result<Option<rushdb_types::model::LogicalType>, QueryError> + 'a;
1871
1872#[expect(
1873    clippy::too_many_lines,
1874    reason = "one exhaustive match over every field predicate keeps the operator semantics readable in a single place"
1875)]
1876pub(crate) fn eval_pred(
1877    declared_type: &DeclaredType<'_>,
1878    pred: &FieldPred,
1879    obj: &Map<String, Json>,
1880) -> Result<bool, QueryError> {
1881    Ok(match pred {
1882        FieldPred::Exists(name, want) => {
1883            let present = raw_field(obj, name).is_some();
1884            present == *want
1885        }
1886        FieldPred::IsNull(name, want) => {
1887            let v = raw_field(obj, name);
1888            let is_null = matches!(v, Some(Json::Null));
1889            is_null == *want
1890        }
1891        FieldPred::Eq(name, val) => match (raw_field(obj, name), val) {
1892            (Some(_), Json::Null) => matches!(raw_field(obj, name), Some(Json::Null)),
1893            (Some(actual), expected) => {
1894                let datetime = is_datetime(declared_type, name)?;
1895                any_element(actual, |el| value_equals(el, expected, datetime))
1896            }
1897            (None, _) => false,
1898        },
1899        FieldPred::Ne(name, val) => {
1900            // $ne requires the field to be present (RushDB: value <> X).
1901            match raw_field(obj, name) {
1902                None => false,
1903                Some(actual) => {
1904                    let datetime = is_datetime(declared_type, name)?;
1905                    !any_element(actual, |el| value_equals(el, val, datetime))
1906                }
1907            }
1908        }
1909        FieldPred::In(name, vals) => match raw_field(obj, name) {
1910            None => false,
1911            Some(actual) => {
1912                let datetime = is_datetime(declared_type, name)?;
1913                any_element(actual, |el| {
1914                    vals.iter().any(|v| value_equals(el, v, datetime))
1915                })
1916            }
1917        },
1918        FieldPred::Contains(name, sub) => raw_field(obj, name).is_some_and(|j| {
1919            let sub = sub.to_lowercase();
1920            any_element(j, |el| {
1921                el.as_str()
1922                    .is_some_and(|st| st.to_lowercase().contains(sub.as_str()))
1923            })
1924        }),
1925        FieldPred::StartsWith(name, prefix) => raw_field(obj, name).is_some_and(|j| {
1926            let prefix = prefix.to_lowercase();
1927            any_element(j, |el| {
1928                el.as_str()
1929                    .is_some_and(|st| st.to_lowercase().starts_with(prefix.as_str()))
1930            })
1931        }),
1932        FieldPred::Gt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1933            o == std::cmp::Ordering::Greater
1934        }),
1935        FieldPred::Gte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1936            o != std::cmp::Ordering::Less
1937        }),
1938        FieldPred::Lt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1939            o == std::cmp::Ordering::Less
1940        }),
1941        FieldPred::Lte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1942            o != std::cmp::Ordering::Greater
1943        }),
1944        FieldPred::Nin(name, vals) => {
1945            // $nin requires presence; present value must match none.
1946            match raw_field(obj, name) {
1947                None => false,
1948                Some(actual) => {
1949                    let datetime = is_datetime(declared_type, name)?;
1950                    !any_element(actual, |el| {
1951                        vals.iter().any(|v| value_equals(el, v, datetime))
1952                    })
1953                }
1954            }
1955        }
1956        FieldPred::EndsWith(name, suffix) => raw_field(obj, name).is_some_and(|j| {
1957            let suffix = suffix.to_lowercase();
1958            any_element(j, |el| {
1959                el.as_str()
1960                    .is_some_and(|st| st.to_lowercase().ends_with(suffix.as_str()))
1961            })
1962        }),
1963        FieldPred::Id(id) => obj
1964            .get(ID_FIELD)
1965            .and_then(|v| v.as_str())
1966            .is_some_and(|s| canonical_id(s) == canonical_id(id)),
1967        FieldPred::DatetimeComponent {
1968            field,
1969            which,
1970            value,
1971        } => raw_field(obj, field)
1972            .and_then(serde_json::Value::as_i64)
1973            .is_some_and(|ms| {
1974                let actual = crate::datetime::component_of(ms, *which);
1975                actual == *value
1976            }),
1977        FieldPred::Type(name, expected) => {
1978            let want = canonical_type_alias(&expected.to_lowercase());
1979            let (base_want, want_array) = match want.strip_suffix("[]") {
1980                Some(base) if !base.is_empty() => (Some(canonical_type_alias(base)), true),
1981                _ => (None, want == "array"),
1982            };
1983            let value = raw_field(obj, name);
1984            // A record without the property is only `null`: the catalog type
1985            // of the property name must not make it match anything else.
1986            if value.is_none() {
1987                return Ok(want == "null");
1988            }
1989            let is_array = matches!(value, Some(Json::Array(_)));
1990
1991            // "array" matches any array-shaped value regardless of cardinality.
1992            if want == "array" {
1993                return Ok(is_array);
1994            }
1995
1996            // declared-type match: a field whose definition resolves to the
1997            // expected logical type (datetime/string/number/boolean) matches
1998            // regardless of the reconstructed JSON shape
1999            if let Some(ty) = declared_type(name)? {
2000                let name_of = |t: &rushdb_types::model::LogicalType| -> &'static str {
2001                    match t {
2002                        rushdb_types::model::LogicalType::Boolean => "boolean",
2003                        rushdb_types::model::LogicalType::Int64
2004                        | rushdb_types::model::LogicalType::Float64 => "number",
2005                        rushdb_types::model::LogicalType::Utf8 => "string",
2006                        rushdb_types::model::LogicalType::DateTime => "datetime",
2007                    }
2008                };
2009                if want_array {
2010                    // `$type: "string[]"`: value must be an array whose element
2011                    // type is `string` (baseline reports string[] for arrays).
2012                    if is_array && name_of(&ty) == base_want.as_deref().unwrap_or("") {
2013                        return Ok(true);
2014                    }
2015                } else if name_of(&ty) == want {
2016                    // `$type: "string"` matches both scalar and string[] values
2017                    // (RushDB baseline stores array primitives as plain string).
2018                    return Ok(true);
2019                }
2020            }
2021
2022            let shape_of = |v: &Json| -> &'static str {
2023                if v.is_string() {
2024                    "string"
2025                } else if v.is_number() {
2026                    "number"
2027                } else if v.is_boolean() {
2028                    "boolean"
2029                } else if v.is_array() {
2030                    "array"
2031                } else if v.is_object() {
2032                    "object"
2033                } else {
2034                    "null"
2035                }
2036            };
2037
2038            if want_array {
2039                // element-shape fallback: any non-null element carries the
2040                // requested base type
2041                let base = base_want.as_deref().unwrap_or("");
2042                return Ok(
2043                    matches!(value, Some(Json::Array(items)) if items.iter().any(|el| shape_of(el) == base)),
2044                );
2045            }
2046
2047            let actual = value.map_or("null", shape_of);
2048            actual == want
2049        }
2050    })
2051}
2052
2053fn numeric_cmp(
2054    value: Option<&Json>,
2055    bound: f64,
2056    accept: impl Fn(std::cmp::Ordering) -> bool,
2057) -> bool {
2058    value
2059        .and_then(infer_scalar)
2060        .and_then(|(_, scalar)| match scalar {
2061            Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
2062            _ => None,
2063        })
2064        .and_then(|value| canonical_scalar_cmp(&value, &Scalar::Float64(bound)))
2065        .is_some_and(accept)
2066}
2067
2068fn numeric_cmp_any(
2069    value: Option<&Json>,
2070    bound: f64,
2071    accept: impl Fn(std::cmp::Ordering) -> bool,
2072) -> bool {
2073    match value {
2074        Some(v) if v.is_array() => v
2075            .as_array()
2076            .is_some_and(|a| a.iter().any(|el| numeric_cmp(Some(el), bound, &accept))),
2077        other => numeric_cmp(other, bound, accept),
2078    }
2079}
2080
2081/// Canonical client-facing type alias: `bool` is accepted as an alias for
2082/// `boolean` so legacy queries keep working after the type was renamed for
2083/// the client surface (NestJS/UI operate with `boolean`).
2084fn canonical_type_alias(t: &str) -> String {
2085    if t == "bool" {
2086        "boolean".to_string()
2087    } else {
2088        t.to_string()
2089    }
2090}
2091
2092/// Total order for ORDER BY. Handles NaN deterministically (NaN sorts below
2093/// every number — `partial_cmp(NaN, _) == None` breaks `sort_by`'s total-order
2094/// requirement), and gives mixed non-numeric types a stable string fallback so
2095/// the comparator stays transitive.
2096fn json_value_cmp(a: &Json, b: &Json) -> std::cmp::Ordering {
2097    let numeric = |v: &Json| {
2098        infer_scalar(v).and_then(|(_, scalar)| match scalar {
2099            Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
2100            _ => None,
2101        })
2102    };
2103    match (numeric(a), numeric(b)) {
2104        (Some(x), Some(y)) => canonical_scalar_cmp(&x, &y).unwrap_or(std::cmp::Ordering::Equal),
2105        // one side is not a real number (NaN or non-numeric): it sorts first
2106        (None, Some(_)) => std::cmp::Ordering::Less,
2107        (Some(_), None) => std::cmp::Ordering::Greater,
2108        (None, None) => match (a.as_str(), b.as_str()) {
2109            (Some(x), Some(y)) => x.cmp(y),
2110            _ => format!("{a}").cmp(&format!("{b}")),
2111        },
2112    }
2113}
2114
2115fn raw_field<'a>(obj: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
2116    let norm = normalize(name);
2117    obj.iter()
2118        .find(|(k, _)| normalize(k) == norm)
2119        .map(|(_, v)| v)
2120}
2121
2122fn canonical_id(id: &str) -> String {
2123    id.bytes()
2124        .filter(|byte| *byte != b'-')
2125        .map(|byte| (byte as char).to_ascii_lowercase())
2126        .collect()
2127}
2128
2129/// Distinct root ordinals feeding every perspective and aggregate.
2130fn binding_roots_with_cancel(
2131    lmpg: &Lmpg,
2132    req: &SearchRequest,
2133    is_cancelled: &dyn Fn() -> bool,
2134    snap: &QuerySnapshot,
2135    budget: Option<usize>,
2136) -> Result<Vec<u64>, QueryError> {
2137    // Ordinal-level matching: never round-trip through reconstructed JSON
2138    // (`_id` -> `record_by_id`) just to recover an ordinal the index already
2139    // holds. Reconstruction happens only when a residual predicate or a
2140    // traversal must be evaluated against record properties.
2141    let plan = index_candidates_with_budget(lmpg, snap, req, budget, is_cancelled)?;
2142    let index_exact = plan.as_ref().is_some_and(|plan| plan.exact);
2143    let candidates: Vec<u64> = match plan {
2144        Some(plan) => plan.ords.into_iter().collect(),
2145        None => scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?.collect(is_cancelled)?,
2146    };
2147    // The candidates and the matches they narrow to: two ordinal vectors.
2148    let candidate_bytes = candidates.capacity().saturating_mul(16);
2149    aggregate_budget::check(budget, candidate_bytes)?;
2150    if index_exact && req.order_by.is_empty() {
2151        return Ok(candidates);
2152    }
2153    // The same liveness, residual-predicate and branch checks a records
2154    // search makes (`is_match`): the predicate reads only the properties it
2155    // names, by point reads, with one reconstruction session for the walk.
2156    let ctx = EvalCtx {
2157        lmpg,
2158        snap: snap.clone(),
2159        is_cancelled: Some(is_cancelled),
2160        cycles: crate::cycle::CycleGraphs::default(),
2161        declared_types: std::cell::RefCell::default(),
2162        record_types: std::cell::RefCell::default(),
2163    };
2164    let predicate_fields = match &req.where_ {
2165        Some(expr) => PointFields::for_predicate(snap, lmpg.tenant, expr)?,
2166        None => None,
2167    };
2168    let mut session = reconstruct::ReconstructSession::new(snap, lmpg.tenant);
2169    let mut working = WorkingSet {
2170        limit: budget
2171            .map(|limit| u64::try_from(limit.saturating_sub(candidate_bytes)).unwrap_or(u64::MAX)),
2172        charged: 0,
2173    };
2174    let mut matched: Vec<u64> = Vec::new();
2175    for ord in candidates {
2176        if is_cancelled() {
2177            return Err(QueryError::Cancelled);
2178        }
2179        if is_match(
2180            &ctx,
2181            req,
2182            req.where_.as_ref(),
2183            predicate_fields.as_ref(),
2184            &mut session,
2185            &mut working,
2186            ord,
2187        )? {
2188            matched.push(ord);
2189        }
2190    }
2191    Ok(matched)
2192}
2193
2194/// Normalize a property/label name to the engine's canonical form.
2195#[must_use]
2196pub fn normalize(name: &str) -> String {
2197    rushdb_types::value::normalize_name(name)
2198}
2199
2200fn resolve_field(obj: &Map<String, Json>, field: &str) -> Option<Json> {
2201    let field = field.strip_prefix("$record.").unwrap_or(field);
2202    // A row keyed by the request's own spelling (point-read rows, or a
2203    // record whose stored name is the request's) answers without
2204    // normalizing every key.
2205    if let Some(value) = obj.get(field) {
2206        return Some(value.clone());
2207    }
2208    let norm = normalize(field);
2209    obj.iter()
2210        .find(|(k, _)| normalize(k) == norm)
2211        .map(|(_, v)| v.clone())
2212}
2213
2214fn resolve_field_numeric(obj: &Map<String, Json>, field: &str) -> Option<Json> {
2215    resolve_field(obj, field).filter(serde_json::Value::is_number)
2216}
2217
2218#[cfg(test)]
2219mod index_planner_tests;