sunchao commented on code in PR #55885:
URL: https://github.com/apache/spark/pull/55885#discussion_r3503138262
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -92,24 +126,145 @@ case class TransformExpression(
*/
def reducers(other: TransformExpression): Option[Reducer[_, _]] = {
(function, other.function) match {
- case(e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
- reducer(e1, numBucketsOpt, e2, other.numBucketsOpt)
+ case (e1: ReducibleFunction[_, _], e2: ReducibleFunction[_, _]) =>
+ reducer(e1, this, e2, other)
case _ => None
}
}
- // Return a Reducer for a reducible function on another reducible function
+ /**
+ * 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 every literal parameter already matches the bound function's
declared input type at its
+ * position. The SPJ reducer paths hand literal values to the connector
reducer, or evaluate the
+ * transform directly, without Analyzer type coercion. A literal whose type
differs from the
+ * declared input type (a legal implicit cast under [[BoundFunction]]) is
therefore treated as not
+ * reducible: the join falls back to a shuffle rather than coercing a value
the partitions were
+ * not built on, or crashing when the connector casts it to the declared
type.
+ */
+ lazy val literalParamsMatchInputTypes: Boolean = {
+ val declaredTypes = function.inputTypes()
+ children.zipWithIndex.forall {
+ // Reject only a genuine type mismatch at a declared position. A literal
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 / the other guards rather than rejected
here.
+ case (l: Literal, i) => i >= declaredTypes.length || l.dataType ==
declaredTypes(i)
+ case _ => true
+ }
+ }
+
+ /**
+ * Reducer precondition: positionally-aligned argument structure with
`other` -- at each zipped
+ * position a literal aligns with a literal, nested transforms are
recursively the same function,
+ * and any other slot is a column reference on both sides. Only literal
*values* may differ. Arity
+ * is NOT required to match: children are zipped (a shorter side truncates),
so a zero-vs-one
+ * parameter pair is admitted and left to the connector reducer. Unlike
[[isSameFunction]] the
+ * function name is not compared.
+ */
+ private def sameArgumentLayout(other: TransformExpression): Boolean =
+ childrenMatch(other)((_, _) => true)
+
+ /**
+ * Whether no literal parameter has a complex type. A literal is rejected if
its [[DataType]] is
+ * [[ArrayType]] / [[MapType]] / [[StructType]] / [[UserDefinedType]]. Such
params (whose value is
+ * a Catalyst-internal container, or -- for a UDT -- whatever its `sqlType`
serializes to) must
+ * not cross the public reducer boundary, so the transform is treated as not
reducible. Keying off
+ * the type (not the value) also rejects a null-valued complex literal, and
rejecting all UDTs is
+ * a safe over-approximation (a UDT transform parameter is exotic; the cost
is a shuffle). Scalar
+ * types such as `CalendarIntervalType` are admitted (the connector
interprets them via the type).
+ */
+ private def noComplexLiteralParams: Boolean =
+ literalChildren.forall(_.dataType match {
+ case _: ArrayType | _: MapType | _: StructType | _: UserDefinedType[_]
=> false
+ case _ => true
+ })
+
+ /**
+ * Return a Reducer for a reducible function on another reducible function
+ * Handles both parameterized (bucket, truncate) and non-parameterized
(days, hours) functions.
+ */
private def reducer(
thisFunction: ReducibleFunction[_, _],
- thisNumBucketsOpt: Option[Int],
+ thisExpr: TransformExpression,
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)
+ otherExpr: TransformExpression): Option[Reducer[_, _]] = {
+ if (!thisExpr.sameArgumentLayout(otherExpr) ||
+ !thisExpr.literalParamsMatchInputTypes ||
!otherExpr.literalParamsMatchInputTypes ||
+ !thisExpr.noComplexLiteralParams || !otherExpr.noComplexLiteralParams)
{
+ return None
}
- Option(res)
+
+ val thisParams = thisExpr.extractParameters
+ val otherParams = otherExpr.extractParameters
+ val thisName = thisExpr.function.canonicalName()
+
+ // Gate on DataType, not the boxed runtime class
(DateType/YearMonthInterval box to Int).
+ def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+ p.length == 1 && p(0).dataType == IntegerType
Review Comment:
[P2] Please exclude typed-null integer literals from the deprecated
primitive-int probe. The public `Literal` contract does not prohibit `(value =
null, dataType = IntegerType)`, and `isSingleInt` accepts it; Scala's
`null.asInstanceOf[Int]` then becomes `0`. A legacy reducer can accept that
fabricated zero and return a reducer, so the generalized overload never
receives the actual typed null. If the transform distinguishes null from zero,
Spark can falsely declare the pair compatible and skip a required shuffle.
Require both literal values to be non-null before trying the deprecated
overload; otherwise invoke the generalized overload directly. Please add a
typed-null-vs-int regression where the legacy overload succeeds but the
generalized overload rejects the actual null.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/ReducibleFunction.java:
##########
@@ -60,6 +61,48 @@
@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 non-complex {@link Literal} carrying both its value
and data type:
+ * array/map/struct/UDT-typed values are filtered out by Spark and not
passed here, but other
+ * scalar values (e.g. bucket numBuckets, truncate width, or a
+ * {@code CalendarInterval}) may be. {@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.
+ * <p>
+ * {@code thisParams} and {@code otherParams} hold each side's own literal
parameters and may have
+ * different lengths -- for example a zero-parameter transform reducing onto
a one-parameter one.
+ * Implementations must check each array's length before indexing into it.
+ * <p>
+ * Returning {@code null} means "not reducible for these parameters".
Throwing
+ * {@link UnsupportedOperationException} signals that this overload is not
implemented (Spark then
Review Comment:
[P2] Please correct this public fallback contract. For a single integer
parameter on each side, Spark probes the deprecated `reducer(int, ..., int)`
overload first and invokes this generalized overload only if that probe did not
return a reducer; an `UnsupportedOperationException` from this method never
triggers a later fallback to the deprecated method. For other parameterized
shapes there is no deprecated-int probe at all. This exception is not always
silent either: when every eligible overload throws
`UnsupportedOperationException`, Spark logs the `implements no reducer`
warning. Since connector authors will rely on this Javadoc to understand
dispatch, please document the actual deprecated-first order and terminal
behavior.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -641,19 +640,15 @@ 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
+ // Should only consider column references, not literals.
+ val nonLiteralChildren =
transform.children.filterNot(_.isInstanceOf[Literal])
+ // We need exactly one column reference per transform.
+ nonLiteralChildren.size == 1 &&
TransformExpression.isColumnRef(nonLiteralChildren.head)
Review Comment:
[P1] Thanks—rejecting the mismatched literal fixes that reproducer, but this
still checks only `Literal` children. `BoundFunction.inputTypes()` applies to
every argument, and `identityReducer` directly evaluates every raw child
through the same unanalyzed path. For example, a connector can legally bind
`(ShortType, IntegerType)` and return a scalar function whose declared inputs
are `(IntegerType, IntegerType)`. Because the literal is already an exact
`IntegerType`, `literalParamsMatchInputTypes` passes; then
`identityReducer.reduce` feeds the boxed `Short` through
`ApplyFunctionExpression` into `SpecificInternalRow(IntegerType)`, which throws
`ClassCastException`. Please validate every child against its corresponding
declared input type before this direct evaluation (or insert the required
casts), and add an identity-vs-transform regression with a `ShortType` column
and exact-typed integer literal. The current tests cover only the literal slot.
--
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]