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