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

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


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new d0af88ad0b1c [SPARK-59887][SPARK-59901][4.2][SQL] Fix wrong results 
when a storage-partitioned join pairs a transform of a join-key expression with 
one of a column
d0af88ad0b1c is described below

commit d0af88ad0b1c4eadb7619dabff66f4bdfb404a94
Author: Peter Toth <[email protected]>
AuthorDate: Tue Oct 6 14:42:28 2026 +0200

    [SPARK-59887][SPARK-59901][4.2][SQL] Fix wrong results when a 
storage-partitioned join pairs a transform of a join-key expression with one of 
a column
    
    ### Differences from #59165
    
    This is the branch-4.2 backport of #59165. The description below is the 
original one. The cherry-pick does not apply, since the storage-partitioned 
join code on this branch predates master's rework of it. So the fix is ported 
onto this branch's own code:
    - `KeyedShuffleSpec.keyPositions` maps a partition expression by its leaves 
here, not by its references. So a key with a literal, e.g. `b + 1`, already 
maps to no cluster key and is not paired wrongly on this branch. A single-leaf 
key such as `-b` or `s.a` is. The assertion in `keyPositions` fails here too 
for an expression with two leaves, `b + 1` included (SPARK-59901). It is 
replaced by "only an expression with a single leaf maps to a position".
    - This branch picks the layout to shuffle onto in `EnsureRequirements` 
through `candidateSpecs`, `bestSpecOpt` and `bestMemberOpt`, not 
`pickCoPartitionTarget`. With `ShuffleSpecCollection.canCreatePartitioning` an 
`exists`, a collection whose first member cannot serve becomes a candidate 
here. So three places look at its serving members only:
      - the ranking counts a collection by its first serving member, which is 
its first member whenever that one serves, as before;
      - a child counts as matched only through a serving member;
      - the layout is picked from the serving members.
    - Two new tests in `EnsureRequirementsSuite` cover the ranking and the 
matching, since master's tests do not reach them on this branch: "SPARK-59887: 
a collection ranks by its first member that can serve as the layout" and 
"SPARK-59887: a child matched only through a member that cannot serve is 
shuffled". Each fails with its change removed.
    - Matching only through members that can serve costs a shuffle in one more 
shape on this branch. Two legs that line up only through members of the same 
shape now take a shuffle, e.g. `[a, bucket(4, -y)]` against `[m, bucket(4, 
-n)]`, joined on `l.a = r.m AND l.y = r.n`. With 
`allowJoinKeysSubsetOfPartitionKeys`, the shuffle conf on and 
`requireAllClusterKeysForCoPartition` off, branch-4.2 took no shuffle, and this 
change shuffles one side onto 2 partitions. The rows are right either way.
      - Master pairs such legs in `checkKeyGroupCompatible`, which compares 
every member of both sides. It takes no shuffle there, before and after this 
change.
      - This branch's `createKeyedShuffleSpec` offers only the first member 
that satisfies the distribution, here `[a, bucket(4, c)]`. So only the shuffle 
path paired the legs, through a member that cannot serve as the layout. This 
change keeps the shuffle path from that, which is also where master's third 
cost above comes from.
      - A widening cast instead of the negation behaves the same. So "Two sides 
that both carry the cast still pair" holds here only where the 
storage-partitioned join path pairs them.
    - This branch's `createKeyedShuffleSpec` checks 
`spark.sql.requireAllClusterKeysForCoPartition` by matching the leaves of the 
layout to the join keys one by one, where master checks coverage. So the 
coverage change becomes: a layout with an expression with several leaves does 
not meet the requirement. With the rest of this change alone, the rows are 
right, but a join on `(b, c, d)` over `[b + c, d]` is paired on `d` alone.
    - That needs a sibling layout that satisfies the join's distribution, so 
that the child is not shuffled first. The coverage test, "SPARK-59901: a join 
key over two columns does not cover its columns", has none on this branch, 
since each leg's projection drops the identity side's columns. So it also runs 
with those columns kept.
    - `KeyedPartitioning.createShuffleSpec` gets master's refusal to project 
onto no position. It came with SPARK-59289, which is not on this branch, and 
master's SPARK-59901 fix relies on it.
      - A join keyed on the expression itself, e.g. `l.b + l.c = r.k`, can 
satisfy the distribution under 
`spark.sql.requireAllClusterKeysForDistribution`, while no position maps to a 
cluster key.
      - With `allowJoinKeysSubsetOfPartitionKeys`, the projection would then 
run the join in one partition. That needs 
`spark.sql.sources.v2.bucketing.shuffle.enabled`, or another side over the same 
expression with `requireAllClusterKeysForCoPartition` off. A `-b` key already 
did that on this branch.
      - A `b + 1` or `b + c` key failed there with the `AssertionError` before 
this change, even with the shuffle conf off. Now all three get the shuffle, 
which can mean 2 shuffles where branch-4.2 had none.
      - The new test "SPARK-59901: a join keyed on a join-key expression does 
not run in one partition" covers `-b`, `b + 1` and `b + c`.
    - `TransformExpressionSuite` does not exist on this branch, so its hunk is 
dropped. So are the doc changes for code this branch does not have.
    - Three tests are tailored, since master's version cannot pass here. On 
this branch a key with a literal maps to no cluster key, so it is shuffled 
rather than paired:
      - the first `ShuffleSpecSuite` test uses `-a`, `-b` and `abs(b)` for 
master's `a + 1`, `b + 1` and `b + 2`;
      - "two layouts over the same shape of a join key pair as two columns do" 
and "the same shape reduced together pairs only through the same pairing" use 
`-p.b` over negative values for master's `p.b + 1`.
    - Measured on branch-4.2 without the change, with the shuffle conf on: the 
`-p.b` key returns 4 of 8 rows through a one-side shuffle and through the 
`bucket(8)` reducer path. Through a broadcast join it returns 4 of 8 rows with 
every conf at its default. The struct field pairing returns 0 of 8 rows, and 
shuffling onto it fails with a `ClassCastException`.
    - Two parts of the original description need a `-p.b` key on this branch, 
since a member with a literal does not satisfy the distribution here:
      - the plan that gets better: `ident i JOIN plain p ON i.id = -p.b JOIN xy 
q ON i.id = q.x AND p.b = q.y` takes 2 shuffles instead of 3. With `p.b + 1` it 
already took 2;
      - the third cost: with `-p.b` branch-4.2 took no shuffle, and this change 
takes 1. With `p.b + 1` one side was already shuffled.
    - A single join keyed on `p.b + p.c` does not fail on this branch, AQE 
included, so "a single join is enough" does not hold here. So the first query 
of "SPARK-59901: a join key over two columns is not paired through one of them" 
passes without the change, and its second query fails with the `AssertionError`.
    - These included tests pass on branch-4.2 without the change:
      - the `p.b + 1` variants of the shuffled-onto, `xy`, broadcast and 
identity tests;
      - "two layouts over the same shape of a join key pair as two columns do";
      - "a member that cannot serve does not rank its collection", since its 
`p.y + 1` member does not satisfy the distribution here;
      - the SPARK-59905 test, which now runs `p.b + p.c` with AQE on as on 
master;
      - the new ranking test, since a collection with a member that cannot 
serve is no candidate there.
    - Each change was removed on its own:
      - without the shape gate, the `-p.b` shuffled-onto, broadcast and 
identity tests and the struct field test fail;
      - without the `canCreatePartitioning` clause, the `-p.b` shuffled-onto 
and `xy` tests, the struct field, reduce and same-pairing tests fail on the 
assertion in `rebuiltOver`. The new matching test and the first 
`ShuffleSpecSuite` test fail too;
      - without the member filter, the `-p.b` `xy` test and both new 
`EnsureRequirementsSuite` tests fail;
      - with the ranking by the first member back, the new ranking test fails;
      - with the matching through any member back, the new matching test fails;
      - without the `GroupPartitionsExec` change, the reduce test and the 
two-column `GROUP BY` fail;
      - with the `keyPositions` assertion back, the `ShuffleSpecSuite` 
SPARK-59901 test, "SPARK-59901: a join key over two columns is not paired 
through one of them" and "SPARK-59901: a join keyed on a join-key expression 
does not run in one partition" fail. The coverage test passes, since the 
coverage change keeps `[b + c, d]` away from `keyPositions`;
      - with the `forall` back in 
`ShuffleSpecCollection.canCreatePartitioning`, the `-p.b` `xy` test fails on 
the shuffle count, and so do the new matching test and the first 
`ShuffleSpecSuite` test;
      - removing the coverage change or the refusal to project onto no position 
fails only its new test or run.
    - Ran `ShuffleSpecSuite`, `DistributionSuite`, the 
`KeyGroupedPartitioning*` suites, `EnsureRequirementsSuite`, 
`GroupPartitionsExecSuite`, `ProjectedOrderingAndPartitioningSuite`, 
`PlannerSuite`, `WriteDistributionAndOrderingSuite`, 
`BucketedReadWithoutHiveSupportSuite`, the plan stability suites, the 
`*JoinSuite` suites and `AdaptiveQueryExecSuite` on branch-4.2, 1403 tests in 
all, plus `dev/lint-scala`.
    
    ### 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 #59238 from peter-toth/SPARK-59887-spj-expression-key-identity-4.2.
    
    Authored-by: Peter Toth <[email protected]>
    Signed-off-by: Peter Toth <[email protected]>
---
 .../catalyst/expressions/TransformExpression.scala |  10 -
 .../sql/catalyst/plans/physical/partitioning.scala | 149 +++++--
 .../spark/sql/catalyst/ShuffleSpecSuite.scala      | 108 ++++-
 .../datasources/v2/GroupPartitionsExec.scala       |  14 +-
 .../execution/exchange/EnsureRequirements.scala    |  28 +-
 .../connector/KeyGroupedPartitioningSuite.scala    | 450 ++++++++++++++++++++-
 .../exchange/EnsureRequirementsSuite.scala         |  66 +++
 7 files changed, 766 insertions(+), 59 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 05824238f83f..76e930395ea2 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
@@ -23,6 +23,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
@@ -625,6 +626,16 @@ case class KeyedPartitioning(
       if (mayContainUnknownPartitionKeys && joinKeyPositions.length < 
expressions.length) {
         return result
       }
+      // No position covers a cluster key, so there is nothing to project 
onto. Projecting onto
+      // none would coalesce every partition into one, which is never the 
answer, and it is the
+      // same refusal `EnsureRequirements.resolveKeyedPartitioning` makes for 
its own caller.
+      // `keyPositions` maps an expression's single *leaf* onto the 
clustering, so this is
+      // reachable for a clustering that names the partition expression 
itself, where
+      // `satisfies0` says yes and this finds nothing. That covers an 
expression with several
+      // leaves too, e.g. `b + 1`. Return the unprojected spec, as above.
+      if (joinKeyPositions.isEmpty && expressions.nonEmpty) {
+        return result
+      }
       // If allowing join keys to be subset of clustering keys, we should 
create a new
       // `KeyedPartitioning` here that is grouped on the join keys instead, 
and use that as
       // the returned shuffle spec. The construction carries the unknown-keys 
marker across:
@@ -946,12 +957,22 @@ 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)`, `bucket(4, b + c)` or 
`b + 1`;
+   *  - a [[KeyedShuffleSpec]] with a partition expression that is neither a 
column nor a transform,
+   *    e.g. `-b` 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.
+   * Whether this shuffle spec can be used to create partitionings for the 
other children. A
+   * [[ShuffleSpecCollection]] can when any of its members can, and the 
planner builds the
+   * partitioning from a member that can.
    */
   def canCreatePartitioning: Boolean
 
@@ -1099,10 +1120,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)
 
@@ -1115,11 +1136,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 leaf.
   @transient private lazy val bound: Expression =
     BindReferences.bindReference(transform, 
AttributeSeq(transform.references.toSeq))
 
@@ -1148,8 +1171,18 @@ case class KeyedShuffleSpec(
    * keys. For instance, if cluster keys are [a, b, b] and partition 
expressions are
    * [bucket(4, a), years(b)], the result will be [(0), (1, 2)].
    *
-   * Note that we only allow each partition expression to contain a single 
partition key.
-   * Therefore the mapping here is very similar to that from `HashShuffleSpec`.
+   * Only an expression with a single leaf maps to a position. 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`. A one-side shuffle 
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, and
+   * `canCreatePartitioning` and `areKeysCompatible` turn the spec away. Under
+   * 
`spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.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)` 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]
@@ -1158,8 +1191,11 @@ case class KeyedShuffleSpec(
     }
     partitioning.expressions.map { e =>
       val leaves = e.collectLeaves()
-      assert(leaves.size == 1, s"Expected exactly one child from $e, but found 
${leaves.size}")
-      distKeyToPos.getOrElse(leaves.head.canonicalized, mutable.BitSet.empty)
+      if (leaves.size == 1) {
+        distKeyToPos.getOrElse(leaves.head.canonicalized, mutable.BitSet.empty)
+      } else {
+        mutable.BitSet.empty
+      }
     }
   }
 
@@ -1174,9 +1210,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 &&
@@ -1266,7 +1303,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
@@ -1291,6 +1337,47 @@ 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 is over. 
That pairing
+   * describes one key space when the argument is the column itself. 
`bucket(4, -b)` 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)` with `bucket(4, -c)` on a 
join `b = c`. A
+   * reduce of `bucket(8, -b)` onto `bucket(4, -c)` 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` 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 `-` of
+   * `bucket(4, -b)`.
+   */
+  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.
@@ -1340,8 +1427,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.
@@ -1368,15 +1455,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 leaves 
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)
     }
@@ -1396,8 +1484,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 `-` of `bucket(4, 
-b)` 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.
@@ -1409,7 +1503,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 child re-shuffled onto this layout may hold keys outside the 
declared set, so every
@@ -1474,7 +1569,7 @@ case class ShuffleSpecCollection(specs: Seq[ShuffleSpec]) 
extends ShuffleSpec {
   }
 
   override def canCreatePartitioning: Boolean =
-    specs.forall(_.canCreatePartitioning)
+    specs.exists(_.canCreatePartitioning)
 
   override def createPartitioning(clustering: Seq[Expression]): Partitioning = 
{
     // as we only consider # of partitions as the cost now, it doesn't matter 
which one we choose
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 17621e67d1c4..ca03d6b1cb37 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.{Abs, 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}
@@ -646,6 +646,112 @@ 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 
is over. Over `a = b`
+    // it would pair `bucket(4, -a)` 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)` and `bucket(4, -b)` are the same function of `a` 
and of `b`, so they
+    // pair up. The expression is `-a`, since `keyPositions` maps only an 
expression with a single
+    // leaf, and `a + 1` has two.
+    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, -b, Abs(b)),
+        ("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/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 81a64dc15ba1..79973b599ede 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
@@ -76,7 +76,7 @@ case class GroupPartitionsExec(
         // There can be multiple `KeyedPartitioning`s in an output 
partitioning of a join, but they
         // can only differ in `expressions`. `partitionKeys` must match, so 
they are calculated
         // only once via `groupedPartitions`. When reducers are applied, the 
stored reduced
-        // expressions are re-targeted at each `KeyedPartitioning`'s own key 
attribute and reported
+        // 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 (see 
`KeyedShuffleSpec.reducersBothWays`).
@@ -112,9 +112,15 @@ case class GroupPartitionsExec(
                 projectedExpressions.zip(exprs).map {
                   case (expr, Some(KeyReducer(_, reduced))) =>
                     // `reduced` was stored from the single spec that 
`createKeyedShuffleSpec`
-                    // picked (`collectFirst`); re-target it at this 
`KeyedPartitioning`'s own key
-                    // attribute so that every `KeyedPartitioning` in a 
collection keeps its own.
-                    reduced.withReference(expr.references.head)
+                    // picked (`collectFirst`), which need not be this 
`KeyedPartitioning`. Only
+                    // its function says what the reduced keys are, so this 
member's own argument
+                    // takes the place of `reduced`'s. A side shuffled onto 
this layout reports
+                    // `bucket(8, -b)` next to `bucket(8, id)`, or `-b` 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 63dd2e639aa6..d85b0493aebb 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
@@ -206,7 +206,11 @@ case class EnsureRequirements(
       val shouldConsiderMinParallelism = specs.forall(p =>
         !p._2.canCreatePartitioning || 
children(p._1).isInstanceOf[ShuffleExchangeLike]
       )
-      // Choose all the specs that can be used to shuffle other children
+      // Choose all the specs that can be used to shuffle other children. For 
a collection only the
+      // members that can serve as the layout count, here and below. Any other 
member would build a
+      // partitioning its own child is not laid out on.
+      def servingMembers(spec: ShuffleSpec): Seq[ShuffleSpec] =
+        flattenSpec(spec).filter(_.canCreatePartitioning)
       val candidateSpecs = specs.filter { case (index, spec) =>
         spec.canCreatePartitioning &&
           (!shouldConsiderMinParallelism ||
@@ -228,8 +232,9 @@ case class EnsureRequirements(
         } else {
           candidateSpecs
         }
-        // Pick the spec with the best parallelism
-        Some(finalCandidateSpecs.values.maxBy(_.numPartitions))
+        // Pick the spec with the best parallelism. A collection counts by its 
first serving member,
+        // which is its first member whenever that one serves.
+        
Some(finalCandidateSpecs.values.maxBy(servingMembers(_).head.numPartitions))
       }
 
       // Check if the following conditions are satisfied:
@@ -261,16 +266,17 @@ case class EnsureRequirements(
       // a compatible child and the partitioning built for a re-shuffled child 
both have to come
       // from one member, otherwise the sides end up grouped on different 
keys, or on a key set the
       // child does not even have. Pick that member once, preferring the 
finest when several
-      // qualify. Only the branch that shuffles a child reads these, hence 
`lazy`.
+      // qualify. Only the branch that shuffles a child reads these, hence 
`lazy`. A child counts as
+      // matched only through a member that can serve as the layout.
       lazy val matchedIndexes = bestSpecOpt.toSeq.flatMap { best =>
-        childrenIndexes.filter(i => best.isCompatibleWith(specs(i)))
+        childrenIndexes.filter(i => 
servingMembers(best).exists(_.isCompatibleWith(specs(i))))
       }
       lazy val bestMemberOpt = bestSpecOpt.flatMap { best =>
         val matchedMembers = matchedIndexes.map(i => flattenSpec(specs(i)))
         // No member serving every matched child means there is no layout to 
align them on, so they
         // all take the ordinary shuffle. That needs three or more clustered 
children, since with
         // two the member that reported the match serves both, and no operator 
has three today.
-        flattenSpec(best)
+        servingMembers(best)
           .filter(m => matchedMembers.forall(_.exists(m.isCompatibleWith)))
           .maxByOption(_.numPartitions)
       }
@@ -928,9 +934,13 @@ case class EnsureRequirements(
       val clustering = distribution.clustering
 
       val satisfies = if 
(SQLConf.get.getConf(SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION)) {
-        attributes.length == clustering.length && 
attributes.zip(clustering).forall {
-          case (l, r) => l.semanticEquals(r)
-        }
+        // Only an expression with a single leaf covers that leaf. One with 
several, e.g. `b + c`,
+        // maps to no position (`KeyedShuffleSpec.keyPositions`). The spec 
turns it away or
+        // projects it away, so a layout with one does not cover the cluster 
keys.
+        partitioning.expressions.forall(_.collectLeaves().size == 1) &&
+          attributes.length == clustering.length && 
attributes.zip(clustering).forall {
+            case (l, r) => l.semanticEquals(r)
+          }
       } else {
         partitioning.satisfies(distribution)
       }
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 47c66b298e6c..6904476f754a 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
@@ -43,7 +43,7 @@ import org.apache.spark.sql.execution.{
   UnionExec}
 import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec, 
DataSourceV2ScanRelation, GroupPartitionsExec}
 import org.apache.spark.sql.execution.exchange.{ShuffleExchangeExec, 
ShuffleExchangeLike, ValidateRequirements}
-import org.apache.spark.sql.execution.joins.{ShuffledHashJoinExec, 
SortMergeJoinExec}
+import org.apache.spark.sql.execution.joins.{BroadcastHashJoinExec, 
ShuffledHashJoinExec, SortMergeJoinExec}
 import org.apache.spark.sql.functions.{col, max}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.internal.SQLConf._
@@ -337,6 +337,33 @@ class KeyGroupedPartitioningSuite extends 
DistributionAndOrderingSuiteBase with
   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 " +
@@ -949,6 +976,415 @@ class KeyGroupedPartitioningSuite extends 
DistributionAndOrderingSuiteBase with
     }
   }
 
+  // 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)` and 
`bucket(4, -q.b)`, 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. The key is `-p.b`, since a key with a 
literal such as `p.b + 1`
+    // maps to no cluster key on this branch.
+    Seq(4, 8).foreach(n => createBucketedIdTable(s"bucket$n", n, numIds = 8))
+    createPlainTable(-7L to 0L)
+    // A second unpartitioned table, so that the two one-side shuffles are not 
one reused exchange.
+    createPlainTable(-7L to 0L, 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) l
+             |JOIN
+             |  (SELECT q.b FROM testcat.ns.bucket4 t2
+             |   JOIN testcat.ns.plainq q ON t2.id = -q.b) r
+             |ON l.b = r.b""".stripMargin)
+        checkAnswer(legs, (-7 to 0).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(-23L to 0L)
+    // A second unpartitioned table, so that the two one-side shuffles are not 
one reused exchange.
+    createPlainTable(-23L to 0L, name = "plainq")
+
+    // Each leg shuffles its plain table onto `bucket(12, -b)`. The first leg 
then reduces
+    // `bucket(12)` with `bucket(8)`, so its `-b` 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
+             |   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
+             |   $reduce) r
+             |ON l.b = r.b""".stripMargin)
+        checkAnswer(df, (-23 to 0).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.
+    // On this branch `[a, y + 1]` never satisfies the distribution, so this 
passes without the
+    // change too. "a collection ranks by its first member that can serve as 
the layout" in
+    // `EnsureRequirementsSuite` covers the ranking here.
+    withSQLConf(
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_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. On master,
+    // AQE validates the first plan and fails there, but not on this branch. 
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_JOIN_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)
+
+      // With the identity side's columns kept, a sibling layout such as `[x, 
d]` satisfies the
+      // join's distribution, so no leg is shuffled first. Then only the 
coverage check keeps
+      // `[b + c, d]` from pairing on `d` alone.
+      val kept = sql(
+        """SELECT l.b, l.c, l.d, l.x, l.id, r.x, r.id FROM
+          |  (SELECT /*+ BROADCAST(p) */ p.b, p.c, p.d, i.x, i.d AS id 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, j.x, j.d AS id 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(kept, Seq(Row(0L, 1L, 1L, 1L, 1L, 1L, 1L), Row(1L, 1L, 1L, 
2L, 1L, 2L, 1L),
+        Row(1L, 2L, 2L, 3L, 2L, 3L, 2L), Row(2L, 2L, 2L, 4L, 2L, 4L, 2L)))
+      
assert(collectAllGroupPartitions(kept.queryExecution.executedPlan).isEmpty)
+    }
+  }
+
+  test("SPARK-59901: a join keyed on a join-key expression does not run in one 
partition") {
+    createTable("i1", Array(Column.create("id", LongType)), 
Array(identity("id")))
+    sql("INSERT INTO testcat.ns.i1 VALUES " + (-2 to 4).map(i => 
s"($i)").mkString(", "))
+    createTable("pa", Array(Column.create("b", LongType), Column.create("c", 
LongType)),
+      Array.empty)
+    sql("INSERT INTO testcat.ns.pa VALUES (0, 1), (1, 1), (1, 2), (2, 2)")
+    createTable("rk", Array(Column.create("k", LongType)), Array.empty)
+    sql("INSERT INTO testcat.ns.rk VALUES " + (-2 to 4).map(i => 
s"($i)").mkString(", "))
+
+    // The broadcast join reports `i1`'s layout over its join key, e.g. `[b + 
c]`. The second join
+    // is keyed on that expression itself, so under 
`requireAllClusterKeysForDistribution` the
+    // layout satisfies it, but no position maps to a cluster key. Projecting 
onto the positions
+    // that map would run the join in one partition. `-b` is the same with one 
leaf.
+    withSQLConf(
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_DISTRIBUTION.key -> "true",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      Seq(
+        ("-p.b", "-l.b", Seq(Row(0L, 1L, 0L), Row(1L, 1L, -1L), Row(1L, 2L, 
-1L),
+          Row(2L, 2L, -2L))),
+        ("p.b + 1", "l.b + 1", Seq(Row(0L, 1L, 1L), Row(1L, 1L, 2L), Row(1L, 
2L, 2L),
+          Row(2L, 2L, 3L))),
+        ("p.b + p.c", "l.b + l.c", Seq(Row(0L, 1L, 1L), Row(1L, 1L, 2L), 
Row(1L, 2L, 3L),
+          Row(2L, 2L, 4L)))).foreach { case (innerKey, outerKey, expected) =>
+        val df = sql(
+          s"""SELECT l.b, l.c, r.k FROM
+             |  (SELECT /*+ BROADCAST(p) */ p.b, p.c FROM testcat.ns.i1 i
+             |   JOIN testcat.ns.pa p ON i.id = $innerKey) l
+             |JOIN testcat.ns.rk r ON $outerKey = r.k""".stripMargin)
+        checkAnswer(df, expected)
+        val plan = df.queryExecution.executedPlan
+        assert(collectAllGroupPartitions(plan).isEmpty, innerKey)
+        assert(collectShuffles(plan).size == 2, innerKey)
+      }
+    }
+  }
+
   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
@@ -4545,21 +4981,19 @@ class KeyGroupedPartitioningSuite extends 
DistributionAndOrderingSuiteBase with
     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(
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
index d941c8406061..004a946d2c66 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/EnsureRequirementsSuite.scala
@@ -1439,6 +1439,72 @@ class EnsureRequirementsSuite extends SharedSparkSession 
{
       requiredChildOrdering = Seq(Seq.empty)
     )
 
+  test("SPARK-59887: a collection ranks by its first member that can serve as 
the layout") {
+    val a = AttributeReference("a", IntegerType)()
+    val c = AttributeReference("c", IntegerType)()
+    val y = AttributeReference("y", IntegerType)()
+    val m = AttributeReference("m", IntegerType)()
+    val n = AttributeReference("n", IntegerType)()
+    val keys = Seq(InternalRow(1, 1), InternalRow(1, 2), InternalRow(1, 3), 
InternalRow(2, 4),
+      InternalRow(2, 5))
+    // Clustered on `(a, y)`, `[a, -y]` keeps its 5 partitions and cannot 
serve as the layout,
+    // since `-y` is no column. `[a, c]` projects to `[a]`, 2 partitions, and 
can. The first member
+    // is the one that cannot, so ranking the collection by its first member 
would rank it at 5.
+    val keyed = new DummySparkPlanWithBatchScanChild(
+      outputPartitioning = PartitioningCollection(Seq(
+        KeyedPartitioning(Seq(a, UnaryMinus(y)), keys),
+        KeyedPartitioning(Seq(a, c), keys))))
+    val other = new DummySparkPlanWithBatchScanChild(
+      outputPartitioning = KeyedPartitioning(Seq(m), Seq(InternalRow(1), 
InternalRow(2),
+        InternalRow(3))))
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true") {
+      val smj = SortMergeJoinExec(Seq(a, y), Seq(m, n), Inner, None, keyed, 
other)
+      val planned = 
EnsureRequirements.apply(smj).asInstanceOf[SortMergeJoinExec]
+
+      // The collection ranks at 2, its serving member, so the other side wins 
with 3, and the
+      // collection's side is shuffled onto it.
+      val shuffles = planned.left.collect { case s: ShuffleExchangeExec => s }
+      assert(shuffles.map(_.outputPartitioning.numPartitions) === Seq(3))
+      assert(planned.right.collect { case s: ShuffleExchangeExec => s 
}.isEmpty)
+    }
+  }
+
+  test("SPARK-59887: a child matched only through a member that cannot serve 
is shuffled") {
+    val a = AttributeReference("a", IntegerType)()
+    val c = AttributeReference("c", IntegerType)()
+    val y = AttributeReference("y", IntegerType)()
+    val m = AttributeReference("m", IntegerType)()
+    val n = AttributeReference("n", IntegerType)()
+    val keys = Seq(InternalRow(1, 1), InternalRow(1, 2), InternalRow(1, 3), 
InternalRow(2, 0),
+      InternalRow(2, 1))
+    // Clustered on `(a, y)`, `[a, c]` projects to `[a]`, 2 partitions, and 
serves as the layout.
+    // `[a, bucket(4, -y)]` cannot, since its argument is no column. The other 
side pairs with that
+    // second member only. So it is shuffled onto `[a]`, and the keyed side 
keeps its layout.
+    // Without this change both sides stay as they are, paired through `[a, 
bucket(4, -y)]`. That
+    // plan is correct, and giving it up is a cost the description lists.
+    val keyed = DummySparkPlan(
+      outputPartitioning = PartitioningCollection(Seq(
+        KeyedPartitioning(Seq(a, c), keys),
+        KeyedPartitioning(Seq(a, bucket(4, UnaryMinus(y))), keys))))
+    val other = DummySparkPlan(
+      outputPartitioning = KeyedPartitioning(Seq(m, bucket(4, UnaryMinus(n))), 
keys))
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+      val smj = SortMergeJoinExec(Seq(a, y), Seq(m, n), Inner, None, keyed, 
other)
+      val planned = 
EnsureRequirements.apply(smj).asInstanceOf[SortMergeJoinExec]
+
+      assert(planned.left.collect { case s: ShuffleExchangeExec => s }.isEmpty)
+      val shuffles = planned.right.collect { case s: ShuffleExchangeExec => s }
+      assert(shuffles.map(_.outputPartitioning.numPartitions) === Seq(2))
+    }
+  }
+
   test("SPARK-59080: pushed-down positions index into the child's own 
partition expressions") {
     val nL = AttributeReference("nL", IntegerType)()
     val iL = AttributeReference("iL", IntegerType)()


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

Reply via email to