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