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

peter-toth pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 3c1903e8ea6a [SPARK-58769][SQL] Document the BoundFunction equality 
contract and drop the name-based transform comparison
3c1903e8ea6a is described below

commit 3c1903e8ea6af150891b0fe3b1604c4bcc2772cd
Author: Peter Toth <[email protected]>
AuthorDate: Fri Aug 21 15:23:11 2026 +0200

    [SPARK-58769][SQL] Document the BoundFunction equality contract and drop 
the name-based transform comparison
    
    ### What changes were proposed in this pull request?
    
    Spark compares partition transform expressions with ordinary expression 
equality, which reaches `BoundFunction.equals`. It binds a function afresh 
every time it converts a partitioning or ordering reported by a source, so two 
reports of one transform -- from two scans of the same table, or from the 
partitioning and the ordering of a single scan -- hold two bound instances. A 
function that does not implement `equals`/`hashCode` therefore yields 
expressions that never compare equal acro [...]
    
    - **`BoundFunction` gets an `equals`/`hashCode` contract** (both redeclared 
on the interface): implementations SHOULD override them, comparing whatever 
state affects behaviour; comparing `canonicalName` alone is not necessarily 
enough; whatever is compared must be stable across `bind` calls; `hashCode` 
must agree, since these expressions land in hash-based collections. Two 
functions that can produce different values must **not** compare equal -- the 
same comparison decides whether two [...]
    - **The relationship between the two comparisons is stated**: `equals` is 
the finer of the two, so two functions that compare equal must return the same 
`canonicalName`, while the same name does not make them equal. An 
implementation overriding `equals` should override `canonicalName` too, instead 
of claiming two instances are the same expression but not the same partition 
function -- which would cost it exactly the co-partitioning the name exists to 
enable.
    - **`canonicalName`'s javadoc is scoped** to the question it actually 
answers -- whether two transforms are the same partition function -- says it is 
deliberately coarser than `equals`, and leads with the one requirement here 
that guards results rather than performance: two functions that partition data 
differently must not return the same name.
    - **`PlanMerger`'s `sameReportedExpressions` / `reportedOrderingSatisfies` 
are removed**, so it compares reported transforms the way every other rule does.
    
    **Why not derive the identity in Spark instead?** Because it cannot be 
derived. `canonicalName` says nothing about `inputTypes`, `resultType`, 
`isResultNullable`, `isDeterministic`, a `ReducibleFunction`'s reducers, or 
whatever else an implementation holds. Adding the function's class separates 
two different classes, but not two instances of **one** class that captured 
different state when they were bound -- a width, a precision, a timezone, a 
snapshot id -- and `BoundFunction` is an  [...]
    
    **Relationship to `isSameFunction` / `isCompatible`.** They are untouched, 
and answer a different question:
    
    | | Question | Arguments | Used by |
    |---|---|---|---|
    | `equals` (via the connector) | same transform **and** same arguments? | 
compared by Spark | plan identity: dedup, reuse, canonicalization |
    | `isSameFunction` | same transform, arguments aside? | children 
**ignored**, bucket count compared | SPJ co-partitioning |
    | `isCompatible` / `reducers` | different transforms, reconcilable? | 
children ignored | SPJ under `allowCompatibleTransforms` |
    
    A storage-partitioned join *must* ignore the arguments -- it compares 
`bucket(4, left.id)` against `bucket(4, right.id)` and recovers the positions 
separately via `KeyedShuffleSpec.keyPositions` -- so no amount of correct 
`equals` replaces it. Note the division of labour in the other direction too: a 
coarse connector-side `equals` does not have to carry the whole identity, 
because Spark compares the transform's arguments and bucket count itself.
    
    ### Why are the changes needed?
    
    Every place that compares a reported key-grouped partitioning or ordering 
treats it as an ordinary expression, so all of them miss matches for a 
connector without `equals`:
    
    - `UnionExec.comparePartitioning` compares the children's partition 
expressions with `semanticEquals`, *after* `prepareOutputPartitioning` has 
remapped them onto the first child's attributes. For a union of two scans of 
one bucketed table the expressions are then identical except for the function 
instance, so the partitioning merge is declined and the union loses its 
`KeyedPartitioning`.
    - `GroupPartitionsExec.outputOrdering` keeps the sort orders whose 
expression is a partition key expression, via 
`ExpressionSet(keyedPartitionings.flatMap(_.expressions)).contains(order.child)`.
 The reported ordering and the reported partitioning come from two separate 
binds, so the reported ordering is dropped.
    - `BatchScanExec.equals` compares `keyGroupedPartitioning`, so two 
identical bucketed scans miss plan reuse.
    - `DataSourceV2ScanRelation`'s canonical form does too, so `PlanMerger` 
cannot spot two identical subqueries over a bucketed table.
    
    SPARK-58549 made `PlanMerger` the one exception, comparing reported 
transforms by `isSameFunction` -- canonical name, arguments ignored -- instead 
of by equality. The inconsistency is sharpest inside `PlanMerger` itself: 
`checkIdenticalPlans` asks `newPlan.canonicalized == cachedPlan.canonicalized`, 
which for a connector without `equals` misses the very pair the workaround then 
merged the expensive way. So that connector was denied the cheap identical-plan 
reuse and granted the costly [...]
    
    Worth being explicit about what the removal is *not* justified by: at this 
one site the deleted comparison could not actually conflate two different 
functions. `tryMergeScanRelations` gates the merge on 
`np.relation.canonicalized == cp.relation.canonicalized` -- the same table, 
catalog, options and identifier -- and both reports come from binding that one 
`UnboundFunction` with the same input types, so the two instances differ only 
if `bind` is non-deterministic. The argument for remo [...]
    
    Iceberg's `BaseScalarFunction` implements `equals`/`hashCode` in terms of 
`canonicalName` and is therefore unaffected; it added them in 
apache/iceberg#9873, which is evidence a connector can be caught by this in 
practice. What was missing is that Spark never said it depended on them.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    - **No released behaviour changes.** The only behaviour this PR alters is 
the scan merge SPARK-58549 added, and that is in no release: it is on `master` 
and on `branch-4.x` at 4.4.0-SNAPSHOT, which are the two branches this PR 
targets. Every release that will ever contain the workaround will also contain 
this change, so no connector loses a merge it ever shipped with. A connector 
without `equals`/`hashCode` keeps missing the four matches above exactly as it 
does today.
    - **No API change.** The interface now declares `equals` and `hashCode`, 
but both are inherited from `Object`, so every existing implementation already 
satisfies them, nothing needs recompiling, and MiMa reports nothing. No new 
configuration.
    - **Nothing else moves.** No plan changes for any other connector, no 
change to `isSameFunction`, `isCompatible` or storage-partitioned join, and no 
change for a connector that implements the contract (Iceberg).
    - What does change is the published `BoundFunction` javadoc, which 
documents an obligation Spark already relied on.
    
    ### How was this patch tested?
    
    New `TransformExpressionSuite` (catalyst, 3 tests) for what the contract 
means: expression equality follows the function's own `equals` -- a 
non-comparable function's two binds do not compare equal, a shared instance 
does, and a comparable one deduplicates in an `ExpressionSet`; the two 
comparisons agree for a function that follows the contract, while still 
differing in what they take into account (`isSameFunction` ignores the 
arguments, equality does not); and a coarse `equals` does  [...]
    
    `MergeSubplansSuite` is where the behaviour change is covered, in both 
directions. Its `TestBucketFunction` now implements the contract, which is what 
keeps SPARK-58549's `merge DSv2 scans reporting the same bucket transform 
partitioning` passing with the workaround gone -- removing `equals`/`hashCode` 
from that fixture makes it **fail**, so it is now a check that Spark honours 
the contract rather than a check that the deleted helper worked. A new sibling 
pins the documented cost: wit [...]
    
    `build/sbt 'catalyst/testOnly *TransformExpressionSuite'` -- 3 pass. 
`build/sbt 'sql/testOnly *KeyGroupedPartitioningSuite 
*ProjectedOrderingAndPartitioningSuite *MergeSubplansSuite 
*DSv2PlanMergingSuite *PlanMergingSuite'` -- 230 pass. `dev/lint-scala` clean.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    Closes #58002 from peter-toth/SPARK-58769-transform-expression-equality.
    
    Authored-by: Peter Toth <[email protected]>
    Signed-off-by: Peter Toth <[email protected]>
    (cherry picked from commit 11ea3f7e8bb95d950ba449232666dbc4e74886a7)
    Signed-off-by: Peter Toth <[email protected]>
---
 .../connector/catalog/functions/BoundFunction.java | 42 ++++++++++
 .../expressions/TransformExpressionSuite.scala     | 93 ++++++++++++++++++++++
 .../sql/execution/planmerging/PlanMerger.scala     | 50 +++---------
 .../execution/planmerging/MergeSubplansSuite.scala | 88 ++++++++++++++++----
 4 files changed, 218 insertions(+), 55 deletions(-)

diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/BoundFunction.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/BoundFunction.java
index 53a1beb9c4b1..2c8c33f1653f 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/BoundFunction.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/BoundFunction.java
@@ -90,6 +90,14 @@ public interface BoundFunction extends Function {
    * functions in other catalogs. For example, many catalogs may define a 
"bucket" function with a
    * different implementation. Adding context, like 
"com.mycompany.bucket(string)", is recommended
    * to avoid unintentional collisions.
+   * <p>
+   * Two functions that partition data differently must not return the same 
name; Spark may
+   * otherwise treat unrelated data as co-partitioned. An override should 
return a stable name
+   * across {@code bind} calls, since Spark may bind a function multiple times 
and has only this
+   * name to relate two bound instances by. Equal functions must share the 
same canonical name;
+   * the reverse is not true, as this name is deliberately coarser and says 
nothing about the
+   * rest of a function's state. For whether two transform expressions are the 
same expression,
+   * see {@link #equals(Object)}.
    *
    * @return a canonical name for this function
    */
@@ -100,4 +108,38 @@ public interface BoundFunction extends Function {
     // bugs if not replaced before release.
     return UUID.randomUUID().toString();
   }
+
+  /**
+   * Implementations SHOULD override {@link Object#equals(Object)} and {@link 
Object#hashCode()}.
+   * <p>
+   * Spark may bind a function multiple times, so the same transform can be 
represented by two
+   * different bound instances. Without a semantic {@code equals} it cannot 
tell they are the
+   * same, and misses optimizations such as:
+   * <ul>
+   *   <li>keeping a union's keyed partitioning</li>
+   *   <li>retaining a reported ordering that matches the partitioning</li>
+   *   <li>reusing identical bucketed scans</li>
+   *   <li>recognizing two identical subplans</li>
+   * </ul>
+   * Missed matches cost performance only, never correctness.
+   * <p>
+   * Compare whatever state affects behaviour, and keep it stable across 
{@code bind} calls.
+   * {@link #canonicalName()} alone is not always enough: two functions can 
share a name and still
+   * differ in, say, {@link #resultType()} or a {@link ReducibleFunction}'s 
reducers. Two functions
+   * that can produce different values must not compare equal -- the same 
comparison decides whether
+   * two ordinary calls to a {@link ScalarFunction} or an {@link 
AggregateFunction} are the same
+   * expression, so Spark may otherwise evaluate one where the query asked for 
the other.
+   * {@code hashCode} must agree with {@code equals}.
+   * <p>
+   * If this method is overridden, {@link #canonicalName()} should be 
overridden as well, so that
+   * equal functions share the same name.
+   */
+  @Override
+  boolean equals(Object other);
+
+  /**
+   * Must agree with {@link #equals(Object)}. See that method.
+   */
+  @Override
+  int hashCode();
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/TransformExpressionSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/TransformExpressionSuite.scala
new file mode 100644
index 000000000000..1c41331eb900
--- /dev/null
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/TransformExpressionSuite.scala
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.expressions
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.connector.catalog.functions.{BoundFunction, 
ScalarFunction}
+import org.apache.spark.sql.types.{DataType, IntegerType}
+
+class TransformExpressionSuite extends SparkFunSuite {
+
+  /**
+   * A bound function with a stable canonical name and no `equals` of its own. 
A plain class, NOT a
+   * case class: a case class would bring structural `equals`, which is the 
connector-provided
+   * comparison these tests are trying to do without.
+   */
+  private class NamedFunction(canonical: String) extends ScalarFunction[Int] {
+    override def inputTypes(): Array[DataType] = Array(IntegerType)
+    override def resultType(): DataType = IntegerType
+    override def name(): String = canonical
+    override def canonicalName(): String = canonical
+  }
+
+  /** Honours the contract in `BoundFunction#equals`. */
+  private class ComparableFunction extends NamedFunction("test.comparable") {
+    override def equals(other: Any): Boolean = 
other.isInstanceOf[ComparableFunction]
+    override def hashCode(): Int = canonicalName().hashCode
+  }
+
+  private val a = AttributeReference("a", IntegerType)()
+  private val b = AttributeReference("b", IntegerType)()
+
+  private def bucket(function: BoundFunction, child: Expression, numBuckets: 
Int = 4) =
+    TransformExpression(function, Seq(child), Some(numBuckets))
+
+  test("SPARK-58769: expression equality follows the function's own equals") {
+    // Spark does not derive transform identity itself -- it defers to the 
connector, because only
+    // the connector knows which of its state matters. A function that does 
not implement `equals`
+    // therefore yields expressions that do not compare equal across separate 
binds, which costs
+    // deduplication and reuse but never correctness. See BoundFunction#equals.
+    assert(
+      bucket(new NamedFunction("test.bucket"), a) != bucket(new 
NamedFunction("test.bucket"), a),
+      "no equals on the function means no equality across binds")
+
+    val shared = new NamedFunction("test.bucket")
+    assert(bucket(shared, a) == bucket(shared, a), "a shared instance is equal 
either way")
+
+    // A function that does implement it gets the deduplication.
+    val left = bucket(new ComparableFunction, a)
+    val right = bucket(new ComparableFunction, a)
+    assert(left.function ne right.function, "the fixture must bind a fresh 
instance per call")
+    assert(left == right)
+    assert(left.semanticEquals(right))
+    assert(ExpressionSet(Seq(left, right)).size == 1)
+  }
+
+  test("SPARK-58769: the two comparisons agree for a function that follows the 
contract") {
+    // `equals` is the finer comparison and the canonical name the coarser 
one, so a function that
+    // overrides both answers both consistently. They still differ in what 
they take into account:
+    // `isSameFunction` ignores the arguments, because a join compares 
bucket(4, left.id) against
+    // bucket(4, right.id) and recovers the positions separately, while 
equality does not.
+    val left = bucket(new ComparableFunction, a)
+    val right = bucket(new ComparableFunction, b)
+    assert(left.function ne right.function, "the fixture must bind a fresh 
instance per call")
+    assert(left.function == right.function)
+    assert(left.function.canonicalName() == right.function.canonicalName())
+    assert(left.isSameFunction(right), "the same partition function, arguments 
aside")
+    assert(left != right, "but not the same expression, since the arguments 
differ")
+  }
+
+  test("SPARK-58769: the function's equals does not override the arguments") {
+    // A coarse comparison on the connector's side does not have to carry the 
whole identity: the
+    // transform's arguments and bucket count are compared separately, by 
Spark.
+    assert(bucket(new ComparableFunction, a) != bucket(new ComparableFunction, 
b),
+      "different argument")
+    assert(bucket(new ComparableFunction, a, 4) != bucket(new 
ComparableFunction, a, 8),
+      "different bucket count")
+  }
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
index b614985bed9e..0c967421283f 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
@@ -19,7 +19,7 @@ package org.apache.spark.sql.execution.planmerging
 
 import scala.collection.mutable
 
-import org.apache.spark.sql.catalyst.expressions.{Alias, And, Attribute, 
AttributeMap, AttributeSet, Expression, ExpressionSet, If, Literal, 
NamedExpression, Or, SortOrder, TransformExpression}
+import org.apache.spark.sql.catalyst.expressions.{Alias, And, Attribute, 
AttributeMap, AttributeSet, Expression, ExpressionSet, If, Literal, 
NamedExpression, Or, SortOrder}
 import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
 import org.apache.spark.sql.catalyst.plans.{Cross, Inner, JoinType, LeftAnti, 
LeftOuter, LeftSemi, RightOuter}
 import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, Join, 
LogicalPlan, Project}
@@ -858,49 +858,17 @@ class PlanMerger(
       }
   }
 
-  // Compares two reported partitioning expression lists. A 
TransformExpression must be compared by
-  // transform semantics -- `isSameFunction`, i.e. the connector's 
`canonicalName` and bucket count
-  // -- and NOT by the BoundFunction instance it holds. V2ExpressionUtils 
binds the function afresh
-  // on every derivation, and while `canonicalName` is a documented obligation 
(its default returns
-  // a random UUID precisely to force an override), `equals` on BoundFunction 
is not required
-  // anywhere. So for a connector that meets only the documented contract, 
`canonicalized ==` calls
-  // two identical `bucket(4, id)` reports different and declines the merge. 
Connectors that do
-  // define `equals` semantically are unaffected -- Iceberg's 
`BaseScalarFunction` compares on
-  // `canonicalName` -- but that is a courtesy we should not depend on. 
`isSameFunction` is the same
-  // notion a storage-partitioned join uses to compare its two sides, but only 
that part of it is
-  // borrowed: `KeyedShuffleSpec.isExpressionCompatible` matches any two 
leaves (it recovers the
-  // positions separately), ignores transform children and honours
-  // `v2BucketingAllowCompatibleTransforms`, none of which is sound here -- 
preserving a report
-  // means reproducing it exactly, so leaves and children must match too.
-  private def sameReportedExpressions(a: Seq[Expression], b: Seq[Expression]): 
Boolean = {
-    a.length == b.length && a.zip(b).forall {
-      case (l: TransformExpression, r: TransformExpression) =>
-        // Recurse, so a nested transform's children are compared by transform 
semantics too,
-        // rather than falling back to the instance-sensitive equality this 
method exists to avoid.
-        l.isSameFunction(r) && sameReportedExpressions(l.children, r.children)
-      case (l, r) => l semanticEquals r
-    }
-  }
-
-  // [[SortOrder.orderingSatisfies]] with the sort keys compared by 
`sameReportedExpressions` (see
-  // above) rather than semanticEquals. `sameOrderExpressions` needs no 
handling here: a reported
-  // ordering comes from V2ExpressionUtils.toCatalystOrdering, which always 
leaves it empty.
-  private def reportedOrderingSatisfies(o1: Seq[SortOrder], o2: 
Seq[SortOrder]): Boolean = {
-    o2.length <= o1.length && o2.zip(o1).forall { case (required, provided) =>
-      provided.direction == required.direction && provided.nullOrdering == 
required.nullOrdering &&
-        sameReportedExpressions(Seq(provided.child), Seq(required.child))
-    }
-  }
-
   // The key-grouped partitioning the merged scan must reproduce to keep both 
inputs not-worse: the
   // two must be equal, so a differing non-empty pair is INCOMPATIBLE (None); 
an empty side imposes
   // no constraint. Compared in cp's relation space (np's report was remapped 
into it by the
-  // caller).
+  // caller). A report carrying a `TransformExpression` compares equal across 
the two inputs only if
+  // the connector's `BoundFunction` implements `equals` -- Spark does not 
derive that identity
+  // itself, see `BoundFunction#equals` -- so a connector that does not gives 
up this merge.
   private def combineRequiredKeyGroupedPartitioning(
       a: Seq[Expression], b: Seq[Expression]): Option[Seq[Expression]] = {
     if (a.isEmpty) Some(b)
     else if (b.isEmpty) Some(a)
-    else if (sameReportedExpressions(a, b)) Some(a)
+    else if (a.map(_.canonicalized) == b.map(_.canonicalized)) Some(a)
     else None
   }
 
@@ -909,8 +877,8 @@ class PlanMerger(
   // neither satisfies the other they are INCOMPATIBLE (None). An empty 
ordering never constrains.
   private def combineRequiredOrdering(
       a: Seq[SortOrder], b: Seq[SortOrder]): Option[Seq[SortOrder]] = {
-    if (reportedOrderingSatisfies(a, b)) Some(a)
-    else if (reportedOrderingSatisfies(b, a)) Some(b)
+    if (SortOrder.orderingSatisfies(a, b)) Some(a)
+    else if (SortOrder.orderingSatisfies(b, a)) Some(b)
     else None
   }
 
@@ -936,10 +904,10 @@ class PlanMerger(
     val kgpDegraded = !dsv2AllowKeyGroupedPartitioningDegradation &&
       requiredKeyGroupedPartitioning.nonEmpty &&
       !merged.keyGroupedPartitioning.exists(
-        sameReportedExpressions(_, requiredKeyGroupedPartitioning))
+        _.map(_.canonicalized) == 
requiredKeyGroupedPartitioning.map(_.canonicalized))
     val orderingDegraded = !dsv2AllowOrderingDegradation &&
       requiredOrdering.nonEmpty &&
-      !reportedOrderingSatisfies(merged.ordering.getOrElse(Nil), 
requiredOrdering)
+      !SortOrder.orderingSatisfies(merged.ordering.getOrElse(Nil), 
requiredOrdering)
     kgpDegraded || orderingDegraded
   }
 
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
index bf689c73476f..670366ee87ed 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
@@ -2036,10 +2036,12 @@ class MergeSubplansSuite extends PlanTest {
    * exactly as in production, where each scan relation is annotated by its 
own derivation.
    */
   private def v2ScanReportingOn(
-      table: TestV2Table, cols: Seq[String]): DataSourceV2ScanRelation = {
+      table: TestV2Table,
+      cols: Seq[String],
+      funCatalog: FunctionCatalog = TestFreshBindFunctionCatalog): 
DataSourceV2ScanRelation = {
     val fullOutput = toAttributes(table.schema())
     val relation = DataSourceV2Relation(
-      table, fullOutput, Some(TestFreshBindFunctionCatalog), None, 
CaseInsensitiveStringMap.empty())
+      table, fullOutput, Some(funCatalog), None, 
CaseInsensitiveStringMap.empty())
     val output = cols.map(c => fullOutput.find(_.name == c).get)
     val builder = table.newScanBuilder(CaseInsensitiveStringMap.empty())
     builder.asInstanceOf[SupportsPushDownRequiredColumns].pruneColumns(
@@ -2779,11 +2781,11 @@ class MergeSubplansSuite extends PlanTest {
   test("SPARK-58549: merge DSv2 scans reporting the same bucket transform 
partitioning") {
     // Both inputs report `bucket(4, a)`, each derived independently, so each 
holds its OWN
     // BoundFunction instance -- what production does, since V2ExpressionUtils 
binds the function
-    // afresh per derivation and a BoundFunction defines no `equals`. 
Comparing the two reports by
-    // canonical equality would therefore call them different and decline the 
merge; they must be
-    // compared by transform semantics (isSameFunction), and then the merge 
proceeds and the rebuilt
-    // scan re-derives the same report. This is the only test that reaches the 
transform arm of
-    // sameReportedExpressions: an identity-partitioned source reports a plain 
attribute instead.
+    // afresh per derivation. The two reports compare equal only because 
`TestBucketFunction`
+    // implements `equals`/`hashCode` the way `BoundFunction` asks a connector 
to, so this is the
+    // end-to-end check that Spark honours that contract: the merge proceeds 
and the rebuilt scan
+    // re-derives the same report. It is also the only test here whose report 
is a transform rather
+    // than a plain attribute, which is what an identity-partitioned source 
would report.
     val q = testRelation.select(
       ScalarSubquery(v2ScanReportingOn(v2TableBucketedOnA, Seq("a", "b"))
         .groupBy()(sum($"b").as("sum_b"))),
@@ -2800,6 +2802,23 @@ class MergeSubplansSuite extends PlanTest {
       s"the preserved partitioning should be on a; got $kgp")
   }
 
+  test("SPARK-58769: decline the merge when the reported transform is not 
comparable") {
+    // The mirror of the test above, and the cost this documents: same query, 
same reported
+    // `bucket(4, a)`, but the connector's function does not implement 
`equals`, so the two
+    // independently bound instances do not compare equal and Spark cannot 
tell the two reports
+    // apart from two different partitionings. It does not derive that 
identity itself (see
+    // `BoundFunction#equals`), so the merge is declined rather than done on a 
report it cannot
+    // verify, and the plan is left alone.
+    val q = testRelation.select(
+      ScalarSubquery(
+        v2ScanReportingOn(v2TableBucketedOnA, Seq("a", "b"), 
TestNotComparableFunctionCatalog)
+          .groupBy()(sum($"b").as("sum_b"))),
+      ScalarSubquery(
+        v2ScanReportingOn(v2TableBucketedOnA, Seq("a", "c"), 
TestNotComparableFunctionCatalog)
+          .groupBy()(sum($"c").as("sum_c"))))
+    comparePlans(Optimize.execute(q.analyze), q.analyze)
+  }
+
   test("SPARK-58549: enforce the required report on the deferred under-Filter 
scan build") {
     // The above tests fuse scans directly under an Aggregate (no Filter), so 
they exercise the
     // scan build at the leaf. When the scans sit under an (identical) Filter 
the build is instead
@@ -3161,8 +3180,8 @@ private case class TestV2ReportingScan(
  * A `FunctionCatalog` that resolves `bucket`, binding it to a FRESH function 
instance every time --
  * as a real connector does, since `V2ExpressionUtils.loadV2FunctionOpt` binds 
the function afresh
  * for every derivation of a reported transform. Spark's own 
`UnboundBucketFunction` hands back a
- * singleton, which would hide the fact that two independently derived reports 
have to be
- * compared by transform semantics rather than by function instance.
+ * singleton, which would hide the fact that relating two independently 
derived reports rests on the
+ * function's own `equals` (see `BoundFunction#equals`).
  */
 private object TestFreshBindFunctionCatalog extends FunctionCatalog {
   override def initialize(name: String, options: CaseInsensitiveStringMap): 
Unit = {}
@@ -3183,15 +3202,56 @@ private object TestUnboundBucketFunction extends 
UnboundFunction {
 }
 
 /**
- * A bound `bucket` with no `equals`, so two instances are never `==` -- which 
is what makes the
- * fixture able to catch an instance-sensitive comparison. `canonicalName` is 
stable, as the
- * `BoundFunction` contract requires (its default returns a fresh random 
UUID); that stable name is
- * what identifies two separately bound instances as the same transform, for a 
storage-partitioned
- * join and for the merge's not-worse check alike.
+ * A bound `bucket` that implements `equals`/`hashCode` over the state 
identifying it, as
+ * `BoundFunction` asks a connector to. That is what lets Spark recognize two 
separately bound
+ * instances as the same transform: `V2ExpressionUtils` binds afresh on every 
derivation, so two
+ * reported partitionings hold two different instances and nothing but this 
comparison relates them.
+ * `canonicalName` is stable too (its default returns a fresh random UUID), 
which is what a
+ * storage-partitioned join compares.
  */
 private class TestBucketFunction extends ScalarFunction[Int] {
   override def inputTypes(): Array[DataType] = Array(IntegerType, IntegerType)
   override def resultType(): DataType = IntegerType
   override def name(): String = "bucket"
   override def canonicalName(): String = "testcat.bucket"
+
+  override def equals(other: Any): Boolean = other match {
+    case that: TestBucketFunction =>
+      canonicalName() == that.canonicalName() && resultType() == 
that.resultType() &&
+        // `Array` equality is reference identity, so compare the elements.
+        inputTypes().sameElements(that.inputTypes())
+    case _ => false
+  }
+
+  override def hashCode(): Int = canonicalName().hashCode
+}
+
+/**
+ * The same catalog with a bound `bucket` that does NOT implement 
`equals`/`hashCode` -- a connector
+ * that meets only the `canonicalName` obligation. Two of its separately bound 
instances never
+ * compare equal, so Spark cannot relate two reports of one transform.
+ */
+private object TestNotComparableFunctionCatalog extends FunctionCatalog {
+  override def initialize(name: String, options: CaseInsensitiveStringMap): 
Unit = {}
+  override def name(): String = "test_not_comparable"
+  override def listFunctions(namespace: Array[String]): Array[Identifier] =
+    Array(Identifier.of(Array.empty, "bucket"))
+  override def loadFunction(ident: Identifier): UnboundFunction = ident.name() 
match {
+    case "bucket" => TestUnboundNotComparableBucketFunction
+    case other => throw new UnsupportedOperationException(s"no such function: 
$other")
+  }
+}
+
+private object TestUnboundNotComparableBucketFunction extends UnboundFunction {
+  override def bind(inputType: StructType): BoundFunction = new 
TestNotComparableBucketFunction
+  override def description(): String = name()
+  override def name(): String = "bucket"
+}
+
+/** A plain class, so it inherits the identity comparison from `Object`. */
+private class TestNotComparableBucketFunction extends ScalarFunction[Int] {
+  override def inputTypes(): Array[DataType] = Array(IntegerType, IntegerType)
+  override def resultType(): DataType = IntegerType
+  override def name(): String = "bucket"
+  override def canonicalName(): String = "testcat.bucket"
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to