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

MaxGekk pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 254cbd96fa60 [SPARK-57568][SQL] Support TimeType in Parquet/ORC 
aggregate push-down
254cbd96fa60 is described below

commit 254cbd96fa606792cbdb2a33ae60037190ab3143
Author: Maxim Gekk <[email protected]>
AuthorDate: Tue Jun 30 00:24:57 2026 +0200

    [SPARK-57568][SQL] Support TimeType in Parquet/ORC aggregate push-down
    
    ### What changes were proposed in this pull request?
    Enable MIN/MAX/COUNT aggregate push-down over `TIME` columns for the 
Parquet and ORC data sources, computed from file-footer statistics.
    
    - Add `TimeType` to the MIN/MAX type allow-list in 
`AggregatePushDownUtils.getSchemaForPushedAggregation`. This is shared, 
engine-agnostic code called by both `ParquetScanBuilder` and `OrcScanBuilder`, 
so the single change enables push-down eligibility for both engines at once.
    - Add a `TimeType` case to `OrcUtils.getMinMaxFromColumnStatistics`. ORC 
stores `TIME` as a `LONG`, so its statistics are `IntegerColumnStatistics`; the 
min/max value is wrapped in a `LongWritable` and converted back to the Spark 
`TimeType` by `OrcDeserializer`.
    - No Parquet reader change is needed: `TIME` is stored as Parquet `INT64`, 
so the existing `INT64` branch in `ParquetUtils.createAggInternalRowFromFooter` 
feeds the footer stat into a `ParquetRowConverter` built from the footer 
`PrimitiveType`, which carries the `TIME(MICROS)`/`TIME(NANOS)` logical 
annotation and maps to `TimeType`.
    
    The columnar conversion 
(`AggregatePushDownUtils.convertAggregatesRowToBatch` via 
`RowToColumnConverter`) already supports `TimeType` (SPARK-54203), so the 
columnar path works for `TIME`.
    
    ### Why are the changes needed?
    This is a sub-task of SPARK-57550 (extending support for the `TIME` data 
type). Aggregate push-down lets Parquet/ORC answer `MIN`/`MAX`/`COUNT` from 
footer statistics without reading and aggregating `TIME` data at the Spark 
layer.
    
    ### Does this PR introduce _any_ user-facing change?
    No. This is an internal optimization on the aggregate push-down path; query 
results are unchanged.
    
    ### How was this patch tested?
    Added tests to the shared `FileSourceAggregatePushDownSuite` trait, which 
is extended by `ParquetV1/V2AggregatePushDownSuite` and 
`OrcV1/V2AggregatePushDownSuite`, so each test exercises all four engines:
    - Positive: `MIN`/`MAX`/`COUNT(col)`/`COUNT(*)` push-down over a `TIME` 
column at precisions 0, 6, 7, and 9, covering both the Parquet micros 
(precision <= 6) and nanos (precision >= 7) storage paths, with a null row so 
`COUNT(col)` and `COUNT(*)` differ.
    - Negative: a data filter on the `TIME` column, an aggregate over a 
non-column expression, and push-down disabled by config -- all asserting the 
aggregate is not pushed.
    
    Ran:
    ```
    build/sbt 'sql/testOnly *ParquetV1AggregatePushDownSuite 
*ParquetV2AggregatePushDownSuite *OrcV1AggregatePushDownSuite 
*OrcV2AggregatePushDownSuite'
    ```
    All 92 tests pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Generated-by: Cursor (Claude Opus 4.8)
    
    Closes #56846 from MaxGekk/time-aggr-pushdown.
    
    Authored-by: Maxim Gekk <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
    (cherry picked from commit 9c86d4e48908137e92b5c5a25e9ce4ef0915a519)
    Signed-off-by: Max Gekk <[email protected]>
---
 .../datasources/AggregatePushDownUtils.scala       |   9 +-
 .../sql/execution/datasources/orc/OrcUtils.scala   |   4 +
 .../FileSourceAggregatePushDownSuite.scala         | 128 ++++++++++++++++++++-
 3 files changed, 138 insertions(+), 3 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/AggregatePushDownUtils.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/AggregatePushDownUtils.scala
index 97ee3cd661b3..db3a34db05ac 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/AggregatePushDownUtils.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/AggregatePushDownUtils.scala
@@ -24,7 +24,7 @@ import 
org.apache.spark.sql.connector.expressions.aggregate.{AggregateFunc, Aggr
 import org.apache.spark.sql.execution.RowToColumnConverter
 import org.apache.spark.sql.execution.datasources.v2.V2ColumnUtils
 import org.apache.spark.sql.execution.vectorized.{OffHeapColumnVector, 
OnHeapColumnVector}
-import org.apache.spark.sql.types.{BooleanType, ByteType, DateType, 
DoubleType, FloatType, IntegerType, LongType, ShortType, StructField, 
StructType}
+import org.apache.spark.sql.types.{BooleanType, ByteType, DateType, 
DoubleType, FloatType, IntegerType, LongType, ShortType, StructField, 
StructType, TimeType}
 import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
 
 /**
@@ -67,6 +67,8 @@ object AggregatePushDownUtils {
       val structField = getStructFieldForCol(columnName)
 
       structField.dataType match {
+        // Push down min/max only for primitive types whose footer statistics 
are
+        // well-defined and directly comparable. Notable exclusions:
         // not push down complex type
         // not push down Timestamp because INT96 sort order is undefined,
         // Parquet doesn't return statistics for INT96
@@ -74,8 +76,11 @@ object AggregatePushDownUtils {
         // (https://issues.apache.org/jira/browse/PARQUET-1685), Parquet Binary
         // could be Spark StringType, BinaryType or DecimalType.
         // not push down for ORC with same reason.
+        // TIME is included because it is INT64/LONG-backed with a defined sort
+        // order, so real min/max statistics exist in the footer for both 
Parquet
+        // and ORC.
         case BooleanType | ByteType | ShortType | IntegerType
-             | LongType | FloatType | DoubleType | DateType =>
+             | LongType | FloatType | DoubleType | DateType | _: TimeType =>
           finalSchema = finalSchema.add(structField.copy(s"$aggType(" + 
structField.name + ")"))
           true
         case _ =>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcUtils.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcUtils.scala
index c1ed3864b63a..e20216d69863 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcUtils.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/orc/OrcUtils.scala
@@ -506,6 +506,10 @@ object OrcUtils extends Logging {
             case ShortType => new ShortWritable(value.toShort)
             case IntegerType => new IntWritable(value.toInt)
             case LongType => new LongWritable(value)
+            // ORC stores TIME as LONG (nanos-of-day), so its stats are
+            // IntegerColumnStatistics. OrcDeserializer converts the 
LongWritable
+            // back to the Spark TimeType.
+            case _: TimeType => new LongWritable(value)
             case _ => throw new IllegalArgumentException(
               s"getMinMaxFromColumnStatistics should not take type $dataType " 
+
               "for IntegerColumnStatistics")
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileSourceAggregatePushDownSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileSourceAggregatePushDownSuite.scala
index c95842fc7306..ac677876c238 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileSourceAggregatePushDownSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileSourceAggregatePushDownSuite.scala
@@ -18,6 +18,7 @@
 package org.apache.spark.sql.execution.datasources
 
 import java.sql.{Date, Timestamp}
+import java.time.LocalTime
 
 import org.apache.spark.{SparkConf, SparkUnsupportedOperationException}
 import org.apache.spark.sql.{DataFrame, ExplainSuiteHelper, Row}
@@ -29,7 +30,7 @@ import 
org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation
 import org.apache.spark.sql.functions.min
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
-import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
DateType, Decimal, DecimalType, DoubleType, FloatType, IntegerType, LongType, 
ShortType, StringType, StructField, StructType, TimestampType}
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
DateType, Decimal, DecimalType, DoubleType, FloatType, IntegerType, LongType, 
ShortType, StringType, StructField, StructType, TimestampType, TimeType}
 import org.apache.spark.tags.SlowSQLTest
 
 /**
@@ -571,6 +572,131 @@ trait FileSourceAggregatePushDownSuite
       Seq(nullRow), Seq(nullRowWithOutTSAndBinary), Seq(zeroCount))
   }
 
+  private def testTimeAggPushDown(
+      precision: Int,
+      times: Seq[LocalTime],
+      expectedMin: LocalTime,
+      expectedMax: LocalTime): Unit = {
+    val schema = StructType(Seq(StructField("TimeCol", TimeType(precision))))
+    // One null row in addition to the non-null `times`, so COUNT(TimeCol) 
excludes it but
+    // COUNT(*) includes it.
+    val rows = times.map(Row(_)) :+ Row(null)
+    val rdd = sparkContext.parallelize(rows)
+    withTempPath { file =>
+      spark.createDataFrame(rdd, 
schema).write.format(format).save(file.getCanonicalPath)
+      withTempView("time_test") {
+        spark.read.format(format).load(file.getCanonicalPath)
+          .createOrReplaceTempView("time_test")
+        Seq("false", "true").foreach { enableVectorizedReader =>
+          withSQLConf(aggPushDownEnabledKey -> "true",
+            vectorizedReaderEnabledKey -> enableVectorizedReader) {
+            val df = sql(
+              "SELECT min(TimeCol), max(TimeCol), count(TimeCol), count(*) 
FROM time_test")
+            checkPushedInfo(df,
+              "PushedAggregation: [MIN(TimeCol), MAX(TimeCol), COUNT(TimeCol), 
COUNT(*)]")
+            checkAnswer(df,
+              Seq(Row(expectedMin, expectedMax, times.length, times.length + 
1)))
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57568: aggregate push down - TIME of different precisions") {
+    // Parquet stores TIME with precision 0..6 as INT64 TIME(MICROS) and 
precision 7..9 as
+    // INT64 TIME(NANOS); ORC stores TIME as the raw nanos-of-day LONG. To 
keep a single shared
+    // expectation valid across both engines, each value is chosen to be 
exactly representable at
+    // its column's precision, so no engine-specific truncation can diverge.
+    // precision 0 (seconds)
+    testTimeAggPushDown(0,
+      Seq(LocalTime.of(1, 2, 3), LocalTime.of(23, 59, 59), LocalTime.of(10, 
30, 0)),
+      LocalTime.of(1, 2, 3), LocalTime.of(23, 59, 59))
+    // precision 6 (microseconds)
+    testTimeAggPushDown(6,
+      Seq(LocalTime.of(1, 2, 3, 123456000), LocalTime.of(23, 59, 59, 
999999000),
+        LocalTime.of(10, 30, 0, 500000)),
+      LocalTime.of(1, 2, 3, 123456000), LocalTime.of(23, 59, 59, 999999000))
+    // precision 7 (hundreds of nanoseconds)
+    testTimeAggPushDown(7,
+      Seq(LocalTime.of(1, 2, 3, 123456700), LocalTime.of(23, 59, 59, 
999999900),
+        LocalTime.of(10, 30, 0, 100)),
+      LocalTime.of(1, 2, 3, 123456700), LocalTime.of(23, 59, 59, 999999900))
+    // precision 9 (nanoseconds)
+    testTimeAggPushDown(9,
+      Seq(LocalTime.of(1, 2, 3, 123456789), LocalTime.of(23, 59, 59, 
999999999),
+        LocalTime.of(10, 30, 0, 1)),
+      LocalTime.of(1, 2, 3, 123456789), LocalTime.of(23, 59, 59, 999999999))
+  }
+
+  test("SPARK-57568: aggregate push down - TIME over an empty file") {
+    // Aggregating a TIME column over zero rows: MIN/MAX return NULL and COUNT 
returns 0 on every
+    // engine. This pins the no-data path, which the precision test above 
(always >= 1 row) does
+    // not reach. An all-null but non-empty file is intentionally not asserted 
here: that is
+    // pre-existing, type-agnostic behavior shared by all push-down types 
(Parquet rejects MIN/MAX
+    // push-down on an all-null block while ORC returns NULL), unchanged by 
this PR.
+    Seq(6, 9).foreach { precision =>
+      val schema = StructType(Seq(StructField("TimeCol", TimeType(precision))))
+      val rdd = sparkContext.parallelize(Seq.empty[Row])
+      withTempPath { file =>
+        spark.createDataFrame(rdd, 
schema).write.format(format).save(file.getCanonicalPath)
+        withTempView("time_empty") {
+          spark.read.format(format).load(file.getCanonicalPath)
+            .createOrReplaceTempView("time_empty")
+          Seq("false", "true").foreach { enableVectorizedReader =>
+            withSQLConf(aggPushDownEnabledKey -> "true",
+              vectorizedReaderEnabledKey -> enableVectorizedReader) {
+              val df = sql(
+                "SELECT min(TimeCol), max(TimeCol), count(TimeCol), count(*) 
FROM time_empty")
+              checkPushedInfo(df,
+                "PushedAggregation: [MIN(TimeCol), MAX(TimeCol), 
COUNT(TimeCol), COUNT(*)]")
+              checkAnswer(df, Seq(Row(null, null, 0, 0)))
+            }
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-57568: aggregate not push down - TIME with filter or 
expression") {
+    val schema = StructType(Seq(StructField("TimeCol", TimeType(6))))
+    val rows = Seq(
+      Row(LocalTime.of(1, 2, 3)),
+      Row(LocalTime.of(23, 59, 59)),
+      Row(LocalTime.of(10, 30, 0)))
+    val rdd = sparkContext.parallelize(rows)
+    withTempPath { file =>
+      spark.createDataFrame(rdd, 
schema).write.format(format).save(file.getCanonicalPath)
+      withTempView("time_neg_test") {
+        spark.read.format(format).load(file.getCanonicalPath)
+          .createOrReplaceTempView("time_neg_test")
+        withSQLConf(aggPushDownEnabledKey -> "true") {
+          // A data filter on a non-partition column prevents push down.
+          val withFilter =
+            sql("SELECT min(TimeCol) FROM time_neg_test WHERE TimeCol > 
TIME'05:00:00'")
+          checkPushedInfo(withFilter, "PushedAggregation: []")
+          checkAnswer(withFilter, Seq(Row(LocalTime.of(10, 30, 0))))
+
+          // Aggregating over an expression (not a plain column) prevents push 
down. The two CASE
+          // branches differ so the optimizer cannot fold the expression back 
into a column.
+          val withExpr = sql(
+            "SELECT max(CASE WHEN TimeCol > TIME'05:00:00' THEN TimeCol ELSE 
TIME'00:00:00' END) " +
+              "FROM time_neg_test")
+          checkPushedInfo(withExpr, "PushedAggregation: []")
+          checkAnswer(withExpr, Seq(Row(LocalTime.of(23, 59, 59))))
+
+          // Aggregate push down disabled by config.
+          withSQLConf(aggPushDownEnabledKey -> "false") {
+            val disabled =
+              sql("SELECT min(TimeCol), max(TimeCol), count(TimeCol) FROM 
time_neg_test")
+            checkPushedInfo(disabled, "PushedAggregation: []")
+            checkAnswer(disabled,
+              Seq(Row(LocalTime.of(1, 2, 3), LocalTime.of(23, 59, 59), 3)))
+          }
+        }
+      }
+    }
+  }
+
   test("column name case sensitivity") {
     Seq("false", "true").foreach { enableVectorizedReader =>
       withSQLConf(aggPushDownEnabledKey -> "true",


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

Reply via email to