This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 149a1dbb7d feat: support max_by and min_by aggregate expressions
(#4817)
149a1dbb7d is described below
commit 149a1dbb7dc53c89f901c1eb50793c6015b3a4bf
Author: Andy Grove <[email protected]>
AuthorDate: Thu Sep 10 21:49:05 2026 -0600
feat: support max_by and min_by aggregate expressions (#4817)
---
.../expression-audits/agg_funcs.md | 14 +
docs/source/user-guide/latest/expressions.md | 4 +-
native/core/src/execution/planner.rs | 20 +-
native/proto/src/proto/expr.proto | 16 +
native/spark-expr/src/agg_funcs/max_min_by.rs | 895 +++++++++++++++++++++
native/spark-expr/src/agg_funcs/mod.rs | 2 +
.../org/apache/comet/serde/QueryPlanSerde.scala | 2 +
.../scala/org/apache/comet/serde/aggregates.scala | 85 +-
.../sql-tests/expressions/aggregate/max_by.sql | 284 +++++++
.../sql-tests/expressions/aggregate/min_by.sql | 266 ++++++
.../CometAggregateExpressionBenchmark.scala | 24 +-
11 files changed, 1605 insertions(+), 7 deletions(-)
diff --git a/docs/source/contributor-guide/expression-audits/agg_funcs.md
b/docs/source/contributor-guide/expression-audits/agg_funcs.md
index 164ca2a72c..b2d2efae6b 100644
--- a/docs/source/contributor-guide/expression-audits/agg_funcs.md
+++ b/docs/source/contributor-guide/expression-audits/agg_funcs.md
@@ -70,6 +70,13 @@
- Comet implementation: the native side delegates to
`datafusion_spark::function::aggregate::collect::SparkCollectSet`, which wraps
`DistinctArrayAggAccumulator` with `ignore_nulls = true` in a
`NullToEmptyListAccumulator` so a final NULL accumulator state becomes an empty
array. The `containsNull` mismatch against Spark's declared output type, and
its rationale, are identical to [collect_list](#collect-list).
- `CometCollectSet` reports `Incompatible` for float and double input when
`spark.comet.exec.strictFloatingPoint=true`, because the native distinct
comparison treats `NaN == NaN` and collapses repeated `NaN`s into a single
element while Spark keeps each one. The native path for floating-point input is
then opt-in via `spark.comet.expression.CollectSet.allowIncompatible=true`. All
other input types are `Compatible`.
+## max_by
+
+- Spark 3.4.3 (2026-07-03): `MaxBy` is a 2-argument `DeclarativeAggregate`
registered as `expression[MaxBy]("max_by")`. Buffer is
`(valueWithExtremumOrdering, extremumOrdering)`; null orderings are ignored,
the value paired with the maximum ordering is returned (and may itself be
null), and an all-null-ordering group yields null. Comet implements a native
`max_by` aggregate. Only fixed-length value and ordering types are accelerated:
a variable-length or nested type (string, binary, stru [...]
+- Spark 3.5.8 (2026-07-03): aggregate logic identical to 3.4.3.
+- Spark 4.0.1 (2026-07-03): aggregate logic identical to 3.4.3; only the
`@ExpressionDescription` example and note text differ.
+- Spark 4.1.1 (2026-07-03): aggregate logic identical to 3.4.3. The 3-argument
top-k form `max_by(x, y, k)` arrived in Spark 4.2 (`MaxMinByK.scala`, absent on
`branch-4.1`), so it is absent from 3.4 through 4.1 and present on the 4.2
profile this repo builds. `MaxByBuilder.build` still returns a plain `MaxBy`
for the 2-argument call, and the 3-argument call becomes `MaxMinByK`, a
different class with no serde registration, so Comet handles only the
2-argument form and the top-k form falls back.
+
## median
- Spark 3.4.3 (audited 2026-06-24): `Median(child)` is a
`RuntimeReplaceableAggregate` with `replacement = Percentile(child,
Literal(0.5))`. Catalyst rewrites `median(x)` to `percentile(x, 0.5)` before
Comet sees the plan, so it is served by `CometPercentile`.
@@ -77,6 +84,13 @@
- Spark 4.0.1 (audited 2026-06-24): `replacement` becomes `lazy val`;
semantics unchanged.
- Spark 4.1.1 (audited 2026-06-24): identical to 4.0.1.
+## min_by
+
+- Spark 3.4.3 (2026-07-03): `MinBy` shares the abstract `MaxMinBy`
`DeclarativeAggregate` with `MaxBy`, differing only in the comparison direction
(`least` / `<` instead of `greatest` / `>`). Registered as
`expression[MinBy]("min_by")`. Null orderings are ignored, the value paired
with the minimum ordering is returned (and may itself be null), and an
all-null-ordering group yields null. Comet serves it through the same native
`MaxMinBy` aggregate as `max_by`, with the same fixed-length v [...]
+- Spark 3.5.8 (2026-07-03): aggregate logic identical to 3.4.3.
+- Spark 4.0.1 (2026-07-03): aggregate logic identical to 3.4.3; only the
`@ExpressionDescription` example and note text differ.
+- Spark 4.1.1 (2026-07-03): aggregate logic identical to 3.4.3. The 3-argument
top-k form `min_by(x, y, k)` arrived in Spark 4.2 alongside `max_by(x, y, k)`;
`MinByBuilder.build` returns a plain `MinBy` for the 2-argument call, so Comet
handles only the 2-argument form and the top-k form falls back.
+
## percentile
- Spark 3.4.3 (audited 2026-06-24): `Percentile(child, percentageExpression,
frequencyExpression, ..., reverse)` over `PercentileBase`. Exact percentile
using `index = p * (n - 1)` linear interpolation, NULL inputs skipped,
empty/all-null group returns NULL. `CometPercentile` maps the
single-literal-percentage, default-frequency, numeric-input, ascending form to
DataFusion's `percentile_cont` (same interpolation). Array-of-percentages, a
non-default frequency argument, descending order, [...]
diff --git a/docs/source/user-guide/latest/expressions.md
b/docs/source/user-guide/latest/expressions.md
index 65e73ec3bc..208a5f3f12 100644
--- a/docs/source/user-guide/latest/expressions.md
+++ b/docs/source/user-guide/latest/expressions.md
@@ -109,11 +109,11 @@ The tables below list every Spark built-in expression
with its current status.
| `last_value` | ✅ | Native | |
| `listagg` | 🔜 | — | String aggregation |
| `max` | ✅ | Native | |
-| `max_by` | 🔜 | — |
[#3841](https://github.com/apache/datafusion-comet/issues/3841) |
+| `max_by` | ✅ | Native | Value and ordering must be fixed-length types |
| `mean` | ✅ | Native | |
| `median` | ✅ | — | Rewrites to `percentile(col, 0.5)` and runs natively for
supported percentile inputs |
| `min` | ✅ | Native | |
-| `min_by` | 🔜 | — |
[#3841](https://github.com/apache/datafusion-comet/issues/3841) |
+| `min_by` | ✅ | Native | Value and ordering must be fixed-length types |
| `mode` | ✅ | Native | `mode(col)` only; Spark breaks ties
non-deterministically, so Comet returns the smallest tied value and falls back
by default, opt-in via allowIncompatible
([#3970](https://github.com/apache/datafusion-comet/issues/3970)) |
| `percentile` | ✅ | Native | Single literal percentage on numeric input runs
natively; array of percentages and a frequency argument fall back to Spark |
| `percentile_cont` | ✅ | — | Spark 4.0+ `WITHIN GROUP (ORDER BY ...)`;
ascending only runs natively, `DESC` falls back to Spark |
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 1cd84d314f..eeaee34478 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -149,9 +149,9 @@ use datafusion_comet_proto::{
use datafusion_comet_spark_expr::{
jvm_udf::JvmScalarUdfExpr, ApproxPercentile, ArrayInsert, Avg, AvgDecimal,
Cast, CheckOverflow,
Correlation, Covariance, CreateNamedStruct, DecimalRescaleCheckOverflow,
GetArrayStructFields,
- GetStructField, HllPlusPlus, IfExpr, ListExtract, Mode,
NormalizeNaNAndZero, Regr, RegrType,
- SparkCastOptions, Stddev, SumDecimal, ToJson, UnboundColumn, Variance,
WideDecimalBinaryExpr,
- WideDecimalOp,
+ GetStructField, HllPlusPlus, IfExpr, ListExtract, MaxMinBy, Mode,
NormalizeNaNAndZero, Regr,
+ RegrType, SparkCastOptions, Stddev, SumDecimal, ToJson, UnboundColumn,
Variance,
+ WideDecimalBinaryExpr, WideDecimalOp,
};
use itertools::Itertools;
use jni::objects::{Global, JObject};
@@ -3188,6 +3188,20 @@ impl PhysicalPlanner {
let func =
AggregateUDF::new_from_impl(HllPlusPlus::new(expr.precision));
Self::create_aggr_func_expr("approx_count_distinct", schema,
vec![child], func)
}
+ AggExprStruct::MaxBy(expr) => {
+ let value = self.create_expr(expr.value.as_ref().unwrap(),
Arc::clone(&schema))?;
+ let ordering =
+ self.create_expr(expr.ordering.as_ref().unwrap(),
Arc::clone(&schema))?;
+ let func = AggregateUDF::new_from_impl(MaxMinBy::new_max_by());
+ Self::create_aggr_func_expr("max_by", schema, vec![value,
ordering], func)
+ }
+ AggExprStruct::MinBy(expr) => {
+ let value = self.create_expr(expr.value.as_ref().unwrap(),
Arc::clone(&schema))?;
+ let ordering =
+ self.create_expr(expr.ordering.as_ref().unwrap(),
Arc::clone(&schema))?;
+ let func = AggregateUDF::new_from_impl(MaxMinBy::new_min_by());
+ Self::create_aggr_func_expr("min_by", schema, vec![value,
ordering], func)
+ }
AggExprStruct::Mode(expr) => {
let child = self.create_expr(expr.child.as_ref().unwrap(),
Arc::clone(&schema))?;
let datatype =
to_arrow_datatype(expr.datatype.as_ref().unwrap());
diff --git a/native/proto/src/proto/expr.proto
b/native/proto/src/proto/expr.proto
index 06fdf19925..34d502daac 100644
--- a/native/proto/src/proto/expr.proto
+++ b/native/proto/src/proto/expr.proto
@@ -164,6 +164,8 @@ message AggExpr {
CollectList collectList = 21;
Regr regr = 22;
Mode mode = 23;
+ MaxBy maxBy = 24;
+ MinBy minBy = 25;
}
// Optional filter expression for SQL FILTER (WHERE ...) clause.
@@ -353,6 +355,20 @@ message HllPlusPlus {
int32 precision = 2;
}
+message MaxBy {
+ // The value returned by the aggregate (associated with the maximum
ordering).
+ Expr value = 1;
+ // The ordering expression whose maximum selects the returned value.
+ Expr ordering = 2;
+}
+
+message MinBy {
+ // The value returned by the aggregate (associated with the minimum
ordering).
+ Expr value = 1;
+ // The ordering expression whose minimum selects the returned value.
+ Expr ordering = 2;
+}
+
message Mode {
Expr child = 1;
DataType datatype = 2;
diff --git a/native/spark-expr/src/agg_funcs/max_min_by.rs
b/native/spark-expr/src/agg_funcs/max_min_by.rs
new file mode 100644
index 0000000000..147f1eb92b
--- /dev/null
+++ b/native/spark-expr/src/agg_funcs/max_min_by.rs
@@ -0,0 +1,895 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use arrow::array::{new_null_array, Array, ArrayRef, AsArray, BooleanArray};
+use arrow::compute::SortOptions;
+use arrow::datatypes::{DataType, Field, FieldRef, Float32Type, Float64Type};
+use arrow::row::{OwnedRow, RowConverter, SortField};
+use datafusion::common::{not_impl_err, Result, ScalarValue};
+use datafusion::logical_expr::function::{AccumulatorArgs, StateFieldsArgs};
+use datafusion::logical_expr::{
+ Accumulator, AggregateUDFImpl, EmitTo, GroupsAccumulator, Signature,
Volatility,
+};
+use datafusion::physical_expr::expressions::format_state_name;
+use std::mem::size_of_val;
+use std::sync::Arc;
+
+/// Spark-compatible `max_by(value, ordering)` / `min_by(value, ordering)`
aggregate.
+///
+/// Returns the `value` associated with the maximum (`max_by`) or minimum
(`min_by`)
+/// non-null `ordering`. Rows with a null `ordering` are ignored. The returned
value
+/// may itself be null when it is the value paired with the extremum ordering.
If every
+/// `ordering` in the group is null, the result is null.
+///
+/// Spark's `MaxBy`/`MinBy` are `DeclarativeAggregate`s that keep a `(value,
ordering)`
+/// buffer and, on a tie in the ordering, the later row wins. Because ties
across
+/// partitions are processed in an unspecified order, Spark documents the
function as
+/// non-deterministic when several rows share the extremum ordering.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MaxMinBy {
+ name: String,
+ signature: Signature,
+ /// `true` for `max_by`, `false` for `min_by`.
+ is_max: bool,
+}
+
+impl std::hash::Hash for MaxMinBy {
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+ self.name.hash(state);
+ self.signature.hash(state);
+ self.is_max.hash(state);
+ }
+}
+
+impl MaxMinBy {
+ /// Create a `max_by` aggregate.
+ pub fn new_max_by() -> Self {
+ Self {
+ name: "max_by".to_string(),
+ signature: Signature::any(2, Volatility::Immutable),
+ is_max: true,
+ }
+ }
+
+ /// Create a `min_by` aggregate.
+ pub fn new_min_by() -> Self {
+ Self {
+ name: "min_by".to_string(),
+ signature: Signature::any(2, Volatility::Immutable),
+ is_max: false,
+ }
+ }
+}
+
+impl AggregateUDFImpl for MaxMinBy {
+ fn name(&self) -> &str {
+ &self.name
+ }
+
+ fn signature(&self) -> &Signature {
+ &self.signature
+ }
+
+ fn return_type(&self, arg_types: &[DataType]) -> Result<DataType> {
+ // The result has the same type as the `value` argument.
+ Ok(arg_types[0].clone())
+ }
+
+ fn accumulator(&self, acc_args: AccumulatorArgs) -> Result<Box<dyn
Accumulator>> {
+ let value_type = acc_args.exprs[0].data_type(acc_args.schema)?;
+ let ordering_type = acc_args.exprs[1].data_type(acc_args.schema)?;
+ Ok(Box::new(MaxMinByAccumulator::try_new(
+ value_type,
+ ordering_type,
+ self.is_max,
+ )?))
+ }
+
+ fn state_fields(&self, args: StateFieldsArgs) -> Result<Vec<FieldRef>> {
+ let value_type = args.input_fields[0].data_type().clone();
+ let ordering_type = args.input_fields[1].data_type().clone();
+ Ok(vec![
+ Arc::new(Field::new(
+ format_state_name(&self.name, "value"),
+ value_type,
+ true,
+ )),
+ Arc::new(Field::new(
+ format_state_name(&self.name, "ordering"),
+ ordering_type,
+ true,
+ )),
+ ])
+ }
+
+ fn groups_accumulator_supported(&self, _args: AccumulatorArgs) -> bool {
+ true
+ }
+
+ fn create_groups_accumulator(
+ &self,
+ args: AccumulatorArgs,
+ ) -> Result<Box<dyn GroupsAccumulator>> {
+ let value_type = args.exprs[0].data_type(args.schema)?;
+ let ordering_type = args.exprs[1].data_type(args.schema)?;
+ Ok(Box::new(MaxMinByGroupsAccumulator::try_new(
+ value_type,
+ ordering_type,
+ self.is_max,
+ )?))
+ }
+}
+
+/// Sort options that make the wanted extremum encode to the largest row
bytes: ascending for
+/// `max_by` (largest ordering wins), descending for `min_by` (smallest
ordering wins). Nulls
+/// sort first (smallest) so they are never selected as the extremum; null
orderings are also
+/// skipped explicitly.
+fn extremum_sort_options(is_max: bool) -> SortOptions {
+ SortOptions {
+ descending: !is_max,
+ nulls_first: true,
+ }
+}
+
+/// Canonicalize a floating-point ordering column so that Arrow's row-format
byte order reproduces
+/// Spark's comparison for this aggregate.
+///
+/// Spark compares the ordering with
`SQLOrderingUtil.compareDoubles`/`compareFloats`, wired in via
+/// `PhysicalDoubleType.ordering`/`PhysicalFloatType.ordering`. That is
+/// `if (x == y) 0 else java.lang.Double.compare(x, y)`, which has two
consequences Arrow's row
+/// format does not share:
+///
+/// * `-0.0` and `0.0` tie, because the `x == y` short-circuit is IEEE
equality. Arrow encodes
+/// floats by flipping the bits off the sign, a total order placing `-0.0`
strictly below `0.0`.
+/// * every `NaN` is one value and sorts above `+Infinity`, because
`Double.compare` goes through
+/// `doubleToLongBits`. Arrow uses the raw bits, so a sign-bit-set `NaN`
would sort below
+/// `-Infinity` instead.
+///
+/// Folding `-0.0` into `0.0` and every `NaN` into the canonical `NaN` makes
the row bytes agree
+/// with `compareDoubles` on both counts. This is verified identical on Spark
3.4 through master.
+///
+/// Note this is the opposite of what `mode` needs: `mode` keys a hash map via
+/// `OpenHashSet`'s `equals` (`java.lang.Double.equals`), which distinguishes
`-0.0` from `0.0`, so
+/// it must *not* fold them. Same two input values, different Spark comparison
path, opposite
+/// correct behaviour.
+fn canonicalize_float_ordering(array: &ArrayRef) -> ArrayRef {
+ match array.data_type() {
+ DataType::Float32 =>
Arc::new(array.as_primitive::<Float32Type>().unary::<_, Float32Type>(
+ |v| {
+ if v.is_nan() {
+ f32::NAN
+ } else if v == 0.0 {
+ // `-0.0 == 0.0` in IEEE 754, so this catches negative
zero only.
+ 0.0
+ } else {
+ v
+ }
+ },
+ )),
+ DataType::Float64 =>
Arc::new(array.as_primitive::<Float64Type>().unary::<_, Float64Type>(
+ |v| {
+ if v.is_nan() {
+ f64::NAN
+ } else if v == 0.0 {
+ 0.0
+ } else {
+ v
+ }
+ },
+ )),
+ _ => Arc::clone(array),
+ }
+}
+
+/// Accumulator that tracks the running `(value, ordering)` pair for the
extremum ordering.
+#[derive(Debug)]
+struct MaxMinByAccumulator {
+ /// Converts the ordering column into Arrow's byte-comparable row format.
Held as a field so a
+ /// batch update does not pay a `RowConverter` construction, matching the
grouped accumulator.
+ ordering_converter: RowConverter,
+ /// Ordering type, needed to produce a null ordering scalar in `state`
before any row is seen.
+ ordering_type: DataType,
+ /// The value paired with the current extremum ordering. May be null.
+ value: ScalarValue,
+ /// Row bytes of the current extremum ordering. `None` until a non-null
ordering is seen, so
+ /// comparing against the running extremum is a byte compare with no
allocation.
+ best_ordering: Option<OwnedRow>,
+}
+
+impl MaxMinByAccumulator {
+ fn try_new(value_type: DataType, ordering_type: DataType, is_max: bool) ->
Result<Self> {
+ let ordering_converter =
RowConverter::new(vec![SortField::new_with_options(
+ ordering_type.clone(),
+ extremum_sort_options(is_max),
+ )])?;
+ Ok(Self {
+ ordering_converter,
+ ordering_type,
+ value: ScalarValue::try_from(&value_type)?,
+ best_ordering: None,
+ })
+ }
+
+ /// Apply a batch of `(value, ordering)` columns, keeping the value paired
with the
+ /// extremum ordering. Rows with a null ordering are ignored.
+ fn update_from(&mut self, value_arr: &ArrayRef, ordering_arr: &ArrayRef)
-> Result<()> {
+ if ordering_arr.is_empty() {
+ return Ok(());
+ }
+
+ let ordering_arr = canonicalize_float_ordering(ordering_arr);
+ let rows = self
+ .ordering_converter
+ .convert_columns(&[Arc::clone(&ordering_arr)])?;
+
+ // Find the index of the extremum ordering in this batch, ignoring
null orderings. `>=`
+ // makes the later row win a tie, which is what Spark's update does:
it evaluates
+ // `If(predicate(extremumOrdering, orderingExpr),
valueWithExtremumOrdering, valueExpr)`
+ // where `predicate` is the strict `oldExpr > newExpr` for `max_by`
(`<` for `min_by`), so
+ // an equal ordering makes the predicate false and the *new* row's
value is kept
+ // (`MaxByAndMinBy.scala`). The same strictness is why the signed-zero
canonicalization
+ // above matters: without it Arrow sees a strict inequality where
Spark sees a tie.
+ let mut best: Option<usize> = None;
+ for i in 0..ordering_arr.len() {
+ if ordering_arr.is_null(i) {
+ continue;
+ }
+ best = match best {
+ None => Some(i),
+ Some(b) if rows.row(i) >= rows.row(b) => Some(i),
+ Some(b) => Some(b),
+ };
+ }
+
+ let Some(b) = best else {
+ return Ok(());
+ };
+
+ let candidate = rows.row(b);
+ let take = match &self.best_ordering {
+ None => true,
+ Some(running) => candidate >= running.row(),
+ };
+
+ if take {
+ self.value = ScalarValue::try_from_array(value_arr, b)?;
+ self.best_ordering = Some(candidate.owned());
+ }
+
+ Ok(())
+ }
+
+ /// The running extremum ordering as a scalar, for the aggregation state.
+ fn ordering_scalar(&self) -> Result<ScalarValue> {
+ match &self.best_ordering {
+ Some(row) => {
+ let arrays = self
+ .ordering_converter
+ .convert_rows(std::iter::once(row.row()))?;
+ ScalarValue::try_from_array(&arrays[0], 0)
+ }
+ None => ScalarValue::try_from(&self.ordering_type),
+ }
+ }
+}
+
+impl Accumulator for MaxMinByAccumulator {
+ fn update_batch(&mut self, values: &[ArrayRef]) -> Result<()> {
+ self.update_from(&values[0], &values[1])
+ }
+
+ fn merge_batch(&mut self, states: &[ArrayRef]) -> Result<()> {
+ // State columns mirror the input columns: [value, ordering].
+ self.update_from(&states[0], &states[1])
+ }
+
+ fn state(&mut self) -> Result<Vec<ScalarValue>> {
+ Ok(vec![self.value.clone(), self.ordering_scalar()?])
+ }
+
+ fn evaluate(&mut self) -> Result<ScalarValue> {
+ Ok(self.value.clone())
+ }
+
+ fn size(&self) -> usize {
+ size_of_val(self)
+ + self.value.size()
+ + self
+ .best_ordering
+ .as_ref()
+ .map_or(0, |r| r.row().as_ref().len())
+ }
+}
+
+/// Vectorized grouped accumulator for `max_by` / `min_by`.
+///
+/// Each group keeps the best `(value, ordering)` pair as Arrow row-format
bytes. The ordering
+/// rows are byte-comparable, so selecting the extremum for a batch is a
single row conversion
+/// plus per-row byte comparisons, avoiding the per-group `ScalarValue` work
of the generic
+/// `GroupsAccumulatorAdapter`.
+struct MaxMinByGroupsAccumulator {
+ /// Converts and compares the ordering column. Its sort options encode the
wanted extremum
+ /// as the largest row bytes (see `extremum_sort_options`).
+ ordering_converter: RowConverter,
+ /// Converts the value column to and from row bytes. Sort options are
irrelevant here since
+ /// values are only stored, never compared.
+ value_converter: RowConverter,
+ /// Row bytes for a null value, used for groups that have not been updated
(or whose winning
+ /// value is null).
+ null_value_row: OwnedRow,
+ /// Row bytes for a null ordering, used for groups that have seen no
non-null ordering.
+ null_ordering_row: OwnedRow,
+ /// Per-group winning value (row bytes).
+ best_value: Vec<OwnedRow>,
+ /// Per-group winning ordering (row bytes).
+ best_ordering: Vec<OwnedRow>,
+ /// Per-group flag: has a non-null ordering been seen yet?
+ has_ordering: Vec<bool>,
+}
+
+impl MaxMinByGroupsAccumulator {
+ fn try_new(value_type: DataType, ordering_type: DataType, is_max: bool) ->
Result<Self> {
+ let ordering_converter =
RowConverter::new(vec![SortField::new_with_options(
+ ordering_type.clone(),
+ extremum_sort_options(is_max),
+ )])?;
+ let value_converter =
RowConverter::new(vec![SortField::new(value_type.clone())])?;
+ let null_ordering_row = ordering_converter
+ .convert_columns(&[new_null_array(&ordering_type, 1)])?
+ .row(0)
+ .owned();
+ let null_value_row = value_converter
+ .convert_columns(&[new_null_array(&value_type, 1)])?
+ .row(0)
+ .owned();
+ Ok(Self {
+ ordering_converter,
+ value_converter,
+ null_value_row,
+ null_ordering_row,
+ best_value: Vec::new(),
+ best_ordering: Vec::new(),
+ has_ordering: Vec::new(),
+ })
+ }
+
+ fn resize(&mut self, total_num_groups: usize) {
+ self.best_value
+ .resize(total_num_groups, self.null_value_row.clone());
+ self.best_ordering
+ .resize(total_num_groups, self.null_ordering_row.clone());
+ self.has_ordering.resize(total_num_groups, false);
+ }
+
+ /// Shared update/merge logic: `values[0]` is the value column,
`values[1]` the ordering
+ /// column. Rows with a null ordering are ignored; on a tie the later row
wins, matching the
+ /// strict predicate in Spark's update (see
`MaxMinByAccumulator::update_from`).
+ fn update_groups(
+ &mut self,
+ values: &[ArrayRef],
+ group_indices: &[usize],
+ opt_filter: Option<&BooleanArray>,
+ total_num_groups: usize,
+ ) -> Result<()> {
+ self.resize(total_num_groups);
+ let value_rows = self
+ .value_converter
+ .convert_columns(&[Arc::clone(&values[0])])?;
+ let ordering_arr = canonicalize_float_ordering(&values[1]);
+ let ordering_rows = self
+ .ordering_converter
+ .convert_columns(&[Arc::clone(&ordering_arr)])?;
+
+ for (idx, &group_index) in group_indices.iter().enumerate() {
+ if let Some(filter) = opt_filter {
+ if !filter.is_valid(idx) || !filter.value(idx) {
+ continue;
+ }
+ }
+ if ordering_arr.is_null(idx) {
+ continue;
+ }
+ let candidate = ordering_rows.row(idx);
+ let take = !self.has_ordering[group_index]
+ || candidate >= self.best_ordering[group_index].row();
+ if take {
+ self.best_ordering[group_index] = candidate.owned();
+ self.best_value[group_index] = value_rows.row(idx).owned();
+ self.has_ordering[group_index] = true;
+ }
+ }
+ Ok(())
+ }
+}
+
+impl GroupsAccumulator for MaxMinByGroupsAccumulator {
+ fn update_batch(
+ &mut self,
+ values: &[ArrayRef],
+ group_indices: &[usize],
+ opt_filter: Option<&BooleanArray>,
+ total_num_groups: usize,
+ ) -> Result<()> {
+ self.update_groups(values, group_indices, opt_filter, total_num_groups)
+ }
+
+ fn merge_batch(
+ &mut self,
+ values: &[ArrayRef],
+ group_indices: &[usize],
+ total_num_groups: usize,
+ ) -> Result<()> {
+ // State columns mirror the input columns: [value, ordering].
+ self.update_groups(values, group_indices, None, total_num_groups)
+ }
+
+ fn evaluate(&mut self, emit_to: EmitTo) -> Result<ArrayRef> {
+ let value_rows = emit_to.take_needed(&mut self.best_value);
+ let _ = emit_to.take_needed(&mut self.best_ordering);
+ let _ = emit_to.take_needed(&mut self.has_ordering);
+ let arrays = self
+ .value_converter
+ .convert_rows(value_rows.iter().map(|r| r.row()))?;
+ Ok(Arc::clone(&arrays[0]))
+ }
+
+ fn state(&mut self, emit_to: EmitTo) -> Result<Vec<ArrayRef>> {
+ let value_rows = emit_to.take_needed(&mut self.best_value);
+ let ordering_rows = emit_to.take_needed(&mut self.best_ordering);
+ let _ = emit_to.take_needed(&mut self.has_ordering);
+ let value_arrays = self
+ .value_converter
+ .convert_rows(value_rows.iter().map(|r| r.row()))?;
+ let ordering_arrays = self
+ .ordering_converter
+ .convert_rows(ordering_rows.iter().map(|r| r.row()))?;
+ Ok(vec![
+ Arc::clone(&value_arrays[0]),
+ Arc::clone(&ordering_arrays[0]),
+ ])
+ }
+
+ fn convert_to_state(
+ &self,
+ _values: &[ArrayRef],
+ _opt_filter: Option<&BooleanArray>,
+ ) -> Result<Vec<ArrayRef>> {
+ not_impl_err!("Input batch conversion to state not implemented")
+ }
+
+ fn size(&self) -> usize {
+ // `size_of::<OwnedRow>()` only covers the inline `Box<[u8]>`
pointer/len, not the row
+ // bytes behind it, so the per-group payloads have to be summed
separately. Leaving them
+ // out under-reports to the memory pool that drives spill decisions.
+ let row_bytes = |rows: &Vec<OwnedRow>| -> usize {
+ rows.iter().map(|r| r.row().as_ref().len()).sum::<usize>()
+ };
+ size_of_val(self)
+ + (self.best_value.capacity() + self.best_ordering.capacity())
+ * std::mem::size_of::<OwnedRow>()
+ + row_bytes(&self.best_value)
+ + row_bytes(&self.best_ordering)
+ + self.has_ordering.capacity()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow::array::{AsArray, Float32Array, Float64Array, Int32Array,
Int64Array, StringArray};
+
+ fn max_by_acc(value_type: DataType, ordering_type: DataType) ->
MaxMinByAccumulator {
+ MaxMinByAccumulator::try_new(value_type, ordering_type, true).unwrap()
+ }
+
+ fn min_by_acc(value_type: DataType, ordering_type: DataType) ->
MaxMinByAccumulator {
+ MaxMinByAccumulator::try_new(value_type, ordering_type, false).unwrap()
+ }
+
+ #[test]
+ fn max_by_basic() {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Int32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b",
"c"]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![10, 50, 20]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn min_by_basic() {
+ let mut acc = min_by_acc(DataType::Utf8, DataType::Int32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b",
"c"]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![10, 50, 20]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("a"));
+ }
+
+ #[test]
+ fn null_ordering_is_ignored() {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Int32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec![Some("a"),
Some("b"), Some("c")]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![Some(10),
None, Some(5)]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ // The row with ordering=None (value "b") is ignored; max ordering is
10 -> "a".
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("a"));
+ }
+
+ #[test]
+ fn all_null_ordering_yields_null() {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Int32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec![Some("a"),
Some("b")]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![None, None]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::Utf8(None));
+ }
+
+ #[test]
+ fn null_value_at_extremum_is_returned() {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Int32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec![Some("a"),
None]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![Some(10),
Some(50)]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ // Max ordering 50 pairs with a null value.
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::Utf8(None));
+ }
+
+ #[test]
+ fn empty_group_yields_null() {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Int32);
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::Utf8(None));
+ }
+
+ #[test]
+ fn max_by_nan_is_largest() {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Float64);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b",
"c"]));
+ let ordering: ArrayRef = Arc::new(Float64Array::from(vec![1.0,
f64::NAN, 2.0]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ // Spark treats NaN as the largest value, matching arrow's row
ordering.
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn max_by_sign_bit_nan_is_still_largest() {
+ // `Double.compare` goes through `doubleToLongBits`, so a sign-bit-set
NaN is the same
+ // value as a positive NaN and still sorts above every finite
ordering. Arrow's raw-bit
+ // encoding would otherwise place it below -Infinity.
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Float64);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b",
"c"]));
+ let ordering: ArrayRef = Arc::new(Float64Array::from(vec![1.0,
-f64::NAN, 2.0]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn min_by_sign_bit_nan_is_not_smallest() {
+ // The mirror of the case above: because every NaN is the *largest*
value, a sign-bit-set
+ // NaN loses to any finite ordering in `min_by` and must not be
selected. Without the
+ // canonicalization arrow's raw-bit encoding places it below
-Infinity, which would make it
+ // the minimum and return "a".
+ let mut acc = min_by_acc(DataType::Utf8, DataType::Float64);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+ let ordering: ArrayRef = Arc::new(Float64Array::from(vec![-f64::NAN,
2.0]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn max_by_ties_signed_zeros_and_keeps_last_row() {
+ // Spark compares the ordering with `SQLOrderingUtil.compareDoubles`,
which ties
+ // `-0.0 == 0.0`, and its update keeps the *new* row on a tie. So both
orders below must
+ // return the later row. Without the canonicalization, arrow's row
format ranks
+ // `0.0 > -0.0` strictly and the second case would return "a".
+ for (ordering, expected) in [
+ (vec![0.0_f64, -0.0_f64], "b"),
+ (vec![-0.0_f64, 0.0_f64], "b"),
+ ] {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Float64);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+ let ordering_arr: ArrayRef =
Arc::new(Float64Array::from(ordering.clone()));
+ acc.update_batch(&[values, ordering_arr]).unwrap();
+ assert_eq!(
+ acc.evaluate().unwrap(),
+ ScalarValue::from(expected),
+ "max_by over ordering {ordering:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn min_by_ties_signed_zeros_and_keeps_last_row() {
+ // `min_by`'s predicate is the strict `oldExpr < newExpr`, so a tie
likewise keeps the new
+ // row. The signed zeros must not break the tie in either direction.
+ for (ordering, expected) in [
+ (vec![0.0_f64, -0.0_f64], "b"),
+ (vec![-0.0_f64, 0.0_f64], "b"),
+ ] {
+ let mut acc = min_by_acc(DataType::Utf8, DataType::Float64);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+ let ordering_arr: ArrayRef =
Arc::new(Float64Array::from(ordering.clone()));
+ acc.update_batch(&[values, ordering_arr]).unwrap();
+ assert_eq!(
+ acc.evaluate().unwrap(),
+ ScalarValue::from(expected),
+ "min_by over ordering {ordering:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn signed_zero_tie_holds_across_batches() {
+ // The running extremum is compared against the next batch's extremum
with the same
+ // encoding, so the tie must survive the batch boundary too: a second
batch whose ordering
+ // is the other zero still displaces the first batch's value.
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Float64);
+ acc.update_batch(&[
+ Arc::new(StringArray::from(vec!["a"])) as ArrayRef,
+ Arc::new(Float64Array::from(vec![0.0_f64])) as ArrayRef,
+ ])
+ .unwrap();
+ acc.update_batch(&[
+ Arc::new(StringArray::from(vec!["b"])) as ArrayRef,
+ Arc::new(Float64Array::from(vec![-0.0_f64])) as ArrayRef,
+ ])
+ .unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn float32_signed_zero_tie() {
+ // `compareFloats` has the same shape as `compareDoubles`, so Float32
orderings need the
+ // same treatment. Both orders are checked: the `(-0.0, 0.0)` order
alone would pass even
+ // without the canonicalization, because arrow's ranking happens to
pick the later row too.
+ for (ordering, expected) in [
+ (vec![0.0_f32, -0.0_f32], "b"),
+ (vec![-0.0_f32, 0.0_f32], "b"),
+ ] {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Float32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+ let ordering_arr: ArrayRef =
Arc::new(Float32Array::from(ordering.clone()));
+ acc.update_batch(&[values, ordering_arr]).unwrap();
+ assert_eq!(
+ acc.evaluate().unwrap(),
+ ScalarValue::from(expected),
+ "max_by over f32 ordering {ordering:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn float32_max_by_sign_bit_nan_is_largest() {
+ // `compareFloats` routes through `floatToIntBits` just as
`compareDoubles` routes through
+ // `doubleToLongBits`, so a sign-bit-set NaN is the same value as a
positive one and is the
+ // largest ordering for Float32 too. Without the canonicalization
arrow ranks `-NaN` below
+ // -Infinity, so this would return "c".
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Float32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b",
"c"]));
+ let ordering: ArrayRef = Arc::new(Float32Array::from(vec![1.0,
-f32::NAN, 2.0]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn float32_min_by_sign_bit_nan_is_not_smallest() {
+ // Kept separate from the `max_by` case above rather than asserted
alongside it: a failure
+ // of the first assertion would stop the test before the second ever
ran, which is how a
+ // half-vacuous test hides.
+ let mut acc = min_by_acc(DataType::Utf8, DataType::Float32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+ let ordering: ArrayRef = Arc::new(Float32Array::from(vec![-f32::NAN,
2.0]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ assert_eq!(acc.evaluate().unwrap(), ScalarValue::from("b"));
+ }
+
+ #[test]
+ fn groups_signed_zero_tie() {
+ // Same invariant in the grouped accumulator: group 0 sees (0.0, -0.0)
and group 1 sees
+ // (-0.0, 0.0); both must keep the later row.
+ let mut acc =
+ MaxMinByGroupsAccumulator::try_new(DataType::Utf8,
DataType::Float64, true).unwrap();
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b", "c",
"d"]));
+ let ordering: ArrayRef = Arc::new(Float64Array::from(vec![
+ 0.0_f64, -0.0_f64, -0.0_f64, 0.0_f64,
+ ]));
+ acc.update_batch(&[values, ordering], &[0, 0, 1, 1], None, 2)
+ .unwrap();
+ let result = acc.evaluate(EmitTo::All).unwrap();
+ let result = result.as_string::<i32>();
+ assert_eq!(result.value(0), "b");
+ assert_eq!(result.value(1), "d");
+ }
+
+ #[test]
+ fn merge_matches_single_shot() {
+ let single = {
+ let mut acc = max_by_acc(DataType::Utf8, DataType::Int32);
+ let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b",
"c", "d", "e", "f"]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![1, 6, 3,
2, 5, 4]));
+ acc.update_batch(&[values, ordering]).unwrap();
+ acc.evaluate().unwrap()
+ };
+
+ let mut left = max_by_acc(DataType::Utf8, DataType::Int32);
+ left.update_batch(&[
+ Arc::new(StringArray::from(vec!["a", "b", "c"])) as ArrayRef,
+ Arc::new(Int32Array::from(vec![1, 6, 3])) as ArrayRef,
+ ])
+ .unwrap();
+ let mut right = max_by_acc(DataType::Utf8, DataType::Int32);
+ right
+ .update_batch(&[
+ Arc::new(StringArray::from(vec!["d", "e", "f"])) as ArrayRef,
+ Arc::new(Int32Array::from(vec![2, 5, 4])) as ArrayRef,
+ ])
+ .unwrap();
+
+ let mut merged = max_by_acc(DataType::Utf8, DataType::Int32);
+ for acc in [&mut left, &mut right] {
+ let state = acc.state().unwrap();
+ let value_arr =
ScalarValue::iter_to_array(vec![state[0].clone()]).unwrap();
+ let ordering_arr =
ScalarValue::iter_to_array(vec![state[1].clone()]).unwrap();
+ merged.merge_batch(&[value_arr, ordering_arr]).unwrap();
+ }
+ assert_eq!(merged.evaluate().unwrap(), single);
+ }
+
+ // ---- GroupsAccumulator tests ----
+
+ fn max_by_groups(value_type: DataType, ordering_type: DataType) ->
MaxMinByGroupsAccumulator {
+ MaxMinByGroupsAccumulator::try_new(value_type, ordering_type,
true).unwrap()
+ }
+
+ fn min_by_groups(value_type: DataType, ordering_type: DataType) ->
MaxMinByGroupsAccumulator {
+ MaxMinByGroupsAccumulator::try_new(value_type, ordering_type,
false).unwrap()
+ }
+
+ fn eval_int(acc: &mut MaxMinByGroupsAccumulator) -> Vec<Option<i32>> {
+ acc.evaluate(EmitTo::All)
+ .unwrap()
+ .as_primitive::<arrow::datatypes::Int32Type>()
+ .iter()
+ .collect()
+ }
+
+ #[test]
+ fn groups_max_by_multi_group() {
+ let mut acc = max_by_groups(DataType::Int32, DataType::Int32);
+ // group 0: values 10,30 orderings 1,2 -> 30 ; group 1: values 20,40
orderings 5,4 -> 20
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 30,
40]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![1, 5, 2, 4]));
+ acc.update_batch(&[values, ordering], &[0, 1, 0, 1], None, 2)
+ .unwrap();
+ assert_eq!(eval_int(&mut acc), vec![Some(30), Some(20)]);
+ }
+
+ #[test]
+ fn groups_min_by_multi_group() {
+ let mut acc = min_by_groups(DataType::Int32, DataType::Int32);
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 30,
40]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![1, 5, 2, 4]));
+ acc.update_batch(&[values, ordering], &[0, 1, 0, 1], None, 2)
+ .unwrap();
+ // group 0: min ordering 1 -> 10 ; group 1: min ordering 4 -> 40
+ assert_eq!(eval_int(&mut acc), vec![Some(10), Some(40)]);
+ }
+
+ #[test]
+ fn groups_null_ordering_and_empty_group() {
+ let mut acc = max_by_groups(DataType::Int32, DataType::Int32);
+ // group 0: (10,null),(30,5) -> 30 ; group 1: no rows -> null ; group
2: (40,null) -> null
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(10),
Some(30), Some(40)]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![None, Some(5),
None]));
+ acc.update_batch(&[values, ordering], &[0, 0, 2], None, 3)
+ .unwrap();
+ assert_eq!(eval_int(&mut acc), vec![Some(30), None, None]);
+ }
+
+ #[test]
+ fn groups_null_value_at_extremum() {
+ let mut acc = max_by_groups(DataType::Int32, DataType::Int32);
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![Some(10),
None]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![Some(1),
Some(9)]));
+ acc.update_batch(&[values, ordering], &[0, 0], None, 1)
+ .unwrap();
+ assert_eq!(eval_int(&mut acc), vec![None]);
+ }
+
+ #[test]
+ fn groups_merge_matches_single_shot() {
+ let single = {
+ let mut acc = max_by_groups(DataType::Int32, DataType::Int32);
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![1, 2, 3, 4,
5, 6]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![1, 6, 3,
2, 5, 4]));
+ acc.update_batch(&[values, ordering], &[0, 0, 0, 0, 0, 0], None, 1)
+ .unwrap();
+ eval_int(&mut acc)
+ };
+
+ let mut left = max_by_groups(DataType::Int32, DataType::Int32);
+ left.update_batch(
+ &[
+ Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef,
+ Arc::new(Int32Array::from(vec![1, 6, 3])) as ArrayRef,
+ ],
+ &[0, 0, 0],
+ None,
+ 1,
+ )
+ .unwrap();
+ let mut right = max_by_groups(DataType::Int32, DataType::Int32);
+ right
+ .update_batch(
+ &[
+ Arc::new(Int32Array::from(vec![4, 5, 6])) as ArrayRef,
+ Arc::new(Int32Array::from(vec![2, 5, 4])) as ArrayRef,
+ ],
+ &[0, 0, 0],
+ None,
+ 1,
+ )
+ .unwrap();
+
+ let mut merged = max_by_groups(DataType::Int32, DataType::Int32);
+ for acc in [&mut left, &mut right] {
+ let state = acc.state(EmitTo::All).unwrap();
+ merged.merge_batch(&state, &[0], 1).unwrap();
+ }
+ assert_eq!(eval_int(&mut merged), single);
+ }
+
+ #[test]
+ fn groups_filter_is_respected() {
+ let mut acc = max_by_groups(DataType::Int32, DataType::Int32);
+ let values: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 30]));
+ let ordering: ArrayRef = Arc::new(Int32Array::from(vec![1, 9, 2]));
+ // Filter out the row with ordering 9, so the max becomes ordering 2
-> value 30.
+ let filter = BooleanArray::from(vec![true, false, true]);
+ acc.update_batch(&[values, ordering], &[0, 0, 0], Some(&filter), 1)
+ .unwrap();
+ assert_eq!(eval_int(&mut acc), vec![Some(30)]);
+ }
+
+ #[test]
+ fn groups_accumulator_size_counts_row_bytes() {
+ // Each group owns two `OwnedRow`s, and each one holds its bytes in a
separate `Box<[u8]>`.
+ // Counting only `size_of::<OwnedRow>()` per slot leaves that payload
invisible to the
+ // memory pool that drives spill decisions.
+ const GROUPS: usize = 1_000;
+ let mut acc = max_by_groups(DataType::Int32, DataType::Int64);
+ let values: ArrayRef = Arc::new(Int32Array::from((0..GROUPS as
i32).collect::<Vec<_>>()));
+ let ordering: ArrayRef = Arc::new(Int64Array::from((0..GROUPS as
i64).collect::<Vec<_>>()));
+ acc.update_batch(
+ &[values, ordering],
+ &(0..GROUPS).collect::<Vec<_>>(),
+ None,
+ GROUPS,
+ )
+ .unwrap();
+
+ // A row is a one-byte null sentinel plus the fixed-width encoding: 5
bytes for the Int32
+ // value and 9 for the Int64 ordering.
+ let payload = GROUPS * (5 + 9);
+ let headers = (acc.best_value.capacity() +
acc.best_ordering.capacity())
+ * std::mem::size_of::<OwnedRow>();
+ assert!(
+ acc.size() >= size_of_val(&acc) + headers + payload,
+ "size() = {} does not cover {payload} bytes of row payload on top
of {headers} bytes \
+ of slot headers",
+ acc.size()
+ );
+ }
+}
diff --git a/native/spark-expr/src/agg_funcs/mod.rs
b/native/spark-expr/src/agg_funcs/mod.rs
index a63a8ddb0a..d0d8083195 100644
--- a/native/spark-expr/src/agg_funcs/mod.rs
+++ b/native/spark-expr/src/agg_funcs/mod.rs
@@ -22,6 +22,7 @@ mod correlation;
mod covariance;
mod hll_plus_plus;
mod hll_plus_plus_const;
+mod max_min_by;
mod mode;
mod percentile;
mod quantile_summaries;
@@ -38,6 +39,7 @@ pub use avg_decimal::AvgDecimal;
pub use correlation::Correlation;
pub use covariance::Covariance;
pub use hll_plus_plus::{hllpp_precision, HllPlusPlus};
+pub use max_min_by::MaxMinBy;
pub use mode::Mode;
pub use percentile::SparkPercentile;
pub use regr::{Regr, RegrType};
diff --git a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
index e748e82cd7..be4bc9c341 100644
--- a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
@@ -414,7 +414,9 @@ object QueryPlanSerde extends Logging with CometExprShim
with CometTypeShim {
classOf[First] -> CometFirst,
classOf[Last] -> CometLast,
classOf[Max] -> CometMax,
+ classOf[MaxBy] -> CometMaxBy,
classOf[Min] -> CometMin,
+ classOf[MinBy] -> CometMinBy,
classOf[Mode] -> CometMode,
classOf[Percentile] -> CometPercentile,
classOf[RegrIntercept] -> CometRegrIntercept,
diff --git a/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
b/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
index 4f4b0f5f41..aef649debc 100644
--- a/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/aggregates.scala
@@ -22,7 +22,7 @@ package org.apache.comet.serde
import scala.jdk.CollectionConverters._
import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, Expression,
Literal}
-import
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression,
ApproximatePercentile, Average, BitAndAgg, BitOrAgg, BitXorAgg,
BloomFilterAggregate, CentralMomentAgg, CollectList, CollectSet, Corr, Count,
Covariance, CovPopulation, CovSample, First, HyperLogLogPlusPlus, Last, Max,
Min, Mode, Percentile, RegrIntercept, RegrR2, RegrReplacement, RegrSlope,
RegrSXY, StddevPop, StddevSamp, Sum, VariancePop, VarianceSamp}
+import
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression,
ApproximatePercentile, Average, BitAndAgg, BitOrAgg, BitXorAgg,
BloomFilterAggregate, CentralMomentAgg, CollectList, CollectSet, Corr, Count,
Covariance, CovPopulation, CovSample, First, HyperLogLogPlusPlus, Last, Max,
MaxBy, MaxMinBy, Min, MinBy, Mode, Percentile, RegrIntercept, RegrR2,
RegrReplacement, RegrSlope, RegrSXY, StddevPop, StddevSamp, Sum, VariancePop,
VarianceSamp}
import org.apache.spark.sql.catalyst.util.ArrayData
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType,
DataType, DateType, DecimalType, DoubleType, FloatType, IntegerType, LongType,
NumericType, ShortType, StringType, TimestampNTZType, TimestampType}
@@ -105,6 +105,89 @@ object CometMax extends CometAggregateExpressionSerde[Max]
{
}
}
+/**
+ * Shared serde for `max_by` and `min_by`. Both are 2-argument `MaxMinBy`
`DeclarativeAggregate`s
+ * differing only in the comparison direction, so the value/ordering handling
is identical.
+ */
+abstract class CometMaxMinBy[T <: MaxMinBy] extends
CometAggregateExpressionSerde[T] {
+
+ /** `true` for `max_by`, `false` for `min_by`. */
+ protected def isMax: Boolean
+
+ /** `"maximum"` or `"minimum"`, used in the non-determinism note. */
+ private def extremum: String = if (isMax) "maximum" else "minimum"
+
+ override def getCompatibleNotes(): Seq[String] = Seq(
+ s"This function is non-deterministic when multiple rows share the
$extremum ordering value." +
+ " Results may differ from Spark in that case.")
+
+ override def getUnsupportedReasons(): Seq[String] = Seq(
+ "The value and ordering must both be fixed-length types (boolean,
integral, floating-point," +
+ " decimal, date, or timestamp). A variable-length or nested type such as
string, binary, or" +
+ " struct falls back to Spark.")
+
+ override def getSupportLevel(expr: T): SupportLevel = {
+ // Both the value and ordering must be fixed-length types.
+ //
+ // On its own a variable-length type never reaches here: Spark only uses
HashAggregate (the
+ // aggregate operator Comet accelerates) when the aggregation buffer is
mutable, and the buffer
+ // holds both the running value and the running ordering, so a StringType
in either position
+ // forces SortAggregate, which Comet does not convert.
+ //
+ // The check is still load-bearing, because a TypedImperativeAggregate
elsewhere in the same
+ // aggregate switches Spark to ObjectHashAggregate, which Comet does
convert. In that shape a
+ // string ordering would otherwise be compared by Arrow's row format as
raw UTF-8 bytes, while
+ // Spark compares collation sort keys. See the fallback cases in
max_by.sql.
+ //
+ // The native side compares the ordering column via Arrow's row format,
which supports all of
+ // the fixed-length orderable types allowed below.
+ if (!AggSerde.minMaxDataTypeSupported(expr.valueExpr.dataType)) {
+ Unsupported(Some(s"Unsupported value data type:
${expr.valueExpr.dataType}"))
+ } else if (!AggSerde.minMaxDataTypeSupported(expr.orderingExpr.dataType)) {
+ Unsupported(Some(s"Unsupported ordering data type:
${expr.orderingExpr.dataType}"))
+ } else {
+ Compatible()
+ }
+ }
+
+ override def convert(
+ aggExpr: AggregateExpression,
+ expr: T,
+ inputs: Seq[Attribute],
+ binding: Boolean,
+ conf: SQLConf): Option[ExprOuterClass.AggExpr] = {
+ val valueExpr = exprToProto(expr.valueExpr, inputs, binding)
+ val orderingExpr = exprToProto(expr.orderingExpr, inputs, binding)
+
+ if (valueExpr.isDefined && orderingExpr.isDefined) {
+ val aggBuilder = ExprOuterClass.AggExpr.newBuilder()
+ if (isMax) {
+ val builder = ExprOuterClass.MaxBy.newBuilder()
+ builder.setValue(valueExpr.get)
+ builder.setOrdering(orderingExpr.get)
+ aggBuilder.setMaxBy(builder)
+ } else {
+ val builder = ExprOuterClass.MinBy.newBuilder()
+ builder.setValue(valueExpr.get)
+ builder.setOrdering(orderingExpr.get)
+ aggBuilder.setMinBy(builder)
+ }
+ Some(aggBuilder.build())
+ } else {
+ withFallbackReason(aggExpr, "Child expression or data type not
supported")
+ None
+ }
+ }
+}
+
+object CometMaxBy extends CometMaxMinBy[MaxBy] {
+ override protected def isMax: Boolean = true
+}
+
+object CometMinBy extends CometMaxMinBy[MinBy] {
+ override protected def isMax: Boolean = false
+}
+
object CometCount extends CometAggregateExpressionSerde[Count] {
override def convert(
aggExpr: AggregateExpression,
diff --git
a/spark/src/test/resources/sql-tests/expressions/aggregate/max_by.sql
b/spark/src/test/resources/sql-tests/expressions/aggregate/max_by.sql
new file mode 100644
index 0000000000..9c8354f06a
--- /dev/null
+++ b/spark/src/test/resources/sql-tests/expressions/aggregate/max_by.sql
@@ -0,0 +1,284 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements. See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership. The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied. See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- max_by(x, y) returns the value of x associated with the maximum value of y.
+--
+-- The value (x) must be a fixed-length type: Spark only uses HashAggregate
(the aggregate
+-- operator Comet accelerates) when the aggregation buffer is mutable, so
variable-length
+-- value types such as string force SortAggregate and fall back to Spark.
+--
+-- Ordering values are kept unique within each group so results are
deterministic (max_by is
+-- non-deterministic when several rows tie on the maximum ordering).
+
+-- ============================================================
+-- Setup: tables
+-- ============================================================
+
+statement
+CREATE TABLE mb_src(v int, ord int, grp string) USING parquet
+
+statement
+INSERT INTO mb_src VALUES
+ (10, 10, 'g1'), (20, 50, 'g1'), (30, 20, 'g1'),
+ (40, 40, 'g2'), (50, 5, 'g2'), (60, 30, 'g2'),
+ (70, 99, 'g3')
+
+-- ordering NULLs are ignored; a group of all-NULL orderings yields NULL
+statement
+CREATE TABLE mb_nulls(v int, ord int, grp string) USING parquet
+
+statement
+INSERT INTO mb_nulls VALUES
+ (1, 10, 'g1'), (2, NULL, 'g1'), (3, 5, 'g1'),
+ (4, NULL, 'g2'), (5, NULL, 'g2'),
+ (6, 7, 'g3'), (NULL, 100, 'g3')
+
+statement
+CREATE TABLE mb_empty(v int, ord int) USING parquet
+
+-- ============================================================
+-- Global aggregate (no GROUP BY)
+-- ============================================================
+
+query
+SELECT max_by(v, ord) FROM mb_src
+
+-- ============================================================
+-- GROUP BY
+-- ============================================================
+
+query
+SELECT grp, max_by(v, ord) FROM mb_src GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- NULL handling: NULL orderings ignored; the value paired with the
+-- maximum ordering may itself be NULL; all-NULL orderings yield NULL
+-- ============================================================
+
+query
+SELECT grp, max_by(v, ord) FROM mb_nulls GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Empty table yields NULL
+-- ============================================================
+
+query
+SELECT max_by(v, ord) FROM mb_empty
+
+-- ============================================================
+-- Literal arguments (evaluated natively; constant folding is disabled)
+-- ============================================================
+
+query
+SELECT max_by(5, 10), max_by(CAST(NULL AS INT), 20)
+
+-- ============================================================
+-- Mixed with other aggregates
+-- ============================================================
+
+query
+SELECT grp, max_by(v, ord), count(*), max(ord)
+FROM mb_src GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- BigInt value
+-- ============================================================
+
+statement
+CREATE TABLE mb_long(val bigint, ord int, grp string) USING parquet
+
+statement
+INSERT INTO mb_long VALUES
+ (1000000000000, 1, 'a'), (2000000000000, 3, 'a'), (3000000000000, 2, 'a'),
+ (4000000000000, 5, 'b'), (5000000000000, 4, 'b')
+
+query
+SELECT grp, max_by(val, ord) FROM mb_long GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Double value and double ordering (NaN is the maximum in Spark)
+-- ============================================================
+
+statement
+CREATE TABLE mb_dbl(v double, ord double, grp string) USING parquet
+
+statement
+INSERT INTO mb_dbl VALUES
+ (1.1, 1.5, 'g1'), (2.2, 2.5, 'g1'), (3.3, 0.5, 'g1'),
+ (4.4, 1.0, 'g2'), (5.5, CAST('NaN' AS DOUBLE), 'g2'), (6.6, 100.0, 'g2'),
+ (7.7, CAST('Infinity' AS DOUBLE), 'g3'), (8.8, 3.0, 'g3')
+
+query
+SELECT grp, max_by(v, ord) FROM mb_dbl GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Decimal value and decimal ordering
+-- ============================================================
+
+statement
+CREATE TABLE mb_dec(v decimal(10,2), ord decimal(10,2), grp string) USING
parquet
+
+statement
+INSERT INTO mb_dec VALUES
+ (10.01, 1.50, 'g1'), (20.02, 9.99, 'g1'), (30.03, 5.00, 'g1'),
+ (40.04, 2.00, 'g2'), (50.05, 8.25, 'g2')
+
+query
+SELECT grp, max_by(v, ord) FROM mb_dec GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Date / timestamp value and ordering
+-- ============================================================
+
+statement
+CREATE TABLE mb_dt(d date, ts timestamp, ord int, grp string) USING parquet
+
+statement
+INSERT INTO mb_dt VALUES
+ (DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00', 1, 'g1'),
+ (DATE '2024-06-15', TIMESTAMP '2024-06-15 12:30:00', 3, 'g1'),
+ (DATE '2023-12-31', TIMESTAMP '2023-12-31 23:59:59', 2, 'g1'),
+ (DATE '2024-03-01', TIMESTAMP '2024-03-01 08:00:00', 1, 'g2')
+
+query
+SELECT grp, max_by(d, ord) FROM mb_dt GROUP BY grp ORDER BY grp
+
+query
+SELECT grp, max_by(ts, ord) FROM mb_dt GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Date / timestamp as the ordering column
+-- ============================================================
+
+query
+SELECT grp, max_by(ord, d) FROM mb_dt GROUP BY grp ORDER BY grp
+
+query
+SELECT grp, max_by(ord, ts) FROM mb_dt GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Negative and boundary ordering values
+-- ============================================================
+
+statement
+CREATE TABLE mb_bound(v int, ord bigint, grp string) USING parquet
+
+statement
+INSERT INTO mb_bound VALUES
+ (1, -100, 'g1'), (2, -5, 'g1'), (3, -50,
'g1'),
+ (4, -9223372036854775808, 'g2'), (5, 9223372036854775807, 'g2'), (6, 0,
'g2'),
+ (7, -2147483648, 'g3'), (8, 2147483647, 'g3')
+
+query
+SELECT grp, max_by(v, ord) FROM mb_bound GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Double ordering with -Infinity and Infinity
+-- ============================================================
+
+statement
+CREATE TABLE mb_inf(v int, ord double, grp string) USING parquet
+
+statement
+INSERT INTO mb_inf VALUES
+ (1, CAST('-Infinity' AS DOUBLE), 'g1'), (2, -1.0, 'g1'), (3, CAST('Infinity'
AS DOUBLE), 'g1'),
+ (4, CAST('-Infinity' AS DOUBLE), 'g2'), (5, -2.0, 'g2')
+
+query
+SELECT grp, max_by(v, ord) FROM mb_inf GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Multiple max_by in one query
+-- ============================================================
+
+query
+SELECT grp, max_by(v, ord), max_by(ord, v) FROM mb_src GROUP BY grp ORDER BY
grp
+
+-- ============================================================
+-- Value and ordering are the same column
+-- ============================================================
+
+query
+SELECT grp, max_by(ord, ord) FROM mb_src GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Boolean value
+-- ============================================================
+
+statement
+CREATE TABLE mb_bool(v boolean, ord int, grp string) USING parquet
+
+statement
+INSERT INTO mb_bool VALUES
+ (true, 1, 'a'), (false, 3, 'a'), (true, 2, 'a'),
+ (false, 5, 'b'), (true, 4, 'b')
+
+query
+SELECT grp, max_by(v, ord) FROM mb_bool GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Signed zeros in the ordering
+--
+-- Spark compares the ordering with SQLOrderingUtil.compareDoubles, whose `if
(x == y) 0`
+-- short-circuit ties -0.0 with 0.0. Arrow's row format instead ranks -0.0
strictly below 0.0, so
+-- the native side canonicalizes the ordering column before encoding it.
+--
+-- The tied rows deliberately share the same value (v), which keeps this query
deterministic. A
+-- fixture that gave the tied rows different values could not be asserted
here: which tied row wins
+-- depends on the inter-partition merge order, and this table spans several
partitions, so max_by is
+-- genuinely non-deterministic in that case (as Spark documents). The
order-dependent behaviour is
+-- pinned in the Rust unit tests in max_min_by.rs instead, where row order is
explicit. This case
+-- still exercises the canonicalization path end to end.
+--
+-- Negative zero must be written as -0.0D: an unsuffixed -0.0 is a DecimalType
literal, and Decimal
+-- has no signed zero, so CAST(-0.0 AS DOUBLE) would silently store +0.0.
+-- ============================================================
+
+statement
+CREATE TABLE mb_signed_zero(v int, ord double, grp string) USING parquet
+
+statement
+INSERT INTO mb_signed_zero VALUES
+ (1, -0.0D, 'g1'), (1, 0.0D, 'g1'), (2, -5.0D, 'g1'),
+ (3, 0.0D, 'g2'), (3, -0.0D, 'g2'), (4, -5.0D, 'g2')
+
+query
+SELECT grp, max_by(v, ord) FROM mb_signed_zero GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Variable-length value or ordering falls back to Spark
+--
+-- A plain max_by over a string is planned as SortAggregate, which Comet never
converts, so the
+-- serde's type check is not what stops it. Pairing it with a
TypedImperativeAggregate switches
+-- Spark to ObjectHashAggregate, which Comet does convert, and then
getSupportLevel is the only
+-- thing that keeps the aggregate off the native path. That matters most for a
string *ordering*:
+-- Arrow's row format compares raw UTF-8 bytes, while Spark compares collation
sort keys, so this
+-- check is load-bearing for correctness and not just an optimisation.
+-- ============================================================
+
+statement
+CREATE TABLE mb_varlen(v int, s string, grp string) USING parquet
+
+statement
+INSERT INTO mb_varlen VALUES
+ (1, 'a', 'g1'), (2, 'b', 'g1'), (3, 'c', 'g2')
+
+query expect_fallback(Unsupported value data type)
+SELECT grp, max_by(s, v), percentile(v, 0.5) FROM mb_varlen GROUP BY grp ORDER
BY grp
+
+query expect_fallback(Unsupported ordering data type)
+SELECT grp, max_by(v, s), percentile(v, 0.5) FROM mb_varlen GROUP BY grp ORDER
BY grp
diff --git
a/spark/src/test/resources/sql-tests/expressions/aggregate/min_by.sql
b/spark/src/test/resources/sql-tests/expressions/aggregate/min_by.sql
new file mode 100644
index 0000000000..d015acc35d
--- /dev/null
+++ b/spark/src/test/resources/sql-tests/expressions/aggregate/min_by.sql
@@ -0,0 +1,266 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements. See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership. The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied. See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- min_by(x, y) returns the value of x associated with the minimum value of y.
+--
+-- The value (x) must be a fixed-length type: Spark only uses HashAggregate
(the aggregate
+-- operator Comet accelerates) when the aggregation buffer is mutable, so
variable-length
+-- value types such as string force SortAggregate and fall back to Spark.
+--
+-- Ordering values are kept unique within each group so results are
deterministic (min_by is
+-- non-deterministic when several rows tie on the minimum ordering).
+
+-- ============================================================
+-- Setup: tables
+-- ============================================================
+
+statement
+CREATE TABLE nb_src(v int, ord int, grp string) USING parquet
+
+statement
+INSERT INTO nb_src VALUES
+ (10, 10, 'g1'), (20, 50, 'g1'), (30, 20, 'g1'),
+ (40, 40, 'g2'), (50, 5, 'g2'), (60, 30, 'g2'),
+ (70, 99, 'g3')
+
+-- ordering NULLs are ignored; a group of all-NULL orderings yields NULL
+statement
+CREATE TABLE nb_nulls(v int, ord int, grp string) USING parquet
+
+statement
+INSERT INTO nb_nulls VALUES
+ (1, 10, 'g1'), (2, NULL, 'g1'), (3, 5, 'g1'),
+ (4, NULL, 'g2'), (5, NULL, 'g2'),
+ (6, 7, 'g3'), (NULL, 1, 'g3')
+
+statement
+CREATE TABLE nb_empty(v int, ord int) USING parquet
+
+-- ============================================================
+-- Global aggregate (no GROUP BY)
+-- ============================================================
+
+query
+SELECT min_by(v, ord) FROM nb_src
+
+-- ============================================================
+-- GROUP BY
+-- ============================================================
+
+query
+SELECT grp, min_by(v, ord) FROM nb_src GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- NULL handling: NULL orderings ignored; the value paired with the
+-- minimum ordering may itself be NULL; all-NULL orderings yield NULL
+-- ============================================================
+
+query
+SELECT grp, min_by(v, ord) FROM nb_nulls GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Empty table yields NULL
+-- ============================================================
+
+query
+SELECT min_by(v, ord) FROM nb_empty
+
+-- ============================================================
+-- Literal arguments (evaluated natively; constant folding is disabled)
+-- ============================================================
+
+query
+SELECT min_by(5, 10), min_by(CAST(NULL AS INT), 20)
+
+-- ============================================================
+-- Mixed with other aggregates
+-- ============================================================
+
+query
+SELECT grp, min_by(v, ord), count(*), min(ord)
+FROM nb_src GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- BigInt value
+-- ============================================================
+
+statement
+CREATE TABLE nb_long(val bigint, ord int, grp string) USING parquet
+
+statement
+INSERT INTO nb_long VALUES
+ (1000000000000, 1, 'a'), (2000000000000, 3, 'a'), (3000000000000, 2, 'a'),
+ (4000000000000, 5, 'b'), (5000000000000, 4, 'b')
+
+query
+SELECT grp, min_by(val, ord) FROM nb_long GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Double value and double ordering (NaN is the maximum in Spark, so it is
+-- never selected by min_by unless it is the only non-null ordering)
+-- ============================================================
+
+statement
+CREATE TABLE nb_dbl(v double, ord double, grp string) USING parquet
+
+statement
+INSERT INTO nb_dbl VALUES
+ (1.1, 1.5, 'g1'), (2.2, 2.5, 'g1'), (3.3, 0.5, 'g1'),
+ (4.4, 1.0, 'g2'), (5.5, CAST('NaN' AS DOUBLE), 'g2'), (6.6, 100.0, 'g2'),
+ (7.7, CAST('-Infinity' AS DOUBLE), 'g3'), (8.8, 3.0, 'g3')
+
+query
+SELECT grp, min_by(v, ord) FROM nb_dbl GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Decimal value and decimal ordering
+-- ============================================================
+
+statement
+CREATE TABLE nb_dec(v decimal(10,2), ord decimal(10,2), grp string) USING
parquet
+
+statement
+INSERT INTO nb_dec VALUES
+ (10.01, 1.50, 'g1'), (20.02, 9.99, 'g1'), (30.03, 5.00, 'g1'),
+ (40.04, 2.00, 'g2'), (50.05, 8.25, 'g2')
+
+query
+SELECT grp, min_by(v, ord) FROM nb_dec GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Date / timestamp value and ordering
+-- ============================================================
+
+statement
+CREATE TABLE nb_dt(d date, ts timestamp, ord int, grp string) USING parquet
+
+statement
+INSERT INTO nb_dt VALUES
+ (DATE '2024-01-01', TIMESTAMP '2024-01-01 00:00:00', 1, 'g1'),
+ (DATE '2024-06-15', TIMESTAMP '2024-06-15 12:30:00', 3, 'g1'),
+ (DATE '2023-12-31', TIMESTAMP '2023-12-31 23:59:59', 2, 'g1'),
+ (DATE '2024-03-01', TIMESTAMP '2024-03-01 08:00:00', 1, 'g2')
+
+query
+SELECT grp, min_by(d, ord) FROM nb_dt GROUP BY grp ORDER BY grp
+
+query
+SELECT grp, min_by(ts, ord) FROM nb_dt GROUP BY grp ORDER BY grp
+
+query
+SELECT grp, min_by(ord, d) FROM nb_dt GROUP BY grp ORDER BY grp
+
+query
+SELECT grp, min_by(ord, ts) FROM nb_dt GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Negative and boundary ordering values
+-- ============================================================
+
+statement
+CREATE TABLE nb_bound(v int, ord bigint, grp string) USING parquet
+
+statement
+INSERT INTO nb_bound VALUES
+ (1, -100, 'g1'), (2, -5, 'g1'), (3, -50,
'g1'),
+ (4, -9223372036854775808, 'g2'), (5, 9223372036854775807, 'g2'), (6, 0,
'g2'),
+ (7, -2147483648, 'g3'), (8, 2147483647, 'g3')
+
+query
+SELECT grp, min_by(v, ord) FROM nb_bound GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Multiple min_by and value = ordering column
+-- ============================================================
+
+query
+SELECT grp, min_by(v, ord), min_by(ord, v) FROM nb_src GROUP BY grp ORDER BY
grp
+
+query
+SELECT grp, min_by(ord, ord) FROM nb_src GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Boolean value
+-- ============================================================
+
+statement
+CREATE TABLE nb_bool(v boolean, ord int, grp string) USING parquet
+
+statement
+INSERT INTO nb_bool VALUES
+ (true, 1, 'a'), (false, 3, 'a'), (true, 2, 'a'),
+ (false, 5, 'b'), (true, 4, 'b')
+
+query
+SELECT grp, min_by(v, ord) FROM nb_bool GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- max_by and min_by in the same query
+-- ============================================================
+
+query
+SELECT grp, max_by(v, ord), min_by(v, ord) FROM nb_src GROUP BY grp ORDER BY
grp
+
+-- ============================================================
+-- Signed zeros in the ordering
+--
+-- Spark compares the ordering with SQLOrderingUtil.compareDoubles, whose `if
(x == y) 0`
+-- short-circuit ties -0.0 with 0.0. Arrow's row format instead ranks -0.0
strictly below 0.0, so
+-- the native side canonicalizes the ordering column before encoding it.
+--
+-- The tied rows deliberately share the same value (v), which keeps this query
deterministic. A
+-- fixture that gave the tied rows different values could not be asserted
here: which tied row wins
+-- depends on the inter-partition merge order, and this table spans several
partitions, so min_by is
+-- genuinely non-deterministic in that case (as Spark documents). The
order-dependent behaviour is
+-- pinned in the Rust unit tests in max_min_by.rs instead, where row order is
explicit. This case
+-- still exercises the canonicalization path end to end.
+--
+-- Negative zero must be written as -0.0D: an unsuffixed -0.0 is a DecimalType
literal, and Decimal
+-- has no signed zero, so CAST(-0.0 AS DOUBLE) would silently store +0.0.
+-- ============================================================
+
+statement
+CREATE TABLE mnb_signed_zero(v int, ord double, grp string) USING parquet
+
+statement
+INSERT INTO mnb_signed_zero VALUES
+ (1, -0.0D, 'g1'), (1, 0.0D, 'g1'), (2, 5.0D, 'g1'),
+ (3, 0.0D, 'g2'), (3, -0.0D, 'g2'), (4, 5.0D, 'g2')
+
+query
+SELECT grp, min_by(v, ord) FROM mnb_signed_zero GROUP BY grp ORDER BY grp
+
+-- ============================================================
+-- Variable-length value or ordering falls back to Spark
+--
+-- See the equivalent section in max_by.sql for why this needs a
TypedImperativeAggregate
+-- alongside it: without one, Spark plans SortAggregate and Comet never sees
the aggregate at all,
+-- so the serde's type check would go untested.
+-- ============================================================
+
+statement
+CREATE TABLE mnb_varlen(v int, s string, grp string) USING parquet
+
+statement
+INSERT INTO mnb_varlen VALUES
+ (1, 'a', 'g1'), (2, 'b', 'g1'), (3, 'c', 'g2')
+
+query expect_fallback(Unsupported value data type)
+SELECT grp, min_by(s, v), percentile(v, 0.5) FROM mnb_varlen GROUP BY grp
ORDER BY grp
+
+query expect_fallback(Unsupported ordering data type)
+SELECT grp, min_by(v, s), percentile(v, 0.5) FROM mnb_varlen GROUP BY grp
ORDER BY grp
diff --git
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
index 7aa66fb0a9..2772d37ffb 100644
---
a/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
+++
b/spark/src/test/scala/org/apache/spark/sql/benchmark/CometAggregateExpressionBenchmark.scala
@@ -168,6 +168,28 @@ object CometAggregateExpressionBenchmark extends
CometBenchmarkBase {
"approx_percentile_double_high_card",
"SELECT approx_percentile(c_double, 0.5) FROM parquetV1Table GROUP BY
high_card_grp"))
+ // max_by / min_by run natively only when both the value and ordering are
fixed-length types (a
+ // variable-length value or ordering forces Spark's SortAggregate, which
Comet does not run).
+ private val maxMinByAggregates = List(
+ AggExprConfig("max_by_int", "SELECT max_by(c_int, c_long) FROM
parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "max_by_double",
+ "SELECT max_by(c_double, c_int) FROM parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "max_by_decimal",
+ "SELECT max_by(c_decimal, c_int) FROM parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "max_by_high_card",
+ "SELECT max_by(c_int, c_long) FROM parquetV1Table GROUP BY
high_card_grp"),
+ AggExprConfig("max_by_global", "SELECT max_by(c_int, c_long) FROM
parquetV1Table"),
+ AggExprConfig("min_by_int", "SELECT min_by(c_int, c_long) FROM
parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "min_by_double",
+ "SELECT min_by(c_double, c_int) FROM parquetV1Table GROUP BY grp"),
+ AggExprConfig(
+ "min_by_high_card",
+ "SELECT min_by(c_int, c_long) FROM parquetV1Table GROUP BY
high_card_grp"))
+
override def runCometBenchmark(mainArgs: Array[String]): Unit = {
val values = 1024 * 1024
@@ -192,7 +214,7 @@ object CometAggregateExpressionBenchmark extends
CometBenchmarkBase {
val allAggregates = basicAggregates ++ statisticalAggregates ++
bitwiseAggregates ++
multiKeyAggregates ++ multiAggregates ++ decimalAggregates ++
highCardinalityAggregates ++ percentileAggregates ++
approxPercentileAggregates ++
- approxCountDistinctAggregates
+ approxCountDistinctAggregates ++ maxMinByAggregates
allAggregates.foreach { config =>
runExpressionBenchmark(config.name, v, config.query,
config.extraCometConfigs)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]