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 d8d72d2d3b18 [SPARK-59887][SPARK-59901][SQL] Fix wrong results when a 
storage-partitioned join pairs a transform of a join-key expression with one of 
a column
d8d72d2d3b18 is described below

commit d8d72d2d3b18d0357776c3cc06314482a25b3c7d
Author: Peter Toth <[email protected]>
AuthorDate: Mon Oct 5 17:14:35 2026 +0200

    [SPARK-59887][SPARK-59901][SQL] Fix wrong results when a 
storage-partitioned join pairs a transform of a join-key expression with one of 
a column
    
    ### What changes were proposed in this pull request?
    
    A storage-partitioned join now pairs a partition transform whose argument 
is not a bare column, i.e. is an expression or a struct field, only with a 
transform over the same argument. It also stops dropping such an argument where 
it used to:
    
    - `KeyedShuffleSpec.isExpressionCompatible` pairs a transform with an 
argument that is not an `Attribute` only with a transform over the same 
argument shape, i.e. the argument with its one column replaced by a 
placeholder. So `bucket(4, b + 1)` pairs with `bucket(4, c + 1)` on `b = c`, 
but not with `bucket(4, x)`. Over the same shape the two compare as two 
transforms of columns do. Under `allowCompatibleTransforms`, `bucket(8, b + 1)` 
reduces onto `bucket(4, c + 1)`, since a reduce ma [...]
    - `KeyedShuffleSpec.canCreatePartitioning` asks the same question, so such 
a layout is never the one other children are shuffled onto. 
`createPartitioning` replaces a transform's argument with the other child's 
cluster key, which drops whatever surrounds the key. It and the identity arm of 
`reducersBothWays` now rebuild a transform through one helper that asserts the 
argument is a column.
    - `ShuffleSpecCollection.canCreatePartitioning` is now true when any member 
can, and `EnsureRequirements.pickCoPartitionTarget` offers and ranks only the 
members that can. So one member that cannot serve no longer rules out a usable 
sibling. That also covers a bare expression such as `b + 1`, which master 
already refused.
    - `GroupPartitionsExec` rebuilds a reduced expression over each member's 
own argument when it reports the members of a collection. It used to re-target 
the column only.
    - `KeyedShuffleSpec.keyPositions` maps an expression with more than one 
reference, e.g. `bucket(4, b + c)`, to no position instead of failing an 
assertion (SPARK-59901).
    - `EnsureRequirements.createKeyedShuffleSpecs` counts only an expression 
over one column towards `spark.sql.requireAllClusterKeysForCoPartition`. An 
expression over two columns maps to no position, so a projection would drop it 
together with its columns.
    
    ### Why are the changes needed?
    
    With `spark.sql.sources.v2.bucketing.shuffle.enabled` on, this query 
returns 0 rows instead of 7. `t1(id)` and `t3(x)` are partitioned by `bucket(4, 
...)`, `plain(b)` is not partitioned, and each holds the values 0 to 7:
    
    ```sql
    SELECT t1.id, p.b, t3.x FROM t1
    JOIN plain p ON t1.id = p.b + 1
    JOIN t3 ON p.b = t3.x
    ```
    
    1. The first join shuffles `plain` onto `t1`'s partitioning. 
`KeyedShuffleSpec.createPartitioning` builds that partitioning from `plain`'s 
join key, so it is `bucket(4, b + 1)`. This join is correct.
    2. The second join clusters `plain` on the bare `b`. 
`KeyedShuffleSpec.keyPositions` maps a partition expression to a cluster key 
through its reference, so `bucket(4, b + 1)` counts as a function of `b`, the 
counterpart of `x`.
    3. `isSameFunction` compares only the function name and the bucket count. 
So `bucket(4, b + 1)` is the same as `t3`'s `bucket(4, x)`, and the join pairs 
the partitions as they stand. A row with `b = x` sits in bucket `(b + 1) % 4` 
on one side and in `x % 4` on the other.
    
    The comparison ignores which column an argument is, and relies on 
`keyPositions` to pair the columns up. That is sound only when the argument is 
the column itself. `bucket(4, b + 1)` is a function of `b`, but not the same 
function of it as `bucket(4, x)` is of `x`. A scan never reports a transform of 
an expression or of a struct field, but two planner paths build one. A one-side 
shuffle does, under `spark.sql.sources.v2.bucketing.shuffle.enabled`. An inner 
broadcast hash join does too [...]
    
    - **A broadcast join.** With every conf at its default, `SELECT /*+ 
BROADCAST(p) */ ... FROM t1 JOIN plain p ON t1.id = p.b + 1 JOIN t3 ON p.b = 
t3.x` returns 0 of 7 rows the same way, through the expanded `bucket(4, b + 1)` 
layout.
    - **The reducer path.** Under 
`spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, a 
`bucket(8, x)` third table loses the rows the same way.
    - **A struct field.** A join key `p.s.a` gives `bucket(4, s.a)`, which 
`keyPositions` pairs through `s`. Joining two such sides on `s` pairs 
`bucket(4, s.a)` with `bucket(4, s.b)` and returns 0 of 8 rows. Shuffling 
another side onto such a layout builds `bucket(4, s)`, which fails on executors 
with a `ClassCastException`.
    - **Shuffling onto the layout.** In `plain p JOIN t1 ON p.b + 1 = t1.id 
JOIN xy q ON t1.id = q.x AND p.b = q.y`, the second join shuffles `xy` onto the 
first join's `bucket(4, b + 1)` layout. That builds `bucket(4, y)` for `xy`, 
drops the `+ 1`, and returns 0 of 8 rows.
    - **After a reduce.** When a later join reduces the first join's output 
from `bucket(4)` onto `bucket(2)`, `GroupPartitionsExec` reports the `bucket(4, 
b + 1)` member as `bucket(2, b)`. A join on `b` then pairs it with a `bucket(2, 
x)` scan, or shuffles another side onto it, and returns 0 of 7 rows. The same 
happens to the bare `b + 1` member of a side shuffled onto an 
identity-partitioned table. The struct form fails with the `ClassCastException`.
    - **An identity side reduced onto it.** Under `allowCompatibleTransforms`, 
a later join can reduce an identity-partitioned side onto `bucket(4, b + 1)`. 
That evaluates `bucket(4, id + 1)` on the side's partition keys while planning. 
The keys come out right, but under ANSI a key of `Long.MaxValue` fails the 
query with `ARITHMETIC_OVERFLOW`, although the query never computes `id + 1`.
    - **A GROUP BY after a reduce.** With `allowCompatibleTransforms`, `SELECT 
/*+ BROADCAST(p) */ p.b, max(p.c) FROM ident i JOIN plain2 p ON i.id = p.b + 
p.c JOIN bucket2 t2 ON i.id = t2.id GROUP BY p.b` returns 4 groups instead of 
2. The reduce reports the `p.b + p.c` member as `bucket(2, p.b)`, which serves 
the `GROUP BY` as it stands.
    - **A key over two columns.** A join key such as `p.b + p.c` gives 
`bucket(4, b + c)`, or the bare `b + c` over an identity-partitioned side. Any 
spec over it fails planning with an `AssertionError` in `keyPositions` 
(SPARK-59901). AQE validates the plan in `OptimizeSkewedJoin`, so a single join 
is enough.
    
    An `ORDER BY` over such a partition expression had a related problem in 
another code path. That was SPARK-59905, fixed in #59189.
    
    The one-side shuffle of transform expressions came with SPARK-48012, in 
4.0.0. So did the broadcast expansion of a keyed layout, since SPARK-49205 made 
`KeyGroupedPartitioning` an expression.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, it fixes the wrong results, the crash and the planning failures above. 
Such a query now shuffles the side instead of pairing it or shuffling onto it.
    
    That costs shuffles in three shapes whose plan was already correct:
    - A widening cast counts as part of the argument's shape. When `t1.id` is 
`BIGINT` and `p.b` is `INT`, the first join reports `bucket(4, cast(b as 
bigint))`. A later join `p.b = t3.x` with an `INT` bucketed `t3` then shuffles 
both sides. Before, a connector whose bucket function has one canonical name 
for both types paired the two sides as they stood. This happens with every conf 
at its default, through a broadcast join. Two sides that both carry the cast 
still pair.
    - Under `allowCompatibleTransforms`, an identity-partitioned side is no 
longer reduced onto such a transform, so it is shuffled.
    - With `spark.sql.sources.v2.bucketing.shuffle.enabled`, a join on two keys 
whose sides pair only through a transform of an expression gets one more 
shuffle. Take two broadcast joins, `t1 JOIN /*+ BROADCAST(p) */ p ON t1.id = 
p.b + 1` and the same over `t2` and `q`, joined on `l.id = r.c AND l.b = r.b`. 
Each layout covers only one join key, so the storage-partitioned join declines 
under `requireAllClusterKeysForCoPartition`. Master then keeps both sides, 
paired on `bucket(4, b + 1)`.  [...]
    
    One plan gets better. A collection with a bare expression member, e.g. the 
`b + 1` of a side shuffled onto an identity-partitioned table, used to be 
refused as a layout as a whole. Now its other members can serve, so `ident i 
JOIN plain p ON i.id = p.b + 1 JOIN xy q ON i.id = q.x AND p.b = q.y` takes 2 
shuffles instead of 3.
    
    This PR keeps the fix small, since it has to reach the maintenance 
branches. SPARK-59900 is the follow-up for the rest: shuffling another side 
onto such a layout, and reducing an identity side onto it.
    
    ### How was this patch tested?
    
    New tests. All but two of them fail on master:
    - `ShuffleSpecSuite`, "SPARK-59887: a spec over a transform of an 
expression pairs only with the same shape". For an expression and a struct 
field argument, it covers the same shape, another shape and a column, both ways 
and with itself, the reduce in both directions, the two sides of one reduce, 
two sides reduced through different pairings, an identity side, 
`canCreatePartitioning`, and a collection with a usable sibling.
    - `ShuffleSpecSuite`, "SPARK-59901: an expression over two columns maps to 
no cluster key".
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a side shuffled onto a 
transform of an expression is not paired as it is". It runs the query above 
against a `bucket(4)` third table, and with `allowCompatibleTransforms` against 
a `bucket(8)` one that the join reduces, and asserts two shuffles.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a side is not shuffled onto 
a transform of an expression". It runs both join orders and asserts that `xy` 
is shuffled onto `bucket(4, x)` and never onto a bucket of `y`.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a broadcast join's layout 
over a join key is not paired as it is". It runs with every conf at its default 
and checks that the first join is a broadcast join.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a layout over a transform of 
a struct field is not paired nor shuffled onto". Master returns 0 of 8 rows.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a reduce keeps what each 
member's keys are computed from". It covers a bucketed and an 
identity-partitioned first table, a bucketed and an unpartitioned third table, 
and the struct form. Master returns 0 of 7 rows.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: an identity side is not 
reduced onto a transform of an expression". It runs with `p.b + 1` over a 
`Long.MaxValue` key and with `-p.b` over a `Long.MinValue` key. Master fails 
with the `ARITHMETIC_OVERFLOW` for both.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a reduce keeps a member over 
two columns". It runs the `GROUP BY` above. Master returns 4 groups instead of 
2.
    - `KeyGroupedPartitioningSuite`, "SPARK-59901: a join key over two columns 
is not paired through one of them". Master fails with the `AssertionError`.
    - `KeyGroupedPartitioningSuite`, "SPARK-59901: a join key over two columns 
does not cover its columns". It joins two broadcast joins that report `[b + c, 
d]` on `(b, c, d)` under `allowKeysSubsetOfPartitionKeys`, and asserts that the 
join is not paired on `d` alone. Master fails with the `AssertionError`.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: two layouts over the same 
shape of a join key pair as two columns do". It runs the cast case above with 
every conf at its default. It also runs two one-side shuffles onto `bucket(4, b 
+ 1)` and onto `bucket(4)` or `bucket(8)` of `q.b + 1`, where 
`allowCompatibleTransforms` reduces the `bucket(8)` one. It asserts that the 
join adds no shuffle. It passes on master, which pairs and reduces these as 
well.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: the same shape reduced 
together pairs only through the same pairing". Two legs each shuffle onto 
`bucket(12, b + 1)`. The first leg reduces with `bucket(8)`, so its keys are 
buckets of 4. The second leg reduces with `bucket(8)` too, with `bucket(18)`, 
or not at all, so its keys are buckets of 4, 6 or 12. It asserts 2 shuffles for 
the first and 4 for the other two. Master returns 0 of 23 rows for the third.
    - `KeyGroupedPartitioningSuite`, "SPARK-59887: a member that cannot serve 
does not rank its collection". It pins the ranking: `q` keeps its layout with 3 
partitions, as on master, where the collection is refused as a whole. Without 
the member filter, the collection would rank first with 5 partitions, and `q` 
would be shuffled onto 2.
    
    The SPARK-59905 test from #59189 now also runs its `p.b + p.c` query with 
AQE on. Master fails that run with the `AssertionError`.
    
    The three expression tests each run with the key `p.b + 1` and with `-p.b`. 
Master returns 0 of 8 rows for `p.b + 1` and 4 of 8 for `-p.b`. The `-p.b` key 
is the shape that also fails on 4.2, 4.1 and 4.0. On those branches `b + 1` 
already works, since its literal leaf blocks the pairing.
    
    Each change was removed on its own:
    - without the guard in `isExpressionCompatible`, the one-side shuffle and 
broadcast pairing tests, the struct field test and the reduce test return wrong 
rows, and the identity tests fail on the assertion in `rebuiltOver`;
    - without the shape pairing, the same-shape and same-pairing tests fail on 
the shuffle count;
    - with a reduce of such a pair refused, the same-shape test fails on the 
shuffle count;
    - with every pair of reduced keys over an expression refused, the 
same-pairing test fails on the shuffle count;
    - with the reduced marks ignored, or only checked on both sides, the 
same-pairing test returns 7 of 23 rows;
    - without the `canCreatePartitioning` clause, the one-side shuffle pairing, 
`xy`, struct field, reduce and same-pairing tests fail, all on the assertion in 
`rebuiltOver`;
    - with the `forall` back in `ShuffleSpecCollection.canCreatePartitioning`, 
the `xy` tests fail on the shuffle count;
    - without the member filter in `pickCoPartitionTarget`, 15 tests fail in 
`KeyGroupedPartitioningSuite` and `EnsureRequirementsSuite`. The filter now 
also does what the collection-level check before it did, so 8 of them are older 
tests. Of this PR's tests, 7 fail, 6 of them on the assertion in `rebuiltOver`. 
A member over the same shape pairs with itself, so only the filter keeps it 
from being the layout;
    - without the `GroupPartitionsExec` change, the reduce test returns 0 of 7 
rows, and the two-column `GROUP BY` returns 4 groups;
    - without the `requireAllClusterKeysForCoPartition` change, the coverage 
test pairs on `d` alone;
    - with the `keyPositions` assertion back, both SPARK-59901 tests in 
`KeyGroupedPartitioningSuite` fail.
    
    The first `ShuffleSpecSuite` test also fails for each change to 
`isExpressionCompatible` above.
    
    The `xy` tests pass with the guard removed, since the 
`canCreatePartitioning` clause and the member filter keep `xy` off that layout.
    
    Also ran `TransformExpressionSuite`, `ShuffleSpecSuite`, 
`DistributionSuite`, the `KeyGroupedPartitioning*` suites, 
`WriteDistributionAndOrderingSuite`, `PlannerSuite`, 
`ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`, 
`EnsureRequirementsSuite`, `BucketedReadWithoutHiveSupportSuite`, the plan 
stability suites, the `*JoinSuite` suites and `AdaptiveQueryExecSuite`, 1955 
tests in all, plus `dev/lint-scala`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5.5)
    
    Closes #59165 from peter-toth/SPARK-59887-spj-expression-key-identity.
    
    Authored-by: Peter Toth <[email protected]>
    Signed-off-by: Peter Toth <[email protected]>
---
 .../catalyst/expressions/TransformExpression.scala |  10 -
 .../sql/catalyst/plans/physical/partitioning.scala | 161 +++++++--
 .../spark/sql/catalyst/ShuffleSpecSuite.scala      | 107 +++++-
 .../expressions/TransformExpressionSuite.scala     |   4 -
 .../datasources/v2/GroupPartitionsExec.scala       |  15 +-
 .../execution/exchange/EnsureRequirements.scala    |  33 +-
 .../connector/KeyGroupedPartitioningSuite.scala    | 392 ++++++++++++++++++++-
 7 files changed, 642 insertions(+), 80 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala
index 191de9bf5062..bfa94914f4ae 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala
@@ -123,16 +123,6 @@ case class TransformExpression(
     }
   }
 
-  /**
-   * Re-targets this partition transform expression at `attr`. A partition 
transform expression
-   * has a single leaf attribute (`KeyedPartitioning.supportsExpressions`), so 
this replaces that
-   * attribute and keeps the rest of the expression: any field path above the 
leaf comes from this
-   * expression, not from the re-targeted key. Re-targeting is therefore only 
faithful when the
-   * source and the target key expressions have the same path shape.
-   */
-  def withReference(attr: Attribute): TransformExpression =
-    transform { case _: AttributeReference => attr 
}.asInstanceOf[TransformExpression]
-
   // Return a Reducer for a reducible function on another reducible function
   private def reducer(
       thisFunction: ReducibleFunction[_, _],
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala
index bfc4d7ac8eab..f14410eb0ae5 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala
@@ -26,6 +26,7 @@ import scala.collection.mutable
 import org.apache.spark.{SparkException, SparkUnsupportedOperationException}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.plans.QueryPlan
 import org.apache.spark.sql.catalyst.util.InternalRowComparableWrapper
 import org.apache.spark.sql.connector.catalog.functions.Reducer
 import org.apache.spark.sql.internal.SQLConf
@@ -873,7 +874,7 @@ case class KeyedPartitioning(
    * Not the same question as `KeyedShuffleSpec.keyPositions`, and the two 
must not be merged. That
    * one answers, per expression, which cluster key the expression is a 
function *of*. That is
    * what `KeyedShuffleSpec.createPartitioning` needs, since it rebuilds the 
expression over the
-   * other side's clustering with `te.copy(children = ... 
clustering(positionSet.head))`. Handing it
+   * other side's clustering with `rebuiltOver(te, 
clustering(positionSet.head))`. Handing it
    * the second form above would rebuild a `years(ts)` clustered on 
`years(ts)` as
    * `years(years(ts))`.
    * The two coincide exactly where every expression has a single reference 
and no expression is
@@ -975,9 +976,9 @@ case class KeyedPartitioning(
    * `keysSatisfy` may read the partition keys to decide whether a projection 
would merge anything.
    * `mayProjectToClusterKeys` holds the permission.
    *
-   * Given the single reference per expression that `supportsExpressions` 
enforces, that case
-   * coincides with `KeyedShuffleSpec.keyPositions.exists(_.nonEmpty)`, where 
`createShuffleSpec`
-   * takes its `joinKeyPositions` from. They cannot be merged into one 
derivation, see
+   * Where every partition expression has a single reference, that case 
coincides with
+   * `KeyedShuffleSpec.keyPositions.exists(_.nonEmpty)`, where 
`createShuffleSpec` takes its
+   * `joinKeyPositions` from. They cannot be merged into one derivation, see
    * `positionsCoveringClusterKeys`.
    */
   private def keysCanSatisfy(required: Distribution): Boolean = {
@@ -1035,7 +1036,8 @@ case class KeyedPartitioning(
       // same refusal `EnsureRequirements.resolveKeyedPartitioning` makes for 
its own caller.
       // `keyPositions` maps an expression's single *reference* onto the 
clustering, so this is
       // reachable for a clustering that names the partition expression 
itself, where `keysSatisfy`
-      // says yes and this finds nothing. Return the unprojected spec, as 
above.
+      // says yes and this finds nothing. It is reachable for an expression 
over several columns
+      // too, e.g. `bucket(4, b + c)`. Return the unprojected spec, as above.
       if (joinKeyPositions.isEmpty && expressions.nonEmpty) {
         return result
       }
@@ -1375,11 +1377,7 @@ case class PartitioningCollection(partitionings: 
Seq[Partitioning])
     // through here.
     //
     // Every admitted member stays, because `isCompatibleWith` answers for any 
of them and the
-    // collection cannot know which one the other side matched. The cost is 
that
-    // `ShuffleSpecCollection.canCreatePartitioning` is a `forall` over 
members whose keyed half is
-    // false without `v2BucketingShuffleEnabled`, so a groupable keyed member 
beside a usable
-    // non-keyed one would cost the collection its role as a shuffle template. 
No operator is known
-    // to report that mixture.
+    // collection cannot know which one the other side matched.
     val filtered =
       partitionings.filter(PartitioningCollection.maySatisfyAfterProjection(_, 
distribution))
     ShuffleSpecCollection(filtered.map(_.createShuffleSpec(distribution)))
@@ -1588,15 +1586,23 @@ sealed trait ShuffleSpec {
    * co-partitioned with the `other`, and therefore no shuffle is required 
when joining the two
    * sides.
    *
-   * Note that Spark assumes this to be reflexive, symmetric and transitive.
+   * Note that Spark assumes this to be reflexive, symmetric and transitive. 
Some specs are not
+   * reflexive:
+   *  - a [[RangeShuffleSpec]], whose boundaries are sampled;
+   *  - a [[KeyedShuffleSpec]] with a partition expression that `keyPositions` 
maps to no cluster
+   *    key, e.g. `years(ts)` clustered on `years(ts)`, or `bucket(4, b + c)`;
+   *  - a [[KeyedShuffleSpec]] with a partition expression that is neither a 
column nor a transform,
+   *    e.g. `b + 1` or `s.a`.
+   * None of them answers [[canCreatePartitioning]], which keeps the planner 
from picking one as
+   * the layout to shuffle the other children onto.
    */
   def isCompatibleWith(other: ShuffleSpec): Boolean
 
   /**
    * Whether this shuffle spec can be used to create partitionings for the 
other children. A
-   * [[ShuffleSpecCollection]] answers for the whole choice, since the planner 
asks it of a child's
-   * spec as a whole. Building the partitioning is 
[[LeafShuffleSpec.createPartitioning]], and that
-   * is always one member's job.
+   * [[ShuffleSpecCollection]] can when any of its members can. Building the 
partitioning is
+   * [[LeafShuffleSpec.createPartitioning]], and that is always one member's 
job, so the planner
+   * builds it from a member that can.
    */
   def canCreatePartitioning: Boolean
 
@@ -1873,10 +1879,10 @@ case class CoalescedHashShuffleSpec(
  *                          keys, and this is this side's own expression 
marked with the pairing
  *                          that reduced it 
(`TransformExpression.reducedWith`). Stored as-is from
  *                          the expression pair that produced the reducer; the 
only structural
- *                          consumer, `GroupPartitionsExec`, re-targets it at 
each reported
- *                          `KeyedPartitioning`'s own key attribute in 
`outputPartitioning` and
+ *                          consumer, `GroupPartitionsExec`, rebuilds it over 
each reported
+ *                          `KeyedPartitioning`'s own argument in 
`outputPartitioning` and
  *                          normalizes it positionally in `doCanonicalize`, so 
the attribute it
- *                          carries here is not load-bearing.
+ *                          carries here does not matter.
  */
 case class KeyReducer(reducer: Reducer[_, _], reducedExpression: 
TransformExpression)
 
@@ -1889,11 +1895,13 @@ case class KeyReducer(reducer: Reducer[_, _], 
reducedExpression: TransformExpres
  * It is a case class so that structurally identical reducers compare by 
value, and plans holding
  * them stay canonicalization-equal.
  *
- * @param transform the partition transform, re-targeted at the identity 
side's key attribute
+ * @param transform the partition transform, rebuilt over the identity side's 
key attribute
  */
 case class IdentityReducer(transform: TransformExpression) extends 
Reducer[Any, Any] {
-  // `transform` has a single leaf attribute 
(`KeyedPartitioning.supportsExpressions`), which is
-  // bound to ordinal 0 of the single-value row `reduce` evaluates it against.
+  // `transform` is over a single bare column, which is bound to ordinal 0 of 
the single-value row
+  // `reduce` evaluates it against. `KeyedShuffleSpec.isExpressionCompatible` 
admits only bare
+  // column arguments here, and `keyPositions` maps an expression to a 
position only when it has
+  // one reference.
   @transient private lazy val bound: Expression =
     BindReferences.bindReference(transform, 
AttributeSeq(transform.references.toSeq))
 
@@ -1941,8 +1949,20 @@ case class KeyedShuffleSpec(
    * `KeyedPartitioning.positionsCoveringClusterKeys` counts it. See that 
method for why the two
    * stay apart.
    *
-   * The assertion is what makes the reference reading sound. With one 
reference per expression,
-   * "some reference is a cluster key" and "every reference is" are the same 
statement.
+   * Only an expression with a single reference maps to a position. With one 
reference, "some
+   * reference is a cluster key" and "every reference is" are the same 
statement, which is what
+   * makes the reference reading sound. A scan reports no other expression
+   * (`KeyedPartitioning.supportsExpressions`), but two planner paths build 
one over the other
+   * side's join key, e.g. `bucket(4, b + c)` from `b + c`. 
`createPartitioning` does, and so does
+   * a broadcast hash join, which reports the streamed side's layout over the 
build side's join
+   * key. No single cluster key stands for such an expression, so it maps to 
nothing.
+   * `canCreatePartitioning` and `areKeysCompatible` then turn the spec away. 
Under
+   * `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`,
+   * `KeyedPartitioning.createShuffleSpec` projects the position away from an 
unmarked layout
+   * where another position maps.
+   *
+   * This says which cluster key an expression is a function of, not which 
function it is.
+   * `bucket(4, b + 1)` maps to `b` just as `bucket(4, b)` does, see 
`argumentsAreColumns`.
    */
   lazy val keyPositions: Seq[mutable.BitSet] = {
     val distKeyToPos = mutable.Map.empty[Expression, mutable.BitSet]
@@ -1951,8 +1971,11 @@ case class KeyedShuffleSpec(
     }
     partitioning.expressions.map { e =>
       val refs = e.references
-      assert(refs.size == 1, s"Expected exactly one child from $e, but found 
${refs.size}")
-      distKeyToPos.getOrElse(refs.head.canonicalized, mutable.BitSet.empty)
+      if (refs.size == 1) {
+        distKeyToPos.getOrElse(refs.head.canonicalized, mutable.BitSet.empty)
+      } else {
+        mutable.BitSet.empty
+      }
     }
   }
 
@@ -1967,9 +1990,10 @@ case class KeyedShuffleSpec(
     //    3.2 for each pair of partition expressions at the same index, the 
corresponding
     //        partition keys must share overlapping positions in their 
respective clustering keys.
     //    3.3 each pair of partition expressions at the same index must 
describe one key space: two
-    //        bare references, the same transform function, or the two sides 
of one reduce. A pair
-    //        the join would first reduce onto one key space does not count, 
see
-    //        `areKeysCompatible`'s `allowReduce`.
+    //        bare references, the same transform function, or two transforms 
reduced through the
+    //        same pairing. A transform of an expression counts only against 
one over the same
+    //        argument shape. A pair the join would first reduce onto one key 
space does not count,
+    //        see `areKeysCompatible`'s `allowReduce`.
     //  4. the partition values from both sides are following the same order.
     case otherSpec @ KeyedShuffleSpec(otherPartitioning, otherDistribution, _) 
=>
       distribution.clustering.length == otherDistribution.clustering.length &&
@@ -2061,7 +2085,16 @@ case class KeyedShuffleSpec(
       left: Expression,
       right: Expression,
       allowReduce: Boolean): Boolean = {
-    if (TransformExpression.hasReducedKeys(left) || 
TransformExpression.hasReducedKeys(right)) {
+    val overExpression = Seq(left, right).exists {
+      case t: TransformExpression => !argumentsAreColumns(t)
+      case _ => false
+    }
+    if (overExpression && 
!argumentShape(left).exists(argumentShape(right).contains)) {
+      // A transform of an expression pairs only with a transform over the 
same argument shape.
+      // See `argumentsAreColumns`.
+      false
+    } else if (
+        TransformExpression.hasReducedKeys(left) || 
TransformExpression.hasReducedKeys(right)) {
       // Reduced keys are in a key space that neither transform names, so 
comparing the transforms
       // says nothing about whether the two sides are laid out the same way. 
The pair that was
       // reduced together is laid out the same way, since its two sides came 
out of one reduce onto
@@ -2086,6 +2119,48 @@ case class KeyedShuffleSpec(
     }
   }
 
+  /**
+   * Whether every argument of `transform` is a bare column. 
`isExpressionCompatible` asks it of
+   * both sides before it compares anything, `canCreatePartitioning` asks it 
of a template, and
+   * `rebuiltOver` asserts it.
+   *
+   * `keyPositions` pairs two sides up by the column each expression 
references. That pairing
+   * describes one key space when the argument is the column itself. 
`bucket(4, b + 1)` is a
+   * function of `b`, but not the same function of it as `bucket(4, x)` is of 
`x`. Nor is
+   * `bucket(4, s.a)` the same function of `s` as `bucket(4, t.b)` is of `t`. 
So such a transform
+   * pairs only with a transform over the same `argumentShape`. Over the same 
shape, the two
+   * compare as two transforms of columns do, e.g. `bucket(4, b + 1)` with 
`bucket(4, c + 1)` on a
+   * join `b = c`. A reduce of `bucket(8, b + 1)` onto `bucket(4, c + 1)` is 
fine too, since it
+   * maps the partition keys and evaluates no argument. An identity side is 
not reduced onto such a
+   * transform, since that would evaluate `id + 1` on keys the query never 
computes it for, which
+   * can overflow.
+   */
+  private def argumentsAreColumns(transform: TransformExpression): Boolean =
+    transform.children.forall(_.isInstanceOf[Attribute])
+
+  /**
+   * `transform`, whose arguments are bare columns, with each replaced by 
`key`, which may be any
+   * expression. Any other argument would lose what surrounds its column, e.g. 
the `+ 1` of
+   * `bucket(4, b + 1)`.
+   */
+  private def rebuiltOver(transform: TransformExpression, key: Expression)
+      : TransformExpression = {
+    assert(argumentsAreColumns(transform), s"Cannot rebuild $transform over 
$key")
+    transform.copy(children = transform.children.map(_ => key))
+  }
+
+  /**
+   * The arguments of a transform with their one column replaced by a 
placeholder of its type, or
+   * `None` when they reference other than one column or are not 
deterministic, or `expression` is
+   * not a transform. Two transforms with the same function and shape are the 
same function of
+   * their columns, so a join that pairs the columns up pairs the keys up too.
+   */
+  private def argumentShape(expression: Expression): Option[Seq[Expression]] = 
expression match {
+    case t: TransformExpression if t.references.size == 1 && t.deterministic =>
+      Some(t.children.map(QueryPlan.normalizeExpressions(_, 
t.references.toSeq)))
+    case _ => None
+  }
+
   /**
    * Whether a join may reduce one or both sides' partition keys onto a common 
key space, which is
    * what lets two different transforms be compatible in the first place.
@@ -2135,8 +2210,8 @@ case class KeyedShuffleSpec(
           if (otherReducer.isEmpty) {
             // Only this side reduces. The reducer contract is r(f1(x)) = 
f2(x) where "=" matches
             // both value and data type, so the reduced keys equal the target 
transform applied to
-            // this side's child. Report the target transform (re-targeted at 
the reporting
-            // partitioning's key attribute by `GroupPartitionsExec`) instead 
of the un-reduced
+            // this side's child. Report the target transform (rebuilt over 
the reporting
+            // partitioning's own argument by `GroupPartitionsExec`) instead 
of the un-reduced
             // `e1`, whose type can differ from the reduced keys. A connector 
that violates the
             // contract with a reducer of a different result type fails the 
reduced-types check in
             // `EnsureRequirements`, since the other side's keys are typed by 
the target transform.
@@ -2164,15 +2239,16 @@ case class KeyedShuffleSpec(
         (thisResult, otherResult)
 
       // Identity transform on this side, arbitrary transform on the other 
side: create a reducer
-      // that applies the other's transform to the raw identity values. Each 
partition expression
-      // is guaranteed to have exactly one leaf child (asserted in 
keyPositions), which
-      // `IdentityReducer` binds to ordinal 0.
+      // that applies the other's transform to the raw identity values. 
`areKeysCompatible` admitted
+      // this pair, so `t`'s argument is one bare column. 
`isExpressionCompatible` refuses any other
+      // argument here, and `keyPositions` maps an expression with more 
references to no position.
+      // `IdentityReducer` binds `a` to ordinal 0.
       case (a: AttributeReference, t: TransformExpression) =>
-        (Some(KeyReducer(IdentityReducer(t.withReference(a)), t)), None)
+        (Some(KeyReducer(IdentityReducer(rebuiltOver(t, a)), t)), None)
 
       // Symmetric: identity transform on the other side.
       case (t: TransformExpression, a: AttributeReference) =>
-        (None, Some(KeyReducer(IdentityReducer(t.withReference(a)), t)))
+        (None, Some(KeyReducer(IdentityReducer(rebuiltOver(t, a)), t)))
 
       case (_, _) => (None, None)
     }
@@ -2199,8 +2275,14 @@ case class KeyedShuffleSpec(
       // cannot rewrite it. This also keeps the unprojected spec returned by 
`createShuffleSpec`
       // for a marked narrowing projection from being chosen as the best spec.
       keyPositions.forall(_.nonEmpty) &&
-      partitioning.expressions.forall { e =>
-        e.isInstanceOf[AttributeReference] || 
e.isInstanceOf[TransformExpression]
+      partitioning.expressions.forall {
+        case _: AttributeReference => true
+        // `createPartitioning` replaces a transform's argument with the other 
child's cluster key,
+        // which drops whatever surrounds the key, e.g. the `+ 1` of 
`bucket(4, b + 1)` or the `.a`
+        // of `bucket(4, s.a)`. Such a spec's own child would then be laid out 
differently from the
+        // children shuffled onto it.
+        case t: TransformExpression => argumentsAreColumns(t)
+        case _ => false
       } &&
       // Shuffling another child onto these keys evaluates the partition 
expressions per row to
       // decide where each row goes, and reduced keys are not what those 
expressions compute.
@@ -2212,7 +2294,8 @@ case class KeyedShuffleSpec(
 
     val newExpressions = partitioning.expressions.zip(keyPositions).map {
       case (te: TransformExpression, positionSet) =>
-        te.copy(children = te.children.map(_ => clustering(positionSet.head)))
+        // `canCreatePartitioning` admits only a template over bare columns, 
see there.
+        rebuiltOver(te, clustering(positionSet.head))
       case (_, positionSet) => clustering(positionSet.head)
     }
     // The shuffled side is laid out on this side's partitions, so it shares 
their layout, with one
@@ -2289,7 +2372,7 @@ case class ShuffleSpecCollection(specs: Seq[ShuffleSpec]) 
extends ShuffleSpec {
   }
 
   override def canCreatePartitioning: Boolean =
-    specs.forall(_.canCreatePartitioning)
+    specs.exists(_.canCreatePartitioning)
 
   override def flatten: Seq[LeafShuffleSpec] = specs.flatMap(_.flatten)
 }
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/ShuffleSpecSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/ShuffleSpecSuite.scala
index 86eec4be3870..a73235118c57 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/ShuffleSpecSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/ShuffleSpecSuite.scala
@@ -19,7 +19,7 @@ package org.apache.spark.sql.catalyst
 
 import org.apache.spark.{SparkFunSuite, SparkUnsupportedOperationException}
 import org.apache.spark.sql.catalyst.dsl.expressions._
-import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, DirectShufflePartitionID, Expression, TransformExpression}
+import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, DirectShufflePartitionID, Expression, GetStructField, 
TransformExpression}
 import org.apache.spark.sql.catalyst.plans.SQLHelper
 import org.apache.spark.sql.catalyst.plans.physical._
 import org.apache.spark.sql.connector.catalog.functions.{FlipLowBitFunction, 
Reducer, ReducibleFunction, ScalarFunction}
@@ -761,6 +761,111 @@ class ShuffleSpecSuite extends SparkFunSuite with 
SQLHelper {
     }
   }
 
+  test("SPARK-59887: a spec over a transform of an expression pairs only with 
the same shape") {
+    // A join pairs its two sides up by the column each partition expression 
references. Over
+    // `a = b` it would pair `bucket(4, a + 1)` with `bucket(4, b)`, and over 
`s = t` it would pair
+    // `bucket(4, s.x)` with `bucket(4, t.y)`. A row with `a = b` sits in a 
different bucket on each
+    // side. `bucket(4, a + 1)` and `bucket(4, b + 1)` are the same function 
of `a` and of `b`, so
+    // they pair up.
+    val fn = new FakeBucket
+    val a = $"a".long
+    val b = $"b".long
+    val structType = new StructType().add("x", LongType).add("y", LongType)
+    val s = $"s".struct(structType)
+    val t = $"t".struct(structType)
+    // A spec clustered on the column its one partition expression references.
+    def keyed(expression: Expression): KeyedShuffleSpec =
+      KeyedShuffleSpec(
+        KeyedPartitioning(Seq(expression), Seq(InternalRow(0L), 
InternalRow(1L))),
+        ClusteredDistribution(expression.references.toSeq))
+    def bucket(numBuckets: Int, argument: Expression): TransformExpression =
+      TransformExpression(fn, Seq(argument), Some(numBuckets))
+    def spec(numBuckets: Int, argument: Expression): KeyedShuffleSpec =
+      keyed(bucket(numBuckets, argument))
+    // Whether the two sides of one reduce, `bucket(8, left)` with `bucket(4, 
right)`, pair up.
+    def pairedByReduce(left: Expression, right: Expression): Boolean =
+      keyed(bucket(8, left).reducedTogetherWith(bucket(4, right)))
+        .isCompatibleWith(keyed(bucket(4, right).reducedTogetherWith(bucket(8, 
left))))
+    val column = spec(4, b)
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED.key -> 
"false",
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      assert(spec(4, a).isCompatibleWith(column), "two columns pair up")
+      assert(spec(4, a).canCreatePartitioning)
+      assert(spec(8, a).areKeysCompatible(column, allowReduce = true),
+        "the fixture reduces two columns")
+      assert(pairedByReduce(a, b), "two columns reduced together share one key 
space")
+      assert(keyed(b).areKeysCompatible(spec(4, a), allowReduce = true),
+        "an identity side reduces onto a column's transform")
+
+      Seq(
+        ("an expression", a + 1L, b + 1L, b + 2L),
+        ("a struct field", GetStructField(s, 0), GetStructField(t, 0), 
GetStructField(t, 1))
+      ).foreach { case (shape, argument, sameShape, otherShape) =>
+        val over = spec(4, argument)
+        assert(over.isCompatibleWith(spec(4, sameShape)), s"over $shape, the 
same shape")
+        assert(over.isCompatibleWith(over), s"over $shape, with itself")
+        assert(!over.isCompatibleWith(spec(4, otherShape)), s"over $shape, 
another shape")
+        assert(!over.isCompatibleWith(column), s"over $shape, against a 
column")
+        assert(!column.isCompatibleWith(over), s"over $shape, the other way 
round")
+        assert(!over.canCreatePartitioning, s"over $shape, no layout to 
shuffle onto")
+
+        // Over the same shape, such a pair reduces as two columns do, 
whichever side reduces. It
+        // does not reduce onto another shape or a column, and an identity 
side does not reduce
+        // onto it.
+        assert(spec(8, argument).areKeysCompatible(spec(4, sameShape), 
allowReduce = true),
+          s"over $shape, reducing the same shape")
+        assert(spec(4, sameShape).areKeysCompatible(spec(8, argument), 
allowReduce = true),
+          s"over $shape, reducing the same shape the other way round")
+        assert(!spec(8, argument).areKeysCompatible(spec(4, otherShape), 
allowReduce = true),
+          s"over $shape, reducing another shape")
+        assert(!spec(8, argument).areKeysCompatible(column, allowReduce = 
true), s"over $shape")
+        assert(!column.areKeysCompatible(spec(8, argument), allowReduce = 
true), s"over $shape")
+        assert(!over.areKeysCompatible(spec(8, b), allowReduce = true), s"over 
$shape")
+        assert(!keyed(b).areKeysCompatible(over, allowReduce = true),
+          s"over $shape, reducing an identity side")
+        // Keys an earlier join reduced are in the space of that reduce. Two 
transforms reduced
+        // through the same pairing pair over the same shape, as two columns 
do. `bucket(12)`
+        // reduced with `bucket(8)` holds buckets of 4, with `bucket(18)` 
buckets of 6, and
+        // unreduced buckets of 12.
+        assert(pairedByReduce(argument, sameShape), s"over $shape, reduced 
together")
+        assert(!pairedByReduce(argument, otherShape),
+          s"over $shape, another shape reduced together")
+        val reducedWith8 = keyed(bucket(12, 
argument).reducedTogetherWith(bucket(8, a)))
+        assert(!reducedWith8.isCompatibleWith(
+          keyed(bucket(12, sameShape).reducedTogetherWith(bucket(18, b)))),
+          s"over $shape, reduced through different pairings")
+        assert(!reducedWith8.isCompatibleWith(spec(12, sameShape)),
+          s"over $shape, reduced against unreduced")
+        assert(!spec(12, sameShape).isCompatibleWith(reducedWith8),
+          s"over $shape, unreduced against reduced")
+
+        // One such member does not keep a sibling from serving as the layout.
+        assert(ShuffleSpecCollection(Seq(over, spec(4, 
a))).canCreatePartitioning)
+      }
+    }
+  }
+
+  test("SPARK-59901: an expression over two columns maps to no cluster key") {
+    // A one-side shuffle builds its partition expression over the other 
side's join key, which can
+    // reference two columns. No single cluster key stands for it.
+    val a = $"a".long
+    val b = $"b".long
+    Seq(a + b, TransformExpression(new FakeBucket, Seq(a + b), 
Some(4))).foreach { e =>
+      val twoColumns = KeyedShuffleSpec(
+        KeyedPartitioning(Seq(e), Seq(InternalRow(0L), InternalRow(1L))),
+        ClusteredDistribution(Seq(a, b)))
+      assert(twoColumns.keyPositions.forall(_.isEmpty), s"$e")
+      assert(!twoColumns.isCompatibleWith(twoColumns), s"$e")
+      withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+        assert(!twoColumns.canCreatePartitioning, s"$e")
+      }
+    }
+  }
+
   test("createShuffleSpec: a marked narrowing projection yields an unusable 
spec") {
     val a = $"a".int
     val b = $"b".int
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
index db24614774f4..d333b4144161 100644
--- 
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
@@ -111,9 +111,5 @@ class TransformExpressionSuite extends SparkFunSuite {
     assert(!left.hasSameReducedKeys(b12), "an unreduced side never shares one")
     assert(!b12.hasSameReducedKeys(left))
     assert(!b12.hasSameReducedKeys(b8), "nor do two unreduced ones")
-
-    // The marker rides on the expression, so it survives the attribute 
rewrites a projection and
-    // `GroupPartitionsExec` apply to a reported partitioning.
-    assert(left.hasSameReducedKeys(left.withReference(b)))
   }
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala
index ea6adcd814dc..2cdc6b9d410b 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala
@@ -570,8 +570,8 @@ private[sql] object GroupPartitionsExec {
     // can only differ in `expressions`, since they share one `KeyLayout` 
(enforced by
     // `PartitioningCollection`). So the grouping is computed once, and the 
layout it describes
     // is shared by the members below.
-    // When reducers are applied, the stored reduced expressions are 
re-targeted at each
-    // `KeyedPartitioning`'s own key attribute and reported instead of the 
original ones. Their
+    // When reducers are applied, the stored reduced expressions are rebuilt 
over each
+    // `KeyedPartitioning`'s own argument and reported instead of the original 
ones. Their
     // data types match the reduced partition keys for the 
identity-vs-transform and
     // single-side-transform reducers; for the both-sides-reduce shape no 
single transform
     // describes the keys, so the reduce marks it (see 
`KeyedShuffleSpec.reducersBothWays`).
@@ -604,8 +604,15 @@ private[sql] object GroupPartitionsExec {
                   // `reduced` came from the one member 
`checkKeyGroupCompatible` paired
                   // this side on, which need not be the member being 
rewritten. The keys are
                   // reduced once, from the shared key rows, so `reduced` 
describes them
-                  // whichever member this is, and only the key attribute is 
re-targeted.
-                  reduced.withReference(expr.references.head)
+                  // whichever member this is. Only its function says that, so 
the member's own
+                  // argument takes the place of `reduced`'s. A side shuffled 
onto this layout
+                  // reports `bucket(8, b + 1)` next to `bucket(8, id)`, or `b 
+ 1` next to an
+                  // identity `id`.
+                  val argument = expr match {
+                    case t: TransformExpression => t.children
+                    case e => Seq(e)
+                  }
+                  reduced.copy(children = argument)
                 case (expr, None) => expr
               }
             case None => projectedExpressions
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
index b6a01a4ed73b..af186f4d0a62 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala
@@ -377,25 +377,28 @@ case class EnsureRequirements(
     val shouldConsiderMinParallelism = children.zip(specs).forall { case 
(child, spec) =>
       spec.forall(!_.canCreatePartitioning) || 
child.isInstanceOf[ShuffleExchangeLike]
     }
-    // Choose all the specs that can be used to shuffle other children
-    val candidateSpecs = children.zip(specs).collect {
-      case (child, Some(spec)) if spec.canCreatePartitioning &&
-          (!shouldConsiderMinParallelism ||
-            child.outputPartitioning.numPartitions >= 
conf.defaultNumShufflePartitions) =>
-        child -> spec
+    // Choose all the children whose spec can be used to shuffle other 
children, each with the
+    // members that can serve as the layout. Any other member would build a 
partitioning its own
+    // child is not laid out on, and it does not count towards the ranking 
below either.
+    val candidateSpecs = children.zip(specs).flatMap {
+      case (child, Some(spec)) if !shouldConsiderMinParallelism ||
+          child.outputPartitioning.numPartitions >= 
conf.defaultNumShufflePartitions =>
+        val members = spec.flatten.filter(_.canCreatePartitioning)
+        if (members.nonEmpty) Some(child -> members) else None
+      case _ => None
     }
     // Rank on two things at once. A child with no `ShuffleExchangeLike` node 
comes first, since
     // keeping it costs nothing. For instance, if we have:
     //   A: (No_Exchange, 100) <---> B: (Exchange, 120)
     // it's better to pick A and change B to (Exchange, 100) instead of 
picking B and insert a
     // new shuffle for A. Then the best parallelism decides, and for a 
collection that is the best
-    // any member offers, since a collection has no count of its own.
+    // any serving member offers, since a collection has no count of its own.
     //
     // What the winner contributes is its members, the alternatives the layout 
is picked from below.
     // Empty when no child can serve as the layout.
-    val bestMembers: Seq[LeafShuffleSpec] = candidateSpecs.maxByOption { case 
(child, spec) =>
-      (!child.isInstanceOf[ShuffleExchangeLike], 
spec.flatten.map(_.numPartitions).max)
-    }.toSeq.flatMap(_._2.flatten)
+    val bestMembers: Seq[LeafShuffleSpec] = candidateSpecs.maxByOption { case 
(child, members) =>
+      (!child.isInstanceOf[ShuffleExchangeLike], 
members.map(_.numPartitions).max)
+    }.toSeq.flatMap(_._2)
 
     // A `ShuffleSpecCollection` answers `isCompatibleWith` if *any* of its 
members does, so the
     // winner alone does not say which member the sides agreed on. The 
projection pushed into a
@@ -408,7 +411,7 @@ case class EnsureRequirements(
     val pairings: Seq[(LeafShuffleSpec, Seq[Option[LeafShuffleSpec]])] = 
bestMembers.map { member =>
       member -> childMembers.map(_.find(member.isCompatibleWith))
     }
-    // Which children the winner reaches at all, over all of its members.
+    // Which children the winner reaches at all, over all of its serving 
members.
     val reached: Seq[Boolean] = 
pairings.map(_._2).transpose.map(_.exists(_.isDefined))
     // The member picked has to pair with every child the winner reaches. A 
child it does not
     // reach, and a child that is not in the decision, are shuffled whichever 
member wins, so
@@ -1133,9 +1136,11 @@ case class EnsureRequirements(
       // the skew of joining on keys that are coarser than the join keys. Key 
order and duplicated
       // cluster keys don't matter.
       def allClusterKeysCovered: Boolean =
-        // The single-column invariant in 
KeyedPartitioning.supportsExpressions guarantees one
-        // attribute per partition expression.
-        
distribution.allClusterKeysAmong(partitioning.expressions.flatMap(_.references))
+        // Only an expression over a single column covers that column. One 
over several, e.g.
+        // `b + c`, maps to no position (`KeyedShuffleSpec.keyPositions`). The 
spec turns it away
+        // or projects it away, so its columns are not covered.
+        distribution.allClusterKeysAmong(
+          partitioning.expressions.filter(_.references.size == 
1).flatMap(_.references))
 
       // The coverage requirement is a comparison of expressions, while 
`keysMaySatisfy` can end in
       // a projection of the partition keys, so the cheap question is asked 
first. The requirement
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
index 03b493b96727..ea1fafd94cfe 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
@@ -48,7 +48,7 @@ import org.apache.spark.sql.execution.{
 import org.apache.spark.sql.execution.aggregate.{BaseAggregateExec, 
HashAggregateExec, SortAggregateExec}
 import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, 
DataSourceV2ScanRelation, GroupPartitionsExec}
 import org.apache.spark.sql.execution.exchange.{EnsureRequirements, 
ReusedExchangeExec, ShuffleExchangeExec, ShuffleExchangeLike, 
ValidateRequirements}
-import org.apache.spark.sql.execution.joins.{ShuffledHashJoinExec, 
ShuffledJoin, SortMergeJoinExec}
+import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, 
ShuffledHashJoinExec, ShuffledJoin, SortMergeJoinExec}
 import org.apache.spark.sql.execution.metric.SQLMetricsTestUtils
 import org.apache.spark.sql.execution.ui.SparkPlanGraphNode
 import org.apache.spark.sql.execution.window.{Final, Partial, 
WindowGroupLimitExec, WindowGroupLimitMode}
@@ -750,6 +750,33 @@ class KeyGroupedPartitioningSuite
   private def createBucketedIdTables(bucketCounts: Int*): Unit =
     bucketCounts.foreach(n => createBucketedIdTable(s"bucket$n", n))
 
+  /** Creates `ident`, identity-partitioned on `id`, holding `ids`. */
+  private def createIdentityIdTable(ids: Seq[Long] = 0L until 8L): Unit = {
+    createTable("ident", Array(Column.create("id", LongType)), 
Array(identity("id")))
+    sql("INSERT INTO testcat.ns.ident VALUES " + ids.map(i => 
s"($i)").mkString(", "))
+  }
+
+  /** Creates `name`, unpartitioned, with a column `b` holding `bs`. */
+  private def createPlainTable(bs: Seq[Long] = 0L until 8L, name: String = 
"plain"): Unit = {
+    createTable(name, Array(Column.create("b", LongType)), Array.empty)
+    sql(s"INSERT INTO testcat.ns.$name VALUES " + bs.map(b => 
s"($b)").mkString(", "))
+  }
+
+  /** Creates `plain2`, unpartitioned, with columns `b` and `c`, where `b + c` 
runs from 1 to 3. */
+  private def createPlain2Table(): Unit = {
+    createTable("plain2", Array(Column.create("b", LongType), 
Column.create("c", LongType)),
+      Array.empty)
+    sql("INSERT INTO testcat.ns.plain2 VALUES (0, 1), (0, 2), (1, 1), (1, 2)")
+  }
+
+  /** Creates `ps` with a struct column `s` of 8 rows, `(a = i, b = 7 - i)`. */
+  private def createStructTable(): Unit = {
+    createTable("ps", Array(Column.create("s", new StructType().add("a", 
LongType)
+      .add("b", LongType))), Array.empty)
+    sql("INSERT INTO testcat.ns.ps VALUES " +
+      (0 until 8).map(i => s"(named_struct('a', ${i}L, 'b', ${7 - 
i}L))").mkString(", "))
+  }
+
   /** Joins `bucket12`, `bucket8` and `bucket<third>` on `id`, in that order. 
*/
   private def threeWayBucketJoinDF(third: Int): DataFrame =
     sql("SELECT b12.id FROM testcat.ns.bucket12 b12 " +
@@ -1529,6 +1556,357 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  // Each key is a function of `b` other than `b` itself. A check on the 
leaves of the key would
+  // reject `b + 1` for its literal, but not `-b`, so both are tested.
+  Seq("p.b + 1" -> ((b: Long) => b + 1), "-p.b" -> ((b: Long) => -b)).foreach {
+    case (key, keyOf) =>
+      // `plain` holds `b` in 0..7, and `keys`, bucketed on `id`, holds the 
key of each `b`.
+      def createKeyTables(): Unit = {
+        createPlainTable()
+        createTable("keys", Array(Column.create("id", LongType)), 
Array(bucket(4, "id")))
+        sql("INSERT INTO testcat.ns.keys VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)})").mkString(", "))
+      }
+
+      test("SPARK-59887: a side shuffled onto a transform of an expression is 
not paired as it " +
+          s"is ($key)") {
+        createKeyTables()
+        Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 
8))
+
+        // The first join shuffles `plain` onto the bucket of its key. That is 
a function of `b`,
+        // but not the same function of it as the third table's `bucket(n, 
id)` is of `id`. The
+        // second join clusters `plain` on `b`. Pairing the two there, as they 
stand or after
+        // reducing `bucket(8, id)` onto 4 buckets, puts a row with `b = id` 
on a different
+        // partition on each side.
+        Seq(4 -> "false", 8 -> "true").foreach { case (third, 
allowCompatibleTransforms) =>
+          withSQLConf(
+            SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+            SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> 
allowCompatibleTransforms) {
+            val df = sql(
+              s"""SELECT k.id, p.b, t.id FROM testcat.ns.keys k
+                 |JOIN testcat.ns.plain p ON k.id = $key
+                 |JOIN testcat.ns.bucket$third t ON p.b = t.id""".stripMargin)
+            checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
b.toLong)))
+            // One shuffle per join: `plain` for the first, the first join's 
output for the
+            // second, and none for the third table. The second join does not 
shuffle onto the
+            // bucket of the key, since that would move both of its sides.
+            
assert(collectShuffles(stripAQEPlan(df.queryExecution.executedPlan)).size == 2)
+          }
+        }
+      }
+
+      test(s"SPARK-59887: a side is not shuffled onto a transform of an 
expression ($key)") {
+        createKeyTables()
+        createTable("xy", Array(Column.create("x", LongType), 
Column.create("y", LongType)),
+          Array.empty)
+        sql("INSERT INTO testcat.ns.xy VALUES " +
+          (0 until 8).map(b => s"(${keyOf(b)}, $b)").mkString(", "))
+
+        // The first join shuffles `plain` onto the bucket of its key, so its 
output offers that
+        // layout next to `bucket(4, id)`. The second join must not shuffle 
`xy` onto the first
+        // one. That builds `bucket(4, y)` for `xy`, not the bucket of the 
same function of `y`. A
+        // row with `b = y` then lands in a different bucket on each side. The 
join order decides
+        // which of the two layouts comes first, so both orders run.
+        withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+          Seq(
+            s"testcat.ns.plain p JOIN testcat.ns.keys k ON $key = k.id",
+            s"testcat.ns.keys k JOIN testcat.ns.plain p ON k.id = 
$key").foreach { firstJoin =>
+            val df = sql(
+              s"""SELECT k.id, p.b, q.x FROM $firstJoin
+                 |JOIN testcat.ns.xy q ON k.id = q.x AND p.b = 
q.y""".stripMargin)
+            checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
keyOf(b))))
+            // `xy` is shuffled onto the usable layout, `bucket(4, x)`, and 
never onto a bucket of
+            // `y`. So `plain` and `xy` are the only sides that move.
+            val shuffles = 
collectShuffles(stripAQEPlan(df.queryExecution.executedPlan))
+            assert(shuffles.size == 2)
+            
assert(!keyedPartitioningsOf(shuffles).exists(_.references.exists(_.name == 
"y")))
+          }
+        }
+      }
+
+      test("SPARK-59887: a broadcast join's layout over a join key is not 
paired as it is " +
+          s"($key)") {
+        createKeyTables()
+        createBucketedIdTable("bucket4", 4, numIds = 8)
+
+        // An inner broadcast join reports the streamed side's layout over the 
build side's join
+        // key too, so the first join also offers the bucket of `plain`'s key. 
That needs no conf
+        // beyond the defaults. The second join clusters `plain` on `b`, so 
pairing that layout with
+        // `bucket(4, id)` as it stands puts a row with `b = id` on a 
different partition on each
+        // side.
+        val df = sql(
+          s"""SELECT /*+ BROADCAST(p) */ k.id, p.b, t.id FROM testcat.ns.keys k
+             |JOIN testcat.ns.plain p ON k.id = $key
+             |JOIN testcat.ns.bucket4 t ON p.b = t.id""".stripMargin)
+        checkAnswer(df, (0 until 8).map(b => Row(keyOf(b), b.toLong, 
b.toLong)))
+        assert(collect(df.queryExecution.executedPlan) { case j: 
BroadcastHashJoinExec => j }
+          .nonEmpty, "the first join is a broadcast join")
+      }
+  }
+
+  test("SPARK-59887: a layout over a transform of a struct field is not paired 
nor shuffled onto") {
+    createBucketedIdTable("bucket4", 4, numIds = 8)
+    createStructTable()
+    // `l` shuffles `ps` onto `bucket(4, s.a)`, and `r` shuffles it onto 
`bucket(4, s.b)`.
+    val l = "SELECT x.id, ps.s FROM testcat.ns.bucket4 x JOIN testcat.ns.ps ps 
ON x.id = ps.s.a"
+    val r = "SELECT y.id, qs.s FROM testcat.ns.bucket4 y JOIN testcat.ns.ps qs 
ON y.id = qs.s.b"
+
+    withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+      // Both layouts are functions of `s`, but not the same one, so the join 
on `s` must not pair
+      // them as they stand. A row lands in bucket `a % 4` on one side and `b 
% 4` on the other.
+      checkAnswer(
+        sql(s"SELECT l.id, r.id FROM ($l) l JOIN ($r) r ON l.s = r.s"),
+        (0 until 8).map(i => Row(i.toLong, (7 - i).toLong)))
+      // Nor may a join shuffle a plain `ps` onto `l`'s layout. That builds 
`bucket(4, s)` for it,
+      // which drops the `.a` and hands the whole struct to a bucket function 
over a long.
+      checkAnswer(
+        sql(s"SELECT l.id FROM ($l) l JOIN testcat.ns.ps r ON l.s = r.s"),
+        (0 until 8).map(i => Row(i.toLong)))
+    }
+  }
+
+  test("SPARK-59887: a reduce keeps what each member's keys are computed 
from") {
+    Seq(4, 2).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 8))
+    createIdentityIdTable()
+    createPlainTable()
+    createStructTable()
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      // The first join shuffles `plain` onto `bucket(4, b + 1)`, or onto `b + 
1` for the
+      // identity-partitioned `ident`. The second join reduces the first one's 
output onto
+      // `bucket(2)`, `plain`'s member included. That member is still over `b 
+ 1`. So the third
+      // join, on the bare `b`, must neither pair it with `bucket(2, id)` nor 
shuffle a plain
+      // side onto it.
+      for {
+        first <- Seq("bucket4", "ident")
+        (third, thirdKey) <- Seq("bucket2" -> "id", "plain" -> "b")
+      } {
+        val df = sql(
+          s"""SELECT f.id, p.b, t2.id, t3.$thirdKey FROM testcat.ns.$first f
+             |JOIN testcat.ns.plain p ON f.id = p.b + 1
+             |JOIN testcat.ns.bucket2 t2 ON f.id = t2.id
+             |JOIN testcat.ns.$third t3 ON p.b = t3.$thirdKey""".stripMargin)
+        checkAnswer(df, (0 until 7).map(b => Row(b + 1L, b.toLong, b + 1L, 
b.toLong)))
+      }
+
+      // The same for a struct field. `ps`'s member is still over `s.a` after 
the reduce, so the
+      // third join must not shuffle a plain `ps` onto it, which would build 
`bucket(2, s)`.
+      val df = sql(
+        """SELECT x.id FROM testcat.ns.bucket4 x
+          |JOIN testcat.ns.ps ps ON x.id = ps.s.a
+          |JOIN testcat.ns.bucket2 t2 ON x.id = t2.id
+          |JOIN testcat.ns.ps r ON ps.s = r.s""".stripMargin)
+      checkAnswer(df, (0 until 8).map(i => Row(i.toLong)))
+    }
+  }
+
+  // `ident` also holds an id that the key's function overflows on under ANSI.
+  Seq(
+    ("p.b + 1", 0L until 8L, Long.MaxValue, (b: Long) => b + 1),
+    ("-p.b", -7L to 0L, Long.MinValue, (b: Long) => -b)
+  ).foreach { case (key, bs, overflowingId, keyOf) =>
+    test(s"SPARK-59887: an identity side is not reduced onto a transform of an 
expression ($key)") {
+      createBucketedIdTable("bucket4", 4, numIds = 8)
+      createPlainTable(bs)
+      createIdentityIdTable(bs :+ overflowingId)
+
+      // The first join shuffles `plain` onto the bucket of its key. Reducing 
`ident` onto it would
+      // evaluate the key's function on `ident`'s partition keys while 
planning, which overflows for
+      // that id. The query itself never computes it, since no `b` matches 
that id.
+      withSQLConf(
+          SQLConf.ANSI_ENABLED.key -> "true",
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        val df = sql(
+          s"""SELECT k.id, p.b, i.id FROM testcat.ns.bucket4 k
+             |JOIN testcat.ns.plain p ON k.id = $key
+             |JOIN testcat.ns.ident i ON p.b = i.id""".stripMargin)
+        checkAnswer(df,
+          bs.filter(b => (0L until 8L).contains(keyOf(b))).map(b => 
Row(keyOf(b), b, b)))
+      }
+    }
+  }
+
+  test("SPARK-59887: a reduce keeps a member over two columns") {
+    createIdentityIdTable()
+    createBucketedIdTable("bucket2", 2, numIds = 8)
+    createPlain2Table()
+
+    // The broadcast join reports `ident`'s layout over `p.b + p.c` too, and 
the second join reduces
+    // it onto `bucket(2)`. That member has to stay over `p.b + p.c`. Reported 
over `p.b`, it would
+    // serve the `GROUP BY p.b` as it stands, while the rows of one `b` sit in 
two buckets.
+    withSQLConf(SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> 
"true") {
+      checkAnswer(
+        sql("""SELECT /*+ BROADCAST(p) */ p.b, max(p.c) FROM testcat.ns.ident i
+              |JOIN testcat.ns.plain2 p ON i.id = p.b + p.c
+              |JOIN testcat.ns.bucket2 t2 ON i.id = t2.id
+              |GROUP BY p.b""".stripMargin),
+        Seq(Row(0L, 2L), Row(1L, 2L)))
+    }
+  }
+
+  test("SPARK-59887: two layouts over the same shape of a join key pair as two 
columns do") {
+    Seq("orders", "returns").foreach(createBucketedIdTable(_, 16, numIds = 16))
+    createTable("customers", Array(Column.create("id", IntegerType)), 
Array.empty)
+    sql("INSERT INTO testcat.ns.customers VALUES " + (0 until 16).map(i => 
s"($i)").mkString(", "))
+
+    // Each broadcast join reports its fact table's layout over the 
dimension's key too, widened to
+    // the fact key's type, `bucket(16, cast(c.id as bigint))`. That needs no 
conf beyond the
+    // defaults. The join on `c1.id = c2.id` pairs the two as they stand, 
since they are the same
+    // function of their columns.
+    val dimensions = sql(
+      """SELECT a.id FROM
+        |  (SELECT /*+ BROADCAST(c1) */ c1.id FROM testcat.ns.orders o
+        |   JOIN testcat.ns.customers c1 ON o.id = c1.id) a
+        |JOIN
+        |  (SELECT /*+ BROADCAST(c2) */ c2.id FROM testcat.ns.returns r
+        |   JOIN testcat.ns.customers c2 ON r.id = c2.id) b
+        |ON a.id = b.id""".stripMargin)
+    checkAnswer(dimensions, (0 until 16).map(Row(_)))
+    assert(collectAllShuffles(dimensions.queryExecution.executedPlan).isEmpty)
+
+    // The same for two one-side shuffles onto `bucket(n, p.b + 1)` and 
`bucket(4, q.b + 1)`,
+    // joined on `p.b = q.b`. With `bucket(4)` on both sides they pair as they 
stand. With
+    // `bucket(8)` on one side, `allowCompatibleTransforms` reduces it onto 
`bucket(4)`. Either way
+    // only the two one-side shuffles are left.
+    Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 8))
+    createPlainTable()
+    // A second unpartitioned table, so that the two one-side shuffles are not 
one reused exchange.
+    createPlainTable(name = "plainq")
+    Seq(4, 8).foreach { n =>
+      withSQLConf(
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        val legs = sql(
+          s"""SELECT l.b FROM
+             |  (SELECT p.b FROM testcat.ns.bucket$n t1
+             |   JOIN testcat.ns.plain p ON t1.id = p.b + 1) l
+             |JOIN
+             |  (SELECT q.b FROM testcat.ns.bucket4 t2
+             |   JOIN testcat.ns.plainq q ON t2.id = q.b + 1) r
+             |ON l.b = r.b""".stripMargin)
+        checkAnswer(legs, (0 until 7).map(b => Row(b.toLong)))
+        assert(collectAllShuffles(legs.queryExecution.executedPlan).size == 2, 
s"bucket$n")
+      }
+    }
+  }
+
+  test("SPARK-59887: the same shape reduced together pairs only through the 
same pairing") {
+    Seq(12, 8, 18).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 
24))
+    createPlainTable(0L until 24L)
+    // A second unpartitioned table, so that the two one-side shuffles are not 
one reused exchange.
+    createPlainTable(0L until 24L, name = "plainq")
+
+    // Each leg shuffles its plain table onto `bucket(12, b + 1)`. The first 
leg then reduces
+    // `bucket(12)` with `bucket(8)`, so its `b + 1` member holds buckets of 
4. The second leg
+    // reduces with `bucket(8)` too, with `bucket(18)`, or not at all. Only 
with `bucket(8)` does
+    // it hold buckets of 4 as well, and only then does the final join pair 
the two as they stand.
+    // Otherwise it holds buckets of 6 or of 12, and the final join shuffles 
both sides.
+    withSQLConf(
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      Seq(Some(8) -> 2, Some(18) -> 4, None -> 4).foreach { case (other, 
shuffles) =>
+        val reduce = other.fold("")(n => s"JOIN testcat.ns.bucket$n u ON t.id 
= u.id")
+        val df = sql(
+          s"""SELECT l.b FROM
+             |  (SELECT p.b FROM testcat.ns.bucket12 t JOIN testcat.ns.plain p 
ON t.id = p.b + 1
+             |   JOIN testcat.ns.bucket8 u ON t.id = u.id) l
+             |JOIN
+             |  (SELECT q.b FROM testcat.ns.bucket12 t JOIN testcat.ns.plainq 
q ON t.id = q.b + 1
+             |   $reduce) r
+             |ON l.b = r.b""".stripMargin)
+        checkAnswer(df, (0 until 23).map(b => Row(b.toLong)))
+        assert(collectAllShuffles(df.queryExecution.executedPlan).size == 
shuffles, s"$other")
+      }
+    }
+  }
+
+  test("SPARK-59887: a member that cannot serve does not rank its collection") 
{
+    createTable("t1", Array(Column.create("a", LongType), Column.create("c", 
LongType)),
+      Array(identity("a"), identity("c")))
+    sql("INSERT INTO testcat.ns.t1 VALUES (1, 1), (1, 2), (1, 3), (2, 4), (2, 
5)")
+    createTable("p", Array(Column.create("y", LongType)), Array.empty)
+    sql("INSERT INTO testcat.ns.p VALUES (0), (1), (2), (3), (4)")
+    createTable("q", Array(Column.create("m", LongType), Column.create("n", 
LongType)),
+      Array(identity("m")))
+    sql("INSERT INTO testcat.ns.q VALUES (1, 0), (2, 3), (3, 9)")
+
+    // The broadcast join reports `[a, c]` and `[a, y + 1]`. The first 
projects to `[a]`, 2
+    // partitions, and can serve as the layout. The second keeps 5 partitions 
and cannot. So `q`,
+    // with 3 partitions and no exchange, ranks first, and the other side is 
shuffled onto it.
+    withSQLConf(
+        SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true",
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val df = sql(
+        """SELECT /*+ BROADCAST(p) */ * FROM testcat.ns.t1
+          |JOIN testcat.ns.p ON t1.c = p.y + 1
+          |JOIN testcat.ns.q ON t1.a = q.m AND p.y = q.n""".stripMargin)
+      checkAnswer(df, Seq(Row(1L, 1L, 0L, 1L, 0L), Row(2L, 4L, 3L, 2L, 3L)))
+      assert(collectAllShuffles(df.queryExecution.executedPlan)
+        .map(_.outputPartitioning.numPartitions) == Seq(3))
+    }
+  }
+
+  test("SPARK-59901: a join key over two columns is not paired through one of 
them") {
+    createIdentityIdTable()
+    createBucketedIdTable("bucket4", 4, numIds = 8)
+    createPlain2Table()
+    val expected = Seq(Row(1L, 0L, 1L), Row(2L, 0L, 2L), Row(2L, 1L, 1L), 
Row(3L, 1L, 2L))
+
+    // A one-side shuffle builds its partition expression over the other 
side's join key. That is
+    // `b + c` here, or `bucket(4, b + c)` over a bucketed side. No single 
cluster key stands for
+    // either, so a spec over one pairs with nothing and is no layout to 
shuffle onto. AQE
+    // validates the first plan. The second join of the second query asks what 
to shuffle onto.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
+      checkAnswer(
+        sql("""SELECT i.id, p.b, p.c FROM testcat.ns.ident i
+              |JOIN testcat.ns.plain2 p ON i.id = p.b + p.c""".stripMargin),
+        expected)
+      checkAnswer(
+        sql("""SELECT b4.id, q.b, q.c FROM testcat.ns.bucket4 b4
+              |JOIN testcat.ns.plain2 p ON b4.id = p.b + p.c
+              |JOIN testcat.ns.plain2 q ON p.b = q.b AND p.c = 
q.c""".stripMargin),
+        expected)
+    }
+  }
+
+  test("SPARK-59901: a join key over two columns does not cover its columns") {
+    Seq("ident2a", "ident2b").foreach { name =>
+      createTable(name, Array(Column.create("x", LongType), Column.create("d", 
LongType)),
+        Array(identity("x"), identity("d")))
+      sql(s"INSERT INTO testcat.ns.$name VALUES (1, 1), (2, 1), (3, 2), (4, 
2)")
+    }
+    Seq("pa", "pb").foreach { name =>
+      createTable(name, Array(Column.create("b", LongType), Column.create("c", 
LongType),
+        Column.create("d", LongType)), Array.empty)
+      sql(s"INSERT INTO testcat.ns.$name VALUES (0, 1, 1), (1, 1, 1), (1, 2, 
2), (2, 2, 2)")
+    }
+
+    // Each broadcast join reports its identity side's layout over `[b + c, 
d]` too. A projection
+    // to the join keys drops `b + c`, which maps to no position, so it does 
not cover `b` and `c`
+    // for `spark.sql.requireAllClusterKeysForCoPartition`. Otherwise the join 
on `(b, c, d)` would
+    // be paired on `d` alone.
+    withSQLConf(
+        SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val df = sql(
+        """SELECT l.b, l.c, l.d FROM
+          |  (SELECT /*+ BROADCAST(p) */ p.b, p.c, p.d FROM testcat.ns.ident2a 
i
+          |   JOIN testcat.ns.pa p ON i.x = p.b + p.c AND i.d = p.d) l
+          |JOIN
+          |  (SELECT /*+ BROADCAST(q) */ q.b, q.c, q.d FROM testcat.ns.ident2b 
j
+          |   JOIN testcat.ns.pb q ON j.x = q.b + q.c AND j.d = q.d) r
+          |ON l.b = r.b AND l.c = r.c AND l.d = r.d""".stripMargin)
+      checkAnswer(df, Seq(Row(0L, 1L, 1L), Row(1L, 1L, 1L), Row(1L, 2L, 2L), 
Row(2L, 2L, 2L)))
+      assert(collectAllGroupPartitions(df.queryExecution.executedPlan).isEmpty)
+    }
+  }
+
   test("SPARK-59121: two reduced partitionings are not compatible by their 
transforms") {
     // 24 ids, so that each leg's two sides really report different key sets 
and the join reduces
     // them. With 12 ids `bucket(18, id)` is the identity and the leg would be 
co-partitioned as it
@@ -5492,21 +5870,19 @@ class KeyGroupedPartitioningSuite
     case class Query(left: String, right: String, on: String, order: String, 
select: String,
         expected: Seq[Row], sortingNodes: Int)
     val ids = (94 to 100).map(i => Row(i.toLong))
-    val singleColumnKeys = Seq(
+    val queries = Seq(
       Query("ident", "plain", "i.id = 100 - p.b", "100 - p.b", "p.b",
         (6 to 0 by -1).map(b => Row(b.toLong)), 0),
       Query("ident", "plain", "i.id = 100 - p.b", "100 - p.b DESC", "p.b",
         (0 to 6).map(b => Row(b.toLong)), 1),
       Query("ident", "plain", "i.id = p.s.a", "p.s.a", "i.id", ids, 0),
       Query("ident2", "plain2", "i.x = 100 - p.b AND i.y = p.c", "100 - p.b, 
p.c DESC", "p.b, p.c",
-        Seq(Row(6L, 2L), Row(6L, 1L), Row(5L, 1L)), 1))
-    // AQE's plan validation fails on `b + c` until SPARK-59901 is fixed, so 
it runs with AQE off.
-    val twoColumnKey =
-      Query("ident", "plain", "i.id = p.b + p.c", "p.b + p.c", "i.id", ids, 0)
+        Seq(Row(6L, 2L), Row(6L, 1L), Row(5L, 1L)), 1),
+      Query("ident", "plain", "i.id = p.b + p.c", "p.b + p.c", "i.id", ids, 0))
 
     for {
-      (query, aqe) <-
-        singleColumnKeys.flatMap(q => Seq(q -> true, q -> false)) :+ 
(twoColumnKey -> false)
+      query <- queries
+      aqe <- Seq(true, false)
       broadcast <- Seq(false, true)
     } {
       withSQLConf(


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

Reply via email to