HyukjinKwon commented on code in PR #59258:
URL: https://github.com/apache/spark/pull/59258#discussion_r4225050761


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala:
##########
@@ -126,6 +125,92 @@ trait ConstraintHelper {
     inferred -- constraints
   }
 
+  /**
+   * Infers the full additional constraint set for a given set of constraints 
by combining the
+   * equality-based inferences above ([[inferAdditionalConstraints]],
+   * [[inferConstraintsFromLiteralBindings]]) with transitive inequality 
chaining
+   * ([[inferInequalityConstraints]]). The inequality step is iterated to a 
fixed point because a
+   * single pass only chains pairs present in the input set: e.g. `a < b && b 
< c && c < 5`
+   * requires one pass to derive `b < 5` and a second to derive `a < 5` from 
it.
+   */
+  def inferAllAdditionalConstraints(constraints: ExpressionSet): ExpressionSet 
= {
+    @tailrec
+    def chainInequalities(inferred: ExpressionSet): ExpressionSet = {
+      val newlyInferred = inferInequalityConstraints(constraints ++ inferred)
+      if (newlyInferred.isEmpty) inferred else chainInequalities(inferred ++ 
newlyInferred)
+    }
+
+    chainInequalities(
+      inferAdditionalConstraints(constraints) ++
+        inferConstraintsFromLiteralBindings(constraints))
+  }
+
+  /**
+   * Infers an additional set of constraints from a given set of inequality 
constraints.
+   * For e.g., if an operator has constraints of the form (`a > b`, `b > 5`), 
this returns an
+   * additional constraint of the form `a > 5`.
+   */
+  private def inferInequalityConstraints(constraints: ExpressionSet): 
ExpressionSet = {
+    // Substituting through a comparison is only safe under binary-stable 
collations, for the
+    // same reason as in inferConstraintsFromLiteralBindings: 
non-binary-stable collations may
+    // order values differently than their binary representation would 
suggest. Attribute-to-
+    // attribute equality is excluded here too: its transitivity is already 
handled by
+    // inferAdditionalConstraints via substitution, so chaining it here would 
only re-derive the
+    // same fact as a redundant <= / >= pair instead of the clean substituted 
equality that path
+    // already produces.
+    val validComparisons = constraints.filter {
+      case b: BinaryComparison =>
+        b.deterministic && !b.isInstanceOf[EqualNullSafe] &&
+          (!b.isInstanceOf[EqualTo] || b.left.foldable || b.right.foldable) &&
+          isBinaryStable(b.left.dataType) && isBinaryStable(b.right.dataType)
+      case _ => false
+    }
+
+    def hasFoldableOperand(e: Expression): Boolean = e match {
+      case BinaryComparison(l, r) => l.foldable || r.foldable
+      case _ => false
+    }
+
+    if (!validComparisons.exists(hasFoldableOperand)) {
+      return ExpressionSet()
+    }
+
+    // Canonicalize comparisons into LessThan / LessThanOrEqual direction. 
EqualTo is expanded
+    // into both directions, unlike GreaterThan/GreaterThanOrEqual, because an 
equality acts as
+    // both an upper and a lower bound and the matching below is 
direction-sensitive on which
+    // side carries the literal.
+    val canonical = validComparisons.toSet[Expression].flatMap {

Review Comment:
   **Nit (P3):** `toSet[Expression]`, and the `partition` and for-comprehension 
that follow, switch from the insertion-ordered `ExpressionSet` to Scala `Set`s. 
Those become hash-ordered `HashSet`s once there are 5 or more elements. Since 
`AttributeReference.hashCode` includes the exprId, the order of the inferred 
conjuncts can change between runs of the same query whenever one pass infers 
two or more bounds. That order carries into EXPLAIN, `PushedFilters`, and 
generated code. An example is `a < c AND b < c AND c < 10 AND a > 0 AND b > 0`. 
Plan-stability goldens normalize exprIds but not conjunct order, so such a 
query would make them flaky. Keeping these as `Seq`s (e.g. 
`validComparisons.toSeq.flatMap`) would preserve the input order, as the other 
inference helpers do.
   
   **Verification:**
   - **Regression:** A Filter with at least five chainable comparisons that 
infers two or more bounds in one pass yields the same inferred-conjunct order 
when its attributes are re-created with different exprIds, checked without 
order-normalizing the plan.
   - **Inspection:** inferInequalityConstraints iterates only insertion-ordered 
collections between validComparisons and the returned ExpressionSet.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala:
##########
@@ -126,6 +125,92 @@ trait ConstraintHelper {
     inferred -- constraints
   }
 
+  /**
+   * Infers the full additional constraint set for a given set of constraints 
by combining the
+   * equality-based inferences above ([[inferAdditionalConstraints]],
+   * [[inferConstraintsFromLiteralBindings]]) with transitive inequality 
chaining
+   * ([[inferInequalityConstraints]]). The inequality step is iterated to a 
fixed point because a
+   * single pass only chains pairs present in the input set: e.g. `a < b && b 
< c && c < 5`
+   * requires one pass to derive `b < 5` and a second to derive `a < 5` from 
it.
+   */
+  def inferAllAdditionalConstraints(constraints: ExpressionSet): ExpressionSet 
= {
+    @tailrec
+    def chainInequalities(inferred: ExpressionSet): ExpressionSet = {
+      val newlyInferred = inferInequalityConstraints(constraints ++ inferred)
+      if (newlyInferred.isEmpty) inferred else chainInequalities(inferred ++ 
newlyInferred)
+    }
+
+    chainInequalities(
+      inferAdditionalConstraints(constraints) ++
+        inferConstraintsFromLiteralBindings(constraints))
+  }
+
+  /**
+   * Infers an additional set of constraints from a given set of inequality 
constraints.
+   * For e.g., if an operator has constraints of the form (`a > b`, `b > 5`), 
this returns an
+   * additional constraint of the form `a > 5`.
+   */
+  private def inferInequalityConstraints(constraints: ExpressionSet): 
ExpressionSet = {
+    // Substituting through a comparison is only safe under binary-stable 
collations, for the
+    // same reason as in inferConstraintsFromLiteralBindings: 
non-binary-stable collations may
+    // order values differently than their binary representation would 
suggest. Attribute-to-
+    // attribute equality is excluded here too: its transitivity is already 
handled by
+    // inferAdditionalConstraints via substitution, so chaining it here would 
only re-derive the
+    // same fact as a redundant <= / >= pair instead of the clean substituted 
equality that path
+    // already produces.
+    val validComparisons = constraints.filter {
+      case b: BinaryComparison =>
+        b.deterministic && !b.isInstanceOf[EqualNullSafe] &&

Review Comment:
   **Nit (P3):** `validComparisons` checks `deterministic` but not `throwable`, 
so a comparison whose operand can throw is still chained. 
`InferFiltersFromConstraints` then materializes the derived bound where the 
original predicate was never evaluated. For example, in `SELECT * FROM x JOIN y 
ON x.k = y.k AND CASE WHEN y.v < 0 THEN raise_error('neg') ELSE y.v END < x.b 
WHERE x.b < 10`, the inferred `CASE ... END < 10` is pushed as a Filter on `y`, 
so an unmatched `y` row with `v < 0` now fails the query. 
`PushPredicateThroughJoin` and `CombineFilters` keep throwable predicates in 
place for this reason. Adding `!b.throwable` here, with a small test, would 
keep the new chaining consistent with them. The literal-binding path already 
has the same gap, so guarding where `inferNewFilter` materializes inferred 
constraints would cover both.
   
   **Verification:**
   - **Regression:** An inner equi-join whose residual compares a 
raise_error-containing CASE expression with a join attribute that has a literal 
bound on its own side optimizes with no inferred Filter on the throwable side.
   - **Compatibility:** The existing inequality-inference optimizer, 
constraint-propagation, and q12 plan-stability expectations remain unchanged.



##########
sql/core/src/test/scala/org/apache/spark/sql/SQLQuerySuite.scala:
##########
@@ -5369,6 +5369,55 @@ class SQLQuerySuite extends SharedSparkSession with 
AdaptiveSparkPlanHelper
         Seq(Row(0), Row(1), Row(2)))
     }
   }
+
+  test("SPARK-30768: inferred inequality chaining does not drop rows with NaN 
operands") {

Review Comment:
   **Nit (P3):** This test cannot fail for the case its name describes. Under 
Spark's NaN-is-largest ordering, every NaN-bearing row already fails `a < b AND 
b < 3.0`, so the expected `Row(1.0, 2.0)` does not depend on how the inferred 
`a < 3.0` handles NaN. To pin NaN behavior, add a case where the original 
predicate keeps a NaN row: for example `a > b AND b > 3.0` over `(Double.NaN, 
5.0)`. Then assert that the row survives and that `a > 3.0` is inferred.
   
   **Verification:**
   - **Behavior:** With constraint propagation on and off, `a > b AND b > 3.0` 
over data containing (NaN, 5.0) returns that row, and the inferred `a > 3.0` is 
present only when propagation is enabled.



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