spg_engine/aggregate.rs
1//! Aggregate executor.
2//!
3//! Handles `SELECT … <aggs> … [GROUP BY …]` queries. The planning strategy
4//! is straightforward:
5//!
6//! 1. Walk the SELECT (and ORDER BY) expressions to find every aggregate
7//! function call. Dedupe by AST equality and assign each `__agg_<i>`.
8//! 2. Same for every `GROUP BY` expression: assign `__grp_<j>`.
9//! 3. Stream the WHERE-filtered rows, group by the tuple of GROUP BY
10//! values, and update per-group aggregate state.
11//! 4. Materialise a synthetic per-group row containing
12//! `[__grp_0..__grp_K, __agg_0..__agg_N]` and rewrite the user's
13//! SELECT / ORDER BY expressions to reference those synthetic columns
14//! instead of the originals.
15//! 5. Evaluate the rewritten expressions against the synthetic schema and
16//! emit results.
17//!
18//! v1.8 implements `count(*)`, `count(expr)`, `sum`, `min`, `max`, `avg`.
19//! NULL semantics follow PG: aggregates skip NULL inputs (except
20//! `count(*)`, which counts rows). `sum(int)` widens to `BigInt`;
21//! `avg(int|bigint)` returns `Float`.
22
23use alloc::borrow::Cow;
24use alloc::boxed::Box;
25use alloc::collections::BTreeSet;
26use alloc::format;
27use alloc::string::{String, ToString};
28use alloc::vec::Vec;
29
30use spg_sql::ast::{Expr, SelectItem, SelectStatement};
31use spg_storage::{ColumnSchema, DataType, Row, Value};
32
33use crate::eval::{self, EvalContext, EvalError};
34use crate::join::AggRows;
35
36impl crate::Engine {
37 /// v7.39 (round 763, F31-C1) — expand a `*` / `alias.*` SELECT item
38 /// into explicit column refs when the statement takes the aggregate
39 /// path and the FROM is one plain catalog table. Returns `None`
40 /// when nothing applies (the caller keeps the original statement).
41 /// Joined / derived / SRF sources keep the old refusal for now.
42 pub(crate) fn expand_aggregate_wildcard(
43 &self,
44 stmt: &SelectStatement,
45 ) -> Option<SelectStatement> {
46 use spg_sql::ast::SelectItem;
47 if !stmt
48 .items
49 .iter()
50 .any(|i| matches!(i, SelectItem::Wildcard | SelectItem::QualifiedWildcard(_)))
51 {
52 return None;
53 }
54 if !uses_aggregate(stmt) {
55 return None;
56 }
57 let from = stmt.from.as_ref()?;
58 if !from.joins.is_empty()
59 || from.primary.unnest_expr.is_some()
60 || from.primary.lateral_subquery.is_some()
61 || from.primary.generate_series_args.is_some()
62 || from.primary.table_fn_call.is_some()
63 || from.primary.json_table.is_some()
64 || from.primary.jsonb_each_text_arg.is_some()
65 {
66 return None;
67 }
68 let table = self.active_catalog().get(&from.primary.name)?;
69 let alias = from
70 .primary
71 .alias
72 .clone()
73 .unwrap_or_else(|| from.primary.name.clone());
74 let mut items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
75 for item in &stmt.items {
76 match item {
77 SelectItem::Wildcard => {
78 for c in &table.schema().columns {
79 items.push(SelectItem::Expr {
80 expr: Expr::Column(spg_sql::ast::ColumnName {
81 qualifier: None,
82 name: c.name.clone(),
83 }),
84 alias: None,
85 });
86 }
87 }
88 SelectItem::QualifiedWildcard(q) => {
89 if !q.eq_ignore_ascii_case(&alias) {
90 return None; // unknown qualifier — keep the old path
91 }
92 // Bare names: the single-table qualifier is
93 // redundant, and the group-expr matcher unifies
94 // bare-to-bare (a qualified ref would miss a bare
95 // GROUP BY id).
96 for c in &table.schema().columns {
97 items.push(SelectItem::Expr {
98 expr: Expr::Column(spg_sql::ast::ColumnName {
99 qualifier: None,
100 name: c.name.clone(),
101 }),
102 alias: None,
103 });
104 }
105 }
106 other => items.push(other.clone()),
107 }
108 }
109 let mut out = stmt.clone();
110 out.items = items;
111 Some(out)
112 }
113}
114
115/// True if this statement should go through the aggregate path.
116pub fn uses_aggregate(stmt: &SelectStatement) -> bool {
117 if stmt.group_by.is_some() || stmt.having.is_some() {
118 return true;
119 }
120 uses_aggregate_ignoring_group_by(stmt)
121}
122
123/// v7.38.13 — the same question with the GROUP BY / HAVING short-circuit
124/// removed: does an aggregate CALL appear anywhere? `baregroup` needs
125/// this to tell a grouped aggregate from a GROUP BY that is a DISTINCT.
126pub(crate) fn uses_aggregate_ignoring_group_by(stmt: &SelectStatement) -> bool {
127 for item in &stmt.items {
128 if let SelectItem::Expr { expr, .. } = item
129 && contains_aggregate(expr)
130 {
131 return true;
132 }
133 }
134 for o in &stmt.order_by {
135 if contains_aggregate(&o.expr) {
136 return true;
137 }
138 }
139 if let Some(h) = &stmt.having
140 && contains_aggregate(h)
141 {
142 return true;
143 }
144 false
145}
146
147pub fn contains_aggregate(e: &Expr) -> bool {
148 match e {
149 Expr::FunctionCall { name, args } => {
150 is_aggregate_name(name) || args.iter().any(contains_aggregate)
151 }
152 Expr::Collate { expr, .. } | Expr::NamedArg { expr, .. } => contains_aggregate(expr),
153 Expr::Variadic(expr) => contains_aggregate(expr),
154 Expr::AggregateOrdered { .. } => true,
155 Expr::Binary { lhs, rhs, .. } => contains_aggregate(lhs) || contains_aggregate(rhs),
156 Expr::Unary { expr, .. }
157 | Expr::Cast { expr, .. }
158 | Expr::IsNull { expr, .. }
159 | Expr::BoolTest { expr, .. }
160 | Expr::FieldAccess { base: expr, .. } => contains_aggregate(expr),
161 Expr::Like { expr, pattern, .. } => contains_aggregate(expr) || contains_aggregate(pattern),
162 Expr::Extract { source, .. } => contains_aggregate(source),
163 // v4.10 subqueries + v4.12 window functions / Literal /
164 // Column — all non-aggregate leaves from the regular
165 // aggregate planner's POV. Window-bearing projections are
166 // routed to exec_select_with_window before this runs.
167 Expr::ScalarSubquery(_)
168 | Expr::Exists { .. }
169 | Expr::InSubquery { .. }
170 | Expr::RowInSubquery { .. }
171 | Expr::RowCmpSubquery { .. }
172 | Expr::WindowFunction { .. }
173 | Expr::Literal(_)
174 | Expr::Placeholder(_)
175 | Expr::Column(_) => false,
176 // v7.10.10 — recurse into array constructor / subscript /
177 // ANY/ALL children. Aggregates inside `ARRAY[SUM(x)]` are
178 // valid PG and must be detected here.
179 Expr::Array(items) => items.iter().any(contains_aggregate),
180 Expr::ArraySubscript { target, index } => {
181 contains_aggregate(target) || contains_aggregate(index)
182 }
183 Expr::ArraySlice { target, lo, hi } => {
184 contains_aggregate(target)
185 || lo.as_deref().is_some_and(contains_aggregate)
186 || hi.as_deref().is_some_and(contains_aggregate)
187 }
188 Expr::AnyAll { expr, array, .. } => contains_aggregate(expr) || contains_aggregate(array),
189 Expr::InList { expr, list, .. } => {
190 contains_aggregate(expr) || list.iter().any(contains_aggregate)
191 }
192 // v7.13.0 — CASE WHEN … END. Recurse into operand,
193 // every (WHEN, THEN) pair, and the ELSE branch.
194 Expr::Case {
195 operand,
196 branches,
197 else_branch,
198 } => {
199 operand.as_deref().is_some_and(contains_aggregate)
200 || branches
201 .iter()
202 .any(|(w, t)| contains_aggregate(w) || contains_aggregate(t))
203 || else_branch.as_deref().is_some_and(contains_aggregate)
204 }
205 }
206}
207
208pub fn is_aggregate_name(name: &str) -> bool {
209 matches!(
210 name.to_ascii_lowercase().as_str(),
211 "count"
212 | "count_star"
213 | "sum"
214 | "min"
215 | "max"
216 | "avg"
217 // v7.17.0 — variadic / collection aggregates. ORM
218 // reports (Hibernate / Rails / Django) emit these in
219 // GROUP BY rollups; pre-7.17 SPG hit "unknown
220 // aggregate".
221 | "string_agg"
222 | "array_agg"
223 // PG 16+ — any_value: an arbitrary non-NULL value from
224 // the group (SPG: the first seen, deterministic for
225 // ordered input).
226 | "any_value"
227 // PG 14+ — range_agg: collect ranges into a multirange
228 // (insertion order, no coalescing — matches the
229 // multirange constructor contract).
230 | "range_agg"
231 // PG 14+ — range_intersect_agg: intersection fold.
232 | "range_intersect_agg"
233 // MySQL group_concat (string_agg with ',' default) +
234 // SQL/XML xmlagg (separator-less concatenation).
235 | "group_concat"
236 | "xmlagg"
237 // v7.17.0 — boolean aggregates. `every` is SQL-standard
238 // alias for `bool_and`.
239 | "bool_and"
240 | "bool_or"
241 | "every"
242 // v7.32 (round-29) — statistical aggregates (every BI /
243 // dashboard emits these in rollups).
244 | "stddev" | "stddev_samp" | "stddev_pop"
245 | "variance" | "var_samp" | "var_pop"
246 // v7.32 (round-29) — bitwise aggregates.
247 | "bit_and" | "bit_or" | "bit_xor"
248 // v7.32 (round-29) — ordered-set aggregates (used with
249 // `WITHIN GROUP (ORDER BY …)`).
250 | "percentile_cont" | "percentile_disc" | "mode"
251 // v7.32 (round-29) — hypothetical-set aggregates (also
252 // `WITHIN GROUP`): the rank the direct args WOULD have.
253 | "rank" | "dense_rank" | "percent_rank" | "cume_dist"
254 // v7.32 (round-29) — two-argument regression family.
255 | "covar_pop" | "covar_samp" | "corr"
256 | "regr_count" | "regr_avgx" | "regr_avgy" | "regr_slope"
257 | "regr_intercept" | "regr_r2" | "regr_sxx" | "regr_syy" | "regr_sxy"
258 // v7.32 (round-29) — JSON aggregates.
259 | "json_agg" | "jsonb_agg" | "json_object_agg" | "jsonb_object_agg"
260 | "json_agg_strict" | "jsonb_agg_strict"
261 | "json_object_agg_strict" | "jsonb_object_agg_strict"
262 | "json_object_agg_unique" | "jsonb_object_agg_unique"
263 | "json_object_agg_unique_strict" | "jsonb_object_agg_unique_strict"
264 // SQL:2016 standard spellings (PG 16+ accepts both).
265 | "json_arrayagg" | "json_objectagg"
266 )
267}
268
269/// v7.32 (round-29) — two-argument regression aggregates `f(Y, X)`.
270fn is_regression_name(name: &str) -> bool {
271 matches!(
272 name,
273 "covar_pop"
274 | "covar_samp"
275 | "corr"
276 | "regr_count"
277 | "regr_avgx"
278 | "regr_avgy"
279 | "regr_slope"
280 | "regr_intercept"
281 | "regr_r2"
282 | "regr_sxx"
283 | "regr_syy"
284 | "regr_sxy"
285 )
286}
287
288/// v7.32 (round-29) — aggregates that consume a second positional
289/// argument: `string_agg(v, sep)`, the regression family `f(Y, X)`, and
290/// `json_object_agg(key, value)`.
291fn agg_uses_second_arg(name: &str) -> bool {
292 // v7.39 (round 354, M12) — group_concat's SEPARATOR is lowered onto the
293 // same second argument string_agg takes; without this the separator was
294 // parsed and then dropped, so `SEPARATOR '|'` silently kept the default
295 // comma.
296 name == "group_concat"
297 || name == "string_agg"
298 || name.starts_with("json_object_agg")
299 || name.starts_with("jsonb_object_agg")
300 || name == "jsonb_object_agg"
301 || name == "json_objectagg"
302 || is_regression_name(name)
303}
304
305/// v7.32 (round-29) — ordered-set aggregates: the value to aggregate
306/// comes from the `WITHIN GROUP (ORDER BY …)` sort spec, and any
307/// in-parens arguments are *direct* arguments (the percentile fraction).
308/// `mode()` takes no direct argument.
309pub fn is_ordered_set_name(name: &str) -> bool {
310 // v7.32 — `eq_ignore_ascii_case` instead of `to_ascii_lowercase()`:
311 // these classifiers run in the aggregate row/group loop, where the
312 // old per-call `String` allocation showed up as ~16% of the inbox's
313 // aggregate path in a sampled profile (the names are constant).
314 ["percentile_cont", "percentile_disc", "mode"]
315 .iter()
316 .any(|k| name.eq_ignore_ascii_case(k))
317}
318
319/// v7.32 (round-29) — hypothetical-set aggregates: `rank(args) WITHIN
320/// GROUP (ORDER BY …)` and friends compute the rank the hypothetical
321/// row would have. Like ordered-set, the value stream comes from the
322/// sort spec and the in-parens args are direct (the hypothetical row).
323pub fn is_hypothetical_set_name(name: &str) -> bool {
324 ["rank", "dense_rank", "percent_rank", "cume_dist"]
325 .iter()
326 .any(|k| name.eq_ignore_ascii_case(k))
327}
328
329/// v7.32 (round-29) — every aggregate that takes its value stream from
330/// a `WITHIN GROUP (ORDER BY …)` clause (ordered-set + hypothetical-set).
331pub fn is_within_group_name(name: &str) -> bool {
332 is_ordered_set_name(name) || is_hypothetical_set_name(name)
333}
334
335/// v7.37.4 (R34) — pre-computed aggregate kind. Replaces per-row
336/// string matches in `update_state` with a single `match` on a
337/// `Copy` enum (compiles to a jump table). For the mailrs prod
338/// `/api/conversations` shape (14 aggregates × 100 k rows = 1.4 M
339/// inner-loop iterations) this is the dominant per-row cost.
340///
341/// Lowered from `AggSpec::name` at spec build time via
342/// [`classify_agg_name`]; populated by the three `AggSpec`
343/// construction sites (window+ORDER, plain, `first_ordered`
344/// `array_agg`).
345#[derive(Copy, Clone, Debug, PartialEq, Eq)]
346pub(crate) enum AggKind {
347 CountStar,
348 Count,
349 Sum,
350 Avg,
351 Min,
352 Max,
353 /// PG 16+ any_value — first non-NULL value seen.
354 AnyValue,
355 /// PG 14+ range_agg — collect ranges into a multirange.
356 RangeAgg,
357 /// PG 14+ range_intersect_agg — intersection fold over ranges.
358 RangeIntersectAgg,
359 StringAgg,
360 ArrayAgg,
361 BoolAnd,
362 BoolOr,
363 /// stddev / stddev_samp / stddev_pop / variance / var_samp / var_pop.
364 StddevFamily,
365 BitAnd,
366 BitOr,
367 BitXor,
368 /// ordered-set (`percentile_cont/disc`, `mode`) +
369 /// hypothetical-set (`rank`/`dense_rank`/etc.) aggregates that
370 /// share the WITHIN-GROUP collection path.
371 WithinGroup,
372 /// covar_samp / covar_pop / corr / regr_*.
373 Regression,
374 JsonAgg,
375 JsonObjectAgg,
376}
377
378/// v7.37.4 (R34) — name → kind, called once per spec at build time.
379/// Hot path (`update_state_kind`) only sees the enum; the canonical
380/// string still travels with the spec so `finalize` and errors can
381/// quote it.
382/// v7.39 (round 231) — the spelling `classify_agg_name` / `update_state` /
383/// `finalize` expect. PG's `every` is a standard-SQL alias for `bool_and`
384/// and every accumulator keys off the latter. The GROUP BY builder folded
385/// it at two of its own call sites; the window path (round 230) reached
386/// `classify_agg_name` without folding and hit its panic arm, so
387/// `every(x) OVER (…)` aborted the query. One entry point now, and
388/// `every_aggregate_name_classifies` keeps the two name lists in step.
389pub(crate) fn canonical_agg_name(name: &str) -> &str {
390 if name.eq_ignore_ascii_case("every") {
391 "bool_and"
392 } else {
393 name
394 }
395}
396
397pub(crate) fn classify_agg_name(name: &str) -> AggKind {
398 match name {
399 "count_star" => AggKind::CountStar,
400 "count" => AggKind::Count,
401 "sum" => AggKind::Sum,
402 "avg" => AggKind::Avg,
403 "min" => AggKind::Min,
404 "max" => AggKind::Max,
405 "any_value" => AggKind::AnyValue,
406 "range_agg" => AggKind::RangeAgg,
407 "range_intersect_agg" => AggKind::RangeIntersectAgg,
408 "string_agg" | "group_concat" | "xmlagg" => AggKind::StringAgg,
409 "array_agg" => AggKind::ArrayAgg,
410 "bool_and" => AggKind::BoolAnd,
411 "bool_or" => AggKind::BoolOr,
412 "stddev" | "stddev_samp" | "stddev_pop" | "variance" | "var_samp" | "var_pop" => {
413 AggKind::StddevFamily
414 }
415 "bit_and" => AggKind::BitAnd,
416 "bit_or" => AggKind::BitOr,
417 "bit_xor" => AggKind::BitXor,
418 "json_agg" | "jsonb_agg" | "json_arrayagg" | "json_agg_strict" | "jsonb_agg_strict" => {
419 AggKind::JsonAgg
420 }
421 "json_object_agg"
422 | "jsonb_object_agg"
423 | "json_objectagg"
424 | "json_object_agg_strict"
425 | "jsonb_object_agg_strict"
426 | "json_object_agg_unique"
427 | "jsonb_object_agg_unique"
428 | "json_object_agg_unique_strict"
429 | "jsonb_object_agg_unique_strict" => AggKind::JsonObjectAgg,
430 n if is_within_group_name(n) => AggKind::WithinGroup,
431 n if is_regression_name(n) => AggKind::Regression,
432 other => panic!("classify_agg_name: unknown aggregate {other}"),
433 }
434}
435
436/// Per-aggregate running state.
437///
438/// The four `use_*` flags are independent observations about which value
439/// shapes have flowed through this accumulator (a single `sum()` can see both
440/// numeric and float inputs), not a discriminant — collapsing them into one
441/// enum would change accumulation semantics, and a bitflags word would hide
442/// which gate each fast path reads.
443#[allow(clippy::struct_excessive_bools)]
444#[derive(Debug, Default, Clone)]
445pub(crate) struct AggState {
446 /// The shared sum/avg running state (see `NumAcc`).
447 num: NumAcc,
448 extreme: Option<Value<'static>>,
449 /// v7.17.0 — running collection for string_agg / array_agg.
450 /// Each entry is one row's contribution (NULL preserved as
451 /// `Value::Null`; string_agg's finalize step drops them, but
452 /// array_agg keeps them). Pushing in insertion order matches
453 /// PG behaviour when no `ORDER BY` is given inside the
454 /// aggregate call.
455 items: Vec<Value<'static>>,
456 /// v7.39 (round 762, F31-C2) — per-item separator, parallel to
457 /// `items`. PG evaluates string_agg's separator PER ROW: element
458 /// i is prefixed by ITS row's separator (`string_agg(v,
459 /// '<'||v||'>')` over a,b,c answers `a<b>b<c>c`; a NULL separator
460 /// renders empty; a skipped-NULL value row's separator is never
461 /// used). Populated only on the general path when the call has a
462 /// second argument; the fused lane is literal-separator only and
463 /// keeps the single `separator` snapshot below.
464 item_seps: Vec<Option<alloc::vec::Vec<u8>>>,
465 /// v7.25 (round-17) — per-group dedupe set for DISTINCT
466 /// aggregates (encoded values; NULLs never reach it because
467 /// the caller's skip runs after the per-aggregate NULL rules).
468 /// v7.37.4 measured `hashbrown::HashSet` as worse at this
469 /// shape — the per-(group × distinct-spec) hash table alloc
470 /// overhead beats the lookup-speed gain when each set is
471 /// small. Sticking with `BTreeSet`; the dispatch-side enum
472 /// fix in `update_state` is the R34 win.
473 seen: BTreeSet<String>,
474 /// v7.37.x (docker-fair DISTA attack) — fast-path BigInt seen
475 /// set. The hot DISTINCT path used `encode_key_refs_into` to
476 /// turn `Value::BigInt(n)` into a string key like `"I<n>|"` then
477 /// inserted that into the String BTreeSet — ~100 ns of pure alloc
478 /// + format churn per row × 25 k rows × 1 BigInt DISTINCT spec
479 /// (the DISTA `COUNT(DISTINCT m.id)` shape) ≈ 2.5 ms of waste.
480 /// Direct `BTreeSet<i64>` skips encode entirely; lookups stay
481 /// O(log small) on the per-group set. Lazy-allocated — only the
482 /// BigInt-DISTINCT path constructs it.
483 seen_int: Option<BTreeSet<i64>>,
484 /// v7.24 (round-16 A) — per-item ORDER BY key tuples, parallel
485 /// to `items` (pushed under the same skip/keep conditions).
486 /// Empty when the aggregate carries no internal ordering.
487 /// v7.39 (round 723) — FLAT (SoA): `order_by.len()` key values per
488 /// item, back to back. The per-item `Vec<Vec<Value>>` form allocated
489 /// one heap Vec PER ROW just to hold (usually) one integer — ~20 ms
490 /// of pure allocator traffic on the panel's 500k `string_agg(s, ','
491 /// ORDER BY id)`. The key width is the spec's `order_by.len()`,
492 /// which every consumer already has.
493 item_keys: Vec<Value<'static>>,
494 /// v7.17.0 — captured separator for string_agg: the last
495 /// non-NULL text seen. v7.39 (round 762, F31-C2) — this is the
496 /// CONSTANT-separator snapshot only (fused lane, group_concat
497 /// default, DISTINCT fallback); the per-row truth lives in
498 /// `item_seps` (the old note claimed "use the latest row's
499 /// value" was PG's behaviour — measured false, PG is per-row).
500 // v7.39.2 — bytes, not a String: PG's `string_agg(bytea, bytea)`
501 // takes a bytea separator, and a bytea result must be joined with the
502 // separator's bytes. A text separator stores its own UTF-8.
503 separator: Option<alloc::vec::Vec<u8>>,
504 /// v7.17.0 — running boolean accumulator for bool_and /
505 /// bool_or / every. `None` until the first non-NULL input;
506 /// at finalize None → SQL NULL.
507 bool_acc: Option<bool>,
508 /// v7.32 (round-29) — sum of squares for the variance / stddev
509 /// family (`sum_float` carries the running sum; `count` the n).
510 sum_sq: f64,
511 /// v7.38 (read01) — exact accumulators for the stddev/variance family.
512 /// PG computes those aggregates in NUMERIC over exact inputs (its float8
513 /// overload only serves float inputs), so an f64 accumulator loses PG's
514 /// exact division scale — `var_pop(1,2,3)` is `0.66666666666666666667`,
515 /// not the 16-digit double. `stddev_saw_float` flips on the first
516 /// float/real input and drops the family back to the f64 accumulators,
517 /// whose result is then double precision, matching PG's float8 overload.
518 stddev_saw_float: bool,
519 stddev_sum: Option<spg_storage::bignum::BigNumeric>,
520 stddev_sum_sq: Option<spg_storage::bignum::BigNumeric>,
521 /// v7.39 (round 615) — the same exact Σx / Σx², accumulated in `i128`
522 /// while every input is an integer and neither sum has overflowed.
523 ///
524 /// The `BigNumeric` pair above is exact and is what the finaliser wants,
525 /// but reaching it cost NINE allocations a row on a plain INTEGER column
526 /// — a boxed value per input, its square, and a fresh box for each of
527 /// the two running totals — where `sum` and `avg` over the same column
528 /// cost none. `i128` holds the same integers exactly: an `int4` squares
529 /// to at most 4.6e18, so the running Σx² has room for 3.7e19 rows before
530 /// it can overflow, and a `bigint` input that does overflow falls back
531 /// below with nothing lost — the pair is folded into the BigNumeric
532 /// accumulator first, so the total is the one it would have had.
533 stddev_i_sum: i128,
534 stddev_i_sum_sq: i128,
535 stddev_i_spent: bool,
536 /// v7.32 (round-29) — running accumulator for bit_and / bit_or /
537 /// bit_xor. `None` until the first non-NULL input → SQL NULL.
538 bit_acc: Option<i64>,
539 /// v7.38 (read01, T4.4) — true once a BIGINT input is seen, so
540 /// bit_and/or/xor finalize as bigint vs integer (PG input-typed).
541 bit_wide: bool,
542 /// v7.39 (round 254/255) — EVERY row fed to a WITHIN GROUP
543 /// aggregate, NULLs included. `items` (and `count`) hold only the
544 /// non-NULL values, which is right for `percentile_*` / `mode` —
545 /// but PG's hypothetical-set fractions divide by the full input
546 /// size: with one extra NULL row, `percent_rank(3)` moves from 2/6
547 /// to 2/7 (probed live). rank / dense_rank are unaffected either
548 /// way, since they only count values sorting before the
549 /// hypothetical row.
550 within_group_rows: usize,
551 /// v7.32 (round-29) — two-argument regression family
552 /// (`covar_*` / `corr` / `regr_*`), PG arg order `f(Y, X)`. Only
553 /// rows where BOTH inputs are non-NULL contribute (`count` is the
554 /// paired n, independent of the single-arg `sum_*`).
555 reg_n: i64,
556 reg_sx: f64,
557 reg_sy: f64,
558 reg_sxx: f64,
559 reg_syy: f64,
560 reg_sxy: f64,
561 /// v7.32 (round-29) — second value stream for `json_object_agg`
562 /// (`items` holds the keys, `aux_items` the values).
563 aux_items: Vec<Value<'static>>,
564 /// v7.33 (array_agg argmax) — for a `first_ordered` spec
565 /// (`(array_agg(x ORDER BY y))[1]`), the running first-by-order
566 /// (sort-key tuple, value). Replaced only when a new row's key sorts
567 /// strictly before the current best (ties keep the earliest row, =
568 /// the stable-sort `[1]`). No items/item_keys array is built.
569 first_best: Option<(Vec<Value<'static>>, Value<'static>)>,
570}
571
572#[derive(Debug, Clone)]
573struct AggSpec {
574 name: String, // lowercased
575 /// First argument (value expression) for every aggregate
576 /// except `count(*)`. `None` for `count_star`.
577 arg: Option<Expr>,
578 /// v7.17.0 — second argument. Only `string_agg(value, sep)`
579 /// uses it today. `None` for every other aggregate (or for
580 /// `array_agg`, which is single-arg). Carried in the spec so
581 /// per-row evaluation can re-use the same separator
582 /// expression across calls.
583 arg2: Option<Expr>,
584 /// v7.25 (round-17) — `COUNT(DISTINCT x)` & friends: dedupe
585 /// the input stream per group before accumulation.
586 distinct: bool,
587 /// v7.24 (round-16 A) — aggregate-internal ORDER BY keys
588 /// (`array_agg(x ORDER BY y DESC NULLS LAST)`). Empty for the
589 /// plain form. Only the collection aggregates honour it;
590 /// other aggregates are order-insensitive and ignore it (PG
591 /// accepts the syntax everywhere too).
592 order_by: Vec<spg_sql::ast::OrderBy>,
593 /// v7.32 (round-29) — `FILTER (WHERE cond)`: a per-row predicate
594 /// evaluated against the source row before accumulation. A row
595 /// whose `cond` is not TRUE (false or NULL) is excluded from this
596 /// aggregate only. `None` for the unfiltered form.
597 filter: Option<Expr>,
598 /// v7.32 (round-29) — ordered-set aggregates only: the *direct*
599 /// argument (the percentile fraction for `percentile_cont/disc`).
600 /// PG requires it constant, so it is evaluated once. `None` for
601 /// `mode()` and for every non-ordered-set aggregate.
602 direct_arg: Option<Expr>,
603 /// v7.39 (read01 orderedsetaggs.c) — the remaining direct arguments
604 /// of a multi-key hypothetical-set call (`rank(5, 'x') WITHIN GROUP
605 /// (ORDER BY a, b)`); one per sort key past the first. Empty
606 /// everywhere else.
607 direct_args_extra: Vec<Expr>,
608 /// v7.33 (array_agg argmax) — set when this spec came from
609 /// `(array_agg(x ORDER BY y))[1]`: accumulate only the first-by-order
610 /// element (a running argmax/argmin) and finalise to that scalar
611 /// value, instead of collecting + sorting + materialising the whole
612 /// per-group array just to take element 1. Returns the element type,
613 /// not the array type.
614 first_ordered: bool,
615 /// v7.37.4 (R34) — derived from `name` at spec build time so the
616 /// per-row inner loop dispatches via a `match` on `Copy` enum
617 /// instead of a string compare for every (row × aggregate)
618 /// iteration.
619 kind: AggKind,
620 /// v7.39 (enum order knife) — member labels when the aggregate's
621 /// argument is enum-typed and the aggregate orders its input
622 /// (min/max): extreme comparisons use member order, not label text.
623 /// Enriched once per query in `run` (spec collection is AST-only and
624 /// has no catalog).
625 enum_labels: Option<Vec<String>>,
626 /// v7.39 (round 690) — the argument column's declared collation, for
627 /// `min`/`max`. Resolved beside `enum_labels` and for the same reason:
628 /// both are facts about the ARGUMENT that the comparison needs and
629 /// cannot look up for itself.
630 arg_collation: Option<alloc::string::String>,
631 /// v7.39 (enum order knife) — per-ORDER-BY-key member labels for the
632 /// ordered collection aggregates (`array_agg(x ORDER BY enum_col)`).
633 /// Parallel to `order_by`; all-None when no key is enum-typed.
634 order_enum_labels: Vec<Option<Vec<String>>>,
635 /// v7.38.18 — per-ORDER-BY-key declared collation, for the ordered
636 /// collection aggregates. Parallel to `order_by`, resolved the same
637 /// way and for the same reason as `order_enum_labels` beside it.
638 ///
639 /// `min`/`max` have read the argument's collation since round 690
640 /// (`arg_collation`), and so does the statement's own ORDER BY, but
641 /// the sort INSIDE an aggregate did not: on a column declared
642 /// `COLLATE "en_US.utf8"`, `SELECT x FROM t ORDER BY x` answered
643 /// `apple, client, DateStyle, Zebra` while `string_agg(x, ' ' ORDER
644 /// BY x)` over the same column answered `DateStyle Zebra apple
645 /// client`. Two orderings of one column in one query.
646 order_collations: Vec<Option<alloc::string::String>>,
647}
648
649/// Output of running the aggregate path. Schema describes one row per
650/// group; rows are not yet ORDER BY-sorted (caller does it).
651#[derive(Debug)]
652pub struct AggResult {
653 pub columns: Vec<ColumnSchema>,
654 pub rows: Vec<Row<'static>>,
655 /// v7.31 (perf — PG lesson #1, post-LIMIT subquery projection):
656 /// select-list items whose rewritten expr carries a subquery and
657 /// is referenced by neither ORDER BY nor HAVING. Their output
658 /// cells hold NULL placeholders; the caller truncates to
659 /// LIMIT+OFFSET first and only then evaluates these for the
660 /// surviving rows (PG runs the same shape with SubPlan loops=50
661 /// instead of loops=24000). `(output_col, rewritten_expr)`.
662 pub deferred: Vec<(usize, Expr)>,
663 /// Synthetic group rows aligned 1:1 with `rows`; populated only
664 /// when `deferred` is non-empty.
665 pub synth_rows: Vec<Row<'static>>,
666 /// Schema the deferred exprs evaluate against.
667 pub synth_schema: Vec<ColumnSchema>,
668}
669
670/// Execute aggregate logic against an already-WHERE-filtered iterator of
671/// rows. `table_alias` is the alias accepted by column resolution.
672#[allow(clippy::too_many_lines)]
673/// v7.25.2 (round-19 A) — caller-injected evaluator for synth-row
674/// expressions that still carry subquery nodes after the rewrite
675/// (correlated subqueries in the select list / HAVING / aggregate
676/// ORDER BY of a GROUP BY query). The engine passes its
677/// correlated-aware evaluator; pure-library callers pass None and
678/// surviving subqueries keep erroring loudly.
679pub type CorrelatedEval<'a> =
680 &'a dyn Fn(&Expr, &Row<'static>, &EvalContext<'_>) -> Result<Value<'static>, EvalError>;
681
682/// Output of the per-group projection stage (`project_groups`): the
683/// output schema, the projected rows, the synth rows kept alongside
684/// them for post-LIMIT deferred evaluation, the deferred subquery
685/// items, and the rewritten ORDER BY exprs (shared with the sort).
686struct Projection {
687 columns: Vec<ColumnSchema>,
688 out_rows: Vec<Row<'static>>,
689 kept_synth: Vec<Row<'static>>,
690 deferred: Vec<(usize, Expr)>,
691 order_rewritten: Vec<Expr>,
692 /// v7.37.x — when `defer_projection` is requested, `out_rows`
693 /// carries empty placeholders and the caller runs the per-item
694 /// eval pass after sort+truncate over the surviving ≤ keep_n
695 /// rows. `None` when projection was performed inline.
696 deferred_project: Option<DeferredProject>,
697}
698
699struct DeferredProject {
700 items_rewritten: Vec<Option<Expr>>,
701 items_compiled: Vec<Option<eval::CompiledExpr>>,
702}
703
704/// v7.35.0 — detect the `SELECT COUNT(*) FROM … [WHERE …]` shape
705/// (single item, no GROUP BY / HAVING / ORDER BY / DISTINCT /
706/// LIMIT WITH TIES / FILTER / window). For this shape the answer
707/// is exactly `rows.len()` as `BigInt`, no group state needed.
708/// Returns `None` for any deviation so the caller's full pipeline
709/// runs verbatim.
710///
711/// v7.35.2 — also short-circuit `COUNT(<literal>)` (e.g.
712/// `COUNT(1)`) and `COUNT(<column>)` when the column is declared
713/// NOT NULL on the input schema. PG handles both cases as
714/// `COUNT(*)` (the non-null filter is a no-op), so doing the same
715/// here keeps every `count this thing` shape on the same fast path
716/// instead of routing the literal / non-null-col variants through
717/// the four-stage aggregate pipeline.
718fn try_pure_count_star_short_circuit(
719 stmt: &SelectStatement,
720 rows: AggRows<'_>,
721 schema_cols: &[ColumnSchema],
722 table_alias: Option<&str>,
723) -> Option<AggResult> {
724 if stmt.distinct
725 || stmt.limit_with_ties
726 || stmt.group_by.is_some()
727 || stmt.having.is_some()
728 || !stmt.order_by.is_empty()
729 {
730 return None;
731 }
732 if stmt.items.len() != 1 {
733 return None;
734 }
735 let SelectItem::Expr { expr, alias } = &stmt.items[0] else {
736 return None;
737 };
738 let Expr::FunctionCall { name, args } = expr else {
739 return None;
740 };
741 if !name.eq_ignore_ascii_case("count") && !name.eq_ignore_ascii_case("count_star") {
742 return None;
743 }
744 let count_star_shape = match args.as_slice() {
745 // `COUNT(*)` parses to `count_star` with no args.
746 [] if name.eq_ignore_ascii_case("count_star") => true,
747 // `COUNT(<literal>)` — the per-row test is "is this literal
748 // non-null?" which is constant, so it's COUNT(*) when the
749 // literal is non-null.
750 [Expr::Literal(lit)] => !matches!(lit, spg_sql::ast::Literal::Null),
751 // `COUNT(<column>)` — same answer as COUNT(*) when the
752 // column is statically declared NOT NULL on the input
753 // schema. Resolve through the alias if one is set.
754 [Expr::Column(c)] => {
755 if let Some(q) = c.qualifier.as_deref()
756 && let Some(alias) = table_alias
757 && !q.eq_ignore_ascii_case(alias)
758 {
759 return None;
760 }
761 schema_cols
762 .iter()
763 .find(|s| s.name.eq_ignore_ascii_case(&c.name))
764 .is_some_and(|s| !s.nullable)
765 }
766 _ => return None,
767 };
768 if !count_star_shape {
769 return None;
770 }
771 let col_name = alias.clone().unwrap_or_else(|| "count".to_string());
772 let count = i64::try_from(rows.len()).unwrap_or(i64::MAX);
773 Some(AggResult {
774 columns: alloc::vec![ColumnSchema::new(col_name, DataType::BigInt, false)],
775 rows: alloc::vec![Row::new(alloc::vec![Value::BigInt(count)])],
776 deferred: Vec::new(),
777 synth_rows: Vec::new(),
778 synth_schema: Vec::new(),
779 })
780}
781
782/// v7.39 (round 528) — a GROUP BY name that names an output column.
783///
784/// `SELECT date_trunc('day', ts) AS d, count(*) FROM t GROUP BY d` is the
785/// canonical daily rollup, and it answered `column "d" does not exist`.
786/// Both PG and MySQL take a GROUP BY identifier that matches an output
787/// alias and group by the expression behind it; only grouping by a real
788/// column or an ordinal worked here.
789///
790/// Precedence is PG's, measured: an INPUT column of that name WINS.
791/// `SELECT v AS ts … GROUP BY ts` on a table that has a `ts` column
792/// groups by the column, which is why PG then rejects the ungrouped `v` —
793/// so the alias is consulted only when nothing else answers to the name.
794fn resolve_group_by_aliases(
795 keys: Vec<Expr>,
796 stmt: &SelectStatement,
797 schema_cols: &[ColumnSchema],
798) -> Result<Vec<Expr>, EvalError> {
799 let mut out = Vec::with_capacity(keys.len());
800 for key in keys {
801 let Expr::Column(c) = &key else {
802 out.push(key);
803 continue;
804 };
805 if c.qualifier.is_some()
806 || schema_cols
807 .iter()
808 .any(|sc| sc.name.eq_ignore_ascii_case(&c.name))
809 {
810 out.push(key);
811 continue;
812 }
813 let target = stmt.items.iter().find_map(|it| match it {
814 SelectItem::Expr {
815 expr,
816 alias: Some(a),
817 } if a.eq_ignore_ascii_case(&c.name) => Some(expr),
818 _ => None,
819 });
820 match target {
821 // PG's wording for the one alias that cannot be grouped by.
822 Some(e) if contains_aggregate(e) => {
823 return Err(EvalError::TypeMismatch {
824 detail: alloc::string::String::from(
825 "aggregate functions are not allowed in GROUP BY",
826 ),
827 });
828 }
829 Some(e) => out.push(e.clone()),
830 // Not an alias either — leave it, so the resolver reports the
831 // missing column as it always did.
832 None => out.push(key),
833 }
834 }
835 Ok(out)
836}
837
838pub(crate) fn run(
839 stmt: &SelectStatement,
840 rows: AggRows<'_>,
841 schema_cols: &[ColumnSchema],
842 table_alias: Option<&str>,
843 correlated_eval: Option<CorrelatedEval<'_>>,
844 // v7.39 (parallel-agg P1) — host-injected executor; None = the
845 // single-threaded paths, byte-identical to pre-P1.
846 runner: Option<&dyn crate::ParallelRunner>,
847 // v7.39 (enum order knife) — catalog for enum member-order metadata
848 // (spec collection is AST-only). None keeps every ordering textual.
849 catalog: Option<&spg_storage::Catalog>,
850 // v7.39 (read01 round 63) — and the engine, so a user function whose body
851 // has its own FROM can run inside an aggregate's argument
852 // (`string_agg(lookup(id), ',')`). The catalog alone is not enough: the body
853 // is a QUERY and has to go through the real executor.
854 engine: Option<&crate::Engine>,
855) -> Result<AggResult, EvalError> {
856 // v7.38 P0 元机制 A — fires at the top of the aggregate
857 // executor with the number of input rows. Tests use this to
858 // block before a hypothetical spill decision; in release it
859 // expands to `let _ = (...);`.
860 let __spg_row_count = rows.len();
861 crate::injection_point!("aggregate_spill_trigger", &__spg_row_count);
862 // v7.35.0 — pure `SELECT COUNT(*) FROM … WHERE …` short-circuit.
863 // The caller already filtered rows by WHERE (we run on the
864 // post-WHERE survivor set), so for the canonical pure-COUNT(*)
865 // shape (no GROUP BY / HAVING / ORDER BY / DISTINCT / FILTER /
866 // window) the answer is simply `rows.len()`. The four-stage
867 // aggregate pipeline below (accumulate_groups → build_synth_schema
868 // → finalize_synth_rows → project_groups) collapses to a single
869 // BigInt cell when there's a single group, but each stage still
870 // pays its own allocation tax — group state map, synth schema
871 // vec, finalize loop. `exists_in_60` (mailrs prod #4 baseline)
872 // is exactly this shape on a 25 k-row JOIN.
873 if let Some(short) = try_pure_count_star_short_circuit(stmt, rows, schema_cols, table_alias) {
874 return Ok(short);
875 }
876 let group_exprs: Vec<Expr> = stmt.group_by.clone().unwrap_or_default();
877 // v7.39 (round 528) — a GROUP BY name that is only an output ALIAS.
878 let group_exprs = resolve_group_by_aliases(group_exprs, stmt, schema_cols)?;
879
880 // v7.39 (round 620) — PG's strict rule, checked BEFORE the pipeline so the
881 // diagnosis names what is actually wrong. Skipped under the MySQL dialect,
882 // which licenses exactly what this rejects (the loose rewrite below), and
883 // skipped when the grouping is by a primary key, which licenses every other
884 // column of that table.
885 // A GROUP BY name that resolves to nothing is reported as the missing
886 // column it is, ahead of this rule — measured against PG, which answers
887 // `column "nosuch" does not exist` for `SELECT v FROM t GROUP BY nosuch`
888 // rather than complaining that `v` is ungrouped.
889 let group_keys_all_resolve = group_exprs.iter().all(|g| match g {
890 Expr::Column(c) => {
891 c.qualifier.is_some()
892 || schema_cols
893 .iter()
894 .any(|sc| sc.name.eq_ignore_ascii_case(&c.name))
895 }
896 _ => true,
897 });
898 let licensed = qualifiers_grouped_by_primary_key(stmt, &group_exprs, schema_cols, catalog);
899 let fd_on_primary_key = !licensed.is_empty();
900 // v7.39.2 — "the dialect is MySQL" used to be the whole test here,
901 // so the strict rule was off even under MySQL's own default
902 // `sql_mode`, which carries ONLY_FULL_GROUP_BY. It asks sql_mode
903 // now. The other four dialect checks in this file ask DIFFERENT
904 // questions through the same flag — column naming, HAVING aliases,
905 // collation folding — and are deliberately left alone.
906 if group_keys_all_resolve && !engine.is_some_and(crate::Engine::group_by_is_loose) {
907 // v7.39.2 — WHERE the offender was found, and at which 1-based
908 // position. PostgreSQL's sentence needs neither; MySQL's names
909 // both ("Expression #2 of SELECT list", "Expression #1 of ORDER
910 // BY clause"), so the search has to keep what it used to throw
911 // away the moment it found a column.
912 let offender = stmt
913 .items
914 .iter()
915 .enumerate()
916 .find_map(|(i, it)| match it {
917 SelectItem::Expr { expr, .. } => {
918 first_ungrouped_column(expr, &group_exprs, schema_cols, &licensed)
919 .map(|c| (c, "SELECT list", i + 1))
920 }
921 _ => None,
922 })
923 .or_else(|| {
924 stmt.order_by.iter().enumerate().find_map(|(i, o)| {
925 first_ungrouped_column(&o.expr, &group_exprs, schema_cols, &licensed)
926 .map(|c| (c, "ORDER BY clause", i + 1))
927 })
928 })
929 .or_else(|| {
930 stmt.having.as_ref().and_then(|h| {
931 first_ungrouped_column(h, &group_exprs, schema_cols, &licensed)
932 .map(|c| (c, "HAVING clause", 1))
933 })
934 });
935 if let Some((c, origin, position)) = offender {
936 // PG qualifies the column with the alias when there is one, and
937 // with the table name otherwise.
938 let qual = c
939 .qualifier
940 .as_deref()
941 .or(table_alias)
942 .or_else(|| stmt.from.as_ref().map(|f| f.primary.name.as_str()))
943 .unwrap_or("");
944 // v7.39.2 — MySQL 9.7.2's own sentences, measured. It names
945 // the clause, the 1-based expression position and the
946 // three-part `db.table.column`, and it uses a DIFFERENT
947 // errno when there is no GROUP BY at all: 1140
948 // (ER_MIX_OF_GROUP_FUNC_AND_FIELDS) rather than 1055. SPG
949 // answered PostgreSQL's one sentence and 1055 to all of
950 // them, so a driver branching on the number could not tell
951 // the two faults apart.
952 //
953 // The HAVING case is not this error there at all: measured,
954 // `HAVING b > 1` over a query grouped by `a` answers 1054
955 // `Unknown column 'b' in 'having clause'`, because a grouped
956 // query's HAVING can only see the grouped columns and the
957 // aggregates. That is the scope rule, not the wording.
958 if engine.is_some_and(|e| e.speaks_mysql) {
959 let schema = engine.map_or_else(
960 || alloc::string::String::from("spg"),
961 crate::Engine::mysql_schema_name,
962 );
963 let table = c
964 .qualifier
965 .as_deref()
966 .or(table_alias)
967 .or_else(|| stmt.from.as_ref().map(|f| f.primary.name.as_str()))
968 .unwrap_or("");
969 let full = alloc::format!("{schema}.{table}.{}", c.name);
970 if origin == "HAVING clause" {
971 return Err(EvalError::TypeMismatch {
972 detail: alloc::format!("Unknown column '{}' in 'having clause'", c.name),
973 });
974 }
975 if stmt.group_by.is_none() {
976 return Err(EvalError::TypeMismatch {
977 detail: alloc::format!(
978 "In aggregated query without GROUP BY, expression #{position} of \
979 {origin} contains nonaggregated column '{full}'; this is \
980 incompatible with sql_mode=only_full_group_by"
981 ),
982 });
983 }
984 return Err(EvalError::TypeMismatch {
985 detail: alloc::format!(
986 "Expression #{position} of {origin} is not in GROUP BY clause and \
987 contains nonaggregated column '{full}' which is not functionally \
988 dependent on columns in GROUP BY clause; this is incompatible with \
989 sql_mode=only_full_group_by"
990 ),
991 });
992 }
993 return Err(EvalError::TypeMismatch {
994 detail: alloc::format!(
995 "column \"{qual}.{}\" must appear in the GROUP BY clause or be used in an aggregate function",
996 c.name
997 ),
998 });
999 }
1000 }
1001
1002 // v7.39 (round 405) — MySQL's loose GROUP BY: wrap each non-grouped,
1003 // non-aggregated column in `any_value(col)` so the rest of the pipeline
1004 // treats it as an aggregate (first-seen value per group). Only under the
1005 // dialect and only when there is an explicit GROUP BY; PG keeps the
1006 // strict "must appear in GROUP BY / be aggregated" rule.
1007 //
1008 // v7.39 (round 620) — the same rewrite serves PG's functional dependency.
1009 // Letting the ungrouped column PAST the check above is not enough: the
1010 // grouped row carries only the keys and the aggregates, so `s` still has
1011 // nowhere to be read from and the query failed on `column "s" does not
1012 // exist`. Grouping by a primary key means one input row per group, so
1013 // "any value in the group" IS the value — the identical rewrite, reached
1014 // for a different and much narrower reason.
1015 let mysql_loose = engine.is_some_and(crate::Engine::group_by_is_loose);
1016 let loose_stmt;
1017 let stmt = if (mysql_loose || fd_on_primary_key) && !group_exprs.is_empty() {
1018 // The dialect claims every ungrouped column; the functional dependency
1019 // claims only what a grouped primary key determines.
1020 let claim: Option<&[alloc::string::String]> =
1021 if mysql_loose { None } else { Some(&licensed) };
1022 let mut s = stmt.clone();
1023 for item in &mut s.items {
1024 if let SelectItem::Expr { expr, .. } = item {
1025 let taken = core::mem::replace(expr, Expr::Literal(spg_sql::ast::Literal::Null));
1026 *expr = wrap_loose_group_columns(taken, &group_exprs, schema_cols, claim);
1027 }
1028 }
1029 for o in &mut s.order_by {
1030 let taken = core::mem::replace(&mut o.expr, Expr::Literal(spg_sql::ast::Literal::Null));
1031 o.expr = wrap_loose_group_columns(taken, &group_exprs, schema_cols, claim);
1032 }
1033 if let Some(h) = s.having.take() {
1034 s.having = Some(wrap_loose_group_columns(
1035 h,
1036 &group_exprs,
1037 schema_cols,
1038 claim,
1039 ));
1040 }
1041 loose_stmt = s;
1042 &loose_stmt
1043 } else {
1044 stmt
1045 };
1046
1047 // Collect aggregate sub-expressions across items + order_by.
1048 let mut agg_specs: Vec<AggSpec> = Vec::new();
1049 for item in &stmt.items {
1050 if let SelectItem::Expr { expr, .. } = item {
1051 collect_aggregates(expr, &mut agg_specs);
1052 }
1053 }
1054 for o in &stmt.order_by {
1055 collect_aggregates(&o.expr, &mut agg_specs);
1056 }
1057 if let Some(h) = &stmt.having {
1058 collect_aggregates(h, &mut agg_specs);
1059 }
1060 // v7.17.0 — arity validation. The collector tolerates an
1061 // arbitrary positional-arg count; here we enforce the
1062 // per-aggregate contract so a malformed call (e.g.
1063 // `array_agg()` or `string_agg(x)`) surfaces as a SQL error
1064 // rather than silently coercing to a degenerate aggregate.
1065 validate_agg_arities(stmt, &agg_specs, schema_cols)?;
1066 validate_within_group(&agg_specs, schema_cols, stmt.group_by.as_deref())?;
1067
1068 // v7.38.18 (S2) — the database's collation, for the columns that
1069 // declare none. `None` when it is byte order, which is every
1070 // database written before this existed.
1071 let db_collation: Option<&str> = catalog
1072 .map(spg_storage::Catalog::db_collation)
1073 .filter(|d| !crate::collate::is_byte_wise(d));
1074 // v7.39 (round 690) — resolve the argument's declared collation for
1075 // `min`/`max`. This rides beside `enum_labels` in `AggSpec` but NOT
1076 // inside its resolver loop: that loop only runs when the catalog holds
1077 // at least one enum type, and a collation has nothing to do with enums.
1078 for spec in &mut agg_specs {
1079 if matches!(spec.kind, AggKind::Min | AggKind::Max)
1080 && let Some(Expr::Column(c)) = &spec.arg
1081 {
1082 // A bare column argument carries its collation; an expression
1083 // produces a new value and has none (derivation is unbuilt).
1084 spec.arg_collation = schema_cols
1085 .iter()
1086 .find(|sc| sc.name.eq_ignore_ascii_case(&c.name))
1087 .and_then(|sc| sc.collation_name.clone())
1088 // v7.38.18 (S2) — the database's when the column
1089 // declares none. `C` filters out below, so nothing moves
1090 // for a database that has not asked for a locale.
1091 .or_else(|| db_collation.map(alloc::string::String::from))
1092 .filter(|n| crate::collate::is_supported(n));
1093 }
1094 }
1095
1096 // v7.38.18 — the same fact for each ORDER BY key of an ordered
1097 // collection aggregate. Outside the enum resolver below for the
1098 // reason the loop above is: that one only runs when the catalog
1099 // holds an enum type, and a collation has nothing to do with enums.
1100 for spec in &mut agg_specs {
1101 if spec.order_by.is_empty() {
1102 continue;
1103 }
1104 spec.order_collations = spec
1105 .order_by
1106 .iter()
1107 .map(|o| {
1108 // v7.39.2 — the key's OWN `COLLATE`, which the parser has
1109 // been putting on `OrderBy::collation` all along and this
1110 // never read. Measured: `string_agg(x, ',' ORDER BY x
1111 // COLLATE \"C\")` answered the database's order where
1112 // PostgreSQL 18.6 answers byte order — the clause was
1113 // captured one layer down and dropped here.
1114 //
1115 // It comes first because it is EXPLICIT, and PG's
1116 // derivation has an explicit collation beat the column's.
1117 if let Some(written) = o.collation.as_deref() {
1118 return crate::collate::is_supported(written)
1119 .then(|| alloc::string::String::from(written));
1120 }
1121 // A bare column key carries its collation; an expression
1122 // produces a new value and has none, the same limit
1123 // `min`/`max` has over an expression argument.
1124 let Expr::Column(c) = &o.expr else {
1125 return None;
1126 };
1127 schema_cols
1128 .iter()
1129 .find(|sc| sc.name.eq_ignore_ascii_case(&c.name))
1130 .and_then(|sc| sc.collation_name.clone())
1131 .or_else(|| db_collation.map(alloc::string::String::from))
1132 .filter(|n| crate::collate::is_supported(n))
1133 })
1134 .collect();
1135 }
1136
1137 // v7.39 (enum order knife) — resolve enum member-order metadata once
1138 // per query: min/max extremes and ordered-collection sort keys over
1139 // enum-typed expressions compare by member order (PG enumsortorder).
1140 if let Some(cat) = catalog
1141 && !cat.enum_types().is_empty()
1142 {
1143 for spec in &mut agg_specs {
1144 // v7.39 (round 258) — min/max have always needed the argument's
1145 // enum labels; a DISTINCT aggregate now does too, because its
1146 // dedup sort must follow MEMBER order (round 257 added the sort
1147 // and, deriving labels only here, sorted enum columns by text).
1148 if (matches!(spec.kind, AggKind::Min | AggKind::Max) || spec.distinct)
1149 && let Some(arg) = &spec.arg
1150 {
1151 spec.enum_labels = crate::eval::expr_enum_labels(arg, schema_cols, catalog)
1152 .map(<[String]>::to_vec);
1153 }
1154 if !spec.order_by.is_empty() {
1155 spec.order_enum_labels = spec
1156 .order_by
1157 .iter()
1158 .map(|o| {
1159 crate::eval::expr_enum_labels(&o.expr, schema_cols, catalog)
1160 .map(<[String]>::to_vec)
1161 })
1162 .collect();
1163 }
1164 }
1165 }
1166
1167 // (1) Stream the WHERE-filtered rows into insertion-ordered group state.
1168 let order = accumulate_groups(
1169 rows,
1170 &group_exprs,
1171 &agg_specs,
1172 schema_cols,
1173 table_alias,
1174 correlated_eval,
1175 runner,
1176 catalog,
1177 engine,
1178 )?;
1179
1180 // (2) Build the synthetic per-group schema and finalise each group's row.
1181 let synth_schema = build_synth_schema(
1182 rows,
1183 &group_exprs,
1184 &agg_specs,
1185 schema_cols,
1186 table_alias,
1187 catalog,
1188 engine,
1189 )?;
1190 let synth_rows = finalize_synth_rows(
1191 &order,
1192 &agg_specs,
1193 &synth_schema,
1194 rows,
1195 schema_cols,
1196 table_alias,
1197 catalog,
1198 engine,
1199 runner,
1200 )?;
1201
1202 // v7.37.x (mailrs Track A 100k attack) — defer the bound
1203 // per-item SELECT projection on the synth rows until AFTER
1204 // sort + LIMIT truncation. On a `GROUP BY t ORDER BY agg DESC
1205 // LIMIT 50` with 20 000 groups (the mailrs minimal 100k shape)
1206 // pre-defer ran 20 000 × N_items compiled-VM evals + Row
1207 // allocations before discarding 99.75 % at the sort truncation
1208 // step. HAVING still runs inline on every group because it
1209 // filters BEFORE the LIMIT; we only skip the SELECT-list eval.
1210 //
1211 // v7.37 (round 998) — and so a HAVING no longer stands the deferral
1212 // down. It used to, which cost the mailrs Track A query 11.9 ms of
1213 // 83. Neither clause is expensive alone: HAVING costs 5.0 ms without
1214 // an ORDER BY and 16.9 with one, and an ORDER BY costs MINUS 8.6 ms
1215 // without a HAVING, because ORDER BY + LIMIT is what switches this
1216 // deferral on. The residue of 11.9 ms belonged to neither and
1217 // appeared only together.
1218 //
1219 // What named it: the interaction tracks what the aggregates COST
1220 // rather than how many there are — one expensive aggregate
1221 // reproduces it as fully as twelve cheap ones — and it does not move
1222 // when the LIMIT changes. Both follow from projecting all 20 000
1223 // groups instead of the 50 that survive truncation.
1224 //
1225 // Safe because the clause above runs first: HAVING filters into
1226 // `kept_synth` BEFORE this branch, the sort truncates that survivor
1227 // list, and the completion projects from it. HAVING is rewritten
1228 // against the synthetic group schema, so it never reads a projected
1229 // item.
1230 //
1231 // v7.37 (round 997) — a set-returning item must NOT defer. The
1232 // deferred completion at the end of this function evaluates each item
1233 // scalarly; the expansion that turns one group into one row per
1234 // element lives in the branch the deferral skips. So a deferred
1235 // `unnest(...)` in the select list came back as
1236 // `function unnest(integer[]) does not exist` — the exact error round
1237 // 621 had fixed, reintroduced for the shapes that qualify to defer.
1238 // Differential against PG18.4: the same query answered correctly
1239 // without LIMIT, with LIMIT >= the group count, and — at the time —
1240 // with a HAVING, those being the cases where the deferral was off.
1241 // Round 998 removed the HAVING one from that list, which is why this
1242 // guard carries the SRF rule on its own now.
1243 let any_srf_item = stmt.items.iter().any(|i| match i {
1244 SelectItem::Expr { expr, .. } => crate::select::top_level_srf_kind(expr).is_some(),
1245 _ => false,
1246 });
1247 let defer_projection = !stmt.order_by.is_empty()
1248 && !stmt.distinct
1249 && !stmt.limit_with_ties
1250 && !any_srf_item
1251 && stmt.limit_literal().is_some_and(|l| {
1252 let off = stmt.offset_literal().unwrap_or(0) as usize;
1253 let k = (l as usize).saturating_add(off);
1254 k > 0 && k < synth_rows.len()
1255 });
1256
1257 // (3) Rewrite the user's expressions, filter groups by HAVING and project.
1258 let Projection {
1259 columns,
1260 mut out_rows,
1261 mut kept_synth,
1262 deferred,
1263 order_rewritten,
1264 deferred_project,
1265 } = project_groups(
1266 synth_rows,
1267 stmt,
1268 &group_exprs,
1269 &agg_specs,
1270 &synth_schema,
1271 correlated_eval,
1272 defer_projection,
1273 catalog,
1274 engine.is_some_and(|e| e.speaks_mysql),
1275 )?;
1276
1277 // (4) ORDER BY on the aggregated output (the caller applies LIMIT).
1278 //
1279 // v7.37.3 (mailrs prod /api/contacts 3.21× regression — and the
1280 // general inbox-listing-shape SPG-vs-PG gap) — top-K sink for
1281 // `ORDER BY <agg> [DESC] LIMIT k`. Pre-7.37.3 this stage ran a
1282 // full O(N log N) sort over every surviving group, then the
1283 // caller truncated to `k`. With high-cardinality GROUP BY (a
1284 // sender column with hundreds-thousands of distinct values) the
1285 // truncated set is a tiny fraction of `N` — keep an O(k) top-K
1286 // sink and never sort the discarded majority. Matches PG /
1287 // MySQL / MariaDB's standard "LIMIT k under ORDER BY agg"
1288 // optimisation; SPG previously implemented it only on the
1289 // streamed inner-join path (`try_streamed_inner_join_topn`)
1290 // and not on the aggregate output.
1291 //
1292 // Gate: needs a literal LIMIT (placeholder LIMIT we can't bound
1293 // statically here), no DISTINCT (would need post-dedup, can't
1294 // truncate during sort), no LIMIT WITH TIES (which extends past
1295 // the literal k by run-time tie-key comparison).
1296 let keep_n: Option<usize> =
1297 if !stmt.order_by.is_empty() && !stmt.distinct && !stmt.limit_with_ties {
1298 stmt.limit_literal().map(|l| {
1299 let off = stmt.offset_literal().unwrap_or(0) as usize;
1300 (l as usize).saturating_add(off)
1301 })
1302 } else {
1303 None
1304 };
1305 if !stmt.order_by.is_empty() {
1306 let (sorted_synth, sorted_out) = sort_synth_by_order_by(
1307 &synth_schema,
1308 &columns,
1309 &stmt.order_by,
1310 &order_rewritten,
1311 kept_synth,
1312 out_rows,
1313 correlated_eval,
1314 keep_n,
1315 catalog,
1316 engine.is_some_and(|e| e.speaks_mysql),
1317 )?;
1318 kept_synth = sorted_synth;
1319 out_rows = sorted_out;
1320 }
1321
1322 // v7.37.x — run deferred SELECT-list projection on the truncated
1323 // top-K survivors. For `GROUP BY thread_id ORDER BY MAX(date) DESC
1324 // LIMIT 50` against 20 000 groups, this turns ~40 000 compiled-VM
1325 // evals + Row allocations into 100, saving ~2-3 ms on the mailrs
1326 // minimal 100k shape.
1327 if let Some(DeferredProject {
1328 items_rewritten,
1329 items_compiled,
1330 }) = deferred_project
1331 {
1332 let mut synth_ctx = EvalContext::new(&synth_schema, None);
1333 if let Some(cat) = catalog {
1334 synth_ctx = synth_ctx.with_catalog(cat);
1335 }
1336 let mut stack: Vec<Value<'static>> = Vec::new();
1337 for (idx, srow) in kept_synth.iter().enumerate() {
1338 let mut values: Vec<Value<'static>> = Vec::with_capacity(columns.len());
1339 for (i, rewritten) in items_rewritten.iter().enumerate() {
1340 let Some(rewritten) = rewritten else { continue };
1341 if deferred.iter().any(|(c, _)| *c == i) {
1342 values.push(Value::Null);
1343 continue;
1344 }
1345 values.push(if let Some(cc) = &items_compiled[i] {
1346 eval::eval_compiled(cc, srow, &synth_ctx, &mut stack)?
1347 } else {
1348 match correlated_eval {
1349 Some(f) if crate::expr_has_subquery(rewritten) => {
1350 f(rewritten, srow, &synth_ctx)?
1351 }
1352 _ => eval::eval_expr(rewritten, srow, &synth_ctx)?,
1353 }
1354 });
1355 }
1356 out_rows[idx] = Row::new(values);
1357 }
1358 }
1359
1360 // v7.37 (round 999) — SELECT DISTINCT over a GROUP BY query.
1361 //
1362 // Every other path deduplicates: the scan paths, the window path and
1363 // the set operations all call `dedup_rows`. This one never did, so
1364 // `SELECT DISTINCT count(*) FROM t GROUP BY g` returned one row per
1365 // GROUP — 200 where PG18.4 returns 1, all of them the same value.
1366 // Not an error, not a missing column: 199 extra rows, silently.
1367 //
1368 // The gate on the top-K sink above says it in as many words — "no
1369 // DISTINCT (would need post-dedup, can't truncate during sort)" — so
1370 // the sink correctly declines to truncate, and the post-dedup it
1371 // names was never written. This is it.
1372 //
1373 // After the ORDER BY, like the window path: duplicate rows carry
1374 // identical sort keys, so removing them cannot disturb the order.
1375 // Before the LIMIT, which the caller applies, because PG deduplicates
1376 // and then counts.
1377 //
1378 // Only `out_rows` needs it: `deferred` is empty whenever DISTINCT is
1379 // set (`defer_enabled` requires `!stmt.distinct`), so nothing indexes
1380 // into `kept_synth` alongside these rows.
1381 if stmt.distinct {
1382 // v7.38.14 — masked, not dialect-only. `SELECT DISTINCT` over a
1383 // GROUP BY result folded every text position regardless of what
1384 // the column declared, which is the defect 3b494b6e closed on the
1385 // main scan path. The output schema is in scope here and carries
1386 // the collation, so the mask needs no new plumbing.
1387 out_rows = crate::select::dedup_rows(
1388 out_rows,
1389 crate::select::FoldSpec::of_masks(
1390 engine.is_some_and(|e| e.speaks_mysql),
1391 &crate::select::fold_mask_of_columns(&columns),
1392 &crate::select::pad_mask_of_columns(&columns),
1393 ),
1394 );
1395 }
1396
1397 let (synth_rows_out, synth_schema_out) = if deferred.is_empty() {
1398 (Vec::new(), Vec::new())
1399 } else {
1400 (kept_synth, synth_schema.clone())
1401 };
1402 Ok(AggResult {
1403 columns,
1404 rows: out_rows,
1405 deferred,
1406 synth_rows: synth_rows_out,
1407 synth_schema: synth_schema_out,
1408 })
1409}
1410
1411/// v7.32 (round-29) — validate the structural requirements of WITHIN
1412/// GROUP (ordered-set / hypothetical-set) aggregates up front, so a
1413/// malformed call surfaces as a SQL error rather than a silently
1414/// degenerate aggregate.
1415/// v7.39 (round 255) — PG's name for an expression's type in an
1416/// ordered-set signature error. Only a CAST / COLUMN is trusted (the
1417/// round-237 lesson: `describe_expr` reports a binary operator as its
1418/// left operand's type); an untyped literal is PG's own `unknown`, and
1419/// anything else falls back to `unknown` rather than guessing.
1420fn ordered_set_arg_type_name(e: &Expr, columns: &[ColumnSchema]) -> String {
1421 if matches!(
1422 e,
1423 Expr::Literal(spg_sql::ast::Literal::String(_))
1424 | Expr::Literal(spg_sql::ast::Literal::Null)
1425 ) {
1426 return String::from("unknown");
1427 }
1428 match e {
1429 Expr::Cast { .. } | Expr::Column(_) | Expr::Literal(_) => {
1430 crate::describe::describe_expr(e, columns).map_or_else(
1431 || String::from("unknown"),
1432 |s| crate::conversions::pg_type_name_for_error(s.ty),
1433 )
1434 }
1435 _ => String::from("unknown"),
1436 }
1437}
1438
1439/// v7.39 (round 255) — PG resolves an ordered-set / hypothetical-set
1440/// call as ONE function whose signature is `(direct args…, WITHIN GROUP
1441/// args…)`; anything that does not match a declared overload is a plain
1442/// `function f(…) does not exist` (42883), not a bespoke message. Probed
1443/// live: `percentile_cont(numeric, text)`, `rank(integer, integer,
1444/// text)`, `mode(integer, integer)`.
1445fn ordered_set_signature_error(name: &str, spec: &AggSpec, columns: &[ColumnSchema]) -> EvalError {
1446 let mut parts: Vec<String> = Vec::new();
1447 if let Some(d) = &spec.direct_arg {
1448 parts.push(ordered_set_arg_type_name(d, columns));
1449 }
1450 for d in &spec.direct_args_extra {
1451 parts.push(ordered_set_arg_type_name(d, columns));
1452 }
1453 for o in &spec.order_by {
1454 parts.push(ordered_set_arg_type_name(&o.expr, columns));
1455 }
1456 EvalError::TypeMismatch {
1457 detail: format!("function {name}({}) does not exist", parts.join(", ")),
1458 }
1459}
1460
1461fn validate_within_group(
1462 agg_specs: &[AggSpec],
1463 columns: &[ColumnSchema],
1464 group_by: Option<&[Expr]>,
1465) -> Result<(), EvalError> {
1466 // v7.39 (round 765, F31-D2) — PG requires an ordered-set
1467 // aggregate's DIRECT arguments to use only grouped columns
1468 // (`percentile_cont(x) WITHIN GROUP (ORDER BY x)` refuses with
1469 // "column … must appear in the GROUP BY clause", DETAIL "Direct
1470 // arguments of an ordered-set aggregate must use only grouped
1471 // columns", PG18-measured); SPG evaluated the first row's value
1472 // and answered.
1473 fn first_ungrouped(e: &Expr, group_by: Option<&[Expr]>) -> Option<String> {
1474 let mut found: Option<String> = None;
1475 let mut subs: Vec<&SelectStatement> = Vec::new();
1476 crate::visit_expr_columns_and_subqueries(
1477 e,
1478 &mut |c| {
1479 if found.is_some() {
1480 return;
1481 }
1482 let grouped = group_by.is_some_and(|gs| {
1483 gs.iter().any(|g| match g {
1484 Expr::Column(gc) => gc.name.eq_ignore_ascii_case(&c.name),
1485 _ => false,
1486 })
1487 });
1488 // The visitor's exotic-node BAIL marker is an empty
1489 // name — not a real column; skip it (refusing on it
1490 // would reject constant shapes like ARRAY[…] casts).
1491 if !grouped && !c.name.is_empty() {
1492 found = Some(match &c.qualifier {
1493 Some(q) => format!("{q}.{}", c.name),
1494 None => c.name.clone(),
1495 });
1496 }
1497 },
1498 &mut |s| subs.push(s),
1499 );
1500 found
1501 }
1502 for spec in agg_specs {
1503 if !is_within_group_name(&spec.name) {
1504 continue;
1505 }
1506 for d in spec.direct_arg.iter().chain(spec.direct_args_extra.iter()) {
1507 if let Some(col) = first_ungrouped(d, group_by) {
1508 return Err(EvalError::TypeMismatch {
1509 detail: format!(
1510 "column \"{col}\" must appear in the GROUP BY clause or be used in an aggregate function"
1511 ),
1512 });
1513 }
1514 }
1515 }
1516 // v7.32 (round-29) — WITHIN GROUP aggregates require the clause (PG
1517 // raises a hard error otherwise rather than silently degrading), and
1518 // SPG supports the single-sort-key form only.
1519 for spec in agg_specs {
1520 if is_within_group_name(&spec.name) {
1521 if spec.order_by.is_empty() {
1522 // v7.39 (round 704) — the hypothetical-set names double as
1523 // WINDOW functions, and PG resolves the bare zero-argument
1524 // spelling to the window reading: `SELECT rank() FROM t` is
1525 // `window function rank requires an OVER clause` there, not
1526 // a WITHIN GROUP complaint. With a direct argument the
1527 // ordered-set reading is the one the caller meant, and the
1528 // WITHIN GROUP wording stands.
1529 if spec.direct_arg.is_none() && is_hypothetical_set_name(&spec.name) {
1530 return Err(EvalError::TypeMismatch {
1531 detail: format!("window function {} requires an OVER clause", spec.name),
1532 });
1533 }
1534 return Err(EvalError::TypeMismatch {
1535 detail: format!("{}() requires WITHIN GROUP (ORDER BY …)", spec.name),
1536 });
1537 }
1538 // mode() is the only WITHIN GROUP aggregate with no direct
1539 // argument; the rest carry one (percentile fraction /
1540 // hypothetical value).
1541 if spec.name != "mode" && spec.direct_arg.is_none() {
1542 return Err(EvalError::TypeMismatch {
1543 detail: format!("{}() requires a direct argument", spec.name),
1544 });
1545 }
1546 // …and mode() takes NONE: `mode(1)` used to be accepted with
1547 // the argument silently dropped.
1548 if spec.name == "mode" && spec.direct_arg.is_some() {
1549 return Err(ordered_set_signature_error(&spec.name, spec, columns));
1550 }
1551 // v7.39 (read01 orderedsetaggs.c) — the hypothetical-set
1552 // family supports the multi-key form: one direct argument
1553 // per sort key (PG resolves a mismatch as a missing
1554 // function overload; its HINT carries the real rule).
1555 let hypothetical = matches!(
1556 spec.name.as_str(),
1557 "rank" | "dense_rank" | "percent_rank" | "cume_dist"
1558 );
1559 // Only the hypothetical-set family takes a multi-key sort
1560 // spec, and then it needs exactly one direct argument per
1561 // key. PG reports every mismatch as a missing overload.
1562 if hypothetical {
1563 if 1 + spec.direct_args_extra.len() != spec.order_by.len() {
1564 return Err(ordered_set_signature_error(&spec.name, spec, columns));
1565 }
1566 } else if spec.order_by.len() > 1 || !spec.direct_args_extra.is_empty() {
1567 // `percentile_cont(0.5, 0.6)` and `mode(1)` used to be
1568 // silently accepted (the extra arguments were dropped and
1569 // the aggregate answered anyway).
1570 return Err(ordered_set_signature_error(&spec.name, spec, columns));
1571 }
1572 // v7.39 (round 255) — `percentile_cont` interpolates, so PG
1573 // declares it only over the numeric tower and interval
1574 // (probed: text / date / timestamp / bool are refused, while
1575 // `percentile_disc` and `mode` take any sortable type). SPG
1576 // answered NULL for the refused types. Judged from the
1577 // STATICALLY known type only — an unknown one is let through
1578 // (round 237: refusing a legal query is worse than missing an
1579 // illegal one).
1580 if spec.name == "percentile_cont"
1581 && let Some(o) = spec.order_by.first()
1582 && matches!(o.expr, Expr::Cast { .. } | Expr::Column(_))
1583 && let Some(sch) = crate::describe::describe_expr(&o.expr, columns)
1584 && !matches!(
1585 sch.ty,
1586 spg_storage::DataType::SmallInt
1587 | spg_storage::DataType::Int
1588 | spg_storage::DataType::BigInt
1589 | spg_storage::DataType::Float
1590 | spg_storage::DataType::Real
1591 | spg_storage::DataType::Numeric { .. }
1592 | spg_storage::DataType::Interval
1593 )
1594 {
1595 return Err(ordered_set_signature_error(&spec.name, spec, columns));
1596 }
1597 }
1598 }
1599 Ok(())
1600}
1601
1602/// (1) Stream the WHERE-filtered rows, group by the GROUP BY value
1603/// tuple, and update per-group aggregate state. Returns the groups in
1604/// insertion order. See `run` for the bind-once fast path rationale.
1605/// v7.39 (round 665) — the running numeric state a sum/avg keeps, in ONE
1606/// place.
1607///
1608/// It used to live in four independently written copies: `FusedAcc`'s own
1609/// fields, `AggState`'s own fields, and twice more as loose locals inside
1610/// `accumulate_groups`. `FusedAcc`'s doc comment described that openly —
1611/// "field-for-field the same running state the single-spec sum/avg fast
1612/// path keeps in locals" — so the duplication was deliberate manual
1613/// inlining, not drift.
1614///
1615/// The cost was not abstract. Round 664 measured it: adding one guard to
1616/// the sum/avg family meant editing FOUR sites, and three of the four were
1617/// found only by running a different SQL shape and watching the wrong
1618/// answer come back. Reading the code did not reveal them, because the
1619/// three parallel loops in the fused block are not symmetric — the middle
1620/// one is a `length()` shortcut that accumulates nothing numeric.
1621///
1622/// `count` deliberately stays outside: `count(*)` keeps it too, and it is
1623/// not part of the numeric running state.
1624#[derive(Debug, Default, Clone)]
1625struct NumAcc {
1626 sum_int: i64,
1627 sum_float: f64,
1628 use_float: bool,
1629 float_not_real: bool,
1630 sum_num_scaled: i128,
1631 sum_num_kind: spg_storage::NumericKind,
1632 sum_num_scale: u16,
1633 /// v7.39 (read01 numeric.c) — bignum spill; see `SumBig`.
1634 sum_big: SumBig,
1635 use_numeric: bool,
1636 sum_iv_months: i64,
1637 sum_iv_days: i64,
1638 sum_iv_micros: i128,
1639 use_interval: bool,
1640 sum_money: i128,
1641 use_money: bool,
1642 /// Inside the struct, not beside it. Measured: splitting it out gave
1643 /// `acc_cell` two base pointers where the copy it replaced had one,
1644 /// and `sum(int)` over 500k rows lost ~8% (paired, n=12, p=0.04).
1645 /// `count(*)` reading `st.num.count` is a small price for that.
1646 count: i64,
1647}
1648
1649#[allow(clippy::too_many_lines, clippy::type_complexity)]
1650/// v7.37.16 — per-spec accumulator for the fused multi-spec fast path.
1651/// Field-for-field the same running state the single-spec sum/avg fast
1652/// path keeps in locals; finalized into `AggState` identically.
1653#[derive(Default, Clone)]
1654struct FusedAcc {
1655 /// The shared sum/avg running state (see `NumAcc`).
1656 num: NumAcc,
1657 /// v7.39 (round 568/569) — the min/max lane. `min` and `max` were
1658 /// the only ordinary aggregates the fused layout did not accept, so
1659 /// they fell to the generic per-spec machinery and cost DOUBLE a
1660 /// `sum` over the same scan (500k INTs: sum 13.4 ms, min 26.5,
1661 /// max 27.6, while PG18 is flat at 8.2 for all three). They also
1662 /// missed the shard-parallel scan the fused path runs.
1663 extreme: Option<Value<'static>>,
1664 /// Which way this accumulator's comparison goes, so a shard merge
1665 /// does not need to be told.
1666 extreme_max: bool,
1667 extreme_mysql: bool,
1668 /// v7.39 (round 690) — the argument's declared collation, so a
1669 /// shard merge compares the two extremes the same way the scan did.
1670 extreme_coll: Option<alloc::string::String>,
1671 /// v7.39 (round 724) — the collection lanes: string_agg / array_agg
1672 /// items in ROW order (shard merge concatenates in shard order,
1673 /// which IS row order), plus the flat ORDER BY keys (round 723's
1674 /// layout). The finalize sort/join is the existing AggState path.
1675 items: Vec<Value<'static>>,
1676 item_keys: Vec<Value<'static>>,
1677}
1678
1679/// v7.39 (round 569) — a fresh accumulator per op, carrying each one's
1680/// comparison direction so `merge_fused` stays a two-argument fold.
1681fn fused_accs(ops: &[FusedOp], mysql: bool) -> Vec<FusedAcc> {
1682 ops.iter()
1683 .map(|op| {
1684 let mut a = FusedAcc::default();
1685 if let FusedOp::Extreme { max, coll, .. } | FusedOp::ExtremeExpr { max, coll, .. } = op
1686 {
1687 a.extreme_max = *max;
1688 a.extreme_mysql = mysql;
1689 a.extreme_coll = coll.clone();
1690 }
1691 a
1692 })
1693 .collect()
1694}
1695
1696/// v7.39 (parallel-agg P3) — the fused-op layout shared by the
1697/// single-group fast path and the parallel GROUP BY fast path.
1698/// `spec_src[i]`: None = count(*) (finalize from the group row
1699/// count); Some(slot) = unique_ops[slot]'s accumulator.
1700enum FusedOp {
1701 CountCol(usize),
1702 AccCol(usize),
1703 /// v7.39 (round 569) — min/max over a bound column.
1704 /// v7.39 (round 690) — `coll` is the column's declared collation.
1705 /// Unlike an enum's member order (which sends the spec to the
1706 /// generic path), a collation rides along, so a collated column
1707 /// keeps the fused lane's shard-parallel scan.
1708 Extreme {
1709 pos: usize,
1710 max: bool,
1711 coll: Option<alloc::string::String>,
1712 },
1713 /// v7.39 (round 716, S07) — the same three shapes over a COMPILED
1714 /// argument expression. `count(least(id, 0))` used to fall off this
1715 /// lane entirely — `fused_layout` only accepted bound columns — and
1716 /// landed in the SERIAL generic loop, which is where the whole 7.6×
1717 /// against PG lived: PG runs the identical cell as a parallel seq
1718 /// scan. The payload is the SPEC INDEX whose `arg_compiled` program
1719 /// to run; the accumulator lanes are the ones the column ops use.
1720 CountExpr(usize),
1721 AccExpr(usize),
1722 ExtremeExpr {
1723 spec: usize,
1724 max: bool,
1725 coll: Option<alloc::string::String>,
1726 },
1727 /// v7.39 (round 724) — string_agg / array_agg over a bound column,
1728 /// optional bound ORDER BY keys. The payload is the spec index; the
1729 /// scan reads arg_pos / order_pos through it. Collection was the
1730 /// last per-row aggregate stuck on the serial generic loop — 32 ms
1731 /// single-threaded on the panel's 500k string_agg where PG runs a
1732 /// parallel plan.
1733 Collect {
1734 spec: usize,
1735 string_kind: bool,
1736 },
1737}
1738
1739/// Returns the (spec_src, unique_ops) layout when EVERY aggregate
1740/// spec is fused-eligible (count*/count/sum/avg over bound columns,
1741/// no FILTER/DISTINCT/arg2/ORDER), else None.
1742fn fused_layout(
1743 agg_specs: &[AggSpec],
1744 arg_pos: &[Option<usize>],
1745 // v7.39 (round 716) — a compiled argument keeps a spec on the fused
1746 // lane now; a bound column still takes the (cheaper) column op.
1747 arg_compiled: &[Option<eval::CompiledExpr>],
1748 // v7.39 (round 724) — bound ORDER BY key positions, for Collect.
1749 order_pos: &[Vec<Option<usize>>],
1750 arg2_literal_val: &[Option<Value<'static>>],
1751) -> Option<(Vec<Option<usize>>, Vec<FusedOp>)> {
1752 if agg_specs.is_empty() {
1753 return None;
1754 }
1755 let has_arg = |i: usize| arg_pos[i].is_some() || arg_compiled[i].is_some();
1756 // v7.39 (round 724) — a collection spec: bound argument, literal
1757 // separator (string_agg), every ORDER BY key a bound column. The
1758 // finalize path (sort + join) is the ordinary AggState one, so
1759 // multi-key and DESC orders are the finalizer's business, not ours.
1760 let collectible = |i: usize, s: &AggSpec| -> bool {
1761 !s.distinct
1762 && s.filter.is_none()
1763 && !s.first_ordered
1764 && arg_pos[i].is_some()
1765 && s.order_by
1766 .iter()
1767 .enumerate()
1768 .all(|(k, _)| order_pos[i].get(k).copied().flatten().is_some())
1769 && match s.name.as_str() {
1770 "string_agg" => matches!(&arg2_literal_val[i], Some(Value::Text(_))),
1771 "array_agg" => s.arg2.is_none() && s.enum_labels.is_none(),
1772 _ => false,
1773 }
1774 };
1775 let eligible = agg_specs.iter().enumerate().all(|(i, s)| {
1776 collectible(i, s)
1777 || (s.filter.is_none()
1778 && s.arg2.is_none()
1779 && s.order_by.is_empty()
1780 && !s.distinct
1781 && !s.first_ordered
1782 && match s.name.as_str() {
1783 "count_star" => s.arg.is_none(),
1784 "count" | "sum" | "avg" => has_arg(i),
1785 // v7.39 (round 569) — an enum argument compares by
1786 // catalog member order, which the fused lane does not
1787 // carry; those keep the generic path.
1788 "min" | "max" => has_arg(i) && s.enum_labels.is_none(),
1789 _ => false,
1790 })
1791 });
1792 if !eligible {
1793 return None;
1794 }
1795 let mut unique_ops: Vec<FusedOp> = Vec::new();
1796 // Compiled dedupe key = the source Expr (same rule the executor-time
1797 // CSE uses): two specs share a slot only when their argument TREES
1798 // are equal, which `fully_compilable`'s purity makes sufficient.
1799 let same_arg = |j: usize, i: usize| agg_specs[j].arg == agg_specs[i].arg;
1800 let spec_src: Vec<Option<usize>> = agg_specs
1801 .iter()
1802 .enumerate()
1803 .map(|(i, s)| match s.name.as_str() {
1804 "count_star" => None,
1805 // Collection ops never share slots (each keeps its own
1806 // items), so no dedupe probe.
1807 "string_agg" | "array_agg" => {
1808 unique_ops.push(FusedOp::Collect {
1809 spec: i,
1810 string_kind: s.name.as_str() == "string_agg",
1811 });
1812 Some(unique_ops.len() - 1)
1813 }
1814 "min" | "max" => {
1815 let max = s.name.as_str() == "max";
1816 let slot = if let Some(p) = arg_pos[i] {
1817 unique_ops
1818 .iter()
1819 .position(|o| {
1820 matches!(o, FusedOp::Extreme { pos, max: m, coll }
1821 if *pos == p && *m == max && *coll == s.arg_collation)
1822 })
1823 .unwrap_or_else(|| {
1824 unique_ops.push(FusedOp::Extreme {
1825 pos: p,
1826 max,
1827 coll: s.arg_collation.clone(),
1828 });
1829 unique_ops.len() - 1
1830 })
1831 } else {
1832 unique_ops
1833 .iter()
1834 .position(|o| {
1835 matches!(o, FusedOp::ExtremeExpr { spec, max: m, coll }
1836 if same_arg(*spec, i) && *m == max && *coll == s.arg_collation)
1837 })
1838 .unwrap_or_else(|| {
1839 unique_ops.push(FusedOp::ExtremeExpr {
1840 spec: i,
1841 max,
1842 coll: s.arg_collation.clone(),
1843 });
1844 unique_ops.len() - 1
1845 })
1846 };
1847 Some(slot)
1848 }
1849 "count" => {
1850 let slot = if let Some(p) = arg_pos[i] {
1851 unique_ops
1852 .iter()
1853 .position(|o| matches!(o, FusedOp::CountCol(q) if *q == p))
1854 .unwrap_or_else(|| {
1855 unique_ops.push(FusedOp::CountCol(p));
1856 unique_ops.len() - 1
1857 })
1858 } else {
1859 unique_ops
1860 .iter()
1861 .position(|o| matches!(o, FusedOp::CountExpr(j) if same_arg(*j, i)))
1862 .unwrap_or_else(|| {
1863 unique_ops.push(FusedOp::CountExpr(i));
1864 unique_ops.len() - 1
1865 })
1866 };
1867 Some(slot)
1868 }
1869 _ => {
1870 let slot = if let Some(p) = arg_pos[i] {
1871 unique_ops
1872 .iter()
1873 .position(|o| matches!(o, FusedOp::AccCol(q) if *q == p))
1874 .unwrap_or_else(|| {
1875 unique_ops.push(FusedOp::AccCol(p));
1876 unique_ops.len() - 1
1877 })
1878 } else {
1879 unique_ops
1880 .iter()
1881 .position(|o| matches!(o, FusedOp::AccExpr(j) if same_arg(*j, i)))
1882 .unwrap_or_else(|| {
1883 unique_ops.push(FusedOp::AccExpr(i));
1884 unique_ops.len() - 1
1885 })
1886 };
1887 Some(slot)
1888 }
1889 })
1890 .collect();
1891 Some((spec_src, unique_ops))
1892}
1893
1894/// v7.39 (parallel-agg P1) — fold shard accumulator `b` into `a`.
1895/// Every FusedAcc field is a running sum plus a type-witness flag, so
1896/// the merge is field-wise addition with `numeric_add` aligning the
1897/// decimal scales. Merging in shard order keeps float summation
1898/// deterministic for a given shard count (PG's parallel aggregate
1899/// makes the same no-serial-equivalence tradeoff for floats).
1900fn merge_fused(a: &mut FusedAcc, b: &mut FusedAcc) {
1901 // v7.39 (round 569) — fold the shard's extreme in the direction this
1902 // accumulator was built for.
1903 if let Some(be) = &b.extreme {
1904 let take = match &a.extreme {
1905 None => true,
1906 Some(ae) => {
1907 let ord = extreme_cmp_in(None, a.extreme_coll.as_deref(), be, ae, a.extreme_mysql);
1908 if a.extreme_max {
1909 ord == core::cmp::Ordering::Greater
1910 } else {
1911 ord == core::cmp::Ordering::Less
1912 }
1913 }
1914 };
1915 if take {
1916 a.extreme = Some(be.clone());
1917 }
1918 }
1919 a.num.count += b.num.count;
1920 a.num.sum_int += b.num.sum_int;
1921 a.num.sum_float += b.num.sum_float;
1922 a.num.use_float |= b.num.use_float;
1923 a.num.float_not_real |= b.num.float_not_real;
1924 if b.num.use_numeric {
1925 // v7.39 (read01 numeric.c) — fold the shard's bignum spill first,
1926 // then its i128 lane (zero if the shard promoted).
1927 if let Some(bb) = &b.num.sum_big {
1928 sum_add_bignum(
1929 &mut a.num.sum_num_scaled,
1930 &mut a.num.sum_num_scale,
1931 &mut a.num.sum_big,
1932 bb,
1933 );
1934 }
1935 sum_add_exact(
1936 &mut a.num.sum_num_scaled,
1937 &mut a.num.sum_num_scale,
1938 &mut a.num.sum_big,
1939 b.num.sum_num_scaled,
1940 b.num.sum_num_scale,
1941 );
1942 a.num.sum_num_kind = fold_sum_kind(a.num.sum_num_kind, b.num.sum_num_kind);
1943 a.num.use_numeric = true;
1944 }
1945 a.num.sum_iv_months += b.num.sum_iv_months;
1946 a.num.sum_iv_days += b.num.sum_iv_days;
1947 a.num.sum_iv_micros += b.num.sum_iv_micros;
1948 a.num.use_interval |= b.num.use_interval;
1949 a.num.sum_money += b.num.sum_money;
1950 a.num.use_money |= b.num.use_money;
1951 // v7.39 (round 724) — collection lanes concatenate; shard order is
1952 // row order. The merge takes `b` by reference (both call sites), so
1953 // this clones — the per-shard vectors are moved into place only at
1954 // fill time.
1955 a.items.extend(core::mem::take(&mut b.items));
1956 a.item_keys.extend(core::mem::take(&mut b.item_keys));
1957}
1958
1959/// v7.39 — write fused accumulators into the per-spec AggStates
1960/// (shared by the single-group and parallel-GROUP-BY fast paths).
1961/// `group_rows` finalizes count(*) specs.
1962/// v7.39 (round 724) — one row's contribution to a fused Collect op.
1963/// Mirrors `update_state`'s StringAgg / ArrayAgg arms: string_agg skips
1964/// NULL and renders through the shared helper (a non-renderable type
1965/// errors with the same sentence); array_agg keeps NULL elements.
1966fn collect_cell(
1967 a: &mut FusedAcc,
1968 row: &crate::join::RowRef<'_>,
1969 pos: usize,
1970 key_pos: &[Option<usize>],
1971 string_kind: bool,
1972) -> Result<(), EvalError> {
1973 let v = row.get(pos).unwrap_or(&Value::Null);
1974 if string_kind {
1975 if matches!(v, Value::Null) {
1976 return Ok(());
1977 }
1978 let Some(item) = render_string_agg_item(v) else {
1979 return Err(EvalError::TypeMismatch {
1980 detail: format!(
1981 "string_agg requires text value, got {}",
1982 crate::conversions::pg_type_name_for_error_opt(v.data_type())
1983 ),
1984 });
1985 };
1986 a.items.push(item);
1987 } else {
1988 a.items.push(v.clone().into_owned());
1989 }
1990 a.num.count += 1;
1991 for kp in key_pos {
1992 let kv = row
1993 .get(kp.expect("layout-gated bound key"))
1994 .cloned()
1995 .map(Value::into_owned)
1996 .unwrap_or(Value::Null);
1997 a.item_keys.push(kv);
1998 }
1999 Ok(())
2000}
2001
2002/// The string_agg item rendering, shared by `update_state` and the
2003/// round-724 fused Collect op — one place, so the two paths cannot
2004/// drift. Text collects as-is; other scalars coerce to their text
2005/// rendering (MySQL group_concat semantics — also matches PG's
2006/// cast-then-aggregate idiom for `string_agg(v::text, sep)`).
2007fn render_string_agg_item(v: &Value<'_>) -> Option<Value<'static>> {
2008 match v {
2009 Value::Text(s) => Some(Value::text(s.clone())),
2010 // v7.39 (round 626, S05b/F29) — CHAR(n). PG aggregates a
2011 // bpchar column (`string_agg(c, ',')` -> text) and SPG said
2012 // "string_agg requires text value, got character". The text
2013 // form of a bpchar drops its padding, which is what PG's
2014 // own bpchar->text cast does.
2015 Value::BpChar(s) => Some(Value::text(s.trim_end_matches(' ').to_string())),
2016 // v7.39 (read01 round 111) — xmlagg feeds xml values through this
2017 // shared StringAgg path; render the fragment's text (it joins
2018 // separator-less into the concatenated document).
2019 Value::Xml(s) => Some(Value::text(s.to_string())),
2020 Value::Int(n) => Some(Value::text(n.to_string())),
2021 Value::BigInt(n) => Some(Value::text(n.to_string())),
2022 Value::SmallInt(n) => Some(Value::text(n.to_string())),
2023 Value::Float(f) => Some(Value::text(f.to_string())),
2024 Value::Bool(b) => Some(Value::text(if *b { "1" } else { "0" })),
2025 // v7.39.2 — PG18 answers `string_agg(bytea, ',')` with a BYTEA
2026 // (`\x412c42` for 'A' and 'B' joined by a comma); SPG refused it on
2027 // both dialects, which is also what made MySQL's
2028 // `GROUP_CONCAT(X'41')` an error. The bytes stay bytes and the join
2029 // below decides the result's type from them.
2030 Value::Bytes(b) => Some(Value::bytes(b.clone().into_owned())),
2031 _ => None,
2032 }
2033}
2034
2035fn fill_states_from_fused(
2036 states: &mut [AggState],
2037 spec_src: &[Option<usize>],
2038 accs: &mut [FusedAcc],
2039 group_rows: i64,
2040 // v7.39 (round 724) — string_agg's literal separator, per spec.
2041 arg2_literal_val: &[Option<Value<'static>>],
2042) {
2043 for (i, src) in spec_src.iter().enumerate() {
2044 let state = &mut states[i];
2045 match src {
2046 None => state.num.count = group_rows,
2047 Some(slot) => {
2048 // Collection lanes MOVE (they are per-spec, never
2049 // shared; see the layout's no-dedupe rule).
2050 {
2051 let a = &mut accs[*slot];
2052 if !a.items.is_empty() {
2053 state.items = core::mem::take(&mut a.items);
2054 state.item_keys = core::mem::take(&mut a.item_keys);
2055 }
2056 }
2057 // v7.39.2 — no bytea arm here, deliberately: a bytea
2058 // separator is always written as a CAST (`'\x2c'::bytea`,
2059 // and MySQL's `X'2c'` lowers onto the same cast), never as
2060 // a bare literal, so this fused lane — literal separators
2061 // only — cannot see one. An arm was written and removed
2062 // again when no shape could be found that reached it.
2063 if let Some(Value::Text(sep)) = &arg2_literal_val[i] {
2064 state.separator = Some(sep.as_bytes().to_vec());
2065 }
2066 let a = &accs[*slot];
2067 state.num.count = a.num.count;
2068 state.num.sum_int = a.num.sum_int;
2069 state.num.sum_float = a.num.sum_float;
2070 state.num.use_float = a.num.use_float;
2071 state.num.float_not_real = a.num.float_not_real;
2072 state.num.sum_num_scaled = a.num.sum_num_scaled;
2073 state.num.sum_num_kind = a.num.sum_num_kind;
2074 state.num.sum_num_scale = a.num.sum_num_scale;
2075 state.num.sum_big = a.num.sum_big.clone();
2076 state.num.use_numeric = a.num.use_numeric;
2077 state.num.sum_iv_months = a.num.sum_iv_months;
2078 state.num.sum_iv_days = a.num.sum_iv_days;
2079 state.num.sum_iv_micros = a.num.sum_iv_micros;
2080 state.num.use_interval = a.num.use_interval;
2081 state.num.sum_money = a.num.sum_money;
2082 state.num.use_money = a.num.use_money;
2083 if a.extreme.is_some() {
2084 state.extreme = a.extreme.clone();
2085 }
2086 }
2087 }
2088 }
2089}
2090
2091/// v7.39 (read01 numeric.c) — the bignum spill lane of the NUMERIC sum
2092/// tri-state (i128 mantissa + scale + optional BigNumeric). `None` until the
2093/// i128 lane would overflow; from then on the sum lives in the spill and the
2094/// i128 lane stays frozen at zero (PG's sum(numeric) never saturates).
2095type SumBig = Option<alloc::boxed::Box<spg_storage::bignum::BigNumeric>>;
2096
2097/// Add an exact NUMERIC (mantissa × 10^-scale) into the sum tri-state.
2098fn sum_add_exact(
2099 scaled: &mut i128,
2100 scale: &mut u16,
2101 big: &mut SumBig,
2102 add_scaled: i128,
2103 add_scale: u16,
2104) {
2105 use spg_storage::bignum::BigNumeric;
2106 if let Some(b) = big {
2107 **b = b.add(&BigNumeric::from_i128(add_scaled, add_scale));
2108 return;
2109 }
2110 match crate::numeric::numeric_add_checked(*scaled, *scale, add_scaled, add_scale) {
2111 Some((s, sc)) => {
2112 *scaled = s;
2113 *scale = sc;
2114 }
2115 None => {
2116 *big = Some(alloc::boxed::Box::new(
2117 BigNumeric::from_i128(*scaled, *scale)
2118 .add(&BigNumeric::from_i128(add_scaled, add_scale)),
2119 ));
2120 *scaled = 0;
2121 *scale = 0;
2122 }
2123 }
2124}
2125
2126/// Add a BigNumeric input into the sum tri-state (promotes immediately).
2127fn sum_add_bignum(
2128 scaled: &mut i128,
2129 scale: &mut u16,
2130 big: &mut SumBig,
2131 b_in: &spg_storage::bignum::BigNumeric,
2132) {
2133 use spg_storage::bignum::BigNumeric;
2134 let cur = match big.take() {
2135 Some(b) => *b,
2136 None => {
2137 let c = BigNumeric::from_i128(*scaled, *scale);
2138 *scaled = 0;
2139 *scale = 0;
2140 c
2141 }
2142 };
2143 *big = Some(alloc::boxed::Box::new(cur.add(b_in)));
2144}
2145
2146/// One sum/avg accumulation step — the same variant arms (and the same
2147/// error text) as the single-spec fast path's inline match.
2148#[inline]
2149/// v7.39 (round 569) — one row's contribution to a min/max lane.
2150///
2151/// The same question `accumulate_groups` asks per spec per row, with
2152/// none of the per-spec indexing around it. NULL contributes nothing,
2153/// which is PG's rule and the generic path's.
2154fn fused_extreme_cell(a: &mut FusedAcc, v: &Value<'_>, max: bool) -> Result<(), EvalError> {
2155 if matches!(v, Value::Null) {
2156 return Ok(());
2157 }
2158 // v7.39 (round 626) — the FOURTH place this comparison is made. The
2159 // deny list went onto the dispatched arm and the two inlined grouped
2160 // copies first, and `SELECT min(bool_col) FROM t` — no GROUP BY — still
2161 // answered, because it lands here.
2162 if !a.extreme_mysql && min_max_unsupported_type(v) {
2163 return Err(EvalError::TypeMismatch {
2164 detail: format!(
2165 "function {}({}) does not exist",
2166 if max { "max" } else { "min" },
2167 crate::conversions::pg_type_name_for_error_opt(v.data_type())
2168 ),
2169 });
2170 }
2171 let take = match &a.extreme {
2172 None => true,
2173 Some(prev) => {
2174 let ord = extreme_cmp_in(None, a.extreme_coll.as_deref(), v, prev, a.extreme_mysql);
2175 if max {
2176 ord == core::cmp::Ordering::Greater
2177 } else {
2178 ord == core::cmp::Ordering::Less
2179 }
2180 }
2181 };
2182 if take {
2183 a.extreme = Some(v.clone().into_owned());
2184 }
2185 Ok(())
2186}
2187
2188/// v7.39 (round 626, S05b/F29) — the types PG has no `min`/`max` for.
2189///
2190/// A DENY list, not an allow list, and every entry measured: PG accepts
2191/// min/max over int2 int4 int8 numeric float4 float8 money text varchar
2192/// bpchar name date time timetz timestamp timestamptz interval bytea inet
2193/// cidr and the array types, and refuses exactly these. Writing the allow
2194/// list instead is how round 625's first cut of the string guard managed to
2195/// refuse five overloads PG actually has; a deny list of measured
2196/// rejections cannot over-refuse.
2197fn min_max_unsupported_type(v: &Value<'_>) -> bool {
2198 matches!(
2199 v.data_type(),
2200 Some(
2201 spg_storage::DataType::Bool
2202 | spg_storage::DataType::Uuid
2203 | spg_storage::DataType::Macaddr
2204 | spg_storage::DataType::Macaddr8
2205 | spg_storage::DataType::Json
2206 | spg_storage::DataType::Jsonb
2207 | spg_storage::DataType::Bit(_)
2208 | spg_storage::DataType::BitVarying(_)
2209 | spg_storage::DataType::Xml
2210 | spg_storage::DataType::TsVector
2211 | spg_storage::DataType::TsQuery
2212 // v7.39 (round 641) — a transaction id has no ordering
2213 // operator, so PG has no `min(xid)` / `max(xid)` either:
2214 // "function min(xid) does not exist", measured. SPG
2215 // answered, because a Value::Xid carries a u32 that
2216 // compares perfectly well — which is exactly the trap
2217 // the type exists to avoid.
2218 | spg_storage::DataType::Xid
2219 )
2220 )
2221}
2222
2223/// Fold one value into a running sum/avg. THE accumulator — there is no
2224/// second copy, by design; see `NumAcc` for what four copies cost.
2225///
2226/// No `inline(always)` here, and the reason is measured rather than
2227/// stylistic. The four copies were hand-inlining, so the obvious guess was
2228/// that the collapse would cost a call per row and the attribute would buy
2229/// it back. It did not: with `count` split out of `NumAcc`, `sum(int)`
2230/// over 500k rows lost ~8% WITH the attribute applied. What actually
2231/// mattered was the pointer count — the copy this replaces took one
2232/// `&mut FusedAcc`, and passing `&mut NumAcc` plus a separate `&mut i64`
2233/// made two base pointers. Folding `count` back into the struct closed the
2234/// gap; the attribute never did, so it is not here.
2235fn acc_cell(a: &mut NumAcc, v: &Value<'_>) -> Result<(), EvalError> {
2236 match v {
2237 Value::Null => {}
2238 Value::SmallInt(n) => {
2239 a.sum_int += i64::from(*n);
2240 a.count += 1;
2241 }
2242 Value::Int(n) => {
2243 a.sum_int += i64::from(*n);
2244 a.count += 1;
2245 }
2246 // v7.38 (read01, T4) — BIGINT sums as exact NUMERIC (PG).
2247 Value::BigInt(n) => {
2248 sum_add_exact(
2249 &mut a.sum_num_scaled,
2250 &mut a.sum_num_scale,
2251 &mut a.sum_big,
2252 i128::from(*n),
2253 0,
2254 );
2255 a.use_numeric = true;
2256 a.count += 1;
2257 }
2258 Value::Float(x) => {
2259 a.sum_float += *x;
2260 a.use_float = true;
2261 a.float_not_real = true;
2262 a.count += 1;
2263 }
2264 Value::Real(x) => {
2265 a.sum_float += f64::from(*x);
2266 a.use_float = true;
2267 a.count += 1;
2268 }
2269 Value::Numeric {
2270 scaled,
2271 scale,
2272 kind,
2273 } => {
2274 sum_add_exact(
2275 &mut a.sum_num_scaled,
2276 &mut a.sum_num_scale,
2277 &mut a.sum_big,
2278 *scaled,
2279 *scale,
2280 );
2281 a.sum_num_kind = fold_sum_kind(a.sum_num_kind, *kind);
2282 a.use_numeric = true;
2283 a.count += 1;
2284 }
2285 // v7.39 (read01 numeric.c) — a NumericBig input promotes to the spill.
2286 Value::NumericBig(b) => {
2287 sum_add_bignum(
2288 &mut a.sum_num_scaled,
2289 &mut a.sum_num_scale,
2290 &mut a.sum_big,
2291 b,
2292 );
2293 a.use_numeric = true;
2294 a.count += 1;
2295 }
2296 Value::Interval {
2297 months,
2298 days,
2299 micros,
2300 kind,
2301 } => {
2302 a.sum_iv_months += i64::from(*months);
2303 a.sum_iv_days += i64::from(*days);
2304 a.sum_iv_micros += i128::from(*micros);
2305 a.use_interval = true;
2306 a.count += 1;
2307 }
2308 Value::Money(c) => {
2309 a.sum_money += i128::from(*c);
2310 a.use_money = true;
2311 a.count += 1;
2312 }
2313 other => {
2314 return Err(EvalError::TypeMismatch {
2315 detail: format!(
2316 "sum/avg need numeric, got {}",
2317 crate::conversions::pg_type_name_for_error_opt(other.data_type())
2318 ),
2319 });
2320 }
2321 }
2322 Ok(())
2323}
2324
2325/// v7.39 (read01 round 61) — thread the catalog into a stage's context when the
2326/// caller has one. `EvalContext::with_catalog` takes a reference, so this keeps
2327/// the Option handling in one place rather than at four call sites.
2328fn with_catalog<'a>(
2329 ctx: EvalContext<'a>,
2330 catalog: Option<&'a spg_storage::Catalog>,
2331 engine: Option<&'a crate::Engine>,
2332) -> EvalContext<'a> {
2333 let ctx = match catalog {
2334 Some(c) => ctx.with_catalog(c),
2335 None => ctx,
2336 };
2337 match engine {
2338 Some(e) => ctx.with_engine(e),
2339 None => ctx,
2340 }
2341}
2342
2343fn accumulate_groups(
2344 rows: AggRows<'_>,
2345 group_exprs: &[Expr],
2346 agg_specs: &[AggSpec],
2347 schema_cols: &[ColumnSchema],
2348 table_alias: Option<&str>,
2349 correlated_eval: Option<CorrelatedEval<'_>>,
2350 runner: Option<&dyn crate::ParallelRunner>,
2351 // v7.39 (read01 round 61) — the catalog. `run` has carried it since the
2352 // enum-order knife, but the four stages below each built a BARE context and
2353 // dropped it — so a catalog-dependent expression inside an aggregate's
2354 // argument (`string_agg(f1(id), ',')`, a user function) answered "unknown
2355 // function". Same family as rounds 49/53/54/55/56.
2356 catalog: Option<&spg_storage::Catalog>,
2357 engine: Option<&crate::Engine>,
2358) -> Result<Vec<(Vec<Value<'static>>, Vec<AggState>)>, EvalError> {
2359 let ctx = with_catalog(EvalContext::new(schema_cols, table_alias), catalog, engine);
2360 // Map group key (vec of values, encoded as canonical string) -> group state.
2361 // v7.32 (architecture v2, P2b) — insertion-ordered group state in
2362 // a Vec; the hash map only maps key → index. Removes the parallel
2363 // `key_order: Vec<String>` (a second per-group key clone) and the
2364 // per-group re-probe `groups[k]` at finalize (24k hash lookups for
2365 // the inbox shape). The map owns its key once on vacant insert.
2366 let mut order: Vec<(Vec<Value<'static>>, Vec<AggState>)> = Vec::new();
2367 let mut groups: hashbrown::HashMap<String, usize> = hashbrown::HashMap::new();
2368 // v7.37.x (mailrs Track A perf — SPGE ≫ PG18) — single-Text GROUP
2369 // BY column fast path. The canonical-string encode (`S<text>|`)
2370 // + `encode_key_refs_into` reuse-buffer churn dominated the 30 k-
2371 // row mailrs minimal probe (~3-4 ms / 30 k). For `GROUP BY t` on
2372 // a TEXT column (the inbox-listing / conversation-grouping shape)
2373 // the column text IS the canonical key — no encoder, no prefix
2374 // byte, no `refs` Vec rebuild per row. The fallback `groups` map
2375 // above is retained for multi-col / non-Text / collation paths;
2376 // this map only fires when the schema and value structurally
2377 // permit it. `null_group_idx` collects NULL group rows (SQL groups
2378 // all NULLs into one bucket).
2379 let mut groups_text: hashbrown::HashMap<String, usize> = hashbrown::HashMap::new();
2380 // v7.37.16 — raw-i64 group map for the single-INT GROUP BY fast path.
2381 let mut groups_int: hashbrown::HashMap<i64, usize> = hashbrown::HashMap::new();
2382 let mut null_group_idx: Option<usize> = None;
2383 // When there are no GROUP BY exprs *and* there is at least one aggregate,
2384 // every row collapses into a single anonymous group keyed by "".
2385 if rows.is_empty() && group_exprs.is_empty() {
2386 // Single empty-aggregate group: count=0, sum=0, max=NULL, etc.
2387 // No rows follow, so the map is never probed — seed `order` only.
2388 let init: Vec<AggState> = (0..agg_specs.len()).map(|_| AggState::default()).collect();
2389 order.push((Vec::new(), init));
2390 }
2391
2392 // v7.30 (perf campaign) - hoist the per-row work that doesn't
2393 // depend on the row: which group exprs need collation folding
2394 // (none, for most queries - the old code cloned the whole
2395 // group_vals vec per row just in case).
2396 // v7.30 (perf campaign) - the no-tax row loop. When a group
2397 // expr or an aggregate argument is a bare column reference
2398 // (the overwhelmingly common shape), bind its position ONCE
2399 // and read row cells by offset in the loop - no per-row tree
2400 // walk, no owned-Value clone out of resolve_column. Anything
2401 // more complex keeps the eval path.
2402 let col_pos = |e: &Expr| -> Option<usize> {
2403 // v7.37.16 — bind bare names too, via the compiled-WHERE
2404 // resolver: `compile_column_pos` mirrors resolve_column's
2405 // happy layers exactly (composite → prefix/alias gate → bare
2406 // exact → unique suffix) and returns None on anything that
2407 // would reach an ambiguity / whole-row / error path, so the
2408 // eval fallback keeps identical semantics. Previously only
2409 // qualified refs bound (via the looser find_column_pos), so
2410 // single-table `GROUP BY g` / `avg(v)` ran the per-row
2411 // eval_expr tree-walk + Vec + encode_key String alloc — the
2412 // heavy.rs group_by / filter_agg residual loss vs PG18.
2413 if let Expr::Column(c) = e {
2414 eval::compile_column_pos(c, &ctx)
2415 } else {
2416 None
2417 }
2418 };
2419 let group_pos: Vec<Option<usize>> = group_exprs.iter().map(col_pos).collect();
2420 let all_groups_bound = group_pos.iter().all(Option::is_some);
2421 // v7.37.x — single-col GROUP BY on a TEXT-typed column lets the
2422 // hot loop key the hash map by the column text directly. Resolved
2423 // once from the bound position against `schema_cols`.
2424 // v7.39 (round 364, M4 P2) — the raw-text GROUP BY fast path keys
2425 // by the column's bytes, which cannot fold; a MySQL session takes
2426 // the general encoder path (which folds) instead.
2427 let single_text_group_col: bool = !ctx.mysql_dialect
2428 && group_pos.len() == 1
2429 && group_pos[0].is_some_and(|p| {
2430 schema_cols
2431 .get(p)
2432 .is_some_and(|c| matches!(c.ty, spg_storage::DataType::Text))
2433 });
2434 // v7.37.16 (heavy.rs group_500k 1.12× loss) — single-col GROUP BY on
2435 // an INTEGER-typed column keys the map by the raw i64 instead of the
2436 // canonical-string encode ("I{n}|" write! + String-keyed hash probe
2437 // was ~25-40 ns of the 42 ns/row 500k GROUP BY budget). Mirrors the
2438 // single-Text fast path; NULLs share `null_group_idx`; a non-integer
2439 // cell (coercion edge) falls back to the encoded path.
2440 let single_int_group_col: bool = group_pos.len() == 1
2441 && group_pos[0].is_some_and(|p| {
2442 schema_cols.get(p).is_some_and(|c| {
2443 matches!(
2444 c.ty,
2445 spg_storage::DataType::SmallInt
2446 | spg_storage::DataType::Int
2447 | spg_storage::DataType::BigInt
2448 )
2449 })
2450 });
2451 let arg_pos: Vec<Option<usize>> = agg_specs
2452 .iter()
2453 .map(|spec| spec.arg.as_ref().and_then(|e| col_pos(e)))
2454 .collect();
2455 // v7.39 (round 370, M4 P4a) — the MySQL dialect folds GROUP BY /
2456 // DISTINCT text keys (M4 P2), EXCEPT over a column with an explicit
2457 // `COLLATE utf8mb4_bin` (stored `Binary`), which de-dups byte-wise.
2458 // A folding default column stores `CaseInsensitive`, so only an
2459 // explicit binary column suppresses the fold. Multi-column GROUP BY
2460 // mixing a binary and a folding column is treated byte-wise as a whole
2461 // (rare; residual).
2462 let is_binary_key_col = |p: Option<usize>| -> bool {
2463 p.and_then(|i| schema_cols.get(i))
2464 .is_some_and(|c| matches!(c.collation, spg_storage::Collation::Binary))
2465 };
2466 // v7.39 (round 371, M4 P4b) — a per-expression `… COLLATE utf8mb4_bin`
2467 // / `BINARY …` key is byte-wise too, so its GROUP BY / DISTINCT does
2468 // not fold. The clause lowers to a `binary` cast the parser emits.
2469 let mysql_fold_groups: bool = ctx.mysql_dialect
2470 && !group_pos.iter().any(|&p| is_binary_key_col(p))
2471 && !group_exprs
2472 .iter()
2473 .any(|e| crate::eval::is_binary_coerced(e));
2474 // v7.38.18 — the padding mask, built beside the fold mask off the
2475 // same argument column so the two cannot come from different places.
2476 let distinct_pads: Vec<bool> = arg_pos
2477 .iter()
2478 .map(|&p| {
2479 p.and_then(|i| schema_cols.get(i))
2480 .is_some_and(|c| crate::collate::pads_space(c.collation_name.as_deref()))
2481 })
2482 .collect();
2483 let distinct_fold_case: Vec<bool> = arg_pos.iter().map(|&p| !is_binary_key_col(p)).collect();
2484 let distinct_fold: Vec<bool> = agg_specs
2485 .iter()
2486 .enumerate()
2487 .map(|(i, spec)| {
2488 // v7.38.18 — a byte-wise column still needs this step when
2489 // its collation PADS. `utf8mb4_bin` folds no case and
2490 // ignores trailing spaces, which is one flag short of what
2491 // a single boolean can say; the pad mask beside this one
2492 // carries the second half and the fold is skipped by
2493 // `distinct_fold_case` below.
2494 ctx.mysql_dialect
2495 && (!is_binary_key_col(arg_pos[i]) || distinct_pads[i])
2496 && !spec
2497 .arg
2498 .as_ref()
2499 .is_some_and(|e| crate::eval::is_binary_coerced(e))
2500 })
2501 .collect();
2502 // v7.37.x (mailrs Track A 100k attack) — dedicated tight loop
2503 // for the "single-Text GROUP BY + single MAX(bound numeric arg)"
2504 // shape. This is the mailrs `/api/conversations` minimal shape
2505 // (`GROUP BY thread_id, MAX(internal_date)`) and an inbox-listing
2506 // staple across the SPG customer set. Skipping the per-row spec
2507 // loop, FILTER / arg2 / order_keys checks, and the union-typed
2508 // `update_state` enum jump saves ~80-100 ns/row at 100 k input
2509 // — the gap closing the SPGE vs PG18 ratio at this scale.
2510 let dedicated_max_loop: bool = single_text_group_col
2511 && agg_specs.len() == 1
2512 && matches!(agg_specs[0].kind, AggKind::Max)
2513 && agg_specs[0].filter.is_none()
2514 && agg_specs[0].arg2.is_none()
2515 && agg_specs[0].order_by.is_empty()
2516 && !agg_specs[0].distinct
2517 && !agg_specs[0].first_ordered
2518 && arg_pos[0].is_some();
2519 // v7.36 (perf — mailrs Ask 1 SUM(LENGTH(text_body)) 18ms → ?) —
2520 // pre-compile every aggregate arg that's a `fully_compilable`
2521 // PURE expression over bound columns. Without this, `LENGTH(col)`
2522 // / `COALESCE(col, '')` / `CAST(col AS BIGINT)` etc. ALL fell
2523 // through to the `(None, Some(e)) => eval_arg(e, mat, ...)` slow
2524 // path that materialises a Cow<Row> per input row — for a 25k-row
2525 // JOIN that's 25k full-row clones for one column read. The Step
2526 // VM (`eval_compiled_ref`) reads columns by RowRef::get and runs
2527 // the same `apply_function` dispatcher with zero materialisation.
2528 let arg_compiled: Vec<Option<eval::CompiledExpr>> = agg_specs
2529 .iter()
2530 .enumerate()
2531 .map(|(i, spec)| match (&arg_pos[i], &spec.arg) {
2532 (Some(_), _) => None,
2533 (None, Some(e)) if eval::fully_compilable(e) => Some(eval::compile_expr(e, &ctx)),
2534 _ => None,
2535 })
2536 .collect();
2537 // v7.37.4 (L1 — executor-time CSE / mailrs P0) — dedupe
2538 // compiled aggregate-arg expressions across specs. mailrs's
2539 // `/api/conversations` SQL has 14 aggregates whose compiled
2540 // CASE/CAST arg expressions overlap heavily (`m.message_id != ''`
2541 // re-appears 4×, the inner `CASE WHEN m.message_id != '' THEN
2542 // m.message_id ELSE CAST(m.id AS TEXT) END` re-appears 3×). Each
2543 // dup currently costs one Step-VM walk per row — 100k rows ×
2544 // ~3-4 redundant evals = ~300-400k wasted Step-VM runs.
2545 //
2546 // Dedupe key = source `Expr` (PartialEq). `CompiledExpr` itself
2547 // is not `Hash` / `Eq`, but n_specs is small (≤ ~20 in practice);
2548 // O(n²) PartialEq probe cost = ~196 cmp per query, vs millions
2549 // of saved per-row evals. `fully_compilable` requires PURE
2550 // scalars (no NOW / RANDOM / sequence accessors), so an earlier
2551 // eval has identical observable semantics to the original.
2552 //
2553 // `arg_slot[i] = Some(s)` means spec `i`'s compiled arg lives in
2554 // slot `s` of `arg_unique_idx` (which points back into
2555 // `arg_compiled` for the canonical owner). Per-row cache fills
2556 // LAZILY — preserves the current FILTER semantics where an arg
2557 // whose spec is filtered out is never evaluated (and never
2558 // surfaces a type error). Reset to `None` at the top of each row.
2559 let mut arg_unique_idx: Vec<usize> = Vec::new();
2560 let mut arg_slot: Vec<Option<usize>> = Vec::with_capacity(agg_specs.len());
2561 arg_slot.resize(agg_specs.len(), None);
2562 for (i, spec) in agg_specs.iter().enumerate() {
2563 if arg_pos[i].is_some() || arg_compiled[i].is_none() {
2564 continue;
2565 }
2566 let src = spec.arg.as_ref().expect("arg_compiled => spec.arg is Some");
2567 let pos = arg_unique_idx
2568 .iter()
2569 .position(|&j| agg_specs[j].arg.as_ref().is_some_and(|other| other == src));
2570 arg_slot[i] = Some(match pos {
2571 Some(p) => p,
2572 None => {
2573 arg_unique_idx.push(i);
2574 arg_unique_idx.len() - 1
2575 }
2576 });
2577 }
2578 let mut row_eval_cache: Vec<Option<Value>> = Vec::with_capacity(arg_unique_idx.len());
2579 row_eval_cache.resize(arg_unique_idx.len(), None);
2580 // v7.33 (array_agg perf) — bound positions for each spec's internal
2581 // ORDER BY keys, so an ordered aggregate (`array_agg(x ORDER BY y)`)
2582 // reads the sort key by reference (RowRef::get) instead of
2583 // materialising the whole combined join row per input row just to
2584 // eval one bound column. Mirrors arg_pos. On the inbox shape this
2585 // turned 24k full-row (~1 KB each) clones into 24k single-cell reads.
2586 let order_pos: Vec<Vec<Option<usize>>> = agg_specs
2587 .iter()
2588 .map(|spec| spec.order_by.iter().map(|o| col_pos(&o.expr)).collect())
2589 .collect();
2590 // v7.37.43 (DISTA A-3) — precompute the per-spec arg2 when it is a
2591 // bare literal. `string_agg(DISTINCT col, ',')` and every other
2592 // call with a constant separator goes through this path; PG evaluates
2593 // arg2 as a Const once at plan time. SPG was paying a Cow row
2594 // materialisation per input row purely so `eval_arg(literal, &row)`
2595 // could run — but a literal doesn't read the row at all. Hoist the
2596 // literal value into a per-query table; per-row arg2 just clones it.
2597 //
2598 // Sentinel: when arg2 is present but NOT a literal, the entry stays
2599 // `None` and the per-row path still falls into the eval branch
2600 // (which forces `needs_mat`).
2601 let arg2_literal_val: Vec<Option<Value<'static>>> = agg_specs
2602 .iter()
2603 .map(|s| match &s.arg2 {
2604 Some(Expr::Literal(l)) => Some(eval::literal_to_value(l)),
2605 _ => None,
2606 })
2607 .collect();
2608 // Does any spec need the fully-materialised row in the bound fast
2609 // path — a FILTER, a non-bound value arg, a NON-LITERAL second arg,
2610 // or a non-bound ORDER key? When false (every aggregate arg/key is a
2611 // bound column — the inbox shape, and the DISTA shape after A-3)
2612 // the bound fast path never materialises a row.
2613 let needs_mat = agg_specs.iter().enumerate().any(|(i, s)| {
2614 s.filter.is_some()
2615 || (s.arg.is_some() && arg_pos[i].is_none() && arg_compiled[i].is_none())
2616 || (s.arg2.is_some() && arg2_literal_val[i].is_none())
2617 || order_pos[i].iter().any(Option::is_none)
2618 });
2619 let ci_positions: Vec<usize> = group_exprs
2620 .iter()
2621 .enumerate()
2622 .filter(|(_, g)| {
2623 matches!(
2624 eval::column_collation(g, &ctx),
2625 Some(spg_storage::Collation::CaseInsensitive)
2626 )
2627 })
2628 .map(|(i, _)| i)
2629 .collect();
2630 // v7.31 (perf 3e) — per-row scratch buffers. The fast path used
2631 // to allocate a key String (and a refs Vec) for EVERY row just
2632 // to probe the group map; hits — the overwhelming case — now
2633 // touch the allocator zero times.
2634 let mut keybuf_s = String::new();
2635 // v7.36 — reused Step VM eval stack for compiled aggregate args.
2636 // v7.37.9 T3 S2 — elided lifetime so the Vec's `'val` binds to the
2637 // row-borrow lifetime per call (`eval_compiled_ref<'row, 'val>` now
2638 // requires `'row: 'val`). Caller-side Vec<Value<'_>> lets compiler
2639 // infer the shortest lifetime that covers all calls.
2640 let mut eval_stack: Vec<Value<'_>> = Vec::new();
2641 let mut dkeybuf = String::new();
2642 let mut refs: Vec<&Value> = Vec::with_capacity(group_pos.len());
2643 // v7.32 (round-31) — an aggregate's argument / FILTER / second arg /
2644 // ORDER key may itself be a *correlated* subquery, e.g.
2645 // `MAX((SELECT i.v FROM inner i WHERE i.fk = o.id))`. A non-correlated
2646 // subquery is pre-resolved to a literal before this loop, but a
2647 // correlated one survives as a subquery node and must be evaluated per
2648 // outer row through the correlated evaluator — the same hook the
2649 // select-list / HAVING / ORDER finalisers already use below. Plain
2650 // `eval_expr` would hit "subquery reached row eval".
2651 //
2652 // The `any_agg_subquery` gate is computed once here so the common case
2653 // (no subquery anywhere in the aggregate args — including every hot
2654 // scan/group aggregate) short-circuits before the per-row
2655 // `expr_has_subquery` walk: `eval_arg` is then exactly `eval_expr`.
2656 let any_agg_subquery = correlated_eval.is_some()
2657 && agg_specs.iter().any(|s| {
2658 s.filter
2659 .as_ref()
2660 .is_some_and(|e| crate::expr_has_subquery(e))
2661 || s.arg.as_ref().is_some_and(|e| crate::expr_has_subquery(e))
2662 || s.arg2.as_ref().is_some_and(|e| crate::expr_has_subquery(e))
2663 || s.order_by.iter().any(|o| crate::expr_has_subquery(&o.expr))
2664 });
2665 let eval_arg =
2666 |e: &Expr, r: &Row<'static>, c: &EvalContext<'_>| -> Result<Value<'static>, EvalError> {
2667 match correlated_eval {
2668 Some(f) if any_agg_subquery && crate::expr_has_subquery(e) => f(e, r, c),
2669 _ => eval::eval_expr(e, r, c),
2670 }
2671 };
2672 // v7.36 (perf — mailrs Phase 1, post u64-hash) — single
2673 // anonymous group fast path. When the query has no GROUP BY
2674 // (`SELECT SUM(LENGTH(col)) FROM ...`, COUNT, AVG, etc.) the
2675 // whole input collapses into one group. The fast path below
2676 // still pays one `groups.get("")` hash probe per row plus
2677 // `entry = &mut order[0]` reindex even when the empty-key
2678 // path encodes nothing — measured ~50 ns/row across 25 k rows
2679 // = ~1.25 ms of pure bookkeeping on the user_storage_usage
2680 // baseline.
2681 //
2682 // Bypass: lift `entry` outside the loop and feed every row
2683 // straight into it. Same `update_state` machinery, zero
2684 // per-row hash work, zero per-row index lookup.
2685 let single_anon_group = group_exprs.is_empty() && !rows.is_empty();
2686 if single_anon_group {
2687 // Seed the single group at idx 0 once.
2688 let init: Vec<AggState> = (0..agg_specs.len()).map(|_| AggState::default()).collect();
2689 order.clear();
2690 order.push((Vec::new(), init));
2691 }
2692 // v7.36 (perf — mailrs Phase 1, count_messages 2.58 → ?) —
2693 // `COUNT(*)` short-circuit. For a single-anon-group `COUNT(*)`
2694 // with no FILTER / DISTINCT, every survivor counts once — the
2695 // answer IS `rows.len()`. Skips the 25 k iterations of
2696 // `update_state("count_star", …)` on the mailrs count_messages
2697 // shape; the JOIN already produced exactly the set of rows
2698 // that must be counted.
2699 if single_anon_group
2700 && agg_specs.len() == 1
2701 && agg_specs[0].name == "count_star"
2702 && agg_specs[0].filter.is_none()
2703 && agg_specs[0].arg.is_none()
2704 && agg_specs[0].arg2.is_none()
2705 && agg_specs[0].order_by.is_empty()
2706 && !agg_specs[0].distinct
2707 {
2708 let state = &mut order[0].1[0];
2709 state.num.count = rows.len() as i64;
2710 return Ok(order);
2711 }
2712 // v7.37.16 (heavy.rs agg_500k 1.6× loss) — fused streaming accumulator
2713 // for ANY number of count(*)/count(col)/sum(col)/avg(col) specs over
2714 // BOUND columns (no FILTER/DISTINCT/arg2/ORDER). The generic per-row
2715 // spec loop paid arg dispatch + union-typed update_state per spec per
2716 // row (~10 ns/spec/row); PG's parallel agg runs the 500k 3-spec shape
2717 // at ~18 ns/row effective. Three cuts:
2718 // - count(*) never enters the row loop — it IS rows.len();
2719 // - sum/avg over the SAME column share one accumulator (identical
2720 // running state), so `count(*), sum(v), avg(v)` does ONE cell read
2721 // and one accumulate per row;
2722 // - remaining ops run in one tight pass, no update_state.
2723 // Finalize writes the same AggState fields as the single-spec path.
2724 if single_anon_group
2725 && let Some((spec_src, unique_ops)) = fused_layout(
2726 agg_specs,
2727 &arg_pos,
2728 &arg_compiled,
2729 &order_pos,
2730 &arg2_literal_val,
2731 )
2732 {
2733 let mut accs: Vec<FusedAcc> = fused_accs(&unique_ops, ctx.mysql_dialect);
2734 // v7.39 (parallel-agg P1) — shard the row scan across the
2735 // host-injected executor when the input is large enough.
2736 // Each shard runs the same tight loop over its row range and
2737 // returns its own Vec<FusedAcc>; the merge is field-wise
2738 // (see merge_fused). Errors inside a shard surface as the
2739 // shard result and re-raise after join.
2740 // v7.39 (round 716) — the scan takes its EvalContext as a
2741 // parameter: `EvalContext` is not Sync (per-eval memo Cells, the
2742 // sequence resolver's plain `&dyn Fn`), so the parallel branch
2743 // hands each shard a locally-built minimal context instead of
2744 // capturing the outer one. The compiled ops only reach the parts
2745 // a shard context carries — columns, alias, dialect, catalog —
2746 // because `fully_compilable` excludes everything else (params,
2747 // sequences, user functions, FTS).
2748 let fused_scan = |range: core::ops::Range<usize>,
2749 accs: &mut Vec<FusedAcc>,
2750 fctx: &EvalContext<'_>|
2751 -> Result<(), EvalError> {
2752 // One Step-VM stack per shard call, reused across every
2753 // row and every compiled op.
2754 let mut stack: Vec<Value<'_>> = Vec::new();
2755 for row in rows.range(range.start, range.end).iter() {
2756 for (si, op) in unique_ops.iter().enumerate() {
2757 match op {
2758 FusedOp::CountCol(p) => {
2759 if !matches!(row.get(*p), Some(Value::Null) | None) {
2760 accs[si].num.count += 1;
2761 }
2762 }
2763 FusedOp::AccCol(p) => {
2764 {
2765 let a = &mut accs[si];
2766 acc_cell(&mut a.num, row.get(*p).unwrap_or(&Value::Null))
2767 }?;
2768 }
2769 FusedOp::Extreme { pos, max, .. } => {
2770 fused_extreme_cell(
2771 &mut accs[si],
2772 row.get(*pos).unwrap_or(&Value::Null),
2773 *max,
2774 )?;
2775 }
2776 FusedOp::CountExpr(sp) => {
2777 let c = arg_compiled[*sp].as_ref().expect("gated compiled");
2778 let v = eval::eval_compiled_ref(c, row, fctx, &mut stack)?;
2779 if !matches!(v, Value::Null) {
2780 accs[si].num.count += 1;
2781 }
2782 }
2783 FusedOp::AccExpr(sp) => {
2784 let c = arg_compiled[*sp].as_ref().expect("gated compiled");
2785 let v = eval::eval_compiled_ref(c, row, fctx, &mut stack)?;
2786 acc_cell(&mut accs[si].num, &v)?;
2787 }
2788 FusedOp::ExtremeExpr { spec, max, .. } => {
2789 let c = arg_compiled[*spec].as_ref().expect("gated compiled");
2790 let v = eval::eval_compiled_ref(c, row, fctx, &mut stack)?;
2791 fused_extreme_cell(&mut accs[si], &v, *max)?;
2792 }
2793 FusedOp::Collect { spec, string_kind } => {
2794 collect_cell(
2795 &mut accs[si],
2796 &row,
2797 arg_pos[*spec].expect("gated bound"),
2798 &order_pos[*spec],
2799 *string_kind,
2800 )?;
2801 }
2802 }
2803 }
2804 }
2805 Ok(())
2806 };
2807 if !unique_ops.is_empty() {
2808 let par = runner.filter(|_| rows.len() >= crate::PARALLEL_MIN_ROWS);
2809 if let Some(r) = par {
2810 crate::PARALLEL_AGG_FIRED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
2811 let n_shards = (rows.len() / crate::PARALLEL_MIN_ROWS).clamp(2, 8);
2812 let chunk = rows.len().div_ceil(n_shards);
2813 type ShardOut = Result<Vec<FusedAcc>, EvalError>;
2814 let ops = &unique_ops;
2815 let mysql_for_accs = ctx.mysql_dialect;
2816 // v7.39 (round 716) — the whitelisted concat family
2817 // renders through the SESSION's style; a shard context
2818 // built from defaults would silently re-render dates and
2819 // floats the default way. RenderStyle is Copy.
2820 let outer_style = ctx.render_style;
2821 let results = r.run_shards(n_shards, &|i| {
2822 let lo = i * chunk;
2823 let hi = ((i + 1) * chunk).min(rows.len());
2824 let mut local: Vec<FusedAcc> = fused_accs(ops, mysql_for_accs);
2825 // Shard-local minimal context (the outer one is not
2826 // Sync); see the fused_scan comment.
2827 let mut sctx = EvalContext::new(schema_cols, table_alias);
2828 sctx.mysql_dialect = mysql_for_accs;
2829 sctx.render_style = outer_style;
2830 let sctx = match catalog {
2831 Some(c) => sctx.with_catalog(c),
2832 None => sctx,
2833 };
2834 let out: ShardOut = fused_scan(lo..hi, &mut local, &sctx).map(|()| local);
2835 alloc::boxed::Box::new(out)
2836 });
2837 for boxed in results {
2838 let shard = boxed
2839 .downcast::<ShardOut>()
2840 .expect("runner echoes the closure's box");
2841 let mut shard_accs = (*shard)?;
2842 for (si, b) in shard_accs.iter_mut().enumerate() {
2843 merge_fused(&mut accs[si], b);
2844 }
2845 }
2846 } else {
2847 fused_scan(0..rows.len(), &mut accs, &ctx)?;
2848 }
2849 }
2850 fill_states_from_fused(
2851 &mut order[0].1,
2852 &spec_src,
2853 &mut accs,
2854 rows.len() as i64,
2855 &arg2_literal_val,
2856 );
2857 return Ok(order);
2858 }
2859 // v7.39 (parallel-agg P3) — parallel GROUP BY fast path: a single
2860 // bound INT group column with every spec fused-eligible (the
2861 // `GROUP BY g` + count/sum/avg panel shape). Shards build local
2862 // i64-keyed maps of FusedAcc slots; the merge folds maps in shard
2863 // order (first-seen group order across shards — SQL leaves GROUP
2864 // BY output order unspecified). Any non-integer cell under the
2865 // integer schema (coercion edge) aborts the shard and the whole
2866 // scan falls back to the serial path below.
2867 if single_int_group_col
2868 && group_exprs.len() == 1
2869 && rows.len() >= crate::PARALLEL_MIN_ROWS
2870 && let Some(r) = runner
2871 && let Some((spec_src, unique_ops)) = fused_layout(
2872 agg_specs,
2873 &arg_pos,
2874 &arg_compiled,
2875 &order_pos,
2876 &arg2_literal_val,
2877 )
2878 && !unique_ops.is_empty()
2879 {
2880 crate::PARALLEL_AGG_FIRED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
2881 let gp = group_pos[0].expect("single_int_group_col implies bound");
2882 struct ShardMap {
2883 // first-seen order of keys within the shard.
2884 keys: Vec<(i64, Value<'static>)>,
2885 slots: hashbrown::HashMap<i64, Vec<FusedAcc>>,
2886 null_slot: Option<Vec<FusedAcc>>,
2887 null_rows: i64,
2888 key_rows: hashbrown::HashMap<i64, i64>,
2889 }
2890 // Err(None) = coercion edge -> serial fallback; Err(Some(e)) = real error.
2891 type ShardOut = Result<ShardMap, Option<EvalError>>;
2892 let n_shards = (rows.len() / crate::PARALLEL_MIN_ROWS).clamp(2, 8);
2893 let chunk = rows.len().div_ceil(n_shards);
2894 let ops = &unique_ops;
2895 let mysql_for_accs = ctx.mysql_dialect;
2896 // Same session-style carry as the anonymous-group lane.
2897 let outer_style = ctx.render_style;
2898 let results = r.run_shards(n_shards, &|si| {
2899 let lo = si * chunk;
2900 let hi = ((si + 1) * chunk).min(rows.len());
2901 let mut m = ShardMap {
2902 keys: Vec::new(),
2903 slots: hashbrown::HashMap::new(),
2904 null_slot: None,
2905 null_rows: 0,
2906 key_rows: hashbrown::HashMap::new(),
2907 };
2908 let out: ShardOut = (|| {
2909 // v7.39 (round 716) — per-shard Step-VM stack for the
2910 // compiled-argument ops, reused across rows, plus a
2911 // shard-local minimal context (the outer one is not
2912 // Sync); see the anonymous-group fused_scan comment.
2913 let mut stack: Vec<Value<'_>> = Vec::new();
2914 let mut sctx = EvalContext::new(schema_cols, table_alias);
2915 sctx.mysql_dialect = mysql_for_accs;
2916 sctx.render_style = outer_style;
2917 let sctx = match catalog {
2918 Some(c) => sctx.with_catalog(c),
2919 None => sctx,
2920 };
2921 for row in rows.range(lo, hi).iter() {
2922 let v = row.get(gp).unwrap_or(&Value::Null);
2923 let key: Option<i64> = match v {
2924 Value::SmallInt(n) => Some(i64::from(*n)),
2925 Value::Int(n) => Some(i64::from(*n)),
2926 Value::BigInt(n) => Some(*n),
2927 Value::Null => None,
2928 _ => return Err(None), // coercion edge -> serial
2929 };
2930 let slots = match key {
2931 Some(k) => {
2932 *m.key_rows.entry(k).or_insert(0) += 1;
2933 m.slots.entry(k).or_insert_with(|| {
2934 m.keys.push((k, v.clone().into_owned()));
2935 fused_accs(ops, mysql_for_accs)
2936 })
2937 }
2938 None => {
2939 m.null_rows += 1;
2940 m.null_slot
2941 .get_or_insert_with(|| fused_accs(ops, mysql_for_accs))
2942 }
2943 };
2944 for (oi, op) in ops.iter().enumerate() {
2945 match op {
2946 FusedOp::CountCol(p) => {
2947 if !matches!(row.get(*p), Some(Value::Null) | None) {
2948 slots[oi].num.count += 1;
2949 }
2950 }
2951 FusedOp::AccCol(p) => {
2952 {
2953 let a = &mut slots[oi];
2954 acc_cell(&mut a.num, row.get(*p).unwrap_or(&Value::Null))
2955 }
2956 .map_err(Some)?;
2957 }
2958 FusedOp::Extreme { pos, max, .. } => {
2959 fused_extreme_cell(
2960 &mut slots[oi],
2961 row.get(*pos).unwrap_or(&Value::Null),
2962 *max,
2963 )
2964 .map_err(Some)?;
2965 }
2966 FusedOp::CountExpr(sp) => {
2967 let c = arg_compiled[*sp].as_ref().expect("gated compiled");
2968 let v = eval::eval_compiled_ref(c, row, &sctx, &mut stack)
2969 .map_err(Some)?;
2970 if !matches!(v, Value::Null) {
2971 slots[oi].num.count += 1;
2972 }
2973 }
2974 FusedOp::AccExpr(sp) => {
2975 let c = arg_compiled[*sp].as_ref().expect("gated compiled");
2976 let v = eval::eval_compiled_ref(c, row, &sctx, &mut stack)
2977 .map_err(Some)?;
2978 acc_cell(&mut slots[oi].num, &v).map_err(Some)?;
2979 }
2980 FusedOp::ExtremeExpr { spec, max, .. } => {
2981 let c = arg_compiled[*spec].as_ref().expect("gated compiled");
2982 let v = eval::eval_compiled_ref(c, row, &sctx, &mut stack)
2983 .map_err(Some)?;
2984 fused_extreme_cell(&mut slots[oi], &v, *max).map_err(Some)?;
2985 }
2986 FusedOp::Collect { spec, string_kind } => {
2987 collect_cell(
2988 &mut slots[oi],
2989 &row,
2990 arg_pos[*spec].expect("gated bound"),
2991 &order_pos[*spec],
2992 *string_kind,
2993 )
2994 .map_err(Some)?;
2995 }
2996 }
2997 }
2998 }
2999 Ok(m)
3000 })();
3001 alloc::boxed::Box::new(out)
3002 });
3003 // Merge in shard order; a fallback sentinel drops to serial.
3004 let mut merged_keys: Vec<(i64, Value<'static>)> = Vec::new();
3005 let mut merged: hashbrown::HashMap<i64, (Vec<FusedAcc>, i64)> = hashbrown::HashMap::new();
3006 let mut merged_null: Option<(Vec<FusedAcc>, i64)> = None;
3007 let mut fallback = false;
3008 let mut shard_err: Option<EvalError> = None;
3009 for boxed in results {
3010 let shard = boxed
3011 .downcast::<ShardOut>()
3012 .expect("runner echoes the closure's box");
3013 match *shard {
3014 Ok(mut m) => {
3015 for (k, kv) in m.keys {
3016 // Removed (not borrowed): the slot MOVES into the
3017 // merged map on first sight, and the round-724
3018 // collection lanes move out of it on merge.
3019 let mut accs = m.slots.remove(&k).expect("keyed slot");
3020 let rows_k = m.key_rows[&k];
3021 match merged.get_mut(&k) {
3022 Some((dst, cnt)) => {
3023 for (i, b) in accs.iter_mut().enumerate() {
3024 merge_fused(&mut dst[i], b);
3025 }
3026 *cnt += rows_k;
3027 }
3028 None => {
3029 merged_keys.push((k, kv));
3030 merged.insert(k, (accs, rows_k));
3031 }
3032 }
3033 }
3034 if let Some(mut nb) = m.null_slot.take() {
3035 match &mut merged_null {
3036 Some((dst, cnt)) => {
3037 for (i, b) in nb.iter_mut().enumerate() {
3038 merge_fused(&mut dst[i], b);
3039 }
3040 *cnt += m.null_rows;
3041 }
3042 None => merged_null = Some((nb, m.null_rows)),
3043 }
3044 }
3045 }
3046 Err(None) => fallback = true,
3047 Err(Some(e)) => shard_err = Some(e),
3048 }
3049 }
3050 if let Some(e) = shard_err {
3051 return Err(e);
3052 }
3053 if !fallback {
3054 for (k, kv) in merged_keys {
3055 let (mut accs, group_rows) = merged.remove(&k).expect("key recorded");
3056 let mut states: Vec<AggState> =
3057 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3058 fill_states_from_fused(
3059 &mut states,
3060 &spec_src,
3061 &mut accs,
3062 group_rows,
3063 &arg2_literal_val,
3064 );
3065 order.push((alloc::vec![kv], states));
3066 }
3067 if let Some((mut accs, group_rows)) = merged_null {
3068 let mut states: Vec<AggState> =
3069 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3070 fill_states_from_fused(
3071 &mut states,
3072 &spec_src,
3073 &mut accs,
3074 group_rows,
3075 &arg2_literal_val,
3076 );
3077 order.push((alloc::vec![Value::Null], states));
3078 }
3079 return Ok(order);
3080 }
3081 // fallthrough: serial paths below handle the coercion edge.
3082 }
3083
3084 // v7.36 (perf — mailrs Phase 1) — `COUNT(<bound col>)` (non-`*`)
3085 // collapses to: read the cell, increment when not NULL. Skips
3086 // the per-row spec dispatch + `update_state("count", …)`.
3087 if single_anon_group
3088 && agg_specs.len() == 1
3089 && agg_specs[0].name == "count"
3090 && agg_specs[0].filter.is_none()
3091 && agg_specs[0].arg2.is_none()
3092 && agg_specs[0].order_by.is_empty()
3093 && !agg_specs[0].distinct
3094 && arg_pos[0].is_some()
3095 {
3096 let p = arg_pos[0].unwrap();
3097 let mut count: i64 = 0;
3098 for row in rows.iter() {
3099 if !matches!(row.get(p), Some(Value::Null) | None) {
3100 count += 1;
3101 }
3102 }
3103 let state = &mut order[0].1[0];
3104 state.num.count = count;
3105 return Ok(order);
3106 }
3107 // v7.36 (perf — mailrs Phase 1, user_storage_usage 7.5 → ?) —
3108 // single-aggregate streaming accumulator. For
3109 // `SUM(<compiled-expr>)` / `SUM(<bound col>)` with no GROUP BY,
3110 // no FILTER, no arg2, no ORDER BY, no DISTINCT, the whole
3111 // per-row work collapses to: eval the arg, match the Value
3112 // variant, accumulate. Skips the spec-dispatch loop +
3113 // `update_state` per-row name match. On a 25 k-row JOIN
3114 // (user_storage_usage `SUM(LENGTH(text_body))`) that's
3115 // ~50-100 ns/row of pure spec-dispatch overhead removed.
3116 if single_anon_group
3117 && agg_specs.len() == 1
3118 && agg_specs[0].filter.is_none()
3119 && agg_specs[0].arg2.is_none()
3120 && agg_specs[0].order_by.is_empty()
3121 && !agg_specs[0].distinct
3122 && (agg_specs[0].name == "sum" || agg_specs[0].name == "avg")
3123 && (arg_pos[0].is_some() || arg_compiled[0].is_some())
3124 {
3125 let arg_pos0 = arg_pos[0];
3126 let arg_c0 = &arg_compiled[0];
3127 // v7.39 (round 665) — was fifteen loose locals mirroring
3128 // `NumAcc` field for field; `FusedAcc`'s doc comment even
3129 // said so. One struct now, folded by the one `acc_cell`.
3130 let mut na = NumAcc::default();
3131 // Borrow-aware fast inner: avoid the per-row clone when arg
3132 // is a bound column position.
3133 if let Some(p) = arg_pos0 {
3134 for row in rows.iter() {
3135 let v_ref = row.get(p).unwrap_or(&Value::Null);
3136 acc_cell(&mut na, v_ref)?;
3137 }
3138 } else if let Some(p) = arg_c0.as_ref().and_then(|c| c.as_single_column_length()) {
3139 // v7.36 (perf — mailrs Phase 1, user_storage_usage hot
3140 // inner) — `SUM(LENGTH(<text col>))` collapses to a
3141 // straight scan: read the cell by ref, branch on the
3142 // variant, do an ASCII probe + `len()` (or
3143 // `chars().count()` on non-ASCII), accumulate. No Step
3144 // VM, no stack push/pop, no `BigInt` boxing on the way
3145 // out — pure i64 sum. The original Step VM path keeps
3146 // running for everything outside this shape (`SUM(col)`,
3147 // `SUM(expr)`, multi-step compiled args).
3148 for row in rows.iter() {
3149 let Some(v_ref) = row.get(p) else {
3150 continue;
3151 };
3152 let n = match v_ref {
3153 Value::Null => continue,
3154 Value::Text(s) => {
3155 if s.is_ascii() {
3156 s.len() as i64
3157 } else {
3158 s.chars().count() as i64
3159 }
3160 }
3161 other => {
3162 return Err(EvalError::TypeMismatch {
3163 detail: format!(
3164 "length() needs text, got {}",
3165 crate::conversions::pg_type_name_for_error_opt(other.data_type())
3166 ),
3167 });
3168 }
3169 };
3170 na.sum_int += n;
3171 na.count += 1;
3172 }
3173 } else {
3174 let c = arg_c0.as_ref().unwrap();
3175 for row in rows.iter() {
3176 let v = eval::eval_compiled_ref(c, row, &ctx, &mut eval_stack)?;
3177 acc_cell(&mut na, &v)?;
3178 }
3179 }
3180 let state = &mut order[0].1[0];
3181 state.num = na;
3182 return Ok(order);
3183 }
3184 // v7.37.x (mailrs Track A 100k attack) — tight inlined loop for
3185 // the "single-Text GROUP BY + single MAX(bound numeric arg)"
3186 // shape. See `dedicated_max_loop` above for the gate. Returns
3187 // straight to the caller; the rest of the function (single-anon,
3188 // bound-fast, eval-slow paths) is skipped.
3189 if dedicated_max_loop && !single_anon_group {
3190 let gpos = group_pos[0].expect("dedicated_max_loop gates on Some");
3191 let apos = arg_pos[0].expect("dedicated_max_loop gates on Some");
3192 for row in rows.iter() {
3193 let kv = row.get(gpos).unwrap_or(&Value::Null);
3194 let idx = match kv {
3195 Value::Text(s) => match groups_text.get(s.as_ref()) {
3196 Some(&i) => i,
3197 None => {
3198 let i = order.len();
3199 order.push((
3200 alloc::vec![Value::text(s.clone())],
3201 alloc::vec![AggState::default()],
3202 ));
3203 groups_text.insert(s.to_string(), i);
3204 i
3205 }
3206 },
3207 Value::Null => match null_group_idx {
3208 Some(i) => i,
3209 None => {
3210 let i = order.len();
3211 order.push((alloc::vec![Value::Null], alloc::vec![AggState::default()]));
3212 null_group_idx = Some(i);
3213 i
3214 }
3215 },
3216 _ => {
3217 // Schema said Text but value isn't — fall back to
3218 // the generic encoded path for correctness.
3219 refs.clear();
3220 refs.push(kv);
3221 encode_key_refs_into_in(&refs, &mut keybuf_s, mysql_fold_groups);
3222 match groups.get(keybuf_s.as_str()) {
3223 Some(&i) => i,
3224 None => {
3225 let i = order.len();
3226 order.push((
3227 alloc::vec![kv.clone().into_owned()],
3228 alloc::vec![AggState::default()],
3229 ));
3230 groups.insert(keybuf_s.clone(), i);
3231 i
3232 }
3233 }
3234 }
3235 };
3236 // Inline MAX accumulator — skip the union-typed
3237 // `update_state` enum jump and per-spec arg dispatch.
3238 let av = row.get(apos).unwrap_or(&Value::Null);
3239 if !matches!(av, Value::Null) {
3240 let st = &mut order[idx].1[0];
3241 let upd = match &st.extreme {
3242 None => true,
3243 Some(prev) => {
3244 extreme_cmp_in(
3245 agg_specs[0].enum_labels.as_deref(),
3246 agg_specs[0].arg_collation.as_deref(),
3247 av,
3248 prev,
3249 ctx.mysql_dialect,
3250 ) == core::cmp::Ordering::Greater
3251 }
3252 };
3253 if upd {
3254 st.extreme = Some(av.clone().into_owned());
3255 }
3256 }
3257 }
3258 return Ok(order);
3259 }
3260
3261 for row in rows.iter() {
3262 // v7.37.4 (L1 CSE) — reset per-row cache for shared compiled
3263 // aggregate-arg evals. No-op when no dedupe (empty vec).
3264 for slot in row_eval_cache.iter_mut() {
3265 *slot = None;
3266 }
3267 if single_anon_group {
3268 let entry = &mut order[0];
3269 let mat: Option<Cow<'_, Row>> = if needs_mat { Some(row.as_row()) } else { None };
3270 for (i, spec) in agg_specs.iter().enumerate() {
3271 if let Some(f) = &spec.filter
3272 && !matches!(
3273 eval_arg(f, mat.as_deref().expect("needs_mat for FILTER"), &ctx)?,
3274 Value::Bool(true)
3275 )
3276 {
3277 continue;
3278 }
3279 let arg_owned: Value;
3280 let arg_ref: &Value = match (&arg_pos[i], arg_slot[i], &spec.arg) {
3281 (Some(p), _, _) => {
3282 // v7.37.9 Phase 1A-ext counter — fast position-bound arg.
3283 crate::bump_counter!(AGG_PER_ROW_FAST_POS);
3284 row.get(*p).unwrap_or(&Value::Null)
3285 }
3286 (None, None, None) => {
3287 // COUNT(*) sentinel
3288 crate::bump_counter!(AGG_PER_ROW_COUNT_STAR_SENTINEL);
3289 arg_owned = Value::Bool(true);
3290 &arg_owned
3291 }
3292 (None, Some(s), _) => {
3293 if row_eval_cache[s].is_none() {
3294 // v7.37.9 Phase 1A-ext counter — Step-VM ran (cache miss).
3295 crate::bump_counter!(AGG_PER_ROW_COMPILED_MISS);
3296 let c = arg_compiled[arg_unique_idx[s]]
3297 .as_ref()
3298 .expect("arg_unique_idx points at a compiled spec");
3299 let v = eval::eval_compiled_ref(c, row, &ctx, &mut eval_stack)?;
3300 row_eval_cache[s] = Some(v);
3301 } else {
3302 // v7.37.9 Phase 1A-ext counter — CSE cache hit
3303 // (compiled arg deduped across specs in same row).
3304 crate::bump_counter!(AGG_PER_ROW_COMPILED_HIT);
3305 }
3306 row_eval_cache[s].as_ref().expect("just filled above")
3307 }
3308 (None, None, Some(e)) => {
3309 // v7.37.9 Phase 1A-ext counter — eval_expr fallback
3310 // (uncompilable spec — Cow row materialise per row).
3311 crate::bump_counter!(AGG_PER_ROW_EVAL_FALLBACK);
3312 arg_owned = eval_arg(
3313 e,
3314 mat.as_deref().expect("needs_mat for non-bound arg"),
3315 &ctx,
3316 )?;
3317 &arg_owned
3318 }
3319 };
3320 let arg2_val = match (&spec.arg2, &arg2_literal_val[i]) {
3321 (None, _) => None,
3322 // v7.37.43 (DISTA A-3) — literal arg2: clone the
3323 // precomputed value, skip per-row eval & row mat.
3324 (Some(_), Some(lit)) => {
3325 // v7.37.9 Phase 0 diagnostic — count per-row
3326 // hits of the DISTA A-3 fast path.
3327 crate::bump_counter!(DISTA_LITERAL_ARG2_CACHE_FIRE);
3328 Some(lit.clone())
3329 }
3330 (Some(e), None) => Some(eval_arg(
3331 e,
3332 mat.as_deref().expect("needs_mat for arg2"),
3333 &ctx,
3334 )?),
3335 };
3336 let order_keys: Option<Vec<Value<'static>>> = if spec.order_by.is_empty() {
3337 None
3338 } else {
3339 crate::bump_counter!(AGGREGATE_ARRAY_AGG_ORDER_BY_FIRE);
3340 let mut keys: Vec<Value<'static>> = Vec::with_capacity(spec.order_by.len());
3341 for (k, o) in spec.order_by.iter().enumerate() {
3342 let v: Value<'static> = if let Some(p) = order_pos[i][k] {
3343 row.get(p)
3344 .cloned()
3345 .map(Value::into_owned)
3346 .unwrap_or(Value::Null)
3347 } else {
3348 eval_arg(
3349 &o.expr,
3350 mat.as_deref().expect("needs_mat for ORDER key"),
3351 &ctx,
3352 )?
3353 };
3354 keys.push(v);
3355 }
3356 Some(keys)
3357 };
3358 // v7.36 (perf — bugfix v7.36.1 candidate) — first_ordered
3359 // was missing from the single_anon_group fast path,
3360 // sending `(array_agg(x ORDER BY y))[1]` values into
3361 // `update_state(array_agg, …)` whose finalize ignored
3362 // the absent `first_best` and returned `[]`. The slow
3363 // path below has the same branch — keep them aligned.
3364 if spec.first_ordered {
3365 if let Some(keys) = order_keys {
3366 let st = &mut entry.1[i];
3367 let better = match &st.first_best {
3368 None => true,
3369 Some((bk, _)) => {
3370 cmp_order_keys(
3371 &spec.order_by,
3372 &spec.order_enum_labels,
3373 &spec.order_collations,
3374 &keys,
3375 bk,
3376 ctx.mysql_dialect,
3377 ) == core::cmp::Ordering::Less
3378 }
3379 };
3380 if better {
3381 st.first_best = Some((keys, arg_ref.clone().into_owned()));
3382 }
3383 }
3384 continue;
3385 }
3386 if spec.distinct {
3387 // v7.37.x (mailrs Track A 100k distinct_aggs attack)
3388 // — single-Text DISTINCT fast path. Within a single
3389 // distinct spec all input values come from one
3390 // expression and share one type, so the encode-
3391 // prefix (`S<text>|`) is redundant: the column
3392 // text alone is collision-free within this spec's
3393 // `seen` set. Skips encode_one + 2-walk
3394 // contains+insert; only Text arms apply, others
3395 // ride the encoded path unchanged.
3396 //
3397 // v7.37.x (docker-fair DISTA attack) — extend the
3398 // single-family fast path to BigInt via a parallel
3399 // `seen_int: Option<BTreeSet<i64>>`. The DISTA
3400 // `COUNT(DISTINCT m.id)` shape pumps 25 k BigInt
3401 // probes; skipping `encode_key_refs_into` saves
3402 // ~100 ns of alloc + format churn per row.
3403 if let Value::Text(s) = arg_ref {
3404 // v7.39 (round 364, M4 P2) — a MySQL session folds
3405 // the distinct key (case/accent) so `Foo`/`foo`
3406 // count once. The `seen` set stays internally
3407 // consistent: both probe and insert fold.
3408 // v7.39 (round 370, M4 P4a) — but an explicit
3409 // `COLLATE utf8mb4_bin` column de-dups byte-wise.
3410 if distinct_fold[i] {
3411 // v7.38.18 — and pad when the argument's
3412 // collation says trailing spaces do not
3413 // count. `utf8mb4_general_ci` folds AND
3414 // pads; `utf8mb4_0900_ai_ci` only folds.
3415 let base = if distinct_pads[i] {
3416 s.trim_end_matches(' ')
3417 } else {
3418 s.as_ref()
3419 };
3420 let k = if distinct_fold_case[i] {
3421 spg_storage::mysql_ci_fold(base)
3422 } else {
3423 alloc::string::ToString::to_string(base)
3424 };
3425 if entry.1[i].seen.contains(k.as_str()) {
3426 continue;
3427 }
3428 entry.1[i].seen.insert(k);
3429 } else {
3430 if entry.1[i].seen.contains(s.as_ref()) {
3431 continue;
3432 }
3433 entry.1[i].seen.insert(s.to_string());
3434 }
3435 } else if let Value::BigInt(n) = arg_ref {
3436 let set = entry.1[i].seen_int.get_or_insert_with(BTreeSet::new);
3437 if !set.insert(*n) {
3438 continue;
3439 }
3440 } else if let Value::Int(n) = arg_ref {
3441 let set = entry.1[i].seen_int.get_or_insert_with(BTreeSet::new);
3442 if !set.insert(i64::from(*n)) {
3443 continue;
3444 }
3445 } else {
3446 encode_key_refs_into_in(
3447 core::slice::from_ref(&arg_ref),
3448 &mut dkeybuf,
3449 distinct_fold[i],
3450 );
3451 if entry.1[i].seen.contains(dkeybuf.as_str()) {
3452 continue;
3453 }
3454 entry.1[i].seen.insert(dkeybuf.clone());
3455 }
3456 }
3457 // v7.37.x (mailrs Track A 100k attack) — inline the
3458 // common aggregate kinds (MAX / MIN / Count / CountStar
3459 // / BoolOr / BoolAnd) here instead of dispatching
3460 // through `update_state`'s enum jump + per-kind branch.
3461 // Skipping the function-call overhead saves ~20-30 ns
3462 // per spec per row at 100 k; the slow kinds keep the
3463 // dispatched call.
3464 match spec.kind {
3465 AggKind::Max => {
3466 if !matches!(arg_ref, Value::Null) {
3467 // v7.39 (round 626) — the same deny list the
3468 // dispatched path applies. These inlined copies
3469 // exist for speed and are where `min(TRUE)`
3470 // actually lands, so a guard placed only on the
3471 // dispatched arm never fires.
3472 if !ctx.mysql_dialect && min_max_unsupported_type(arg_ref) {
3473 return Err(EvalError::TypeMismatch {
3474 detail: format!(
3475 "function max({}) does not exist",
3476 crate::conversions::pg_type_name_for_error_opt(
3477 arg_ref.data_type()
3478 )
3479 ),
3480 });
3481 }
3482 let st = &mut entry.1[i];
3483 let upd = match &st.extreme {
3484 None => true,
3485 Some(prev) => {
3486 extreme_cmp_in(
3487 spec.enum_labels.as_deref(),
3488 spec.arg_collation.as_deref(),
3489 arg_ref,
3490 prev,
3491 ctx.mysql_dialect,
3492 ) == core::cmp::Ordering::Greater
3493 }
3494 };
3495 if upd {
3496 st.extreme = Some(arg_ref.clone().into_owned());
3497 }
3498 }
3499 }
3500 AggKind::Min => {
3501 if !matches!(arg_ref, Value::Null) {
3502 // v7.39 (round 626) — see the Max arm above.
3503 if !ctx.mysql_dialect && min_max_unsupported_type(arg_ref) {
3504 return Err(EvalError::TypeMismatch {
3505 detail: format!(
3506 "function min({}) does not exist",
3507 crate::conversions::pg_type_name_for_error_opt(
3508 arg_ref.data_type()
3509 )
3510 ),
3511 });
3512 }
3513 let st = &mut entry.1[i];
3514 let upd = match &st.extreme {
3515 None => true,
3516 Some(prev) => {
3517 extreme_cmp_in(
3518 spec.enum_labels.as_deref(),
3519 spec.arg_collation.as_deref(),
3520 arg_ref,
3521 prev,
3522 ctx.mysql_dialect,
3523 ) == core::cmp::Ordering::Less
3524 }
3525 };
3526 if upd {
3527 st.extreme = Some(arg_ref.clone().into_owned());
3528 }
3529 }
3530 }
3531 AggKind::AnyValue => {
3532 if !matches!(arg_ref, Value::Null) {
3533 let st = &mut entry.1[i];
3534 if st.extreme.is_none() {
3535 st.extreme = Some(arg_ref.clone().into_owned());
3536 }
3537 }
3538 }
3539 AggKind::CountStar => {
3540 entry.1[i].num.count += 1;
3541 }
3542 AggKind::Count => {
3543 if !matches!(arg_ref, Value::Null) {
3544 entry.1[i].num.count += 1;
3545 }
3546 }
3547 AggKind::BoolOr => match arg_ref {
3548 Value::Bool(b) => {
3549 let st = &mut entry.1[i];
3550 st.bool_acc = Some(st.bool_acc.unwrap_or(false) || *b);
3551 }
3552 Value::Null => {}
3553 _ => update_state(
3554 &mut entry.1[i],
3555 spec.kind,
3556 &spec.name,
3557 arg_ref,
3558 arg2_val.as_ref(),
3559 order_keys,
3560 spec.enum_labels.as_deref(),
3561 spec.arg_collation.as_deref(),
3562 ctx.mysql_dialect,
3563 )?,
3564 },
3565 AggKind::BoolAnd => match arg_ref {
3566 Value::Bool(b) => {
3567 let st = &mut entry.1[i];
3568 st.bool_acc = Some(st.bool_acc.unwrap_or(true) && *b);
3569 }
3570 Value::Null => {}
3571 _ => update_state(
3572 &mut entry.1[i],
3573 spec.kind,
3574 &spec.name,
3575 arg_ref,
3576 arg2_val.as_ref(),
3577 order_keys,
3578 spec.enum_labels.as_deref(),
3579 spec.arg_collation.as_deref(),
3580 ctx.mysql_dialect,
3581 )?,
3582 },
3583 _ => {
3584 update_state(
3585 &mut entry.1[i],
3586 spec.kind,
3587 &spec.name,
3588 arg_ref,
3589 arg2_val.as_ref(),
3590 order_keys,
3591 spec.enum_labels.as_deref(),
3592 spec.arg_collation.as_deref(),
3593 ctx.mysql_dialect,
3594 )?;
3595 }
3596 }
3597 }
3598 continue;
3599 }
3600 // Fast key: bound positions + no ci folding -> encode
3601 // straight from borrowed cells; group_vals materialise
3602 // only when the group is NEW.
3603 if all_groups_bound && ci_positions.is_empty() {
3604 // v7.37.x — single-Text fast path uses the raw text as the
3605 // map key (no encode_one's `S<text>|` prefix/suffix push,
3606 // no refs Vec rebuild). NULL values land in a dedicated
3607 // slot so SQL's "all NULLs share one group" semantics hold.
3608 let idx = if single_text_group_col {
3609 let v = row.get(group_pos[0].unwrap()).unwrap_or(&Value::Null);
3610 match v {
3611 Value::Text(s) => match groups_text.get(s.as_ref()) {
3612 Some(&i) => i,
3613 None => {
3614 let i = order.len();
3615 let init: Vec<AggState> =
3616 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3617 order.push((alloc::vec![Value::text(s.clone())], init));
3618 groups_text.insert(s.to_string(), i);
3619 i
3620 }
3621 },
3622 Value::Null => match null_group_idx {
3623 Some(i) => i,
3624 None => {
3625 let i = order.len();
3626 let init: Vec<AggState> =
3627 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3628 order.push((alloc::vec![Value::Null], init));
3629 null_group_idx = Some(i);
3630 i
3631 }
3632 },
3633 _ => {
3634 // Schema says Text but value is something else
3635 // (coercion edge case). Fall back to the encoded
3636 // path for correctness — same logic as the
3637 // non-single-Text branch below.
3638 refs.clear();
3639 refs.push(v);
3640 encode_key_refs_into_in(&refs, &mut keybuf_s, mysql_fold_groups);
3641 match groups.get(keybuf_s.as_str()) {
3642 Some(&i) => i,
3643 None => {
3644 let i = order.len();
3645 let init: Vec<AggState> =
3646 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3647 order.push((alloc::vec![v.clone().into_owned()], init));
3648 groups.insert(keybuf_s.clone(), i);
3649 i
3650 }
3651 }
3652 }
3653 }
3654 } else if single_int_group_col {
3655 // v7.37.16 — raw-i64 keying (see single_int_group_col).
3656 let v = row.get(group_pos[0].unwrap()).unwrap_or(&Value::Null);
3657 let key: Option<i64> = match v {
3658 Value::SmallInt(n) => Some(i64::from(*n)),
3659 Value::Int(n) => Some(i64::from(*n)),
3660 Value::BigInt(n) => Some(*n),
3661 _ => None,
3662 };
3663 match (key, v) {
3664 (Some(k), _) => match groups_int.get(&k) {
3665 Some(&i) => i,
3666 None => {
3667 let i = order.len();
3668 let init: Vec<AggState> =
3669 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3670 order.push((alloc::vec![v.clone().into_owned()], init));
3671 groups_int.insert(k, i);
3672 i
3673 }
3674 },
3675 (None, Value::Null) => match null_group_idx {
3676 Some(i) => i,
3677 None => {
3678 let i = order.len();
3679 let init: Vec<AggState> =
3680 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3681 order.push((alloc::vec![Value::Null], init));
3682 null_group_idx = Some(i);
3683 i
3684 }
3685 },
3686 (None, _) => {
3687 // Non-integer cell under an integer schema
3688 // (coercion edge) — encoded-path fallback.
3689 refs.clear();
3690 refs.push(v);
3691 encode_key_refs_into_in(&refs, &mut keybuf_s, mysql_fold_groups);
3692 match groups.get(keybuf_s.as_str()) {
3693 Some(&i) => i,
3694 None => {
3695 let i = order.len();
3696 let init: Vec<AggState> =
3697 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3698 order.push((alloc::vec![v.clone().into_owned()], init));
3699 groups.insert(keybuf_s.clone(), i);
3700 i
3701 }
3702 }
3703 }
3704 }
3705 } else {
3706 refs.clear();
3707 refs.extend(
3708 group_pos
3709 .iter()
3710 .map(|p| row.get(p.unwrap()).unwrap_or(&Value::Null)),
3711 );
3712 encode_key_refs_into_in(&refs, &mut keybuf_s, mysql_fold_groups);
3713 match groups.get(keybuf_s.as_str()) {
3714 Some(&i) => i,
3715 None => {
3716 let i = order.len();
3717 let init: Vec<AggState> =
3718 (0..agg_specs.len()).map(|_| AggState::default()).collect();
3719 let owned: Vec<Value<'static>> =
3720 refs.iter().map(|v| (*v).clone().into_owned()).collect();
3721 order.push((owned, init));
3722 groups.insert(keybuf_s.clone(), i);
3723 i
3724 }
3725 }
3726 };
3727 let entry = &mut order[idx];
3728 // v7.33 (array_agg perf) — materialise the combined row AT
3729 // MOST once per input row, and only when a spec actually
3730 // needs the eval path (FILTER / non-bound arg / arg2 / non-
3731 // bound ORDER key). Bound args and bound ORDER keys read
3732 // cells by reference below, so the inbox shape (all bound)
3733 // never materialises — killing the per-row ~1 KB clone that
3734 // dominated the ordered-aggregate cost.
3735 let mat: Option<Cow<'_, Row>> = if needs_mat { Some(row.as_row()) } else { None };
3736 for (i, spec) in agg_specs.iter().enumerate() {
3737 // v7.32 (round-29) — FILTER (WHERE cond): exclude rows
3738 // where cond is not TRUE before they reach this
3739 // aggregate's accumulator (and before DISTINCT dedup).
3740 if let Some(f) = &spec.filter
3741 && !matches!(
3742 eval_arg(f, mat.as_deref().expect("needs_mat for FILTER"), &ctx)?,
3743 Value::Bool(true)
3744 )
3745 {
3746 continue;
3747 }
3748 let arg_owned: Value;
3749 let arg_ref: &Value = match (&arg_pos[i], arg_slot[i], &spec.arg) {
3750 (Some(p), _, _) => {
3751 crate::bump_counter!(AGG_PER_ROW_FAST_POS);
3752 row.get(*p).unwrap_or(&Value::Null)
3753 }
3754 (None, None, None) => {
3755 crate::bump_counter!(AGG_PER_ROW_COUNT_STAR_SENTINEL);
3756 arg_owned = Value::Bool(true);
3757 &arg_owned
3758 }
3759 (None, Some(s), _) => {
3760 // v7.37.4 (L1 CSE) — shared compiled-arg slot.
3761 // First spec that needs slot `s` this row pays
3762 // the Step-VM eval; siblings reading the same
3763 // slot get the cached Value for free. Preserves
3764 // FILTER semantics: a spec filtered out above
3765 // never reaches here, so its arg stays unevaled.
3766 if row_eval_cache[s].is_none() {
3767 crate::bump_counter!(AGG_PER_ROW_COMPILED_MISS);
3768 let c = arg_compiled[arg_unique_idx[s]]
3769 .as_ref()
3770 .expect("arg_unique_idx points at a compiled spec");
3771 let v = eval::eval_compiled_ref(c, row, &ctx, &mut eval_stack)?;
3772 row_eval_cache[s] = Some(v);
3773 } else {
3774 crate::bump_counter!(AGG_PER_ROW_COMPILED_HIT);
3775 }
3776 row_eval_cache[s].as_ref().expect("just filled above")
3777 }
3778 (None, None, Some(e)) => {
3779 crate::bump_counter!(AGG_PER_ROW_EVAL_FALLBACK);
3780 arg_owned = eval_arg(
3781 e,
3782 mat.as_deref().expect("needs_mat for non-bound arg"),
3783 &ctx,
3784 )?;
3785 &arg_owned
3786 }
3787 };
3788 let arg2_val = match (&spec.arg2, &arg2_literal_val[i]) {
3789 (None, _) => None,
3790 // v7.37.43 (DISTA A-3) — literal arg2: clone the
3791 // precomputed value, skip per-row eval & row mat.
3792 (Some(_), Some(lit)) => {
3793 // v7.37.9 Phase 0 diagnostic — count per-row
3794 // hits of the DISTA A-3 fast path.
3795 crate::bump_counter!(DISTA_LITERAL_ARG2_CACHE_FIRE);
3796 Some(lit.clone())
3797 }
3798 (Some(e), None) => Some(eval_arg(
3799 e,
3800 mat.as_deref().expect("needs_mat for arg2"),
3801 &ctx,
3802 )?),
3803 };
3804 let order_keys: Option<Vec<Value<'static>>> = if spec.order_by.is_empty() {
3805 None
3806 } else {
3807 crate::bump_counter!(AGGREGATE_ARRAY_AGG_ORDER_BY_FIRE);
3808 let mut keys: Vec<Value<'static>> = Vec::with_capacity(spec.order_by.len());
3809 for (k, o) in spec.order_by.iter().enumerate() {
3810 // Bound ORDER key → read the cell by reference; only
3811 // a non-bound key falls to the materialised eval path.
3812 keys.push(match order_pos[i][k] {
3813 Some(p) => row
3814 .get(p)
3815 .cloned()
3816 .map(Value::into_owned)
3817 .unwrap_or(Value::Null),
3818 None => eval_arg(
3819 &o.expr,
3820 mat.as_deref().expect("needs_mat for non-bound ORDER key"),
3821 &ctx,
3822 )?,
3823 });
3824 }
3825 Some(keys)
3826 };
3827 // v7.33 (array_agg argmax) — first_ordered: keep only the
3828 // running first-by-order element (strict-less replacement
3829 // = ties keep the earliest row, matching the stable-sort
3830 // `[1]`), no array build.
3831 if spec.first_ordered {
3832 if let Some(keys) = order_keys {
3833 let st = &mut entry.1[i];
3834 let better = match &st.first_best {
3835 None => true,
3836 Some((bk, _)) => {
3837 cmp_order_keys(
3838 &spec.order_by,
3839 &spec.order_enum_labels,
3840 &spec.order_collations,
3841 &keys,
3842 bk,
3843 ctx.mysql_dialect,
3844 ) == core::cmp::Ordering::Less
3845 }
3846 };
3847 if better {
3848 st.first_best = Some((keys, arg_ref.clone().into_owned()));
3849 }
3850 }
3851 continue;
3852 }
3853 if spec.distinct {
3854 // v7.37.x — single-Text DISTINCT fast path (see
3855 // bound fast path counterpart above). Per-spec
3856 // type invariance lets us use the column text as
3857 // the `seen` key directly, no `S<text>|` prefix.
3858 // v7.37.x (docker-fair DISTA) — BigInt parallel
3859 // path skips encode_key_refs_into entirely.
3860 if let Value::Text(s) = arg_ref {
3861 if entry.1[i].seen.contains(s.as_ref()) {
3862 continue;
3863 }
3864 entry.1[i].seen.insert(s.to_string());
3865 } else if let Value::BigInt(n) = arg_ref {
3866 let set = entry.1[i].seen_int.get_or_insert_with(BTreeSet::new);
3867 if !set.insert(*n) {
3868 continue;
3869 }
3870 } else if let Value::Int(n) = arg_ref {
3871 let set = entry.1[i].seen_int.get_or_insert_with(BTreeSet::new);
3872 if !set.insert(i64::from(*n)) {
3873 continue;
3874 }
3875 } else {
3876 encode_key_refs_into_in(
3877 core::slice::from_ref(&arg_ref),
3878 &mut dkeybuf,
3879 distinct_fold[i],
3880 );
3881 if entry.1[i].seen.contains(dkeybuf.as_str()) {
3882 continue;
3883 }
3884 entry.1[i].seen.insert(dkeybuf.clone());
3885 }
3886 }
3887 // v7.37.x (mailrs Track A 100k attack) — inline the
3888 // common aggregate kinds (MAX / MIN / Count / CountStar
3889 // / BoolOr / BoolAnd) here instead of dispatching
3890 // through `update_state`'s enum jump + per-kind branch.
3891 // Skipping the function-call overhead saves ~20-30 ns
3892 // per spec per row at 100 k; the slow kinds keep the
3893 // dispatched call.
3894 match spec.kind {
3895 AggKind::Max => {
3896 if !matches!(arg_ref, Value::Null) {
3897 // v7.39 (round 626) — the same deny list the
3898 // dispatched path applies. These inlined copies
3899 // exist for speed and are where `min(TRUE)`
3900 // actually lands, so a guard placed only on the
3901 // dispatched arm never fires.
3902 if !ctx.mysql_dialect && min_max_unsupported_type(arg_ref) {
3903 return Err(EvalError::TypeMismatch {
3904 detail: format!(
3905 "function max({}) does not exist",
3906 crate::conversions::pg_type_name_for_error_opt(
3907 arg_ref.data_type()
3908 )
3909 ),
3910 });
3911 }
3912 let st = &mut entry.1[i];
3913 let upd = match &st.extreme {
3914 None => true,
3915 Some(prev) => {
3916 extreme_cmp_in(
3917 spec.enum_labels.as_deref(),
3918 spec.arg_collation.as_deref(),
3919 arg_ref,
3920 prev,
3921 ctx.mysql_dialect,
3922 ) == core::cmp::Ordering::Greater
3923 }
3924 };
3925 if upd {
3926 st.extreme = Some(arg_ref.clone().into_owned());
3927 }
3928 }
3929 }
3930 AggKind::Min => {
3931 if !matches!(arg_ref, Value::Null) {
3932 // v7.39 (round 626) — see the Max arm above.
3933 if !ctx.mysql_dialect && min_max_unsupported_type(arg_ref) {
3934 return Err(EvalError::TypeMismatch {
3935 detail: format!(
3936 "function min({}) does not exist",
3937 crate::conversions::pg_type_name_for_error_opt(
3938 arg_ref.data_type()
3939 )
3940 ),
3941 });
3942 }
3943 let st = &mut entry.1[i];
3944 let upd = match &st.extreme {
3945 None => true,
3946 Some(prev) => {
3947 extreme_cmp_in(
3948 spec.enum_labels.as_deref(),
3949 spec.arg_collation.as_deref(),
3950 arg_ref,
3951 prev,
3952 ctx.mysql_dialect,
3953 ) == core::cmp::Ordering::Less
3954 }
3955 };
3956 if upd {
3957 st.extreme = Some(arg_ref.clone().into_owned());
3958 }
3959 }
3960 }
3961 AggKind::AnyValue => {
3962 if !matches!(arg_ref, Value::Null) {
3963 let st = &mut entry.1[i];
3964 if st.extreme.is_none() {
3965 st.extreme = Some(arg_ref.clone().into_owned());
3966 }
3967 }
3968 }
3969 AggKind::CountStar => {
3970 entry.1[i].num.count += 1;
3971 }
3972 AggKind::Count => {
3973 if !matches!(arg_ref, Value::Null) {
3974 entry.1[i].num.count += 1;
3975 }
3976 }
3977 AggKind::BoolOr => match arg_ref {
3978 Value::Bool(b) => {
3979 let st = &mut entry.1[i];
3980 st.bool_acc = Some(st.bool_acc.unwrap_or(false) || *b);
3981 }
3982 Value::Null => {}
3983 _ => update_state(
3984 &mut entry.1[i],
3985 spec.kind,
3986 &spec.name,
3987 arg_ref,
3988 arg2_val.as_ref(),
3989 order_keys,
3990 spec.enum_labels.as_deref(),
3991 spec.arg_collation.as_deref(),
3992 ctx.mysql_dialect,
3993 )?,
3994 },
3995 AggKind::BoolAnd => match arg_ref {
3996 Value::Bool(b) => {
3997 let st = &mut entry.1[i];
3998 st.bool_acc = Some(st.bool_acc.unwrap_or(true) && *b);
3999 }
4000 Value::Null => {}
4001 _ => update_state(
4002 &mut entry.1[i],
4003 spec.kind,
4004 &spec.name,
4005 arg_ref,
4006 arg2_val.as_ref(),
4007 order_keys,
4008 spec.enum_labels.as_deref(),
4009 spec.arg_collation.as_deref(),
4010 ctx.mysql_dialect,
4011 )?,
4012 },
4013 _ => {
4014 update_state(
4015 &mut entry.1[i],
4016 spec.kind,
4017 &spec.name,
4018 arg_ref,
4019 arg2_val.as_ref(),
4020 order_keys,
4021 spec.enum_labels.as_deref(),
4022 spec.arg_collation.as_deref(),
4023 ctx.mysql_dialect,
4024 )?;
4025 }
4026 }
4027 }
4028 continue;
4029 }
4030 // v7.32 (P4 increment 2) — eval (non-bound) path: present the
4031 // row as a borrowed Row once (Owned → zero-cost borrow; a join
4032 // tuple materialises here exactly once, never on the bound fast
4033 // path above), then the original eval loop runs unchanged.
4034 let row_materialised = row.as_row();
4035 let row: &Row<'static> = &row_materialised;
4036 let group_vals: Vec<Value<'static>> = group_exprs
4037 .iter()
4038 .map(|g| eval::eval_expr(g, row, &ctx))
4039 .collect::<Result<_, _>>()?;
4040 // v7.17.0 Phase 2.5b — case-insensitive group keying: fold
4041 // only the ci columns, and only when any exist. Display
4042 // value (`group_vals`) stays original — only the key folds.
4043 let key = if ci_positions.is_empty() {
4044 encode_key(&group_vals)
4045 } else {
4046 let mut key_vals = group_vals.clone();
4047 for &i in &ci_positions {
4048 if let Value::Text(s) = &key_vals[i] {
4049 // v7.39 (round 370, M4 P4a) — a MySQL folding column
4050 // (stored CaseInsensitive) folds case AND accent; a PG
4051 // CITEXT column stays ASCII-only.
4052 key_vals[i] = Value::text(if ctx.mysql_dialect {
4053 spg_storage::mysql_compare_fold(s)
4054 } else {
4055 s.to_ascii_lowercase()
4056 });
4057 }
4058 }
4059 encode_key(&key_vals)
4060 };
4061 // Probe by index; the map owns the key once on vacant insert.
4062 let idx = match groups.get(key.as_str()) {
4063 Some(&i) => i,
4064 None => {
4065 let i = order.len();
4066 let init: Vec<AggState> =
4067 (0..agg_specs.len()).map(|_| AggState::default()).collect();
4068 order.push((group_vals.clone(), init));
4069 groups.insert(key, i);
4070 i
4071 }
4072 };
4073 let entry = &mut order[idx];
4074 for (i, spec) in agg_specs.iter().enumerate() {
4075 // v7.32 (round-29) — FILTER (WHERE cond): exclude rows where
4076 // cond is not TRUE before accumulation (and before DISTINCT).
4077 if let Some(f) = &spec.filter
4078 && !matches!(eval_arg(f, row, &ctx)?, Value::Bool(true))
4079 {
4080 continue;
4081 }
4082 let arg_val = match &spec.arg {
4083 None => Value::Bool(true), // count_star: sentinel non-null
4084 Some(e) => eval_arg(e, row, &ctx)?,
4085 };
4086 // v7.17.0 — `string_agg(value, separator)` evaluates the
4087 // separator per row. v7.39 (round 762, F31-C2) — PG uses
4088 // the PER-ROW value (element i prefixed by row i's
4089 // separator, PG18-measured `a<b>b<c>c`); update_state
4090 // records it alongside the item now (the old note claimed
4091 // PG "treats it as constant" — measured false).
4092 let arg2_val = match &spec.arg2 {
4093 None => None,
4094 Some(e) => Some(eval_arg(e, row, &ctx)?),
4095 };
4096 // v7.24 (round-16 A) — aggregate-internal ORDER BY:
4097 // evaluate the key tuple against the source row.
4098 let order_keys: Option<Vec<Value<'static>>> = if spec.order_by.is_empty() {
4099 None
4100 } else {
4101 let mut keys: Vec<Value<'static>> = Vec::with_capacity(spec.order_by.len());
4102 for o in &spec.order_by {
4103 keys.push(eval_arg(&o.expr, row, &ctx)?);
4104 }
4105 Some(keys)
4106 };
4107 // v7.33 (array_agg argmax) — first_ordered: keep the running
4108 // first-by-order element only (mirrors the bound fast path).
4109 if spec.first_ordered {
4110 if let Some(keys) = order_keys {
4111 let st = &mut entry.1[i];
4112 let better = match &st.first_best {
4113 None => true,
4114 Some((bk, _)) => {
4115 cmp_order_keys(
4116 &spec.order_by,
4117 &spec.order_enum_labels,
4118 &spec.order_collations,
4119 &keys,
4120 bk,
4121 ctx.mysql_dialect,
4122 ) == core::cmp::Ordering::Less
4123 }
4124 };
4125 if better {
4126 st.first_best = Some((keys, arg_val.clone().into_owned()));
4127 }
4128 }
4129 continue;
4130 }
4131 // v7.25 (round-17) — DISTINCT: drop repeated inputs
4132 // before they reach the accumulator. NULLs flow through
4133 // (each aggregate's own NULL rule applies; PG also
4134 // treats NULL as a single distinct value for array_agg).
4135 // v7.37.x — single-Text fast path same shape as the
4136 // bound/slow paths above.
4137 if spec.distinct {
4138 // v7.37.x (docker-fair DISTA) — single-family fast
4139 // paths skip encode_key for Text/BigInt/Int.
4140 let inserted = match &arg_val {
4141 Value::Text(s) => entry.1[i].seen.insert(s.to_string()),
4142 Value::BigInt(n) => entry.1[i]
4143 .seen_int
4144 .get_or_insert_with(BTreeSet::new)
4145 .insert(*n),
4146 Value::Int(n) => entry.1[i]
4147 .seen_int
4148 .get_or_insert_with(BTreeSet::new)
4149 .insert(i64::from(*n)),
4150 _ => {
4151 let key = encode_key(core::slice::from_ref(&arg_val));
4152 entry.1[i].seen.insert(key)
4153 }
4154 };
4155 if !inserted {
4156 continue;
4157 }
4158 }
4159 update_state(
4160 &mut entry.1[i],
4161 spec.kind,
4162 &spec.name,
4163 &arg_val,
4164 arg2_val.as_ref(),
4165 order_keys,
4166 spec.enum_labels.as_deref(),
4167 spec.arg_collation.as_deref(),
4168 ctx.mysql_dialect,
4169 )?;
4170 }
4171 }
4172 Ok(order)
4173}
4174
4175/// (2a) Build the synthetic per-group schema: `__grp_0..K` then
4176/// `__agg_0..N`. Group types are probed from the first row; aggregate
4177/// types from each spec.
4178fn build_synth_schema(
4179 rows: AggRows<'_>,
4180 group_exprs: &[Expr],
4181 agg_specs: &[AggSpec],
4182 schema_cols: &[ColumnSchema],
4183 table_alias: Option<&str>,
4184 catalog: Option<&spg_storage::Catalog>,
4185 engine: Option<&crate::Engine>,
4186) -> Result<Vec<ColumnSchema>, EvalError> {
4187 let ctx = with_catalog(EvalContext::new(schema_cols, table_alias), catalog, engine);
4188 // Build synthetic schema: __grp_0..K then __agg_0..N.
4189 let group_types: Vec<DataType> = if rows.is_empty() {
4190 // Use Text as a safe stand-in — empty result means schema isn't
4191 // observable. Avoids needing to evaluate group exprs on no row.
4192 group_exprs.iter().map(|_| DataType::Text).collect()
4193 } else {
4194 let probe = rows.get(0).expect("non-empty checked above");
4195 let probe_row = probe.as_row();
4196 let probe: &Row<'static> = &probe_row;
4197 group_exprs
4198 .iter()
4199 .map(|g| {
4200 eval::eval_expr(g, probe, &ctx).map(|v| v.data_type().unwrap_or(DataType::Text))
4201 })
4202 .collect::<Result<_, _>>()?
4203 };
4204 let agg_types: Vec<DataType> = agg_specs
4205 .iter()
4206 .map(|spec| infer_agg_type(spec, schema_cols))
4207 .collect();
4208 let mut synth_schema: Vec<ColumnSchema> = Vec::new();
4209 for (i, ty) in group_types.iter().enumerate() {
4210 let mut col = ColumnSchema::new(format!("__grp_{i}"), *ty, true);
4211 // v7.39 (enum order knife) — a bare enum-column group key keeps
4212 // its enum identity so HAVING comparisons and the grouped-output
4213 // ORDER BY sort by member order downstream.
4214 if let Some(Expr::Column(c)) = group_exprs.get(i) {
4215 let src = schema_cols.iter().find(|sc| sc.name == c.name);
4216 col.user_enum_type = src.and_then(|sc| sc.user_enum_type.clone());
4217 // v7.39 (round 686) — and its collation, for the same reason and
4218 // by the same route. A `__grp_j` column is where a GROUP BY key
4219 // lives from here on, so anything the downstream ORDER BY needs
4220 // about the original column has to travel with it. Without this
4221 // the resolver looks the key up in the synthetic schema, finds
4222 // `__grp_0` with no collation, and the group-by ordering silently
4223 // stays byte-wise.
4224 col.collation_name = src.and_then(|sc| sc.collation_name.clone());
4225 // v7.38.14 — and the collation ENUM, which is a different field
4226 // and the one every MySQL text comparison actually reads. The
4227 // note above carried the NAME and stopped, exactly as round 688
4228 // did in `join.rs::build_combined_schema`; both left the enum
4229 // behind, and `ColumnSchema::new` defaults it to `Binary`, which
4230 // downstream reads as "byte-wise ON PURPOSE" rather than as
4231 // "unknown". So a `__grp_j` column claimed to be an explicit
4232 // binary column and `SELECT DISTINCT ... GROUP BY` stopped
4233 // folding. Sixth field through this hole, second site with the
4234 // identical shape.
4235 if let Some(sc) = src {
4236 col.collation = sc.collation;
4237 }
4238 }
4239 synth_schema.push(col);
4240 }
4241 for (i, ty) in agg_types.iter().enumerate() {
4242 synth_schema.push(ColumnSchema::new(format!("__agg_{i}"), *ty, true));
4243 }
4244 Ok(synth_schema)
4245}
4246
4247/// (2b) Materialise one synthetic row per group (insertion order):
4248/// apply each aggregate's internal ORDER BY, then finalise the running
4249/// state into the group + aggregate cells.
4250/// v7.33 — compare two aggregate-internal ORDER BY key tuples under the
4251/// per-key DESC / NULLS directives. This is the exact comparator the
4252/// finalize sort uses, factored out so the `first_ordered` argmax
4253/// accumulator's "keep first" decision is provably identical to taking
4254/// element `[1]` of the fully-sorted array.
4255fn cmp_order_keys(
4256 order_by: &[spg_sql::ast::OrderBy],
4257 order_enum_labels: &[Option<Vec<String>>],
4258 order_collations: &[Option<alloc::string::String>],
4259 a: &[Value<'static>],
4260 b: &[Value<'static>],
4261 mysql: bool,
4262) -> core::cmp::Ordering {
4263 for (k, o) in order_by.iter().enumerate() {
4264 // v7.39 (enum order knife) — an enum-typed sort key compares by
4265 // member order; NULLs and non-members keep the generic path.
4266 if let Some(Some(labels)) = order_enum_labels.get(k)
4267 && !matches!(&a[k], Value::Null)
4268 && !matches!(&b[k], Value::Null)
4269 && let Some(ord) = crate::eval::enum_ord_cmp(labels, &a[k], &b[k])
4270 {
4271 let ord = if o.desc { ord.reverse() } else { ord };
4272 if ord != core::cmp::Ordering::Equal {
4273 return ord;
4274 }
4275 continue;
4276 }
4277 // v7.37 (M4 P2) — `ORDER BY BINARY x` forces byte-wise sorting
4278 // even under the folding MySQL dialect, so a per-key BINARY
4279 // coercion turns folding back off for that key alone.
4280 let fold = mysql && !crate::eval::is_binary_coerced(&o.expr);
4281 // v7.38.18 — the key's declared collation, so the sort inside an
4282 // aggregate orders a collated column the way the statement's own
4283 // ORDER BY orders it.
4284 let coll = order_collations.get(k).and_then(Option::as_deref);
4285 let cmp = crate::orderby::order_by_value_cmp_coll(
4286 o.desc,
4287 o.nulls_first,
4288 &a[k],
4289 &b[k],
4290 fold,
4291 coll,
4292 );
4293 if cmp != core::cmp::Ordering::Equal {
4294 return cmp;
4295 }
4296 }
4297 core::cmp::Ordering::Equal
4298}
4299
4300#[allow(clippy::too_many_arguments)]
4301fn finalize_synth_rows(
4302 order: &[(Vec<Value<'static>>, Vec<AggState>)],
4303 agg_specs: &[AggSpec],
4304 synth_schema: &[ColumnSchema],
4305 rows: AggRows<'_>,
4306 schema_cols: &[ColumnSchema],
4307 table_alias: Option<&str>,
4308 catalog: Option<&spg_storage::Catalog>,
4309 engine: Option<&crate::Engine>,
4310 runner: Option<&dyn crate::ParallelRunner>,
4311) -> Result<Vec<Row<'static>>, EvalError> {
4312 let ctx = with_catalog(EvalContext::new(schema_cols, table_alias), catalog, engine);
4313 // v7.39 (round 747) — GROUP-parallel finalize for the collection
4314 // aggregates. `string_agg(s, ',' ORDER BY id) GROUP BY g` sorted
4315 // and joined every group's items serially — the panel's last
4316 // >=2.0x cell. Groups are independent; shards produce their row
4317 // ranges in group order and concatenate. Admission: every spec a
4318 // collection kind (their finalize reads items/keys/separator and
4319 // the dialect only — nothing that needs the engine hook), no
4320 // ordered-set / first_ordered / regression shapes.
4321 let collections_only = agg_specs.iter().all(|s| {
4322 matches!(
4323 classify_agg_name(&s.name),
4324 AggKind::StringAgg | AggKind::ArrayAgg | AggKind::JsonAgg
4325 ) && !s.first_ordered
4326 && !is_within_group_name(&s.name)
4327 });
4328 if collections_only
4329 && order.len() >= 16
4330 && let Some(r) = runner
4331 {
4332 let group_len_probe = order.first().map(|(g, _)| g.len()).unwrap_or(0);
4333 let _ = group_len_probe;
4334 let n_shards = (order.len() / 8).clamp(2, 8);
4335 let chunk = order.len().div_ceil(n_shards);
4336 type ShardOut = Result<Vec<Row<'static>>, EvalError>;
4337 let mysql = ctx.mysql_dialect;
4338 let style = ctx.render_style;
4339 let results = r.run_shards(n_shards, &|si| {
4340 let lo = si * chunk;
4341 let hi = ((si + 1) * chunk).min(order.len());
4342 let mut sctx = EvalContext::new(schema_cols, table_alias);
4343 sctx.mysql_dialect = mysql;
4344 sctx.render_style = style;
4345 let run = || -> ShardOut {
4346 let mut out: Vec<Row<'static>> = Vec::with_capacity(hi - lo);
4347 for (gvals, states) in &order[lo..hi] {
4348 out.push(finalize_one_group(
4349 gvals,
4350 states,
4351 agg_specs,
4352 synth_schema,
4353 &sctx,
4354 )?);
4355 }
4356 Ok(out)
4357 };
4358 alloc::boxed::Box::new(run())
4359 });
4360 let mut synth_rows: Vec<Row<'static>> = Vec::with_capacity(order.len());
4361 for boxed in results {
4362 let shard = boxed
4363 .downcast::<ShardOut>()
4364 .expect("runner echoes the closure's box");
4365 synth_rows.extend((*shard)?);
4366 }
4367 return Ok(synth_rows);
4368 }
4369 // v7.32 (round-29) — ordered-set direct arguments (the percentile
4370 // fraction) are constant per PG, so evaluate each once up front.
4371 let direct_arg_vals: Vec<Option<Value>> = agg_specs
4372 .iter()
4373 .map(|spec| match (&spec.direct_arg, rows.first().as_ref()) {
4374 (Some(e), Some(r)) => eval::eval_expr(e, &r.as_row(), &ctx).map(Some),
4375 _ => Ok(None),
4376 })
4377 .collect::<Result<_, _>>()?;
4378 // v7.39 (read01 orderedsetaggs.c) — the remaining hypothetical direct
4379 // arguments of a multi-key call, evaluated once like the first.
4380 let direct_extra_vals: Vec<Vec<Value>> = agg_specs
4381 .iter()
4382 .map(|spec| match rows.first().as_ref() {
4383 Some(r) if !spec.direct_args_extra.is_empty() => spec
4384 .direct_args_extra
4385 .iter()
4386 .map(|e| eval::eval_expr(e, &r.as_row(), &ctx))
4387 .collect(),
4388 _ => Ok(Vec::new()),
4389 })
4390 .collect::<Result<_, _>>()?;
4391
4392 // Materialise synthetic rows (insertion order = `order`).
4393 let mut synth_rows: Vec<Row<'static>> = Vec::new();
4394 for (gvals, states) in order {
4395 let mut values: Vec<Value<'static>> = Vec::with_capacity(synth_schema.len());
4396 // The synth schema is [group keys…, aggregates…]; the aggregate at
4397 // index `i` therefore sits at `group_len + i`.
4398 let group_len = gvals.len();
4399 values.extend(gvals.iter().cloned());
4400 for (i, st) in states.iter().enumerate() {
4401 // v7.33 (array_agg argmax) — first_ordered: the running
4402 // first-by-order value IS the result; no array build/sort.
4403 if agg_specs[i].first_ordered {
4404 values.push(
4405 st.first_best
4406 .as_ref()
4407 .map_or(Value::Null, |(_, v)| v.clone()),
4408 );
4409 continue;
4410 }
4411 // v7.24 (round-16 A) — order the collected items per the
4412 // aggregate-internal ORDER BY before finalize consumes
4413 // them.
4414 let st_sorted;
4415 let kw = agg_specs[i].order_by.len();
4416 let st_final: &AggState = if kw > 0 && st.item_keys.len() == st.items.len() * kw {
4417 let mut idx: Vec<usize> = (0..st.items.len()).collect();
4418 let ob = &agg_specs[i].order_by;
4419 idx.sort_by(|&x, &y| {
4420 cmp_order_keys(
4421 ob,
4422 &agg_specs[i].order_enum_labels,
4423 &agg_specs[i].order_collations,
4424 &st.item_keys[x * kw..(x + 1) * kw],
4425 &st.item_keys[y * kw..(y + 1) * kw],
4426 ctx.mysql_dialect,
4427 )
4428 });
4429 // Permute by MOVE out of the clone — the old form
4430 // cloned every item a second time on top of
4431 // `st.clone()`'s first (5000 Strings twice per group).
4432 let mut sorted = st.clone();
4433 let mut new_items: Vec<Value<'static>> = Vec::with_capacity(idx.len());
4434 for &j in &idx {
4435 new_items.push(core::mem::replace(&mut sorted.items[j], Value::Null));
4436 }
4437 // v7.39 (round 762, F31-C2) — the per-row separators
4438 // travel with their items through the sort.
4439 if sorted.item_seps.len() == sorted.items.len() {
4440 let mut new_seps: Vec<Option<alloc::vec::Vec<u8>>> =
4441 Vec::with_capacity(idx.len());
4442 for &j in &idx {
4443 new_seps.push(core::mem::take(&mut sorted.item_seps[j]));
4444 }
4445 sorted.item_seps = new_seps;
4446 }
4447 sorted.items = new_items;
4448 st_sorted = sorted;
4449 &st_sorted
4450 } else if agg_specs[i].distinct && st.items.len() > 1 {
4451 // v7.39 (round 257) — PG dedups a DISTINCT aggregate by
4452 // SORTING its input, so the collection aggregates emit
4453 // their values in sort order (probed across array_agg /
4454 // string_agg / json_agg, ints and text, NULLs last):
4455 // `array_agg(DISTINCT x)` over 2,1,2 is `{1,2}`, where
4456 // SPG kept first-seen order and answered `{2,1}`. An
4457 // explicit ORDER BY takes the branch above instead, and
4458 // the scalar aggregates (count / sum / …) are
4459 // order-insensitive, so this only moves the collections.
4460 // v7.39 (round 258) — an ENUM input sorts by MEMBER
4461 // ORDER, not by its text (`{sad,ok,happy}`, not
4462 // `{happy,ok,sad}`); `spec.enum_labels` already
4463 // carries the aggregate argument's labels for exactly
4464 // this. Round 257 shipped this sort with the generic
4465 // value comparison and regressed enum columns.
4466 let labels = agg_specs[i].enum_labels.as_deref();
4467 let mut sorted = st.clone();
4468 // v7.39 (round 762, F31-C2) — DISTINCT re-sorts items
4469 // alone; per-row separators cannot follow, so the
4470 // constant-separator path applies (the last row's).
4471 sorted.item_seps.clear();
4472 sorted.items.sort_by(|a, b| {
4473 if let Some(labels) = labels
4474 && !matches!(a, Value::Null)
4475 && !matches!(b, Value::Null)
4476 && let Some(ord) = crate::eval::enum_ord_cmp(labels, a, b)
4477 {
4478 return ord;
4479 }
4480 crate::order_by_value_cmp_in(false, Some(false), a, b, ctx.mysql_dialect)
4481 });
4482 st_sorted = sorted;
4483 &st_sorted
4484 } else {
4485 st
4486 };
4487 // Ordered-set aggregates compute from the sorted items + the
4488 // direct fraction; everything else uses the running state.
4489 let v = if is_within_group_name(&agg_specs[i].name) {
4490 finalize_ordered_set(
4491 &agg_specs[i].name,
4492 st_final,
4493 direct_arg_vals[i].as_ref(),
4494 &direct_extra_vals[i],
4495 &agg_specs[i].order_by,
4496 &agg_specs[i].order_collations,
4497 ctx.mysql_dialect,
4498 )?
4499 } else {
4500 finalize(&agg_specs[i].name, st_final, ctx.mysql_dialect)
4501 };
4502 // v7.39 (round 327, V44) — keep the zone identity. SPG carries a
4503 // timestamptz at runtime as `Value::Timestamp`, so the array
4504 // `array_agg` builds is a `TimestampArray` and `pg_typeof`
4505 // answered `timestamp without time zone[]` for
4506 // `array_agg(timestamptz_col)`. The STATIC type in the synth
4507 // schema already knows better (`infer_agg_type` maps
4508 // Timestamptz ⇒ TimestamptzArray); re-tag the value to match
4509 // it. Third code path in this family — V31 fixed the array
4510 // constructor, V43 the literal cast.
4511 let v = match (v, synth_schema.get(group_len + i).map(|c| c.ty)) {
4512 (Value::TimestampArray(items), Some(DataType::TimestamptzArray)) => {
4513 Value::TimestamptzArray(items)
4514 }
4515 (v, _) => v,
4516 };
4517 values.push(v);
4518 }
4519 synth_rows.push(Row::new(values));
4520 }
4521 Ok(synth_rows)
4522}
4523
4524/// v7.39 (round 747) — one group's synth row for the COLLECTION
4525/// aggregates (string_agg / array_agg / json_agg): the ordered/distinct
4526/// sort branches verbatim from the serial loop, then `finalize`. The
4527/// group-parallel path calls this; admission guarantees no
4528/// first_ordered / within-group / timestamptz-retag shapes reach it
4529/// (json/array of timestamptz retag is still applied for safety).
4530fn finalize_one_group(
4531 gvals: &[Value<'static>],
4532 states: &[AggState],
4533 agg_specs: &[AggSpec],
4534 synth_schema: &[ColumnSchema],
4535 ctx: &EvalContext<'_>,
4536) -> Result<Row<'static>, EvalError> {
4537 let group_len = gvals.len();
4538 let mut values: Vec<Value<'static>> = Vec::with_capacity(synth_schema.len());
4539 values.extend(gvals.iter().cloned());
4540 for (i, st) in states.iter().enumerate() {
4541 let st_sorted;
4542 let kw = agg_specs[i].order_by.len();
4543 let st_final: &AggState = if kw > 0 && st.item_keys.len() == st.items.len() * kw {
4544 let mut idx: Vec<usize> = (0..st.items.len()).collect();
4545 let ob = &agg_specs[i].order_by;
4546 idx.sort_by(|&x, &y| {
4547 cmp_order_keys(
4548 ob,
4549 &agg_specs[i].order_enum_labels,
4550 &agg_specs[i].order_collations,
4551 &st.item_keys[x * kw..(x + 1) * kw],
4552 &st.item_keys[y * kw..(y + 1) * kw],
4553 ctx.mysql_dialect,
4554 )
4555 });
4556 let mut sorted = st.clone();
4557 let mut new_items: Vec<Value<'static>> = Vec::with_capacity(idx.len());
4558 for &j in &idx {
4559 new_items.push(core::mem::replace(&mut sorted.items[j], Value::Null));
4560 }
4561 // v7.39 (round 762, F31-C2) — separators travel with items.
4562 if sorted.item_seps.len() == sorted.items.len() {
4563 let mut new_seps: Vec<Option<alloc::vec::Vec<u8>>> = Vec::with_capacity(idx.len());
4564 for &j in &idx {
4565 new_seps.push(core::mem::take(&mut sorted.item_seps[j]));
4566 }
4567 sorted.item_seps = new_seps;
4568 }
4569 sorted.items = new_items;
4570 st_sorted = sorted;
4571 &st_sorted
4572 } else if agg_specs[i].distinct && st.items.len() > 1 {
4573 let labels = agg_specs[i].enum_labels.as_deref();
4574 let mut sorted = st.clone();
4575 // v7.39 (round 762, F31-C2) — see the sibling branch above.
4576 sorted.item_seps.clear();
4577 sorted.items.sort_by(|a, b| {
4578 if let Some(labels) = labels
4579 && !matches!(a, Value::Null)
4580 && !matches!(b, Value::Null)
4581 && let Some(ord) = crate::eval::enum_ord_cmp(labels, a, b)
4582 {
4583 return ord;
4584 }
4585 crate::order_by_value_cmp_in(false, Some(false), a, b, ctx.mysql_dialect)
4586 });
4587 st_sorted = sorted;
4588 &st_sorted
4589 } else {
4590 st
4591 };
4592 let v = finalize(&agg_specs[i].name, st_final, ctx.mysql_dialect);
4593 let v = match (v, synth_schema.get(group_len + i).map(|c| c.ty)) {
4594 (Value::TimestampArray(items), Some(DataType::TimestamptzArray)) => {
4595 Value::TimestamptzArray(items)
4596 }
4597 (v, _) => v,
4598 };
4599 values.push(v);
4600 }
4601 Ok(Row::new(values))
4602}
4603
4604/// (3) Rewrite the user's SELECT items + HAVING to reference the
4605/// synthetic columns, filter groups by HAVING, and project each
4606/// surviving group into an output row. The synth rows ride alongside
4607/// (`kept_synth`) so post-LIMIT deferred subqueries can evaluate later.
4608#[allow(clippy::too_many_lines)]
4609fn project_groups(
4610 synth_rows: Vec<Row<'static>>,
4611 stmt: &SelectStatement,
4612 group_exprs: &[Expr],
4613 agg_specs: &[AggSpec],
4614 synth_schema: &[ColumnSchema],
4615 correlated_eval: Option<CorrelatedEval<'_>>,
4616 defer_projection: bool,
4617 catalog: Option<&spg_storage::Catalog>,
4618 mysql: bool,
4619) -> Result<Projection, EvalError> {
4620 // Rewrite the user's SELECT items + ORDER BY to reference synthetic
4621 // columns. After rewriting, every remaining `Expr::Column` must
4622 // resolve against the synthetic schema (i.e. must have been a GROUP
4623 // BY expression).
4624 let columns: Vec<ColumnSchema> = stmt
4625 .items
4626 .iter()
4627 .map(|item| match item {
4628 SelectItem::Wildcard | SelectItem::QualifiedWildcard(_) => {
4629 Err(EvalError::TypeMismatch {
4630 detail: "SELECT * with aggregates is not supported".into(),
4631 })
4632 }
4633 SelectItem::Expr { expr, alias } => {
4634 let rewritten = rewrite_expr(expr, group_exprs, agg_specs);
4635 let name = alias
4636 .clone()
4637 .unwrap_or_else(|| crate::select::default_output_name(expr, mysql));
4638 // v7.38.14 — the type is looked up in the synthetic schema
4639 // here; the COLLATION has to travel by the same route or the
4640 // output column claims `ColumnSchema::new`'s default, which
4641 // is `Binary` and reads downstream as "byte-wise on
4642 // purpose". That is what made `SELECT DISTINCT ... GROUP BY`
4643 // stop folding: the de-duplication asked the output schema
4644 // and the output schema had forgotten.
4645 //
4646 // Third site with this exact shape in one release, after
4647 // `join.rs::build_combined_schema` and `synth_schema` above.
4648 // Each one hand-picks which attributes survive; none picks
4649 // all of them. See S4 of the v7.38.14 roadmap.
4650 let mut col =
4651 ColumnSchema::new(name, agg_or_group_type(&rewritten, synth_schema), true);
4652 if let Expr::Column(c) = &rewritten
4653 && let Some(sc) = synth_schema
4654 .iter()
4655 .find(|sc| sc.name.eq_ignore_ascii_case(&c.name))
4656 {
4657 col.collation = sc.collation;
4658 col.collation_name.clone_from(&sc.collation_name);
4659 }
4660 Ok(col)
4661 }
4662 })
4663 .collect::<Result<_, _>>()?;
4664
4665 // Project per synthetic row. HAVING filters out groups *before*
4666 // we keep the projected row — same semantics as PG: HAVING runs
4667 // against the aggregated row (so `HAVING count(*) > 1` works) and
4668 // sees only group-by'd columns plus aggregate values.
4669 let mut synth_ctx = EvalContext::new(synth_schema, None);
4670 // v7.39 (enum order knife) — HAVING comparisons over enum group keys
4671 // need the catalog for member-order semantics (both the compile-time
4672 // Subtree fallback witness and the eval hook read it).
4673 if let Some(cat) = catalog {
4674 synth_ctx = synth_ctx.with_catalog(cat);
4675 }
4676 // v7.39 (round 404) — a MySQL session lets HAVING name a SELECT alias.
4677 // Build the (alias, expr) map from renaming SELECT items, then subst
4678 // before the aggregate rewrite.
4679 let having_aliases: Vec<(String, Expr)> = if mysql {
4680 stmt.items
4681 .iter()
4682 .filter_map(|it| match it {
4683 SelectItem::Expr {
4684 expr,
4685 alias: Some(a),
4686 } if !matches!(expr, Expr::Column(c)
4687 if c.qualifier.is_none() && c.name.eq_ignore_ascii_case(a)) =>
4688 {
4689 Some((a.clone(), expr.clone()))
4690 }
4691 _ => None,
4692 })
4693 .collect()
4694 } else {
4695 Vec::new()
4696 };
4697 let having_rewritten = stmt.having.as_ref().map(|h| {
4698 let h = if having_aliases.is_empty() {
4699 h.clone()
4700 } else {
4701 substitute_having_aliases(h.clone(), &having_aliases)
4702 };
4703 rewrite_expr(&h, group_exprs, agg_specs)
4704 });
4705 // v7.30 (phase 3e-1) - rewrite SELECT items ONCE. This ran per
4706 // GROUP (23.5k x 9 items of AST cloning = ~48% of the inbox
4707 // query in sampled stacks); the rewrite is group-independent.
4708 // Stable addresses also let the per-expression subquery plans
4709 // (v7.29 3c) hit across groups instead of rebuilding.
4710 let items_rewritten: alloc::vec::Vec<Option<Expr>> = stmt
4711 .items
4712 .iter()
4713 .map(|item| match item {
4714 SelectItem::Expr { expr, .. } => Some(rewrite_expr(expr, group_exprs, agg_specs)),
4715 SelectItem::Wildcard | SelectItem::QualifiedWildcard(_) => None,
4716 })
4717 .collect();
4718 // v7.31 (perf — PG lesson #1): subquery-bearing select items
4719 // deferred to post-LIMIT, when no sort/filter key can observe
4720 // them. ORDER BY rewrites are hoisted here so the safety check
4721 // and the sort below share one rewrite pass.
4722 let order_rewritten: Vec<Expr> = stmt
4723 .order_by
4724 .iter()
4725 .map(|o| rewrite_expr(&o.expr, group_exprs, agg_specs))
4726 .collect();
4727 let defer_enabled = correlated_eval.is_some()
4728 && !stmt.distinct
4729 && !having_rewritten
4730 .as_ref()
4731 .is_some_and(crate::expr_has_subquery)
4732 && !order_rewritten.iter().any(crate::expr_has_subquery);
4733 let deferred: Vec<(usize, Expr)> = if defer_enabled {
4734 items_rewritten
4735 .iter()
4736 .enumerate()
4737 .filter_map(|(i, r)| {
4738 r.as_ref()
4739 .filter(|e| crate::expr_has_subquery(e))
4740 .map(|e| (i, e.clone()))
4741 })
4742 .collect()
4743 } else {
4744 Vec::new()
4745 };
4746 // v7.32 (architecture v2, P2) — compile the per-group synth-row
4747 // expressions ONCE. The projection / HAVING here run per GROUP
4748 // (24k for the inbox shape) × per item; the rewritten exprs are
4749 // mostly `Column(__agg_N)` / `Column(__grp_K)` against the synth
4750 // schema — flat step programs, no tree walk per group.
4751 let having_compiled = having_rewritten
4752 .as_ref()
4753 .filter(|h| eval::fully_compilable(h))
4754 .map(|h| eval::compile_expr(h, &synth_ctx));
4755 let items_compiled: Vec<Option<eval::CompiledExpr>> = items_rewritten
4756 .iter()
4757 .enumerate()
4758 .map(|(i, r)| {
4759 r.as_ref()
4760 .filter(|e| !deferred.iter().any(|(c, _)| *c == i) && eval::fully_compilable(e))
4761 .map(|e| eval::compile_expr(e, &synth_ctx))
4762 })
4763 .collect();
4764 // v7.39 (round 621) — which items are set-returning, after the rewrite
4765 // (so `unnest(array_agg(x))` is seen as the SRF it is, over a synthetic
4766 // aggregate column). Only the builtin SRFs are recognised here; a user
4767 // `RETURNS SETOF` function inside an aggregate query keeps the old error,
4768 // because running its body needs the executor and this is not it.
4769 let srf_items: Vec<bool> = items_rewritten
4770 .iter()
4771 .map(|r| {
4772 r.as_ref()
4773 .is_some_and(|e| crate::select::top_level_srf_kind(e).is_some())
4774 })
4775 .collect();
4776 let any_srf = srf_items.iter().any(|b| *b);
4777 let mut kept_synth: Vec<Row<'static>> = Vec::new();
4778 let mut out_rows: Vec<Row<'static>> = Vec::new();
4779 let mut stack: Vec<Value<'static>> = Vec::new();
4780 for srow in synth_rows {
4781 if let Some(hc) = &having_compiled {
4782 let cond = eval::eval_compiled(hc, &srow, &synth_ctx, &mut stack)?;
4783 if !crate::eval::predicate_is_true(&cond, "HAVING", synth_ctx.mysql_dialect)? {
4784 continue;
4785 }
4786 } else if let Some(h) = &having_rewritten {
4787 let cond = match correlated_eval {
4788 Some(f) if crate::expr_has_subquery(h) => f(h, &srow, &synth_ctx)?,
4789 _ => eval::eval_expr(h, &srow, &synth_ctx)?,
4790 };
4791 if !crate::eval::predicate_is_true(&cond, "HAVING", synth_ctx.mysql_dialect)? {
4792 continue;
4793 }
4794 }
4795 // v7.37.x — when caller pre-truncates via ORDER BY+LIMIT, skip
4796 // per-item projection here; the caller fills the placeholder
4797 // out_rows from the top-K survivors below.
4798 if defer_projection {
4799 kept_synth.push(srow);
4800 out_rows.push(Row::new(Vec::new()));
4801 continue;
4802 }
4803 let mut values: Vec<Value<'static>> = Vec::with_capacity(columns.len());
4804 for (i, rewritten) in items_rewritten.iter().enumerate() {
4805 let Some(rewritten) = rewritten else { continue };
4806 if deferred.iter().any(|(c, _)| *c == i) {
4807 values.push(Value::Null);
4808 continue;
4809 }
4810 // v7.39 (round 621) — a SET-RETURNING item is collected as its
4811 // whole list; the rows it makes are built after the loop.
4812 if srf_items[i] {
4813 values.push(Value::Null);
4814 continue;
4815 }
4816 values.push(if let Some(cc) = &items_compiled[i] {
4817 eval::eval_compiled(cc, &srow, &synth_ctx, &mut stack)?
4818 } else {
4819 match correlated_eval {
4820 Some(f) if crate::expr_has_subquery(rewritten) => {
4821 f(rewritten, &srow, &synth_ctx)?
4822 }
4823 _ => eval::eval_expr(rewritten, &srow, &synth_ctx)?,
4824 }
4825 });
4826 }
4827 if any_srf {
4828 // v7.39 (round 621) — the aggregate's own output row is what a
4829 // target-list SRF expands over. `SELECT unnest(ARRAY[1,2]),
4830 // count(*) FROM t` answered `function unnest(integer[]) does not
4831 // exist`, because this projection evaluates each item scalarly and
4832 // there is exactly one row per group to put it in. PG answers two
4833 // rows, both carrying the same count — and the shape that matters
4834 // most is `unnest(array_agg(x))`, where the SRF's ARGUMENT is the
4835 // aggregate.
4836 //
4837 // Several SRFs in one list expand in LOCKSTEP with the shorter
4838 // padded to NULL, which is round 67's rule for every other path.
4839 let mut lists: Vec<Vec<Value<'static>>> = Vec::with_capacity(items_rewritten.len());
4840 for (i, rewritten) in items_rewritten.iter().enumerate() {
4841 match (srf_items[i], rewritten) {
4842 (true, Some(r)) => {
4843 lists.push(
4844 crate::select::top_level_srf_output(r, &srow, &synth_ctx).map_err(
4845 |e| match e {
4846 crate::EngineError::Eval(ev) => ev,
4847 other => EvalError::TypeMismatch {
4848 detail: alloc::format!("{other}"),
4849 },
4850 },
4851 )?,
4852 );
4853 }
4854 _ => lists.push(Vec::new()),
4855 }
4856 }
4857 let n = lists.iter().map(Vec::len).max().unwrap_or(0);
4858 for k in 0..n {
4859 let mut vals = values.clone();
4860 for (i, list) in lists.iter().enumerate() {
4861 if srf_items[i]
4862 && let Some(slot) = vals.get_mut(i)
4863 {
4864 *slot = list.get(k).cloned().unwrap_or(Value::Null);
4865 }
4866 }
4867 kept_synth.push(srow.clone());
4868 out_rows.push(Row::new(vals));
4869 }
4870 continue;
4871 }
4872 kept_synth.push(srow);
4873 out_rows.push(Row::new(values));
4874 }
4875 let deferred_project_state = if defer_projection {
4876 Some(DeferredProject {
4877 items_rewritten,
4878 items_compiled,
4879 })
4880 } else {
4881 None
4882 };
4883 Ok(Projection {
4884 columns,
4885 out_rows,
4886 kept_synth,
4887 deferred,
4888 order_rewritten,
4889 deferred_project: deferred_project_state,
4890 })
4891}
4892
4893/// (4) Sort the projected output by the rewritten ORDER BY keys. The
4894/// synth rows ride through the sort so deferred subqueries evaluate
4895/// against the surviving groups after the caller's LIMIT truncation.
4896fn sort_synth_by_order_by(
4897 synth_schema: &[ColumnSchema],
4898 out_columns: &[ColumnSchema],
4899 order_by: &[spg_sql::ast::OrderBy],
4900 order_rewritten: &[Expr],
4901 mut kept_synth: Vec<Row<'static>>,
4902 mut out_rows: Vec<Row<'static>>,
4903 correlated_eval: Option<CorrelatedEval<'_>>,
4904 keep_n: Option<usize>,
4905 catalog: Option<&spg_storage::Catalog>,
4906 mysql: bool,
4907) -> Result<(Vec<Row<'static>>, Vec<Row<'static>>), EvalError> {
4908 let mut synth_ctx = EvalContext::new(synth_schema, None);
4909 if let Some(cat) = catalog {
4910 synth_ctx = synth_ctx.with_catalog(cat);
4911 }
4912 // v7.39 (enum order knife) — per-key member labels when the rewritten
4913 // sort key is an enum-typed column (`__grp_K` carrying user_enum_type).
4914 let key_enum_labels: Vec<Option<&[String]>> = order_rewritten
4915 .iter()
4916 .map(|e| crate::eval::expr_enum_labels(e, synth_schema, catalog))
4917 .collect();
4918 // v7.39 (round 686) — per-key declared collation, built exactly like the
4919 // enum labels above because it is the same kind of thing: metadata the
4920 // comparator needs, resolved once per sort from the key expression.
4921 //
4922 // Located by forcing this call site to reverse and watching
4923 // `GROUP BY loc ORDER BY loc` flip. Rounds 682 and 685 wired eleven
4924 // sites between them without doing that, and none was on the path.
4925 //
4926 // v7.39.11 — and the DATABASE's collation when the column declares
4927 // none, which is what an ordinary `TEXT` column is compared under.
4928 //
4929 // Reading only the column's own name meant this comparator had no
4930 // collation for any column that had not been given one explicitly —
4931 // which is nearly all of them — so an aggregate query sorted text by
4932 // BYTES while the identical query without an aggregate collated.
4933 // Reported by sentori against 7.39.10 and reproduced here, same
4934 // rows, same ORDER BY, on a database collating `en_US.utf8`:
4935 //
4936 // ```text
4937 // PG 18 SPG 7.39.10
4938 // GROUP BY t ORDER BY t a A b B a A b B
4939 // GROUP BY t ORDER BY t + count(*) a A b B A B a b
4940 // GROUP BY t HAVING count(*) > 0 ORDER BY t a A b B A B a b
4941 // DISTINCT t, count(*) OVER () ORDER BY t a A b B A B a b
4942 // ```
4943 //
4944 // No row is wrong and nothing raises; only the order changes, and
4945 // only when an aggregate appears somewhere in the statement. It
4946 // arrived with the collation switch in v7.38.22 and survived every
4947 // release since, including v7.39.5, which was the collation release.
4948 //
4949 // `C` is byte order and resolves to `None`, so a database that never
4950 // asked for a locale takes the path it always did.
4951 //
4952 // An explicit `COLLATE` on the key outranks both: it is the caller
4953 // asking for a specific order, and `ORDER BY t COLLATE "C"` must
4954 // still give byte order on a collated database. Reading only
4955 // `Expr::Column` treated that spelling as "not a column" and fell
4956 // through to the database's collation, which is the opposite of
4957 // what it asks for.
4958 let db_coll = catalog.map(spg_storage::Catalog::db_collation);
4959 let key_colls: Vec<Option<alloc::string::String>> = order_rewritten
4960 .iter()
4961 .zip(order_by.iter())
4962 .map(|(e, orig)| {
4963 // An explicit `COLLATE` outranks everything below, and it
4964 // lives on the ORDER BY item rather than in the expression:
4965 // the rewrite has already turned a group key into a
4966 // `__grp_K` column reference by the time this runs. Found
4967 // by printing both sides rather than reasoning about them —
4968 // the first two attempts looked for `Expr::Collate` and
4969 // there is none. `ORDER BY t COLLATE "C"` is the caller
4970 // asking for byte order and must get it on a collated
4971 // database.
4972 if let Some(name) = &orig.collation {
4973 let name = name.clone();
4974 return (!crate::collate::is_byte_wise(&name)
4975 && crate::collate::is_supported(&name))
4976 .then_some(name);
4977 }
4978 let spg_sql::ast::Expr::Column(c) = e else {
4979 return None;
4980 };
4981 let pos = crate::eval::find_column_pos(c, &synth_ctx)?;
4982 let col = synth_schema.get(pos)?;
4983 // A MySQL column carries its own folding rule and is not a
4984 // candidate for inheriting a PostgreSQL database collation —
4985 // the same exclusion `Table::index_collation` makes.
4986 if matches!(col.collation, spg_storage::Collation::CaseInsensitive) {
4987 return None;
4988 }
4989 let name = col
4990 .collation_name
4991 .clone()
4992 .or_else(|| db_coll.map(alloc::string::String::from))?;
4993 (!crate::collate::is_byte_wise(&name) && crate::collate::is_supported(&name))
4994 .then_some(name)
4995 })
4996 .collect();
4997 // v6.4.0 — multi-key ORDER BY on aggregate output. Each key
4998 // gets its own rewrite + per-key DESC flag. (Rewrites hoisted
4999 // above as `order_rewritten` — shared with the deferral
5000 // safety check.)
5001 let keys_meta: Vec<(bool, Option<bool>)> =
5002 order_by.iter().map(|o| (o.desc, o.nulls_first)).collect();
5003 // P2: compile order-by keys once (per-group sort keys are
5004 // the same `__agg_N` / `__grp_K` shape as the projection).
5005 let order_compiled: Vec<Option<eval::CompiledExpr>> = order_rewritten
5006 .iter()
5007 .map(|e| {
5008 Some(e)
5009 .filter(|e| eval::fully_compilable(e))
5010 .map(|e| eval::compile_expr(e, &synth_ctx))
5011 })
5012 .collect();
5013 // The synth row rides through the sort so deferred exprs can
5014 // evaluate against the surviving groups after the caller's
5015 // LIMIT truncation.
5016 // v7.37 (round 1000) — a sort key that names an OUTPUT column.
5017 //
5018 // `ORDER BY 1` over a set-returning item does not substitute the
5019 // item's expression: round 80 resolved it to the item's output NAME
5020 // instead, because a positional key means the Nth OUTPUT column and
5021 // substituting the expression would make the key "the whole set",
5022 // evaluated once per group, which silently sorted nothing. The
5023 // non-aggregate paths then evaluate that name against the output
5024 // schema.
5025 //
5026 // This one evaluated it against the SYNTHETIC schema, which carries
5027 // `__agg_N` / `__grp_K` and no output aliases, so
5028 // `SELECT unnest(ARRAY[1,2]) AS u, count(*) … GROUP BY g ORDER BY 1`
5029 // answered `column "u" does not exist` — a query PG18.4 answers.
5030 // Spelling it `ORDER BY u` failed differently and for the same
5031 // reason: the alias resolved to the expression, and a set-returning
5032 // call cannot be evaluated scalarly on a group row.
5033 //
5034 // So: a key that names an output column and NOTHING in the synthetic
5035 // schema is read from the projected row, where expansion has already
5036 // put the per-row value. Synthetic names keep precedence, so nothing
5037 // that resolved before resolves differently now.
5038 let out_key_idx: Vec<Option<usize>> = order_rewritten
5039 .iter()
5040 .map(|e| {
5041 let spg_sql::ast::Expr::Column(c) = e else {
5042 return None;
5043 };
5044 if c.qualifier.is_some() || crate::eval::find_column_pos(c, &synth_ctx).is_some() {
5045 return None;
5046 }
5047 out_columns
5048 .iter()
5049 .position(|oc| oc.name.eq_ignore_ascii_case(&c.name))
5050 })
5051 .collect();
5052 let mut keystack: Vec<Value<'static>> = Vec::new();
5053 let mut tagged: Vec<(Vec<Value<'static>>, Row, Row)> = Vec::with_capacity(kept_synth.len());
5054 for (s, o) in kept_synth.into_iter().zip(out_rows) {
5055 let mut keys = Vec::with_capacity(order_rewritten.len());
5056 for (i, (e, oc)) in order_rewritten.iter().zip(&order_compiled).enumerate() {
5057 if let Some(oi) = out_key_idx[i] {
5058 keys.push(o.values.get(oi).cloned().unwrap_or(Value::Null));
5059 continue;
5060 }
5061 keys.push(if let Some(oc) = oc {
5062 eval::eval_compiled(oc, &s, &synth_ctx, &mut keystack)?
5063 } else {
5064 match correlated_eval {
5065 Some(f) if crate::expr_has_subquery(e) => f(e, &s, &synth_ctx)?,
5066 _ => eval::eval_expr(e, &s, &synth_ctx)?,
5067 }
5068 });
5069 }
5070 tagged.push((keys, s, o));
5071 }
5072 let cmp = |a: &(Vec<Value<'static>>, Row, Row), b: &(Vec<Value<'static>>, Row, Row)| {
5073 use core::cmp::Ordering;
5074 for (i, (ka, kb)) in a.0.iter().zip(b.0.iter()).enumerate() {
5075 let (desc, nf) = keys_meta[i];
5076 // v7.39 (enum order knife) — enum keys sort by member order.
5077 if let Some(Some(labels)) = key_enum_labels.get(i)
5078 && !matches!(ka, Value::Null)
5079 && !matches!(kb, Value::Null)
5080 && let Some(ord) = crate::eval::enum_ord_cmp(labels, ka, kb)
5081 {
5082 let ord = if desc { ord.reverse() } else { ord };
5083 if ord != Ordering::Equal {
5084 return ord;
5085 }
5086 continue;
5087 }
5088 let c = crate::orderby::order_by_value_cmp_coll(
5089 desc,
5090 nf,
5091 ka,
5092 kb,
5093 mysql,
5094 key_colls.get(i).and_then(|c| c.as_deref()),
5095 );
5096 if c != Ordering::Equal {
5097 return c;
5098 }
5099 }
5100 Ordering::Equal
5101 };
5102 // v7.37.3 — top-K partial sort when `keep_n` is small enough to
5103 // matter (`Some(k)` with `k < tagged.len()` and `k > 0`).
5104 // `select_nth_unstable_by` partitions in O(N), then we sort the
5105 // surviving prefix in O(K log K). Total = O(N + K log K) vs
5106 // O(N log N) the full sort would pay — matches the inbox-listing
5107 // shape PG uses.
5108 //
5109 match keep_n {
5110 Some(k) if k < tagged.len() && k > 0 => {
5111 let pivot = k - 1;
5112 tagged.select_nth_unstable_by(pivot, cmp);
5113 tagged[..k].sort_by(cmp);
5114 tagged.truncate(k);
5115 }
5116 _ => {
5117 tagged.sort_by(cmp);
5118 }
5119 }
5120 kept_synth = Vec::with_capacity(tagged.len());
5121 out_rows = Vec::with_capacity(tagged.len());
5122 for (_, s, o) in tagged {
5123 kept_synth.push(s);
5124 out_rows.push(o);
5125 }
5126 Ok((kept_synth, out_rows))
5127}
5128
5129/// v7.17.0 — walk the statement again to validate the positional
5130/// arity of every aggregate call site. Done after AST collection
5131/// rather than inside `collect_aggregates` so the collector stays
5132/// infallible; callers in `run()` can do a single early-error
5133/// exit before any per-row work.
5134fn validate_agg_arities(
5135 stmt: &SelectStatement,
5136 _specs: &[AggSpec],
5137 cols: &[ColumnSchema],
5138) -> Result<(), EvalError> {
5139 fn walk(e: &Expr, cols: &[ColumnSchema]) -> Result<(), EvalError> {
5140 if let Expr::FunctionCall { name, args } = e {
5141 let lower = name.to_ascii_lowercase();
5142 let expected: Option<usize> = match lower.as_str() {
5143 "count_star" => Some(0),
5144 "count" | "sum" | "avg" | "min" | "max" | "array_agg"
5145 | "any_value" | "range_agg" | "range_intersect_agg"
5146 // v7.17.0 — boolean aggregates also take exactly
5147 // one arg. `every` is an alias normalised inside
5148 // collect_aggregates / rewrite_expr.
5149 | "bool_and" | "bool_or" | "every"
5150 // v7.32 (round-29) — statistical + bitwise aggregates
5151 // + single-arg JSON aggregate.
5152 | "stddev" | "stddev_samp" | "stddev_pop"
5153 | "variance" | "var_samp" | "var_pop"
5154 | "bit_and" | "bit_or" | "bit_xor"
5155 | "json_agg" | "jsonb_agg" | "xmlagg"
5156 | "json_arrayagg" | "json_agg_strict" | "jsonb_agg_strict" => Some(1),
5157 // v7.39 (round 354, M12) — GROUP_CONCAT takes any number of
5158 // arguments: MySQL concatenates them PER ROW
5159 // (`GROUP_CONCAT(n, ':', t)` is `3:c,1:a,…`, measured), and
5160 // the parser lowers a `SEPARATOR '<s>'` tail onto the last
5161 // one. Fixing the arity at 1 refused both.
5162 "group_concat" => None,
5163 // v7.32 (round-29) — two-argument aggregates: string_agg,
5164 // the regression family f(Y, X), and json_object_agg.
5165 "string_agg"
5166 | "covar_pop" | "covar_samp" | "corr"
5167 | "regr_count" | "regr_avgx" | "regr_avgy" | "regr_slope"
5168 | "regr_intercept" | "regr_r2" | "regr_sxx" | "regr_syy" | "regr_sxy"
5169 | "json_object_agg" | "jsonb_object_agg"
5170 | "json_objectagg"
5171 | "json_object_agg_strict" | "jsonb_object_agg_strict"
5172 | "json_object_agg_unique" | "jsonb_object_agg_unique"
5173 | "json_object_agg_unique_strict" | "jsonb_object_agg_unique_strict" => Some(2),
5174 _ => None,
5175 };
5176 if let Some(want) = expected
5177 && args.len() != want
5178 {
5179 // v7.39.3 — this check runs BEFORE evaluation, over
5180 // unevaluated `Expr`s, so the argument types come from
5181 // the lexeme and the schema rather than from values:
5182 // PostgreSQL names a bare literal `unknown` here and a
5183 // column by its declared type (both measured against
5184 // 18.6). An argument neither of those covers — an
5185 // arithmetic expression, a nested call — has no static
5186 // type to name, and rather than invent one the old
5187 // sentence stands for that call.
5188 let named: Option<alloc::vec::Vec<alloc::string::String>> = args
5189 .iter()
5190 .map(|a| crate::select::static_arg_type(a, cols))
5191 .collect();
5192 return Err(named.map_or_else(
5193 || EvalError::TypeMismatch {
5194 detail: alloc::format!("{lower}() takes {want} arg(s), got {}", args.len()),
5195 },
5196 |t| EvalError::WrongArity {
5197 name: lower.clone(),
5198 types: t.join(", "),
5199 },
5200 ));
5201 }
5202 for a in args {
5203 walk(a, cols)?;
5204 }
5205 } else if let Expr::Binary { lhs, rhs, .. } = e {
5206 walk(lhs, cols)?;
5207 walk(rhs, cols)?;
5208 } else if let Expr::Unary { expr, .. }
5209 | Expr::Cast { expr, .. }
5210 | Expr::IsNull { expr, .. }
5211 | Expr::BoolTest { expr, .. } = e
5212 {
5213 walk(expr, cols)?;
5214 }
5215 Ok(())
5216 }
5217 for item in &stmt.items {
5218 if let SelectItem::Expr { expr, .. } = item {
5219 walk(expr, cols)?;
5220 }
5221 }
5222 for o in &stmt.order_by {
5223 walk(&o.expr, cols)?;
5224 }
5225 if let Some(h) = &stmt.having {
5226 walk(h, cols)?;
5227 }
5228 Ok(())
5229}
5230
5231/// v7.33 (array_agg argmax) — recognise `(array_agg(x ORDER BY y))[1]`,
5232/// the argmax/argmin idiom: a non-DISTINCT ordered `array_agg`
5233/// subscripted by the constant 1. Returns `(value_arg, order_by,
5234/// filter)` on a match. When matched, the whole per-group array build +
5235/// sort + materialise is replaced by a running first-by-order scalar
5236/// accumulator and the subscript node is consumed (replaced by the
5237/// synthetic column). collect_aggregates and rewrite_expr share this one
5238/// matcher so their `__agg_<i>` assignment stays in lockstep.
5239fn first_ordered_array_agg(e: &Expr) -> Option<(&Expr, &[spg_sql::ast::OrderBy], Option<&Expr>)> {
5240 let Expr::ArraySubscript { target, index } = e else {
5241 return None;
5242 };
5243 if !matches!(
5244 index.as_ref(),
5245 Expr::Literal(spg_sql::ast::Literal::Integer(1))
5246 ) {
5247 return None;
5248 }
5249 let Expr::AggregateOrdered {
5250 call,
5251 order_by,
5252 distinct,
5253 filter,
5254 } = target.as_ref()
5255 else {
5256 return None;
5257 };
5258 if *distinct || order_by.is_empty() {
5259 return None;
5260 }
5261 let Expr::FunctionCall { name, args } = call.as_ref() else {
5262 return None;
5263 };
5264 if !name.eq_ignore_ascii_case("array_agg") || args.len() != 1 {
5265 return None;
5266 }
5267 Some((&args[0], order_by, filter.as_deref()))
5268}
5269
5270/// v7.39 (round 615) — the exact pair the finaliser reads: the BigNumeric
5271/// accumulator combined with whatever the i128 one still holds. Read-only,
5272/// because finalisation only borrows the state.
5273fn stddev_exact_pair(
5274 st: &AggState,
5275) -> Option<(
5276 spg_storage::bignum::BigNumeric,
5277 spg_storage::bignum::BigNumeric,
5278)> {
5279 use spg_storage::bignum::BigNumeric as BN;
5280 let fast =
5281 (!st.stddev_i_spent && (st.stddev_i_sum != 0 || st.stddev_i_sum_sq != 0)).then(|| {
5282 (
5283 BN::from_i128(st.stddev_i_sum, 0),
5284 BN::from_i128(st.stddev_i_sum_sq, 0),
5285 )
5286 });
5287 match (st.stddev_sum.as_ref(), st.stddev_sum_sq.as_ref(), fast) {
5288 (Some(s), Some(sq), Some((fs, fsq))) => Some((s.add(&fs), sq.add(&fsq))),
5289 (Some(s), Some(sq), None) => Some((s.clone(), sq.clone())),
5290 (None, None, Some(pair)) => Some(pair),
5291 _ => None,
5292 }
5293}
5294
5295/// v7.39 (round 615) — fold the i128 Σx / Σx² into the exact BigNumeric
5296/// pair and retire the fast accumulator. Called once when an input needs the
5297/// slow path, and once at finalisation; both are idempotent because the fast
5298/// pair is zeroed as it is spent.
5299fn spend_stddev_i128(st: &mut AggState) {
5300 if st.stddev_i_spent {
5301 return;
5302 }
5303 st.stddev_i_spent = true;
5304 if st.stddev_i_sum == 0 && st.stddev_i_sum_sq == 0 {
5305 // Nothing accumulated: leave the pair as it was (None means "no
5306 // exact input yet", which the finaliser reads).
5307 return;
5308 }
5309 use spg_storage::bignum::BigNumeric as BN;
5310 let sum = BN::from_i128(st.stddev_i_sum, 0);
5311 let sum_sq = BN::from_i128(st.stddev_i_sum_sq, 0);
5312 st.stddev_sum = Some(st.stddev_sum.as_ref().map_or(sum.clone(), |s| s.add(&sum)));
5313 st.stddev_sum_sq = Some(
5314 st.stddev_sum_sq
5315 .as_ref()
5316 .map_or(sum_sq.clone(), |s| s.add(&sum_sq)),
5317 );
5318}
5319
5320fn collect_aggregates(e: &Expr, out: &mut Vec<AggSpec>) {
5321 match e {
5322 Expr::Collate { expr, .. } | Expr::NamedArg { expr, .. } => collect_aggregates(expr, out),
5323 Expr::Variadic(expr) => collect_aggregates(expr, out),
5324 // v7.24 (round-16 A) — ordered aggregate: register the inner
5325 // call's spec with the ordering attached.
5326 Expr::AggregateOrdered {
5327 call,
5328 order_by,
5329 distinct,
5330 filter,
5331 } => {
5332 if let Expr::FunctionCall { name, args } = call.as_ref() {
5333 let lower = name.to_ascii_lowercase();
5334 if is_aggregate_name(&lower) {
5335 let canonical = if lower == "every" {
5336 "bool_and".to_string()
5337 } else {
5338 lower
5339 };
5340 // Ordered-set aggregates (`percentile_cont(f)
5341 // WITHIN GROUP (ORDER BY x)`) take the value to
5342 // aggregate from the sort spec and the in-parens
5343 // arg as the direct (fraction) argument.
5344 let ordered_set = is_within_group_name(&canonical);
5345 let (arg, direct_arg, direct_args_extra) = if ordered_set {
5346 (
5347 order_by.first().map(|o| o.expr.clone()),
5348 args.first().cloned(),
5349 args.iter().skip(1).cloned().collect(),
5350 )
5351 } else {
5352 (args.first().cloned(), None, Vec::new())
5353 };
5354 let spec = AggSpec {
5355 kind: classify_agg_name(&canonical),
5356 enum_labels: None,
5357 arg_collation: None,
5358 order_enum_labels: Vec::new(),
5359 order_collations: Vec::new(),
5360 name: canonical.clone(),
5361 arg,
5362 arg2: if agg_uses_second_arg(&canonical) {
5363 args.get(1).cloned()
5364 } else {
5365 None
5366 },
5367 distinct: *distinct,
5368 order_by: order_by.clone(),
5369 filter: filter.as_deref().cloned(),
5370 direct_arg,
5371 direct_args_extra,
5372 first_ordered: false,
5373 };
5374 if !out.iter().any(|s| {
5375 s.name == spec.name
5376 && s.arg == spec.arg
5377 && s.arg2 == spec.arg2
5378 && s.distinct == spec.distinct
5379 && s.order_by == spec.order_by
5380 && s.filter == spec.filter
5381 && s.direct_arg == spec.direct_arg
5382 && s.direct_args_extra == spec.direct_args_extra
5383 && s.first_ordered == spec.first_ordered
5384 }) {
5385 out.push(spec);
5386 }
5387 return;
5388 }
5389 }
5390 collect_aggregates(call, out);
5391 for o in order_by {
5392 collect_aggregates(&o.expr, out);
5393 }
5394 }
5395 Expr::FunctionCall { name, args } => {
5396 let lower = name.to_ascii_lowercase();
5397 if is_aggregate_name(&lower) {
5398 let arg = if lower == "count_star" {
5399 None
5400 } else {
5401 args.first().cloned()
5402 };
5403 // v7.17.0 — second positional arg for
5404 // `string_agg(value, separator)`; v7.32 — also the
5405 // regression family `f(Y, X)` and `json_object_agg`.
5406 let arg2 = if agg_uses_second_arg(&lower) {
5407 args.get(1).cloned()
5408 } else {
5409 None
5410 };
5411 // v7.17.0 — `every` is the SQL-standard alias for
5412 // `bool_and`; collapse at collection time so
5413 // update_state / finalize need only one arm.
5414 let canonical = if lower == "every" {
5415 "bool_and".to_string()
5416 } else {
5417 lower
5418 };
5419 let spec = AggSpec {
5420 kind: classify_agg_name(&canonical),
5421 enum_labels: None,
5422 arg_collation: None,
5423 order_enum_labels: Vec::new(),
5424 order_collations: Vec::new(),
5425 name: canonical,
5426 arg: arg.clone(),
5427 arg2: arg2.clone(),
5428 distinct: false,
5429 order_by: Vec::new(),
5430 filter: None,
5431 direct_arg: None,
5432 direct_args_extra: Vec::new(),
5433 first_ordered: false,
5434 };
5435 if !out.iter().any(|s| {
5436 s.name == spec.name
5437 && s.arg == spec.arg
5438 && s.arg2 == spec.arg2
5439 && !s.distinct
5440 && s.order_by == spec.order_by
5441 && s.filter.is_none()
5442 && !s.first_ordered
5443 }) {
5444 out.push(spec);
5445 }
5446 // Don't recurse into the arg — nested aggregates are
5447 // illegal in standard SQL.
5448 } else {
5449 for a in args {
5450 collect_aggregates(a, out);
5451 }
5452 }
5453 }
5454 Expr::Binary { lhs, rhs, .. } => {
5455 collect_aggregates(lhs, out);
5456 collect_aggregates(rhs, out);
5457 }
5458 Expr::Unary { expr, .. }
5459 | Expr::Cast { expr, .. }
5460 | Expr::IsNull { expr, .. }
5461 | Expr::BoolTest { expr, .. }
5462 | Expr::FieldAccess { base: expr, .. } => {
5463 collect_aggregates(expr, out);
5464 }
5465 Expr::Like { expr, pattern, .. } => {
5466 collect_aggregates(expr, out);
5467 collect_aggregates(pattern, out);
5468 }
5469 Expr::InList { expr, list, .. } => {
5470 collect_aggregates(expr, out);
5471 for item in list {
5472 collect_aggregates(item, out);
5473 }
5474 }
5475 Expr::Extract { source, .. } => collect_aggregates(source, out),
5476 // v4.10 subquery + v4.12 window / Literal / Column —
5477 // non-recursing leaves for the aggregate collector.
5478 Expr::ScalarSubquery(_)
5479 | Expr::Exists { .. }
5480 | Expr::InSubquery { .. }
5481 | Expr::RowInSubquery { .. }
5482 | Expr::RowCmpSubquery { .. }
5483 | Expr::WindowFunction { .. }
5484 | Expr::Literal(_)
5485 | Expr::Placeholder(_)
5486 | Expr::Column(_) => {}
5487 // v7.10.10 — recurse into array constructor children +
5488 // subscript / ANY/ALL operands.
5489 Expr::Array(items) => {
5490 for elem in items {
5491 collect_aggregates(elem, out);
5492 }
5493 }
5494 Expr::ArraySubscript { target, index } => {
5495 // v7.33 (array_agg argmax) — `(array_agg(x ORDER BY y))[1]`
5496 // collects as a first_ordered spec; the subscript is consumed
5497 // here (do NOT recurse into the array_agg, or it would also
5498 // register a plain full-array spec).
5499 if let Some((arg, order_by, filter)) = first_ordered_array_agg(e) {
5500 let spec = AggSpec {
5501 kind: AggKind::ArrayAgg,
5502 enum_labels: None,
5503 arg_collation: None,
5504 order_enum_labels: Vec::new(),
5505 order_collations: Vec::new(),
5506 name: "array_agg".to_string(),
5507 arg: Some(arg.clone()),
5508 arg2: None,
5509 distinct: false,
5510 order_by: order_by.to_vec(),
5511 filter: filter.cloned(),
5512 direct_arg: None,
5513 direct_args_extra: Vec::new(),
5514 first_ordered: true,
5515 };
5516 if !out.iter().any(|s| {
5517 s.name == spec.name
5518 && s.arg == spec.arg
5519 && s.order_by == spec.order_by
5520 && s.filter == spec.filter
5521 && s.first_ordered
5522 }) {
5523 out.push(spec);
5524 }
5525 return;
5526 }
5527 collect_aggregates(target, out);
5528 collect_aggregates(index, out);
5529 }
5530 Expr::ArraySlice { target, lo, hi } => {
5531 collect_aggregates(target, out);
5532 if let Some(l) = lo {
5533 collect_aggregates(l, out);
5534 }
5535 if let Some(h) = hi {
5536 collect_aggregates(h, out);
5537 }
5538 }
5539 Expr::AnyAll { expr, array, .. } => {
5540 collect_aggregates(expr, out);
5541 collect_aggregates(array, out);
5542 }
5543 Expr::Case {
5544 operand,
5545 branches,
5546 else_branch,
5547 } => {
5548 if let Some(o) = operand {
5549 collect_aggregates(o, out);
5550 }
5551 for (w, t) in branches {
5552 collect_aggregates(w, out);
5553 collect_aggregates(t, out);
5554 }
5555 if let Some(e) = else_branch {
5556 collect_aggregates(e, out);
5557 }
5558 }
5559 }
5560}
5561
5562pub(crate) fn update_state(
5563 st: &mut AggState,
5564 kind: AggKind,
5565 name: &str,
5566 v: &Value<'_>,
5567 arg2: Option<&Value<'_>>,
5568 order_keys: Option<Vec<Value<'static>>>,
5569 enum_labels: Option<&[String]>,
5570 // v7.39 (round 690) — the argument column's collation, beside
5571 // `enum_labels` because it is the same kind of fact about the argument.
5572 arg_collation: Option<&str>,
5573 mysql: bool,
5574) -> Result<(), EvalError> {
5575 let is_null = matches!(v, Value::Null);
5576 // v7.37.4 (R34) — dispatch by pre-classified `kind` (`Copy`
5577 // enum), not by per-row string match. Hot inner loop on
5578 // multi-aggregate queries (mailrs `/api/conversations`: 14
5579 // aggregates × 100 k rows = 1.4 M dispatches) sees an enum
5580 // jump table instead of a sequence of `eq_str` checks. `name`
5581 // is still threaded through for error messages so the user-
5582 // facing wording is unchanged.
5583 match kind {
5584 AggKind::CountStar => st.num.count += 1,
5585 AggKind::Count => {
5586 if !is_null {
5587 st.num.count += 1;
5588 }
5589 }
5590 AggKind::Sum | AggKind::Avg => {
5591 // v7.39 (round 665) — was a hand-copied duplicate of `acc_cell`,
5592 // arm for arm, down to the wording of the type error. Verified
5593 // equivalent before collapsing: same nine variants, same error,
5594 // and the two apparent differences are both unobservable — this
5595 // one counted before the match so a value that errors bumped the
5596 // count first (the error aborts the query, so it is discarded),
5597 // and its `is_null` early return is literally
5598 // `matches!(v, Value::Null)`, which is the arm `acc_cell` has.
5599 //
5600 // Round 626 had to add a SMALLINT arm HERE that the other three
5601 // copies already carried; `SELECT sum(x)` over a smallint column
5602 // answered "sum/avg need numeric, got smallint" until then. That
5603 // is the failure mode this collapse removes.
5604 acc_cell(&mut st.num, v)?;
5605 }
5606 AggKind::Min => {
5607 if is_null {
5608 return Ok(());
5609 }
5610 if !mysql && min_max_unsupported_type(v) {
5611 return Err(EvalError::TypeMismatch {
5612 detail: format!(
5613 "function min({}) does not exist",
5614 crate::conversions::pg_type_name_for_error_opt(v.data_type())
5615 ),
5616 });
5617 }
5618 match &st.extreme {
5619 None => st.extreme = Some(v.clone().into_owned()),
5620 Some(cur) => {
5621 if extreme_cmp_in(enum_labels, arg_collation, v, cur, mysql)
5622 == core::cmp::Ordering::Less
5623 {
5624 st.extreme = Some(v.clone().into_owned());
5625 }
5626 }
5627 }
5628 }
5629 AggKind::AnyValue => {
5630 if is_null {
5631 return Ok(());
5632 }
5633 if st.extreme.is_none() {
5634 st.extreme = Some(v.clone().into_owned());
5635 }
5636 }
5637 AggKind::RangeAgg => {
5638 if is_null {
5639 return Ok(());
5640 }
5641 let Value::Range {
5642 kind,
5643 lower,
5644 upper,
5645 lower_inc,
5646 upper_inc,
5647 empty,
5648 } = v
5649 else {
5650 return Err(EvalError::TypeMismatch {
5651 detail: format!(
5652 "range_agg requires a range value, got {}",
5653 crate::conversions::pg_type_name_for_error_opt(v.data_type())
5654 ),
5655 });
5656 };
5657 // Initialise the accumulator on first sight (even for
5658 // an empty range, so all-empty groups finalize to {}).
5659 if st.extreme.is_none() {
5660 st.extreme = Some(Value::Multirange {
5661 kind: *kind,
5662 ranges: alloc::vec::Vec::new(),
5663 });
5664 }
5665 if !empty && let Some(Value::Multirange { ranges, .. }) = &mut st.extreme {
5666 ranges.push(spg_storage::RangeSpan {
5667 lower: lower.clone(),
5668 upper: upper.clone(),
5669 lower_inc: *lower_inc,
5670 upper_inc: *upper_inc,
5671 empty: false,
5672 });
5673 }
5674 }
5675 AggKind::RangeIntersectAgg => {
5676 if is_null {
5677 return Ok(());
5678 }
5679 if !matches!(v, Value::Range { .. }) {
5680 return Err(EvalError::TypeMismatch {
5681 detail: format!(
5682 "range_intersect_agg requires a range value, got {}",
5683 crate::conversions::pg_type_name_for_error_opt(v.data_type())
5684 ),
5685 });
5686 }
5687 match &st.extreme {
5688 None => st.extreme = Some(v.clone().into_owned()),
5689 Some(prev) => {
5690 st.extreme = Some(range_intersect(prev, &v.clone().into_owned()));
5691 }
5692 }
5693 }
5694 AggKind::Max => {
5695 if is_null {
5696 return Ok(());
5697 }
5698 if !mysql && min_max_unsupported_type(v) {
5699 return Err(EvalError::TypeMismatch {
5700 detail: format!(
5701 "function max({}) does not exist",
5702 crate::conversions::pg_type_name_for_error_opt(v.data_type())
5703 ),
5704 });
5705 }
5706 match &st.extreme {
5707 None => st.extreme = Some(v.clone().into_owned()),
5708 Some(cur) => {
5709 if extreme_cmp_in(enum_labels, arg_collation, v, cur, mysql)
5710 == core::cmp::Ordering::Greater
5711 {
5712 st.extreme = Some(v.clone().into_owned());
5713 }
5714 }
5715 }
5716 }
5717 // v7.17.0 — string_agg(value, separator). NULL value is
5718 // skipped (PG aggregate-skip-null). v7.39 (round 762,
5719 // F31-C2) — the separator is PER ROW in PG (the old note's
5720 // "using the last value at finalize" claim was measured
5721 // false): each surviving item records its own row's
5722 // separator in `item_seps`; the `separator` snapshot stays
5723 // for the constant-path consumers. count is bumped so we can
5724 // distinguish "empty group → NULL" from "all-NULL group →
5725 // NULL".
5726 AggKind::StringAgg => {
5727 let has_arg2 = arg2.is_some();
5728 match arg2 {
5729 Some(Value::Text(s)) => st.separator = Some(s.as_bytes().to_vec()),
5730 Some(Value::Bytes(b)) => st.separator = Some(b.to_vec()),
5731 _ => {}
5732 }
5733 if is_null {
5734 return Ok(());
5735 }
5736 // Text collects as-is; other scalars coerce to their
5737 // text rendering (MySQL group_concat semantics — also
5738 // matches PG's cast-then-aggregate idiom for
5739 // string_agg(v::text, sep)).
5740 let rendered = render_string_agg_item(v);
5741 if let Some(item) = rendered {
5742 st.items.push(item);
5743 // v7.39 (round 762, F31-C2) — the row's own separator
5744 // rides with its item (NULL separator → None → empty).
5745 if has_arg2 {
5746 st.item_seps.push(match arg2 {
5747 Some(Value::Text(sp)) => Some(sp.as_bytes().to_vec()),
5748 Some(Value::Bytes(sp)) => Some(sp.to_vec()),
5749 _ => None,
5750 });
5751 }
5752 if let Some(k) = order_keys {
5753 st.item_keys.extend(k);
5754 }
5755 st.num.count += 1;
5756 } else {
5757 return Err(EvalError::TypeMismatch {
5758 detail: format!(
5759 "string_agg requires text value, got {}",
5760 crate::conversions::pg_type_name_for_error_opt(v.data_type())
5761 ),
5762 });
5763 }
5764 }
5765 // v7.17.0 — array_agg(value). Unlike string_agg, NULL
5766 // elements are KEPT in the array (PG behaviour); the
5767 // result is NULL only when ZERO rows fed in. Element type
5768 // is locked from the first row's value type; subsequent
5769 // rows must match (PG also rejects mixed-type array_agg).
5770 AggKind::ArrayAgg => {
5771 st.items.push(v.clone().into_owned());
5772 if let Some(k) = order_keys {
5773 st.item_keys.extend(k);
5774 }
5775 st.num.count += 1;
5776 }
5777 // v7.17.0 — bool_and(p): TRUE iff every non-NULL input is
5778 // TRUE. NULL skipped; running accumulator stays at TRUE
5779 // until the first non-NULL FALSE.
5780 AggKind::BoolAnd => {
5781 if is_null {
5782 return Ok(());
5783 }
5784 let b = match v {
5785 Value::Bool(b) => *b,
5786 other => {
5787 return Err(EvalError::TypeMismatch {
5788 detail: format!(
5789 "bool_and requires bool, got {}",
5790 crate::conversions::pg_type_name_for_error_opt(other.data_type())
5791 ),
5792 });
5793 }
5794 };
5795 st.bool_acc = Some(st.bool_acc.map_or(b, |acc| acc && b));
5796 }
5797 // v7.17.0 — bool_or(p): TRUE iff any non-NULL input is
5798 // TRUE. NULL skipped.
5799 AggKind::BoolOr => {
5800 if is_null {
5801 return Ok(());
5802 }
5803 let b = match v {
5804 Value::Bool(b) => *b,
5805 other => {
5806 return Err(EvalError::TypeMismatch {
5807 detail: format!(
5808 "bool_or requires bool, got {}",
5809 crate::conversions::pg_type_name_for_error_opt(other.data_type())
5810 ),
5811 });
5812 }
5813 };
5814 st.bool_acc = Some(st.bool_acc.map_or(b, |acc| acc || b));
5815 }
5816 // v7.32 (round-29) — variance / stddev family. Accumulate the
5817 // running sum (sum_float) and sum of squares (sum_sq) over the
5818 // non-NULL numeric inputs; finalize divides by n or n-1.
5819 AggKind::StddevFamily => {
5820 if is_null {
5821 return Ok(());
5822 }
5823 // v7.38 (read01) — keep an exact NUMERIC Σx / Σx² alongside the f64
5824 // pair for as long as every input is exact; a float input abandons it.
5825 if !st.stddev_saw_float {
5826 // v7.39 (round 615) — an integer input stays in i128, which is
5827 // exact and allocates nothing. Anything else, or an overflow,
5828 // spends the fast accumulator into the BigNumeric pair and
5829 // takes the old path from there.
5830 let as_int = match v {
5831 Value::SmallInt(n) => Some(i128::from(*n)),
5832 Value::Int(n) => Some(i128::from(*n)),
5833 Value::BigInt(n) => Some(i128::from(*n)),
5834 _ => None,
5835 };
5836 let folded = if st.stddev_i_spent {
5837 None
5838 } else if let Some(x) = as_int {
5839 match (
5840 st.stddev_i_sum.checked_add(x),
5841 x.checked_mul(x)
5842 .and_then(|xx| st.stddev_i_sum_sq.checked_add(xx)),
5843 ) {
5844 (Some(s), Some(sq)) => {
5845 st.stddev_i_sum = s;
5846 st.stddev_i_sum_sq = sq;
5847 Some(())
5848 }
5849 _ => None,
5850 }
5851 } else {
5852 None
5853 };
5854 if folded.is_none() {
5855 spend_stddev_i128(st);
5856 match crate::eval::binop::value_to_bignum(v) {
5857 Some(b) => {
5858 let sq = b.mul(&b);
5859 st.stddev_sum = Some(
5860 st.stddev_sum
5861 .as_ref()
5862 .map_or_else(|| b.clone(), |s| s.add(&b)),
5863 );
5864 st.stddev_sum_sq = Some(
5865 st.stddev_sum_sq
5866 .as_ref()
5867 .map_or_else(|| sq.clone(), |s| s.add(&sq)),
5868 );
5869 }
5870 None => st.stddev_saw_float = true,
5871 }
5872 }
5873 }
5874 let Some(x) = agg_value_to_f64(v) else {
5875 return Err(EvalError::TypeMismatch {
5876 detail: format!(
5877 "{name} needs numeric, got {}",
5878 crate::conversions::pg_type_name_for_error_opt(v.data_type())
5879 ),
5880 });
5881 };
5882 st.num.count += 1;
5883 st.num.sum_float += x;
5884 st.sum_sq += x * x;
5885 }
5886 // v7.32 (round-29) — bitwise aggregates over integer inputs.
5887 AggKind::BitAnd | AggKind::BitOr | AggKind::BitXor => {
5888 if is_null {
5889 return Ok(());
5890 }
5891 let n = match v {
5892 Value::Int(n) => i64::from(*n),
5893 Value::SmallInt(n) => i64::from(*n),
5894 Value::BigInt(n) => *n,
5895 other => {
5896 return Err(EvalError::TypeMismatch {
5897 detail: format!(
5898 "{name} needs integer, got {}",
5899 crate::conversions::pg_type_name_for_error_opt(other.data_type())
5900 ),
5901 });
5902 }
5903 };
5904 if matches!(v, Value::BigInt(_)) {
5905 st.bit_wide = true;
5906 }
5907 st.bit_acc = Some(match (st.bit_acc, kind) {
5908 (None, _) => n,
5909 (Some(acc), AggKind::BitAnd) => acc & n,
5910 (Some(acc), AggKind::BitOr) => acc | n,
5911 (Some(acc), _) => acc ^ n, // BitXor
5912 });
5913 }
5914 // v7.32 (round-29) — WITHIN GROUP aggregates (ordered-set +
5915 // hypothetical-set) collect the sort value (NULLs ignored, per
5916 // PG) into `items`, sorted at finalize by the parallel
5917 // `item_keys`.
5918 AggKind::WithinGroup => {
5919 // Counted before the NULL skip: the hypothetical-set
5920 // fractions divide by the full input size (PG).
5921 st.within_group_rows += 1;
5922 if is_null {
5923 return Ok(());
5924 }
5925 st.items.push(v.clone().into_owned());
5926 if let Some(k) = order_keys {
5927 st.item_keys.extend(k);
5928 }
5929 st.num.count += 1;
5930 }
5931 // v7.32 (round-29) — regression family f(Y, X). Only rows with
5932 // BOTH inputs non-NULL contribute (PG semantics). `v` is Y,
5933 // `arg2` is X.
5934 AggKind::Regression => {
5935 let (Some(y), Some(x)) = (agg_value_to_f64(v), arg2.and_then(agg_value_to_f64)) else {
5936 return Ok(()); // NULL (or non-numeric) in either input
5937 };
5938 // v7.39 (read01 round 115) — accumulate the sums of squared
5939 // deviations (Sxx / Syy / Sxy) incrementally via the Youngs-Cramer
5940 // update, matching PG's float8 regression aggregates to the last
5941 // ULP. The old naive form (`Σx² − (Σx)²/n` at finalize time) is
5942 // mathematically equal but rounds differently, so `corr` drifted in
5943 // the 16th digit. reg_sx / reg_sy stay raw sums (for the averages).
5944 st.reg_n += 1;
5945 let new_n = st.reg_n as f64;
5946 let new_sx = st.reg_sx + x;
5947 let new_sy = st.reg_sy + y;
5948 if st.reg_n > 1 {
5949 let n_prev = new_n - 1.0;
5950 let tmp_x = x * new_n - new_sx;
5951 let tmp_y = y * new_n - new_sy;
5952 let scale = 1.0 / (n_prev * new_n);
5953 st.reg_sxx += tmp_x * tmp_x * scale;
5954 st.reg_syy += tmp_y * tmp_y * scale;
5955 st.reg_sxy += tmp_x * tmp_y * scale;
5956 }
5957 st.reg_sx = new_sx;
5958 st.reg_sy = new_sy;
5959 }
5960 // v7.32 (round-29) — json_agg / jsonb_agg collect every input
5961 // (NULL becomes JSON null, per PG) in row order.
5962 AggKind::JsonAgg => {
5963 // v7.39 (read01 json.c) — the _strict variants skip NULLs.
5964 if is_null && name.ends_with("_strict") {
5965 return Ok(());
5966 }
5967 st.items.push(v.clone().into_owned());
5968 // Attach the ORDER BY key so finalize_synth_rows sorts the
5969 // elements (`json_agg(x ORDER BY x DESC)`), the same way
5970 // string_agg / array_agg do.
5971 if let Some(k) = order_keys {
5972 st.item_keys.extend(k);
5973 }
5974 st.num.count += 1;
5975 }
5976 // v7.32 (round-29) — json_object_agg(key, value): keys in
5977 // `items`, values in `aux_items`. A NULL key is skipped (PG
5978 // raises; we drop it rather than abort the whole query).
5979 AggKind::JsonObjectAgg => {
5980 if is_null {
5981 return Ok(());
5982 }
5983 // v7.39 (read01 json.c) — _strict skips NULL VALUES; _unique
5984 // raises PG's duplicate-key error.
5985 let val = arg2.cloned().map(Value::into_owned).unwrap_or(Value::Null);
5986 if matches!(val, Value::Null) && name.contains("_strict") {
5987 return Ok(());
5988 }
5989 if name.contains("_unique") {
5990 let kt = match v {
5991 Value::Text(s) | Value::Json(s) => s.to_string(),
5992 other => crate::json::value_to_json_text(other),
5993 };
5994 let dup = st.items.iter().any(|k| match k {
5995 Value::Text(s) | Value::Json(s) => *s == kt,
5996 other => crate::json::value_to_json_text(other) == kt,
5997 });
5998 if dup {
5999 return Err(EvalError::TypeMismatch {
6000 detail: alloc::format!("duplicate JSON object key value: {kt:?}"),
6001 });
6002 }
6003 }
6004 st.items.push(v.clone().into_owned());
6005 st.aux_items.push(val);
6006 st.num.count += 1;
6007 }
6008 }
6009 Ok(())
6010}
6011
6012#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
6013pub(crate) fn finalize(name: &str, st: &AggState, mysql: bool) -> Value<'static> {
6014 match name {
6015 "count" | "count_star" => Value::BigInt(st.num.count),
6016 "sum" => {
6017 if st.num.count == 0 {
6018 Value::Null
6019 } else if st.num.use_interval {
6020 Value::Interval {
6021 months: st.num.sum_iv_months as i32,
6022 days: st.num.sum_iv_days as i32,
6023 micros: st.num.sum_iv_micros as i64,
6024 kind: spg_storage::IntervalKind::Finite,
6025 }
6026 } else if st.num.use_money {
6027 Value::Money(st.num.sum_money as i64)
6028 } else if st.num.use_numeric {
6029 // v7.38 (read01, T6.P3) — a NaN / ±Infinity input propagates.
6030 if st.num.sum_num_kind != spg_storage::NumericKind::Finite {
6031 Value::numeric_special(st.num.sum_num_kind)
6032 } else if let Some(big) = &st.num.sum_big {
6033 // v7.39 (read01 numeric.c) — the sum spilled past i128;
6034 // fold in the int lane and render exactly.
6035 let tot = big.add(&spg_storage::bignum::BigNumeric::from_i128(
6036 i128::from(st.num.sum_int),
6037 0,
6038 ));
6039 crate::eval::binop::bignum_to_value(tot)
6040 } else {
6041 let (scaled, scale) = crate::numeric::numeric_add(
6042 st.num.sum_num_scaled,
6043 st.num.sum_num_scale,
6044 i128::from(st.num.sum_int),
6045 0,
6046 );
6047 Value::Numeric {
6048 scaled,
6049 scale,
6050 kind: spg_storage::NumericKind::Finite,
6051 }
6052 }
6053 } else if st.num.use_float {
6054 let total = st.num.sum_float + (st.num.sum_int as f64);
6055 // v7.39 (round 269) — sum over REAL input stays real in
6056 // PG; it widens only when something wider joined the
6057 // accumulation. avg is deliberately not the same:
6058 // avg(real) IS double precision (measured on 18.4).
6059 if st.num.float_not_real {
6060 Value::Float(total)
6061 } else {
6062 #[allow(clippy::cast_possible_truncation)]
6063 Value::Real(total as f32)
6064 }
6065 } else {
6066 Value::BigInt(st.num.sum_int)
6067 }
6068 }
6069 "avg" => {
6070 if st.num.count == 0 {
6071 Value::Null
6072 } else if st.num.use_interval {
6073 // PG interval_div: the month quotient truncates and its
6074 // remainder spills into DAYS (a month = 30 days), taking the
6075 // whole-day part into the day field and only the sub-day
6076 // fraction into time; the day remainder then spills into time.
6077 let n = i128::from(st.num.count);
6078 let day_us = 86_400_000_000i128;
6079 let months = i128::from(st.num.sum_iv_months);
6080 let days = i128::from(st.num.sum_iv_days);
6081 let month_out = months / n;
6082 let mrem_days_total = (months % n) * 30; // days (still over n)
6083 let days_from_month = mrem_days_total / n;
6084 let mrem_frac_us = (mrem_days_total % n) * day_us / n;
6085 let day_out = days / n;
6086 let drem_us = (days % n) * day_us / n;
6087 let micros = st.num.sum_iv_micros / n + mrem_frac_us + drem_us;
6088 Value::Interval {
6089 months: month_out as i32,
6090 days: (day_out + days_from_month) as i32,
6091 micros: micros as i64,
6092 kind: spg_storage::IntervalKind::Finite,
6093 }
6094 } else if st.num.use_money {
6095 // PG has no avg(money); we accept it as a sensible superset —
6096 // average of the cent totals, rounded half-away-from-zero.
6097 //
6098 // DELIBERATE. Round 664 read "PG refuses, SPG answers" off
6099 // the F29 list and wrote guards on four accumulators to
6100 // remove this before a test caught it. Per the round-641
6101 // policy such a divergence is judged by correctness risk,
6102 // and this one carries none: money IS cents, so rounding is
6103 // the type's granularity rather than a loss introduced
6104 // here, and no PG application can reach the shape, because
6105 // PG rejects it. Pinned at eight shapes in
6106 // `e2e_avg_money_round664`.
6107 let n = i128::from(st.num.count);
6108 let q =
6109 (st.num.sum_money * 2 + if st.num.sum_money >= 0 { n } else { -n }) / (2 * n);
6110 Value::Money(q as i64)
6111 } else if st.num.use_numeric {
6112 // v7.38 (read01, T6.P3) — avg of a special is that special
6113 // (NaN→NaN, ±Inf→±Inf); PG matches.
6114 if st.num.sum_num_kind != spg_storage::NumericKind::Finite {
6115 Value::numeric_special(st.num.sum_num_kind)
6116 } else if let Some(big) = &st.num.sum_big {
6117 // v7.39 (read01 numeric.c) — bignum avg = spilled sum /
6118 // count at PG's division display scale.
6119 use spg_storage::bignum::BigNumeric;
6120 let sum_tot = big.add(&BigNumeric::from_i128(i128::from(st.num.sum_int), 0));
6121 let cnt = BigNumeric::from_i128(i128::from(st.num.count), 0);
6122 let rscale = crate::numeric::division_display_scale_big(&sum_tot, &cnt);
6123 match sum_tot.div(&cnt, rscale) {
6124 Some(q) => crate::eval::binop::bignum_to_value(q),
6125 None => Value::Null,
6126 }
6127 } else {
6128 let (sum_scaled, sum_scale) = crate::numeric::numeric_add(
6129 st.num.sum_num_scaled,
6130 st.num.sum_num_scale,
6131 i128::from(st.num.sum_int),
6132 0,
6133 );
6134 let (scaled, scale) = crate::numeric::numeric_avg(
6135 sum_scaled,
6136 sum_scale,
6137 i128::from(st.num.count),
6138 );
6139 Value::Numeric {
6140 scaled,
6141 scale,
6142 kind: spg_storage::NumericKind::Finite,
6143 }
6144 }
6145 } else if st.num.use_float {
6146 Value::Float((st.num.sum_float + (st.num.sum_int as f64)) / (st.num.count as f64))
6147 } else {
6148 // v7.38 (read01, T4) — avg over integer input is exact NUMERIC
6149 // (PG: avg(int)/avg(bigint) → numeric), at PG's division display
6150 // scale. sum(int) is unaffected (it reads sum_int as BigInt).
6151 let (scaled, scale) = crate::numeric::numeric_avg(
6152 i128::from(st.num.sum_int),
6153 0,
6154 i128::from(st.num.count),
6155 );
6156 Value::Numeric {
6157 scaled,
6158 scale,
6159 kind: spg_storage::NumericKind::Finite,
6160 }
6161 }
6162 }
6163 "min" | "max" | "any_value" => st.extreme.clone().unwrap_or(Value::Null),
6164 // PG: range_agg over an empty group is NULL; all-empty
6165 // ranges finalize to the empty multirange {}.
6166 // v7.39 (round 231) — range_agg collects its inputs verbatim while
6167 // accumulating; PG's result is a *normalized* multirange, so the
6168 // spans are sorted, merged where they overlap or abut, and emptied
6169 // ones dropped exactly once, here. Without this
6170 // `range_agg` over `[1,3),[5,9),[2,6)` answered all three spans
6171 // where PG answers the single `{[1,9)}` they cover.
6172 "range_agg" => match st.extreme.clone() {
6173 Some(Value::Multirange { kind, ranges }) => Value::Multirange {
6174 kind,
6175 ranges: crate::eval::binop::normalize_multirange_spans(kind, &ranges),
6176 },
6177 other => other.unwrap_or(Value::Null),
6178 },
6179 "range_intersect_agg" => st.extreme.clone().unwrap_or(Value::Null),
6180 // v7.17.0 — string_agg: join all collected text items with
6181 // the captured separator. Empty / all-NULL group → NULL
6182 // (PG semantics).
6183 "string_agg" | "group_concat" | "xmlagg" => {
6184 if st.items.is_empty() {
6185 return Value::Null;
6186 }
6187 // group_concat defaults to ',' (MySQL); xmlagg and a
6188 // separator-less string_agg join bare.
6189 let sep: alloc::vec::Vec<u8> = st.separator.clone().unwrap_or_else(|| {
6190 if name == "group_concat" {
6191 alloc::vec![b',']
6192 } else {
6193 alloc::vec::Vec::new()
6194 }
6195 });
6196 // v7.39 (round 762, F31-C2) — per-row separators, when the
6197 // accumulate path carried them (aligned with items).
6198 let per_row: Option<&[Option<alloc::vec::Vec<u8>>]> =
6199 if !st.item_seps.is_empty() && st.item_seps.len() == st.items.len() {
6200 Some(&st.item_seps)
6201 } else {
6202 None
6203 };
6204 // v7.39.2 — a bytea input aggregates to a bytea (PG18's
6205 // `string_agg(bytea, bytea)`); every other input to text. The
6206 // decision is the items', not the caller's: the aggregate is
6207 // typed, so the first item speaks for all of them.
6208 let binary = matches!(st.items.first(), Some(Value::Bytes(_)));
6209 let mut out: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
6210 for (i, item) in st.items.iter().enumerate() {
6211 if i > 0 {
6212 match per_row {
6213 Some(seps) => {
6214 if let Some(sp) = &seps[i] {
6215 out.extend_from_slice(sp);
6216 }
6217 }
6218 None => out.extend_from_slice(&sep),
6219 }
6220 }
6221 match item {
6222 Value::Text(s) => out.extend_from_slice(s.as_bytes()),
6223 Value::Bytes(b) => out.extend_from_slice(b),
6224 // MySQL group_concat coerces scalars to text;
6225 // harmless for string_agg (typed inputs are
6226 // Text already).
6227 Value::Int(n) => out.extend_from_slice(n.to_string().as_bytes()),
6228 Value::BigInt(n) => out.extend_from_slice(n.to_string().as_bytes()),
6229 Value::SmallInt(n) => out.extend_from_slice(n.to_string().as_bytes()),
6230 Value::Float(f) => out.extend_from_slice(f.to_string().as_bytes()),
6231 Value::Bool(b) => {
6232 out.extend_from_slice(if *b { b"1" } else { b"0" });
6233 }
6234 _ => {}
6235 }
6236 }
6237 if binary {
6238 return Value::bytes(out);
6239 }
6240 // Every non-binary arm above wrote UTF-8, so this cannot fail;
6241 // a lossy conversion here would turn a bug into mojibake.
6242 match alloc::string::String::from_utf8(out) {
6243 Ok(text) => Value::text(text),
6244 Err(err) => Value::bytes(err.into_bytes()),
6245 }
6246 }
6247 // v7.17.0 — array_agg: collect into a typed array. NULL
6248 // elements are preserved per PG. Result type is decided
6249 // by the first non-NULL element seen (or Text fallback
6250 // when the whole group is NULL — PG would surface the
6251 // declared input type, but SPG hasn't yet wired the
6252 // aggregate's static input-type from `describe`).
6253 // v7.39 (read01 round 73) — ONE builder, shared with the `ARRAY[…]`
6254 // literal. This finalize used to dispatch on the first non-NULL element
6255 // with arms for int and bigint and a text fallback for everything else,
6256 // so `array_agg(bool_col)` came back as text[] — the same fallback-in-
6257 // place-of-a-decision that rounds 71/72 dug out of the literal path and
6258 // the array functions. Fifth site; now there is only one.
6259 "array_agg" => {
6260 if st.items.is_empty() {
6261 return Value::Null;
6262 }
6263 crate::eval::values::build_array_from_values(&st.items)
6264 }
6265 "bool_and" | "bool_or" => st.bool_acc.map_or(Value::Null, Value::Bool),
6266 // v7.32 (round-29) — variance / stddev. PG: `variance` ==
6267 // `var_samp`, `stddev` == `stddev_samp`. samp needs n >= 2
6268 // (n < 2 → NULL); pop needs n >= 1 (n == 1 → 0).
6269 "variance" | "var_samp" | "var_pop" | "stddev" | "stddev_samp" | "stddev_pop" => {
6270 let n = st.num.count;
6271 if n == 0 {
6272 return Value::Null;
6273 }
6274 let nf = n as f64;
6275 // v7.39 (round 381) — MySQL's bare STDDEV / VARIANCE are the
6276 // POPULATION statistics (`STDDEV` = `STDDEV_POP`, `VARIANCE` =
6277 // `VAR_POP` on MariaDB 11), where PG's bare forms are the
6278 // SAMPLE ones. `_samp` / `_pop` are explicit and unchanged.
6279 let pop = name.ends_with("_pop") || (mysql && (name == "stddev" || name == "variance"));
6280 if !pop && n < 2 {
6281 // var_samp / stddev (samp) with n == 1 → NULL.
6282 return Value::Null;
6283 }
6284 // v7.38 (read01) — over exact inputs PG's numeric overload applies:
6285 // variance = (N·Σx² − (Σx)²) / (N² | N·(N−1)) using numeric division's
6286 // display scale, and stddev is its numeric sqrt. Falls through to the
6287 // f64 path (a double result, PG's float8 overload) on a float input.
6288 if !st.stddev_saw_float {
6289 // v7.39 (round 615) — fold whatever the i128 accumulator holds
6290 // into the exact pair, once, here.
6291 if let Some((sum, sum_sq)) = stddev_exact_pair(st) {
6292 let (sum, sum_sq) = (&sum, &sum_sq);
6293 use spg_storage::bignum::BigNumeric as BN;
6294 let nb = BN::from_i128(i128::from(n), 0);
6295 let numerator = nb.mul(sum_sq).sub(&sum.mul(sum));
6296 let divisor = if pop {
6297 nb.mul(&nb)
6298 } else {
6299 nb.mul(&BN::from_i128(i128::from(n - 1), 0))
6300 };
6301 // PG returns a bare `0` (scale 0) for a zero / clamped-negative
6302 // numerator rather than the division's padded zero.
6303 if numerator.is_zero() || numerator.parts().0 {
6304 return Value::Numeric {
6305 scaled: 0,
6306 scale: 0,
6307 kind: spg_storage::NumericKind::Finite,
6308 };
6309 }
6310 let rscale = crate::numeric::division_display_scale_big(&numerator, &divisor);
6311 if let Some(var) = numerator.div(&divisor, rscale) {
6312 let out = if name.starts_with("stddev") {
6313 var.sqrt(crate::numeric::sqrt_display_scale_big(&var))
6314 } else {
6315 Some(var)
6316 };
6317 if let Some(o) = out {
6318 return crate::eval::binop::bignum_to_value(o);
6319 }
6320 }
6321 }
6322 }
6323 // Match PG's float8 accumulator operation order exactly
6324 // (utils/adt/float.c float8_var_pop / _samp): the numerator
6325 // is `N*Σx² - (Σx)²` and the divisor is `N²` (pop) or
6326 // `N*(N-1)` (samp). SPG previously used the algebraically
6327 // equal `(Σx² - (Σx)²/N) / denom`, whose different float
6328 // rounding drifted a ULP from PG on stddev (only masked
6329 // before by an imprecise hand-rolled sqrt).
6330 let numerator = (nf * st.sum_sq - st.num.sum_float * st.num.sum_float).max(0.0);
6331 let divisor = if pop { nf * nf } else { nf * (nf - 1.0) };
6332 let var = numerator / divisor;
6333 let result = if name.starts_with("stddev") {
6334 crate::eval::f64_sqrt(var)
6335 } else {
6336 var
6337 };
6338 // A float input resolves PG's float8 overload → double precision.
6339 Value::Float(result)
6340 }
6341 // v7.32 (round-29) — bitwise aggregates: None (empty / all-NULL)
6342 // → SQL NULL.
6343 "bit_and" | "bit_or" | "bit_xor" => st.bit_acc.map_or(Value::Null, |acc| {
6344 if st.bit_wide {
6345 Value::BigInt(acc)
6346 } else {
6347 Value::Int(acc as i32)
6348 }
6349 }),
6350 // v7.32 (round-29) — regression family. `regr_count` is the
6351 // paired n; everything else is NULL over an empty set. Terms
6352 // are the mean-centred sums of squares / cross-products.
6353 "regr_count" => Value::BigInt(st.reg_n),
6354 "covar_pop" | "covar_samp" | "corr" | "regr_avgx" | "regr_avgy" | "regr_slope"
6355 | "regr_intercept" | "regr_r2" | "regr_sxx" | "regr_syy" | "regr_sxy" => {
6356 let n = st.reg_n;
6357 if n == 0 {
6358 return Value::Null;
6359 }
6360 let nf = n as f64;
6361 // v7.39 (read01 round 115) — Sxx / Syy / Sxy are now the
6362 // Youngs-Cramer running deviation sums (accumulated above), so they
6363 // are used directly rather than re-derived from the raw squares.
6364 let sxx = st.reg_sxx;
6365 let syy = st.reg_syy;
6366 let sxy = st.reg_sxy;
6367 let avgx = st.reg_sx / nf;
6368 let avgy = st.reg_sy / nf;
6369 let out = match name {
6370 "regr_avgx" => Some(avgx),
6371 "regr_avgy" => Some(avgy),
6372 "regr_sxx" => Some(sxx),
6373 "regr_syy" => Some(syy),
6374 "regr_sxy" => Some(sxy),
6375 "covar_pop" => Some(sxy / nf),
6376 "covar_samp" => (n >= 2).then(|| sxy / (nf - 1.0)),
6377 "regr_slope" => (sxx != 0.0).then(|| sxy / sxx),
6378 "regr_intercept" => (sxx != 0.0).then(|| avgy - (sxy / sxx) * avgx),
6379 "corr" => {
6380 let d = sxx * syy;
6381 (d > 0.0).then(|| sxy / crate::eval::f64_sqrt(d))
6382 }
6383 // PG: NULL when sxx==0; 1 when syy==0 (and sxx>0).
6384 "regr_r2" => {
6385 if sxx == 0.0 {
6386 None
6387 } else if syy == 0.0 {
6388 Some(1.0)
6389 } else {
6390 Some((sxy * sxy) / (sxx * syy))
6391 }
6392 }
6393 _ => None,
6394 };
6395 out.map_or(Value::Null, Value::Float)
6396 }
6397 // v7.32 (round-29) — json_agg / jsonb_agg: a JSON array of every
6398 // collected element in row order; empty set → SQL NULL.
6399 "json_agg" | "jsonb_agg" | "json_arrayagg" | "json_agg_strict" | "jsonb_agg_strict" => {
6400 if st.items.is_empty() {
6401 return Value::Null;
6402 }
6403 let mut out = String::from("[");
6404 for (i, item) in st.items.iter().enumerate() {
6405 if i > 0 {
6406 out.push_str(", ");
6407 }
6408 out.push_str(&crate::json::value_to_json_text(item));
6409 }
6410 out.push(']');
6411 // jsonb_agg yields canonical jsonb (nested object keys sorted,
6412 // numbers normalised); json_agg keeps the input verbatim.
6413 let result = Value::json(out);
6414 if name.starts_with("jsonb_agg") {
6415 crate::json::canonicalize_value(result)
6416 } else {
6417 result
6418 }
6419 }
6420 // v7.32 (round-29) — json_object_agg: a JSON object built from
6421 // the parallel key (`items`) / value (`aux_items`) streams.
6422 "json_object_agg"
6423 | "jsonb_object_agg"
6424 | "json_objectagg"
6425 | "json_object_agg_strict"
6426 | "jsonb_object_agg_strict"
6427 | "json_object_agg_unique"
6428 | "jsonb_object_agg_unique"
6429 | "json_object_agg_unique_strict"
6430 | "jsonb_object_agg_unique_strict" => {
6431 if st.items.is_empty() {
6432 return Value::Null;
6433 }
6434 // Object keys are always JSON strings (PG coerces).
6435 let key_text = |key: &Value| -> String {
6436 match key {
6437 Value::Text(s) | Value::Json(s) => s.to_string(),
6438 other => crate::json::value_to_json_text(other),
6439 }
6440 };
6441 // jsonb dedups keys keeping the last value (jsonb is a
6442 // map); json preserves every pair including duplicates.
6443 let dedup = name.starts_with("jsonb_object_agg");
6444 // (key, value-index) pairs in first-seen key order; for
6445 // jsonb a repeated key updates its value-index in place.
6446 let mut pairs: Vec<(String, usize)> = Vec::with_capacity(st.items.len());
6447 for (i, key) in st.items.iter().enumerate() {
6448 let kt = key_text(key);
6449 if dedup {
6450 if let Some(slot) = pairs.iter_mut().find(|(k, _)| *k == kt) {
6451 slot.1 = i;
6452 continue;
6453 }
6454 }
6455 pairs.push((kt, i));
6456 }
6457 // v7.39 (read01 json.c) — PG's json_object_agg emits the
6458 // distinctive "{ \"k\" : v, ... }" spacing (jsonb variants
6459 // canonicalize it away below).
6460 let mut out = String::from("{ ");
6461 for (n, (kt, i)) in pairs.iter().enumerate() {
6462 if n > 0 {
6463 out.push_str(", ");
6464 }
6465 out.push_str(&crate::json::value_to_json_text(&Value::text(kt.clone())));
6466 out.push_str(" : ");
6467 let val = st.aux_items.get(*i).unwrap_or(&Value::Null);
6468 out.push_str(&crate::json::value_to_json_text(val));
6469 }
6470 out.push_str(" }");
6471 // jsonb_object_agg emits canonical jsonb — keys sorted by PG's
6472 // (length, byte) order; json_object_agg keeps first-seen order.
6473 let result = Value::json(out);
6474 if dedup {
6475 crate::json::canonicalize_value(result)
6476 } else {
6477 result
6478 }
6479 }
6480 // Ordered-set aggregates are finalized in `run` (they need the
6481 // sorted items + the direct fraction argument), never here.
6482 _ => unreachable!(),
6483 }
6484}
6485
6486/// v7.32 (round-29) — numeric coercion for the percentile interpolation.
6487fn agg_value_to_f64(v: &Value) -> Option<f64> {
6488 match v {
6489 Value::Int(n) => Some(f64::from(*n)),
6490 Value::SmallInt(n) => Some(f64::from(*n)),
6491 Value::BigInt(n) => Some(*n as f64),
6492 Value::Float(x) => Some(*x),
6493 Value::Real(x) => Some(f64::from(*x)),
6494 Value::Numeric { scaled, scale, .. } => Some(numeric_to_f64(*scaled, *scale)),
6495 _ => None,
6496 }
6497}
6498
6499/// The array form of a `percentile_cont/disc` direct argument
6500/// (`percentile_cont(ARRAY[0.25,0.5,0.75])`), as f64 fractions. `None` when the
6501/// direct argument is a plain scalar fraction. A NULL element stays `None` —
6502/// PG yields a NULL result element for it.
6503fn percentile_fraction_array(v: Option<&Value>) -> Option<Vec<Option<f64>>> {
6504 match v? {
6505 Value::FloatArray(a) => Some(a.clone()),
6506 Value::NumericArray(a) => Some(
6507 a.iter()
6508 .map(|x| x.map(|(scaled, scale)| numeric_to_f64(scaled, scale)))
6509 .collect(),
6510 ),
6511 Value::IntArray(a) => Some(a.iter().map(|x| x.map(f64::from)).collect()),
6512 // Array literals (`ARRAY[0.25,0.5,0.75]`) evaluate to a TextArray of the
6513 // element renderings; parse each back to f64.
6514 Value::TextArray(a) => Some(
6515 a.iter()
6516 .map(|x| x.as_deref().and_then(|s| s.parse::<f64>().ok()))
6517 .collect(),
6518 ),
6519 _ => None,
6520 }
6521}
6522
6523/// Build an array Value from a list of scalar values, dispatching on the first
6524/// non-NULL element's type (mirrors array_agg's finalize). Used by the array
6525/// form of `percentile_disc`, whose result is an array of the ordered-column
6526/// element type.
6527fn values_to_array(picked: &[Value<'_>]) -> Value<'static> {
6528 let owned: alloc::vec::Vec<Value<'static>> =
6529 picked.iter().map(|v| v.clone().into_owned()).collect();
6530 crate::eval::values::build_array_from_values(&owned)
6531}
6532
6533/// NUMERIC → f64 for the float-math aggregates (stddev / variance / corr /
6534/// percentile_cont). `scaled × 10^-scale`; `10^scale` fits in i128 for the
6535/// NUMERIC scale range, so no `f64::powi` (unavailable under no_std) is needed.
6536#[allow(clippy::cast_precision_loss)]
6537fn numeric_to_f64(scaled: i128, scale: u16) -> f64 {
6538 (scaled as f64) / (10i128.pow(u32::from(scale)) as f64)
6539}
6540
6541/// v7.32 (round-29) — finalize a WITHIN GROUP aggregate. `st.items` is
6542/// already sorted by the `WITHIN GROUP (ORDER BY …)` spec. `direct` is
6543/// the evaluated direct argument: the fraction for `percentile_*`, the
6544/// first hypothetical value for the hypothetical-set family (`rank`
6545/// etc. — `direct_extra` carries the rest of a multi-key call), and
6546/// unused by `mode`. `order_by` is the sort spec; the hypothetical-set
6547/// family compares in the sort direction (multi-key via `st.item_keys`).
6548#[allow(
6549 clippy::cast_precision_loss,
6550 clippy::cast_possible_truncation,
6551 clippy::cast_sign_loss,
6552 clippy::too_many_lines
6553)]
6554fn finalize_ordered_set(
6555 name: &str,
6556 st: &AggState,
6557 direct: Option<&Value>,
6558 direct_extra: &[Value<'static>],
6559 order_by: &[spg_sql::ast::OrderBy],
6560 order_collations: &[Option<alloc::string::String>],
6561 mysql: bool,
6562) -> Result<Value<'static>, EvalError> {
6563 let fraction = direct;
6564 // v7.39 (read01 orderedsetaggs.c) — PG validates the percentile
6565 // fraction before looking at the rows (an out-of-range fraction
6566 // errors even over an empty group), and a NULL fraction is NULL.
6567 let check_fraction = |f: f64| -> Result<f64, EvalError> {
6568 if !(0.0..=1.0).contains(&f) || f.is_nan() {
6569 return Err(EvalError::TypeMismatch {
6570 detail: format!("percentile value {f} is not between 0 and 1"),
6571 });
6572 }
6573 Ok(f)
6574 };
6575 let scalar_fraction: Option<Result<f64, EvalError>> =
6576 if matches!(name, "percentile_cont" | "percentile_disc") {
6577 match fraction {
6578 None | Some(Value::Null) => return Ok(Value::Null),
6579 Some(v) => match percentile_fraction_array(Some(v)) {
6580 Some(fracs) => {
6581 for f in fracs.iter().flatten() {
6582 check_fraction(*f)?;
6583 }
6584 None
6585 }
6586 None => Some(
6587 agg_value_to_f64(v)
6588 .ok_or_else(|| EvalError::TypeMismatch {
6589 detail: format!(
6590 "percentile fraction must be numeric, got {}",
6591 crate::conversions::pg_type_name_for_error_opt(v.data_type())
6592 ),
6593 })
6594 .and_then(check_fraction),
6595 ),
6596 },
6597 }
6598 } else {
6599 None
6600 };
6601 let items = &st.items;
6602 if items.is_empty() {
6603 // A hypothetical row ranks first over an empty group; the
6604 // distribution functions are 0 / divide-by-(n+1).
6605 return Ok(match name {
6606 "rank" | "dense_rank" => Value::BigInt(1),
6607 "percent_rank" => Value::Float(0.0),
6608 "cume_dist" => Value::Float(1.0),
6609 _ => Value::Null,
6610 });
6611 }
6612 let n = items.len();
6613 Ok(match name {
6614 // v7.32 (round-29) — hypothetical-set: the rank the direct value
6615 // would have if inserted into the group, in the sort direction.
6616 "rank" | "dense_rank" | "percent_rank" | "cume_dist" => {
6617 let Some(h) = fraction else {
6618 return Ok(Value::Null);
6619 };
6620 // v7.39 (read01 orderedsetaggs.c) — the multi-key form
6621 // compares the hypothetical tuple against the collected
6622 // `item_keys` tuples with the full sort spec.
6623 let kw = order_by.len();
6624 let multi = kw > 1 && st.item_keys.len() == items.len() * kw;
6625 let hv: Vec<Value<'static>> = core::iter::once(h.clone().into_owned())
6626 .chain(direct_extra.iter().cloned())
6627 .collect();
6628 let (desc, nulls_first) = order_by
6629 .first()
6630 .map_or((false, None), |o| (o.desc, o.nulls_first));
6631 let cmp_i = |i: usize| -> core::cmp::Ordering {
6632 if multi {
6633 cmp_order_keys(
6634 order_by,
6635 &[],
6636 order_collations,
6637 &st.item_keys[i * kw..(i + 1) * kw],
6638 &hv,
6639 mysql,
6640 )
6641 } else {
6642 crate::order_by_value_cmp_in(desc, nulls_first, &items[i], h, mysql)
6643 }
6644 };
6645 let mut before: Vec<usize> = Vec::new(); // sort strictly before h
6646 let mut before_or_eq = 0usize; // sort before-or-peer with h
6647 for i in 0..n {
6648 match cmp_i(i) {
6649 core::cmp::Ordering::Less => {
6650 before.push(i);
6651 before_or_eq += 1;
6652 }
6653 core::cmp::Ordering::Equal => before_or_eq += 1,
6654 core::cmp::Ordering::Greater => {}
6655 }
6656 }
6657 // PG divides by the FULL input size (NULL rows included);
6658 // `n` counts only the non-NULL values `items` holds.
6659 let nn = st.within_group_rows.max(n) as f64;
6660 match name {
6661 "rank" => Value::BigInt((before.len() + 1) as i64),
6662 "dense_rank" => {
6663 // Count distinct sort-key tuples among the strictly-
6664 // before rows (items arrive unsorted relative to
6665 // item_keys in the multi-key form, so sort + dedup).
6666 let tuple_cmp = |&x: &usize, &y: &usize| -> core::cmp::Ordering {
6667 if multi {
6668 cmp_order_keys(
6669 order_by,
6670 &[],
6671 order_collations,
6672 &st.item_keys[x * kw..(x + 1) * kw],
6673 &st.item_keys[y * kw..(y + 1) * kw],
6674 mysql,
6675 )
6676 } else {
6677 value_cmp(&items[x], &items[y])
6678 }
6679 };
6680 let mut sorted = before.clone();
6681 sorted.sort_by(tuple_cmp);
6682 let mut distinct = 0usize;
6683 for (k, &i) in sorted.iter().enumerate() {
6684 if k == 0 || tuple_cmp(&sorted[k - 1], &i) != core::cmp::Ordering::Equal {
6685 distinct += 1;
6686 }
6687 }
6688 Value::BigInt((distinct + 1) as i64)
6689 }
6690 "percent_rank" => Value::Float(before.len() as f64 / nn),
6691 "cume_dist" => Value::Float((before_or_eq as f64 + 1.0) / (nn + 1.0)),
6692 _ => unreachable!(),
6693 }
6694 }
6695 // Most frequent value; equal values are adjacent in the sorted
6696 // run, and a frequency tie resolves to the earliest run (the
6697 // smallest value under an ascending sort), matching PG.
6698 "mode" => {
6699 let (mut best_i, mut best_cnt) = (0usize, 1usize);
6700 let (mut run_i, mut run_cnt) = (0usize, 1usize);
6701 for i in 1..n {
6702 if value_cmp(&items[i], &items[run_i]) == core::cmp::Ordering::Equal {
6703 run_cnt += 1;
6704 } else {
6705 run_i = i;
6706 run_cnt = 1;
6707 }
6708 if run_cnt > best_cnt {
6709 best_cnt = run_cnt;
6710 best_i = run_i;
6711 }
6712 }
6713 items[best_i].clone()
6714 }
6715 // The first value whose cumulative fraction reaches `f`. PG accepts
6716 // both a scalar fraction (→ the element) and an array of fractions (→
6717 // an array of the ordered-column element type, with NULL fractions
6718 // yielding NULL elements).
6719 "percentile_disc" => {
6720 let idx_at = |f: f64| -> usize {
6721 if f <= 0.0 {
6722 0
6723 } else {
6724 (crate::eval::f64_ceil(f * n as f64) as usize)
6725 .saturating_sub(1)
6726 .min(n - 1)
6727 }
6728 };
6729 if let Some(fracs) = percentile_fraction_array(fraction) {
6730 let picked: Vec<Value> = fracs
6731 .iter()
6732 .map(|f| f.map_or(Value::Null, |f| items[idx_at(f)].clone()))
6733 .collect();
6734 return Ok(values_to_array(&picked));
6735 }
6736 let f = scalar_fraction.transpose()?.unwrap_or(0.0);
6737 items[idx_at(f)].clone()
6738 }
6739 // Linear interpolation between the two bracketing values. PG accepts
6740 // both a scalar fraction (→ float) and an array of fractions (→ a
6741 // float array, one interpolated value per requested percentile).
6742 "percentile_cont" => {
6743 // v7.39 (read01 orderedsetaggs.c) — the INTERVAL overload
6744 // interpolates component-wise with PG's month→day→time
6745 // remainder spill (a month is 30 days, a day 86400 s).
6746 if items.iter().all(|v| matches!(v, Value::Interval { .. })) {
6747 let iv = |i: usize| -> (f64, f64, f64) {
6748 match &items[i] {
6749 Value::Interval {
6750 months,
6751 days,
6752 micros,
6753 kind,
6754 } => (f64::from(*months), f64::from(*days), *micros as f64),
6755 _ => unreachable!(),
6756 }
6757 };
6758 let at = |f: f64| -> Value<'static> {
6759 if n == 1 {
6760 return items[0].clone();
6761 }
6762 let rank = f * (n as f64 - 1.0);
6763 let lo = crate::eval::f64_floor(rank) as usize;
6764 let hi = crate::eval::f64_ceil(rank) as usize;
6765 let frac = rank - lo as f64;
6766 let (lm, ld, lu) = iv(lo);
6767 let (hm, hd, hu) = iv(hi);
6768 let dm = (hm - lm) * frac;
6769 let m_i = dm as i64; // trunc toward zero
6770 let rem_days = (dm - m_i as f64) * 30.0 + (hd - ld) * frac;
6771 let d_i = rem_days as i64;
6772 let us = (rem_days - d_i as f64) * 86_400_000_000.0 + (hu - lu) * frac;
6773 Value::Interval {
6774 months: (lm as i64 + m_i) as i32,
6775 days: (ld as i64 + d_i) as i32,
6776 micros: lu as i64 + libm::round(us) as i64,
6777 kind: spg_storage::IntervalKind::Finite,
6778 }
6779 };
6780 if let Some(fracs) = percentile_fraction_array(fraction) {
6781 let picked: Vec<Value> =
6782 fracs.iter().map(|f| f.map_or(Value::Null, at)).collect();
6783 return Ok(values_to_array(&picked));
6784 }
6785 let f = scalar_fraction.transpose()?.unwrap_or(0.0);
6786 return Ok(at(f));
6787 }
6788 let Some(nums) = items
6789 .iter()
6790 .map(agg_value_to_f64)
6791 .collect::<Option<Vec<f64>>>()
6792 else {
6793 return Ok(Value::Null); // non-numeric ordered set
6794 };
6795 let at = |f: f64| -> f64 {
6796 if n == 1 {
6797 return nums[0];
6798 }
6799 let rank = f * (n as f64 - 1.0);
6800 let lo = crate::eval::f64_floor(rank) as usize;
6801 let hi = crate::eval::f64_ceil(rank) as usize;
6802 let frac = rank - lo as f64;
6803 nums[lo] + (nums[hi] - nums[lo]) * frac
6804 };
6805 if let Some(fracs) = percentile_fraction_array(fraction) {
6806 return Ok(Value::FloatArray(fracs.iter().map(|f| f.map(at)).collect()));
6807 }
6808 let f = scalar_fraction.transpose()?.unwrap_or(0.0);
6809 Value::Float(at(f))
6810 }
6811 _ => unreachable!(),
6812 })
6813}
6814
6815fn infer_agg_type(spec: &AggSpec, schema_cols: &[ColumnSchema]) -> DataType {
6816 // v7.26 (round-20 C) — the argument's statically-derived shape
6817 // types MIN/MAX/SUM/array_agg properly; RowDescription used to
6818 // report TEXT for these, breaking every sqlx typed decode.
6819 let arg_ty = spec
6820 .arg
6821 .as_ref()
6822 .and_then(|a| crate::describe::describe_expr(a, schema_cols))
6823 .map(|shape| shape.ty);
6824 // v7.33 (array_agg argmax) — `(array_agg(x ORDER BY y))[1]` yields the
6825 // ELEMENT type (x), not the array type.
6826 if spec.first_ordered {
6827 return arg_ty.unwrap_or(DataType::Text);
6828 }
6829 match spec.name.as_str() {
6830 "count" | "count_star" => DataType::BigInt,
6831 // v7.38 (read01, T4) — sum(int) → bigint, sum(bigint) → numeric (PG
6832 // widens to numeric to defend against i64 overflow), sum(float) → float.
6833 "sum" => match arg_ty {
6834 Some(DataType::Float) => DataType::Float,
6835 Some(DataType::BigInt) => DataType::Numeric {
6836 precision: 0,
6837 scale: 0,
6838 },
6839 _ => DataType::BigInt,
6840 },
6841 // v7.38 (read01, T4) — avg over any integer / numeric input is NUMERIC
6842 // (PG); only avg(float8) stays double precision.
6843 "avg" => match arg_ty {
6844 Some(DataType::Float) => DataType::Float,
6845 _ => DataType::Numeric {
6846 precision: 0,
6847 scale: 0,
6848 },
6849 },
6850 // v7.17.0 — string_agg returns TEXT. v7.39.2 — except over bytea,
6851 // where PG18's `string_agg(bytea, bytea)` returns BYTEA and the
6852 // finalize agrees.
6853 "string_agg" | "group_concat" | "xmlagg" => match arg_ty {
6854 Some(DataType::Bytes) => DataType::Bytes,
6855 _ => DataType::Text,
6856 },
6857 // v7.39 (read01 round 73) — the STATIC type follows the same rule the
6858 // finalize does, so `pg_typeof(array_agg(b))` is `boolean[]`.
6859 "array_agg" => match arg_ty {
6860 Some(DataType::Int | DataType::SmallInt) => DataType::IntArray,
6861 Some(DataType::BigInt) => DataType::BigIntArray,
6862 Some(DataType::Bool) => DataType::BoolArray,
6863 Some(DataType::Date) => DataType::DateArray,
6864 Some(DataType::Timestamp) => DataType::TimestampArray,
6865 Some(DataType::Timestamptz) => DataType::TimestamptzArray,
6866 Some(DataType::Uuid) => DataType::UuidArray,
6867 Some(DataType::Float) => DataType::FloatArray,
6868 Some(DataType::Numeric { .. }) => DataType::NumericArray,
6869 Some(DataType::Bytes) => DataType::BytesArray,
6870 _ => DataType::TextArray,
6871 },
6872 // v7.17.0 — boolean aggregates always return BOOL (nullable
6873 // — empty / all-NULL group → NULL).
6874 "bool_and" | "bool_or" => DataType::Bool,
6875 // v7.32 (round-29) — variance / stddev are floating point;
6876 // percentile_cont interpolates to float; the regression family
6877 // (except regr_count) is floating point.
6878 // v7.38 (read01, T4.3) — PG stddev / variance return NUMERIC.
6879 "stddev" | "stddev_samp" | "stddev_pop" | "variance" | "var_samp" | "var_pop" => {
6880 DataType::Numeric {
6881 precision: 0,
6882 scale: 0,
6883 }
6884 }
6885 "percentile_cont" | "covar_pop" | "covar_samp" | "corr" | "regr_avgx" | "regr_avgy"
6886 | "regr_slope" | "regr_intercept" | "regr_r2" | "regr_sxx" | "regr_syy" | "regr_sxy" => {
6887 DataType::Float
6888 }
6889 // v7.32 (round-29) — bitwise aggregates, regr_count, and the
6890 // integer hypothetical-set ranks return an integer.
6891 // v7.38 (read01, T4.4) — bit_and/or/xor return the INPUT integer type
6892 // (PG: bit_and(int) → integer, bit_and(bigint) → bigint).
6893 "bit_and" | "bit_or" | "bit_xor" => match arg_ty {
6894 Some(DataType::SmallInt) => DataType::SmallInt,
6895 Some(DataType::BigInt) => DataType::BigInt,
6896 _ => DataType::Int,
6897 },
6898 "regr_count" | "rank" | "dense_rank" => DataType::BigInt,
6899 // v7.32 (round-29) — hypothetical-set distribution functions.
6900 "percent_rank" | "cume_dist" => DataType::Float,
6901 // v7.32 (round-29) — JSON aggregates return JSON.
6902 "json_agg" | "jsonb_agg" | "json_object_agg" | "jsonb_object_agg" | "json_arrayagg"
6903 | "json_objectagg" => DataType::Json,
6904 // min/max, percentile_disc, mode, and anything pass-through:
6905 // the argument's shape (for ordered-set aggs `spec.arg` is the
6906 // WITHIN GROUP value expression).
6907 _ => arg_ty.unwrap_or(DataType::Text),
6908 }
6909}
6910
6911fn agg_or_group_type(e: &Expr, synth: &[ColumnSchema]) -> DataType {
6912 if let Expr::Column(c) = e
6913 && let Some(s) = synth.iter().find(|s| s.name == c.name)
6914 {
6915 return s.ty;
6916 }
6917 // v7.26 (round-20 C) — compound expressions over aggregates
6918 // (COALESCE(BOOL_OR(…), false), (array_agg(…))[1], CASE …)
6919 // derive their shape statically against the synth schema; the
6920 // old Text fallback broke sqlx typed decodes of exactly these
6921 // columns.
6922 crate::describe::describe_expr(e, synth)
6923 .map(|shape| shape.ty)
6924 .unwrap_or(DataType::Text)
6925}
6926
6927/// v7.39 (round 620) — PG's strict GROUP BY rule, and the diagnosis it earns.
6928///
6929/// `SELECT id, count(*) FROM dc` answered `column "id" does not exist`. The
6930/// column plainly exists; what it is not is grouped. The message came out that
6931/// way because there was no rule at all — the grouped row carries only the
6932/// grouping keys and the aggregates, so the reference simply failed to resolve
6933/// at evaluation time, and the resolver said the only thing it knew. A user
6934/// reading it goes looking for a typo or a missing table.
6935///
6936/// Returns the first bare column reference that is a real input column, is not
6937/// covered by a grouping expression, and is not inside an aggregate. Variants
6938/// this walker does not descend into are left alone, so an uncovered nesting
6939/// keeps the old behaviour rather than inventing an error: under-reporting is
6940/// the status quo, over-reporting would break queries that run today.
6941fn first_ungrouped_column<'a>(
6942 e: &'a Expr,
6943 group_exprs: &[Expr],
6944 columns: &[ColumnSchema],
6945 licensed: &[alloc::string::String],
6946) -> Option<&'a spg_sql::ast::ColumnName> {
6947 if group_exprs.iter().any(|g| g == e) {
6948 return None;
6949 }
6950 let rec = |x: &'a Expr| first_ungrouped_column(x, group_exprs, columns, licensed);
6951 match e {
6952 Expr::Column(c) => {
6953 (column_ref_is_input(c, columns) && !column_is_key_determined(c, licensed)).then_some(c)
6954 }
6955 // An aggregate's arguments are exactly what does not need grouping.
6956 Expr::FunctionCall { name, .. } if is_aggregate_name(&name.to_ascii_lowercase()) => None,
6957 Expr::AggregateOrdered { .. } => None,
6958 // A subquery carries its own scope and its own rules.
6959 Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => None,
6960 Expr::FunctionCall { args, .. } => args.iter().find_map(rec),
6961 Expr::Binary { lhs, rhs, .. } => rec(lhs).or_else(|| rec(rhs)),
6962 Expr::Unary { expr, .. }
6963 | Expr::Cast { expr, .. }
6964 | Expr::IsNull { expr, .. }
6965 | Expr::BoolTest { expr, .. } => rec(expr),
6966 Expr::Like { expr, pattern, .. } => rec(expr).or_else(|| rec(pattern)),
6967 Expr::InList { expr, list, .. } => rec(expr).or_else(|| list.iter().find_map(rec)),
6968 Expr::Case {
6969 operand,
6970 branches,
6971 else_branch,
6972 } => operand
6973 .as_deref()
6974 .and_then(rec)
6975 .or_else(|| branches.iter().find_map(|(w, t)| rec(w).or_else(|| rec(t))))
6976 .or_else(|| else_branch.as_deref().and_then(rec)),
6977 _ => None,
6978 }
6979}
6980
6981/// v7.39 (round 620) — does this column reference name an INPUT column?
6982///
6983/// A joined schema names its columns `a.s`; a single-table one names them `s`
6984/// and answers to the active alias. Matching only the bare name — which the
6985/// first cut of round 620 did — makes every qualified reference in a join
6986/// invisible to both the check and the rewrite below, which is how they
6987/// reached evaluation and came back `missing FROM-clause entry for table "a"`.
6988fn column_ref_is_input(c: &spg_sql::ast::ColumnName, columns: &[ColumnSchema]) -> bool {
6989 if let Some(q) = &c.qualifier {
6990 let composite = alloc::format!("{q}.{}", c.name);
6991 if columns
6992 .iter()
6993 .any(|col| col.name.eq_ignore_ascii_case(&composite))
6994 {
6995 return true;
6996 }
6997 }
6998 columns
6999 .iter()
7000 .any(|col| col.name.eq_ignore_ascii_case(&c.name))
7001}
7002
7003/// v7.39 (round 620) — the qualifiers whose PRIMARY KEY is wholly present in
7004/// the GROUP BY list, which licenses every OTHER column of those tables.
7005///
7006/// `SELECT s, count(*) FROM dc GROUP BY id` where `id` is the primary key is
7007/// answered by PG and was REFUSED here — a query that runs on PG and fails on
7008/// SPG, which is worse than any wording. One row per `id` means `s` has
7009/// exactly one value in the group, so there is nothing ambiguous to resolve;
7010/// the rule is the SQL standard's functional dependency, and PG applies it for
7011/// a base table's primary key.
7012///
7013/// Every FROM entry is considered separately, so a join licenses the side
7014/// whose key is grouped and not the other: `SELECT a.s, b.t … JOIN … GROUP BY
7015/// a.id` answers `a.s` and still refuses `b.t`, which is what PG does.
7016///
7017/// The empty string stands for the unqualified single-table case.
7018fn qualifiers_grouped_by_primary_key(
7019 stmt: &SelectStatement,
7020 group_exprs: &[Expr],
7021 columns: &[ColumnSchema],
7022 catalog: Option<&spg_storage::Catalog>,
7023) -> Vec<alloc::string::String> {
7024 let (Some(from), Some(cat)) = (stmt.from.as_ref(), catalog) else {
7025 return Vec::new();
7026 };
7027 let mut out = Vec::new();
7028 let refs = core::iter::once(&from.primary).chain(from.joins.iter().map(|j| &j.table));
7029 let single = from.joins.is_empty();
7030 for tr in refs {
7031 if tr.unnest_expr.is_some() {
7032 continue;
7033 }
7034 let Some(table) = cat.get(&tr.name) else {
7035 continue;
7036 };
7037 let schema = table.schema();
7038 let Some(pk) = schema
7039 .uniqueness_constraints
7040 .iter()
7041 .find(|u| u.is_primary_key && !u.columns.is_empty())
7042 else {
7043 continue;
7044 };
7045 let qual = tr.alias.as_deref().unwrap_or(tr.name.as_str());
7046 let all_keys_grouped = pk.columns.iter().all(|&pos| {
7047 let Some(name) = schema.columns.get(pos).map(|c| &c.name) else {
7048 return false;
7049 };
7050 // The key column has to be grouped by AS ITSELF, and as this
7051 // table's: an unqualified spelling only counts when there is one
7052 // table for it to mean.
7053 group_exprs.iter().any(|g| match g {
7054 Expr::Column(c) if c.name.eq_ignore_ascii_case(name) => {
7055 let belongs = match &c.qualifier {
7056 Some(q) => q.eq_ignore_ascii_case(qual),
7057 None => single,
7058 };
7059 belongs && column_ref_is_input(c, columns)
7060 }
7061 _ => false,
7062 })
7063 });
7064 if all_keys_grouped {
7065 out.push(alloc::string::String::from(qual));
7066 if single {
7067 out.push(alloc::string::String::new());
7068 }
7069 }
7070 }
7071 out
7072}
7073
7074/// True when this column reference is licensed by one of those keys.
7075fn column_is_key_determined(
7076 c: &spg_sql::ast::ColumnName,
7077 licensed: &[alloc::string::String],
7078) -> bool {
7079 let q = c.qualifier.as_deref().unwrap_or("");
7080 licensed.iter().any(|l| l.eq_ignore_ascii_case(q))
7081}
7082
7083/// v7.39 (round 405) — MySQL's loose GROUP BY: a non-aggregated column
7084/// that is not in GROUP BY is allowed and reads any (the first-seen) row's
7085/// value in the group. PG (and SPG until now) rejects it. Wrapping such a
7086/// bare column in `any_value(col)` reuses the existing aggregate machinery.
7087/// A whole grouping expression stays as-is; an aggregate call is not
7088/// descended into (its inner columns are already fine); a non-aggregate
7089/// function's argument columns are wrapped individually
7090/// (`UPPER(name)` → `UPPER(any_value(name))`).
7091fn wrap_loose_group_columns(
7092 e: Expr,
7093 group_exprs: &[Expr],
7094 columns: &[ColumnSchema],
7095 // v7.39 (round 620) — `None` wraps every ungrouped column, which is what
7096 // MySQL's loose GROUP BY means. `Some(quals)` wraps only the columns a
7097 // grouped primary key determines, so a join licenses the side whose key is
7098 // grouped and leaves the other to be refused.
7099 licensed: Option<&[alloc::string::String]>,
7100) -> Expr {
7101 if group_exprs.iter().any(|g| *g == e) {
7102 return e;
7103 }
7104 let wrap = |x: Expr| wrap_loose_group_columns(x, group_exprs, columns, licensed);
7105 match e {
7106 Expr::Column(c) => {
7107 let claimed = column_ref_is_input(&c, columns)
7108 && licensed.is_none_or(|l| column_is_key_determined(&c, l));
7109 if claimed {
7110 Expr::FunctionCall {
7111 name: String::from("any_value"),
7112 args: alloc::vec![Expr::Column(c)],
7113 }
7114 } else {
7115 Expr::Column(c)
7116 }
7117 }
7118 Expr::FunctionCall { name, args } if is_aggregate_name(&name.to_ascii_lowercase()) => {
7119 Expr::FunctionCall { name, args }
7120 }
7121 Expr::AggregateOrdered { .. } => e,
7122 Expr::FunctionCall { name, args } => Expr::FunctionCall {
7123 name,
7124 args: args.into_iter().map(wrap).collect(),
7125 },
7126 Expr::Binary { op, lhs, rhs } => Expr::Binary {
7127 op,
7128 lhs: Box::new(wrap(*lhs)),
7129 rhs: Box::new(wrap(*rhs)),
7130 },
7131 Expr::Unary { op, expr } => Expr::Unary {
7132 op,
7133 expr: Box::new(wrap(*expr)),
7134 },
7135 Expr::Cast { expr, target } => Expr::Cast {
7136 expr: Box::new(wrap(*expr)),
7137 target,
7138 },
7139 Expr::IsNull { expr, negated } => Expr::IsNull {
7140 expr: Box::new(wrap(*expr)),
7141 negated,
7142 },
7143 Expr::BoolTest {
7144 expr,
7145 value,
7146 negated,
7147 } => Expr::BoolTest {
7148 expr: Box::new(wrap(*expr)),
7149 value,
7150 negated,
7151 },
7152 Expr::Like {
7153 expr,
7154 pattern,
7155 negated,
7156 case_insensitive,
7157 } => Expr::Like {
7158 expr: Box::new(wrap(*expr)),
7159 pattern: Box::new(wrap(*pattern)),
7160 negated,
7161 case_insensitive,
7162 },
7163 Expr::InList {
7164 expr,
7165 list,
7166 negated,
7167 } => Expr::InList {
7168 expr: Box::new(wrap(*expr)),
7169 list: list.into_iter().map(wrap).collect(),
7170 negated,
7171 },
7172 Expr::Case {
7173 operand,
7174 branches,
7175 else_branch,
7176 } => Expr::Case {
7177 operand: operand.map(|o| Box::new(wrap(*o))),
7178 branches: branches
7179 .into_iter()
7180 .map(|(w, t)| (wrap(w), wrap(t)))
7181 .collect(),
7182 else_branch: else_branch.map(|b| Box::new(wrap(*b))),
7183 },
7184 other => other,
7185 }
7186}
7187
7188/// v7.39 (round 404) — MySQL lets HAVING (and ORDER BY) reference a
7189/// SELECT-list alias (`SELECT g, SUM(v) AS sv … HAVING sv > 30`); PG does
7190/// not. Before the aggregate rewrite, replace a bare `Column(alias)` with
7191/// the SELECT expression it names, so the aggregate rewrite then maps it to
7192/// its synthetic column. A nesting this walker does not cover simply leaves
7193/// the column unresolved (the pre-existing "column does not exist" error),
7194/// never a wrong result.
7195fn substitute_having_aliases(e: Expr, aliases: &[(String, Expr)]) -> Expr {
7196 use spg_sql::ast::ColumnName;
7197 let sub = |x: Expr| substitute_having_aliases(x, aliases);
7198 match e {
7199 Expr::Column(ColumnName {
7200 qualifier: None,
7201 name,
7202 }) => aliases
7203 .iter()
7204 .find(|(a, _)| a.eq_ignore_ascii_case(&name))
7205 .map_or_else(
7206 || {
7207 Expr::Column(ColumnName {
7208 qualifier: None,
7209 name,
7210 })
7211 },
7212 |(_, expr)| expr.clone(),
7213 ),
7214 Expr::Binary { op, lhs, rhs } => Expr::Binary {
7215 op,
7216 lhs: Box::new(sub(*lhs)),
7217 rhs: Box::new(sub(*rhs)),
7218 },
7219 Expr::Unary { op, expr } => Expr::Unary {
7220 op,
7221 expr: Box::new(sub(*expr)),
7222 },
7223 Expr::FunctionCall { name, args } => Expr::FunctionCall {
7224 name,
7225 args: args.into_iter().map(sub).collect(),
7226 },
7227 Expr::IsNull { expr, negated } => Expr::IsNull {
7228 expr: Box::new(sub(*expr)),
7229 negated,
7230 },
7231 Expr::BoolTest {
7232 expr,
7233 value,
7234 negated,
7235 } => Expr::BoolTest {
7236 expr: Box::new(sub(*expr)),
7237 value,
7238 negated,
7239 },
7240 Expr::Like {
7241 expr,
7242 pattern,
7243 negated,
7244 case_insensitive,
7245 } => Expr::Like {
7246 expr: Box::new(sub(*expr)),
7247 pattern: Box::new(sub(*pattern)),
7248 negated,
7249 case_insensitive,
7250 },
7251 Expr::InList {
7252 expr,
7253 list,
7254 negated,
7255 } => Expr::InList {
7256 expr: Box::new(sub(*expr)),
7257 list: list.into_iter().map(sub).collect(),
7258 negated,
7259 },
7260 Expr::Case {
7261 operand,
7262 branches,
7263 else_branch,
7264 } => Expr::Case {
7265 operand: operand.map(|o| Box::new(sub(*o))),
7266 branches: branches
7267 .into_iter()
7268 .map(|(w, t)| (sub(w), sub(t)))
7269 .collect(),
7270 else_branch: else_branch.map(|b| Box::new(sub(*b))),
7271 },
7272 Expr::Cast { expr, target } => Expr::Cast {
7273 expr: Box::new(sub(*expr)),
7274 target,
7275 },
7276 other => other,
7277 }
7278}
7279
7280fn rewrite_expr(e: &Expr, group_exprs: &[Expr], aggs: &[AggSpec]) -> Expr {
7281 // v7.33 (array_agg argmax) — `(array_agg(x ORDER BY y))[1]` rewrites
7282 // to its first_ordered synth column, consuming the subscript. Checked
7283 // before the AggregateOrdered/recursion arms (which would otherwise
7284 // rewrite the inner array_agg and leave the subscript). Same matcher
7285 // as collect_aggregates, so the spec it finds is the one collected.
7286 if let Some((arg, order_by, filter)) = first_ordered_array_agg(e) {
7287 let arg_owned = Some(arg.clone());
7288 let filter_owned = filter.cloned();
7289 for (i, spec) in aggs.iter().enumerate() {
7290 if spec.first_ordered
7291 && spec.name == "array_agg"
7292 && spec.arg == arg_owned
7293 && spec.order_by == *order_by
7294 && spec.filter == filter_owned
7295 {
7296 return Expr::Column(spg_sql::ast::ColumnName {
7297 qualifier: None,
7298 name: format!("__agg_{i}"),
7299 });
7300 }
7301 }
7302 }
7303 // v7.24 (round-16 A) — ordered aggregate: match on the inner
7304 // call PLUS the ordering keys.
7305 if let Expr::AggregateOrdered {
7306 call,
7307 order_by,
7308 distinct,
7309 filter,
7310 } = e
7311 && let Expr::FunctionCall { name, args } = call.as_ref()
7312 {
7313 let lower = name.to_ascii_lowercase();
7314 if is_aggregate_name(&lower) {
7315 let canonical: &str = if lower == "every" { "bool_and" } else { &lower };
7316 // Mirror collect_aggregates: ordered-set aggregates take the
7317 // value from the sort spec and the in-parens arg as direct.
7318 let (arg, direct_arg) = if is_within_group_name(canonical) {
7319 (
7320 order_by.first().map(|o| o.expr.clone()),
7321 args.first().cloned(),
7322 )
7323 } else {
7324 (args.first().cloned(), None)
7325 };
7326 let arg2 = if agg_uses_second_arg(canonical) {
7327 args.get(1).cloned()
7328 } else {
7329 None
7330 };
7331 let filter_owned = filter.as_deref().cloned();
7332 for (i, spec) in aggs.iter().enumerate() {
7333 if spec.name == canonical
7334 && spec.arg == arg
7335 && spec.arg2 == arg2
7336 && spec.distinct == *distinct
7337 && spec.order_by == *order_by
7338 && spec.filter == filter_owned
7339 && spec.direct_arg == direct_arg
7340 {
7341 return Expr::Column(spg_sql::ast::ColumnName {
7342 qualifier: None,
7343 name: format!("__agg_{i}"),
7344 });
7345 }
7346 }
7347 }
7348 }
7349 // Match aggregate FunctionCalls first — they sit outside group_by.
7350 if let Expr::FunctionCall { name, args } = e {
7351 let lower = name.to_ascii_lowercase();
7352 if is_aggregate_name(&lower) {
7353 let arg = if lower == "count_star" {
7354 None
7355 } else {
7356 args.first().cloned()
7357 };
7358 // v7.17.0 — match the spec we registered for
7359 // string_agg(value, separator) on the full pair; v7.32 also
7360 // the regression family and json_object_agg.
7361 let arg2 = if agg_uses_second_arg(&lower) {
7362 args.get(1).cloned()
7363 } else {
7364 None
7365 };
7366 // v7.17.0 — `every` collapses into `bool_and` at
7367 // collection; mirror that here so the rewrite finds
7368 // the matching synth column.
7369 let canonical: &str = if lower == "every" {
7370 "bool_and"
7371 } else {
7372 lower.as_str()
7373 };
7374 for (i, spec) in aggs.iter().enumerate() {
7375 if spec.name == canonical
7376 && spec.arg == arg
7377 && spec.arg2 == arg2
7378 && !spec.distinct
7379 && spec.order_by.is_empty()
7380 {
7381 return Expr::Column(spg_sql::ast::ColumnName {
7382 qualifier: None,
7383 name: format!("__agg_{i}"),
7384 });
7385 }
7386 }
7387 }
7388 }
7389 // Match a group_by expression by AST equality.
7390 for (i, g) in group_exprs.iter().enumerate() {
7391 if g == e {
7392 return Expr::Column(spg_sql::ast::ColumnName {
7393 qualifier: None,
7394 name: format!("__grp_{i}"),
7395 });
7396 }
7397 }
7398 // Recurse into children.
7399 match e {
7400 // v7.39.2 — this arm REBUILDS the node, so the collation has to
7401 // be carried across rather than joined to the one beside it: the
7402 // compiler caught the join turning a `COLLATE` into a named
7403 // argument.
7404 Expr::Collate { expr, collation } => Expr::Collate {
7405 expr: alloc::boxed::Box::new(rewrite_expr(expr, group_exprs, aggs)),
7406 collation: collation.clone(),
7407 },
7408 Expr::NamedArg { name, expr } => Expr::NamedArg {
7409 name: name.clone(),
7410 expr: alloc::boxed::Box::new(rewrite_expr(expr, group_exprs, aggs)),
7411 },
7412 Expr::Variadic(expr) => Expr::Variadic(alloc::boxed::Box::new(rewrite_expr(
7413 expr,
7414 group_exprs,
7415 aggs,
7416 ))),
7417 Expr::AggregateOrdered {
7418 call,
7419 order_by,
7420 distinct,
7421 filter,
7422 } => Expr::AggregateOrdered {
7423 call: Box::new(rewrite_expr(call, group_exprs, aggs)),
7424 distinct: *distinct,
7425 order_by: order_by
7426 .iter()
7427 .map(|o| spg_sql::ast::OrderBy {
7428 expr: rewrite_expr(&o.expr, group_exprs, aggs),
7429 desc: o.desc,
7430 nulls_first: o.nulls_first,
7431 collation: o.collation.clone(),
7432 })
7433 .collect(),
7434 // The filter is evaluated against SOURCE rows during
7435 // accumulation, never against synth rows — keep it as-is.
7436 filter: filter.clone(),
7437 },
7438 Expr::Binary { lhs, op, rhs } => Expr::Binary {
7439 lhs: Box::new(rewrite_expr(lhs, group_exprs, aggs)),
7440 op: *op,
7441 rhs: Box::new(rewrite_expr(rhs, group_exprs, aggs)),
7442 },
7443 Expr::Unary { op, expr } => Expr::Unary {
7444 op: *op,
7445 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7446 },
7447 Expr::Cast { expr, target } => Expr::Cast {
7448 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7449 target: target.clone(),
7450 },
7451 Expr::FieldAccess { base, field } => Expr::FieldAccess {
7452 base: Box::new(rewrite_expr(base, group_exprs, aggs)),
7453 field: field.clone(),
7454 },
7455 Expr::IsNull { expr, negated } => Expr::IsNull {
7456 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7457 negated: *negated,
7458 },
7459 Expr::BoolTest {
7460 expr,
7461 value,
7462 negated,
7463 } => Expr::BoolTest {
7464 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7465 value: *value,
7466 negated: *negated,
7467 },
7468 Expr::FunctionCall { name, args } => Expr::FunctionCall {
7469 name: name.clone(),
7470 args: args
7471 .iter()
7472 .map(|a| rewrite_expr(a, group_exprs, aggs))
7473 .collect(),
7474 },
7475 Expr::Like {
7476 expr,
7477 pattern,
7478 negated,
7479 case_insensitive,
7480 } => Expr::Like {
7481 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7482 pattern: Box::new(rewrite_expr(pattern, group_exprs, aggs)),
7483 negated: *negated,
7484 case_insensitive: *case_insensitive,
7485 },
7486 Expr::Extract { field, source } => Expr::Extract {
7487 field: field.clone(),
7488 source: Box::new(rewrite_expr(source, group_exprs, aggs)),
7489 },
7490 // v7.25.2 (round-19 A) — subquery nodes: rewrite group-key
7491 // references INSIDE the body to `__grp_N` so the correlated
7492 // resolver can substitute them against the synthesised group
7493 // row (aggs are NOT matched inside the body — a COUNT in the
7494 // subquery is the subquery's own aggregate).
7495 Expr::ScalarSubquery(s) => {
7496 Expr::ScalarSubquery(Box::new(rewrite_group_keys_in_select(s, group_exprs)))
7497 }
7498 Expr::Exists { subquery, negated } => Expr::Exists {
7499 subquery: Box::new(rewrite_group_keys_in_select(subquery, group_exprs)),
7500 negated: *negated,
7501 },
7502 Expr::InSubquery {
7503 expr,
7504 subquery,
7505 negated,
7506 } => Expr::InSubquery {
7507 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7508 subquery: Box::new(rewrite_group_keys_in_select(subquery, group_exprs)),
7509 negated: *negated,
7510 },
7511 Expr::RowInSubquery {
7512 row,
7513 subquery,
7514 negated,
7515 } => Expr::RowInSubquery {
7516 row: row
7517 .iter()
7518 .map(|el| rewrite_expr(el, group_exprs, aggs))
7519 .collect(),
7520 subquery: Box::new(rewrite_group_keys_in_select(subquery, group_exprs)),
7521 negated: *negated,
7522 },
7523 Expr::RowCmpSubquery { row, op, subquery } => Expr::RowCmpSubquery {
7524 row: row
7525 .iter()
7526 .map(|el| rewrite_expr(el, group_exprs, aggs))
7527 .collect(),
7528 op: *op,
7529 subquery: Box::new(rewrite_group_keys_in_select(subquery, group_exprs)),
7530 },
7531 // v4.12 window / Literal / Column — clone-pass (these don't
7532 // participate in aggregate rewrite).
7533 Expr::WindowFunction { .. } | Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {
7534 e.clone()
7535 }
7536 // v7.10.10 — recurse children for array nodes.
7537 Expr::Array(items) => Expr::Array(
7538 items
7539 .iter()
7540 .map(|elem| rewrite_expr(elem, group_exprs, aggs))
7541 .collect(),
7542 ),
7543 Expr::ArraySubscript { target, index } => Expr::ArraySubscript {
7544 target: Box::new(rewrite_expr(target, group_exprs, aggs)),
7545 index: Box::new(rewrite_expr(index, group_exprs, aggs)),
7546 },
7547 Expr::ArraySlice { target, lo, hi } => Expr::ArraySlice {
7548 target: Box::new(rewrite_expr(target, group_exprs, aggs)),
7549 lo: lo
7550 .as_ref()
7551 .map(|b| Box::new(rewrite_expr(b, group_exprs, aggs))),
7552 hi: hi
7553 .as_ref()
7554 .map(|b| Box::new(rewrite_expr(b, group_exprs, aggs))),
7555 },
7556 Expr::AnyAll {
7557 expr,
7558 op,
7559 array,
7560 is_any,
7561 } => Expr::AnyAll {
7562 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7563 op: *op,
7564 array: Box::new(rewrite_expr(array, group_exprs, aggs)),
7565 is_any: *is_any,
7566 },
7567 Expr::InList {
7568 expr,
7569 list,
7570 negated,
7571 } => Expr::InList {
7572 expr: Box::new(rewrite_expr(expr, group_exprs, aggs)),
7573 list: list
7574 .iter()
7575 .map(|item| rewrite_expr(item, group_exprs, aggs))
7576 .collect(),
7577 negated: *negated,
7578 },
7579 Expr::Case {
7580 operand,
7581 branches,
7582 else_branch,
7583 } => Expr::Case {
7584 operand: operand
7585 .as_deref()
7586 .map(|o| Box::new(rewrite_expr(o, group_exprs, aggs))),
7587 branches: branches
7588 .iter()
7589 .map(|(w, t)| {
7590 (
7591 rewrite_expr(w, group_exprs, aggs),
7592 rewrite_expr(t, group_exprs, aggs),
7593 )
7594 })
7595 .collect(),
7596 else_branch: else_branch
7597 .as_deref()
7598 .map(|e| Box::new(rewrite_expr(e, group_exprs, aggs))),
7599 },
7600 }
7601}
7602
7603/// v7.25.2 (round-19 A) — rewrite group-key references inside a
7604/// subquery body to `__grp_N` synthetic columns (aggregates are
7605/// not touched: empty spec list). Runs through the canonical
7606/// Select walker so every expression slot is covered.
7607fn rewrite_group_keys_in_select(
7608 s: &spg_sql::ast::SelectStatement,
7609 group_exprs: &[Expr],
7610) -> spg_sql::ast::SelectStatement {
7611 let mut out = s.clone();
7612 let _ = crate::walk_select_exprs_mut(&mut out, &mut |e| {
7613 *e = rewrite_expr(e, group_exprs, &[]);
7614 Ok(())
7615 });
7616 out
7617}
7618
7619/// Canonical string key for a tuple of group values. Used as map key.
7620/// Per-value group-key encoding (shared by owned and borrowed paths).
7621fn encode_one(out: &mut String, v: &Value) {
7622 encode_one_in(out, v, false);
7623}
7624
7625/// v7.39 (round 364, M4 P2) — key encoder with the session dialect. On a
7626/// MySQL session a text group / distinct key is FOLDED (accent- and
7627/// case-insensitive) so `Foo`/`foo`/`FOO` share one group and `bar`/`Bär`
7628/// merge — while the group's OUTPUT value stays the first row's original,
7629/// because only the key is folded, not the stored value.
7630fn encode_one_in(out: &mut String, v: &Value, mysql: bool) {
7631 use core::fmt::Write;
7632 if mysql {
7633 if let Value::Text(s) | Value::Json(s) = v {
7634 let _ = write!(out, "S{}|", spg_storage::mysql_compare_fold(s));
7635 return;
7636 }
7637 if let Value::BpChar(s) = v {
7638 let folded = spg_storage::mysql_compare_fold_char(s);
7639 let _ = write!(out, "S{folded}|");
7640 return;
7641 }
7642 }
7643 encode_one_raw(out, v);
7644}
7645
7646fn encode_one_raw(out: &mut String, v: &Value) {
7647 use core::fmt::Write;
7648 match v {
7649 Value::Null => out.push_str("N|"),
7650 // v7.36 (perf — mailrs Phase 1) — switch the integer / float
7651 // encoders to `write!`. `n.to_string()` allocates a fresh
7652 // `String` per cell just to push its bytes into the
7653 // (already-cleared) reuse buffer — for the 25 k-row JOIN
7654 // probe in `count_messages` that's 25 k heap allocs per
7655 // query. `write!(&mut String, ...)` formats straight into
7656 // the buffer; no intermediate alloc.
7657 Value::SmallInt(n) => {
7658 let _ = write!(out, "s{n}|");
7659 }
7660 Value::Int(n) => {
7661 let _ = write!(out, "I{n}|");
7662 }
7663 Value::BigInt(n) => {
7664 let _ = write!(out, "B{n}|");
7665 }
7666 Value::Float(x) => {
7667 // v7.37.16 — fold -0.0 into 0.0: PG's float8 equality (hash and
7668 // btree opclasses) treats them as one value, so GROUP BY /
7669 // DISTINCT must key them together (count(DISTINCT) differential).
7670 // NaN needs no fold — every NaN renders "NaN" here already.
7671 let x = if *x == 0.0 { 0.0 } else { *x };
7672 let _ = write!(out, "F{x}|");
7673 }
7674 Value::Real(x) => {
7675 let x = if *x == 0.0 { 0.0 } else { *x };
7676 let _ = write!(out, "R{x}|");
7677 }
7678 Value::Bool(b) => {
7679 out.push(if *b { 'T' } else { 'f' });
7680 out.push('|');
7681 }
7682 Value::Text(s) => {
7683 out.push('S');
7684 out.push_str(s);
7685 out.push('|');
7686 }
7687 // v7.38 (read01, T11/R3) — bpchar groups / dedups blank-insensitively,
7688 // and shares the text key so `'ab'::char(4)` and `'ab'` co-group.
7689 Value::BpChar(s) => {
7690 out.push('S');
7691 out.push_str(s.trim_end_matches(' '));
7692 out.push('|');
7693 }
7694 Value::Vector(v) => {
7695 out.push('V');
7696 for x in v.iter() {
7697 out.push_str(&x.to_string());
7698 out.push(',');
7699 }
7700 out.push('|');
7701 }
7702 // v6.0.1: GROUP BY on a `VECTOR(N) USING SQ8` column.
7703 // Two cells with byte-identical `(min, max, bytes)`
7704 // share the same group; equivalence is byte-equality
7705 // (same as f32 grouping today — neither path tries to
7706 // normalise nan/-0).
7707 Value::Sq8Vector(q) => {
7708 out.push('Q');
7709 out.push_str(&q.min.to_string());
7710 out.push('@');
7711 out.push_str(&q.max.to_string());
7712 out.push(':');
7713 for b in &q.bytes {
7714 out.push_str(&b.to_string());
7715 out.push(',');
7716 }
7717 out.push('|');
7718 }
7719 // v6.0.3: GROUP BY on a `VECTOR(N) USING HALF` column.
7720 // Byte-equality over the raw u16 bits; matches the SQ8
7721 // path's byte-key model.
7722 Value::HalfVector(h) => {
7723 out.push('H');
7724 for b in &h.bytes {
7725 out.push_str(&b.to_string());
7726 out.push(',');
7727 }
7728 out.push('|');
7729 }
7730 Value::Numeric { scaled, scale, .. } => {
7731 // v7.38 (read01) — DISTINCT keys numerically-equal decimals as one
7732 // regardless of scale (1.0 = 1.00), so strip trailing fractional
7733 // zeros before encoding, matching PG (and set-op / GROUP BY dedup).
7734 let (mut s, mut sc) = (*scaled, *scale);
7735 while sc > 0 && s % 10 == 0 {
7736 s /= 10;
7737 sc -= 1;
7738 }
7739 out.push('D');
7740 out.push_str(&s.to_string());
7741 out.push('@');
7742 out.push_str(&sc.to_string());
7743 out.push('|');
7744 }
7745 Value::Date(d) => {
7746 out.push('d');
7747 out.push_str(&d.to_string());
7748 out.push('|');
7749 }
7750 Value::Timestamp(t) => {
7751 out.push('t');
7752 out.push_str(&t.to_string());
7753 out.push('|');
7754 }
7755 Value::Interval {
7756 months,
7757 days,
7758 micros,
7759 kind,
7760 } => {
7761 out.push('i');
7762 out.push_str(&months.to_string());
7763 out.push('m');
7764 out.push_str(&days.to_string());
7765 out.push('d');
7766 out.push_str(µs.to_string());
7767 out.push('|');
7768 }
7769 Value::Json(s) => {
7770 out.push('j');
7771 out.push_str(s);
7772 out.push('|');
7773 }
7774 // v7.5.0 — Value is #[non_exhaustive] for downstream
7775 // forward-compat. Any future variant lacking explicit
7776 // handling here will share a debug-derived group key,
7777 // which is observably wrong but won't crash.
7778 _ => {
7779 out.push('?');
7780 out.push_str(&format!("{v:?}"));
7781 out.push('|');
7782 }
7783 }
7784}
7785
7786/// v7.30 (perf campaign) - encode from borrowed cells without
7787/// materialising an owned Vec<Value<'static>> first.
7788pub(crate) fn encode_key_refs(vals: &[&Value]) -> String {
7789 let mut out = String::new();
7790 for v in vals {
7791 encode_one(&mut out, v);
7792 }
7793 out
7794}
7795
7796/// v7.31 (perf 3e) — encode into a caller-owned scratch buffer.
7797/// The per-row key paths (group hash, DISTINCT set, join build/
7798/// probe) ran 24k+ String allocations per query through the
7799/// allocator just to LOOK UP a map; the scratch form allocates
7800/// only when a map actually has to take ownership (vacant insert).
7801/// v7.39 (round 590) — append ONE value's encoding, for the join key that
7802/// mixes stored cells with computed ones and so cannot clear as it goes.
7803/// v7.39 (round 590, moved here round 593+) — one component of a key with a COMPUTED side.
7804///
7805/// The whole requirement is that two values SQL calls equal encode the same,
7806/// or the join silently loses rows. Across the numeric family that is not
7807/// free: `5` as INT, `5` as BIGINT, `5.0` as double and `5.00` as NUMERIC all
7808/// compare equal and would otherwise carry four different tags, so they are
7809/// all rendered as one canonical decimal. A non-integral value can never
7810/// equal an integer, so it simply renders as itself; NaN equals nothing and
7811/// any encoding will do. Everything outside the numeric family keeps the
7812/// encoder the column-to-column path already uses.
7813pub(crate) fn push_canonical_key(out: &mut String, v: &Value) {
7814 use core::fmt::Write;
7815 match v {
7816 Value::SmallInt(n) => {
7817 let _ = write!(out, "n{n}|");
7818 }
7819 Value::Int(n) => {
7820 let _ = write!(out, "n{n}|");
7821 }
7822 Value::BigInt(n) => {
7823 let _ = write!(out, "n{n}|");
7824 }
7825 // `-0.0` prints with its sign but equals `0`.
7826 Value::Float(f) if *f == 0.0 => out.push_str("n0|"),
7827 Value::Float(f) => {
7828 let _ = write!(out, "n{f}|");
7829 }
7830 Value::Numeric { .. } => {
7831 let t = crate::eval::value_to_text(v);
7832 let t = if t.contains('.') {
7833 t.trim_end_matches('0').trim_end_matches('.')
7834 } else {
7835 t.as_str()
7836 };
7837 let _ = write!(out, "n{t}|");
7838 }
7839 _ => encode_one_into(out, v),
7840 }
7841}
7842
7843/// v7.39 (round 596) — a whole key encoded the canonical way, for the two
7844/// sides of a decorrelated EXISTS: the set is built from the inner column's
7845/// values and probed with the outer EXPRESSION's, and those need not share a
7846/// numeric width for `=` to call them equal.
7847pub(crate) fn encode_canonical_key(vals: &[Value<'_>]) -> String {
7848 let mut out = String::new();
7849 for v in vals {
7850 push_canonical_key(&mut out, v);
7851 }
7852 out
7853}
7854
7855pub(crate) fn encode_one_into(out: &mut String, v: &Value) {
7856 encode_one_raw(out, v);
7857}
7858
7859pub(crate) fn encode_key_refs_into(vals: &[&Value], out: &mut String) {
7860 encode_key_refs_into_in(vals, out, false);
7861}
7862
7863/// v7.38.14 — key encode with a per-POSITION fold decision.
7864///
7865/// `encode_key_refs_into_in` takes one bool for the whole key, which
7866/// cannot express the case a join actually presents: one key column
7867/// declared `COLLATE utf8mb4_bin` beside another that folds. `folds` is
7868/// resolved once per join from the key columns' collations; a short or
7869/// missing entry means "do not fold", which is what every existing
7870/// caller wants.
7871pub(crate) fn encode_key_refs_folded(vals: &[&Value], out: &mut String, folds: &[bool]) {
7872 out.clear();
7873 for (i, v) in vals.iter().enumerate() {
7874 encode_one_in(out, v, folds.get(i).copied().unwrap_or(false));
7875 }
7876}
7877
7878/// v7.39 (round 364, M4 P2) — key encode with the session dialect.
7879pub(crate) fn encode_key_refs_into_in(vals: &[&Value], out: &mut String, mysql: bool) {
7880 out.clear();
7881 for v in vals {
7882 encode_one_in(out, v, mysql);
7883 }
7884}
7885
7886pub(crate) fn encode_key(vals: &[Value<'static>]) -> String {
7887 let mut out = String::new();
7888 for v in vals {
7889 encode_one(&mut out, v);
7890 }
7891 out
7892}
7893
7894#[allow(clippy::cast_precision_loss)]
7895/// v7.37.17 (17.6 siblings) — intersect two ranges (same kind).
7896/// The greater lower bound wins (tie keeps inclusivity only when
7897/// both are inclusive); the smaller upper bound mirrors it; an
7898/// unbounded side loses to a bounded one. lower > upper — or a
7899/// touch that isn't inclusive on both ends — collapses to empty,
7900/// and any empty input pins the fold at empty.
7901fn range_intersect(a: &Value<'static>, b: &Value<'static>) -> Value<'static> {
7902 let (
7903 Value::Range {
7904 kind,
7905 lower: la,
7906 upper: ua,
7907 lower_inc: lia,
7908 upper_inc: uia,
7909 empty: ea,
7910 },
7911 Value::Range {
7912 lower: lb,
7913 upper: ub,
7914 lower_inc: lib_,
7915 upper_inc: uib,
7916 empty: eb,
7917 ..
7918 },
7919 ) = (a, b)
7920 else {
7921 return Value::Null;
7922 };
7923 let kind = *kind;
7924 let empty_range = Value::Range {
7925 kind,
7926 lower: None,
7927 upper: None,
7928 lower_inc: false,
7929 upper_inc: false,
7930 empty: true,
7931 };
7932 if *ea || *eb {
7933 return empty_range;
7934 }
7935 // Greater lower bound (None = -infinity loses to any bound).
7936 let (lower, lower_inc) = match (la, lb) {
7937 (None, None) => (None, false),
7938 (Some(x), None) => (Some(x.clone()), *lia),
7939 (None, Some(y)) => (Some(y.clone()), *lib_),
7940 (Some(x), Some(y)) => match value_cmp(x, y) {
7941 core::cmp::Ordering::Greater => (Some(x.clone()), *lia),
7942 core::cmp::Ordering::Less => (Some(y.clone()), *lib_),
7943 core::cmp::Ordering::Equal => (Some(x.clone()), *lia && *lib_),
7944 },
7945 };
7946 // Smaller upper bound (None = +infinity loses to any bound).
7947 let (upper, upper_inc) = match (ua, ub) {
7948 (None, None) => (None, false),
7949 (Some(x), None) => (Some(x.clone()), *uia),
7950 (None, Some(y)) => (Some(y.clone()), *uib),
7951 (Some(x), Some(y)) => match value_cmp(x, y) {
7952 core::cmp::Ordering::Less => (Some(x.clone()), *uia),
7953 core::cmp::Ordering::Greater => (Some(y.clone()), *uib),
7954 core::cmp::Ordering::Equal => (Some(x.clone()), *uia && *uib),
7955 },
7956 };
7957 if let (Some(lo), Some(up)) = (&lower, &upper) {
7958 match value_cmp(lo, up) {
7959 core::cmp::Ordering::Greater => return empty_range,
7960 core::cmp::Ordering::Equal if !(lower_inc && upper_inc) => {
7961 return empty_range;
7962 }
7963 _ => {}
7964 }
7965 }
7966 Value::Range {
7967 kind,
7968 lower,
7969 upper,
7970 lower_inc,
7971 upper_inc,
7972 empty: false,
7973 }
7974}
7975
7976/// v7.38 (read01, T6.P3) — fold a NUMERIC input's kind into a running sum's kind:
7977/// NaN wins; ±Inf + finite → that Inf; +Inf + -Inf → NaN; else unchanged.
7978fn fold_sum_kind(
7979 acc: spg_storage::NumericKind,
7980 incoming: spg_storage::NumericKind,
7981) -> spg_storage::NumericKind {
7982 use spg_storage::NumericKind as NK;
7983 match (acc, incoming) {
7984 (NK::NaN, _) | (_, NK::NaN) => NK::NaN,
7985 (NK::Finite, k) | (k, NK::Finite) => k,
7986 (a, b) if a == b => a,
7987 _ => NK::NaN,
7988 }
7989}
7990
7991/// v7.39 (enum order knife) — min/max extreme comparison: member order when
7992/// the spec's argument is enum-typed, the generic value order otherwise.
7993fn extreme_cmp(
7994 enum_labels: Option<&[String]>,
7995 a: &Value,
7996 b: &Value,
7997 mysql: bool,
7998) -> core::cmp::Ordering {
7999 extreme_cmp_in(enum_labels, None, a, b, mysql)
8000}
8001
8002/// v7.39 (round 690) — `extreme_cmp` with the argument column's collation.
8003///
8004/// `min`/`max` over a column declared `COLLATE "en_US.utf8"` answered
8005/// `Banana` and `Ápple` where PG18 gives `apple` and `Zebra`. The collation
8006/// rides beside `enum_labels`, which is already exactly this: per-aggregate
8007/// metadata about the argument, resolved once where the spec is built.
8008///
8009/// No derivation needed here — `min(loc)`'s argument is the column itself.
8010/// An expression argument gets None and keeps byte order, which is the same
8011/// limit `ORDER BY upper(loc)` has.
8012fn extreme_cmp_in(
8013 enum_labels: Option<&[String]>,
8014 collation: Option<&str>,
8015 a: &Value,
8016 b: &Value,
8017 mysql: bool,
8018) -> core::cmp::Ordering {
8019 if let Some(labels) = enum_labels
8020 && let Some(ord) = crate::eval::enum_ord_cmp(labels, a, b)
8021 {
8022 return ord;
8023 }
8024 if let (Value::Text(x), Value::Text(y), Some(c)) = (a, b, collation)
8025 && let Some(ord) = crate::collate::compare(c, x, y)
8026 {
8027 return ord;
8028 }
8029 // v7.39 (round 412) — MIN / MAX over text under the MySQL default
8030 // collation compares by the folded form (case- and accent-insensitive,
8031 // PAD SPACE), matching ORDER BY (round 411).
8032 if mysql {
8033 // v7.38.18 — each side on its own type; see `mysql_fold_value`.
8034 if let (Some(x), Some(y)) = (
8035 spg_storage::mysql_fold_value(a),
8036 spg_storage::mysql_fold_value(b),
8037 ) {
8038 return x.cmp(&y);
8039 }
8040 }
8041 value_cmp(a, b)
8042}
8043
8044/// Compare two values for `min` / `max`.
8045///
8046/// v7.39 (round 674) — the 228 lines that used to live here were a SECOND
8047/// comparison matrix, written independently of `orderby::value_cmp`. A
8048/// census of which `Value` variants each named found them diverged rather
8049/// than duplicated, and two silent wrongs fell out of the gap: `ORDER BY
8050/// time_col` did not sort (round 672) and `min`/`max` over `CHAR(n)`
8051/// returned the first row (round 672). Round 673 found four more on the
8052/// orderby side, where a canonical-text fallback had `ORDER BY money`
8053/// putting $100 before $9.
8054///
8055/// What stays here is the ONLY thing the two legitimately disagreed about:
8056/// where NULL sorts. This one puts NULLs last so `min`/`max` skip them;
8057/// `orderby::value_cmp` puts them first and the ORDER BY layer above it
8058/// applies NULLS FIRST / NULLS LAST. Both were correct in context, which is
8059/// why merging the matrices wholesale would have flipped one of them —
8060/// verified before collapsing, not after, and the eight NULL shapes are
8061/// pinned.
8062fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
8063 use core::cmp::Ordering;
8064 match (a, b) {
8065 (Value::Null, Value::Null) => Ordering::Equal,
8066 // NULLs last, so a NULL never wins a min() or a max().
8067 (Value::Null, _) => Ordering::Greater,
8068 (_, Value::Null) => Ordering::Less,
8069 _ => crate::orderby::value_cmp(a, b),
8070 }
8071}
8072
8073/// v7.37.9 Phase 0 diagnostic counters — see
8074/// `.claude/notes/v7.37.9-class-a-c-cascade-closure-plan.md`. These
8075/// are read-only telemetry, do not gate any code path. Used by
8076/// `xtests/dogfood_replay/src/bin/counter_dump.rs` to verify
8077/// whether the DISTA A-3 + array_agg-ordered fast paths actually
8078/// fire on the mailrs Class A SQL shape.
8079pub static DISTA_LITERAL_ARG2_CACHE_FIRE: core::sync::atomic::AtomicU64 =
8080 core::sync::atomic::AtomicU64::new(0);
8081pub static AGGREGATE_ARRAY_AGG_ORDER_BY_FIRE: core::sync::atomic::AtomicU64 =
8082 core::sync::atomic::AtomicU64::new(0);
8083
8084/// v7.37.9 Phase 1A-ext — per-row spec dispatch branches in
8085/// `accumulate_groups`'s hot loop. Verifies the Phase 1A
8086/// decomposition agent's S06 assumption ("14 specs × eval_expr per
8087/// row"). Sum should equal `n_specs × n_input_rows`. Branch
8088/// distribution tells which attack target ROI is highest:
8089/// FAST_POS many = baseline OK; COMPILED_MISS many = Step-VM is
8090/// hot path; EVAL_FALLBACK > 0 = uncompilable specs walking the
8091/// eval_expr tree per row × Cow row materialise.
8092pub static AGG_PER_ROW_FAST_POS: core::sync::atomic::AtomicU64 =
8093 core::sync::atomic::AtomicU64::new(0);
8094pub static AGG_PER_ROW_COMPILED_HIT: core::sync::atomic::AtomicU64 =
8095 core::sync::atomic::AtomicU64::new(0);
8096pub static AGG_PER_ROW_COMPILED_MISS: core::sync::atomic::AtomicU64 =
8097 core::sync::atomic::AtomicU64::new(0);
8098pub static AGG_PER_ROW_EVAL_FALLBACK: core::sync::atomic::AtomicU64 =
8099 core::sync::atomic::AtomicU64::new(0);
8100pub static AGG_PER_ROW_COUNT_STAR_SENTINEL: core::sync::atomic::AtomicU64 =
8101 core::sync::atomic::AtomicU64::new(0);
8102
8103#[cfg(test)]
8104mod value_cmp_mixed_numeric_tests {
8105 //! v7.37.16 Slice A — direct coverage of the mixed NUMERIC↔int/float
8106 //! arms in the aggregate-local `value_cmp` (drives min / max / argmin
8107 //! / argmax / mode / ordered-set aggregates). These pairs previously
8108 //! hit `_ => Equal`, which made `min`/`max` over a mixed NUMERIC/int
8109 //! key keep whichever row arrived first. Semantics now mirror
8110 //! binop.rs: int→NUMERIC exact promotion, NUMERIC→f64 demotion vs a
8111 //! float.
8112 use super::value_cmp;
8113 use core::cmp::Ordering;
8114 use spg_storage::Value;
8115
8116 fn num(scaled: i128, scale: u16) -> Value<'static> {
8117 Value::Numeric {
8118 scaled,
8119 scale,
8120 kind: spg_storage::NumericKind::Finite,
8121 }
8122 }
8123
8124 #[test]
8125 fn numeric_vs_integer_and_float() {
8126 assert_eq!(value_cmp(&num(250, 2), &Value::Int(5)), Ordering::Less);
8127 assert_eq!(value_cmp(&Value::Int(5), &num(250, 2)), Ordering::Greater);
8128 // debug-string/Equal fallback bug: 1000 vs 9 must be Greater.
8129 assert_eq!(
8130 value_cmp(&num(1000, 0), &Value::SmallInt(9)),
8131 Ordering::Greater
8132 );
8133 assert_eq!(value_cmp(&num(20, 1), &Value::BigInt(2)), Ordering::Equal);
8134 assert_eq!(value_cmp(&Value::BigInt(2), &num(20, 1)), Ordering::Equal);
8135 // NUMERIC↔float demotion.
8136 assert_eq!(value_cmp(&num(35, 1), &Value::Float(3.5)), Ordering::Equal);
8137 assert_eq!(
8138 value_cmp(&num(35, 1), &Value::Float(3.0)),
8139 Ordering::Greater
8140 );
8141 assert_eq!(value_cmp(&Value::Float(1.0), &num(25, 1)), Ordering::Less);
8142 }
8143
8144 /// v7.39 (round 231) — `is_aggregate_name` admits a name and
8145 /// `classify_agg_name` panics on anything it doesn't know, so the two
8146 /// lists drifting apart turns into a SQL-reachable abort. That is how
8147 /// `every(x) OVER (…)` crashed the query in round 230. Walk the whole
8148 /// admitted set and classify each one.
8149 #[test]
8150 fn every_aggregate_name_classifies() {
8151 const NAMES: &[&str] = &[
8152 "count",
8153 "count_star",
8154 "sum",
8155 "min",
8156 "max",
8157 "avg",
8158 "any_value",
8159 "range_agg",
8160 "range_intersect_agg",
8161 "string_agg",
8162 "group_concat",
8163 "xmlagg",
8164 "array_agg",
8165 "bool_and",
8166 "bool_or",
8167 "every",
8168 "stddev",
8169 "stddev_samp",
8170 "stddev_pop",
8171 "variance",
8172 "var_samp",
8173 "var_pop",
8174 "bit_and",
8175 "bit_or",
8176 "bit_xor",
8177 "json_agg",
8178 "jsonb_agg",
8179 "json_object_agg",
8180 "jsonb_object_agg",
8181 ];
8182 for n in NAMES {
8183 assert!(
8184 super::is_aggregate_name(n),
8185 "{n} should be an aggregate name"
8186 );
8187 // Panics if the classifier doesn't know it.
8188 let _ = super::classify_agg_name(super::canonical_agg_name(n));
8189 }
8190 // Anything `is_aggregate_name` admits must classify, so a name added
8191 // to one list and not the other fails here rather than at runtime.
8192 for n in NAMES {
8193 assert!(
8194 super::is_aggregate_name(&n.to_ascii_uppercase()),
8195 "{n} should be case-insensitive"
8196 );
8197 }
8198 }
8199}