peter-toth commented on code in PR #60162:
URL: https://github.com/apache/spark/pull/60162#discussion_r4491015259


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2317,17 +2317,40 @@ object InferFiltersFromConstraints extends 
Rule[LogicalPlan]
   }
 
   private def inferNewFilter(plan: LogicalPlan, constraints: ExpressionSet): 
LogicalPlan = {
-    val newPredicates = constraints
+    val newPredicates = (constraints
       .union(constructIsNotNullConstraints(constraints, plan.output))
       .filter { c =>
         c.references.nonEmpty && c.references.subsetOf(plan.outputSet) && 
c.deterministic
-      } -- plan.constraints
+      } -- plan.constraints).toSeq.filter(canBeFilter)
     if (newPredicates.isEmpty) {
       plan
     } else {
       Filter(newPredicates.reduce(And), plan)
     }
   }
+
+  /**
+   * A constraint only says what holds on the rows an operator outputs. As a 
filter, it also runs
+   * on rows and values the query may never evaluate it on, e.g. `10 / x > 0` 
from `x = y AND
+   * 10 / y > 0` on a row where `x` is 0 and `y` is not, so only a constraint 
that cannot fail
+   * becomes a filter. Constant parts count as what they evaluate to, as they 
are folded right
+   * after this rule, and an uncorrelated subquery as a constant, as it runs 
once whichever rows
+   * use it. Callers pass the original expressions, because 
[[ExpressionSet.filter]] tests the
+   * canonicalized ones.
+   */
+  private def canBeFilter(constraint: Expression): Boolean = {
+    conf.legacyInferFailableFiltersFromConstraints || 
ExprUtils.canEvaluateUnconditionally(

Review Comment:
   **Finding 5.** This breaks an existing test outside the diff, "SPARK-58792: 
inferred literal-binding conjunct must not corrupt a copied Hive UDF" 
(`sql/hive/src/test/scala/org/apache/spark/sql/hive/execution/HiveUDFSuite.scala:1236`).
 Its query is:
   
   ```sql
   SELECT id FROM gt_table WHERE pt = DATE '2024-09-10' AND hive_gt(CAST(pt AS 
TIMESTAMP), created_at)
   ```
   
   The test asserts that the optimized `Filter` holds two copies of the Hive 
UDF. One is the original, and the other is the copy that 
`inferConstraintsFromLiteralBindings` makes with `pt` bound to the literal. 
That copy is a `HiveGenericUDF`, which `canEvaluateUnconditionally` rejects. On 
this head:
   
   ```
   build/sbt -Phive 'hive/testOnly *HiveUDFSuite -- -z SPARK-58792'
   - SPARK-58792: inferred literal-binding conjunct must not corrupt a copied 
Hive UDF *** FAILED ***
     
List(HiveGenericUDF#org.apache.hadoop.hive.ql.udf.generic.GenericUDFOPGreaterThan(cast(pt#17
 as timestamp),created_at#18)) had size 1 instead of expected size 2 
(HiveUDFSuite.scala:1270)
   ```
   
   The test guards against two diverging copies of one Hive UDF reaching 
execution, and only the legacy behavior still makes them. So it should keep 
running with the legacy conf. With its body wrapped in 
`withSQLConf(SQLConf.LEGACY_INFER_FAILABLE_FILTERS_FROM_CONSTRAINTS.key -> 
"true") { ... }`, both SPARK-58792 tests pass. Its leading comment should then 
say that this needs the legacy conf.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2317,17 +2317,40 @@ object InferFiltersFromConstraints extends 
Rule[LogicalPlan]
   }
 
   private def inferNewFilter(plan: LogicalPlan, constraints: ExpressionSet): 
LogicalPlan = {
-    val newPredicates = constraints
+    val newPredicates = (constraints
       .union(constructIsNotNullConstraints(constraints, plan.output))
       .filter { c =>
         c.references.nonEmpty && c.references.subsetOf(plan.outputSet) && 
c.deterministic
-      } -- plan.constraints
+      } -- plan.constraints).toSeq.filter(canBeFilter)
     if (newPredicates.isEmpty) {
       plan
     } else {
       Filter(newPredicates.reduce(And), plan)
     }
   }
+
+  /**
+   * A constraint only says what holds on the rows an operator outputs. As a 
filter, it also runs
+   * on rows and values the query may never evaluate it on, e.g. `10 / x > 0` 
from `x = y AND
+   * 10 / y > 0` on a row where `x` is 0 and `y` is not, so only a constraint 
that cannot fail
+   * becomes a filter. Constant parts count as what they evaluate to, as they 
are folded right
+   * after this rule, and an uncorrelated subquery as a constant, as it runs 
once whichever rows
+   * use it. Callers pass the original expressions, because 
[[ExpressionSet.filter]] tests the
+   * canonicalized ones.
+   */
+  private def canBeFilter(constraint: Expression): Boolean = {
+    conf.legacyInferFailableFiltersFromConstraints || 
ExprUtils.canEvaluateUnconditionally(
+      ConstantFolding.constantFolding(constraint, isConditionalBranch = 
true).transformUp {
+        case c: Cast if isNumericUpCast(c.child.dataType, c.dataType) => 
c.child
+        case i: InSubquery => 
i.values.map(IsNotNull(_)).reduce[Expression](And)

Review Comment:
   **Finding 7.** The doc comment says that only an uncorrelated subquery 
counts as a constant, but the code does not check that. It also drops the 
subquery's correlation condition (`joinCond`). In a `WHERE` clause or an inner 
join, a correlated subquery never gets here. `Filter.validConstraints` drops 
it, and an inner join pushes a one-sided condition down first. An outer join 
keeps it in `ON`, though, and `getAllConstraints` reads that condition 
directly. With ANSI mode on, `l.x` = {6}, `r.y` = {0, 6} and `s(a, b)` = {(0, 
1), (6, 1)}:
   
   ```sql
   SELECT * FROM l LEFT JOIN r ON l.x = r.y AND l.x IN (SELECT a FROM s WHERE 
s.b = 10 / l.x)
   ```
   
   With constraint propagation off, it returns `[6, null]`. On the base and on 
the head it fails with `DIVIDE_BY_ZERO`, with AQE on and off. The copy `r.y IN 
(SELECT a FROM s WHERE s.b = 10 / r.y)` passes the check as `IsNotNull(r.y)`, 
and it divides by zero on the row with `r.y = 0`.
   
   `EXISTS` has the opposite problem. There is no case for it here, so it keeps 
its plan expression and is rejected. For `l LEFT JOIN r ON l.x = r.y AND EXISTS 
(SELECT 1 FROM s WHERE s.a = l.x)`, the base adds a semi-join on `r`, and the 
head adds none, although `a = y` can't fail. Both return the same rows.
   
   Judging the correlation condition with the rest of the constraint fixes 
both. Together with the top-down rewrite of finding 8:
   
   ```scala
     private def canBeFilter(constraint: Expression): Boolean = {
       conf.legacyInferFailableFiltersFromConstraints || 
ExprUtils.canEvaluateUnconditionally(
         stripCannotFail(ConstantFolding.constantFolding(constraint, 
isConditionalBranch = true)))
     }
   
     // Top-down, so that each cast is judged by the type of its original input.
     private def stripCannotFail(e: Expression): Expression = e match {
       case c: Cast if isNumericUpCast(c.child.dataType, c.dataType) => 
stripCannotFail(c.child)
       case i: InSubquery =>
         (i.values.map(IsNotNull(_)) ++ i.query.joinCond).map(stripCannotFail)
           .reduce[Expression](And)
       case x: Exists =>
         
x.joinCond.map(stripCannotFail).foldLeft[Expression](Literal.TrueLiteral)(And)
       case s: ScalarSubquery if !s.isCorrelated => Literal.create(null, 
s.dataType)
       case other => other.mapChildren(stripCannotFail)
     }
   ```
   
   With it:
   - the `IN` query returns `[6, null]`,
   - the `EXISTS` query gets its semi-join back,
   - `InferFiltersFromConstraintsSuite`, the new `DataFrameSuite` test and the 
`*PlanStability*Suite`s (322 tests, ANSI mode on) pass.
   
   The `!s.isCorrelated` guard changes nothing today, because 
`RewriteCorrelatedScalarSubquery` runs before this batch. It makes the code 
match the comment, though. The comment should then say that a subquery counts 
by its correlation condition. "it runs once whichever rows use it" no longer 
explains why that is safe.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -1851,6 +1851,18 @@ object SQLConf {
     .booleanConf
     .createWithDefault(true)
 
+  val LEGACY_INFER_FAILABLE_FILTERS_FROM_CONSTRAINTS =
+    buildConf("spark.sql.legacy.inferFailableFiltersFromConstraints")
+      .internal()
+      .doc("When true, the query optimizer also infers a filter from data 
constraints when " +
+        "evaluating it can fail or is otherwise observable, such as `10 / x > 
0` from " +
+        "`x = y AND 10 / y > 0`, or a UDF. Such a filter runs on rows that the 
query does not " +
+        "evaluate the expression on, so the query can raise an error that it 
does not raise " +
+        "without the filter. When false, only a filter that cannot fail is 
inferred.")
+      .version("4.4.0")

Review Comment:
   **Finding 6.** `SparkConfigBindingPolicySuite` requires every new config to 
declare a binding policy, and its exceptions file is frozen. On this head:
   
   ```
   build/sbt -Phive 'hive/testOnly *SparkConfigBindingPolicySuite'
   - Config enforcement for bindingPolicy *** FAILED ***
     spark.sql.legacy.inferFailableFiltersFromConstraints 
(SparkConfigBindingPolicySuite.scala:66)
   ```
   
   `NOT_APPLICABLE` matches the neighboring 
`spark.sql.legacy.filterShortCircuitOnNullConjunct`. With it, the suite passes:
   
   ```suggestion
         .version("4.4.0")
         .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
   ```
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2317,17 +2317,40 @@ object InferFiltersFromConstraints extends 
Rule[LogicalPlan]
   }
 
   private def inferNewFilter(plan: LogicalPlan, constraints: ExpressionSet): 
LogicalPlan = {
-    val newPredicates = constraints
+    val newPredicates = (constraints
       .union(constructIsNotNullConstraints(constraints, plan.output))
       .filter { c =>
         c.references.nonEmpty && c.references.subsetOf(plan.outputSet) && 
c.deterministic
-      } -- plan.constraints
+      } -- plan.constraints).toSeq.filter(canBeFilter)
     if (newPredicates.isEmpty) {
       plan
     } else {
       Filter(newPredicates.reduce(And), plan)
     }
   }
+
+  /**
+   * A constraint only says what holds on the rows an operator outputs. As a 
filter, it also runs
+   * on rows and values the query may never evaluate it on, e.g. `10 / x > 0` 
from `x = y AND
+   * 10 / y > 0` on a row where `x` is 0 and `y` is not, so only a constraint 
that cannot fail
+   * becomes a filter. Constant parts count as what they evaluate to, as they 
are folded right
+   * after this rule, and an uncorrelated subquery as a constant, as it runs 
once whichever rows
+   * use it. Callers pass the original expressions, because 
[[ExpressionSet.filter]] tests the
+   * canonicalized ones.
+   */
+  private def canBeFilter(constraint: Expression): Boolean = {
+    conf.legacyInferFailableFiltersFromConstraints || 
ExprUtils.canEvaluateUnconditionally(
+      ConstantFolding.constantFolding(constraint, isConditionalBranch = 
true).transformUp {
+        case c: Cast if isNumericUpCast(c.child.dataType, c.dataType) => 
c.child

Review Comment:
   **Finding 8.** `transformUp` replaces the inner cast with its child before 
it looks at the outer one, so the outer cast is judged by the stripped child's 
type. Take `CAST(CAST(y AS FLOAT) AS INT)`. The inner `INT` to `FLOAT` cast is 
stripped, so the outer cast reads as `INT` to `INT`. `Cast.canUpCast` accepts 
that, and the constraint becomes `x > 0`. But `FLOAT` to `INT` overflows in 
ANSI mode. With ANSI mode on and `c(x, y)` = {(2147483647, 1), (1, 1)}:
   
   ```sql
   SELECT * FROM c WHERE x = y AND CAST(CAST(y AS FLOAT) AS INT) > 0
   ```
   
   With constraint propagation off, it returns `[1, 1]`. On the base and on the 
head it fails with `CAST_OVERFLOW`, with AQE on and off, because the inferred 
`CAST(CAST(x AS FLOAT) AS INT) > 0` runs on the row `(2147483647, 1)`.
   
   The top-down `stripCannotFail` in finding 7 judges each cast by its original 
input, and with it the query returns `[1, 1]`. If this PR lands after #60119 
and drops the cast stripping (finding 1), this goes away as well. Then 
`canEvaluateUnconditionally` sees each cast with its real input.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala:
##########
@@ -3022,6 +3022,21 @@ class DataFrameSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-60985: an inferred filter does not raise an error the query does 
not raise") {

Review Comment:
   **Finding 9.** The description says that a constant that fails to evaluate 
does not become a filter. That rejection fixes a literal binding that fails. 
With ANSI mode on and `lb(x, y)` = {(1, 1)}:
   
   ```sql
   SELECT * FROM lb a JOIN lb b ON a.x = b.x AND b.y = 10 / a.y WHERE a.y = 0
   ```
   
   The binding `a.y = 0` turns the join condition into `b.y = 10 / 0` for `b`. 
The base infers that filter and fails with `DIVIDE_BY_ZERO`, although no row 
has `a.y = 0`. The head returns no rows. No test pins this, though. With 
`isConditionalBranch = false` in `canBeFilter`, 
`InferFiltersFromConstraintsSuite` and this test still pass, while the query 
above fails again. This assertion in this test passes on the head, and fails 
with `DIVIDE_BY_ZERO` under that change:
   
   ```scala
           checkAnswer(
             df.as("a").join(df.as("b"), $"a.x" === $"b.x" && $"b.y" === 
lit(10L) / $"a.y")
               .where($"a.y" === 0),
             Seq.empty)
   ```
   



-- 
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