This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git


The following commit(s) were added to refs/heads/main by this push:
     new 424cfd8eb7 fix: reject unsupported Substrait aggregation phases 
(#25045)
424cfd8eb7 is described below

commit 424cfd8eb7e5d73229227324f02b39179babeeb1
Author: Goutam Adwant <[email protected]>
AuthorDate: Thu Sep 24 07:29:44 2026 +0000

    fix: reject unsupported Substrait aggregation phases (#25045)
    
    ## Which issue does this PR close?
    
    - Closes #24967.
    
    ## Rationale for this change
    
    The Substrait consumer treats explicit intermediate aggregate phases as
    complete calls. This can silently return final values when a plan
    requests intermediate state, or report an unrelated root-schema naming
    error.
    
    ## What changes are included in this PR?
    
    - Validate phases in aggregate and window expressions before translating
    their arguments.
    - Accept `INITIAL_TO_RESULT` and retain `UNSPECIFIED` for compatibility
    with existing DataFusion-produced plans.
    - Reject explicit intermediate phases and unknown protobuf enum values
    with clear errors.
    - Document the compatibility limitation: Substrait defines `UNSPECIFIED`
    as `INTERMEDIATE_TO_RESULT`, but the consumer cannot distinguish legacy
    DataFusion complete calls from unspecified intermediate-state calls
    produced elsewhere. Those calls remain accepted. The producer change in
    #25146 is complementary and is not duplicated here.
    
    ## What is the testing strategy for this PR?
    
    - Reproduced the original behavior: an `INITIAL_TO_INTERMEDIATE` average
    over values 1 and 2 returned 1.5 instead of intermediate state.
    - Tests cover supported phases, every explicit unsupported phase, rooted
    and unrooted aggregates, unknown binary-protobuf enum values, and actual
    window output.
    - The full Substrait integration target passes: 213 tests passed, with
    six existing tests ignored.
    - The extended workspace suite passes 11,267 Rust tests, with eight
    existing tests ignored, and all 511 SQL logic-test files using an
    explicit four-thread limit. An initial default-concurrency run failed
    one ordered-aggregate spill test with a test-memory-pool exhaustion; the
    complete limited-concurrency rerun passed without source changes.
    - `cargo clippy --all-targets --all-features -- -D warnings` and the
    complete `./dev/rust_lint.sh` pass, including strict workspace
    documentation checks.
    
    ## Are there any user-facing changes?
    
    Plans with explicit unsupported aggregate or window phases now fail
    instead of being interpreted as complete calls. Existing
    unspecified-phase plans remain accepted, including the ambiguity
    described above. No public Rust API changes are included.
    Intermediate-state execution and the separate AVG output-type mismatch
    are not addressed here.
---
 .../consumer/expr/aggregate_function.rs            |  22 ++-
 .../logical_plan/consumer/expr/window_function.rs  |   2 +
 .../substrait/tests/cases/aggregation_tests.rs     | 193 ++++++++++++++++++++-
 3 files changed, 214 insertions(+), 3 deletions(-)

diff --git 
a/datafusion/substrait/src/logical_plan/consumer/expr/aggregate_function.rs 
b/datafusion/substrait/src/logical_plan/consumer/expr/aggregate_function.rs
index 096eef7ae3..9e020325ae 100644
--- a/datafusion/substrait/src/logical_plan/consumer/expr/aggregate_function.rs
+++ b/datafusion/substrait/src/logical_plan/consumer/expr/aggregate_function.rs
@@ -18,11 +18,28 @@
 use crate::logical_plan::consumer::{
     SubstraitConsumer, from_substrait_func_args, substrait_fun_name,
 };
-use datafusion::common::{DFSchema, ScalarValue, not_impl_datafusion_err, 
plan_err};
+use datafusion::common::{
+    DFSchema, ScalarValue, not_impl_datafusion_err, not_impl_err, 
plan_datafusion_err,
+    plan_err,
+};
 use datafusion::execution::FunctionRegistry;
 use datafusion::logical_expr::{Expr, SortExpr, expr};
 use std::sync::Arc;
-use substrait::proto::AggregateFunction;
+use substrait::proto::{AggregateFunction, AggregationPhase};
+
+pub(super) fn validate_aggregation_phase(phase: i32) -> 
datafusion::common::Result<()> {
+    match AggregationPhase::try_from(phase)
+        .map_err(|e| plan_datafusion_err!("Invalid aggregation phase {phase}: 
{e}"))?
+    {
+        // Substrait defines UNSPECIFIED as INTERMEDIATE_TO_RESULT. Accept it 
as a
+        // complete call only for compatibility with existing 
DataFusion-produced
+        // aggregate and window plans. This exception also accepts unspecified
+        // intermediate-state calls from other producers; their intent cannot 
be
+        // distinguished here. Explicit intermediate phases remain unsupported.
+        AggregationPhase::Unspecified | AggregationPhase::InitialToResult => 
Ok(()),
+        phase => not_impl_err!("Unsupported aggregation phase: {}", 
phase.as_str_name()),
+    }
+}
 
 /// Convert Substrait AggregateFunction to DataFusion Expr
 pub async fn from_substrait_agg_func(
@@ -33,6 +50,7 @@ pub async fn from_substrait_agg_func(
     order_by: Vec<SortExpr>,
     distinct: bool,
 ) -> datafusion::common::Result<Arc<Expr>> {
+    validate_aggregation_phase(f.phase)?;
     let Some(fn_signature) = consumer
         .get_extensions()
         .functions
diff --git 
a/datafusion/substrait/src/logical_plan/consumer/expr/window_function.rs 
b/datafusion/substrait/src/logical_plan/consumer/expr/window_function.rs
index d39b325a54..bd1a63b704 100644
--- a/datafusion/substrait/src/logical_plan/consumer/expr/window_function.rs
+++ b/datafusion/substrait/src/logical_plan/consumer/expr/window_function.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use super::aggregate_function::validate_aggregation_phase;
 use crate::logical_plan::consumer::{
     SubstraitConsumer, from_substrait_func_args, from_substrait_rex_vec,
     from_substrait_sorts, substrait_fun_name,
@@ -39,6 +40,7 @@ pub async fn from_window_function(
     window: &WindowFunction,
     input_schema: &DFSchema,
 ) -> datafusion::common::Result<Expr> {
+    validate_aggregation_phase(window.phase)?;
     let Some(fn_signature) = consumer
         .get_extensions()
         .functions
diff --git a/datafusion/substrait/tests/cases/aggregation_tests.rs 
b/datafusion/substrait/tests/cases/aggregation_tests.rs
index e572023f17..8af4cb7fa5 100644
--- a/datafusion/substrait/tests/cases/aggregation_tests.rs
+++ b/datafusion/substrait/tests/cases/aggregation_tests.rs
@@ -20,11 +20,202 @@
 #[cfg(test)]
 mod tests {
     use crate::utils::test::{add_plan_schemas_to_ctx, read_json};
-    use datafusion::common::Result;
+    use datafusion::arrow::array::record_batch;
+    use datafusion::arrow::datatypes as arrow_schema;
+    use datafusion::common::{Result, ScalarValue, TableReference};
     use datafusion::dataframe::DataFrame;
     use datafusion::prelude::SessionContext;
     use datafusion_substrait::logical_plan::consumer::from_substrait_plan;
     use insta::assert_snapshot;
+    use prost::Message;
+    use serde_json::json;
+    use substrait::proto::{AggregationPhase, Plan, expression, plan_rel, rel};
+
+    fn aggregate_phase_plan(phase: i32, rooted: bool) -> Plan {
+        let i64_type = json!({"i64": {"nullability": "NULLABILITY_REQUIRED"}});
+        let output_type = if phase == AggregationPhase::InitialToIntermediate 
as i32 {
+            json!({"struct": {"types": [i64_type.clone(), i64_type.clone()], 
"nullability": "NULLABILITY_REQUIRED"}})
+        } else {
+            json!({"fp64": {"nullability": "NULLABILITY_NULLABLE"}})
+        };
+        let rel = json!({"aggregate": {
+            "input": {"read": {
+                "baseSchema": {"names": ["c0"], "struct": {
+                    "types": [i64_type], "nullability": "NULLABILITY_REQUIRED"
+                }},
+                "namedTable": {"names": ["t_avg"]}
+            }},
+            "measures": [{"measure": {
+                "functionReference": 1,
+                "outputType": output_type,
+                "arguments": [{"value": {"selection": {
+                    "directReference": {"structField": {}}, "rootReference": {}
+                }}}]
+            }}]
+        }});
+        let relation = if rooted {
+            let names = if phase == AggregationPhase::InitialToIntermediate as 
i32 {
+                vec!["average", "sum", "count"]
+            } else {
+                vec!["average"]
+            };
+            json!({"root": {"input": rel, "names": names}})
+        } else {
+            json!({"rel": rel})
+        };
+        let mut plan: Plan = serde_json::from_value(json!({
+            "extensions": [{"extensionFunction": {"functionAnchor": 1, "name": 
"avg:i64"}}],
+            "relations": [relation]
+        }))
+        .unwrap();
+        let relation = match plan.relations[0].rel_type.as_mut().unwrap() {
+            plan_rel::RelType::Rel(rel) => rel,
+            plan_rel::RelType::Root(root) => root.input.as_mut().unwrap(),
+        };
+        let Some(rel::RelType::Aggregate(aggregate)) = 
relation.rel_type.as_mut() else {
+            panic!("expected aggregate");
+        };
+        aggregate.measures[0].measure.as_mut().unwrap().phase = phase;
+        Plan::decode(plan.encode_to_vec().as_slice()).unwrap()
+    }
+
+    async fn aggregate_phase_context() -> Result<SessionContext> {
+        let ctx = SessionContext::new();
+        ctx.sql("CREATE TABLE t_avg AS SELECT column1 AS c0 FROM (VALUES 
(1::BIGINT), (2::BIGINT))")
+            .await?
+            .collect()
+            .await?;
+        Ok(ctx)
+    }
+
+    #[tokio::test]
+    async fn aggregate_supported_phases() -> Result<()> {
+        let ctx = aggregate_phase_context().await?;
+        for phase in [
+            AggregationPhase::Unspecified,
+            AggregationPhase::InitialToResult,
+        ] {
+            for rooted in [false, true] {
+                let proto = aggregate_phase_plan(phase as i32, rooted);
+                let plan = from_substrait_plan(&ctx.state(), &proto).await?;
+                let batches = DataFrame::new(ctx.state(), 
plan).collect().await?;
+                assert_eq!(
+                    ScalarValue::try_from_array(batches[0].column(0), 0)?,
+                    ScalarValue::Float64(Some(1.5))
+                );
+            }
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn aggregate_unsupported_phases() -> Result<()> {
+        let ctx = aggregate_phase_context().await?;
+        for phase in [
+            AggregationPhase::InitialToIntermediate,
+            AggregationPhase::IntermediateToIntermediate,
+            AggregationPhase::IntermediateToResult,
+        ] {
+            for rooted in [false, true] {
+                let proto = aggregate_phase_plan(phase as i32, rooted);
+                let err = from_substrait_plan(&ctx.state(), 
&proto).await.unwrap_err();
+                assert!(
+                    err.to_string().contains(&format!(
+                        "Unsupported aggregation phase: {}",
+                        phase.as_str_name()
+                    )),
+                    "{err}"
+                );
+            }
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn aggregate_invalid_phase() -> Result<()> {
+        let ctx = aggregate_phase_context().await?;
+        for phase in [-1, 12345] {
+            let proto = aggregate_phase_plan(phase, false);
+            let err = from_substrait_plan(&ctx.state(), 
&proto).await.unwrap_err();
+            assert!(
+                err.to_string()
+                    .contains(&format!("Invalid aggregation phase {phase}")),
+                "{err}"
+            );
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn window_aggregation_phases() -> Result<()> {
+        let original =
+            
read_json("tests/testdata/test_plans/select_window_count.substrait.json");
+        let ctx = SessionContext::new();
+        ctx.register_batch(
+            TableReference::bare("DATA"),
+            record_batch!(
+                ("D", Int32, [1, 2, 3]),
+                ("PART", Int32, [1, 1, 1]),
+                ("ORD", Int32, [1, 2, 3])
+            )?,
+        )?;
+        for phase in [
+            AggregationPhase::Unspecified as i32,
+            AggregationPhase::InitialToResult as i32,
+            AggregationPhase::InitialToIntermediate as i32,
+            AggregationPhase::IntermediateToIntermediate as i32,
+            AggregationPhase::IntermediateToResult as i32,
+            12345,
+        ] {
+            let mut proto = original.clone();
+            let Some(plan_rel::RelType::Root(root)) =
+                proto.relations[0].rel_type.as_mut()
+            else {
+                panic!("expected root");
+            };
+            let Some(rel::RelType::Project(project)) =
+                root.input.as_mut().unwrap().rel_type.as_mut()
+            else {
+                panic!("expected projection");
+            };
+            let Some(expression::RexType::WindowFunction(window)) =
+                project.expressions[0].rex_type.as_mut()
+            else {
+                panic!("expected window function");
+            };
+            window.phase = phase;
+            let proto = 
Plan::decode(proto.encode_to_vec().as_slice()).unwrap();
+            let result = from_substrait_plan(&ctx.state(), &proto).await;
+            if matches!(
+                AggregationPhase::try_from(phase),
+                Ok(AggregationPhase::Unspecified | 
AggregationPhase::InitialToResult)
+            ) {
+                let batches = DataFrame::new(ctx.state(), 
result?).collect().await?;
+                datafusion::assert_batches_sorted_eq!(
+                    [
+                        "+-----------+",
+                        "| LEAD_EXPR |",
+                        "+-----------+",
+                        "| 2         |",
+                        "| 3         |",
+                        "| 3         |",
+                        "+-----------+"
+                    ],
+                    &batches
+                );
+            } else {
+                let err = result.unwrap_err();
+                let expected = match AggregationPhase::try_from(phase) {
+                    Ok(phase) => {
+                        format!("Unsupported aggregation phase: {}", 
phase.as_str_name())
+                    }
+                    Err(_) => format!("Invalid aggregation phase {phase}"),
+                };
+                assert!(err.to_string().contains(&expected), "{err}");
+            }
+        }
+        Ok(())
+    }
 
     #[tokio::test]
     async fn no_grouping_set() -> Result<()> {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to