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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala:
##########
@@ -126,6 +128,229 @@ 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 pass must see the 
equality passes' output
+   * to chain through equality-derived edges and bounds. Because the 
inequality pass emits only
+   * inequalities, the equality-based passes gain nothing from re-running on 
its result, so a

Review Comment:
   **Nit (P3):** This says the equality passes gain nothing from re-running on 
the inequality output, but that doesn't hold for multi-hop equality chains. 
`inferAdditionalConstraints` does one substitution round, and the inequality 
pass drops every `EqualTo` without a safe literal side. Take `a = c AND c = d 
AND d < b AND b < 5`. One round adds only `a = d` and `c < b`, so the 
inequality pass emits `d < 5` and `c < 5` but never `a < 5`. Re-running the 
equality pass on `c < 5` with `a = c` would derive it. Production runs Infer 
Filters once, so `a < 5` is never added as a filter. Results stay correct; only 
that pushdown is missed.
   
   The `validComparisons` comment at lines 240-241 has the same issue from the 
other side. It says a non-literal `EqualTo` is excluded "since no inference 
path below handles a non-literal equality operand". But the canonical step 
already expands `EqualTo` into both orientations, and the edge loop matches any 
`BinaryComparison`. An admitted `a + 1 = b` would therefore become non-strict 
edges that soundly carry a bound across. So the exclusion is a choice, not a 
limit of the code below.
   
   The "gain nothing" wording came from my suggestion in the previous round, 
and it was wrong. Could both comments describe the actual boundary instead? One 
round of the equality passes is followed by one inequality pass, and 
non-literal equalities are deliberately not used as chaining edges, so 
multi-hop equality chains can leave a bound uninferred.
   
   **Verification:**
   - **Inspection:** Both comments match the code. One equality substitution 
round precedes one inequality pass, a two-hop chain such as a = c, c = d, d < 
b, b < 5 does not infer a < 5, and excluding non-literal EqualTo is described 
as a design choice consistent with the literal-bounds-only scope.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala:
##########
@@ -126,6 +128,229 @@ 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 pass must see the 
equality passes' output
+   * to chain through equality-derived edges and bounds. Because the 
inequality pass emits only
+   * inequalities, the equality-based passes gain nothing from re-running on 
its result, so a
+   * single downstream call to [[inferInequalityConstraints]] suffices without 
interleaving.
+   */
+  def inferAllAdditionalConstraints(constraints: ExpressionSet): ExpressionSet 
= {
+    val equalityInferred =
+      inferAdditionalConstraints(constraints) ++ 
inferConstraintsFromLiteralBindings(constraints)
+    equalityInferred ++ inferInequalityConstraints(constraints ++ 
equalityInferred)
+  }
+
+  /**
+   * True when `e` cannot raise an error during evaluation: a non-null 
literal, or an upcast of
+   * one admitted by [[UpCastRule.canUpCast]]. Used by 
[[inferInequalityConstraints]] to decide
+   * which literals are safe to treat as chain anchors without risking an ANSI 
eval failure.
+   * Arbitrary expressions (such as a parsing cast `CAST('abc' AS INT)` or 
arithmetic that can
+   * fail under ANSI) are excluded.
+   */
+  @tailrec
+  private def isSafeBoundLiteral(e: Expression): Boolean = e match {
+    case l: Literal => l.value != null
+    case Cast(child, toType, _, _) =>
+      UpCastRule.canUpCast(child.dataType, toType) && isSafeBoundLiteral(child)
+    case _ => false
+  }
+
+  private def hasBoundOperand(e: Expression): Boolean = e match {
+    case b: BinaryComparison => isSafeBoundLiteral(b.left) || 
isSafeBoundLiteral(b.right)
+    case _ => false
+  }
+
+  /**
+   * True when `e` is safe to copy into a newly inferred, single-side filter 
produced by
+   * [[inferInequalityConstraints]] without risking an error on rows that 
never evaluated it
+   * before: an attribute reference, or an upcast of one admitted by 
[[UpCastRule.canUpCast]].
+   * While some upcasts may lose precision (e.g. Long to Float/Double), 
relocation is sound
+   * because they cannot fail and reuse the existing Cast expression. 
Arbitrary expressions -- a
+   * parsing/narrowing cast, arithmetic that can overflow under ANSI, etc. -- 
are excluded, since
+   * the comparison they came from may have been a cross-side join condition 
that only ever ran
+   * on matched rows.
+   */
+  private def isSafeToRelocate(e: Expression): Boolean = e match {
+    case _: Attribute => true
+    case Cast(a: Attribute, toType, _, _) => UpCastRule.canUpCast(a.dataType, 
toType)
+    case _ => false
+  }
+
+  /**
+   * A known literal bound discovered while chaining inequality constraints in
+   * [[inferInequalityConstraints]]: `site {<,<=} lit` when `upper`, or `lit 
{<,<=} site`
+   * otherwise. `value` stores the evaluated literal value to avoid repeatedly 
evaluating `lit`
+   * during optimization.
+   */
+  private case class InequalityBound(
+      upper: Boolean,
+      site: Expression,
+      lit: Expression,
+      strict: Boolean,
+      value: Any)
+
+  /**
+   * Converts a canonicalized comparison into an [[InequalityBound]] when 
exactly one side is a
+   * safe literal bound ([[isSafeBoundLiteral]]); `None` for 
attribute-to-attribute comparisons.
+   */
+  private def toBound(e: Expression): Option[InequalityBound] = e match {
+    case b: BinaryComparison =>
+      val strict = b.isInstanceOf[LessThan]
+      if (isSafeBoundLiteral(b.right) && !isSafeBoundLiteral(b.left)) {
+        Some(InequalityBound(upper = true, b.left, b.right, strict, 
b.right.eval()))
+      } else if (isSafeBoundLiteral(b.left) && !isSafeBoundLiteral(b.right)) {
+        Some(InequalityBound(upper = false, b.right, b.left, strict, 
b.left.eval()))
+      } else {
+        None
+      }
+    case _ => None
+  }
+
+  /**
+   * Infers tightest literal inequality bounds on relocatable sites by 
transitively chaining
+   * inequality constraints with literal bounds. 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`; a longer
+   * chain such as `a < b && b < c && c < 5` derives both `b < 5` and `a < 5` 
in this same call.
+   * Note that attribute-to-attribute inequalities without literal bounds 
(e.g., `a < c` from
+   * `a < b && b < c`) are not inferred.
+   *
+   * This is computed as a worklist over a directed bound graph rather than a 
repeated fixed point
+   * over the full candidate set: each candidate comparison becomes a directed 
edge once, and each
+   * newly discovered bound is propagated only along its adjacent edges, 
instead of rescanning the
+   * full candidates x bounds product after every newly inferred bound (which 
costs O(n^3) on a
+   * sparse n-element chain such as `x1 < x2 < ... < xn < lit`). A site only 
ever keeps its single
+   * tightest bound per direction, so reaching it via multiple literals (e.g. 
`b < 5` and `b < 10`
+   * both reaching `a` through `a < b`) converges to one inferred predicate 
instead of one per
+   * literal; a relaxation only re-enters the worklist when it strictly 
tightens that site's bound,
+   * which bounds the number of relaxations per site by the number of distinct 
literals that can
+   * reach it and guarantees termination.
+   */
+  private def inferInequalityConstraints(constraints: ExpressionSet): 
ExpressionSet = {
+    // Admits only comparisons between binary-stable types. This is a 
conservative guard, not a
+    // strict requirement: chaining never substitutes a value the way
+    // inferConstraintsFromLiteralBindings does, it only reuses an operand 
under the same
+    // collation-aware comparison, so a non-binary-stable collation could in 
principle still order
+    // consistently with its binary form here. EqualTo is admitted only when 
at least one side is
+    // a safe literal bound; every other EqualTo -- attribute-to-attribute 
(handled by
+    // inferAdditionalConstraints's transitivity), as well as any other shape 
without a safe
+    // literal bound such as `a + 1 = b` -- is excluded, since no inference 
path below handles a
+    // non-literal equality operand.
+    val validComparisons = constraints.toSeq.filter {
+      case b: BinaryComparison =>
+        b.deterministic && !b.throwable && !b.isInstanceOf[EqualNullSafe] &&

Review Comment:
   **Nit (P3):** `b.deterministic` is the only thing that keeps the pass from 
chaining through a non-deterministic operand. `Filter.validConstraints` keeps 
non-deterministic conjuncts, and the later `c.deterministic` filters only drop 
non-deterministic outputs. But no test covers this check. Without it, `WHERE a 
< rand(0) AND rand(0) < 0.5` on an int column would infer the deterministic 
`cast(a as double) < 0.5`. Both `rand(0)` instances canonicalize to the same 
edge key, so the bound on one is chained through the other, even though they 
are evaluated separately and the original filter doesn't imply it. Every added 
and existing test still passes with the check removed, because the existing 
rand tests use `a = rand(0)` or arithmetic sites that the new pass never 
chains. A small test next to the rewritten throwable one, asserting that this 
filter infers no bound on `a`, would pin it.
   
   **Verification:**
   - **Regression:** A literal-bounded chain through a non-deterministic 
operand infers no deterministic bound on the relocatable attribute, and 
removing the deterministic admission check makes the assertion fail.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala:
##########
@@ -126,6 +128,189 @@ 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 two equality-based passes never 
produce a new literal

Review Comment:
   Thanks for updating this. The wording I suggested doesn't fully hold, 
though. With `a = c AND c = d AND d < b AND b < 5`, one equality round only 
adds `a = d` and `c < b`, so the inequality pass emits `d < 5` and `c < 5` but 
never `a < 5`, which re-running the equality pass on its output would derive. I 
left a separate comment with a suggested rewording.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:4263387004","thread_id":"inline:4263387004","verdict_sha256":"504ace95b97cc57b766e13a0b73e622f081c281a8659d4632255b64ca45c5d35"}
 -->



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