This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 245504546445 [SPARK-57437][SQL] Infer additional constraints by
substituting attribute-to-literal bindings
245504546445 is described below
commit 24550454644568cb31bfab28768debcd259da1aa
Author: James Xu <[email protected]>
AuthorDate: Thu Jul 2 14:28:41 2026 +0800
[SPARK-57437][SQL] Infer additional constraints by substituting
attribute-to-literal bindings
### What changes were proposed in this pull request?
When a predicate binds an attribute to a literal (e.g. a.pt = '20260610')
and another predicate references that attribute (e.g. b.pt >= f(a.pt)),
Catalyst previously did not exploit the literal binding to derive a simpler,
pushable predicate.
```sql
SELECT *
FROM a
LEFT JOIN b
ON a.key = b.key
AND b.pt >= f(a.pt)
WHERE a.pt = '20260610';
```
Here table b is full scaned, thus very bad performance.
This change adds a new inferConstraintsFromLiteralBindings that:
1. Collects Attribute = Literal bindings from the constraint set.
2. Substitutes the literal into predicates that reference those
attributes.
3. Adds the resulting deterministic expressions as new inferred
constraints.
After constant folding, the inferred predicates can be pushed into scans as
partition filters, avoiding full-table scans in cases where only a small subset
of partitions can match.
### Why are the changes needed?
Currently query of the following pattern causes full table scan for table b:
```sql
SELECT *
FROM a
LEFT JOIN b
ON a.key = b.key
AND b.pt >= f(a.pt)
WHERE a.pt = '20260610';
```
With this optimization table b can get very good partition pruning.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Added unit tests.
### Was this patch authored or co-authored using generative AI tooling?
No.
Closes #56499 from
xumingming/catalyst-infer-constraints-from-literal-bindings.
Authored-by: James Xu <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../spark/sql/catalyst/optimizer/Optimizer.scala | 4 +-
.../plans/logical/QueryPlanConstraints.scala | 46 +++++++
.../InferFiltersFromConstraintsSuite.scala | 52 +++++++
.../plans/ConstraintPropagationSuite.scala | 149 +++++++++++++++++++++
4 files changed, 250 insertions(+), 1 deletion(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
index 077d6cbe8d47..597a70e01a6e 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
@@ -1834,7 +1834,9 @@ object InferFiltersFromConstraints extends
Rule[LogicalPlan]
conditionOpt: Option[Expression]): ExpressionSet = {
val baseConstraints = left.constraints.union(right.constraints)
.union(ExpressionSet(conditionOpt.map(splitConjunctivePredicates).getOrElse(Nil)))
- baseConstraints.union(inferAdditionalConstraints(baseConstraints))
+ baseConstraints
+ .union(inferAdditionalConstraints(baseConstraints))
+ .union(inferConstraintsFromLiteralBindings(baseConstraints))
}
private def inferNewFilter(plan: LogicalPlan, constraints: ExpressionSet):
LogicalPlan = {
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala
index ef035eba5922..6a1aa54a54d0 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/QueryPlanConstraints.scala
@@ -20,6 +20,7 @@ package org.apache.spark.sql.catalyst.plans.logical
import scala.annotation.tailrec
import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.util.UnsafeRowUtils.isBinaryStable
trait QueryPlanConstraints extends ConstraintHelper { self: LogicalPlan =>
@@ -33,6 +34,7 @@ trait QueryPlanConstraints extends ConstraintHelper { self:
LogicalPlan =>
if (conf.constraintPropagationEnabled) {
validConstraints
.union(inferAdditionalConstraints(validConstraints))
+ .union(inferConstraintsFromLiteralBindings(validConstraints))
.union(constructIsNotNullConstraints(validConstraints, output))
.filter { c =>
c.references.nonEmpty && c.references.subsetOf(outputSet) &&
c.deterministic
@@ -77,9 +79,53 @@ trait ConstraintHelper {
inferredConstraints ++= replaceConstraints(predicates - eq -
EqualNullSafe(l, r), l, r)
case _ => // No inference
}
+
inferredConstraints -- constraints
}
+ /**
+ * Infers additional constraints by substituting known attribute-to-literal
bindings into
+ * non-equality predicates. For example, given `a = 5` and `b >= a`, infers
`b >= 5`.
+ *
+ * Attribute-to-attribute (and cast-form) [[EqualTo]] and all
[[EqualNullSafe]] predicates are
+ * excluded from substitution targets.
+ * Substituting into attribute-attribute [[EqualTo]] would duplicate work
already done by the
+ * transitivity case in [[inferAdditionalConstraints]] (e.g. `a = 5` and `b
= a` imply `b = 5`,
+ * already derived there). Substituting into [[EqualNullSafe]] would produce
a structurally
+ * distinct form (e.g. `b <=> 5`) that downstream rules (such as
subquery-reuse matching)
+ * treat differently from the [[EqualTo]] form, leading to duplicate
subqueries being generated.
+ */
+ def inferConstraintsFromLiteralBindings(constraints: ExpressionSet):
ExpressionSet = {
+ // Collect attr -> literal bindings, guarded by binary-stable collation so
that
+ // the substitution is semantically safe (non-binary-stable collations may
equate
+ // strings that are binary-distinct, so substituting the literal would
change results).
+ val bindings: Map[Attribute, Literal] = constraints.collect {
+ case EqualTo(a: Attribute, l: Literal) if isBinaryStable(a.dataType) =>
a -> l
+ case EqualTo(l: Literal, a: Attribute) if isBinaryStable(a.dataType) =>
a -> l
+ }.toMap
+
+ if (bindings.isEmpty) return ExpressionSet()
+
+ val targets = constraints.filterNot {
+ // attr=attr EqualTo: already covered by inferAdditionalConstraints
(transitivity).
+ // cast-equality EqualTo: would produce redundant cast literals
derivable from that path.
+ // EqualNullSafe: substituting a literal produces a structurally
distinct b <=> lit form
+ // that subquery-reuse matching treats differently from b = lit, causing
duplicate subqueries.
+ // IsNotNull: handled separately by constructIsNotNullConstraints.
+ case EqualTo(_: Attribute, _: Attribute) => true
+ case EqualTo(Cast(_: Attribute, _, _, _), _: Attribute) => true
+ case EqualTo(_: Attribute, Cast(_: Attribute, _, _, _)) => true
+ case _: EqualNullSafe | _: IsNotNull => true
+ case _ => false
+ }
+
+ var inferred = ExpressionSet()
+ bindings.foreach { case (attr, lit) =>
+ inferred ++= replaceConstraints(targets, attr, lit)
+ }
+ inferred -- constraints
+ }
+
private def replaceConstraints(
constraints: ExpressionSet,
source: Expression,
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferFiltersFromConstraintsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferFiltersFromConstraintsSuite.scala
index d8d8a2b333bc..bfed9427c863 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferFiltersFromConstraintsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/InferFiltersFromConstraintsSuite.scala
@@ -399,4 +399,56 @@ class InferFiltersFromConstraintsSuite extends PlanTest {
comparePlans(optimizedQuery, correctAnswer)
comparePlans(InferFiltersFromConstraints(optimizedQuery), correctAnswer)
}
+
+ test("correlated range predicate in left-outer join ON clause is pushed to
right side " +
+ "when left attr is bound to a literal") {
+ testRangePredicatePushDown(LeftOuter)
+ }
+
+ test("correlated range predicate in inner join ON clause is pushed to right
side " +
+ "when left attr is bound to a literal") {
+ testRangePredicatePushDown(Inner)
+ }
+
+ private def testRangePredicatePushDown(joinType: JoinType): Unit = {
+ // Simulates: SELECT * FROM a JOIN b ON a.key = b.key AND b.v >= a.k AND
b.v <= a.k + 10
+ // WHERE a.k = 5
+ // The constant binding a.k = 5 should be substituted into the range
predicates, producing
+ // b.v >= 5 and b.v <= (5 + 10) that get pushed into the right scan.
+ val left = LocalRelation($"k".int, $"key".int).subquery("a")
+ val right = LocalRelation($"v".int, $"key".int).subquery("b")
+
+ val joinCond = ("a.key".attr === "b.key".attr) &&
+ ("b.v".attr >= "a.k".attr) &&
+ ("b.v".attr <= ("a.k".attr + 10))
+
+ val originalQuery = left
+ .where("a.k".attr === 5)
+ .join(right, joinType, Some(joinCond))
+ .analyze
+
+ val optimized = Optimize.execute(originalQuery)
+
+ // The optimized right side must carry the inferred range filters.
+ var joinFound = false
+ optimized.foreach {
+ case Join(_, rightChild, `joinType`, _, _) =>
+ joinFound = true
+ val rightFilters = rightChild.collect { case Filter(cond, _) => cond }
+ val allConds = rightFilters.flatMap(splitConjunctivePredicates)
+ // b.v >= 5 and b.v <= (5 + 10) must appear (arithmetic not folded
without ConstantFolding)
+ assert(allConds.exists {
+ case GreaterThanOrEqual(_: Attribute, Literal(v, IntegerType)) => v
== 5
+ case _ => false
+ }, s"Expected b.v >= 5 in right filter; found: $allConds")
+ assert(allConds.exists {
+ case LessThanOrEqual(_: Attribute, add: Add)
+ if add.left == Literal(5, IntegerType) && add.right == Literal(10,
IntegerType) => true
+ case _ => false
+ }, s"Expected b.v <= (5 + 10) in right filter; found: $allConds")
+ case _ =>
+ }
+ assert(joinFound, "Expected a Join node in the optimized plan")
+ }
+
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/ConstraintPropagationSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/ConstraintPropagationSuite.scala
index 5f6084287815..61ee06d4aaf1 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/ConstraintPropagationSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/plans/ConstraintPropagationSuite.scala
@@ -24,6 +24,7 @@ import org.apache.spark.sql.catalyst.dsl.expressions._
import org.apache.spark.sql.catalyst.dsl.plans._
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.plans.logical._
+import org.apache.spark.sql.catalyst.util.CollationFactory
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{DataType, DoubleType, IntegerType,
LongType, StringType}
@@ -427,4 +428,152 @@ class ConstraintPropagationSuite extends PlanTest {
assert(aliasedRelation.analyze.constraints.isEmpty)
}
}
+
+ test("infer range constraints by substituting attr=literal bindings") {
+ // When a.pt = '20260610' (modeled as a.k = 5) and b.v >= a.k are both
present,
+ // inferConstraintsFromLiteralBindings should emit b.v >= 5.
+ val tr = LocalRelation($"k".int, $"v".int)
+ val a = resolveColumn(tr, "k")
+ val b = resolveColumn(tr, "v")
+
+ val constraints = ExpressionSet(Seq(
+ EqualTo(a, Literal(5)),
+ GreaterThanOrEqual(b, a),
+ LessThanOrEqual(b, Add(a, Literal(10)))))
+
+ val helper = new ConstraintHelper {}
+ val inferred = helper.inferConstraintsFromLiteralBindings(constraints)
+
+ // b >= 5 must be inferred
+ assert(inferred.exists {
+ case GreaterThanOrEqual(attr: Attribute, Literal(v, IntegerType)) =>
+ attr.semanticEquals(b) && v == 5
+ case _ => false
+ }, s"Expected b >= 5 in inferred constraints; got: $inferred")
+
+ // b <= 15 must be inferred (a + 10 with a=5, i.e. Add(Literal(5),
Literal(10)))
+ assert(inferred.exists {
+ case LessThanOrEqual(attr: Attribute, rhs) if attr.semanticEquals(b) =>
+ // rhs is either Literal(15) or Add(Literal(5), Literal(10)) - both
are foldable
+ rhs.foldable && rhs.eval(null) == 15
+ case _ => false
+ }, s"Expected b <= 15 in inferred constraints; got: $inferred")
+ }
+
+ test("infer range constraints: literal on left side of EqualTo") {
+ val tr = LocalRelation($"k".int, $"v".int)
+ val a = resolveColumn(tr, "k")
+ val b = resolveColumn(tr, "v")
+
+ // Literal appears on the left: 5 = a.k
+ val constraints = ExpressionSet(Seq(
+ EqualTo(Literal(5), a),
+ GreaterThanOrEqual(b, a)))
+
+ val helper = new ConstraintHelper {}
+ val inferred = helper.inferConstraintsFromLiteralBindings(constraints)
+
+ assert(inferred.exists {
+ case GreaterThanOrEqual(attr: Attribute, Literal(v, IntegerType)) =>
+ attr.semanticEquals(b) && v == 5
+ case _ => false
+ }, s"Expected b >= 5 in inferred constraints; got: $inferred")
+ }
+
+ test("infer constraints by substituting attr=literal bindings into EqualTo
expressions") {
+ val tr = LocalRelation($"k".int, $"v".int)
+ val a = resolveColumn(tr, "k")
+ val b = resolveColumn(tr, "v")
+
+ val constraints = ExpressionSet(Seq(
+ EqualTo(a, Literal(5)),
+ EqualTo(b, Add(a, Literal(1)))))
+
+ val helper = new ConstraintHelper {}
+ val inferred = helper.inferConstraintsFromLiteralBindings(constraints)
+
+ // b = a + 1 with a = 5 should infer b = 5 + 1 (foldable to 6)
+ assert(inferred.exists {
+ case EqualTo(attr: Attribute, rhs) if attr.semanticEquals(b) =>
+ rhs.foldable && rhs.eval(null) == 6
+ case _ => false
+ }, s"Expected b = 6 in inferred constraints; got: $inferred")
+ }
+
+ test("non-deterministic constraints inferred from literal substitution are
filtered out") {
+ val tr = LocalRelation($"k".int, $"v".int)
+ val a = resolveColumn(tr, "k")
+ val b = resolveColumn(tr, "v")
+
+ // b >= a.k + Rand(); after substituting a.k=5 the result is
non-deterministic.
+ // inferConstraintsFromLiteralBindings may emit it, but the plan-level
constraints
+ // filter must drop it.
+ val randExpr = Add(a, Cast(Rand(0L), IntegerType))
+ val condition = EqualTo(a, Literal(5)) && GreaterThanOrEqual(b, randExpr)
+ val plan = Filter(condition, tr.analyze)
+
+ assert(!plan.analyze.constraints.exists {
+ case GreaterThanOrEqual(attr: Attribute, _) => attr.semanticEquals(b)
+ case _ => false
+ }, s"Should not keep non-deterministic constraint in plan constraints; " +
+ s"got: ${plan.analyze.constraints}")
+ }
+
+ test("do not infer constraints by substituting non-binary-stable collated
attributes") {
+ // a is UTF8_LCASE (case-insensitive). a = 'hello' only tells us a is
case-insensitively
+ // equal to 'hello'; it could still be 'HELLO'. Substituting a with
'hello' into a
+ // UTF8_BINARY comparison like b >= a would infer b >= 'hello', which is
wrong.
+ val a = AttributeReference("a",
StringType(CollationFactory.UTF8_LCASE_COLLATION_ID))()
+ val b = AttributeReference("b", StringType)()
+ val hello = Literal("hello")
+
+ val constraints = ExpressionSet(Seq(
+ EqualTo(a, hello),
+ GreaterThanOrEqual(b, a)))
+
+ val helper = new ConstraintHelper {}
+ val inferred = helper.inferConstraintsFromLiteralBindings(constraints)
+
+ assert(!inferred.exists {
+ case GreaterThanOrEqual(attr: Attribute, lit: Literal) =>
+ attr.semanticEquals(b) && lit.semanticEquals(hello)
+ case _ => false
+ }, s"Should not infer b >= 'hello' for non-binary-stable collation; got:
$inferred")
+ }
+
+ test("do not substitute attr=literal bindings into EqualNullSafe or
attr-attr EqualTo targets") {
+ // EqualNullSafe and attribute-attribute EqualTo are excluded from
substitution targets:
+ // - Substituting into EqualNullSafe(b, a) would yield b <=> 5, a
structurally distinct form
+ // that subquery-reuse matching treats differently from b = 5, causing
duplicate subqueries
+ // (the q78 regression root).
+ // - Substituting into EqualTo(c, a) would duplicate the transitivity
already handled by
+ // inferAdditionalConstraints, which derives c = 5 from c = a and a = 5.
+ val tr = LocalRelation($"k".int, $"v".int, $"w".int)
+ val a = resolveColumn(tr, "k")
+ val b = resolveColumn(tr, "v")
+ val c = resolveColumn(tr, "w")
+
+ val constraints = ExpressionSet(Seq(
+ EqualTo(a, Literal(5)),
+ EqualNullSafe(b, a),
+ EqualTo(c, a)))
+
+ val helper = new ConstraintHelper {}
+ val inferred = helper.inferConstraintsFromLiteralBindings(constraints)
+
+ // No b <=> 5 should be synthesized from the EqualNullSafe target.
+ assert(!inferred.exists {
+ case EqualNullSafe(attr: Attribute, lit: Literal) =>
+ attr.semanticEquals(b) && lit.semanticEquals(Literal(5))
+ case _ => false
+ }, s"Should not infer b <=> 5 from EqualNullSafe target; got: $inferred")
+
+ // No c = 5 should be synthesized from the attr-attr EqualTo target; that
derivation
+ // belongs to inferAdditionalConstraints, not literal substitution.
+ assert(!inferred.exists {
+ case EqualTo(attr: Attribute, lit: Literal) =>
+ attr.semanticEquals(c) && lit.semanticEquals(Literal(5))
+ case _ => false
+ }, s"Should not infer c = 5 from attr-attr EqualTo target; got: $inferred")
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]