Skip to main content

rushdb_query/
aggregation.rs

1//! Budgeted grouping, aggregate evaluation, and result projection.
2
3use super::{
4    aggregate_budget, alias_field_values, attach_collects_with_budget, binding_roots_with_cancel,
5    canonical_scalar_cmp, count_related, infer_scalar, json_value_cmp, normalize, reconstruct,
6    related_specs_from_where, resolve_field, resolve_field_numeric, working_set_budget,
7    AggregateRequest, Arc, GroupBucket, Json, Lmpg, Map, OutputExpr, Pagination, PointFields,
8    QueryError, QuerySnapshot, Scalar, SearchRequest, MISSING_SENTINEL,
9};
10use crate::aggregate_catalog::{dictionary_grouped_count, too_many_groups};
11use std::collections::{BTreeSet, HashMap, HashSet};
12
13/// Branch-grain aggregation over a `BindingRelation` of distinct roots:
14/// one root contributes exactly one binding row, independent branches are
15/// filtered existentially rather than multiplied into flat rows.
16///
17/// # Errors
18///
19/// Propagates storage/engine read errors and predicate evaluation failures as
20/// [`QueryError`].
21pub fn aggregate(lmpg: &Lmpg, req: &AggregateRequest) -> Result<Vec<GroupBucket>, QueryError> {
22    aggregate_with_cancel(lmpg, req, &|| false).map(|(buckets, _)| buckets)
23}
24
25/// Maximum number of distinct buckets a single aggregation may create before
26/// it is refused with [`QueryError::ResourceLimit`].
27pub const MAX_AGGREGATE_BUCKETS: usize = 1_000;
28
29/// Aggregate execution with cooperative cancellation at root and bucket
30/// boundaries. Returns the completed buckets plus the total number of matched
31/// roots (the `total` reported by the data plane, matching a search result's
32/// total).
33///
34/// # Errors
35///
36/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
37/// [`QueryError::ResourceLimit`] when more than [`MAX_AGGREGATE_BUCKETS`]
38/// distinct groups would be created. Storage/engine and predicate errors are
39/// propagated from the underlying reads.
40///
41/// # Panics
42///
43/// Panics only if a bucket just inserted by `ensure_bucket` cannot be
44/// retrieved, which is unreachable.
45pub fn aggregate_with_cancel(
46    lmpg: &Lmpg,
47    req: &AggregateRequest,
48    is_cancelled: &dyn Fn() -> bool,
49) -> Result<(Vec<GroupBucket>, u64), QueryError> {
50    let snap = Arc::new(lmpg.store.read_view()?);
51    aggregate_with_cancel_in_view(lmpg, req, is_cancelled, snap)
52}
53
54/// Aggregate execution over a caller-owned pinned read view. Embedded
55/// engines use this to keep every phase of a request on one snapshot and to
56/// apply the same deadline/budget context as the transport path.
57///
58/// # Errors
59/// Propagates storage, predicate, cancellation and aggregate resource errors.
60pub fn aggregate_in_view(
61    lmpg: &Lmpg,
62    req: &AggregateRequest,
63    snap: QuerySnapshot,
64) -> Result<(Vec<GroupBucket>, u64), QueryError> {
65    aggregate_with_cancel_in_view(lmpg, req, &|| false, snap)
66}
67
68/// Execute an aggregate on the supplied view.
69///
70/// The working set (candidate ordinals, the row being aggregated, the
71/// buckets) is capped by `query-memory-max`, tightened by the request, the
72/// same cap a records search uses. It is never the request's response byte
73/// budget: that caps only the rendered page
74/// ([`aggregate_to_rows_in_view_with_budget`]). Charging the working set
75/// against the 16 MiB response budget refused every grouped aggregate over
76/// ~1M records (the candidate ordinals alone filled it).
77///
78/// # Errors
79/// Returns storage, predicate, cancellation, or resource errors.
80pub fn aggregate_with_cancel_in_view(
81    lmpg: &Lmpg,
82    req: &AggregateRequest,
83    is_cancelled: &dyn Fn() -> bool,
84    snap: QuerySnapshot,
85) -> Result<(Vec<GroupBucket>, u64), QueryError> {
86    let is_cancelled: &dyn Fn() -> bool = &crate::cancel_probe(lmpg, is_cancelled);
87    if !snap.belongs_to(&lmpg.store) {
88        return Err(rushdb_storage::StorageError::InvalidRead(
89            "aggregate view belongs to another Store".into(),
90        )
91        .into());
92    }
93    if is_cancelled() {
94        return Err(QueryError::Cancelled);
95    }
96    let budget = working_set_budget(lmpg);
97    aggregate_budget::check(budget, std::mem::size_of::<GroupBucket>())?;
98    aggregate_budgeted(lmpg, req, is_cancelled, snap, budget)
99        .map_err(aggregate_budget::resource_error)
100}
101
102#[expect(
103    clippy::too_many_lines,
104    clippy::cast_sign_loss,
105    clippy::cast_precision_loss,
106    clippy::needless_pass_by_value,
107    reason = "aggregate accumulation keeps one live budget and pinned snapshot"
108)]
109fn aggregate_budgeted(
110    lmpg: &Lmpg,
111    req: &AggregateRequest,
112    is_cancelled: &dyn Fn() -> bool,
113    snap: QuerySnapshot,
114    budget: Option<usize>,
115) -> Result<(Vec<GroupBucket>, u64), QueryError> {
116    let page_req = SearchRequest {
117        labels: req.labels.clone(),
118        where_: req.where_.clone(),
119        related: req.related.clone(),
120        collects: Vec::new(),
121        pagination: Pagination::default(),
122        order_by: Vec::new(),
123        force_scan: false,
124    };
125    // Count-only fast path: a single global `$count: "*"` with no grouping or
126    // other metrics needs only the match count. A single unscoped label is
127    // answered straight from the maintained label catalog (O(labels)); other
128    // shapes fall through to the ordinal matcher below, which never
129    // reconstructs records to produce a count.
130    if is_count_only(req) {
131        if let Some(total) = catalog_count_total(lmpg, &page_req, &snap)? {
132            let mut buckets = count_only_bucket(total);
133            apply_aggregate_pagination(&mut buckets, req);
134            return Ok((buckets, total));
135        }
136    }
137    // Grouped counts over one property: the value dictionary holds the
138    // exact owner count per value, so no record is read.
139    if let Some(buckets) = dictionary_grouped_count(lmpg, req, &snap, is_cancelled)? {
140        aggregate_budget::check(
141            budget,
142            buckets.iter().fold(0usize, |sum, b| {
143                sum.saturating_add(aggregate_budget::bucket_bytes(b))
144            }),
145        )?;
146        let total = buckets.iter().map(|b| b.count_roots).sum();
147        return Ok((order_and_page(buckets, req), total));
148    }
149    let candidates: Vec<u64> =
150        binding_roots_with_cancel(lmpg, &page_req, is_cancelled, &snap, budget)?;
151    // What is retained: one ordinal per match. The walk charged its own
152    // transient copies.
153    let candidate_bytes = candidates.len().saturating_mul(std::mem::size_of::<u64>());
154    aggregate_budget::check(budget, candidate_bytes)?;
155    let total_matched = candidates.len() as u64;
156    if is_count_only(req) {
157        let mut buckets = count_only_bucket(total_matched);
158        apply_aggregate_pagination(&mut buckets, req);
159        return Ok((buckets, total_matched));
160    }
161    // Aggregate alias targets come from the `where` traversal blocks.
162    let alias_specs = related_specs_from_where(req.where_.as_ref());
163    let pure_projection = !req.plain_fields.is_empty()
164        && req.group_by.is_empty()
165        && req.sums.is_empty()
166        && req.counts.is_empty()
167        && req.mins.is_empty()
168        && req.maxs.is_empty()
169        && req.outputs.is_empty()
170        && req.expr_sums.is_empty()
171        && req.expr_avgs.is_empty()
172        && req.avg_terms.is_empty()
173        && req.time_bucket.is_none();
174
175    // A `select` without groupBy is per-root output-shaping (each root its own
176    // row) when it projects root fields or collects. Metrics alone roll up
177    // globally; a bare rollup (e.g. timeBucket with no select/groupBy) is a
178    // single bucket.
179    let per_root =
180        req.group_by.is_empty() && (!req.plain_fields.is_empty() || !req.collects.is_empty());
181    // Each matched root is its own row, so rows are bounded by the paging
182    // and the memory budget, not by the group cap; without an `orderBy` only
183    // the requested page is reconstructed.
184    let per_row = pure_projection || per_root;
185    let candidates = if per_row && req.order_by.is_empty() {
186        let page_end = req.skip.saturating_add(req.limit.unwrap_or(u64::MAX));
187        candidates
188            .into_iter()
189            .take(usize::try_from(page_end).unwrap_or(usize::MAX))
190            .collect()
191    } else {
192        candidates
193    };
194
195    let mut buckets: Vec<GroupBucket> = Vec::new();
196    // Grouped buckets are found by their rendered key, not by comparing
197    // every bucket's key map per row.
198    let mut bucket_index: HashMap<String, usize> = HashMap::new();
199    // Only the properties the request names are read: by point reads when
200    // every one is a scalar (READ-1's `PointFields`), else by folding the
201    // record's occurrences into just those names. One session serves every
202    // row: family definitions are frozen within the pinned snapshot.
203    let names = referenced_fields(req);
204    let point_fields = PointFields::resolve(&snap, lmpg.tenant, names.iter().map(String::as_str))?;
205    let selected: HashSet<String> = names
206        .iter()
207        .flat_map(|name| [name.clone(), normalize(name)])
208        .collect();
209    let mut session = reconstruct::ReconstructSession::new(&snap, lmpg.tenant);
210    let metric_count = req
211        .counts
212        .len()
213        .saturating_add(req.sums.len())
214        .saturating_add(req.mins.len())
215        .saturating_add(req.maxs.len())
216        .saturating_add(req.expr_avgs.len())
217        .saturating_add(req.expr_sums.len())
218        .saturating_add(req.avg_terms.len())
219        .saturating_add(req.group_by.len())
220        .saturating_add(req.plain_fields.len());
221    let mut retained_bytes = candidate_bytes;
222    for ord in candidates {
223        if is_cancelled() {
224            return Err(QueryError::Cancelled);
225        }
226        let obj: Map<String, Json> = match &point_fields {
227            Some(fields) => fields.row(&snap, lmpg.tenant, ord)?,
228            None => match session.reconstruct_record_selected(ord, &selected)? {
229                Some(Json::Object(map)) => map,
230                _ => continue,
231            },
232        };
233        // The row is transient: it must fit beside what is retained, but it
234        // is not retained itself.
235        let row_bytes = aggregate_budget::object_bytes(&obj);
236        aggregate_budget::check(budget, retained_bytes.saturating_add(row_bytes))?;
237        let alias_budget = budget.map(|limit| {
238            limit.saturating_sub(retained_bytes.saturating_add(row_bytes))
239                / metric_count.saturating_add(1)
240        });
241
242        let mut bucket_keys: Map<String, Json> = Map::new();
243        if pure_projection || per_root {
244            // A `select` without groupBy is row projection / per-root
245            // aggregation, not a global rollup: the hidden root ordinal keeps
246            // each root a distinct row; aggregate_to_rows removes it.
247            bucket_keys.insert("__rushdb_projection_root".into(), Json::Number(ord.into()));
248        }
249        if pure_projection {
250            for (output, field) in &req.plain_fields {
251                if let Some(value) = resolve_field(&obj, field) {
252                    bucket_keys.insert(output.clone(), value);
253                }
254            }
255        } else if per_root {
256            // Per-root rows with metrics still carry plain field projections.
257            for (output, field) in &req.plain_fields {
258                if let Some(value) = resolve_field(&obj, field) {
259                    bucket_keys.insert(output.clone(), value);
260                }
261            }
262        }
263        for g in &req.group_by {
264            // a group-by entry that is a timeBucket alias is resolved by the
265            // timeBucket step below; don't leak a separate missing key
266            if req.time_bucket_alias.as_deref() == Some(g.as_str()) {
267                continue;
268            }
269            // The key keeps the caller's spelling; lookups by output name
270            // compare normalized names.
271            let v = resolve_field(&obj, g).unwrap_or(Json::String(MISSING_SENTINEL.into()));
272            bucket_keys.insert(g.clone(), v);
273        }
274        if let Some(tb) = &req.time_bucket {
275            let keyed = resolve_field(&obj, &tb.field)
276                .and_then(|v| v.as_f64())
277                .map_or_else(
278                    || Json::String(MISSING_SENTINEL.into()),
279                    |ms| Json::Number((tb.floor_millis(ms) as u64).into()),
280                );
281            // output naming honors the user's `as` alias; the default key is
282            // the documented `__bucket_<field>/<unit>` form
283            let tb_key = req.time_bucket_alias.clone().unwrap_or_else(|| {
284                format!("__bucket_{}/{}", normalize(&tb.field), tb.unit.unit_str())
285            });
286            bucket_keys.insert(tb_key, keyed);
287        }
288
289        // Per-row keys carry the root ordinal, so they never repeat.
290        let (bucket, previous_bucket_bytes) = if per_row {
291            (new_bucket(&mut buckets, bucket_keys), 0)
292        } else {
293            let rendered = serde_json::to_string(&bucket_keys).unwrap_or_default();
294            if let Some(&position) = bucket_index.get(&rendered) {
295                let bucket = &mut buckets[position];
296                let bytes = aggregate_budget::bucket_bytes(bucket);
297                (bucket, bytes)
298            } else {
299                if buckets.len() >= MAX_AGGREGATE_BUCKETS {
300                    return Err(too_many_groups());
301                }
302                bucket_index.insert(rendered, buckets.len());
303                (new_bucket(&mut buckets, bucket_keys), 0)
304            }
305        };
306
307        // $count '*' = distinct root identity; each row already unique.
308        bucket.count_roots += 1;
309
310        for (out, field) in &req.counts {
311            if req.hidden_outputs.contains(out) {
312                continue;
313            }
314            let target_key = out.clone();
315            if field.as_str() == "*" {
316                continue;
317            }
318            // P20 fix: a `$count` over a related-record alias (`$count: "$battle"`)
319            // counts the distinct related records, not a root-record field. The
320            // alias comes from the `where`/`related` traversal specs.
321            if let Some(related) = count_related(
322                lmpg,
323                &snap,
324                lmpg.tenant,
325                ord,
326                &alias_specs,
327                field,
328                alias_budget,
329            )? {
330                aggregate_budget::check(alias_budget, related.len().saturating_mul(64))?;
331                for _other in related {
332                    bump_count(bucket, &target_key);
333                }
334                continue;
335            }
336            if resolve_field(&obj, field).is_some() {
337                bump_count(bucket, &target_key);
338            }
339        }
340        for (out, field) in &req.sums {
341            // alias metrics: `$sum: "$employee.salary"` aggregates over the
342            // root's related records via the `where` traversal alias.
343            if let Some(values) = alias_field_values(
344                lmpg,
345                &snap,
346                lmpg.tenant,
347                ord,
348                &alias_specs,
349                field,
350                alias_budget,
351            )? {
352                let key = out.clone();
353                let cur = bucket
354                    .sums
355                    .get(&key)
356                    .and_then(serde_json::Value::as_f64)
357                    .unwrap_or(0.0);
358                let sum = values
359                    .iter()
360                    .try_fold(cur, |sum, value| -> Result<f64, QueryError> {
361                        if !value.is_number() {
362                            return Ok(sum);
363                        }
364                        let value = exact_f64(value).ok_or_else(|| {
365                            QueryError::InvalidPredicate(
366                                "aggregate integer is not exactly representable as f64".into(),
367                            )
368                        })?;
369                        Ok(sum + value)
370                    })?;
371                bucket.sums.insert(key, serde_json::json!(sum));
372            } else if let Some(Json::Number(n)) = resolve_field_numeric(&obj, field) {
373                let key = out.clone();
374                let cur = bucket
375                    .sums
376                    .get(&key)
377                    .and_then(serde_json::Value::as_f64)
378                    .unwrap_or(0.0);
379                let next = cur
380                    + exact_f64(&Json::Number(n.clone())).ok_or_else(|| {
381                        QueryError::InvalidPredicate(
382                            "aggregate integer is not exactly representable as f64".into(),
383                        )
384                    })?;
385                bucket.sums.insert(key, serde_json::json!(next));
386            }
387        }
388        for (out, expr) in &req.expr_sums {
389            if let Some(v) = eval_row_expr(expr, &obj) {
390                let key = out.clone();
391                let cur = bucket
392                    .sums
393                    .get(&key)
394                    .and_then(serde_json::Value::as_f64)
395                    .unwrap_or(0.0);
396                bucket.sums.insert(key, serde_json::json!(cur + v));
397            }
398        }
399        for (out, expr) in &req.expr_avgs {
400            if let Some(v) = eval_row_expr(expr, &obj) {
401                let key = out.clone();
402                let cur = bucket
403                    .sums
404                    .get(&key)
405                    .and_then(serde_json::Value::as_f64)
406                    .unwrap_or(0.0);
407                bucket.sums.insert(key, serde_json::json!(cur + v));
408                bump_avg_count(bucket, format!("__avg_n_{out}"), 1);
409            }
410        }
411        for (out, field) in &req.avg_terms {
412            if let Some(values) = alias_field_values(
413                lmpg,
414                &snap,
415                lmpg.tenant,
416                ord,
417                &alias_specs,
418                field,
419                alias_budget,
420            )? {
421                let key = out.clone();
422                let sum = values
423                    .iter()
424                    .try_fold(0.0, |sum, value| -> Result<f64, QueryError> {
425                        if !value.is_number() {
426                            return Ok(sum);
427                        }
428                        let value = exact_f64(value).ok_or_else(|| {
429                            QueryError::InvalidPredicate(
430                                "aggregate integer is not exactly representable as f64".into(),
431                            )
432                        })?;
433                        Ok(sum + value)
434                    })?;
435                let cur = bucket
436                    .sums
437                    .get(&key)
438                    .and_then(serde_json::Value::as_f64)
439                    .unwrap_or(0.0);
440                bucket.sums.insert(key, serde_json::json!(cur + sum));
441                let added = values.iter().filter(|v| v.is_number()).count() as u64;
442                bump_avg_count(bucket, format!("__avg_n_{out}"), added);
443            } else if let Some(Json::Number(n)) = resolve_field_numeric(&obj, field) {
444                let key = out.clone();
445                let cur = bucket
446                    .sums
447                    .get(&key)
448                    .and_then(serde_json::Value::as_f64)
449                    .unwrap_or(0.0);
450                let value = exact_f64(&Json::Number(n.clone())).ok_or_else(|| {
451                    QueryError::InvalidPredicate(
452                        "aggregate integer is not exactly representable as f64".into(),
453                    )
454                })?;
455                bucket.sums.insert(key, serde_json::json!(cur + value));
456                bump_avg_count(bucket, format!("__avg_n_{out}"), 1);
457            }
458        }
459        for (out, field) in &req.mins {
460            if let Some(values) = alias_field_values(
461                lmpg,
462                &snap,
463                lmpg.tenant,
464                ord,
465                &alias_specs,
466                field,
467                alias_budget,
468            )? {
469                for v in values {
470                    minmax(bucket, out.clone(), &v, true);
471                }
472            } else if let Some(v) = resolve_field(&obj, field) {
473                minmax(bucket, out.clone(), &v, true);
474            }
475        }
476        for (out, field) in &req.maxs {
477            if let Some(values) = alias_field_values(
478                lmpg,
479                &snap,
480                lmpg.tenant,
481                ord,
482                &alias_specs,
483                field,
484                alias_budget,
485            )? {
486                for v in values {
487                    minmax(bucket, out.clone(), &v, false);
488                }
489            } else if let Some(v) = resolve_field(&obj, field) {
490                minmax(bucket, out.clone(), &v, false);
491            }
492        }
493        retained_bytes = retained_bytes
494            .saturating_sub(previous_bucket_bytes)
495            .saturating_add(aggregate_budget::bucket_bytes(bucket));
496        aggregate_budget::check(budget, retained_bytes)?;
497    }
498
499    // Derived outputs ($ref chains): source sums materialize first, then
500    // refs resolve in declaration order; cycles surface as unresolved refs.
501    // The candidate ordinals are no longer needed; what stays is the buckets
502    // plus one derived value per output and bucket.
503    if !req.outputs.is_empty() || !req.avg_terms.is_empty() || !req.expr_avgs.is_empty() {
504        let output_bytes = req
505            .outputs
506            .iter()
507            .map(|(name, _)| name)
508            .chain(req.avg_terms.iter().map(|(name, _)| name))
509            .chain(req.expr_avgs.iter().map(|(name, _)| name))
510            .fold(0usize, |sum, name| {
511                sum.saturating_add(name.len()).saturating_add(256)
512            })
513            .saturating_mul(buckets.len());
514        aggregate_budget::check(
515            budget,
516            retained_bytes
517                .saturating_sub(candidate_bytes)
518                .saturating_add(output_bytes),
519        )?;
520        for b in &mut buckets {
521            if is_cancelled() {
522                return Err(QueryError::Cancelled);
523            }
524            let mut resolved: std::collections::BTreeMap<String, f64> = b
525                .sums
526                .clone()
527                .into_iter()
528                .filter_map(|(k, v)| v.as_f64().map(|x| (k, x)))
529                .collect();
530            resolved.insert("count_roots".into(), b.count_roots as f64);
531            // Count outputs are also valid `$ref` targets in derived math
532            // (`$count: "*"` → the root count; other counts → occurrence
533            // count, which equals the distinct related-record count because
534            // the traversal already deduplicates).
535            for (out, field) in &req.counts {
536                if req.hidden_outputs.contains(out) {
537                    continue;
538                }
539                let value = if field.as_str() == "*" {
540                    b.count_roots as f64
541                } else {
542                    b.counts
543                        .get(out)
544                        .and_then(serde_json::Value::as_u64)
545                        .map_or(0.0, |n| n as f64)
546                };
547                resolved.insert(out.clone(), value);
548            }
549            // avg aliases: value = sum / non-null count, precision applied
550            for name in req
551                .avg_terms
552                .iter()
553                .map(|(n, _)| n)
554                .chain(req.expr_avgs.iter().map(|(n, _)| n))
555            {
556                let sum = resolved.get(name).copied().unwrap_or(0.0);
557                let n = b
558                    .counts
559                    .get(&format!("__avg_n_{name}"))
560                    .and_then(serde_json::Value::as_u64)
561                    .unwrap_or(0);
562                let avg = if n > 0 { sum / n as f64 } else { 0.0 };
563                let avg = match req.avg_precision.get(name) {
564                    // reference `toInteger(avg(...))`: truncate toward zero
565                    Some(&0) => avg.trunc(),
566                    Some(&p) => {
567                        // Defensive: the parser clamps to `MAX_AVG_PRECISION`,
568                        // but a directly built request must not overflow.
569                        let p = p.min(crate::MAX_AVG_PRECISION);
570                        let m = 10f64.powi(i32::try_from(p).unwrap_or(0));
571                        (avg * m).round() / m
572                    }
573                    None => avg,
574                };
575                resolved.insert(name.clone(), avg);
576                b.sums.insert(name.clone(), serde_json::json!(avg));
577            }
578            for (name, expr) in &req.outputs {
579                eval_output(&normalize(name), expr, &mut resolved)?;
580            }
581            for (k, v) in &resolved {
582                b.derived_outputs.insert(k.clone(), serde_json::json!(v));
583                // mirror derived outputs into sums so orderBy sees them
584                b.sums
585                    .entry(k.clone())
586                    .or_insert_with(|| serde_json::json!(v));
587            }
588        }
589    }
590
591    Ok((order_and_page(buckets, req), total_matched))
592}
593
594/// Aggregate, then paginate: complete buckets first, then deterministic
595/// ordering by output name and a single explicit slice domain.
596fn order_and_page(mut buckets: Vec<GroupBucket>, req: &AggregateRequest) -> Vec<GroupBucket> {
597    for (name, desc) in req.order_by.iter().rev() {
598        buckets.sort_by(|a, b| {
599            let va = bucket_metric(a, name, req);
600            let vb = bucket_metric(b, name, req);
601            let ord = compare_json_metric(va.as_ref(), vb.as_ref());
602            if *desc {
603                ord.reverse()
604            } else {
605                ord
606            }
607        });
608    }
609    if buckets.is_empty() && is_global_rollup(req) {
610        // As in SQL, a rollup over no matches is one row: counts are 0.
611        buckets.push(GroupBucket::default());
612    }
613    apply_aggregate_pagination(&mut buckets, req);
614    buckets
615}
616
617/// A metric with no value over a global rollup's matches is `null`, as in
618/// SQL.
619fn fill_absent_metrics(row: &mut Map<String, Json>, req: &AggregateRequest) {
620    if !is_global_rollup(req) {
621        return;
622    }
623    let metrics = req
624        .sums
625        .iter()
626        .chain(&req.avg_terms)
627        .map(|(out, _)| out)
628        .chain(req.expr_avgs.iter().map(|(out, _)| out))
629        .chain(req.mins.iter().map(|(out, _)| out))
630        .chain(req.maxs.iter().map(|(out, _)| out))
631        .chain(req.outputs.iter().map(|(out, _)| out));
632    for out in metrics {
633        if !req.hidden_outputs.contains(out) {
634            row.entry(out.clone()).or_insert(Json::Null);
635        }
636    }
637}
638
639/// True when metrics roll up into one global row: some metric, and no
640/// grouping, time buckets, per-root fields or collects. Such a rollup always
641/// has its row.
642fn is_global_rollup(req: &AggregateRequest) -> bool {
643    let metrics = !req.counts.is_empty()
644        || !req.sums.is_empty()
645        || !req.mins.is_empty()
646        || !req.maxs.is_empty()
647        || !req.outputs.is_empty()
648        || !req.expr_sums.is_empty()
649        || !req.expr_avgs.is_empty()
650        || !req.avg_terms.is_empty();
651    metrics
652        && req.group_by.is_empty()
653        && req.time_bucket.is_none()
654        && req.plain_fields.is_empty()
655        && req.collects.is_empty()
656}
657
658/// True when an aggregate is exactly a global `$count: "*"` (no grouping, no
659/// other metrics, no per-root projection/collect). Such a request needs only
660/// the match count, never per-record reconstruction.
661fn is_count_only(req: &AggregateRequest) -> bool {
662    req.group_by.is_empty()
663        && req.plain_fields.is_empty()
664        && req.collects.is_empty()
665        && req.sums.is_empty()
666        && req.mins.is_empty()
667        && req.maxs.is_empty()
668        && req.outputs.is_empty()
669        && req.expr_sums.is_empty()
670        && req.expr_avgs.is_empty()
671        && req.avg_terms.is_empty()
672        && req.time_bucket.is_none()
673        && !req.counts.is_empty()
674        && req.counts.iter().all(|(_, field)| field == "*")
675}
676
677/// One row's worth of `count_roots` (zero matches is a row of 0, as the
678/// general rollup gives).
679fn count_only_bucket(total: u64) -> Vec<GroupBucket> {
680    vec![GroupBucket {
681        count_roots: total,
682        ..GroupBucket::default()
683    }]
684}
685
686fn apply_aggregate_pagination(buckets: &mut Vec<GroupBucket>, req: &AggregateRequest) {
687    if req.skip > 0 || req.limit.is_some() {
688        let start = usize::try_from(req.skip)
689            .unwrap_or(usize::MAX)
690            .min(buckets.len());
691        *buckets = buckets[start..]
692            .iter()
693            .take(usize::try_from(req.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX))
694            .cloned()
695            .collect();
696    }
697}
698
699/// O(labels) count for a single unscoped label from the maintained catalog.
700/// Returns `None` when the shape needs the ordinal matcher (predicates,
701/// traversal, multiple labels, or a label absent from the catalog).
702fn catalog_count_total(
703    lmpg: &Lmpg,
704    req: &SearchRequest,
705    snap: &QuerySnapshot,
706) -> Result<Option<u64>, QueryError> {
707    if req.where_.is_some() || !req.related.is_empty() || !req.order_by.is_empty() {
708        return Ok(None);
709    }
710    if req.labels.len() != 1 {
711        return Ok(None);
712    }
713    // The on-disk label counter is exact and snapshot-consistent (DC1/DC12).
714    Ok(Some(rushdb_lmpg::derived::label_count(
715        snap,
716        lmpg.tenant,
717        &req.labels[0],
718    )?))
719}
720
721/// Shape aggregate buckets into flat result rows: group-by dimensions (incl.
722/// timeBucket aliases), count/sum outputs, and derived outputs keyed by their
723/// output name.
724///
725/// # Errors
726///
727/// Propagates storage/engine read errors when opening the snapshot or
728/// attaching per-root `$collect` shapes.
729pub fn aggregate_to_rows(
730    lmpg: &Lmpg,
731    buckets: &[GroupBucket],
732    req: &AggregateRequest,
733) -> Result<Vec<Json>, QueryError> {
734    let snap: QuerySnapshot = Arc::new(lmpg.store.read_view()?);
735    aggregate_to_rows_in_view(lmpg, &snap, buckets, req)
736}
737
738/// Shape aggregate buckets using a caller-owned snapshot.
739///
740/// Keeping the view supplied by the caller prevents `$collect` reconstruction
741/// from observing a later commit than the aggregate scan itself.
742///
743/// # Errors
744///
745/// Propagates storage/engine read errors while attaching per-root collects.
746pub fn aggregate_to_rows_in_view(
747    lmpg: &Lmpg,
748    snap: &QuerySnapshot,
749    buckets: &[GroupBucket],
750    req: &AggregateRequest,
751) -> Result<Vec<Json>, QueryError> {
752    aggregate_to_rows_in_view_with_budget(lmpg, snap, buckets, req, None)
753}
754
755/// Shape aggregate output with bounded working and retained rows.
756///
757/// # Errors
758/// Returns storage or resource errors while reconstructing collects.
759pub fn aggregate_to_rows_in_view_with_budget(
760    lmpg: &Lmpg,
761    snap: &QuerySnapshot,
762    buckets: &[GroupBucket],
763    req: &AggregateRequest,
764    budget: Option<usize>,
765) -> Result<Vec<Json>, QueryError> {
766    let configured = lmpg
767        .limits()
768        .query_memory_max_bytes()
769        .map(|bytes| usize::try_from(bytes).unwrap_or(usize::MAX));
770    let budget = match (budget, configured) {
771        (Some(a), Some(b)) => Some(a.min(b)),
772        (a, b) => a.or(b),
773    };
774    let snap = budget.map_or_else(
775        || Arc::clone(snap),
776        |bytes| snap.with_materialization_limit(bytes / 16),
777    );
778    let mut retained = buckets.iter().fold(0usize, |sum, bucket| {
779        sum.saturating_add(aggregate_budget::bucket_bytes(bucket))
780    });
781    aggregate_budget::check(budget, retained)?;
782    let mut rows = Vec::new();
783    for b in buckets {
784        aggregate_budget::check(
785            budget,
786            retained.saturating_add(aggregate_budget::bucket_bytes(b)),
787        )?;
788        let root_ordinal = b
789            .keys
790            .get("__rushdb_projection_root")
791            .and_then(serde_json::Value::as_u64);
792        let mut row = Map::new();
793        for (k, v) in &b.keys {
794            if k != "__rushdb_projection_root" {
795                row.insert(k.clone(), v.clone());
796            }
797        }
798        for (out, field) in &req.counts {
799            if field == "*" {
800                row.insert(out.clone(), Json::Number((b.count_roots).into()));
801            } else {
802                let n = map_metric(&b.counts, out).and_then(serde_json::Value::as_u64);
803                row.insert(out.clone(), Json::Number(n.unwrap_or(0).into()));
804            }
805        }
806        for (out, _field) in &req.sums {
807            if req.hidden_outputs.contains(out) {
808                continue;
809            }
810            if let Some(v) = b.sums.get(out) {
811                row.insert(out.clone(), v.clone());
812            }
813        }
814        for out in req
815            .avg_terms
816            .iter()
817            .map(|(out, _)| out)
818            .chain(req.expr_avgs.iter().map(|(out, _)| out))
819        {
820            if !req.hidden_outputs.contains(out) {
821                if let Some(v) = b.sums.get(out) {
822                    row.insert(out.clone(), v.clone());
823                }
824            }
825        }
826        for (out, _) in &req.mins {
827            if !req.hidden_outputs.contains(out) {
828                if let Some(v) = b.mins.get(out) {
829                    row.insert(out.clone(), v.clone());
830                }
831            }
832        }
833        for (out, _) in &req.maxs {
834            if !req.hidden_outputs.contains(out) {
835                if let Some(v) = b.maxs.get(out) {
836                    row.insert(out.clone(), v.clone());
837                }
838            }
839        }
840        for (out, _) in &req.outputs {
841            if let Some(v) = b.derived_outputs.get(&normalize(out)) {
842                row.insert(out.clone(), v.clone());
843            }
844        }
845        fill_absent_metrics(&mut row, req);
846        // Attach per-root `$collect` shapes (docs' "aggregation with select"):
847        // the row keeps its aggregate metrics and gains the collected arrays.
848        let mut row_json = Json::Object(row);
849        if !req.collects.is_empty() {
850            if let Some(ord) = root_ordinal {
851                row_json = attach_collects_with_budget(
852                    lmpg,
853                    &snap,
854                    ord,
855                    row_json,
856                    &req.collects,
857                    budget.map(|limit| limit.saturating_sub(retained)),
858                    &|| false,
859                )
860                .map_err(aggregate_budget::resource_error)?;
861            }
862        }
863        retained = retained.saturating_add(aggregate_budget::value_bytes(&row_json));
864        aggregate_budget::check(budget, retained)?;
865        rows.push(row_json);
866    }
867    Ok(rows)
868}
869
870/// The record properties an aggregate reads per row, as the request spells
871/// them (without the `$record.` prefix): group keys, the time-bucket field,
872/// counted, summed, averaged and min/max fields, plain projections and the
873/// fields of per-row expressions. Alias metrics (`$sum: "$employee.salary"`)
874/// read related records through their traversal, not the row, so their
875/// names resolve to nothing here.
876fn referenced_fields(req: &AggregateRequest) -> BTreeSet<String> {
877    let mut names = BTreeSet::new();
878    let mut add = |field: &str| {
879        names.insert(field.strip_prefix("$record.").unwrap_or(field).to_owned());
880    };
881    for g in &req.group_by {
882        if req.time_bucket_alias.as_deref() != Some(g.as_str()) {
883            add(g);
884        }
885    }
886    if let Some(tb) = &req.time_bucket {
887        add(&tb.field);
888    }
889    for (_, field) in req
890        .counts
891        .iter()
892        .filter(|(_, field)| field != "*")
893        .chain(&req.sums)
894        .chain(&req.mins)
895        .chain(&req.maxs)
896        .chain(&req.avg_terms)
897        .chain(&req.plain_fields)
898    {
899        add(field);
900    }
901    for (_, expr) in req.expr_sums.iter().chain(&req.expr_avgs) {
902        expr_fields(expr, &mut add);
903    }
904    names
905}
906
907fn expr_fields(expr: &OutputExpr, add: &mut impl FnMut(&str)) {
908    match expr {
909        OutputExpr::Field(name) | OutputExpr::Ref(name) => add(name),
910        OutputExpr::Add(a, b) | OutputExpr::Subtract(a, b) | OutputExpr::Multiply(a, b) => {
911            expr_fields(a, add);
912            expr_fields(b, add);
913        }
914        OutputExpr::Divide {
915            dividend, divisor, ..
916        } => {
917            expr_fields(dividend, add);
918            expr_fields(divisor, add);
919        }
920        OutputExpr::Literal(_) => {}
921    }
922}
923
924fn new_bucket(buckets: &mut Vec<GroupBucket>, keys: Map<String, Json>) -> &mut GroupBucket {
925    buckets.push(GroupBucket {
926        keys,
927        count_roots: 0,
928        sums: Map::new(),
929        counts: Map::new(),
930        mins: Map::new(),
931        maxs: Map::new(),
932        derived_outputs: std::collections::BTreeMap::new(),
933    });
934    let last = buckets.len() - 1;
935    &mut buckets[last]
936}
937
938/// Increment an `$count` occurrence counter. Stored as a numeric counter rather
939/// than one array entry per counted value, so a high-cardinality count does not
940/// retain O(values) memory. A `$count` over a traversal alias counts distinct
941/// related records because the traversal already deduplicates its result.
942fn bump_count(bucket: &mut GroupBucket, key: &str) {
943    let entry = bucket
944        .counts
945        .entry(key.to_string())
946        .or_insert_with(|| Json::Number(0.into()));
947    let next = entry.as_u64().unwrap_or(0).saturating_add(1);
948    *entry = Json::Number(next.into());
949}
950
951/// Increment an `$avg` non-null count. Stored as a numeric counter rather than
952/// one array entry per value, so a high-cardinality average does not retain
953/// O(values) memory.
954fn bump_avg_count(bucket: &mut GroupBucket, key: String, delta: u64) {
955    let entry = bucket
956        .counts
957        .entry(key)
958        .or_insert_with(|| Json::Number(0.into()));
959    if let Json::Number(n) = entry {
960        *n = (n.as_u64().unwrap_or(0) + delta).into();
961    }
962}
963
964pub(super) fn minmax(bucket: &mut GroupBucket, key: String, v: &Json, is_min: bool) {
965    let entry = if is_min {
966        &mut bucket.mins
967    } else {
968        &mut bucket.maxs
969    };
970    let entry = entry
971        .entry(key)
972        .or_insert_with(|| aggregate_numeric_value(v));
973    let Some((_, next)) = infer_scalar(v) else {
974        return;
975    };
976    let Some((_, current)) = infer_scalar(entry) else {
977        return;
978    };
979    if !matches!(next, Scalar::Int64(_) | Scalar::Float64(_))
980        || !matches!(current, Scalar::Int64(_) | Scalar::Float64(_))
981    {
982        return;
983    }
984    let Some(ordering) = canonical_scalar_cmp(&next, &current) else {
985        return;
986    };
987    let replace = if is_min {
988        ordering == std::cmp::Ordering::Less
989    } else {
990        ordering == std::cmp::Ordering::Greater
991    };
992    if replace {
993        *entry = aggregate_numeric_value(v);
994    }
995}
996
997#[expect(
998    clippy::cast_precision_loss,
999    clippy::cast_possible_truncation,
1000    reason = "the paired integer round-trip check preserves exact large integers"
1001)]
1002fn aggregate_numeric_value(value: &Json) -> Json {
1003    let Some((_, scalar)) = infer_scalar(value) else {
1004        return value.clone();
1005    };
1006    match scalar {
1007        Scalar::Int64(integer) => {
1008            let float = integer as f64;
1009            if (float as i128) == i128::from(integer) {
1010                serde_json::Number::from_f64(float).map_or_else(|| value.clone(), Json::Number)
1011            } else {
1012                value.clone()
1013            }
1014        }
1015        _ => value.clone(),
1016    }
1017}
1018
1019fn map_metric<'a>(map: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
1020    let normalized = normalize(name);
1021    map.iter()
1022        .find(|(key, _)| normalize(key) == normalized)
1023        .map(|(_, value)| value)
1024}
1025
1026fn bucket_metric(b: &GroupBucket, name: &str, req: &AggregateRequest) -> Option<Json> {
1027    if normalize(name) == "count_roots" {
1028        return Some(Json::Number(b.count_roots.into()));
1029    }
1030    if let Some((output, field)) = req
1031        .counts
1032        .iter()
1033        .find(|(output, _)| normalize(output) == normalize(name))
1034    {
1035        if field == "*" {
1036            return Some(Json::Number(b.count_roots.into()));
1037        }
1038        return map_metric(&b.counts, output)
1039            .map(|values| Json::Number(values.as_u64().unwrap_or(0).into()));
1040    }
1041    map_metric(&b.sums, name)
1042        .or_else(|| map_metric(&b.mins, name))
1043        .or_else(|| map_metric(&b.maxs, name))
1044        .or_else(|| map_metric(&b.keys, name))
1045        .cloned()
1046}
1047
1048fn compare_json_metric(a: Option<&Json>, b: Option<&Json>) -> std::cmp::Ordering {
1049    match (a, b) {
1050        (None, None) => std::cmp::Ordering::Equal,
1051        (None, _) => std::cmp::Ordering::Less,
1052        (_, None) => std::cmp::Ordering::Greater,
1053        (Some(x), Some(y)) => json_value_cmp(x, y),
1054    }
1055}
1056
1057/// Evaluate an `OutputExpr` against a single reconstructed record object.
1058pub(super) fn eval_row_expr(expr: &OutputExpr, obj: &Map<String, Json>) -> Option<f64> {
1059    match expr {
1060        OutputExpr::Literal(l) => Some(*l),
1061        OutputExpr::Field(field) => resolve_field(obj, field).and_then(|value| exact_f64(&value)),
1062        OutputExpr::Ref(name) => obj.get(&normalize(name)).and_then(exact_f64),
1063        OutputExpr::Add(a, b) => Some(eval_row_expr(a, obj)? + eval_row_expr(b, obj)?),
1064        OutputExpr::Subtract(a, b) => Some(eval_row_expr(a, obj)? - eval_row_expr(b, obj)?),
1065        OutputExpr::Multiply(a, b) => Some(eval_row_expr(a, obj)? * eval_row_expr(b, obj)?),
1066        OutputExpr::Divide {
1067            dividend, divisor, ..
1068        } => {
1069            let d = eval_row_expr(dividend, obj)?;
1070            let q = eval_row_expr(divisor, obj)?;
1071            if q == 0.0 {
1072                None
1073            } else {
1074                Some(d / q)
1075            }
1076        }
1077    }
1078}
1079
1080/// Convert a JSON number to the aggregate expression representation without
1081/// silently rounding an integer that cannot be represented by `f64`.
1082#[expect(
1083    clippy::cast_precision_loss,
1084    clippy::cast_possible_truncation,
1085    reason = "the integer round-trip check rejects lossy widening"
1086)]
1087pub(super) fn exact_f64(value: &Json) -> Option<f64> {
1088    let number = value.as_number()?;
1089    if let Some(integer) = number.as_i64() {
1090        let float = integer as f64;
1091        return ((float as i128) == i128::from(integer)).then_some(float);
1092    }
1093    if let Some(integer) = number.as_u64() {
1094        let float = integer as f64;
1095        return ((float as i128) == i128::from(integer)).then_some(float);
1096    }
1097    number.as_f64()
1098}
1099
1100fn eval_output(
1101    name: &str,
1102    expr: &OutputExpr,
1103    results: &mut std::collections::BTreeMap<String, f64>,
1104) -> Result<f64, QueryError> {
1105    let v = match expr {
1106        OutputExpr::Literal(l) => *l,
1107        OutputExpr::Field(_) => {
1108            // Field refs are evaluated per-row in the aggregate loop; at the
1109            // bucket stage they carry no value.
1110            return Ok(0.0);
1111        }
1112        OutputExpr::Ref(other) => results
1113            .get(other)
1114            .copied()
1115            .ok_or_else(|| QueryError::InvalidPredicate(format!("unresolved ref `{other}`")))?,
1116        OutputExpr::Add(a, b) => eval_output(name, a, results)? + eval_output(name, b, results)?,
1117        OutputExpr::Subtract(a, b) => {
1118            eval_output(name, a, results)? - eval_output(name, b, results)?
1119        }
1120        OutputExpr::Multiply(a, b) => {
1121            eval_output(name, a, results)? * eval_output(name, b, results)?
1122        }
1123        OutputExpr::Divide {
1124            dividend, divisor, ..
1125        } => {
1126            let d = eval_output(name, dividend, results)?;
1127            let q = eval_output(name, divisor, results)?;
1128            if q == 0.0 {
1129                return Err(QueryError::InvalidPredicate("divide by zero".into()));
1130            }
1131            d / q
1132        }
1133    };
1134    results.insert(name.to_string(), v);
1135    Ok(v)
1136}