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