MaxGekk commented on code in PR #59606:
URL: https://github.com/apache/spark/pull/59606#discussion_r4319319876


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetFilterOps.scala:
##########
@@ -62,6 +64,14 @@ private[parquet] trait ParquetFilterOps {
   /** Whether `value` (a non-null external filter value) is pushable for this 
type. */
   def acceptsValue(value: Any): Boolean
 
+  /**
+   * Whether a predicate with a value is pushable for a column of this 
encoding that is read as
+   * `readType`, the Spark type in the read schema. Parquet evaluates a pushed 
down predicate
+   * against the stored values, so this is false when the reader changes them 
for `readType`, e.g.
+   * truncates them to a lower precision. IsNull and IsNotNull are pushed down 
regardless.
+   */

Review Comment:
   The same truncation-on-read exists for `TIMESTAMP(NANOS)`: 
`TimestampNanosParquetOps.nanosConverter` and 
`TimestampNanosVectorUpdater(precision)` floor the stored nanos to the read 
precision via `epochNanosToTimestampNanos(value, p)` (see 
`ParquetTimestampNanosSuite` "SPARK-57102: explicit lower-precision read schema 
truncates sub-precision nanos"), but `ltzFilterOps`/`ntzFilterOps` still 
inherit this default. So a file storing `00:00:00.123456789` read as 
`TIMESTAMP_NTZ(7)` with `WHERE ts = CAST('... 00:00:00.1234567' AS 
TIMESTAMP_NTZ(7))` pushes `Eq(123456700)` against the stored `123456789` and 
returns no rows (same for `<=`, `<=>`, `IN`, V1 and V2, both readers).
   
   Could we wire the nanos ops here too (or in a follow-up)?
   
   ```scala
   override def acceptsReadType(readType: DataType): Boolean = readType match {
     case t: TimestampLTZNanosType => t.precision >= 9
     case t: TimestampNTZNanosType => t.precision >= 9
     case _ => true
   }
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilters.scala:
##########
@@ -125,6 +126,27 @@ class ParquetFilters(
       fieldNames: Array[String],
       fieldType: ParquetSchemaType)
 
+  // A map which contains the field names in `readSchema` and the Spark data 
types that the fields
+  // are read as, keyed like `nameToParquetField`. A data type can differ from 
the type that the
+  // file was written with, e.g. under a user-specified schema. Lazy because 
it is used only for a
+  // predicate with a value on a Types Framework field, see 
`valueCanMakeFilterOn`.
+  private lazy val nameToReadType: Map[String, DataType] = {
+    import 
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
+    def getLeafFields(
+        fields: Seq[StructField],
+        parentFieldNames: Seq[String] = Seq.empty): Seq[(String, DataType)] = {
+      fields.flatMap { f =>
+        val fieldNames = parentFieldNames :+ f.name
+        f.dataType match {
+          case s: StructType => getLeafFields(s.fields.toImmutableArraySeq, 
fieldNames)
+          case dataType => Seq(fieldNames.quoted -> dataType)
+        }
+      }
+    }
+    val readTypes = readSchema.toSeq.flatMap(s => 
getLeafFields(s.fields.toImmutableArraySeq)).toMap
+    if (caseSensitive) readTypes else CaseInsensitiveMap(readTypes)

Review Comment:
   nit: `nameToParquetField` drops names that are ambiguous under case folding, 
while `CaseInsensitiveMap(readTypes)` keeps whichever entry comes last. 
Near-zero likelihood since the analyzer rejects such schemas, but it would be 
nice for the two maps to apply the same dedup rule.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilters.scala:
##########
@@ -918,8 +940,15 @@ class ParquetFilters(
 
   // Parquet's type in the given file should be matched to the value's type
   // in the pushed filter in order to push down the filter to Parquet.
+  // Also, the type that a Types Framework field is read as should not make 
the reader change the
+  // stored values, e.g. truncate TIME(MICROS) values to TIME(3), because 
Parquet evaluates the
+  // pushed filter against the stored values. Otherwise, only IsNull and 
IsNotNull are pushed down.
   private def valueCanMakeFilterOn(name: String, value: Any): Boolean = {
-    valueMatchesParquetType(nameToParquetField(name).fieldType, value)
+    val fieldType = nameToParquetField(name).fieldType
+    valueMatchesParquetType(fieldType, value) && (value == null || (fieldType 
match {
+      case FrameworkFilterOps(ops) => 
nameToReadType.get(name).forall(ops.acceptsReadType)

Review Comment:
   nit: `FrameworkFilterOps.unapply` is a linear `filterOpsList.find`, and it 
now runs twice per non-null value (here and in `valueMatchesParquetType`), so 
`IN (v1..vN)` pays 2N lookups. Matching `FrameworkFilterOps(ops)` once and 
checking `ops.acceptsValue(value) && readType.forall(ops.acceptsReadType)` from 
the same binding would avoid that.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilters.scala:
##########
@@ -61,7 +61,8 @@ class ParquetFilters(
     pushDownInFilterThreshold: Int,
     caseSensitive: Boolean,
     datetimeRebaseSpec: RebaseSpec,
-    variantExtractionSchema: Option[StructType] = None) {
+    variantExtractionSchema: Option[StructType] = None,
+    readSchema: Option[StructType] = None) {

Review Comment:
   This defaults fail-open: with `readSchema = None`, or a filter column 
missing from the read schema, `nameToReadType.get(name).forall(...)` pushes the 
unsafe predicate. All three production callers pass it now, but a future caller 
that omits it silently reinstates the wrong-results bug with no compile-time 
signal. Could we make it a required parameter, and/or treat a column absent 
from `nameToReadType` as not pushable for a value predicate?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetFilterSuite.scala:
##########
@@ -2309,6 +2311,91 @@ abstract class ParquetFilterSuite extends ParquetTest 
with SharedSparkSession {
     }
   }
 
+  test("SPARK-60358: filter pushdown - time read with a lower precision than 
the stored value") {
+    // A TIME(6) value 12:00:00.1005 read as TIME(3) is truncated to 
12:00:00.100, while Parquet
+    // evaluates a pushed down predicate against the stored value. For 
example, a pushed down
+    // `t = 12:00:00.100` would skip the row group that holds the matching row.
+    import testImplicits._
+    def time(s: String): LocalTime = LocalTime.parse(s)
+    def time3(s: String): String = s"CAST('$s' AS TIME(3))"
+    def toTime3(dt: DataType): DataType = dt match {
+      case s: StructType => StructType(s.map(f => f.copy(dataType = 
toTime3(f.dataType))))
+      case _: TimeType => TimeType(3)
+      case other => other
+    }
+    val inputDF = Seq(Some(time("12:00:00.1005")), Some(time("12:00:01")), 
None).toDF()
+    withNestedDataFrame(inputDF).foreach { case (df, colName, resultFun) =>
+      withTempPath { dir =>
+        df.coalesce(1).write.parquet(dir.getCanonicalPath)
+        val readDF = 
spark.read.schema(toTime3(df.schema).asInstanceOf[StructType])
+          .parquet(dir.getCanonicalPath)
+        def check(condition: String, expected: Seq[String]): Unit = {
+          checkAnswer(readDF.where(condition), expected.map(s => 
Row(resultFun(time(s)))))
+        }
+        withAllParquetReaders {
+          check(s"$colName = ${time3("12:00:00.1")}", Seq("12:00:00.1"))
+          check(s"$colName <=> ${time3("12:00:00.1")}", Seq("12:00:00.1"))
+          check(s"$colName != ${time3("12:00:00.1")}", Seq("12:00:01"))
+          check(s"$colName < ${time3("12:00:01")}", Seq("12:00:00.1"))
+          check(s"$colName <= ${time3("12:00:00.1")}", Seq("12:00:00.1"))
+          check(s"$colName > ${time3("12:00:00.1")}", Seq("12:00:01"))
+          check(s"$colName >= ${time3("12:00:00.1")}", Seq("12:00:00.1", 
"12:00:01"))
+          Seq(1, 10).foreach { threshold =>
+            withSQLConf(SQLConf.PARQUET_FILTER_PUSHDOWN_INFILTERTHRESHOLD.key 
-> s"$threshold") {
+              check(s"$colName IN (${time3("12:00:00.1")}, 
${time3("12:00:02")})",
+                Seq("12:00:00.1"))
+            }
+          }
+          checkAnswer(readDF.where(s"$colName IS NULL"), Row(resultFun(null)))
+          check(s"$colName IS NOT NULL", Seq("12:00:00.1", "12:00:01"))
+        }
+      }
+    }
+  }
+
+  test("SPARK-60358: TIME(MICROS) predicates with a value are not pushed down 
for TIME(0-5)") {

Review Comment:
   Test gap related to the `TIMESTAMP(NANOS)` comment above: nothing checks 
that a `TIMESTAMP(NANOS)` column read as precision 7 or 8 withholds value 
predicates. A case mirroring this `TIME(0-5)` matrix for `TIMESTAMP(NANOS, 
true/false)` with `TimestampLTZNanosType`/`TimestampNTZNanosType(7|8)` 
expecting `createFilter(EqualTo(...)).isEmpty`, plus an end-to-end read of a 
9-digit stored value at precision 7, would guard it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala:
##########
@@ -163,6 +163,15 @@ private[ops] object TimeTypeParquetOps {
       override def acceptsValue(value: Any): Boolean =
         value.isInstanceOf[LocalTime] && 
isMicrosResolution(value.asInstanceOf[LocalTime])
 
+      // Below microsecond precision, the reader truncates the stored micros 
to the read precision
+      // (see TimeTypeParquetOps.newConverter and TimeVectorUpdater), while a 
pushed down predicate
+      // compares the stored micros. E.g. a stored 12:00:00.1005 is read as 
12:00:00.100 for
+      // TIME(3), so a pushed down `t = 12:00:00.100` would skip it.
+      override def acceptsReadType(readType: DataType): Boolean = readType 
match {

Review Comment:
   This withholds every value predicate for `TIME(0-5)`, but only `=`, `<=>`, 
`<=` and `IN` actually produce false negatives. For a literal `v` on the read 
grid (step = 10^(6-p) micros), a stored `s` truncates below `v` iff `s < v`, so 
`<` and `>=` are exact against the stored micros, and `>` / `!=` are only 
over-inclusive, which is harmless since Spark re-evaluates pushed Parquet 
filters after the scan. E.g. `t < CAST('12:00:01' AS TIME(3))` is a correct 
row-group-skipping predicate that this now drops.
   
   Since `acceptsReadType` sees neither the operator nor the value, maybe pass 
the operator (or decide per `make*` method) and keep the four safe ones? 
Optionally `=` could be rewritten to `[v, v+step)` and `<=` to `< v+step` so 
nothing is lost.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/parquet/ParquetScanBuilder.scala:
##########
@@ -85,7 +85,8 @@ case class ParquetScanBuilder(
         isCaseSensitive,
         // The rebase mode doesn't matter here because the filters are used to 
determine
         // whether they is convertible.
-        RebaseSpec(LegacyBehaviorPolicy.CORRECTED))
+        RebaseSpec(LegacyBehaviorPolicy.CORRECTED),
+        readSchema = Some(readDataSchema()))

Review Comment:
   nit: `readDataSchema()` is now computed twice in `pushDataFilters` (also for 
`SparkToParquetSchemaConverter.convert` above). It rebuilds a `StructType` each 
call, so binding it to a local `val` and passing it to both would be cheaper.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to