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

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new c58c54e068b5 fix(spark): reject negative factors in data skipping 
multiply/divide (#20457)
c58c54e068b5 is described below

commit c58c54e068b59df35d0d25e22873b3c57636b557
Author: voonhous <[email protected]>
AuthorDate: Wed Nov 4 10:42:13 2026 +0800

    fix(spark): reject negative factors in data skipping multiply/divide 
(#20457)
    
    * fix(spark): reject negative factors in data skipping multiply/divide
    
    The Multiply and Divide arms of the order-preserving matcher accepted
    any operand as the factor. A negative constant reverses the ordering,
    so the translated min/max bound could prune files holding matching
    rows (A in [-10, 0] with A * -1.0 < 5.0 translates to 10 < 5).
    
    Multiply and Divide now require a strictly positive numeric literal
    factor; anything else falls back to no pruning for that predicate.
    
    Refs #19445
    
    * fix(spark): reject Add operands that reference the column
    
    The Add arms matched the order-preserving side without inspecting the
    other operand, so A + A * -2 (= -A) slipped a negative factor past the
    Multiply check and pruned files holding matching rows. Require the
    other operand to be reference-free.
    
    * fix(spark): reject non-constant and infinite data skipping operands
    
    - Add arms now require a foldable other operand instead of a
      reference-free one: data skipping re-evaluates it against the
      min/max bounds, so A + rand() could prune files holding matching
      rows
    - Multiply/Divide factors must be finite: 0 * Infinity and
      Infinity / Infinity give NaN, which sorts above every number
    
    Refs #19445
---
 .../sql/BaseHoodieCatalystExpressionUtils.scala    | 34 +++++++++---
 .../org/apache/hudi/TestDataSkippingUtils.scala    | 63 ++++++++++++++++++++--
 .../hudi/TestHoodieCatalystExpressionUtils.scala   | 40 +++++++++++++-
 3 files changed, 125 insertions(+), 12 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala
index 4ea289bf496c..e929fabaf94f 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/BaseHoodieCatalystExpressionUtils.scala
@@ -18,9 +18,9 @@
 package org.apache.spark.sql
 
 import org.apache.spark.sql.HoodieSparkTypeUtils.isCastPreservingOrdering
-import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, 
AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, 
DateFormatClass, DateSub, Divide, Exp, Expm1, Expression, FromUnixTime, 
FromUTCTimestamp, Log, Log10, Log1p, Log2, Multiply, PredicateHelper, 
ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp}
+import org.apache.spark.sql.catalyst.expressions.{Add, Attribute, 
AttributeReference, AttributeSet, BitwiseOr, Cast, DateAdd, DateDiff, 
DateFormatClass, DateSub, Divide, Exp, Expm1, Expression, FromUnixTime, 
FromUTCTimestamp, Literal, Log, Log10, Log1p, Log2, Multiply, PredicateHelper, 
ShiftLeft, ShiftRight, ToUnixTimestamp, ToUTCTimestamp}
 import org.apache.spark.sql.execution.datasources.DataSourceStrategy
-import org.apache.spark.sql.types.DataType
+import org.apache.spark.sql.types.{DataType, Decimal}
 
 /**
  * Base implementation of [[HoodieCatalystExpressionUtils]] carrying the 
method bodies that are
@@ -81,11 +81,14 @@ abstract class BaseHoodieCatalystExpressionUtils extends 
HoodieCatalystExpressio
 
         // Math Expressions
         // Binary
-        case Add(OrderPreservingTransformation(attrRef), _, _) => Some(attrRef)
-        case Add(_, OrderPreservingTransformation(attrRef), _) => Some(attrRef)
-        case Multiply(OrderPreservingTransformation(attrRef), _, _) => 
Some(attrRef)
-        case Multiply(_, OrderPreservingTransformation(attrRef), _) => 
Some(attrRef)
-        case Divide(OrderPreservingTransformation(attrRef), _, _) => 
Some(attrRef)
+        // NOTE: The other operand must be a constant. Referencing the column 
could reverse the
+        //       ordering (for ex, A + A * -2 = -A), and a non-deterministic 
operand is re-evaluated
+        //       against the min/max bounds (for ex, A + rand())
+        case Add(OrderPreservingTransformation(attrRef), other, _) if 
other.foldable => Some(attrRef)
+        case Add(other, OrderPreservingTransformation(attrRef), _) if 
other.foldable => Some(attrRef)
+        case Multiply(OrderPreservingTransformation(attrRef), factor, _) if 
isPositiveNumericLiteral(factor) => Some(attrRef)
+        case Multiply(factor, OrderPreservingTransformation(attrRef), _) if 
isPositiveNumericLiteral(factor) => Some(attrRef)
+        case Divide(OrderPreservingTransformation(attrRef), divisor, _) if 
isPositiveNumericLiteral(divisor) => Some(attrRef)
         case BitwiseOr(OrderPreservingTransformation(attrRef), _) => 
Some(attrRef)
         case BitwiseOr(_, OrderPreservingTransformation(attrRef)) => 
Some(attrRef)
         // Unary
@@ -111,5 +114,22 @@ abstract class BaseHoodieCatalystExpressionUtils extends 
HoodieCatalystExpressio
         }
       }
     }
+
+    // Negative factors reverse the ordering and zero collapses it. Infinite 
factors can yield NaN
+    // (0 * Infinity, Infinity / Infinity), which sorts above every number. 
Non-literal factors are
+    // not matched since the optimizer folds constant factors to literals 
before data skipping runs
+    private def isPositiveNumericLiteral(expr: Expression): Boolean = expr 
match {
+      case Literal(value, _) => value match {
+        case b: Byte => b > 0
+        case s: Short => s > 0
+        case i: Int => i > 0
+        case l: Long => l > 0
+        case f: Float => f > 0 && !f.isInfinite
+        case d: Double => d > 0 && !d.isInfinite
+        case dec: Decimal => dec.toBigDecimal.signum > 0
+        case _ => false
+      }
+      case _ => false
+    }
   }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSkippingUtils.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSkippingUtils.scala
index 41c04681fd54..4ac23be2174b 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSkippingUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestDataSkippingUtils.scala
@@ -29,7 +29,7 @@ import org.apache.spark.sql.catalyst.expressions.{Expression, 
InSet, Not}
 import org.apache.spark.sql.catalyst.optimizer.OptimizeIn
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.catalyst.rules.Rule
-import org.apache.spark.sql.functions.{col, date_format, lower, upper}
+import org.apache.spark.sql.functions.{col, date_format, lit, lower, upper}
 import org.apache.spark.sql.hudi.DataSkippingUtils
 import org.apache.spark.sql.internal.SQLConf.SESSION_LOCAL_TIMEZONE
 import org.apache.spark.sql.types._
@@ -95,7 +95,8 @@ class TestDataSkippingUtils extends HoodieSparkClientTestBase 
with SparkAdapterS
     "testAdvancedLookupFilterExpressionsSource",
     "testCompositeFilterExpressionsSource",
     "testSupportedAndUnsupportedDataSkippingColumnsSource",
-    "testNonOrderPreservingTransformationsSource"
+    "testNonOrderPreservingTransformationsSource",
+    "testNegativeFactorSource"
   ))
   def testLookupFilterExpressions(sourceFilterExprStr: String, input: 
Seq[IndexRow], expectedOutput: Seq[String]): Unit = {
     // We have to fix the timezone to make sure all date-bound utilities output
@@ -335,7 +336,18 @@ object TestDataSkippingUtils {
             C_maxValue = new Timestamp(1648785048000L), // 04/01/2022
             C_nullCount = 0)
         ),
-        Seq("file_2"))
+        Seq("file_2")),
+      arguments(
+        // An infinite factor turns the zero min into NaN (0 * Infinity), 
which sorts above every
+        // number, so the translated bound A_minValue * Infinity <= Infinity 
is false and would
+        // wrongly prune file_1, although A = 1 gives Infinity <= Infinity. 
Built with lit() since
+        // SQL has no infinite double literal. Must fall back to always-true, 
keeping the file
+        sparkAdapter.getExpressionFromColumn(
+          col("A").cast(DoubleType) * lit(Double.PositiveInfinity) <= 
lit(Double.PositiveInfinity)),
+        Seq(
+          IndexRow("file_1", valueCount = 2, A_minValue = 0, A_maxValue = 1, 
A_nullCount = 0)
+        ),
+        Seq("file_1"))
     )
   }
 
@@ -576,6 +588,51 @@ object TestDataSkippingUtils {
     )
   }
 
+  def testNegativeFactorSource(): java.util.stream.Stream[Arguments] = {
+    java.util.stream.Stream.of(
+      // Multiplying by a negative constant reverses ordering: the translated 
bound
+      // A_minValue * -1.0 < 5.0 is 10 < 5 and would wrongly prune file_1, 
although A = 0 gives
+      // 0 < 5. Must fall back to always-true, keeping the file
+      arguments(
+        "CAST(A AS DOUBLE) * -1.0D < 5.0D",
+        Seq(
+          IndexRow("file_1", valueCount = 2, A_minValue = -10, A_maxValue = 0, 
A_nullCount = 0)
+        ),
+        Seq("file_1")),
+      // Dividing by a negative constant reverses ordering the same way
+      arguments(
+        "CAST(A AS DOUBLE) / -2.0D < 5.0D",
+        Seq(
+          IndexRow("file_1", valueCount = 2, A_minValue = -20, A_maxValue = 0, 
A_nullCount = 0)
+        ),
+        Seq("file_1")),
+      // Adding a term that references the column can carry a negative factor 
past the Multiply
+      // check: A + A * -2 = -A, so the translated bound A_minValue + 
A_minValue * -2 < -5 is
+      // 0 < -5 and would wrongly prune file_1, although A = 10 gives -10 < -5
+      arguments(
+        "A + A * -2 < -5",
+        Seq(
+          IndexRow("file_1", valueCount = 2, A_minValue = 0, A_maxValue = 10, 
A_nullCount = 0)
+        ),
+        Seq("file_1")),
+      // Multiplying and dividing by a positive constant still translate and 
prune
+      arguments(
+        "CAST(A AS DOUBLE) * 2.0D < 5.0D",
+        Seq(
+          IndexRow("file_1", valueCount = 2, A_minValue = 1, A_maxValue = 2, 
A_nullCount = 0),
+          IndexRow("file_2", valueCount = 2, A_minValue = 3, A_maxValue = 4, 
A_nullCount = 0)
+        ),
+        Seq("file_1")),
+      arguments(
+        "CAST(A AS DOUBLE) / 2.0D > 50.0D",
+        Seq(
+          IndexRow("file_1", valueCount = 2, A_minValue = 1, A_maxValue = 100, 
A_nullCount = 0),
+          IndexRow("file_2", valueCount = 2, A_minValue = 101, A_maxValue = 
200, A_nullCount = 0)
+        ),
+        Seq("file_2"))
+    )
+  }
+
   def testCompositeFilterExpressionsSource(): 
java.util.stream.Stream[Arguments] = {
     // NOTE: all timestamps in UTC
     java.util.stream.Stream.of(
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCatalystExpressionUtils.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCatalystExpressionUtils.scala
index d937ed51f7ca..fd063bac95ba 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCatalystExpressionUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieCatalystExpressionUtils.scala
@@ -17,8 +17,8 @@
 
 package org.apache.hudi
 
-import org.apache.spark.sql.catalyst.expressions.{Add, AttributeReference, 
BitwiseOr, Cast, DateAdd, DateDiff, DateSub, Divide, Exp, Expression, Literal, 
Log, Lower, Multiply, ParseToDate, ShiftLeft, Sqrt, Upper}
-import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
DataType, DateType, DecimalType, DoubleType, FloatType, IntegerType, LongType, 
ShortType, StringType, TimestampType}
+import org.apache.spark.sql.catalyst.expressions.{Add, AttributeReference, 
BitwiseOr, Cast, DateAdd, DateDiff, DateSub, Divide, Exp, Expression, Literal, 
Log, Lower, Multiply, ParseToDate, Rand, ShiftLeft, Sqrt, Upper}
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
DataType, DateType, Decimal, DecimalType, DoubleType, FloatType, IntegerType, 
LongType, ShortType, StringType, TimestampType}
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Assumptions.assumeTrue
 import org.junit.jupiter.api.Test
@@ -61,6 +61,42 @@ class TestHoodieCatalystExpressionUtils extends 
SparkAdapterSupport {
     assertEquals(Some(intAttr), matched(ShiftLeft(intAttr, Literal(1))))
   }
 
+  @Test
+  def testMultiplyDivideRequirePositiveLiteralFactor(): Unit = {
+    // Negative factors reverse ordering.
+    assertEquals(None, matched(Multiply(dblAttr, Literal(-1.0d))))
+    assertEquals(None, matched(Multiply(Literal(-1), intAttr)))
+    assertEquals(None, matched(Divide(dblAttr, Literal(Decimal(-2), 
DecimalType(1, 0)))))
+    // Zero collapses ordering and makes division undefined.
+    assertEquals(None, matched(Multiply(intAttr, Literal(0L))))
+    assertEquals(None, matched(Divide(dblAttr, Literal(0.0d))))
+    // NaN fails the positive check; typed null literals carry a null value.
+    assertEquals(None, matched(Multiply(dblAttr, Literal(Double.NaN))))
+    // Infinite factors can yield NaN (0 * Infinity, Infinity / Infinity).
+    assertEquals(None, matched(Multiply(dblAttr, 
Literal(Double.PositiveInfinity))))
+    assertEquals(None, matched(Multiply(Literal(Float.PositiveInfinity), 
dblAttr)))
+    assertEquals(None, matched(Divide(dblAttr, 
Literal(Double.PositiveInfinity))))
+    assertEquals(None, matched(Multiply(intAttr, Literal(null, IntegerType))))
+    // Non-literal factors are not matched, even when reference-free or the 
column itself.
+    assertEquals(None, matched(Multiply(intAttr, Add(Literal(1), Literal(1)))))
+    assertEquals(None, matched(Multiply(intAttr, intAttr)))
+    // Strictly positive literals of any numeric type match in the supported 
operand positions.
+    assertEquals(Some(dblAttr), matched(Multiply(dblAttr, Literal(2.5d))))
+    assertEquals(Some(longAttr), matched(Multiply(Literal(3L), longAttr)))
+    assertEquals(Some(dblAttr), matched(Divide(dblAttr, Literal(Decimal(2), 
DecimalType(1, 0)))))
+  }
+
+  @Test
+  def testAddRequiresConstantOtherOperand(): Unit = {
+    // A + A * -2 = -A reverses ordering, so an operand referencing the column 
must not match.
+    assertEquals(None, matched(Add(dblAttr, Multiply(dblAttr, 
Literal(-2.0d)))))
+    assertEquals(None, matched(Add(Multiply(dblAttr, Literal(-2.0d)), 
dblAttr)))
+    assertEquals(None, matched(Add(intAttr, intAttr)))
+    // Data skipping re-evaluates the operand against the min/max bounds, so 
it must be a constant.
+    assertEquals(None, matched(Add(dblAttr, Rand(Literal(0L)))))
+    assertEquals(None, matched(Add(Rand(Literal(0L)), dblAttr)))
+  }
+
   @Test
   def testUnaryMathTransformationsPreserveOrdering(): Unit = {
     assertEquals(Some(dblAttr), matched(Exp(dblAttr)))

Reply via email to