1use 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
11pub fn aggregate(lmpg: &Lmpg, req: &AggregateRequest) -> Result<Vec<GroupBucket>, QueryError> {
20 aggregate_with_cancel(lmpg, req, &|| false).map(|(buckets, _)| buckets)
21}
22
23pub const MAX_AGGREGATE_BUCKETS: usize = 1_000;
26
27pub 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
52pub 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
66pub 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
79pub 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 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 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 let per_root =
184 req.group_by.is_empty() && (!req.plain_fields.is_empty() || !req.collects.is_empty());
185 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 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 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 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 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 if req.time_bucket_alias.as_deref() == Some(g.as_str()) {
265 continue;
266 }
267 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 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 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 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 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 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 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 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 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 Some(&0) => avg.trunc(),
562 Some(&p) => {
563 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 b.sums
581 .entry(k.clone())
582 .or_insert_with(|| serde_json::json!(v));
583 }
584 }
585 }
586
587 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 buckets.push(GroupBucket::default());
604 }
605 apply_aggregate_pagination(&mut buckets, req);
606 Ok((buckets, total_matched))
607}
608
609fn 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
631fn 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
650fn 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
669fn 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
691fn 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 Ok(Some(rushdb_lmpg::derived::label_count(
707 snap,
708 lmpg.tenant,
709 &req.labels[0],
710 )?))
711}
712
713pub 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
730pub 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
747pub 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 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
883fn 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
896fn 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, ¤t) 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
1002pub(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#[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 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}