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 aggregation;
24mod cycle;
25mod equality;
26mod field_types;
27pub use aggregation::MAX_AGGREGATE_BUCKETS;
28pub use aggregation::{
29    aggregate, aggregate_in_view, aggregate_to_rows, aggregate_to_rows_in_view,
30    aggregate_to_rows_in_view_with_budget, aggregate_with_cancel, aggregate_with_cancel_in_view,
31    aggregate_with_cancel_in_view_with_budget,
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 `fields` names, by point reads, or the rebuilt record.
713fn read_row(
714    fields: Option<&PointFields>,
715    session: &mut reconstruct::ReconstructSession<'_, QuerySnapshot>,
716    snap: &QuerySnapshot,
717    tenant: rushdb_types::ids::TenantId,
718    ord: u64,
719) -> Result<Map<String, Json>, QueryError> {
720    match fields {
721        Some(fields) => fields.row(snap, tenant, ord),
722        None => Ok(session
723            .reconstruct_record(ord)?
724            .and_then(|record| match record {
725                Json::Object(map) => Some(map),
726                _ => None,
727            })
728            .unwrap_or_default()),
729    }
730}
731
732/// Where a search's candidates come from and what is left to check.
733struct CandidateSource<'v> {
734    candidates: scan::Candidates<'v>,
735    /// Every candidate is a live match: neither the predicate nor the
736    /// record's existence is checked per candidate.
737    exact: bool,
738    /// The total, when known without counting.
739    known: Option<u64>,
740    /// What is left to check per candidate: the conjuncts the owner set does
741    /// not answer, or the whole `where` on a walk.
742    predicate: Option<Expr>,
743}
744
745/// The index plan's owner set (charged to `working`), or a walk of the
746/// tenant's or labels' records.
747fn candidate_source<'v>(
748    lmpg: &Lmpg,
749    req: &SearchRequest,
750    is_cancelled: &dyn Fn() -> bool,
751    snap: &'v QuerySnapshot,
752    known_total: Option<u64>,
753    working: &mut WorkingSet,
754) -> Result<CandidateSource<'v>, QueryError> {
755    if let Some(plan) =
756        index_candidates_with_budget(lmpg, snap, req, working_set_budget(lmpg), is_cancelled)?
757    {
758        working.charge(
759            u64::try_from(plan.ords.serialized_size()).unwrap_or(u64::MAX),
760            true,
761        )?;
762        let known = known_total
763            .or(plan.known_total)
764            .or_else(|| plan.exact.then(|| plan.ords.len()));
765        return Ok(CandidateSource {
766            candidates: scan::Candidates::from_set(plan.ords),
767            exact: plan.exact,
768            known,
769            predicate: plan.residual,
770        });
771    }
772    let exact = req.where_.is_none() && req.related.is_empty();
773    let known = match known_total {
774        Some(total) => Some(total),
775        None if exact => Some(walk_total(lmpg, snap, &req.labels)?),
776        None => None,
777    };
778    Ok(CandidateSource {
779        candidates: scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?,
780        exact,
781        known,
782        predicate: req.where_.clone(),
783    })
784}
785
786/// One ordering row: a match's ordinal and the properties it is ordered by.
787type OrderRow = (u64, Map<String, Json>);
788
789/// Sort the ordering buffer and keep its best `keep` rows; returns the bytes
790/// of the rows dropped.
791fn cut_ordered(
792    ordered: &mut Vec<OrderRow>,
793    bytes: &mut Vec<u64>,
794    req: &SearchRequest,
795    keep: usize,
796) -> u64 {
797    let mut rows: Vec<(OrderRow, u64)> = ordered.drain(..).zip(bytes.drain(..)).collect();
798    // Candidates arrive in ascending ordinal order and the sort is stable,
799    // so ties keep that order across cuts, exactly as one sort of every
800    // match would.
801    sort_ordered_rows(&mut rows, req);
802    let freed = rows.iter().skip(keep).map(|(_, b)| *b).sum();
803    rows.truncate(keep);
804    for (row, b) in rows {
805        ordered.push(row);
806        bytes.push(b);
807    }
808    freed
809}
810
811/// Live records of the tenant (`labels` empty) or of `labels`, from counters.
812fn walk_total(lmpg: &Lmpg, snap: &QuerySnapshot, labels: &[String]) -> Result<u64, QueryError> {
813    if labels.is_empty() {
814        return Ok(rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records);
815    }
816    let distinct: std::collections::BTreeSet<&String> = labels.iter().collect();
817    let mut total = 0u64;
818    for label in distinct {
819        total = total.saturating_add(rushdb_lmpg::derived::label_count(
820            snap.as_ref(),
821            lmpg.tenant,
822            label,
823        )?);
824    }
825    Ok(total)
826}
827
828/// Execute against a caller-owned statement view, allowing response metadata
829/// and follow-up reconstruction to share the same committed version. The
830/// view must belong to `lmpg.store`; the caller owns authorization scope.
831///
832/// # Errors
833///
834/// Returns a storage error when `snap` belongs to a different store, and
835/// [`QueryError::Cancelled`] when the probe requests a stop. Storage/engine
836/// read errors and predicate evaluation failures are propagated.
837pub fn find_records_in_view(
838    lmpg: &Lmpg,
839    req: &SearchRequest,
840    is_cancelled: &dyn Fn() -> bool,
841    snap: QuerySnapshot,
842) -> Result<Page, QueryError> {
843    find_records_in_view_with_budget(lmpg, req, is_cancelled, snap, None)
844}
845
846/// Refuse a filter that has no usable index when the records it would check
847/// exceed `unindexed-walk-max-owners` or the request's own limit (SCALE-3,
848/// QRY-3; none by default, like PostgreSQL). The population is exact
849/// and read from counters before any work: the walked property's support,
850/// or, when no condition can be walked, every record of the labels.
851fn check_walk_budget(
852    lmpg: &Lmpg,
853    snap: &QuerySnapshot,
854    req: &SearchRequest,
855) -> Result<(), QueryError> {
856    let (Some(limit), Some(expr)) = (lmpg.limits().unindexed_walk_max_owners(), &req.where_) else {
857        return Ok(());
858    };
859    if explain_index(snap.as_ref(), lmpg.tenant, &req.labels, expr)?.is_some() {
860        return Ok(());
861    }
862    let walk = explain_walk(snap.as_ref(), lmpg.tenant, &req.labels, expr)?;
863    let (property, owners, labels) = if let Some(walk) = walk {
864        walk
865    } else {
866        // No condition can be walked: every record of the labels is checked.
867        let owners = if req.labels.is_empty() {
868            rushdb_lmpg::derived::tenant_usage(snap.as_ref(), lmpg.tenant)?.records
869        } else {
870            let mut owners = 0u64;
871            for label in &req.labels {
872                owners = owners.saturating_add(rushdb_lmpg::derived::label_count(
873                    snap.as_ref(),
874                    lmpg.tenant,
875                    label,
876                )?);
877            }
878            owners
879        };
880        (
881            "(no indexable condition)".to_owned(),
882            owners,
883            req.labels.clone(),
884        )
885    };
886    if owners <= limit {
887        return Ok(());
888    }
889    Err(QueryError::UnindexedWalk(format!(
890        "the filter on `{property}` would check {owners} records of [{}], over \
891         the unindexed-walk limit ({limit}); declare an ordered index on the property",
892        labels.join(", ")
893    )))
894}
895
896/// The query working-set cap: candidate and match ordinals, ordering rows
897/// and the record being evaluated. It is `query-memory-max` (`0` = off),
898/// tightened by the request,
899/// never the request's response byte budget, which caps only the returned
900/// page (READ-1: charging the working set against the 16 MiB response
901/// budget refused every read of a label over ~2M records).
902fn working_set_budget(lmpg: &Lmpg) -> Option<usize> {
903    lmpg.limits()
904        .query_memory_max_bytes()
905        .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX))
906}
907
908/// The caller's cancellation or the request's deadline, so a
909/// time cap stops every read that runs on the request's handle.
910pub(crate) fn cancel_probe<'a>(
911    lmpg: &Lmpg,
912    is_cancelled: &'a dyn Fn() -> bool,
913) -> impl Fn() -> bool + 'a {
914    let limits = lmpg.limits();
915    move || is_cancelled() || limits.expired()
916}
917
918/// Execute a search with an optional caller-owned response byte budget for
919/// the returned page. The working set (candidates, matches, ordering rows) is
920/// capped by `query-memory-max` instead ([`working_set_budget`]).
921///
922/// # Errors
923/// Returns [`QueryError`] when the view is invalid, cancellation is observed,
924/// storage is corrupt, or the working-byte budget is exceeded.
925#[allow(clippy::needless_pass_by_value)]
926pub fn find_records_in_view_with_budget(
927    lmpg: &Lmpg,
928    req: &SearchRequest,
929    is_cancelled: &dyn Fn() -> bool,
930    snap: QuerySnapshot,
931    work_budget: Option<usize>,
932) -> Result<Page, QueryError> {
933    find_page(lmpg, req, is_cancelled, &snap, work_budget, None)
934}
935
936/// Streaming execution: identical to [`find_records_in_view`]; kept as the
937/// name the differential tests compare against the forced-scan path.
938///
939/// # Errors
940///
941/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
942/// propagates storage/engine read errors and predicate evaluation failures.
943pub fn find_records_streaming(
944    lmpg: &Lmpg,
945    req: &SearchRequest,
946    is_cancelled: &dyn Fn() -> bool,
947    snap: QuerySnapshot,
948) -> Result<Page, QueryError> {
949    find_records_streaming_with_total(lmpg, req, is_cancelled, snap, None)
950}
951
952/// Page with a caller-supplied total (E6 cursor continuations). The total
953/// is only valid when the caller reuses the exact pinned snapshot and match
954/// set the total was computed from — the native cursor guarantees both, so
955/// continuations stop after their page instead of counting again. `None`
956/// computes it (ordinary search path).
957///
958/// # Errors
959///
960/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
961/// propagates storage/engine read errors and predicate evaluation failures.
962#[expect(
963    clippy::needless_pass_by_value,
964    reason = "the snapshot is accepted by value as part of the public API contract so callers can hand over a freshly pinned view"
965)]
966pub fn find_records_streaming_with_total(
967    lmpg: &Lmpg,
968    req: &SearchRequest,
969    is_cancelled: &dyn Fn() -> bool,
970    snap: QuerySnapshot,
971    known_total: Option<u64>,
972) -> Result<Page, QueryError> {
973    find_page(lmpg, req, is_cancelled, &snap, None, known_total)
974}
975
976/// The one records-page executor: match ([`run_matches`]), cut the page,
977/// reconstruct it against the response budget.
978fn find_page(
979    lmpg: &Lmpg,
980    req: &SearchRequest,
981    is_cancelled: &dyn Fn() -> bool,
982    snap: &QuerySnapshot,
983    work_budget: Option<usize>,
984    known_total: Option<u64>,
985) -> Result<Page, QueryError> {
986    let is_cancelled: &dyn Fn() -> bool = &cancel_probe(lmpg, is_cancelled);
987    if !snap.belongs_to(&lmpg.store) {
988        return Err(rushdb_storage::StorageError::InvalidRead(
989            "query view belongs to another Store".into(),
990        )
991        .into());
992    }
993    if is_cancelled() {
994        return Err(QueryError::Cancelled);
995    }
996    let skip = usize::try_from(req.pagination.skip).unwrap_or(usize::MAX);
997    let limit = usize::try_from(req.pagination.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX);
998    let matches = run_matches(
999        lmpg,
1000        req,
1001        is_cancelled,
1002        snap,
1003        skip.saturating_add(limit),
1004        known_total,
1005    )?;
1006    let page_roots: Vec<u64> = matches
1007        .retained
1008        .into_iter()
1009        .skip(skip)
1010        .take(limit)
1011        .collect();
1012    let budget = effective_budget(
1013        work_budget,
1014        lmpg.limits().query_memory_max_bytes().unwrap_or(0),
1015    );
1016    let records =
1017        page::reconstruct_page_with_budget(lmpg, req, is_cancelled, snap, page_roots, budget)?;
1018    Ok(Page {
1019        total_matched: matches.total,
1020        records,
1021    })
1022}
1023
1024/// Sort ordering rows by the declared query order. `Vec::sort_by` is
1025/// stable, so ties preserve candidate order and pagination remains repeatable.
1026fn sort_ordered_rows<T>(sortable: &mut [(OrderRow, T)], req: &SearchRequest) {
1027    for (field, desc) in req.order_by.iter().rev() {
1028        let field = normalize(field);
1029        sortable.sort_by(|((_, a), _), ((_, b), _)| {
1030            let va = a
1031                .iter()
1032                .find(|(k, _)| normalize(k) == field)
1033                .map(|(_, v)| v);
1034            let vb = b
1035                .iter()
1036                .find(|(k, _)| normalize(k) == field)
1037                .map(|(_, v)| v);
1038            let ord = match (va, vb) {
1039                (Some(x), Some(y)) => json_value_cmp(x, y),
1040                (None, Some(_)) => std::cmp::Ordering::Less,
1041                (Some(_), None) => std::cmp::Ordering::Greater,
1042                (None, None) => std::cmp::Ordering::Equal,
1043            };
1044            if *desc {
1045                ord.reverse()
1046            } else {
1047                ord
1048            }
1049        });
1050    }
1051}
1052
1053/// `(ordinal, raw record, projected record)` for one matching collected child.
1054type RelatedItem = (u64, Map<String, Json>, Map<String, Json>);
1055
1056fn collect_neighbors(
1057    lmpg: &Lmpg,
1058    snap: &QuerySnapshot,
1059    root: u64,
1060    spec: &CollectSpec,
1061    budget: Option<usize>,
1062    is_cancelled: &dyn Fn() -> bool,
1063) -> Result<Vec<u64>, QueryError> {
1064    Ok(if spec.steps.is_empty() {
1065        let edges: Vec<(String, u64, u64)> = match spec.direction {
1066            Direction::Outgoing => {
1067                access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
1068            }
1069            Direction::Incoming => {
1070                access::adj_in(snap, lmpg.tenant, root, spec.rel_type.as_deref())?
1071            }
1072            Direction::Both => {
1073                let mut all = access::adj_out(snap, lmpg.tenant, root, spec.rel_type.as_deref())?;
1074                all.extend(access::adj_in(
1075                    snap,
1076                    lmpg.tenant,
1077                    root,
1078                    spec.rel_type.as_deref(),
1079                )?);
1080                all
1081            }
1082        };
1083        edges.into_iter().map(|(_, other, _)| other).collect()
1084    } else {
1085        traverse_alias(
1086            lmpg,
1087            snap,
1088            lmpg.tenant,
1089            root,
1090            &spec.steps,
1091            budget,
1092            is_cancelled,
1093        )?
1094    })
1095}
1096
1097fn order_collected_items(items: &mut Vec<RelatedItem>, spec: &CollectSpec) {
1098    // uniqueness by identity.
1099    if spec.unique {
1100        items.sort_by_key(|(o, _, _)| *o);
1101        items.dedup_by(|a, b| a.1.get(ID_FIELD) == b.1.get(ID_FIELD));
1102    } else {
1103        items.sort_by_key(|(o, _, _)| *o);
1104    }
1105    // order by collected output aliases (fall back to the raw field).
1106    if !spec.order_by.is_empty() {
1107        items.sort_by(|a, b| {
1108            for (field, desc) in &spec.order_by {
1109                let av = raw_field(&a.2, field).or_else(|| raw_field(&a.1, field));
1110                let bv = raw_field(&b.2, field).or_else(|| raw_field(&b.1, field));
1111                let ord = scalar_cmp(av, bv).unwrap_or(std::cmp::Ordering::Equal);
1112                if ord != std::cmp::Ordering::Equal {
1113                    return if *desc { ord.reverse() } else { ord };
1114                }
1115            }
1116            std::cmp::Ordering::Equal
1117        });
1118    }
1119}
1120
1121fn attach_collects_with_budget(
1122    lmpg: &Lmpg,
1123    snap: &QuerySnapshot,
1124    root: u64,
1125    mut record: Json,
1126    collects: &[CollectSpec],
1127    budget: Option<usize>,
1128    is_cancelled: &dyn Fn() -> bool,
1129) -> Result<Json, QueryError> {
1130    if collects.is_empty() {
1131        return Ok(record);
1132    }
1133    let mut retained = aggregate_budget::value_bytes(&record);
1134    aggregate_budget::check(budget, retained)?;
1135    let ctx = EvalCtx {
1136        lmpg,
1137        snap: snap.clone(),
1138        is_cancelled: None,
1139        cycles: crate::cycle::CycleGraphs::default(),
1140        declared_types: std::cell::RefCell::default(),
1141        record_types: std::cell::RefCell::default(),
1142    };
1143    if let Some(obj) = record.as_object_mut() {
1144        for spec in collects {
1145            // Alias-based (`from`) collects traverse the full path (multi-hop)
1146            // applying every level's `where` predicates; label-based collects
1147            // use direct adjacency.
1148            if is_cancelled() {
1149                return Err(QueryError::Cancelled);
1150            }
1151            let neighbors = collect_neighbors(
1152                lmpg,
1153                snap,
1154                root,
1155                spec,
1156                budget.map(|limit| limit.saturating_sub(retained)),
1157                is_cancelled,
1158            )?;
1159            // (ordinal, raw record, projected record) for every matching child.
1160            let base_bytes = retained;
1161            retained = retained.saturating_add(neighbors.capacity().saturating_mul(8));
1162            aggregate_budget::check(budget, retained)?;
1163            let mut items: Vec<RelatedItem> = Vec::new();
1164            for other in neighbors {
1165                if is_cancelled() {
1166                    return Err(QueryError::Cancelled);
1167                }
1168                if !spec.target_labels.is_empty()
1169                    && !access::record_by_ord(snap, lmpg.tenant, other)?
1170                        .is_some_and(|h| spec.target_labels.contains(&h.label))
1171                {
1172                    continue;
1173                }
1174                let reader = aggregate_budget::RecordReader::new(
1175                    snap,
1176                    budget.map(|limit| limit.saturating_sub(retained)),
1177                );
1178                let Some(Json::Object(child)) =
1179                    reconstruct::reconstruct_record(&reader, lmpg.tenant, other)?
1180                else {
1181                    continue;
1182                };
1183                // label-based `where` filter on the collected level.
1184                if let Some(filter) = &spec.filter {
1185                    if !eval_expr_ctx(&ctx, filter, &child)? {
1186                        continue;
1187                    }
1188                }
1189                retained = retained
1190                    .saturating_add(aggregate_budget::object_bytes(&child).saturating_mul(3));
1191                aggregate_budget::check(budget, retained)?;
1192                let projected = match project_fields_map(Json::Object(child.clone()), &spec.fields)
1193                {
1194                    Json::Object(p) => p,
1195                    _ => child.clone(),
1196                };
1197                items.push((other, child, projected));
1198            }
1199            order_collected_items(&mut items, spec);
1200            // slice then recurse nested collects on the projected record.
1201            let start = usize::try_from(spec.skip)
1202                .unwrap_or(usize::MAX)
1203                .min(items.len());
1204            let end = spec.limit.map_or(items.len(), |l| {
1205                start
1206                    .saturating_add(usize::try_from(l).unwrap_or(usize::MAX))
1207                    .min(items.len())
1208            });
1209            let mut projected = Vec::new();
1210            for (other, raw, child) in items.into_iter().take(end).skip(start) {
1211                retained =
1212                    retained.saturating_sub(aggregate_budget::object_bytes(&raw).saturating_mul(3));
1213                let child = attach_collects_with_budget(
1214                    lmpg,
1215                    snap,
1216                    other,
1217                    Json::Object(child),
1218                    &spec.nested,
1219                    budget.map(|limit| limit.saturating_sub(retained)),
1220                    is_cancelled,
1221                )?;
1222                retained = retained.saturating_add(aggregate_budget::value_bytes(&child));
1223                aggregate_budget::check(budget, retained)?;
1224                projected.push(child);
1225            }
1226            let collected = Json::Array(projected);
1227            retained = base_bytes
1228                .saturating_add(spec.alias.len())
1229                .saturating_add(aggregate_budget::value_bytes(&collected));
1230            aggregate_budget::check(budget, retained)?;
1231            obj.insert(spec.alias.clone(), collected);
1232        }
1233    }
1234    Ok(record)
1235}
1236
1237/// Project a collected record into a `(output_key, source_field)` map; an
1238/// empty map keeps the whole record (alias-based default).
1239fn project_fields_map(value: Json, fields: &[(String, String)]) -> Json {
1240    if fields.is_empty() {
1241        return value;
1242    }
1243    let mut out = Map::new();
1244    if let Json::Object(obj) = value {
1245        for (out_key, source) in fields {
1246            if let Some(v) = resolve_field(&obj, source) {
1247                out.insert(out_key.clone(), v);
1248            }
1249        }
1250    }
1251    Json::Object(out)
1252}
1253
1254/// Scalar ordering for collected-record sorting: numeric for numbers,
1255/// lexical for strings (ISO datetimes order correctly), else equal.
1256fn scalar_cmp(a: Option<&Json>, b: Option<&Json>) -> Option<std::cmp::Ordering> {
1257    let (Some(a), Some(b)) = (a, b) else {
1258        return None;
1259    };
1260    match (a, b) {
1261        (Json::Number(_), Json::Number(_)) => {
1262            let left = infer_scalar(a).map(|(_, scalar)| scalar);
1263            let right = infer_scalar(b).map(|(_, scalar)| scalar);
1264            left.as_ref()
1265                .zip(right.as_ref())
1266                .and_then(|(left, right)| canonical_scalar_cmp(left, right))
1267        }
1268        (Json::String(x), Json::String(y)) => Some(x.cmp(y)),
1269        _ => None,
1270    }
1271}
1272
1273fn related_exists(
1274    snap: &QuerySnapshot,
1275    tenant: rushdb_types::ids::TenantId,
1276    ord: u64,
1277    spec: &RelatedSpec,
1278) -> Result<bool, QueryError> {
1279    let edges: Vec<(String, u64, u64)> = match spec.direction {
1280        Direction::Outgoing => access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?,
1281        Direction::Incoming => access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?,
1282        Direction::Both => {
1283            let mut all = access::adj_out(snap, tenant, ord, spec.rel_type.as_deref())?;
1284            all.extend(access::adj_in(snap, tenant, ord, spec.rel_type.as_deref())?);
1285            all
1286        }
1287    };
1288    if spec.target_labels.is_empty() {
1289        return Ok(!edges.is_empty());
1290    }
1291    for (_, other_ord, _) in edges {
1292        if access::record_by_ord(snap, tenant, other_ord)?
1293            .is_some_and(|h| spec.target_labels.contains(&h.label))
1294        {
1295            return Ok(true);
1296        }
1297    }
1298    Ok(false)
1299}
1300
1301/// Distinct related ordinals for a `$count` target that names a traversal
1302/// alias from `where`/`related` (e.g. `$count: "$battle"`). Returns `None`
1303/// when the target is not an alias, so callers fall back to field counting.
1304/// The alias's full traversal path is followed (multi-hop) and every level's
1305/// `where` predicates are applied, matching the reference binding semantics.
1306fn count_related(
1307    lmpg: &Lmpg,
1308    snap: &QuerySnapshot,
1309    tenant: rushdb_types::ids::TenantId,
1310    ord: u64,
1311    specs: &[RelatedSpec],
1312    alias: &str,
1313    budget: Option<usize>,
1314) -> Result<Option<Vec<u64>>, QueryError> {
1315    let Some(spec) = specs.iter().find(|s| {
1316        s.alias
1317            .as_deref()
1318            .is_some_and(|a| a.trim_start_matches('$') == alias)
1319    }) else {
1320        return Ok(None);
1321    };
1322    Ok(Some(traverse_alias(
1323        lmpg,
1324        snap,
1325        tenant,
1326        ord,
1327        &spec.steps,
1328        budget,
1329        &|| false,
1330    )?))
1331}
1332
1333/// Follow a traversal path from `ord` (root) through each step, applying the
1334/// step's target-label filter, rel type/direction, and endpoint predicates.
1335/// Returns the distinct ordinals bound at the final level.
1336fn traverse_alias(
1337    lmpg: &Lmpg,
1338    snap: &QuerySnapshot,
1339    tenant: rushdb_types::ids::TenantId,
1340    ord: u64,
1341    steps: &[TraversalStep],
1342    budget: Option<usize>,
1343    is_cancelled: &dyn Fn() -> bool,
1344) -> Result<Vec<u64>, QueryError> {
1345    if steps.is_empty() {
1346        return Ok(Vec::new());
1347    }
1348    let ctx = EvalCtx {
1349        lmpg,
1350        snap: snap.clone(),
1351        is_cancelled: Some(is_cancelled),
1352        cycles: crate::cycle::CycleGraphs::default(),
1353        declared_types: std::cell::RefCell::default(),
1354        record_types: std::cell::RefCell::default(),
1355    };
1356    let mut cur = vec![ord];
1357    for step in steps {
1358        if is_cancelled() {
1359            return Err(QueryError::Cancelled);
1360        }
1361        let mut next: Vec<u64> = Vec::new();
1362        for o in &cur {
1363            if is_cancelled() {
1364                return Err(QueryError::Cancelled);
1365            }
1366            let edges: Vec<(String, u64, u64)> = match step.direction {
1367                Direction::Outgoing => access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?,
1368                Direction::Incoming => access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?,
1369                Direction::Both => {
1370                    let mut all = access::adj_out(snap, tenant, *o, step.rel_type.as_deref())?;
1371                    all.extend(access::adj_in(snap, tenant, *o, step.rel_type.as_deref())?);
1372                    all
1373                }
1374            };
1375            for (_, other, _) in edges {
1376                if !step.target_labels.is_empty()
1377                    && !access::record_by_ord(snap, tenant, other)?
1378                        .is_some_and(|h| step.target_labels.contains(&h.label))
1379                {
1380                    continue;
1381                }
1382                if let Some(pred) = &step.filter {
1383                    let reader = aggregate_budget::RecordReader::new(
1384                        snap,
1385                        budget.map(|limit| {
1386                            limit.saturating_sub(
1387                                cur.len().saturating_add(next.len()).saturating_mul(32),
1388                            )
1389                        }),
1390                    );
1391                    let props = reconstruct::reconstruct_record(&reader, tenant, other)?;
1392                    let obj = props
1393                        .as_ref()
1394                        .and_then(|p| p.as_object().cloned())
1395                        .unwrap_or_default();
1396                    if !eval_expr_ctx(&ctx, pred, &obj)? {
1397                        continue;
1398                    }
1399                }
1400                aggregate_budget::check(
1401                    budget,
1402                    cur.len()
1403                        .saturating_add(next.len())
1404                        .saturating_add(1)
1405                        .saturating_mul(32),
1406                )?;
1407                next.push(other);
1408            }
1409        }
1410        next.sort_unstable();
1411        next.dedup();
1412        cur = next;
1413    }
1414    Ok(cur)
1415}
1416
1417/// Collect the `where`-block related aliases (as [`RelatedSpec`]s) so aggregate
1418/// `$count`/`$sum` targets can reference them. Aggregates keep `related` empty;
1419/// the aliases live inside the `Expr::Related` nodes of `where_`.
1420fn related_specs_from_where(expr: Option<&Expr>) -> Vec<RelatedSpec> {
1421    /// Split a level's filter into its endpoint predicates and the nested
1422    /// label-block children (each child becomes a further traversal step).
1423    fn split_level(filter: Option<&Expr>, children: &mut Vec<Expr>) -> Option<Expr> {
1424        let f = filter?;
1425        match f {
1426            Expr::And(items) => {
1427                let mut preds: Vec<Expr> = Vec::new();
1428                for item in items {
1429                    match item {
1430                        Expr::Related(_) => children.push(item.clone()),
1431                        other => preds.push(other.clone()),
1432                    }
1433                }
1434                match preds.len() {
1435                    0 => None,
1436                    1 => Some(preds.remove(0)),
1437                    _ => Some(Expr::And(preds)),
1438                }
1439            }
1440            Expr::Related(_) => {
1441                children.push(f.clone());
1442                None
1443            }
1444            other => Some(other.clone()),
1445        }
1446    }
1447
1448    fn walk(expr: &Expr, path: &mut Vec<TraversalStep>, out: &mut Vec<RelatedSpec>) {
1449        match expr {
1450            Expr::And(items) | Expr::Or(items) | Expr::Nor(items) => {
1451                for e in items {
1452                    walk(e, path, out);
1453                }
1454            }
1455            Expr::Xor(a, b) => {
1456                walk(a, path, out);
1457                walk(b, path, out);
1458            }
1459            Expr::Not(b) => {
1460                walk(b, path, out);
1461            }
1462            Expr::Related(rel) => {
1463                let mut children: Vec<Expr> = Vec::new();
1464                let filter = split_level(rel.filter.as_deref(), &mut children);
1465                let step = TraversalStep {
1466                    target_labels: rel.target_labels.clone(),
1467                    rel_type: rel.rel_type.clone(),
1468                    direction: rel.direction,
1469                    filter,
1470                };
1471                path.push(step);
1472                if let Some(alias) = &rel.alias {
1473                    out.push(RelatedSpec {
1474                        alias: Some(alias.trim_start_matches('$').to_string()),
1475                        rel_type: rel.rel_type.clone(),
1476                        direction: rel.direction,
1477                        target_labels: rel.target_labels.clone(),
1478                        steps: path.clone(),
1479                    });
1480                }
1481                for child in &children {
1482                    walk(child, path, out);
1483                }
1484                path.pop();
1485            }
1486            Expr::Field(_) => {}
1487        }
1488    }
1489    let mut specs = Vec::new();
1490    if let Some(e) = expr {
1491        let mut path: Vec<TraversalStep> = Vec::new();
1492        walk(e, &mut path, &mut specs);
1493    }
1494    specs
1495}
1496
1497/// Resolve `alias.field` values across a root's related records (the docs'
1498/// aggregate-over-alias semantics: `$sum: "$employee.salary"`). Returns `None`
1499/// when `field` is not an alias reference.
1500fn alias_field_values(
1501    lmpg: &Lmpg,
1502    snap: &QuerySnapshot,
1503    tenant: rushdb_types::ids::TenantId,
1504    ord: u64,
1505    specs: &[RelatedSpec],
1506    field: &str,
1507    budget: Option<usize>,
1508) -> Result<Option<Vec<Json>>, QueryError> {
1509    let Some((alias, prop)) = field.split_once('.') else {
1510        return Ok(None);
1511    };
1512    let Some(related) = count_related(lmpg, snap, tenant, ord, specs, alias, budget)? else {
1513        return Ok(None);
1514    };
1515    let mut retained = related.capacity().saturating_mul(8);
1516    aggregate_budget::check(budget, retained)?;
1517    let mut values = Vec::new();
1518    for other in related {
1519        let reader = aggregate_budget::RecordReader::new(
1520            snap,
1521            budget.map(|limit| limit.saturating_sub(retained)),
1522        );
1523        if let Some(Json::Object(child)) = reconstruct::reconstruct_record(&reader, tenant, other)?
1524        {
1525            if let Some(v) = resolve_field(&child, prop) {
1526                retained = retained.saturating_add(aggregate_budget::value_bytes(&v));
1527                aggregate_budget::check(budget, retained)?;
1528                values.push(v);
1529            }
1530        }
1531    }
1532    Ok(Some(values))
1533}
1534
1535/// Group-key sentinel emitted when a `groupBy`/timeBucket source field is
1536/// missing (distinct from an explicit JSON null).
1537pub const MISSING_SENTINEL: &str = "__missing__";
1538
1539/// One aggregation bucket carrying its typed group tuple, per-metric maps
1540/// and materialized derived outputs.
1541#[derive(Debug, Clone, Default, serde::Serialize)]
1542pub struct GroupBucket {
1543    /// One entry per `group_by` field; missing renders as the documented
1544    /// MISSING sentinel so explicit Null (`Json::Null`) stays distinct.
1545    pub keys: Map<String, Json>,
1546    /// Number of root records bound to this bucket.
1547    pub count_roots: u64,
1548    /// `$sum` outputs keyed by normalized output name.
1549    pub sums: Map<String, Json>,
1550    /// `$count` outputs: a JSON array holding each distinct counted value.
1551    pub counts: Map<String, Json>,
1552    /// `$min` outputs keyed by normalized output name.
1553    pub mins: Map<String, Json>,
1554    /// `$max` outputs keyed by normalized output name.
1555    pub maxs: Map<String, Json>,
1556    /// Materialized derived outputs keyed by normalized name.
1557    pub derived_outputs: std::collections::BTreeMap<String, Json>,
1558}
1559
1560/// Derived-output expression graph ($ref chains in document 19):
1561/// arithmetic over other named outputs, evaluated after all source metrics.
1562#[derive(Debug, Clone)]
1563pub enum OutputExpr {
1564    /// Reference to another named output.
1565    Ref(String),
1566    /// Raw property on the root/related record, evaluated per row.
1567    Field(String),
1568    /// Addition of two derived expressions.
1569    Add(Box<OutputExpr>, Box<OutputExpr>),
1570    /// Subtraction of the second expression from the first.
1571    Subtract(Box<OutputExpr>, Box<OutputExpr>),
1572    /// Multiplication of two derived expressions.
1573    Multiply(Box<OutputExpr>, Box<OutputExpr>),
1574    /// Division of `dividend` by `divisor`, rounded to `precision` decimals.
1575    Divide {
1576        /// Numerator expression.
1577        dividend: Box<OutputExpr>,
1578        /// Denominator expression.
1579        divisor: Box<OutputExpr>,
1580        /// Decimal places to round the quotient to.
1581        precision: u32,
1582    },
1583    /// A numeric literal.
1584    Literal(f64),
1585}
1586
1587/// A branch-grain aggregation request over distinct roots.
1588#[derive(Debug, Clone, Default)]
1589pub struct AggregateRequest {
1590    /// Labels a root record must carry; empty matches every label.
1591    pub labels: Vec<String>,
1592    /// Boolean predicate the root record must satisfy.
1593    pub where_: Option<Expr>,
1594    /// Independent traversal branches filtering the roots existentially.
1595    pub related: Vec<RelatedSpec>,
1596    /// Group key field names (`$record.field`-style without prefix).
1597    pub group_by: Vec<String>,
1598    /// Sum metrics keyed by output name -> source field.
1599    pub sums: Vec<(String, String)>,
1600    /// Distinct-root or distinct-entity counts keyed by output name;
1601    /// `"*"` counts root identity per document 19 semantics.
1602    pub counts: Vec<(String, String)>,
1603    /// Min/max aggregates keyed by output name -> source field.
1604    pub mins: Vec<(String, String)>,
1605    /// `$max` aggregates keyed by output name -> source field.
1606    pub maxs: Vec<(String, String)>,
1607    /// Calendar dimension added to group keys when present.
1608    pub time_bucket: Option<TimeBucketSpec>,
1609    /// Derived outputs evaluated after all source metrics (doc 19).
1610    pub outputs: Vec<(String, OutputExpr)>,
1611    /// Per-root math expressions summed into an alias ($sum over math).
1612    pub expr_sums: Vec<(String, OutputExpr)>,
1613    /// Per-root math expressions averaged into an alias ($avg over math).
1614    pub expr_avgs: Vec<(String, OutputExpr)>,
1615    /// Plain-field averages ($avg over a field ref): alias -> field.
1616    pub avg_terms: Vec<(String, String)>,
1617    /// Rounding precision per avg alias.
1618    pub avg_precision: std::collections::BTreeMap<String, u32>,
1619    /// Alias under which the time-bucket dimension is emitted.
1620    pub time_bucket_alias: Option<String>,
1621    /// Plain field projections in listing mode.
1622    pub plain_fields: Vec<(String, String)>,
1623    /// Per-root `$collect` output shapes attached to each aggregate row
1624    /// (the aggregate path mirrors the listing collect behavior).
1625    pub collects: Vec<CollectSpec>,
1626    /// `AggregateThenPaginate`: sort completed buckets by output name.
1627    pub order_by: Vec<(String, bool /* desc */)>,
1628    /// Number of completed buckets to skip after ordering.
1629    pub skip: u64,
1630    /// Maximum number of completed buckets to return; `None` = unbounded.
1631    pub limit: Option<u64>,
1632    /// Compiler-generated source metrics used only by derived outputs.
1633    pub hidden_outputs: std::collections::BTreeSet<String>,
1634}
1635
1636/// Traversal-capable evaluation context. Records-perspective execution
1637/// routes through this when the `where` tree contains `Expr::Related`.
1638pub struct EvalCtx<'a> {
1639    /// Engine handle used for tenant scope and store access.
1640    pub lmpg: &'a Lmpg,
1641    /// Pinned snapshot all traversal reads resolve against.
1642    pub snap: QuerySnapshot,
1643    /// Cooperative cancellation probe, when the caller supplied one.
1644    pub is_cancelled: Option<&'a dyn Fn() -> bool>,
1645    /// `$cycle` graphs built for this query.
1646    pub(crate) cycles: cycle::CycleGraphs,
1647    /// Declared types resolved for this query, by normalized name: a range
1648    /// predicate asks once per record (READ-1: two catalog reads per record
1649    /// and predicate).
1650    pub(crate) declared_types:
1651        std::cell::RefCell<std::collections::HashMap<String, Option<LogicalType>>>,
1652    /// The typed definitions `(pid, type)` of each name stored under more
1653    /// than one type (empty otherwise), by normalized name: a record of such
1654    /// a name is matched by the definition it holds, not the first one.
1655    pub(crate) record_types:
1656        std::cell::RefCell<std::collections::HashMap<String, Vec<(u64, LogicalType)>>>,
1657}
1658
1659impl<'a> EvalCtx<'a> {
1660    /// Open a context pinned to the store's current committed view.
1661    ///
1662    /// # Errors
1663    ///
1664    /// Propagates a [`rushdb_storage::StorageError`] if the read view cannot
1665    /// be opened.
1666    pub fn new(lmpg: &'a Lmpg) -> Result<Self, QueryError> {
1667        Ok(EvalCtx {
1668            lmpg,
1669            snap: Arc::new(lmpg.store.read_view()?),
1670            is_cancelled: None,
1671            cycles: cycle::CycleGraphs::default(),
1672            declared_types: std::cell::RefCell::default(),
1673            record_types: std::cell::RefCell::default(),
1674        })
1675    }
1676
1677    /// Poll the cancellation probe between traversal steps. A no-op when
1678    /// the caller supplied no probe.
1679    ///
1680    /// # Errors
1681    /// Returns [`QueryError::Cancelled`] when the probe requests a stop.
1682    pub fn check_cancelled(&self) -> Result<(), QueryError> {
1683        if self.is_cancelled.is_some_and(|probe| probe()) {
1684            Err(QueryError::Cancelled)
1685        } else {
1686            Ok(())
1687        }
1688    }
1689}
1690
1691fn eval_expr_ctx(
1692    ctx: &EvalCtx<'_>,
1693    expr: &Expr,
1694    obj: &Map<String, Json>,
1695) -> Result<bool, QueryError> {
1696    match expr {
1697        Expr::And(items) => {
1698            for item in items {
1699                if !eval_expr_ctx(ctx, item, obj)? {
1700                    return Ok(false);
1701                }
1702            }
1703            Ok(true)
1704        }
1705        Expr::Or(items) | Expr::Nor(items) => {
1706            let inverted = matches!(expr, Expr::Nor(_));
1707            for item in items {
1708                if eval_expr_ctx(ctx, item, obj)? {
1709                    return Ok(!inverted);
1710                }
1711            }
1712            Ok(inverted)
1713        }
1714        Expr::Xor(a, b) => Ok(eval_expr_ctx(ctx, a, obj)? != eval_expr_ctx(ctx, b, obj)?),
1715        Expr::Not(inner) => Ok(!eval_expr_ctx(ctx, inner, obj)?),
1716        Expr::Field(pred) => eval_pred(&|name| type_held(ctx, name, obj), pred, obj),
1717        Expr::Related(rel) => eval_related(ctx, obj, rel),
1718    }
1719}
1720
1721/// Traversal evaluation with hops + cycle support.
1722///
1723/// - normal (`hops`): existential reachability of an endpoint matching the
1724///   filter at any depth within bounds; edges may be reused across
1725///   different paths but a path never revisits a node.
1726/// - `$cycle`: trail semantics — each relationship edge is used at most once
1727///   per path, and the path must return to the start within bounds
1728///   (answered by [`cycle::CycleGraphs`]).
1729fn eval_related(
1730    ctx: &EvalCtx<'_>,
1731    root: &Map<String, Json>,
1732    rel: &RelatedExpr,
1733) -> Result<bool, QueryError> {
1734    let root_id = root.get(ID_FIELD).and_then(|v| v.as_str()).unwrap_or("");
1735    let start_ord = root_ord_of(ctx, root_id)?;
1736    if start_ord == u64::MAX {
1737        return Ok(false);
1738    }
1739    let (min_hops, max_hops) = rel.hops.unwrap_or((1, 1));
1740
1741    let neighbors = |node: u64| -> Result<Vec<(String, u64, u64)>, QueryError> {
1742        Ok(match rel.direction {
1743            Direction::Outgoing => {
1744                access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
1745            }
1746            Direction::Incoming => {
1747                access::adj_in(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?
1748            }
1749            Direction::Both => {
1750                let mut all =
1751                    access::adj_out(&ctx.snap, ctx.lmpg.tenant, node, rel.rel_type.as_deref())?;
1752                all.extend(access::adj_in(
1753                    &ctx.snap,
1754                    ctx.lmpg.tenant,
1755                    node,
1756                    rel.rel_type.as_deref(),
1757                )?);
1758                all
1759            }
1760        })
1761    };
1762
1763    // endpoint label check for normal traversal
1764    let endpoint_ok = |other: u64| -> Result<bool, QueryError> {
1765        if rel.target_labels.is_empty() {
1766            Ok(true)
1767        } else {
1768            Ok(access::record_by_ord(&ctx.snap, ctx.lmpg.tenant, other)?
1769                .is_some_and(|h| rel.target_labels.contains(&h.label)))
1770        }
1771    };
1772
1773    if rel.cycle {
1774        let cancel = || ctx.check_cancelled();
1775        return ctx
1776            .cycles
1777            .on_cycle(&ctx.snap, ctx.lmpg.tenant, rel, start_ord, &cancel);
1778    }
1779
1780    // normal existential traversal: BFS bounded by max_hops
1781    let mut frontier: Vec<u64> = vec![start_ord];
1782    let mut visited: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
1783    visited.insert(start_ord);
1784    let mut depth = 0u64;
1785
1786    while !frontier.is_empty() && depth < max_hops {
1787        ctx.check_cancelled()?;
1788        depth += 1;
1789        let mut next: Vec<u64> = Vec::new();
1790        for node in &frontier {
1791            for (_ty, other, _ro) in neighbors(*node)? {
1792                if !endpoint_ok(other)? {
1793                    continue;
1794                }
1795                if depth >= min_hops {
1796                    if let Some(filter) = &rel.filter {
1797                        if let Some(target_json) =
1798                            reconstruct::reconstruct_record(&ctx.snap, ctx.lmpg.tenant, other)?
1799                        {
1800                            let target_obj = target_json.as_object().cloned().unwrap_or_default();
1801                            if eval_expr_ctx(ctx, filter, &target_obj)? {
1802                                return Ok(true);
1803                            }
1804                        }
1805                    } else {
1806                        return Ok(true);
1807                    }
1808                }
1809                if depth < max_hops && visited.insert(other) {
1810                    next.push(other);
1811                }
1812            }
1813        }
1814        frontier = next;
1815    }
1816    Ok(false)
1817}
1818
1819fn root_ord_of(ctx: &EvalCtx<'_>, id_hex: &str) -> Result<u64, QueryError> {
1820    let bytes = hex_decode_16(id_hex).unwrap_or([0u8; 16]);
1821    let id = rushdb_types::ids::RecordId(bytes);
1822    Ok(access::record_by_id(&ctx.snap, ctx.lmpg.tenant, &id)?.map_or(u64::MAX, |h| h.ordinal))
1823}
1824
1825/// The declared (catalog) type of a property name, when the caller has one.
1826pub(crate) type DeclaredType<'a> =
1827    dyn Fn(&str) -> Result<Option<rushdb_types::model::LogicalType>, QueryError> + 'a;
1828
1829#[expect(
1830    clippy::too_many_lines,
1831    reason = "one exhaustive match over every field predicate keeps the operator semantics readable in a single place"
1832)]
1833pub(crate) fn eval_pred(
1834    declared_type: &DeclaredType<'_>,
1835    pred: &FieldPred,
1836    obj: &Map<String, Json>,
1837) -> Result<bool, QueryError> {
1838    Ok(match pred {
1839        FieldPred::Exists(name, want) => {
1840            let present = raw_field(obj, name).is_some();
1841            present == *want
1842        }
1843        FieldPred::IsNull(name, want) => {
1844            let v = raw_field(obj, name);
1845            let is_null = matches!(v, Some(Json::Null));
1846            is_null == *want
1847        }
1848        FieldPred::Eq(name, val) => match (raw_field(obj, name), val) {
1849            (Some(_), Json::Null) => matches!(raw_field(obj, name), Some(Json::Null)),
1850            (Some(actual), expected) => {
1851                let datetime = is_datetime(declared_type, name)?;
1852                any_element(actual, |el| value_equals(el, expected, datetime))
1853            }
1854            (None, _) => false,
1855        },
1856        FieldPred::Ne(name, val) => {
1857            // $ne requires the field to be present (RushDB: value <> X).
1858            match raw_field(obj, name) {
1859                None => false,
1860                Some(actual) => {
1861                    let datetime = is_datetime(declared_type, name)?;
1862                    !any_element(actual, |el| value_equals(el, val, datetime))
1863                }
1864            }
1865        }
1866        FieldPred::In(name, vals) => match raw_field(obj, name) {
1867            None => false,
1868            Some(actual) => {
1869                let datetime = is_datetime(declared_type, name)?;
1870                any_element(actual, |el| {
1871                    vals.iter().any(|v| value_equals(el, v, datetime))
1872                })
1873            }
1874        },
1875        FieldPred::Contains(name, sub) => raw_field(obj, name).is_some_and(|j| {
1876            let sub = sub.to_lowercase();
1877            any_element(j, |el| {
1878                el.as_str()
1879                    .is_some_and(|st| st.to_lowercase().contains(sub.as_str()))
1880            })
1881        }),
1882        FieldPred::StartsWith(name, prefix) => raw_field(obj, name).is_some_and(|j| {
1883            let prefix = prefix.to_lowercase();
1884            any_element(j, |el| {
1885                el.as_str()
1886                    .is_some_and(|st| st.to_lowercase().starts_with(prefix.as_str()))
1887            })
1888        }),
1889        FieldPred::Gt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1890            o == std::cmp::Ordering::Greater
1891        }),
1892        FieldPred::Gte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1893            o != std::cmp::Ordering::Less
1894        }),
1895        FieldPred::Lt(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1896            o == std::cmp::Ordering::Less
1897        }),
1898        FieldPred::Lte(name, bound) => numeric_cmp_any(raw_field(obj, name), *bound, |o| {
1899            o != std::cmp::Ordering::Greater
1900        }),
1901        FieldPred::Nin(name, vals) => {
1902            // $nin requires presence; present value must match none.
1903            match raw_field(obj, name) {
1904                None => false,
1905                Some(actual) => {
1906                    let datetime = is_datetime(declared_type, name)?;
1907                    !any_element(actual, |el| {
1908                        vals.iter().any(|v| value_equals(el, v, datetime))
1909                    })
1910                }
1911            }
1912        }
1913        FieldPred::EndsWith(name, suffix) => raw_field(obj, name).is_some_and(|j| {
1914            let suffix = suffix.to_lowercase();
1915            any_element(j, |el| {
1916                el.as_str()
1917                    .is_some_and(|st| st.to_lowercase().ends_with(suffix.as_str()))
1918            })
1919        }),
1920        FieldPred::Id(id) => obj
1921            .get(ID_FIELD)
1922            .and_then(|v| v.as_str())
1923            .is_some_and(|s| canonical_id(s) == canonical_id(id)),
1924        FieldPred::DatetimeComponent {
1925            field,
1926            which,
1927            value,
1928        } => raw_field(obj, field)
1929            .and_then(serde_json::Value::as_i64)
1930            .is_some_and(|ms| {
1931                let actual = crate::datetime::component_of(ms, *which);
1932                actual == *value
1933            }),
1934        FieldPred::Type(name, expected) => {
1935            let want = canonical_type_alias(&expected.to_lowercase());
1936            let (base_want, want_array) = match want.strip_suffix("[]") {
1937                Some(base) if !base.is_empty() => (Some(canonical_type_alias(base)), true),
1938                _ => (None, want == "array"),
1939            };
1940            let value = raw_field(obj, name);
1941            // A record without the property is only `null`: the catalog type
1942            // of the property name must not make it match anything else.
1943            if value.is_none() {
1944                return Ok(want == "null");
1945            }
1946            let is_array = matches!(value, Some(Json::Array(_)));
1947
1948            // "array" matches any array-shaped value regardless of cardinality.
1949            if want == "array" {
1950                return Ok(is_array);
1951            }
1952
1953            // declared-type match: a field whose definition resolves to the
1954            // expected logical type (datetime/string/number/boolean) matches
1955            // regardless of the reconstructed JSON shape
1956            if let Some(ty) = declared_type(name)? {
1957                let name_of = |t: &rushdb_types::model::LogicalType| -> &'static str {
1958                    match t {
1959                        rushdb_types::model::LogicalType::Boolean => "boolean",
1960                        rushdb_types::model::LogicalType::Int64
1961                        | rushdb_types::model::LogicalType::Float64 => "number",
1962                        rushdb_types::model::LogicalType::Utf8 => "string",
1963                        rushdb_types::model::LogicalType::DateTime => "datetime",
1964                    }
1965                };
1966                if want_array {
1967                    // `$type: "string[]"`: value must be an array whose element
1968                    // type is `string` (baseline reports string[] for arrays).
1969                    if is_array && name_of(&ty) == base_want.as_deref().unwrap_or("") {
1970                        return Ok(true);
1971                    }
1972                } else if name_of(&ty) == want {
1973                    // `$type: "string"` matches both scalar and string[] values
1974                    // (RushDB baseline stores array primitives as plain string).
1975                    return Ok(true);
1976                }
1977            }
1978
1979            let shape_of = |v: &Json| -> &'static str {
1980                if v.is_string() {
1981                    "string"
1982                } else if v.is_number() {
1983                    "number"
1984                } else if v.is_boolean() {
1985                    "boolean"
1986                } else if v.is_array() {
1987                    "array"
1988                } else if v.is_object() {
1989                    "object"
1990                } else {
1991                    "null"
1992                }
1993            };
1994
1995            if want_array {
1996                // element-shape fallback: any non-null element carries the
1997                // requested base type
1998                let base = base_want.as_deref().unwrap_or("");
1999                return Ok(
2000                    matches!(value, Some(Json::Array(items)) if items.iter().any(|el| shape_of(el) == base)),
2001                );
2002            }
2003
2004            let actual = value.map_or("null", shape_of);
2005            actual == want
2006        }
2007    })
2008}
2009
2010fn numeric_cmp(
2011    value: Option<&Json>,
2012    bound: f64,
2013    accept: impl Fn(std::cmp::Ordering) -> bool,
2014) -> bool {
2015    value
2016        .and_then(infer_scalar)
2017        .and_then(|(_, scalar)| match scalar {
2018            Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
2019            _ => None,
2020        })
2021        .and_then(|value| canonical_scalar_cmp(&value, &Scalar::Float64(bound)))
2022        .is_some_and(accept)
2023}
2024
2025fn numeric_cmp_any(
2026    value: Option<&Json>,
2027    bound: f64,
2028    accept: impl Fn(std::cmp::Ordering) -> bool,
2029) -> bool {
2030    match value {
2031        Some(v) if v.is_array() => v
2032            .as_array()
2033            .is_some_and(|a| a.iter().any(|el| numeric_cmp(Some(el), bound, &accept))),
2034        other => numeric_cmp(other, bound, accept),
2035    }
2036}
2037
2038/// Canonical client-facing type alias: `bool` is accepted as an alias for
2039/// `boolean` so legacy queries keep working after the type was renamed for
2040/// the client surface (NestJS/UI operate with `boolean`).
2041fn canonical_type_alias(t: &str) -> String {
2042    if t == "bool" {
2043        "boolean".to_string()
2044    } else {
2045        t.to_string()
2046    }
2047}
2048
2049/// Total order for ORDER BY. Handles NaN deterministically (NaN sorts below
2050/// every number — `partial_cmp(NaN, _) == None` breaks `sort_by`'s total-order
2051/// requirement), and gives mixed non-numeric types a stable string fallback so
2052/// the comparator stays transitive.
2053fn json_value_cmp(a: &Json, b: &Json) -> std::cmp::Ordering {
2054    let numeric = |v: &Json| {
2055        infer_scalar(v).and_then(|(_, scalar)| match scalar {
2056            Scalar::Int64(_) | Scalar::Float64(_) => Some(scalar),
2057            _ => None,
2058        })
2059    };
2060    match (numeric(a), numeric(b)) {
2061        (Some(x), Some(y)) => canonical_scalar_cmp(&x, &y).unwrap_or(std::cmp::Ordering::Equal),
2062        // one side is not a real number (NaN or non-numeric): it sorts first
2063        (None, Some(_)) => std::cmp::Ordering::Less,
2064        (Some(_), None) => std::cmp::Ordering::Greater,
2065        (None, None) => match (a.as_str(), b.as_str()) {
2066            (Some(x), Some(y)) => x.cmp(y),
2067            _ => format!("{a}").cmp(&format!("{b}")),
2068        },
2069    }
2070}
2071
2072fn raw_field<'a>(obj: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
2073    let norm = normalize(name);
2074    obj.iter()
2075        .find(|(k, _)| normalize(k) == norm)
2076        .map(|(_, v)| v)
2077}
2078
2079fn canonical_id(id: &str) -> String {
2080    id.bytes()
2081        .filter(|byte| *byte != b'-')
2082        .map(|byte| (byte as char).to_ascii_lowercase())
2083        .collect()
2084}
2085
2086/// Distinct root ordinals feeding every perspective and aggregate.
2087fn binding_roots_with_cancel(
2088    lmpg: &Lmpg,
2089    req: &SearchRequest,
2090    is_cancelled: &dyn Fn() -> bool,
2091    snap: &QuerySnapshot,
2092    budget: Option<usize>,
2093) -> Result<Vec<u64>, QueryError> {
2094    // Ordinal-level matching: never round-trip through reconstructed JSON
2095    // (`_id` -> `record_by_id`) just to recover an ordinal the index already
2096    // holds. Reconstruction happens only when a residual predicate or a
2097    // traversal must be evaluated against record properties.
2098    let plan = index_candidates_with_budget(lmpg, snap, req, budget, is_cancelled)?;
2099    let index_exact = plan.as_ref().is_some_and(|plan| plan.exact);
2100    let candidates: Vec<u64> = match plan {
2101        Some(plan) => plan.ords.into_iter().collect(),
2102        None => scan::Candidates::walk(snap, lmpg.tenant, &req.labels)?.collect(is_cancelled)?,
2103    };
2104    let candidate_bytes = candidates.capacity().saturating_mul(16);
2105    aggregate_budget::check(budget, candidate_bytes)?;
2106    if index_exact && req.order_by.is_empty() {
2107        return Ok(candidates);
2108    }
2109    let ctx = EvalCtx {
2110        lmpg,
2111        snap: snap.clone(),
2112        is_cancelled: Some(is_cancelled),
2113        cycles: crate::cycle::CycleGraphs::default(),
2114        declared_types: std::cell::RefCell::default(),
2115        record_types: std::cell::RefCell::default(),
2116    };
2117    let mut matched: Vec<u64> = Vec::new();
2118    for ord in candidates {
2119        if is_cancelled() {
2120            return Err(QueryError::Cancelled);
2121        }
2122        if access::record_by_ord(snap, lmpg.tenant, ord)?.is_none() {
2123            continue;
2124        }
2125        if let Some(expr) = &req.where_ {
2126            let reader = aggregate_budget::RecordReader::new(
2127                snap,
2128                budget.map(|limit| limit.saturating_sub(candidate_bytes)),
2129            );
2130            let mut session = reconstruct::ReconstructSession::new(&reader, lmpg.tenant);
2131            let props = session.reconstruct_record(ord)?;
2132            let obj = props
2133                .as_ref()
2134                .and_then(|p| p.as_object().cloned())
2135                .unwrap_or_default();
2136            if !eval_expr_ctx(&ctx, expr, &obj)? {
2137                continue;
2138            }
2139        }
2140        let mut all_branches_bound = true;
2141        for spec in &req.related {
2142            if !related_exists(snap, lmpg.tenant, ord, spec)? {
2143                all_branches_bound = false;
2144                break;
2145            }
2146        }
2147        if all_branches_bound {
2148            matched.push(ord);
2149        }
2150    }
2151    Ok(matched)
2152}
2153
2154/// Normalize a property/label name to the engine's canonical form.
2155#[must_use]
2156pub fn normalize(name: &str) -> String {
2157    rushdb_types::value::normalize_name(name)
2158}
2159
2160fn resolve_field(obj: &Map<String, Json>, field: &str) -> Option<Json> {
2161    let norm = normalize(field.strip_prefix("$record.").unwrap_or(field));
2162    obj.iter()
2163        .find(|(k, _)| normalize(k) == norm)
2164        .map(|(_, v)| v.clone())
2165}
2166
2167fn resolve_field_numeric(obj: &Map<String, Json>, field: &str) -> Option<Json> {
2168    resolve_field(obj, field).filter(serde_json::Value::is_number)
2169}
2170
2171#[cfg(test)]
2172mod index_planner_tests;