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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -2389,6 +2389,17 @@ case class KeyedShuffleSpec(
       conf.v2BucketingAllowCompatibleTransforms
   }
 
+  /**
+   * `t` retargeted at the identity side's key `col`, or None if its argument 
types do not match
+   * the bound function's declared types, since evaluating it would then fail. 
The compatibility
+   * gate and `reducersBothWays` must agree on this -- a gate admitting a pair 
the reducer refuses
+   * would pair raw keys and drop matches -- so both call it.
+   */
+  private def retargetForIdentity(
+      t: TransformExpression,
+      col: AttributeReference): Option[TransformExpression] =
+    Some(rebuiltOver(t, col)).filter(_.argsMatchInputTypes)

Review Comment:
   **Finding 11.** This gate is what 
[r3470467090](https://github.com/apache/spark/pull/55885#discussion_r3470467090)
 asked for: the identity reduce no longer evaluates a transform whose arguments 
don't have the declared types. The one-side shuffle evaluates the same kind of 
rebuilt transform, but `canCreatePartitioning` (`partitioning.scala:2516`) only 
checks `argumentsAreColumns(t)` on the template. `createPartitioning` then 
rebuilds it over the other child's key, and `ShuffleExchangeExec` evaluates it 
per row.
   
   Measured on this head with `spark.sql.sources.v2.bucketing.shuffle.enabled`. 
The scan reports `truncate(data, 2S)` against the fixtures' `truncate`, which 
declares `(StringType, IntegerType)`. That is the legal implicit cast the test 
at `KeyGroupedPartitioningSuite.scala:7925` describes:
   
   ```
   width=IntegerType  canCreatePartitioning=true  argsMatchInputTypes=true   
eval -> "ab"
   width=ShortType    canCreatePartitioning=true  argsMatchInputTypes=false  
eval -> ClassCastException: java.lang.Short cannot be cast to java.lang.Integer
   ```
   
   On master, `supportsExpressions` refused a second child, so this scan 
reported no partitioning and the join shuffled both sides. An arity-flexible 
transform such as `zero_or_one(col, 2)` takes the same path and fails with an 
index error instead.
   
   The same predicate on the template closes it, at `partitioning.scala:2516`:
   
   ```scala
           case t: TransformExpression => argumentsAreColumns(t) && 
t.argsMatchInputTypes
   ```
   
   The template's own argument types are enough. The key `createPartitioning` 
substitutes is the other side's join key at this position, so it has this 
column's type.
   
   One fixture has to follow. `ShuffleSpecSuite`'s `TestBucketFunction` still 
declares `Array(IntegerType)` but is now built with two children, so it should 
declare `Array(IntegerType, IntegerType)`. Otherwise the `SPARK-59121` and 
`SPARK-59971` `canCreatePartitioning` tests fail.
   
   With both changes, the Short width gives `canCreatePartitioning=false` and 
the Int width stays `true`. All 466 tests stay green, together with the changes 
from findings 13 and 14. The suites are `TransformExpressionSuite`, 
`ShuffleSpecSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, 
`EnsureRequirementsSuite` and `ProjectedOrderingAndPartitioningSuite`.
   
   A sibling of the two `mismatchedIdVsTransformSpecs()` tests pins it. It 
fails on this head and passes with the clause:
   
   ```scala
       val (_, trSpec) = mismatchedIdVsTransformSpecs()
       withSQLConf(SQLConf.V2_BUCKETING_SHUFFLE_ENABLED.key -> "true") {
         assert(!trSpec.canCreatePartitioning,
           "a template whose arguments don't match the declared types must not 
be shuffled onto")
       }
   ```
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a column reference 
on both sides, or the
+   * same expression of a column on both sides (e.g. `b + 1` and `c + 1`). 
Literal values and arity
+   * may differ; the connector reducer decides those.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =
+    children.zip(other.children).forall {
+      case (_: Literal, _: Literal) => true
+      case (c1, c2) if TransformExpression.isColumnRef(c1) && 
TransformExpression.isColumnRef(c2) =>
+        true
+      case (c1, c2) => TransformExpression.sameShape(c1, c2)
+    }
+
+  /**
+   * Whether no literal parameter has an array, map, struct or UDT type; those 
are not passed to a
+   * connector reducer. Other scalars, such as intervals, are.
+   */
+  private def noComplexLiteralParams: Boolean =
+    literalChildren.forall(_.dataType match {
+      case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_] 
=> false
+      case _ => true
+    })
+
+  /**
+   * Return a [[Reducer]] that maps this transform's partition keys onto 
`other`'s, or None if there
+   * is none: when either function is not a [[ReducibleFunction]], when the 
argument layouts or
+   * literal parameters rule a reducer out, or when the connector reports it 
is not reducible.
+   * Handles both parameterized (bucket, truncate) and non-parameterized 
(days, hours) functions.
    */
   def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
-    (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
-        reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
-      case _ => None
+    import TransformExpression._
+    val thisFunction: ReducibleFunction[_, _] = function match {
+      case f: ReducibleFunction[_, _] => f
+      case _ => return None
+    }
+    val otherFunction: ReducibleFunction[_, _] = other.function match {
+      case o: ReducibleFunction[_, _] => o
+      case _ => return None
+    }
+    if (!sameArgumentLayout(other) ||
+        !literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes ||
+        !noComplexLiteralParams || !other.noComplexLiteralParams ||
+        literalChildren.exists(_.value == null) || 
other.literalChildren.exists(_.value == null)) {
+      return None
     }
-  }
 
-  // Return a Reducer for a reducible function on another reducible function
-  private def reducer(
-      thisFunction: ReducibleFunction[_, _],
-      thisNumBucketsOpt: Option[Int],
-      otherFunction: ReducibleFunction[_, _],
-      otherNumBucketsOpt: Option[Int]): Option[Reducer[_, _]] = {
-    val res = (thisNumBucketsOpt, otherNumBucketsOpt) match {
-      case (Some(numBuckets), Some(otherNumBuckets)) =>
-        thisFunction.reducer(numBuckets, otherFunction, otherNumBuckets)
-      case _ => thisFunction.reducer(otherFunction)
+    val thisParams = extractParameters
+    val otherParams = other.extractParameters
+    val thisName = function.canonicalName()
+
+    // A single IntegerType parameter on each side is the shape the deprecated
+    // reducer(int, ..., int) overload accepts. Null parameters were refused 
above.
+    def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+      p.length == 1 && p(0).dataType == IntegerType
+    }
+
+    // Probe one reducer overload into an Outcome. Pure -- logging is decided 
once, below.
+    def probe(call: => Reducer[_, _]): Outcome = Try(Option(call)) match {
+      case Success(Some(r)) => Reducible(r)
+      case Success(None) => NotReducible
+      case Failure(_: UnsupportedOperationException) => Unimplemented
+      case Failure(e) => Threw(e)
+    }
+
+    // Prefer the generalized overload. Use the deprecated one only if the 
generalized one is not
+    // implemented; any other outcome is final.
+    val outcome =
+      if (thisParams.isEmpty && otherParams.isEmpty) {
+        probe(thisFunction.reducer(otherFunction))
+      } else {
+        probe(thisFunction.reducer(thisParams, otherFunction, otherParams)) 
match {
+          // The deprecated overload is documented for bucket against bucket, 
so only offer it a
+          // pair of the same function.
+          case Unimplemented if isSingleInt(thisParams) && 
isSingleInt(otherParams) &&
+              function.canonicalName() == other.function.canonicalName() =>
+            probe(thisFunction.reducer(
+              thisParams(0).value().asInstanceOf[Int], otherFunction,
+              otherParams(0).value().asInstanceOf[Int]))
+          case other => other
+        }
+      }
+
+    outcome match {
+      case Reducible(r) => Some(r)
+      case NotReducible => None
+      case Threw(e) =>

Review Comment:
   **Finding 12.** `isCompatible` and `reducersBothWays` each ask the two 
directions separately, and a `Threw` here only empties its own direction. So 
when one direction throws and the other returns a reducer, the pair is read as 
a one-side reduce. `reducersBothWays` keeps the working side's reducer and 
reports the throwing side's unreduced transform for its keys. The javadoc 
promises the opposite at `ReducibleFunction.java:90`: "any other exception is 
logged and the pair is treated as not reducible (the join falls back to a 
shuffle)".
   
   A one-side reading is right only when the surviving reducer satisfies 
`r(f1(x)) = f2(x)`. Against a two-sided one, such as the gcd of two bucket 
counts or the int truncate's lcm, it pairs keys from two different spaces.
   
   Measured on this head with a copy of `BucketFunction` whose generalized 
reducer guards its direction with `require(thisNumBuckets >= otherNumBuckets)`. 
That is a natural guard for an implementor who expects to be asked only from 
the coarser side. The query joins `bucket(16, id)` and `bucket(12, id)` over 
the same 48 ids, under `allowCompatibleTransforms`:
   
   ```
   BucketFunction:        rows=48 expected=48 shuffles=0
   require-guarded copy:  rows=16 expected=48 shuffles=0
   ```
   
   The 16 side reduces by `% 4` and is reported as `bucket(12, id)`. The 12 
side keeps its raw keys `0..11`, so only `0..3` meet. On master a throwing 
reducer failed the query. Here the only trace is the WARN.
   
   To match the javadoc, a throw has to veto the pair, not just its own 
direction. One entry point can answer for both directions and return `None` 
when either threw. `isCompatible` and the `(TransformExpression, 
TransformExpression)` arm of `reducersBothWays` would call it instead of 
calling `reducers` twice:
   
   ```scala
     /** Both directions' reducers, or None when either direction threw: then 
the pair shuffles. */
     private[sql] def reducersWith(other: TransformExpression)
         : Option[(Option[Reducer[_, _]], Option[Reducer[_, _]])]
   ```
   
   `reducers` keeps the `Outcome` internally, so `Threw` stays distinguishable 
from `NotReducible`. To pin it, use a fixture like the one above and assert 
that `reducersBothWays` returns `(None, None)` and the row count is complete.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -675,11 +675,13 @@ private[sql] object GroupPartitionsExec {
                   // argument takes the place of `reduced`'s. A side shuffled 
onto this layout
                   // reports `bucket(8, b + 1)` next to `bucket(8, id)`, or `b 
+ 1` next to an
                   // identity `id`.
+                  // Only the column argument is replaced; `reduced` keeps its 
own literal
+                  // parameters, e.g. the reduced bucket count.
                   val argument = expr match {
-                    case t: TransformExpression => t.children
-                    case e => Seq(e)
+                    case t: TransformExpression => t.columnSlots.head
+                    case e => e
                   }
-                  reduced.copy(children = argument)
+                  reduced.rewriteColumnSlots(_ => argument)

Review Comment:
   **Finding 14.** On point 2 of [your 
note](https://github.com/apache/spark/pull/55885#issuecomment-6305985451): 
identity and the gate agree on every pair the planner compares, with one 
exception. The exception is the reduced key space this rebuild produces. 
`reduced` carries `reducedWith`, the partner's identity at the time of the 
reduce, which paired two columns. Rebuilding `reduced` over a member's 
expression argument gives its own identity an `Expr` shape, but the partner 
stays `ColumnRef`. The other side does the same in mirror image, so the two 
members of one reduce stop describing the same key space.
   
   Measured on this head. Each side is a `GroupPartitionsExec` over a 
two-member collection, reduced together by `% 4`. The left side holds 
`bucket(8, id)` and `bucket(8, b + 1)`, the right side `bucket(12, y)` and 
`bucket(12, c + 1)`:
   
   ```
   column members      hasSameReducedKeys = true
   expression members  hasSameReducedKeys = false   (true on master)
   ```
   
   The comment above names that shape, a side shuffled onto this layout over `b 
+ 1`, as the reason for the rebuild. A join above that pairs the two expression 
members passes the `argumentShape` gate, since both are `x + 1`. It now 
shuffles where master paired them.
   
   Rebuilding the partner the same way keeps the pair together. The member that 
pairs with this one on the other side has the same argument shape, since 
`argumentShape` admits nothing else:
   
   ```scala
                     reduced.rewriteColumnSlots(_ => argument).copy(reducedWith 
=
                       
reduced.reducedWith.map(TransformExpression.withColumnShape(_, argument)))
   ```
   
   ```scala
     /** `id` with each column argument's shape replaced by `argument`'s. */
     private[sql] def withColumnShape(id: TransformFunctionId, argument: 
Expression)
         : TransformFunctionId = {
       import TransformFunctionId.ArgumentShape
       val shape = argument match {
         case _: Attribute => Some(ArgumentShape.ColumnRef)
         case e => expressionShape(e).map(ArgumentShape.Expr(_))
       }
       shape.fold(id) { s =>
         id.copy(argumentShapes = id.argumentShapes.map {
           case ArgumentShape.ColumnRef | ArgumentShape.Expr(_) => s
           case other => other
         })
       }
     }
   ```
   
   With it the expression members match, the column-vs-expression cross pair 
still does not, and the six suites stay green.
   
   Erasing the column shapes from `reducedKeySpace` looks simpler but does not 
work. It breaks "isSameFunction and hasSameReducedKeys agree on every pair" on 
the struct-field fixture. The probe above, turned into a 
`GroupPartitionsExecSuite` case, pins this.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a column reference 
on both sides, or the
+   * same expression of a column on both sides (e.g. `b + 1` and `c + 1`). 
Literal values and arity
+   * may differ; the connector reducer decides those.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =
+    children.zip(other.children).forall {
+      case (_: Literal, _: Literal) => true
+      case (c1, c2) if TransformExpression.isColumnRef(c1) && 
TransformExpression.isColumnRef(c2) =>
+        true
+      case (c1, c2) => TransformExpression.sameShape(c1, c2)
+    }
+
+  /**
+   * Whether no literal parameter has an array, map, struct or UDT type; those 
are not passed to a
+   * connector reducer. Other scalars, such as intervals, are.
+   */
+  private def noComplexLiteralParams: Boolean =
+    literalChildren.forall(_.dataType match {
+      case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_] 
=> false
+      case _ => true
+    })
+
+  /**
+   * Return a [[Reducer]] that maps this transform's partition keys onto 
`other`'s, or None if there
+   * is none: when either function is not a [[ReducibleFunction]], when the 
argument layouts or
+   * literal parameters rule a reducer out, or when the connector reports it 
is not reducible.
+   * Handles both parameterized (bucket, truncate) and non-parameterized 
(days, hours) functions.
    */
   def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
-    (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
-        reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
-      case _ => None
+    import TransformExpression._
+    val thisFunction: ReducibleFunction[_, _] = function match {
+      case f: ReducibleFunction[_, _] => f
+      case _ => return None
+    }
+    val otherFunction: ReducibleFunction[_, _] = other.function match {
+      case o: ReducibleFunction[_, _] => o
+      case _ => return None
+    }
+    if (!sameArgumentLayout(other) ||
+        !literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes ||
+        !noComplexLiteralParams || !other.noComplexLiteralParams ||
+        literalChildren.exists(_.value == null) || 
other.literalChildren.exists(_.value == null)) {
+      return None
     }
-  }
 
-  // Return a Reducer for a reducible function on another reducible function
-  private def reducer(
-      thisFunction: ReducibleFunction[_, _],
-      thisNumBucketsOpt: Option[Int],
-      otherFunction: ReducibleFunction[_, _],
-      otherNumBucketsOpt: Option[Int]): Option[Reducer[_, _]] = {
-    val res = (thisNumBucketsOpt, otherNumBucketsOpt) match {
-      case (Some(numBuckets), Some(otherNumBuckets)) =>
-        thisFunction.reducer(numBuckets, otherFunction, otherNumBuckets)
-      case _ => thisFunction.reducer(otherFunction)
+    val thisParams = extractParameters
+    val otherParams = other.extractParameters
+    val thisName = function.canonicalName()
+
+    // A single IntegerType parameter on each side is the shape the deprecated
+    // reducer(int, ..., int) overload accepts. Null parameters were refused 
above.
+    def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+      p.length == 1 && p(0).dataType == IntegerType
+    }
+
+    // Probe one reducer overload into an Outcome. Pure -- logging is decided 
once, below.
+    def probe(call: => Reducer[_, _]): Outcome = Try(Option(call)) match {
+      case Success(Some(r)) => Reducible(r)
+      case Success(None) => NotReducible
+      case Failure(_: UnsupportedOperationException) => Unimplemented
+      case Failure(e) => Threw(e)
+    }
+
+    // Prefer the generalized overload. Use the deprecated one only if the 
generalized one is not
+    // implemented; any other outcome is final.
+    val outcome =
+      if (thisParams.isEmpty && otherParams.isEmpty) {
+        probe(thisFunction.reducer(otherFunction))
+      } else {
+        probe(thisFunction.reducer(thisParams, otherFunction, otherParams)) 
match {
+          // The deprecated overload is documented for bucket against bucket, 
so only offer it a
+          // pair of the same function.
+          case Unimplemented if isSingleInt(thisParams) && 
isSingleInt(otherParams) &&
+              function.canonicalName() == other.function.canonicalName() =>
+            probe(thisFunction.reducer(
+              thisParams(0).value().asInstanceOf[Int], otherFunction,
+              otherParams(0).value().asInstanceOf[Int]))
+          case other => other
+        }
+      }
+
+    outcome match {
+      case Reducible(r) => Some(r)
+      case NotReducible => None
+      case Threw(e) =>
+        logWarning(log"V2 function ${MDC(FUNCTION_NAME, thisName)} reducer 
threw an exception; " +
+          log"treating as not reducible.", e)
+        None
+      case Unimplemented =>

Review Comment:
   **Finding 15.** `reducers` runs from `isCompatible` in both directions, and 
again from `reducersBothWays`. `EnsureRequirements` asks that for each 
candidate spec pair, and AQE re-plans each stage. So a probe that ends here, or 
in `Threw` with its stack trace, logs a WARN several times per join per 
planning pass. Before this PR, a pair that could not be reduced logged nothing.
   
   The text can also be wrong. Take a legacy bucket that implements only the 
deprecated overload, paired with a different function of the same `[literal, 
column]` layout. The pair ends here, because the fallback needs the same 
canonical name. The warning then says the function "implements no reducer" and 
asks for an override of the generalized one. But the function does implement 
one, and that pair would not reduce either way.
   
   Logging once per canonical name (a set of names already warned about), or at 
debug, keeps the hint without the volume. "Has no reducer for this pair" says 
what was found.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -675,11 +675,13 @@ private[sql] object GroupPartitionsExec {
                   // argument takes the place of `reduced`'s. A side shuffled 
onto this layout
                   // reports `bucket(8, b + 1)` next to `bucket(8, id)`, or `b 
+ 1` next to an
                   // identity `id`.
+                  // Only the column argument is replaced; `reduced` keeps its 
own literal
+                  // parameters, e.g. the reduced bucket count.
                   val argument = expr match {
-                    case t: TransformExpression => t.children
-                    case e => Seq(e)
+                    case t: TransformExpression => t.columnSlots.head

Review Comment:
   **Finding 17.** `t.columnSlots.head` throws `NoSuchElementException` for a 
transform with no column argument. With two column arguments, 
`rewriteColumnSlots(_ => argument)` (here and in `rebuiltOver`) would put this 
one argument into both slots. `supportsExpressions` admits exactly one column 
slot, so nothing reaches either case today. A match that says so keeps the 
invariant next to the code that relies on it:
   
   ```scala
                     val argument = expr match {
                       case t: TransformExpression => t.columnSlots match {
                         case Seq(slot) => slot
                         case slots => throw SparkException.internalError(
                           s"Expected one column argument in $t, got 
${slots.length}")
                       }
                       case e => e
                     }
   ```
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a column reference 
on both sides, or the
+   * same expression of a column on both sides (e.g. `b + 1` and `c + 1`). 
Literal values and arity
+   * may differ; the connector reducer decides those.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =
+    children.zip(other.children).forall {
+      case (_: Literal, _: Literal) => true
+      case (c1, c2) if TransformExpression.isColumnRef(c1) && 
TransformExpression.isColumnRef(c2) =>
+        true
+      case (c1, c2) => TransformExpression.sameShape(c1, c2)
+    }
+
+  /**
+   * Whether no literal parameter has an array, map, struct or UDT type; those 
are not passed to a
+   * connector reducer. Other scalars, such as intervals, are.
+   */
+  private def noComplexLiteralParams: Boolean =
+    literalChildren.forall(_.dataType match {
+      case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_] 
=> false
+      case _ => true
+    })
+
+  /**
+   * Return a [[Reducer]] that maps this transform's partition keys onto 
`other`'s, or None if there
+   * is none: when either function is not a [[ReducibleFunction]], when the 
argument layouts or
+   * literal parameters rule a reducer out, or when the connector reports it 
is not reducible.
+   * Handles both parameterized (bucket, truncate) and non-parameterized 
(days, hours) functions.
    */
   def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
-    (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
-        reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
-      case _ => None
+    import TransformExpression._
+    val thisFunction: ReducibleFunction[_, _] = function match {
+      case f: ReducibleFunction[_, _] => f
+      case _ => return None
+    }
+    val otherFunction: ReducibleFunction[_, _] = other.function match {
+      case o: ReducibleFunction[_, _] => o
+      case _ => return None
+    }
+    if (!sameArgumentLayout(other) ||
+        !literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes ||
+        !noComplexLiteralParams || !other.noComplexLiteralParams ||
+        literalChildren.exists(_.value == null) || 
other.literalChildren.exists(_.value == null)) {
+      return None
     }
-  }
 
-  // Return a Reducer for a reducible function on another reducible function
-  private def reducer(
-      thisFunction: ReducibleFunction[_, _],
-      thisNumBucketsOpt: Option[Int],
-      otherFunction: ReducibleFunction[_, _],
-      otherNumBucketsOpt: Option[Int]): Option[Reducer[_, _]] = {
-    val res = (thisNumBucketsOpt, otherNumBucketsOpt) match {
-      case (Some(numBuckets), Some(otherNumBuckets)) =>
-        thisFunction.reducer(numBuckets, otherFunction, otherNumBuckets)
-      case _ => thisFunction.reducer(otherFunction)
+    val thisParams = extractParameters
+    val otherParams = other.extractParameters
+    val thisName = function.canonicalName()
+
+    // A single IntegerType parameter on each side is the shape the deprecated
+    // reducer(int, ..., int) overload accepts. Null parameters were refused 
above.
+    def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+      p.length == 1 && p(0).dataType == IntegerType
+    }
+
+    // Probe one reducer overload into an Outcome. Pure -- logging is decided 
once, below.
+    def probe(call: => Reducer[_, _]): Outcome = Try(Option(call)) match {
+      case Success(Some(r)) => Reducible(r)
+      case Success(None) => NotReducible
+      case Failure(_: UnsupportedOperationException) => Unimplemented
+      case Failure(e) => Threw(e)
+    }
+
+    // Prefer the generalized overload. Use the deprecated one only if the 
generalized one is not
+    // implemented; any other outcome is final.
+    val outcome =
+      if (thisParams.isEmpty && otherParams.isEmpty) {

Review Comment:
   **Finding 13.** The javadoc on the generalized overload says "Spark tries 
this generalized overload first" (`ReducibleFunction.java:85`). For a pair with 
no parameters on either side, this branch calls `reducer(ReducibleFunction)` 
instead. That works only because that method's default now delegates back, and 
the delegation is what finding 9's paragraph has to warn about. A connector 
that overrides both methods gets the old method's answer, which is the reverse 
of the documented order.
   
   Probing the generalized overload first for every shape fixes the order. The 
one-argument overload then becomes the fallback on `Unimplemented` when both 
parameter lists are empty, and its default can throw again, as on master:
   
   ```scala
       val outcome =
         probe(thisFunction.reducer(thisParams, otherFunction, otherParams)) 
match {
           case Unimplemented if thisParams.isEmpty && otherParams.isEmpty =>
             probe(thisFunction.reducer(otherFunction))
           // The deprecated overload is documented for bucket against bucket, 
so only offer it a
           // pair of the same function.
           case Unimplemented if isSingleInt(thisParams) && 
isSingleInt(otherParams) &&
               function.canonicalName() == other.function.canonicalName() =>
             probe(thisFunction.reducer(
               thisParams(0).value().asInstanceOf[Int], otherFunction,
               otherParams(0).value().asInstanceOf[Int]))
           case outcome => outcome
         }
   ```
   
   In `ReducibleFunction.java`, `reducer(ReducibleFunction)` goes back to 
`throw new UnsupportedOperationException();`, and the delegation paragraph goes.
   
   Measured with finding 9's shim, a connector that implements only the 
generalized overload and returns `reducer(other)` for empty parameters. On this 
head, `days(a).reducers(hours(a))` dies with `StackOverflowError`. With the 
change it returns `None`, so the pair shuffles. The fixtures' `DaysFunction`, 
which implements only `reducer(ReducibleFunction)`, reaches it through the 
`Unimplemented` arm. The six suites in finding 11 stay green. This also takes 
care of finding 20.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a column reference 
on both sides, or the
+   * same expression of a column on both sides (e.g. `b + 1` and `c + 1`). 
Literal values and arity
+   * may differ; the connector reducer decides those.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =

Review Comment:
   **Finding 19.** This follows up 
[r3441967481](https://github.com/apache/spark/pull/55885#discussion_r3441967481),
 which asked for one rule here and in `isSameFunction`. The two now disagree on 
struct fields. This method counts a `GetStructField` chain as a column 
(`isColumnRef`), but `functionId` gives it an `Expr` shape. So 
`TransformExpression.isCompatible` says true for `bucket(8, s.a)` against 
`bucket(4, x)`, while `isSameFunction` says false for `bucket(4, s.a)` against 
`bucket(4, x)`. Nothing pairs them today, because 
`KeyedShuffleSpec.argumentShape` refuses the pair first.
   
   The comment at `KeyGroupedPartitioningSuite.scala:7513` states this method's 
rule, "A GetStructField chain IS a column reference". The `isSameFunction` 
assertion below it compares `sf` with itself, so it cannot tell the two rules 
apart.
   
   Deriving the layout check from `functionId`'s shapes, with literal values 
ignored, would give one rule. `argumentShape` (`partitioning.scala:2377`) could 
then call `expressionShape`. Today it repeats it without the `resolved` check.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/ReducibleFunction.java:
##########
@@ -60,6 +61,56 @@
 @Evolving
 public interface ReducibleFunction<I, O> {
 
+  /**
+   * Generic reducer for parameterized functions (bucket, truncate, etc.).
+   *
+   * If this function is 'reducible' on another function, return the {@link 
Reducer}.
+   * <p>
+   * Each parameter is a {@link Literal} carrying both its value and data 
type, such as a bucket
+   * count, a truncate width, or a {@code CalendarInterval}. {@link 
Literal#value()} is Spark's
+   * internal representation (e.g. {@code UTF8String} for strings, {@code 
Decimal} for decimals);
+   * use {@link Literal#dataType()} to interpret it rather than assuming a JVM 
type. Spark does not
+   * call this method for a pair where either side has a null literal, a 
literal of array, map,
+   * struct or UDT type, or one of a type other than the function declares; 
the join shuffles

Review Comment:
   **Finding 23.** "or one of a type other than the function declares" holds 
only up to `inputTypes().length`. `inputTypesMatch` skips the children past 
that on purpose (`TransformExpression.scala:238`), so an arity-flexible 
function such as the fixtures' `ZeroOrOneParamFunction` receives those literals 
unchecked. A connector that trusts this sentence and casts `value()` gets a 
`ClassCastException`, and Spark logs it as "reducer threw". Wording such as "or 
a literal whose type differs from the one the function declares at its 
position; literals past the declared arity are passed as they are" would 
describe what happens.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =
+    literalChildren.map(l => LiteralValue(l.value, l.dataType): 
V2Literal[_]).toArray
+
+  /**
+   * Whether the `select`ed children match the bound function's declared input 
type at their
+   * positions. A child beyond the declared arity has no declared type to 
compare against (e.g. an
+   * arity-flexible function), so it is left to the connector reducer / other 
guards. The DataType
+   * match is exact by design: any mismatch (including cosmetic ones like 
Array `containsNull` or
+   * Decimal precision/scale) fails safe to a shuffle. See the two predicates 
below for the callers.
+   *
+   * Reading `dataType` can throw rather than answer, e.g. for a 
`GetStructField` over a non-struct
+   * column. Both callers are gates that must answer "not reducible" rather 
than fail the query, so
+   * a child whose type cannot be read is treated as not matching.
+   */
+  private def inputTypesMatch(select: Expression => Boolean): Boolean = {
+    val declaredTypes = function.inputTypes()
+    children.zipWithIndex.forall {
+      case (c, i) =>
+        !select(c) || i >= declaredTypes.length ||
+          Try(c.dataType).toOption.contains(declaredTypes(i))
     }
   }
 
   /**
-   * Return a [[Reducer]] for this transform expression on another
-   * on the transform expression.
-   * <p>
-   * A [[Reducer]] exists for a transform expression function if it is
-   * 'reducible' on the other expression function.
-   * <p>
-   * @return reducer function or None if not reducible on the other transform 
expression
+   * Whether every literal parameter matches its declared input type. Used by 
the transform-vs-
+   * transform reducer path, which hands literal *values* to the connector 
reducer without Analyzer
+   * type coercion. A literal whose type differs from the declared input type 
(a legal implicit cast
+   * under [[BoundFunction]]) is not reducible: the join falls back to a 
shuffle rather than handing
+   * the connector a value the partitions were not built on, which it would 
then mis-cast. Column
+   * slots are not checked (they are not passed to the connector); the eval 
path uses
+   * [[argsMatchInputTypes]] instead.
+   */
+  lazy val literalParamsMatchInputTypes: Boolean = 
inputTypesMatch(_.isInstanceOf[Literal])
+
+  /**
+   * Whether every argument has its declared type, at exactly the declared 
arity. Required before
+   * evaluating the transform directly: no cast is applied, and a mismatch 
would fail at evaluation.
+   */
+  lazy val argsMatchInputTypes: Boolean =
+    children.length == function.inputTypes().length && inputTypesMatch(_ => 
true)
+
+  /**
+   * Whether each position holds a literal on both sides, a column reference 
on both sides, or the
+   * same expression of a column on both sides (e.g. `b + 1` and `c + 1`). 
Literal values and arity
+   * may differ; the connector reducer decides those.
+   */
+  private def sameArgumentLayout(other: TransformExpression): Boolean =
+    children.zip(other.children).forall {
+      case (_: Literal, _: Literal) => true
+      case (c1, c2) if TransformExpression.isColumnRef(c1) && 
TransformExpression.isColumnRef(c2) =>
+        true
+      case (c1, c2) => TransformExpression.sameShape(c1, c2)
+    }
+
+  /**
+   * Whether no literal parameter has an array, map, struct or UDT type; those 
are not passed to a
+   * connector reducer. Other scalars, such as intervals, are.
+   */
+  private def noComplexLiteralParams: Boolean =
+    literalChildren.forall(_.dataType match {
+      case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_] 
=> false
+      case _ => true
+    })
+
+  /**
+   * Return a [[Reducer]] that maps this transform's partition keys onto 
`other`'s, or None if there
+   * is none: when either function is not a [[ReducibleFunction]], when the 
argument layouts or
+   * literal parameters rule a reducer out, or when the connector reports it 
is not reducible.
+   * Handles both parameterized (bucket, truncate) and non-parameterized 
(days, hours) functions.
    */
   def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
-    (function, other.function) match {
-      case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
-        reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
-      case _ => None
+    import TransformExpression._
+    val thisFunction: ReducibleFunction[_, _] = function match {
+      case f: ReducibleFunction[_, _] => f
+      case _ => return None
+    }
+    val otherFunction: ReducibleFunction[_, _] = other.function match {
+      case o: ReducibleFunction[_, _] => o
+      case _ => return None
+    }
+    if (!sameArgumentLayout(other) ||
+        !literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes ||
+        !noComplexLiteralParams || !other.noComplexLiteralParams ||
+        literalChildren.exists(_.value == null) || 
other.literalChildren.exists(_.value == null)) {
+      return None
     }
-  }
 
-  // Return a Reducer for a reducible function on another reducible function
-  private def reducer(
-      thisFunction: ReducibleFunction[_, _],
-      thisNumBucketsOpt: Option[Int],
-      otherFunction: ReducibleFunction[_, _],
-      otherNumBucketsOpt: Option[Int]): Option[Reducer[_, _]] = {
-    val res = (thisNumBucketsOpt, otherNumBucketsOpt) match {
-      case (Some(numBuckets), Some(otherNumBuckets)) =>
-        thisFunction.reducer(numBuckets, otherFunction, otherNumBuckets)
-      case _ => thisFunction.reducer(otherFunction)
+    val thisParams = extractParameters
+    val otherParams = other.extractParameters
+    val thisName = function.canonicalName()
+
+    // A single IntegerType parameter on each side is the shape the deprecated
+    // reducer(int, ..., int) overload accepts. Null parameters were refused 
above.
+    def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+      p.length == 1 && p(0).dataType == IntegerType
+    }
+
+    // Probe one reducer overload into an Outcome. Pure -- logging is decided 
once, below.
+    def probe(call: => Reducer[_, _]): Outcome = Try(Option(call)) match {
+      case Success(Some(r)) => Reducible(r)
+      case Success(None) => NotReducible
+      case Failure(_: UnsupportedOperationException) => Unimplemented
+      case Failure(e) => Threw(e)
+    }
+
+    // Prefer the generalized overload. Use the deprecated one only if the 
generalized one is not
+    // implemented; any other outcome is final.
+    val outcome =
+      if (thisParams.isEmpty && otherParams.isEmpty) {
+        probe(thisFunction.reducer(otherFunction))
+      } else {
+        probe(thisFunction.reducer(thisParams, otherFunction, otherParams)) 
match {
+          // The deprecated overload is documented for bucket against bucket, 
so only offer it a
+          // pair of the same function.
+          case Unimplemented if isSingleInt(thisParams) && 
isSingleInt(otherParams) &&
+              function.canonicalName() == other.function.canonicalName() =>
+            probe(thisFunction.reducer(
+              thisParams(0).value().asInstanceOf[Int], otherFunction,
+              otherParams(0).value().asInstanceOf[Int]))
+          case other => other

Review Comment:
   **Finding 20.** `case other => other` rebinds `other`, so this arm and the 
guard two lines up (`other.function.canonicalName()`) mean different things by 
the same name.
   
   ```suggestion
             case outcome => outcome
   ```
   
   The guard can also use `thisName` instead of calling `canonicalName()` again.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes

Review Comment:
   **Finding 22.** A few comments name code that isn't there:
   
   - `keyPositions` has no single-reference assert, so this line's claim is 
wrong. A substituted parameter gives the transform two references, so it maps 
to no position (`partitioning.scala:2174`). That is the silent loss this 
sentence ends with. `supportsExpressions` runs on the scan only, so it never 
sees a projected `truncate(d, w)`.
   - L162 and `TransformExpressionSuite.scala:137` say `sameArgumentLayout` 
runs "only inside `reducer`". The method is `reducers`.
   - `KeyGroupedPartitioningSuite.scala:7734` names `attempt()`, and L7821, 
L7836 (a test name), L7839, L7842 and L7857 name `identityReducer`. Those are 
now `probe` and `retargetForIdentity`.
   - `ShuffleSpecSuite.scala:864` still says `isSameFunction` "compares names 
and bucket counts".
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -7268,6 +7273,710 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  test("SPARK-50593: cross-function truncate vs bucket should NOT trigger 
SPJ") {
+    val partitions1 = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(3))
+    )
+    val partitions2 = Array(
+      Expressions.bucket(4, "data")
+    )
+
+    createTable("trunc_cross1", columns, partitions1)
+    sql("INSERT INTO testcat.ns.trunc_cross1 VALUES " +
+      "(0, 'aaa', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'bbb', CAST('2021-01-01' AS timestamp))")
+
+    createTable("trunc_cross2", columns2, partitions2)
+    sql("INSERT INTO testcat.ns.trunc_cross2 VALUES " +
+      "(1, 5, 'aaa'), " +
+      "(5, 10, 'bbb')")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint("trunc_cross1", "trunc_cross2")}
+           |trunc_cross1.id, trunc_cross2.store_id
+           |FROM testcat.ns.trunc_cross1 JOIN testcat.ns.trunc_cross2
+           |ON trunc_cross1.data = trunc_cross2.data
+           |ORDER BY trunc_cross1.id
+           |""".stripMargin)
+
+      // Different functions (truncate vs bucket) are not mutually reducible, 
so a shuffle
+      // must still be planned.
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.nonEmpty,
+        "truncate vs bucket are not compatible - a shuffle should be present, 
" +
+          "but none was planned")
+      checkAnswer(df, Seq(Row(0, 1), Row(1, 5)))
+    }
+  }
+
+  test("SPARK-50593: truncate(3) vs truncate(5) triggers SPJ via width 
reducer") {
+    // Exercises the Literal[]-based reducer path end-to-end: truncate widths 
3 and 5
+    // are mutually reducible (reduce the larger to the smaller), so SPJ must 
avoid the shuffle.
+    val table1 = "trunc_three"
+    val table2 = "trunc_five"
+
+    val partitions1 = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(3)))
+    val partitions2 = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(5)))
+
+    createTable(table1, columns, partitions1)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    createTable(table2, columns, partitions2)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(10, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(20, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(30, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint(table1, table2)}
+           |$table1.id AS left_id, $table2.id AS right_id
+           |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+           |ON $table1.data = $table2.data
+           |ORDER BY $table1.id
+           |""".stripMargin)
+
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.isEmpty,
+        "truncate(3) vs truncate(5) should avoid shuffle via the width 
reducer, " +
+          "but a shuffle was planned")
+      checkAnswer(df, Seq(Row(0, 10), Row(1, 20), Row(2, 30)))
+    }
+  }
+
+  test("SPARK-50593: identical truncate widths trigger SPJ under the default 
config") {
+    val table1 = "trunc_same_a"
+    val table2 = "trunc_same_b"
+    val partitions = Array(
+      Expressions.apply("truncate", Expressions.column("data"), 
Expressions.literal(3)))
+
+    createTable(table1, columns, partitions)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    createTable(table2, columns, partitions)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(10, 'apple', CAST('2022-01-01' AS timestamp)), " +
+      "(20, 'grape', CAST('2021-01-01' AS timestamp)), " +
+      "(30, 'orange', CAST('2020-01-01' AS timestamp))")
+
+    // No withSQLConf: identical widths are the same transform, so this has to 
work on the
+    // default config, where allowCompatibleTransforms is off.
+    val df = sql(
+      s"""
+         |${selectWithMergeJoinHint(table1, table2)}
+         |$table1.id AS left_id, $table2.id AS right_id
+         |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+         |ON $table1.data = $table2.data
+         |ORDER BY $table1.id
+         |""".stripMargin)
+
+    assert(collectShuffles(df.queryExecution.executedPlan).isEmpty,
+      "identical truncate widths are the same function, so no shuffle is 
needed")
+    checkAnswer(df, Seq(Row(0, 10), Row(1, 20), Row(2, 30)))
+  }
+
+  test("SPARK-50593: existing bucket SPJ still works with Literal[] API") {
+    // Exercises the new Literal[]-based reducer path end-to-end: bucket(4) and
+    // bucket(2) differ, so SPJ can only avoid the shuffle if BucketFunction's 
reducer
+    // (now implemented via Literal[] params) correctly returns a GCD-based 
Reducer.
+    // BucketFunction overrides only the new API, which the dispatch tries 
first, so no fallback to
+    // the deprecated overload is involved.
+    val table1 = "bucket_compat1"
+    val table2 = "bucket_compat2"
+
+    val partitions1 = Array(Expressions.bucket(4, "id"))
+    val partitions2 = Array(Expressions.bucket(2, "store_id"))
+
+    createTable(table1, columns, partitions1)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'aaa', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'bbb', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'ccc', CAST('2020-01-01' AS timestamp)), " +
+      "(3, 'ddd', CAST('2019-01-01' AS timestamp))")
+
+    createTable(table2, columns2, partitions2)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(0, 5, 'aaa'), " +
+      "(1, 10, 'bbb'), " +
+      "(2, 15, 'ccc'), " +
+      "(3, 20, 'ddd')")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint(table1, table2)}
+           |$table1.id, $table2.store_id
+           |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+           |ON $table1.id = $table2.store_id
+           |ORDER BY $table1.id
+           |""".stripMargin)
+
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.isEmpty,
+        "bucket(4) vs bucket(2) should avoid shuffle via the GCD reducer, " +
+          "but a shuffle was planned")
+      checkAnswer(df, Seq(Row(0, 0), Row(1, 1), Row(2, 2), Row(3, 3)))
+    }
+  }
+
+  test("SPARK-50593: bucket(4) vs bucket(3) - no common divisor, must 
shuffle") {
+    // GCD(4, 3) = 1 -- BucketFunction.reducer returns null. Spark must NOT 
enable SPJ.
+    // Regression guard: a buggy null-handling in TransformExpression.reducer 
(e.g.,
+    // Try(...).toOption instead of Try(Option(...))) would treat null as 
Some(null),
+    // enable SPJ, and produce wrong join results for incompatible bucket 
layouts.
+    val table1 = "bucket_gcd1_a"
+    val table2 = "bucket_gcd1_b"
+
+    val partitions1 = Array(Expressions.bucket(4, "id"))
+    val partitions2 = Array(Expressions.bucket(3, "store_id"))
+
+    createTable(table1, columns, partitions1)
+    sql(s"INSERT INTO testcat.ns.$table1 VALUES " +
+      "(0, 'aaa', CAST('2022-01-01' AS timestamp)), " +
+      "(1, 'bbb', CAST('2021-01-01' AS timestamp)), " +
+      "(2, 'ccc', CAST('2020-01-01' AS timestamp))")
+
+    createTable(table2, columns2, partitions2)
+    sql(s"INSERT INTO testcat.ns.$table2 VALUES " +
+      "(0, 5, 'aaa'), " +
+      "(1, 10, 'bbb'), " +
+      "(2, 15, 'ccc')")
+
+    withSQLConf(
+      SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED.key -> "true",
+      SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true") {
+      val df = sql(
+        s"""
+           |${selectWithMergeJoinHint(table1, table2)}
+           |$table1.id, $table2.store_id
+           |FROM testcat.ns.$table1 JOIN testcat.ns.$table2
+           |ON $table1.id = $table2.store_id
+           |ORDER BY $table1.id
+           |""".stripMargin)
+
+      val shuffles = collectShuffles(df.queryExecution.executedPlan)
+      assert(shuffles.nonEmpty,
+        "bucket(4) vs bucket(3) have no common divisor (GCD=1), so the reducer 
" +
+          "returns null. SPJ must NOT be enabled; a shuffle is required.")
+      checkAnswer(df, Seq(Row(0, 0), Row(1, 1), Row(2, 2)))
+    }
+  }
+
+  test("SPARK-50593: isSameFunction is a structural per-position walk, not a 
literal bag") {
+    import org.apache.spark.sql.catalyst.expressions.{Add, Expression, 
GetStructField}
+    val a = attr("a")
+    val b = attr("b")
+    def bucket(n: Int, e: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), e))
+    def years(e: Expression): TransformExpression = 
TransformExpression(YearsFunction, Seq(e))
+    def days(e: Expression): TransformExpression = 
TransformExpression(DaysFunction, Seq(e))
+
+    // Column identity is ignored -- it is reconciled separately, via 
keyPositions.
+    assert(bucket(4, a).isSameFunction(bucket(4, b)))
+    assert(!bucket(4, a).isSameFunction(bucket(2, b)))
+
+    // Nested transforms are compared recursively, although both literal lists 
here are [4].
+    // supportsExpressions rejects nested shapes for SPJ, but isCompatible and 
ValidateRequirements
+    // consult identity too.
+    assert(!bucket(4, years(a)).isSameFunction(bucket(4, days(a))),
+      "a different nested transform is a different function")
+    assert(bucket(4, years(a)).isSameFunction(bucket(4, years(b))),
+      "the same nested transform over a different column is still the same 
function")
+    assert(!bucket(4, years(a)).isSameFunction(bucket(2, years(a))), "the 
literal still matters")
+
+    // An expression argument is compared by its shape, the same rule the 
join's pairing uses.
+    val ra = AttributeReference("a", IntegerType)()
+    val rb = AttributeReference("b", IntegerType)()
+    val add = bucket(4, Add(ra, Literal(1)))
+    assert(add.isSameFunction(bucket(4, Add(rb, Literal(1)))), "the same shape 
over another column")
+    assert(!add.isSameFunction(bucket(4, ra)), "an expression is not a bare 
column")
+
+    // A GetStructField chain IS a column reference, so it is ignored like any 
other column slot.
+    val s = AttributeReference("s", StructType(Seq(StructField("f", 
IntegerType))))()
+    val sf = GetStructField(s, 0)
+    assert(bucket(4, sf).isSameFunction(bucket(4, sf)))
+  }
+
+  test("SPARK-50593: generalized identity is strictly more discriminating than 
" +
+      "bucket-only identity") {
+    // Identity must separate truncate widths, and still separate everything a 
bucket count alone
+    // separated.
+    val a = attr("a")
+    import org.apache.spark.sql.catalyst.expressions.Expression
+    def truncate(e: Expression, width: Int): TransformExpression =
+      TransformExpression(TruncateFunction, Seq(e, Literal(width)))
+    def bucket(n: Int, e: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), e))
+
+    // Truncate widths are distinguished.
+    assert(!truncate(a, 3).isSameFunction(truncate(a, 5)))
+    assert(truncate(a, 3).isSameFunction(truncate(a, 3)))
+
+    // Bucket counts and function names are still distinguished, and the 
column is still ignored.
+    assert(bucket(4, a).isSameFunction(bucket(4, attr("b"))), "bucket count 
equal, column ignored")
+    assert(!bucket(4, a).isSameFunction(bucket(8, a)), "bucket count differs")
+    assert(!TransformExpression(DaysFunction, Seq(a)).isSameFunction(
+      TransformExpression(YearsFunction, Seq(a))), "different function name")
+  }
+
+  test("SPARK-50593: supportsExpressions admits flat parameterized transforms, 
" +
+      "rejects nested and non-reference slots") {
+    import org.apache.spark.sql.catalyst.expressions.{Add, Expression}
+    val a = AttributeReference("a", IntegerType)()
+    val b = AttributeReference("b", IntegerType)()
+    def bucket(n: Int, e: Expression): TransformExpression =
+      TransformExpression(BucketFunction, Seq(Literal(n), e))
+
+    // Flat parameterized transform over a bare column -> admitted (one 
non-literal child = column).
+    assert(physical.KeyedPartitioning.supportsExpressions(Seq(bucket(4, a))))
+    // Bare identity column -> admitted.
+    assert(physical.KeyedPartitioning.supportsExpressions(Seq(a)))
+
+    // Nested transform -> rejected: the non-literal child is a transform, not 
a column reference.
+    // SPJ reasons about a transform via its function and literal params 
alone, which is unsound
+    // when the remaining argument is itself a transform.
+    val nested = bucket(4, TransformExpression(YearsFunction, Seq(a)))
+    assert(!physical.KeyedPartitioning.supportsExpressions(Seq(nested)))
+
+    // Value-changing slot (a + 1) -> rejected: not a plain column reference.
+    assert(!physical.KeyedPartitioning.supportsExpressions(Seq(bucket(4, 
Add(a, Literal(1))))))
+
+    // Two non-literal column references -> rejected: a partition expression 
must map to exactly one
+    // clustering column (the positional keyPositions model needs a single 
column per transform).
+    assert(!physical.KeyedPartitioning.supportsExpressions(
+      Seq(TransformExpression(BucketFunction, Seq(Literal(4), a, b)))))
+  }
+
+  test("SPARK-50593: integer truncate is reducible via lcm (generalized 
reducer, non-bucket)") {
+    // A second reducible transform exercising the generalized Literal[] 
reducer API with reducer
+    // math distinct from bucket (GCD) and string truncate (prefix-min): 
integer truncate snaps to
+    // a coarser grid, so truncate(v, W1) and truncate(v, W2) reduce onto 
multiples of lcm(W1, W2).
+    import org.apache.spark.sql.catalyst.expressions.Expression
+    val id = attr("id")
+    def itrunc(e: Expression, w: Int): TransformExpression =
+      TransformExpression(IntegerTruncateFunction, Seq(e, Literal(w)))
+
+    // Same width -> same function (no reduction needed).
+    assert(itrunc(id, 4).isSameFunction(itrunc(id, 4)))
+
+    // W2 is a multiple of W1: the finer side (W1=2) reduces onto the coarser 
grid (W2=4).
+    assert(itrunc(id, 2).isCompatible(itrunc(id, 4)))
+    val r = itrunc(id, 2).reducers(itrunc(id, 4))
+    assert(r.isDefined, "truncate(2) must reduce onto truncate(4)")
+    val red = r.get.asInstanceOf[Reducer[Integer, Integer]]
+    // truncate(.,2) values snapped to multiples of 4: 6 -> 4, 2 -> 0, 8 -> 8
+    assert(red.reduce(6) == 4 && red.reduce(2) == 0 && red.reduce(8) == 8)
+    // The coarser side (4) is already the common grid -> no reducer.
+    assert(itrunc(id, 4).reducers(itrunc(id, 2)).isEmpty)
+
+    // Neither divides the other: both sides reduce to the lcm grid.
+    assert(itrunc(id, 6).isCompatible(itrunc(id, 4)))          // lcm(6, 4) = 
12
+    assert(itrunc(id, 6).reducers(itrunc(id, 4)).isDefined)
+    assert(itrunc(id, 4).reducers(itrunc(id, 6)).isDefined)
+    assert(itrunc(id, 3).isCompatible(itrunc(id, 5)))          // coprime -> 
lcm(3, 5) = 15
+  }
+
+  test("SPARK-50593: deprecated int reducer API still works (legacy connector 
backward compat)") {
+    // The dispatch falls back to the deprecated reducer(int, func, int) when 
the generalized
+    // overload is not implemented, so a ReducibleFunction that overrides ONLY 
the deprecated
+    // method still reduces.
+    // This mirrors how Iceberg 1.10.0 (and earlier) ship -- they predate the 
Literal[] API.
+    val bucketExpr4 = TransformExpression(LegacyBucketFunction, 
Seq(Literal(4), attr("id")))
+    val bucketExpr2 = TransformExpression(LegacyBucketFunction, 
Seq(Literal(2), attr("id")))
+
+    val reducer = bucketExpr4.reducers(bucketExpr2)
+    assert(reducer.isDefined, "Expected a reducer for legacy_bucket(4) on 
legacy_bucket(2)")
+
+    // Verify the returned Reducer actually reduces bucket 4 -> bucket 2 (GCD 
= 2).
+    // bucket(4, x) produces values in [0, 4); reducing by GCD=2 gives v % 2.
+    val r = reducer.get.asInstanceOf[Reducer[Integer, Integer]]
+    assert(r.reduce(3) == 1, s"Expected reduce(3) == 1, got ${r.reduce(3)}")
+    assert(r.reduce(2) == 0, s"Expected reduce(2) == 0, got ${r.reduce(2)}")
+  }
+
+  test("SPARK-50593: the deprecated int reducer is only offered a pair of the 
same function") {
+    // The deprecated overload is documented for bucket against bucket, so a 
connector may not
+    // check the other function. CarelessLegacyBucketFunction does not, and 
would return a gcd
+    // reducer for any pair of ints; offered a different function with the 
same argument layout, it
+    // would pair partitions that do not hold the same rows.
+    val careless4 = TransformExpression(CarelessLegacyBucketFunction, 
Seq(Literal(4), attr("id")))
+    val careless2 = TransformExpression(CarelessLegacyBucketFunction, 
Seq(Literal(2), attr("id")))
+    val other2 = TransformExpression(LiteralFirstIntFunction, Seq(Literal(2), 
attr("id")))
+
+    assert(careless4.reducers(other2).isEmpty && 
other2.reducers(careless4).isEmpty,
+      "a different function must not be offered the deprecated overload")
+    assert(!careless4.isCompatible(other2))
+    assert(careless4.reducers(careless2).isDefined, "the same function still 
reduces")
+
+    // Same function means the same canonicalName; name() is only a display 
name.
+    val lookalike2 = TransformExpression(CarelessLookalikeFunction, 
Seq(Literal(2), attr("id")))
+    assert(careless4.reducers(lookalike2).isEmpty,
+      "a different function sharing only name() must not be offered the 
deprecated overload")
+  }
+
+  test("SPARK-50593: truncate over a string and over an int are different 
transforms") {
+    // The two partition data differently, so they must not share a canonical 
name.
+    val strTrunc = TransformExpression(TruncateFunction, Seq(attr("data"), 
Literal(3)))
+    val intTrunc = TransformExpression(IntegerTruncateFunction, 
Seq(attr("id"), Literal(3)))
+    assert(!strTrunc.isSameFunction(intTrunc) && 
!intTrunc.isSameFunction(strTrunc))
+  }
+
+  test("SPARK-50593: a non-IntegerType param (DateType) does not reach the 
deprecated " +
+      "int reducer") {
+    // DateType is stored as a boxed Integer (epoch days) internally, so the 
reducer dispatch must
+    // key off the DataType, not the runtime class -- otherwise a DateType 
param is mistaken for the
+    // bucket-style int param and routed to the deprecated reducer(int, ...). 
LegacyBucketFunction
+    // overrides ONLY that deprecated method, so with a DateType param it must 
be unreachable,
+    // leaving the pair not reducible (rather than producing a bogus GCD 
reducer over epoch-days).
+    val l = TransformExpression(LegacyBucketFunction, Seq(Literal(8, 
DateType), attr("id")))

Review Comment:
   **Finding 21.** `LegacyBucketFunction` declares `IntegerType` at position 0, 
so `literalParamsMatchInputTypes` refuses this `DateType` literal before 
`isSingleInt` is reached. The test therefore still passes with `&& 
p(0).dataType == IntegerType` deleted, which I checked by mutation.
   
   A legacy-only fixture that declares `DateType` at that position would make 
the clause load-bearing. The gate then admits the literal, and only the type 
clause keeps it out of the deprecated overload.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,49 +179,180 @@ case class TransformExpression(
    * @param other the transform expression to compare to
    * @return true if compatible, false if not
    */
-  def isCompatible(other: TransformExpression): Boolean = {
-    if (isSameFunction(other)) {
-      true
-    } else {
-      (function, other.function) match {
-        case (f: ReducibleFunction[_, _], o: ReducibleFunction[_, _]) =>
-          val thisReducer = reducer(f, numBucketsOpt, o, other.numBucketsOpt)
-          val otherReducer = reducer(o, other.numBucketsOpt, f, numBucketsOpt)
-          thisReducer.isDefined || otherReducer.isDefined
-        case _ => false
-      }
+  def isCompatible(other: TransformExpression): Boolean =
+    isSameFunction(other) || reducers(other).isDefined || 
other.reducers(this).isDefined
+
+  /**
+   * Rewrites this transform's column arguments with `rewriteColumn`, leaving 
its literal parameters
+   * untouched.
+   *
+   * Since literal parameters (the bucket count, the truncate width) live in 
`children` rather than
+   * in a field of their own, any path that rewrites a transform's children 
has to skip them. A
+   * parameter is not an aliasable value: substituting it changes what the 
transform computes, drops
+   * it out of `functionId`, adds a second entry to `references` (tripping
+   * `KeyedShuffleSpec.keyPositions`' single-reference assert), and makes
+   * `KeyedPartitioning.supportsExpressions` reject the partitioning outright 
-- so SPJ is silently
+   * lost. Use it for any retargeting of a partition transform.
+   *
+   * Callers: `KeyedShuffleSpec.rebuiltOver`, which rebuilds a transform over 
the other side's key,
+   * and `GroupPartitionsExec`, which rebuilds a reduced expression over each 
member's argument.
+   */
+  def rewriteColumnSlots(rewriteColumn: Expression => Expression): 
TransformExpression =
+    copy(children = children.map {
+      case l: Literal => l
+      case c => rewriteColumn(c)
+    })
+
+  /** The non-literal arguments. */
+  def columnSlots: Seq[Expression] = 
children.filterNot(_.isInstanceOf[Literal])
+
+  /**
+   * Extract all literal parameters of this transform as V2 [[V2Literal]]s, 
preserving each value's
+   * internal representation and its `DataType`. Only consulted once a reducer 
path has confirmed
+   * the literal params already match the declared input types (see
+   * [[literalParamsMatchInputTypes]]), so no type coercion happens here. 
Memoized.
+   *
+   * Examples:
+   *   bucket(4, col)        => [Literal(4, IntegerType)]
+   *   truncate(col, 3)      => [Literal(3, IntegerType)]
+   *   days(col)             => []  (no literals)
+   */
+  private lazy val extractParameters: Array[V2Literal[_]] =

Review Comment:
   **Finding 18.** This array is memoized and handed to the connector by 
reference, in both directions and on every probe. A connector that writes into 
it changes what every later call on this expression sees, for the rest of 
planning, and the new javadoc can only ask it not to. Passing 
`extractParameters.clone()` at the two call sites costs one shallow copy of a 
one-element array per probe, and makes the contract unnecessary.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1223,20 +1222,16 @@ object KeyedPartitioning {
   }
 
   def supportsExpressions(expressions: Seq[Expression]): Boolean = {
-    def isSupportedTransform(transform: TransformExpression): Boolean = {
-      transform.children.size == 1 && isReference(transform.children.head)
-    }
-
-    @tailrec
-    def isReference(e: Expression): Boolean = e match {
-      case _: Attribute => true
-      case g: GetStructField => isReference(g.child)
-      case _ => false
-    }
+    // Exactly one column argument, and it must be a plain column reference.
+    def isSupportedTransform(transform: TransformExpression): Boolean =
+      transform.columnSlots match {

Review Comment:
   **Finding 16.** This is where the change reaches past joins. Any transform 
with literal parameters and one column now passes. So a scan partitioned by 
`truncate(data, 2)`, or by any connector function with a literal argument, now 
reports `KeyedPartitioning` where it reported none. Aggregates and windows over 
such a scan then skip their shuffle and run on the scan's partitions. Measured 
over a `truncate(data, 2)` table:
   
   ```
                                                      master                    
  this head
   SELECT data, count(*) ... GROUP BY data            1 shuffle, 5 partitions   
  0 shuffles, 4 (one per key)
   row_number() OVER (PARTITION BY data ORDER BY id)  1 shuffle                 
  0 shuffles
   ```
   
   The user-facing section mentions joins only. A line there would cover it. So 
would a migration-guide entry, like the other 4.4 SPJ ones, if you count the 
change in partition count as user-visible.
   
   One smaller point for the same section. "No action required for existing 
connectors" holds only when a bound function's `inputTypes()` lists every 
argument at its exact type. Both the reduce and the identity retarget now 
refuse anything else, as decided in 
[r3470467090](https://github.com/apache/spark/pull/55885#discussion_r3470467090).
 So a legacy bucket whose `inputTypes()` leaves out the count now loses its 
reduce silently, which is why `ShuffleSpecSuite`'s `FakeBucket` had to change. 
One sentence would cover it.
   
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_



-- 
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