peter-toth commented on code in PR #59599:
URL: https://github.com/apache/spark/pull/59599#discussion_r4389493024


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -698,42 +738,176 @@ case class EnsureRequirements(
         joinType == LeftAnti || joinType == LeftOuter
   }
 
-  // Populate the common partition information down to the scan nodes
-  private def populateCommonPartitionInfo(
-      plan: SparkPlan,
+  /**
+   * Compose join-key positions from an outer SPJ with positions already 
pushed by an inner SPJ.
+   * Outer positions index the layout the outer join sees; inner positions 
index the scan's raw
+   * partition expressions, so they are composed rather than replaced.
+   */
+  private def composeJoinKeyPositions(
+      existing: Option[Seq[Int]],
+      newPositions: Option[Seq[Int]]): Option[Seq[Int]] = {
+    (existing, newPositions) match {
+      case (Some(old), Some(newPos)) => Some(newPos.map(old))
+      case (_, None) => existing
+      case (None, Some(newPos)) => Some(newPos)
+    }
+  }
+
+  /**
+   * Project common partition values onto `projectionPositions` of the layout 
`expressions`
+   * describes and deduplicate. Values that collapse to the same key are read 
as one group, so
+   * they become one partition, unless the scan is partially clustered, where 
each split of the
+   * group still gets its own partition and the split counts add up.
+   *
+   * The keys keep the order in which they first appear, as 
`KeyGroupedPartitioning.apply` does.
+   * A one-side shuffle routes the other child into that order, so partition i 
of both sides must
+   * hold the same key.
+   */
+  private def projectAndDedupeCommonPartitionValues(
       values: Seq[(InternalRow, Int)],
-      joinKeyPositions: Option[Seq[Int]],
-      reducers: Option[Seq[Option[Reducer[_, _]]]],
-      applyPartialClustering: Boolean,
-      replicatePartitions: Boolean): SparkPlan = plan match {
-    case scan: BatchScanExec =>
-      scan.copy(
-        spjParams = scan.spjParams.copy(
-          commonPartitionValues = Some(values),
-          joinKeyPositions = joinKeyPositions,
-          reducers = reducers,
-          applyPartialClustering = applyPartialClustering,
-          replicatePartitions = replicatePartitions
-        )
-      )
-    case node =>
-      node.mapChildren(child => populateCommonPartitionInfo(
-        child, values, joinKeyPositions, reducers, applyPartialClustering, 
replicatePartitions))
+      expressions: Seq[Expression],
+      projectionPositions: Seq[Int],
+      partiallyClustered: Boolean): Seq[(InternalRow, Int)] = {
+    val projectedExpressions = projectionPositions.map(expressions(_))
+    val projected = values.map { case (partValue, numSplits) =>
+      val row = KeyGroupedPartitioning.project(expressions, 
projectionPositions, partValue)
+      (InternalRowComparableWrapper(row, projectedExpressions), numSplits)
+    }
+    val splitsPerKey = projected.groupMapReduce(_._1)(_._2)(_ + _)
+    projected.map(_._1).distinct.map { wrapper =>
+      (wrapper.row, if (partiallyClustered) splitsPerKey(wrapper) else 1)
+    }
+  }
+
+  /**
+   * The partition expressions a scan currently reports, i.e. its own 
partition expressions
+   * projected onto the join-key positions pushed so far.
+   */
+  private def reportedLayout(params: StoragePartitionJoinParams): 
Option[Seq[Expression]] =
+    params.keyGroupedPartitioning.map { expressions =>
+      params.joinKeyPositions.map(_.map(expressions)).getOrElse(expressions)
+    }
+
+  /**
+   * Common partition values for a scan after a push. Values pushed by this 
join are the merge with
+   * the other side and already sit in the layout `newPositions` select, so 
they win. Otherwise the
+   * values an inner join pushed sit in the scan's reported layout, and are 
projected onto
+   * `newPositions`, which index that layout.
+   */
+  private def finalizeCommonPartitionValues(
+      existing: StoragePartitionJoinParams,
+      pushedValues: Option[Seq[(InternalRow, Int)]],
+      newPositions: Option[Seq[Int]]): Option[Seq[(InternalRow, Int)]] = {
+    (pushedValues, existing.commonPartitionValues, newPositions, 
reportedLayout(existing)) match {
+      case (Some(values), _, _, _) => Some(values)
+      case (None, Some(values), Some(positions), Some(layout))
+          if values.nonEmpty && values.head._1.numFields == layout.length &&
+            positions != layout.indices =>
+        Some(projectAndDedupeCommonPartitionValues(
+          values, layout, positions, existing.applyPartialClustering))
+      case (None, values, _, _) => values
+    }
   }
 
+  /**
+   * Reducers for a scan after a push. The reducers an inner join pushed index 
the scan's reported
+   * layout, so they are first projected onto `newPositions`. A reducer this 
join pushes for a
+   * position applies to values the inner reducer already produced, so the two 
are chained.
+   */
+  private def composeReducers(
+      existing: Option[Seq[Option[Reducer[_, _]]]],
+      newPositions: Option[Seq[Int]],
+      pushed: Option[Seq[Option[Reducer[_, _]]]]): 
Option[Seq[Option[Reducer[_, _]]]] = {
+    val projectedExisting = (existing, newPositions) match {
+      case (Some(rs), Some(positions)) => Some(positions.map(rs))
+      case _ => existing
+    }
+    (projectedExisting, pushed) match {
+      case (Some(inner), Some(outer)) =>
+        assert(inner.length == outer.length,
+          "pushed reducers must index the same layout as the composed join-key 
positions")
+        Some(inner.zip(outer).map {
+          case (Some(i), Some(o)) => Some(ChainedReducer(i, o))
+          case (i, o) => o.orElse(i)
+        })
+      case (inner, outer) => outer.orElse(inner)
+    }
+  }
 
-  private def populateJoinKeyPositions(
+  /**
+   * Whether `spec` uses a partition expression of a scan below `plan` whose 
splits an inner join
+   * already reduced at that position. Such a scan reports its own partition 
transform there but
+   * values of the reduced transform, so a spec built on that position 
describes a layout the scan
+   * does not have. Positions without a reducer still hold the scan's own 
values and are sound.
+   */
+  private def isBuiltFromReducedScan(plan: SparkPlan, spec: 
KeyGroupedShuffleSpec): Boolean = {
+    // A `Project` between the joins can rename the column under the 
transform, so map the spec's
+    // columns back through the aliases below `plan` before comparing them 
with the scan's.
+    val aliases = AttributeMap(plan.flatMap(_.expressions).flatMap(_.collect {

Review Comment:
   **Finding 30.** `BroadcastHashJoinExec.expandOutputPartitioning` 
(`BroadcastHashJoinExec.scala:114`) adds a copy of the streamed side's 
partitioning, with each streamed key replaced by its build key. So a nested 
join over a reduced `l12` also reports `bucket(12, lx.id)`, and a later join on 
`lx.id` builds its spec on that copy. No alias maps `lx.id` back to `l12.id`. 
So the exact match that finding 22 asked for does not see the reduction. The 
function match at `fdd7408` did.
   
   `l12`, `l4` and `l3` are `PARTITIONED BY (bucket(12|4|3, id))`, `lx` is not 
partitioned, and each holds BIGINT ids 0 to 23. With 
`allowCompatibleTransforms`:
   
   ```sql
   SELECT /*+ MERGE(l12, l4), BROADCAST(lx), MERGE(l3) */ l12.id, lx.id, l3.id
   FROM l12 JOIN l4 ON l12.id = l4.id
   JOIN lx ON l12.id = lx.id
   JOIN l3 ON lx.id = l3.id
   ```
   
   - This head and `branch-4.1` return 8 of 24 rows, with no shuffle, AQE on 
and off.
   - `fdd7408` returns all 24.
   
   Here the outer join pushes the `bucket(12) -> bucket(3)` reducer into both 
nested scans. So `l12` groups by `x % 12 % 4 % 3`, and `l4` by `x % 4 % 3`. 
`l3` holds `x % 3`.
   
   Mapping a broadcast join's build keys back to its streamed keys, next to the 
aliases, fixes it:
   
   ```scala
       val broadcastKeys = plan.flatMap {
         case j: BroadcastHashJoinExec =>
           val (buildKeys, streamedKeys) = j.buildSide match {
             case BuildLeft => (j.leftKeys, j.rightKeys)
             case BuildRight => (j.rightKeys, j.leftKeys)
           }
           buildKeys.zip(streamedKeys).collect { case (b: Attribute, s: 
Attribute) => b -> s }
         case _ => Nil
       }
       val aliases = AttributeMap(plan.flatMap(_.expressions).flatMap(_.collect 
{
         case a @ Alias(child: Attribute, _) => a.toAttribute -> child
       }) ++ broadcastKeys)
   ```
   
   It needs `BroadcastHashJoinExec` and 
`org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}` imported. 
With it, the query returns all 24 rows, AQE on and off, and both suites pass. 
The general problem is 
[SPARK-59121](https://issues.apache.org/jira/browse/SPARK-59121).
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -144,7 +146,16 @@ case class EnsureRequirements(
             // To choose a KeyGroupedShuffleSpec, we must be able to push down 
SPJ parameters into
             // the scan (for join key positions). If these parameters can't be 
pushed down, this
             // spec can't be used to shuffle other children.
-            case (idx, _: KeyGroupedShuffleSpec) => 
canPushDownSPJParamsToScan(children(idx))
+            case (idx, s: KeyGroupedShuffleSpec) =>
+              canPushDownSPJParamsToScan(children(idx)) &&
+                !isBuiltFromReducedScan(children(idx), s)

Review Comment:
   **Finding 27.** When `forceOptimizeSkewedJoin` is on and a skew split breaks 
a requirement, `OptimizeSkewedJoin` re-applies this rule to the whole plan 
(`OptimizeSkewedJoin.scala:240`). So a nested SPJ that the first pass already 
planned goes through it a second time. Take `b4 JOIN b12 ON b4.id = b12.id JOIN 
b6 ON b4.id = b6.id`. The first pass reduces all three scans onto `bucket(2)`, 
with the merged values `[0, 1]`. On the second pass:
   
   - The outer join's spec is `b4`'s `bucket(4, b4.id)`. `b4` now carries the 
`4 -> 2` reducer that the outer join itself pushed. `isBuiltFromReducedScan` 
reads that as an inner reduction and refuses the spec.
   - `b6`'s scan still carries its `6 -> 2` reducer and `[0, 1]`. No join sits 
above it, so its spec passes here and becomes the template. The nested join is 
shuffled into `bucket(6, b4.id)` with the values `[0, 1]`, which `b6`'s reduced 
partitions do not match.
   
   The comment at L853-L854 says that looking below a join keeps a re-run from 
matching a join's own scans. That holds only for the scans directly below the 
join.
   
   What I ran: `b4`, `b12` and `b6` are `PARTITIONED BY (bucket(4|12|6, id))` 
and hold BIGINT ids 0 to 23. `skewData1`, `skewData2` and the confs are those 
of `SPARK-33832: Support optimize skew join even if introduce extra shuffle` in 
`AdaptiveQueryExecSuite`, plus `shuffle.enabled` and 
`allowCompatibleTransforms`:
   
   ```sql
   SELECT key1 AS k, count(*) AS c FROM skewData1 JOIN skewData2 ON key1 = key2 
GROUP BY key1
   UNION ALL
   SELECT /*+ MERGE(b4, b12, b6) */ b4.id AS k, 1L AS c
   FROM b4 JOIN b12 ON b4.id = b12.id JOIN b6 ON b4.id = b6.id
   ```
   
   - With `forceOptimizeSkewedJoin=true`, the second branch returns 20 of 24 
rows, here and at `fdd7408`.
   - With `false`, it returns all 24.
   - `branch-4.1` returns 24 both ways. It only gets there because bucket 
reducers compose, as in `x % 12 % 2 == x % 2`.
   
   Only an earlier run can leave reducers on a scan that has no join above it. 
So refusing such a scan as a template fixes the rows:
   
   ```scala
               case (idx, s: KeyGroupedShuffleSpec) =>
                 canPushDownSPJParamsToScan(children(idx)) &&
                   !isBuiltFromReducedScan(children(idx), s) &&
                   !hasReducedScanOutsideJoin(children(idx))
   ```
   
   ```scala
     // A scan that carries reducers without a join above it in `plan` got them 
from an earlier run
     // of this rule, so it reports its own transform over reduced values.
     private def hasReducedScanOutsideJoin(plan: SparkPlan): Boolean = plan 
match {
       case _: SortMergeJoinExec | _: ShuffledHashJoinExec => false
       case _: ShuffleExchangeLike | _: BroadcastExchangeLike => false
       case scan: BatchScanExec => 
scan.spjParams.reducers.exists(_.exists(_.isDefined))
       case other => other.children.exists(hasReducedScanOutsideJoin)
     }
   ```
   
   With it, the query returns all 24 rows, and both suites pass. The re-run 
then plans 2 more shuffles than `branch-4.1`, whose rows are right only through 
that bucket algebra. So please list that cost under the user-facing changes. 
[SPARK-58996](https://issues.apache.org/jira/browse/SPARK-58996) is another bug 
on this path.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -698,42 +738,176 @@ case class EnsureRequirements(
         joinType == LeftAnti || joinType == LeftOuter
   }
 
-  // Populate the common partition information down to the scan nodes
-  private def populateCommonPartitionInfo(
-      plan: SparkPlan,
+  /**
+   * Compose join-key positions from an outer SPJ with positions already 
pushed by an inner SPJ.
+   * Outer positions index the layout the outer join sees; inner positions 
index the scan's raw
+   * partition expressions, so they are composed rather than replaced.
+   */
+  private def composeJoinKeyPositions(
+      existing: Option[Seq[Int]],
+      newPositions: Option[Seq[Int]]): Option[Seq[Int]] = {
+    (existing, newPositions) match {
+      case (Some(old), Some(newPos)) => Some(newPos.map(old))
+      case (_, None) => existing
+      case (None, Some(newPos)) => Some(newPos)
+    }
+  }
+
+  /**
+   * Project common partition values onto `projectionPositions` of the layout 
`expressions`
+   * describes and deduplicate. Values that collapse to the same key are read 
as one group, so
+   * they become one partition, unless the scan is partially clustered, where 
each split of the
+   * group still gets its own partition and the split counts add up.
+   *
+   * The keys keep the order in which they first appear, as 
`KeyGroupedPartitioning.apply` does.
+   * A one-side shuffle routes the other child into that order, so partition i 
of both sides must
+   * hold the same key.
+   */
+  private def projectAndDedupeCommonPartitionValues(
       values: Seq[(InternalRow, Int)],
-      joinKeyPositions: Option[Seq[Int]],
-      reducers: Option[Seq[Option[Reducer[_, _]]]],
-      applyPartialClustering: Boolean,
-      replicatePartitions: Boolean): SparkPlan = plan match {
-    case scan: BatchScanExec =>
-      scan.copy(
-        spjParams = scan.spjParams.copy(
-          commonPartitionValues = Some(values),
-          joinKeyPositions = joinKeyPositions,
-          reducers = reducers,
-          applyPartialClustering = applyPartialClustering,
-          replicatePartitions = replicatePartitions
-        )
-      )
-    case node =>
-      node.mapChildren(child => populateCommonPartitionInfo(
-        child, values, joinKeyPositions, reducers, applyPartialClustering, 
replicatePartitions))
+      expressions: Seq[Expression],
+      projectionPositions: Seq[Int],
+      partiallyClustered: Boolean): Seq[(InternalRow, Int)] = {
+    val projectedExpressions = projectionPositions.map(expressions(_))
+    val projected = values.map { case (partValue, numSplits) =>
+      val row = KeyGroupedPartitioning.project(expressions, 
projectionPositions, partValue)
+      (InternalRowComparableWrapper(row, projectedExpressions), numSplits)
+    }
+    val splitsPerKey = projected.groupMapReduce(_._1)(_._2)(_ + _)
+    projected.map(_._1).distinct.map { wrapper =>
+      (wrapper.row, if (partiallyClustered) splitsPerKey(wrapper) else 1)
+    }
+  }
+
+  /**
+   * The partition expressions a scan currently reports, i.e. its own 
partition expressions
+   * projected onto the join-key positions pushed so far.
+   */
+  private def reportedLayout(params: StoragePartitionJoinParams): 
Option[Seq[Expression]] =
+    params.keyGroupedPartitioning.map { expressions =>
+      params.joinKeyPositions.map(_.map(expressions)).getOrElse(expressions)
+    }
+
+  /**
+   * Common partition values for a scan after a push. Values pushed by this 
join are the merge with
+   * the other side and already sit in the layout `newPositions` select, so 
they win. Otherwise the
+   * values an inner join pushed sit in the scan's reported layout, and are 
projected onto
+   * `newPositions`, which index that layout.
+   */
+  private def finalizeCommonPartitionValues(
+      existing: StoragePartitionJoinParams,
+      pushedValues: Option[Seq[(InternalRow, Int)]],
+      newPositions: Option[Seq[Int]]): Option[Seq[(InternalRow, Int)]] = {
+    (pushedValues, existing.commonPartitionValues, newPositions, 
reportedLayout(existing)) match {
+      case (Some(values), _, _, _) => Some(values)
+      case (None, Some(values), Some(positions), Some(layout))
+          if values.nonEmpty && values.head._1.numFields == layout.length &&
+            positions != layout.indices =>
+        Some(projectAndDedupeCommonPartitionValues(
+          values, layout, positions, existing.applyPartialClustering))
+      case (None, values, _, _) => values
+    }
   }
 
+  /**
+   * Reducers for a scan after a push. The reducers an inner join pushed index 
the scan's reported
+   * layout, so they are first projected onto `newPositions`. A reducer this 
join pushes for a
+   * position applies to values the inner reducer already produced, so the two 
are chained.
+   */
+  private def composeReducers(
+      existing: Option[Seq[Option[Reducer[_, _]]]],
+      newPositions: Option[Seq[Int]],
+      pushed: Option[Seq[Option[Reducer[_, _]]]]): 
Option[Seq[Option[Reducer[_, _]]]] = {
+    val projectedExisting = (existing, newPositions) match {
+      case (Some(rs), Some(positions)) => Some(positions.map(rs))
+      case _ => existing
+    }
+    (projectedExisting, pushed) match {
+      case (Some(inner), Some(outer)) =>
+        assert(inner.length == outer.length,
+          "pushed reducers must index the same layout as the composed join-key 
positions")
+        Some(inner.zip(outer).map {
+          case (Some(i), Some(o)) => Some(ChainedReducer(i, o))
+          case (i, o) => o.orElse(i)
+        })
+      case (inner, outer) => outer.orElse(inner)
+    }
+  }
 
-  private def populateJoinKeyPositions(
+  /**
+   * Whether `spec` uses a partition expression of a scan below `plan` whose 
splits an inner join
+   * already reduced at that position. Such a scan reports its own partition 
transform there but
+   * values of the reduced transform, so a spec built on that position 
describes a layout the scan
+   * does not have. Positions without a reducer still hold the scan's own 
values and are sound.
+   */
+  private def isBuiltFromReducedScan(plan: SparkPlan, spec: 
KeyGroupedShuffleSpec): Boolean = {
+    // A `Project` between the joins can rename the column under the 
transform, so map the spec's
+    // columns back through the aliases below `plan` before comparing them 
with the scan's.
+    val aliases = AttributeMap(plan.flatMap(_.expressions).flatMap(_.collect {
+      case a @ Alias(child: Attribute, _) => a.toAttribute -> child
+    }))
+    def unalias(e: Expression): Expression = e.transformUp {
+      case a: Attribute if aliases.contains(a) => unalias(aliases(a))
+    }
+    val specExpressions = spec.partitioning.expressions.map(unalias)
+    // Only a nested join can hand a reduced scan's layout to a later join. 
Looking below a join
+    // also keeps a re-run of this rule over an already planned join from 
matching its own scans.
+    plan.exists {

Review Comment:
   **Finding 29.** A shuffle re-partitions its input on the values it routes, 
so a reduced scan below it no longer decides the layout above it. 
`populateSpjParams` stops at a shuffle, but this walk goes on below it and 
still finds the scan. Take `l12`, `l4`, `c12` and `d12` `PARTITIONED BY 
(bucket(12|4|12|12, id))`, each holding BIGINT ids 0 to 23. With 
`shuffle.enabled` and `allowCompatibleTransforms`:
   
   ```sql
   SELECT /*+ MERGE(l12, l4, c12, d12) */ l12.id, l4.id, c12.id, d12.id
   FROM l12 JOIN l4 ON l12.id = l4.id
   JOIN c12 ON l12.id = c12.id
   JOIN d12 ON l12.id = d12.id
   ```
   
   - The second join shuffles the nested one onto `c12`'s layout, as intended.
   - With AQE off, the third join's spec comes from that shuffle. The walk 
finds `l12` below it, so the left side is shuffled again, onto `d12`'s layout. 
The plan has 2 shuffles.
   - With AQE on, the plan has 1 shuffle.
   - `branch-4.1` plans no shuffle, but returns 8 of 24 rows.
   
   Stopping both of its walks at an exchange gives 1 shuffle with AQE off too, 
with the same 24 rows, and both suites pass. In place of `plan.exists` and 
`join.exists`:
   
   ```scala
       def existsAbove(p: SparkPlan)(f: PartialFunction[SparkPlan, Boolean]): 
Boolean = p match {
         case _: ShuffleExchangeLike | _: BroadcastExchangeLike => false
         case _ => f.applyOrElse(p, (_: SparkPlan) => false) || 
p.children.exists(existsAbove(_)(f))
       }
   ```
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -2980,6 +2980,837 @@ class KeyGroupedPartitioningSuite extends 
DistributionAndOrderingSuiteBase {
     }
   }
 
+  test("SPARK-60232: nested SPJ preserves inner join key positions") {
+    val cols = Array(
+      Column.create("store_id", IntegerType),
+      Column.create("dept_id", IntegerType),
+      Column.create("data", StringType))
+    // t4 is partitioned by (store_id, dept_id) and joins on dept_id only. The 
inner join
+    // pushes joinKeyPositions [1] into the t4 scan. The outer join must not 
overwrite that
+    // with [0] against the already projected dept_id, or t4 is grouped by 
store_id and
+    // the three-way join returns no rows.
+    createTable("t4", cols, Array(identity("store_id"), identity("dept_id")))
+    sql("INSERT INTO testcat.ns.t4 VALUES (1, 2, 'a'), (2, 3, 'b'), (3, 1, 
'c')")
+
+    createTable("t5", cols, Array(identity("dept_id")))
+    sql("INSERT INTO testcat.ns.t5 VALUES (0, 1, 'x1'), (0, 2, 'x2'), (0, 3, 
'x3')")
+
+    createTable("t6", cols, Array(identity("dept_id")))
+    sql("INSERT INTO testcat.ns.t6 VALUES (0, 1, 'x1'), (0, 2, 'x2'), (0, 3, 
'x3')")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(t4, t5, t6) */
+            |  t4.store_id, t4.dept_id, t4.data, t5.data, t6.data
+            |FROM testcat.ns.t4
+            |JOIN testcat.ns.t5 ON t4.dept_id = t5.dept_id
+            |JOIN testcat.ns.t6 ON t4.dept_id = t6.dept_id
+            |ORDER BY t4.store_id, t4.dept_id
+            |""".stripMargin)
+
+        val plan = df.queryExecution.executedPlan
+        assert(collectShuffles(plan).isEmpty, "SPJ should be triggered for 
both joins")
+        val t4Scan = collectScans(plan).find(_.table.name().endsWith("t4")).get
+        assert(t4Scan.spjParams.joinKeyPositions.contains(Seq(1)),
+          "t4 scan should keep the inner join's key position [1], not [0]")
+        checkAnswer(df, Seq(
+          Row(1, 2, "a", "x2", "x2"),
+          Row(2, 3, "b", "x3", "x3"),
+          Row(3, 1, "c", "x1", "x1")))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ with mixed partition key types preserves key 
positions") {
+    val cols = Array(
+      Column.create("id", StringType),
+      Column.create("date", StringType),
+      Column.create("data", StringType))
+    // Same nested join-key overwrite as above, but the leading partition key 
has a different
+    // type than the bucket join key. Without the fix this shape throws 
ClassCastException in
+    // connectors such as Iceberg; here the in-memory source only misaligns 
values.
+    createTable("t1", cols,
+      Array(identity("date"), bucket(32, "id"), bucket(32, "data")))
+    sql("INSERT INTO testcat.ns.t1 VALUES " +
+        "('a', '2026-05-01', 'aa'), " +
+        "('a', '2026-05-02', 'cc'), " +
+        "('b', '2026-05-01', 'bb')")
+
+    createTable("t2", cols, Array(bucket(32, "id")))
+    sql("INSERT INTO testcat.ns.t2 VALUES " +
+        "('a', '2026-05-01', 'xx'), " +
+        "('b', '2026-05-01', 'yy')")
+
+    createTable("t3", cols, Array(bucket(32, "id")))
+    sql("INSERT INTO testcat.ns.t3 VALUES " +
+        "('a', '2026-05-01', 'xx'), " +
+        "('b', '2026-05-01', 'yy')")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(t1, t2, t3) */
+            |  t1.id, t1.date, t1.data, t2.data, t3.data
+            |FROM testcat.ns.t1
+            |JOIN testcat.ns.t2 ON t1.id = t2.id
+            |JOIN testcat.ns.t3 ON t1.id = t3.id
+            |ORDER BY t1.id, t1.date, t1.data
+            |""".stripMargin)
+
+        val plan = df.queryExecution.executedPlan
+        assert(collectShuffles(plan).isEmpty, "SPJ should be triggered for 
both joins")
+        val t1Scan = collectScans(plan).find(_.table.name().endsWith("t1")).get
+        assert(t1Scan.spjParams.joinKeyPositions.contains(Seq(1)),
+          "t1 scan should keep the inner join's key position [1], not [0]")
+        checkAnswer(df, Seq(
+          Row("a", "2026-05-01", "aa", "xx", "xx"),
+          Row("a", "2026-05-02", "cc", "xx", "xx"),
+          Row("b", "2026-05-01", "bb", "yy", "yy")))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ preserves inner join reducers") {
+    val idCols = Array(Column.create("id", IntegerType))
+    createTable("b4", idCols, Array(bucket(4, "id")))
+    sql("INSERT INTO testcat.ns.b4 VALUES (4)")
+
+    createTable("b12", idCols, Array(bucket(12, "id")))
+    sql("INSERT INTO testcat.ns.b12 VALUES (4)")
+
+    createTable("b8", idCols, Array(bucket(8, "id")))
+    sql("INSERT INTO testcat.ns.b8 VALUES (1), (4)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(b4, b12, b8) */ b4.id, b12.id, b8.id
+            |FROM testcat.ns.b4
+            |JOIN testcat.ns.b12 ON b4.id = b12.id
+            |JOIN testcat.ns.b8 ON b4.id = b8.id
+            |""".stripMargin)
+
+        val plan = df.queryExecution.executedPlan
+        assert(collectShuffles(plan).isEmpty, "SPJ should be triggered for 
both joins")
+        val scans = collectScans(plan)
+        def reducersOf(table: String): Option[Seq[Option[Reducer[_, _]]]] =
+          
scans.find(_.table.name().endsWith(s".$table")).get.spjParams.reducers
+        assert(reducersOf("b4").isEmpty, "b4 is the coarsest side and needs no 
reducer")
+        assert(reducersOf("b12").exists(_.length == 1) && 
reducersOf("b12").get.head.isDefined,
+          "b12 scan should keep the inner join's reducer onto bucket(4, id)")
+        assert(reducersOf("b8").exists(_.length == 1) && 
reducersOf("b8").get.head.isDefined,
+          "b8 scan should get the outer join's reducer onto bucket(4, id)")
+        checkAnswer(df, Seq(Row(4, 4, 4)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ preserves join key positions over common 
partition values") {
+    val items2 = "items2"
+    val itemsPartitions = Array(identity("id"), identity("name"))
+
+    val inserts = Seq(
+      s"INSERT INTO testcat.ns.$items VALUES " +
+        "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+        "(2, 'bb', 20.0, cast('2020-01-01' as timestamp))",
+      s"INSERT INTO testcat.ns.$items2 VALUES " +
+        "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+        "(2, 'bb', 20.0, cast('2020-01-01' as timestamp))",
+      "INSERT INTO testcat.ns.purchases VALUES " +
+        "(1, 100.0, cast('2020-01-01' as timestamp)), " +
+        "(2, 200.0, cast('2020-01-01' as timestamp))")
+    val expected = Seq(Row(1, "aa", 10.0, 100.0), Row(2, "bb", 20.0, 200.0))
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        catalog.clearTables()
+        createTable(items, itemsColumns, itemsPartitions)
+        createTable(items2, itemsColumns, itemsPartitions)
+        createTable(purchases, purchasesColumns, Array.empty)
+        inserts.foreach(sql)
+
+        val df = sql(
+          s"""
+             |SELECT /*+ MERGE(i, j, p) */ i.id, i.name, i.price, p.price
+             |FROM testcat.ns.$items i JOIN testcat.ns.$items2 j
+             |ON i.id = j.id AND i.name = j.name
+             |JOIN testcat.ns.$purchases p ON i.id = p.item_id
+             |ORDER BY i.id, i.name
+             |""".stripMargin)
+
+        val plan = df.queryExecution.executedPlan
+        assert(collectShuffles(plan).size == 1,
+          "only the unpartitioned purchases side should shuffle")
+        val itemsScan = 
collectScans(plan).find(_.table.name().endsWith(items)).get
+        assert(itemsScan.spjParams.commonPartitionValues.nonEmpty,
+          "inner join should push common partition values into the items scan")
+        assert(itemsScan.spjParams.joinKeyPositions.contains(Seq(0)),
+          "outer join should project common partition values down to the id 
join key")
+        checkAnswer(df, expected)
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ with multiple names per id over common 
partition values") {
+    val items2 = "items2"
+    val itemsPartitions = Array(identity("id"), identity("name"))
+    val inserts = Seq(
+      s"INSERT INTO testcat.ns.$items VALUES " +
+        "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+        "(1, 'bb', 20.0, cast('2020-01-02' as timestamp))",
+      s"INSERT INTO testcat.ns.$items2 VALUES " +
+        "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+        "(1, 'bb', 20.0, cast('2020-01-02' as timestamp))",
+      "INSERT INTO testcat.ns.purchases VALUES " +
+        "(1, 100.0, cast('2020-01-01' as timestamp))")
+    val expected = Seq(Row(1, "aa", 10.0, 100.0), Row(1, "bb", 20.0, 100.0))
+    Seq(true, false).foreach { aqe =>
+        catalog.clearTables()
+        createTable(items, itemsColumns, itemsPartitions)
+        createTable(items2, itemsColumns, itemsPartitions)
+        createTable(purchases, purchasesColumns, Array.empty)
+        inserts.foreach(sql)
+
+        withSQLConf(
+          SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+          SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+          SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+          SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+          SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+          val df = sql(
+            s"""
+               |SELECT /*+ MERGE(i, j, p) */ i.id, i.name, i.price, p.price
+               |FROM testcat.ns.$items i JOIN testcat.ns.$items2 j
+               |ON i.id = j.id AND i.name = j.name
+               |JOIN testcat.ns.$purchases p ON i.id = p.item_id
+               |ORDER BY i.id, i.name
+               |""".stripMargin)
+
+          val plan = df.queryExecution.executedPlan
+          assert(collectShuffles(plan).size == 1,
+            "only the unpartitioned purchases side should shuffle")
+          checkAnswer(df, expected)
+        }
+    }
+  }
+
+  private def createNestedSpjIdTables(): Unit = {
+    val cols = Array(
+      Column.create("store", IntegerType),
+      Column.create("id", IntegerType),
+      Column.create("data", StringType))
+    Seq("e1", "e2", "e3", "e5").foreach(t => createTable(t, cols, 
Array(identity("id"))))
+    sql("INSERT INTO testcat.ns.e1 VALUES (0, 1, 'a'), (0, 2, 'b')")
+    sql("INSERT INTO testcat.ns.e2 VALUES (0, 1, 'a'), (0, 2, 'b')")
+    sql("INSERT INTO testcat.ns.e3 VALUES (0, 1, 'a'), (0, 2, 'b'), (0, 3, 
'c')")
+    sql("INSERT INTO testcat.ns.e5 VALUES (0, 2, 'b'), (0, 3, 'c')")
+    createTable("e4", cols, Array(identity("store"), identity("id")))
+    sql("INSERT INTO testcat.ns.e4 VALUES (10, 1, 'a'), (20, 2, 'b'), (30, 3, 
'c')")
+  }
+
+  test("SPARK-60232: nested SPJ pushes outer merged values into a nested 
join") {
+    createNestedSpjIdTables()
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.V2_BUCKETING_PARTITION_FILTER_ENABLED.key -> "true") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(e1, e2, e5) */ e1.id, e2.id, e5.id
+            |FROM testcat.ns.e1 JOIN testcat.ns.e2 ON e1.id = e2.id
+            |RIGHT JOIN testcat.ns.e5 ON e1.id = e5.id
+            |""".stripMargin)
+        checkAnswer(df, Seq(Row(2, 2, 2), Row(null, null, 3)))
+      }
+
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(e1, e2, e3) */ e1.id, e2.id, e3.id
+            |FROM testcat.ns.e1 JOIN testcat.ns.e2 ON e1.id = e2.id
+            |JOIN testcat.ns.e3 ON e1.id = e3.id
+            |""".stripMargin)
+        assert(collectShuffles(df.queryExecution.executedPlan).isEmpty,
+          "SPJ should be triggered for both joins")
+        checkAnswer(df, Seq(Row(1, 1, 1), Row(2, 2, 2)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ with subset join keys on a two-key partitioned 
side") {
+    createNestedSpjIdTables()
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        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_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(e1, e2, e4) */ e1.id, e2.id, e4.id, e4.store
+            |FROM testcat.ns.e1 JOIN testcat.ns.e2 ON e1.id = e2.id
+            |JOIN testcat.ns.e4 ON e1.id = e4.id
+            |""".stripMargin)
+        checkAnswer(df, Seq(Row(1, 1, 1, 10), Row(2, 2, 2, 20)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ on all partition keys, then on a subset") {
+    val cols = Array(
+      Column.create("store_id", IntegerType),
+      Column.create("dept_id", IntegerType),
+      Column.create("data", StringType))
+    createTable("a", cols, Array(identity("store_id"), identity("dept_id")))
+    createTable("b", cols, Array(identity("store_id"), identity("dept_id")))
+    createTable("c", cols, Array(identity("dept_id")))
+    sql("INSERT INTO testcat.ns.a VALUES (1, 2, 'a1'), (2, 2, 'a2'), (3, 1, 
'a3')")
+    sql("INSERT INTO testcat.ns.b VALUES (1, 2, 'b1'), (2, 2, 'b2'), (3, 1, 
'b3')")
+    sql("INSERT INTO testcat.ns.c VALUES (0, 1, 'c1'), (0, 2, 'c2'), (0, 3, 
'c3')")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val expected = Seq(
+          Row(1, 2, "a1", 1, 2, "b1", 0, 2, "c2"),
+          Row(2, 2, "a2", 2, 2, "b2", 0, 2, "c2"),
+          Row(3, 1, "a3", 3, 1, "b3", 0, 1, "c1"))
+        Seq("a.dept_id", "b.dept_id").foreach { outerKey =>
+          val df = sql(
+            s"""
+               |SELECT /*+ MERGE(a, b, c) */ *
+               |FROM testcat.ns.a JOIN testcat.ns.b
+               |ON a.store_id = b.store_id AND a.dept_id = b.dept_id
+               |JOIN testcat.ns.c ON $outerKey = c.dept_id
+               |""".stripMargin)
+          checkAnswer(df, expected)
+        }
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ dedupes common values that project to the same 
join key") {
+    val cols = Array(
+      Column.create("store_id", IntegerType),
+      Column.create("dept_id", IntegerType),
+      Column.create("data", StringType))
+    createTable("a", cols, Array(identity("store_id"), identity("dept_id")))
+    createTable("b", cols, Array(identity("store_id"), identity("dept_id")))
+    createTable("c", cols, Array(identity("dept_id")))
+    sql("INSERT INTO testcat.ns.a VALUES (1, 2, 'a1'), (2, 2, 'a2')")
+    sql("INSERT INTO testcat.ns.b VALUES (1, 2, 'b1'), (2, 2, 'b2')")
+    sql("INSERT INTO testcat.ns.c VALUES (0, 1, 'c1'), (0, 2, 'c2'), (0, 3, 
'c3')")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(a, b, c) */ a.data, b.data, c.data
+            |FROM testcat.ns.a JOIN testcat.ns.b
+            |ON a.store_id = b.store_id AND a.dept_id = b.dept_id
+            |JOIN testcat.ns.c ON a.dept_id = c.dept_id
+            |""".stripMargin)
+        checkAnswer(df, Seq(Row("a1", "b1", "c2"), Row("a2", "b2", "c2")))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ with several names per id and a partitioned 
third side") {
+    val cols = Array(
+      Column.create("id", IntegerType),
+      Column.create("name", StringType),
+      Column.create("price", DoubleType))
+    createTable("i", cols, Array(identity("id"), identity("name")))
+    createTable("j", cols, Array(identity("id"), identity("name")))
+    createTable("k", Array(Column.create("id", IntegerType), 
Column.create("v", DoubleType)),
+      Array(identity("id")))
+    sql("INSERT INTO testcat.ns.i VALUES (1, 'aa', 1.0), (1, 'bb', 2.0)")
+    sql("INSERT INTO testcat.ns.j VALUES (1, 'aa', 1.0), (1, 'bb', 2.0)")
+    sql("INSERT INTO testcat.ns.k VALUES (1, 10.0), (2, 20.0)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        checkAnswer(
+          sql(
+            """
+              |SELECT /*+ MERGE(i, j, k) */ i.id, i.name, j.name, k.v
+              |FROM testcat.ns.i JOIN testcat.ns.j ON i.id = j.id AND i.name = 
j.name
+              |JOIN testcat.ns.k ON i.id = k.id
+              |""".stripMargin),
+          Seq(Row(1, "aa", "aa", 10.0), Row(1, "bb", "bb", 10.0)))
+        checkAnswer(
+          sql(
+            """
+              |SELECT /*+ MERGE(i, j, k) */ i.id, i.name, k.v
+              |FROM testcat.ns.i LEFT ANTI JOIN testcat.ns.j
+              |ON i.id = j.id AND i.name = j.name
+              |JOIN testcat.ns.k ON i.id = k.id
+              |""".stripMargin),
+          Seq.empty)
+      }
+    }
+  }
+
+  test("SPARK-60232: four-way nested SPJ joining on a key dropped by an 
earlier join") {
+    val cols = Array(
+      Column.create("id", IntegerType),
+      Column.create("name", StringType),
+      Column.create("price", DoubleType))
+    createTable("i", cols, Array(identity("id"), identity("name")))
+    createTable("j", cols, Array(identity("id"), identity("name")))
+    createTable("k", Array(Column.create("id", IntegerType), 
Column.create("v", DoubleType)),
+      Array(identity("id")))
+    createTable("m", Array(Column.create("name", StringType), 
Column.create("w", DoubleType)),
+      Array(identity("name")))
+    sql("INSERT INTO testcat.ns.i VALUES (1, 'aa', 1.0), (2, 'bb', 2.0)")
+    sql("INSERT INTO testcat.ns.j VALUES (1, 'aa', 1.0), (2, 'bb', 2.0)")
+    sql("INSERT INTO testcat.ns.k VALUES (1, 10.0), (2, 20.0)")
+    sql("INSERT INTO testcat.ns.m VALUES ('aa', 100.0), ('bb', 200.0)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        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_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(i, j, k, m) */ i.id, i.name, k.v, m.w
+            |FROM testcat.ns.i JOIN testcat.ns.j ON i.id = j.id AND i.name = 
j.name
+            |JOIN testcat.ns.k ON i.id = k.id
+            |JOIN testcat.ns.m ON i.name = m.name
+            |""".stripMargin)
+        checkAnswer(df, Seq(Row(1, "aa", 10.0, 100.0), Row(2, "bb", 20.0, 
200.0)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ with compatible transforms across three bucket 
counts") {
+    val idCols = Array(Column.create("id", IntegerType))
+    Seq("b2" -> 2, "b4" -> 4, "b8" -> 8, "b12" -> 12, "c12" -> 12).foreach { 
case (t, n) =>
+      createTable(t, idCols, Array(bucket(n, "id")))
+      sql(s"INSERT INTO testcat.ns.$t SELECT id FROM range(24)")
+    }
+    val expected = (0 until 24).map(i => Row(i, i, i))
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        // When the outer key is on the reduced side, the outer join falls 
back to a shuffle.
+        Seq(
+          ("b12", "b4", "c12", 2),
+          ("b4", "b12", "c12", 0),
+          ("b8", "b4", "b2", 2),
+          ("b4", "b8", "b2", 0)).foreach { case (t1, t2, t3, numShuffles) =>
+          val df = sql(
+            s"""
+               |SELECT /*+ MERGE($t1, $t2, $t3) */ $t1.id, $t2.id, $t3.id
+               |FROM testcat.ns.$t1 JOIN testcat.ns.$t2 ON $t1.id = $t2.id
+               |JOIN testcat.ns.$t3 ON $t1.id = $t3.id
+               |""".stripMargin)
+          assert(collectShuffles(df.queryExecution.executedPlan).size == 
numShuffles,
+            s"$t1 JOIN $t2 JOIN $t3 should plan $numShuffles shuffles")
+          checkAnswer(df, expected)
+        }
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ narrows common values already projected by an 
inner join") {
+    val cols = Array(
+      Column.create("a", IntegerType),
+      Column.create("b", IntegerType),
+      Column.create("c", IntegerType))
+    createTable("ta", cols, Array(identity("a"), identity("b"), identity("c")))
+    createTable("tb", cols, Array(identity("b"), identity("c")))
+    createTable("tc", cols, Array.empty)
+    sql("INSERT INTO testcat.ns.ta VALUES (1, 10, 100), (2, 20, 100), (3, 30, 
300)")
+    sql("INSERT INTO testcat.ns.tb VALUES (0, 10, 100), (0, 20, 100), (0, 30, 
300)")
+    sql("INSERT INTO testcat.ns.tc VALUES (0, 0, 100), (0, 0, 300)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        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_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(ta, tb, tc) */ ta.a, ta.b, ta.c, tc.c
+            |FROM testcat.ns.ta JOIN testcat.ns.tb ON ta.b = tb.b AND ta.c = 
tb.c
+            |JOIN testcat.ns.tc ON ta.c = tc.c
+            |""".stripMargin)
+        checkAnswer(df, Seq(Row(1, 10, 100, 100), Row(2, 20, 100, 100), Row(3, 
30, 300, 300)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ keeps projected values in the order a one-side 
shuffle uses") {
+    val cols = Array(
+      Column.create("store", IntegerType),
+      Column.create("dept", IntegerType),
+      Column.create("data", StringType))
+    createTable("oa", cols, Array(identity("store"), identity("dept")))
+    createTable("ob", cols, Array(identity("store"), identity("dept")))
+    createTable("oc", cols, Array.empty)
+    // The projected dept keys come out as 20, 10, which is not their sorted 
order.
+    sql("INSERT INTO testcat.ns.oa VALUES (1, 20, 'a1'), (2, 10, 'a2')")
+    sql("INSERT INTO testcat.ns.ob VALUES (1, 20, 'b1'), (2, 10, 'b2')")
+    sql("INSERT INTO testcat.ns.oc VALUES (0, 10, 'c10'), (0, 20, 'c20')")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        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_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(oa, ob, oc) */ oa.store, oa.data, ob.data, 
oc.data
+            |FROM testcat.ns.oa JOIN testcat.ns.ob ON oa.store = ob.store AND 
oa.dept = ob.dept
+            |JOIN testcat.ns.oc ON oa.dept = oc.dept
+            |""".stripMargin)
+        assert(collectShuffles(df.queryExecution.executedPlan).size == 1,
+          "only the unpartitioned oc side should shuffle")
+        checkAnswer(df, Seq(Row(1, "a1", "b1", "c20"), Row(2, "a2", "b2", 
"c10")))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ does not shuffle the other side onto a reduced 
scan's layout") {
+    val idCols = Array(Column.create("id", LongType))
+    Seq("l12" -> 12, "l4" -> 4, "l2" -> 2).foreach { case (t, n) =>
+      createTable(t, idCols, Array(bucket(n, "id")))
+      sql(s"INSERT INTO testcat.ns.$t SELECT id FROM range(24)")
+    }
+    createTable("ld", idCols, Array.empty)
+    sql("INSERT INTO testcat.ns.ld SELECT id FROM range(24)")
+    val expected = (0L until 24L).map(i => Row(i, i, i))
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        Seq("l2" -> 1, "ld" -> 2).foreach { case (third, numShuffles) =>
+          val df = sql(
+            s"""
+               |SELECT /*+ MERGE(l12, l4, $third) */ l12.id, l4.id, $third.id
+               |FROM testcat.ns.l12 JOIN testcat.ns.l4 ON l12.id = l4.id
+               |JOIN testcat.ns.$third ON l12.id = $third.id
+               |""".stripMargin)
+          assert(collectShuffles(df.queryExecution.executedPlan).size == 
numShuffles,
+            s"the join with $third should plan $numShuffles shuffles")
+          checkAnswer(df, expected)
+        }
+
+        // An outer join reports a single partitioning rather than a 
collection.
+        val outerDf = sql(
+          """
+            |SELECT /*+ MERGE(l12, l4, ld) */ l12.id, l4.id, ld.id
+            |FROM testcat.ns.l12 LEFT JOIN testcat.ns.l4 ON l12.id = l4.id
+            |JOIN testcat.ns.ld ON l12.id = ld.id
+            |""".stripMargin)
+        checkAnswer(outerDf, expected)
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ keeps SPJ on a key the inner join did not 
reduce") {
+    val cols = Array(Column.create("store", IntegerType), Column.create("id", 
IntegerType))
+    createTable("ra", cols, Array(identity("store"), bucket(12, "id")))
+    createTable("rb", cols, Array(identity("store"), bucket(4, "id")))
+    createTable("rc", Array(Column.create("store", IntegerType)), 
Array(identity("store")))
+    sql("INSERT INTO testcat.ns.ra SELECT CAST(id % 3 AS INT), CAST(id AS INT) 
FROM range(12)")
+    sql("INSERT INTO testcat.ns.rb SELECT CAST(id % 3 AS INT), CAST(id AS INT) 
FROM range(12)")
+    sql("INSERT INTO testcat.ns.rc VALUES (0), (1), (2)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(ra, rb, rc) */ ra.id, rb.id, rc.store
+            |FROM testcat.ns.ra JOIN testcat.ns.rb ON ra.store = rb.store AND 
ra.id = rb.id
+            |JOIN testcat.ns.rc ON ra.store = rc.store
+            |""".stripMargin)
+        assert(collectShuffles(df.queryExecution.executedPlan).isEmpty,
+          "SPJ should be triggered for both joins")
+        checkAnswer(df, (0 until 12).map(i => Row(i, i, i % 3)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ does not push into a broadcast build side") {
+    createTable("qa", Array(Column.create("id", IntegerType), 
Column.create("x", StringType)),
+      Array(identity("id")))
+    createTable("qd", Array(Column.create("x", StringType), 
Column.create("region", IntegerType)),
+      Array(identity("region")))
+    createTable("qc", Array(Column.create("id", IntegerType)), 
Array(identity("id")))
+    sql("INSERT INTO testcat.ns.qa VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+    sql("INSERT INTO testcat.ns.qd VALUES ('a', 10), ('b', 20), ('c', 30)")
+    sql("INSERT INTO testcat.ns.qc VALUES (1), (2), (3)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+        val df = sql(
+          """
+            |SELECT /*+ BROADCAST(qd) */ qa.id, qd.region, qc.id
+            |FROM testcat.ns.qa JOIN testcat.ns.qd ON qa.x = qd.x
+            |JOIN testcat.ns.qc ON qa.id = qc.id
+            |""".stripMargin)
+        checkAnswer(df, Seq(Row(1, 10, 1), Row(2, 20, 2), Row(3, 30, 3)))
+      }
+    }
+  }
+
+  test("SPARK-60232: ordering a subset-key SPJ keeps the join's key 
positions") {
+    val cols = Array(Column.create("a", IntegerType), Column.create("b", 
IntegerType))
+    createTable("oa3", cols, Array(identity("a"), identity("b")))
+    sql("INSERT INTO testcat.ns.oa3 VALUES (1, 10), (2, 20), (3, 10), (4, 30)")
+
+    createTable("ob3", cols, Array(identity("b")))
+    sql("INSERT INTO testcat.ns.ob3 VALUES (0, 10), (0, 20)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_SORTING_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(oa3, ob3) */ oa3.b, oa3.a, ob3.a
+            |FROM testcat.ns.oa3 LEFT JOIN testcat.ns.ob3 ON oa3.b = ob3.b
+            |ORDER BY oa3.b
+            |""".stripMargin)
+
+        // Rows with the same b may come in either order, so check the order 
on b alone.
+        val rows = df.collect().toSeq
+        assert(rows.map(_.getInt(0)) == Seq(10, 10, 20, 30), "rows should be 
ordered by b")
+        assert(rows.toSet ==
+          Set(Row(10, 1, 0), Row(10, 3, 0), Row(20, 2, 0), Row(30, 4, null)))
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ sees a reduced scan through an alias") {
+    val idCols = Array(Column.create("id", LongType))
+    Seq("l12" -> 12, "l4" -> 4, "l3" -> 3).foreach { case (t, n) =>
+      createTable(t, idCols, Array(bucket(n, "id")))
+      sql(s"INSERT INTO testcat.ns.$t SELECT id FROM range(24)")
+    }
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        // The nested join may also output the original column next to its 
alias.
+        Seq("", "l12.id, ").foreach { extraColumn =>
+          val df = sql(
+            s"""
+               |SELECT /*+ MERGE(x, l3) */ x.id2, x.id4, l3.id
+               |FROM (
+               |  SELECT /*+ MERGE(l12, l4) */ ${extraColumn}l12.id AS id2, 
l4.id AS id4
+               |  FROM testcat.ns.l12 JOIN testcat.ns.l4 ON l12.id = l4.id) x
+               |JOIN testcat.ns.l3 ON x.id2 = l3.id
+               |""".stripMargin)
+          assert(collectShuffles(df.queryExecution.executedPlan).size == 2,
+            "the outer join should fall back to shuffling both sides")
+          checkAnswer(df, (0L until 24L).map(i => Row(i, i, i)))
+        }
+      }
+    }
+  }
+
+  test("SPARK-60232: nested SPJ shuffles a compatible nested join built on a 
reduced scan") {
+    val idCols = Array(Column.create("id", LongType))
+    Seq("nb4" -> 4, "nb12" -> 12).foreach { case (t, n) =>
+      createTable(t, idCols, Array(bucket(n, "id")))
+      sql(s"INSERT INTO testcat.ns.$t SELECT id FROM range(24)")
+    }
+    createTable("nc6", idCols, Array(bucket(6, "id")))
+    sql("INSERT INTO testcat.ns.nc6 VALUES (0), (1), (2), (3), (6)")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(nb4, nb12, nc6) */ nb4.id, nb12.id, nc6.id
+            |FROM testcat.ns.nb4 JOIN testcat.ns.nb12 ON nb4.id = nb12.id
+            |JOIN testcat.ns.nc6 ON nb12.id = nc6.id
+            |""".stripMargin)
+        assert(collectShuffles(df.queryExecution.executedPlan).size == 1,
+          "the nested join should be shuffled onto nc6's layout")
+        checkAnswer(df, Seq(0L, 1L, 2L, 3L, 6L).map(i => Row(i, i, i)))

Review Comment:
   **Finding 31.** The outer-join query I suggested in [finding 
25](https://github.com/apache/spark/pull/59599#discussion_r4377309486) goes 
through the `KeyGroupedShuffleSpec` case of `candidateSpecs` 
(`EnsureRequirements.scala:149-151`). It passes without that case's 
reduced-scan check, though. `matchedIndexes` shuffles the outer join anyway, 
and `ld` is shuffled onto the same layout. So that query does not pin the 
check. I missed that in finding 25.
   
   The check matters when the third side is partitioned with a compatible 
transform. Without it, the outer join's spec can win as the best spec. `nc6` 
then keeps its `bucket(6)` layout. The outer join is shuffled by `bucket(12)` 
onto the reduced values 0 to 3, so id 6 lands in another partition than 
`nc6`'s. With this test's tables:
   
   ```suggestion
           checkAnswer(df, Seq(0L, 1L, 2L, 3L, 6L).map(i => Row(i, i, i)))
   
           // An outer join reports a single partitioning rather than a 
collection.
           val outerDf = sql(
             """
               |SELECT /*+ MERGE(nb4, nb12, nc6) */ nb12.id, nb4.id, nc6.id
               |FROM testcat.ns.nb12 LEFT JOIN testcat.ns.nb4 ON nb12.id = 
nb4.id
               |JOIN testcat.ns.nc6 ON nb12.id = nc6.id
               |""".stripMargin)
           assert(collectShuffles(outerDf.queryExecution.executedPlan).size == 
1,
             "the outer join below should be shuffled onto nc6's layout")
           checkAnswer(outerDf, Seq(0L, 1L, 2L, 3L, 6L).map(i => Row(i, i, i)))
   ```
   
   - Here it returns the 5 rows with one shuffle, AQE on and off.
   - Without the check at `EnsureRequirements.scala:151`, it returns 4 rows. 
Row 6 is lost.
   - On `branch-4.1`, it also returns 4 rows, with no shuffle.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to