peter-toth commented on code in PR #55885:
URL: https://github.com/apache/spark/pull/55885#discussion_r4122106752
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/AliasAwareOutputExpression.scala:
##########
@@ -89,6 +89,22 @@ trait PartitioningPreservingUnaryExecNode extends
UnaryExecNode
}
}
+ /**
+ * Projects a partitioning expression through the output aliases. For a
[[TransformExpression]]
+ * only the column argument is projected: `aliasMap` also maps aliased
literals, so `2 AS w` would
+ * otherwise turn `truncate(data, 2)` into `truncate(d, w)`.
+ */
+ private def projectPartitionExpression(expr: Expression):
LazyList[Expression] = expr match {
Review Comment:
**Finding 1.** `dc0afc98b66` closed this for `outputPartitioning` and left
`outputOrdering` on the shared `projectExpression`.
`DataSourceV2ScanExecBase.outputOrdering` derives the ordering from the same
`k.expressions` (`v2BucketingPartitionKeyOrderingEnabled` is on by default),
and `AliasAwareQueryOutputOrdering.outputOrdering` projects it through
`projectExpression` directly, so the literal is still substituted there.
Measured on this head, with a dummy leaf reporting `KP([bucket(32, id)])`
and `outputOrdering = [bucket(32, id) ASC]`, under `ProjectExec(id AS pk, 32 AS
w)`:
```
partitioning: keyedpartitioning(transformexpression(bucket, 32, pk#1), ...)
ordering: List(transformexpression(bucket, w#2, pk#1) ASC NULLS FIRST)
ordering refs: {w#2, pk#1}
ExpressionSet(partitioning.expressions).contains(ordering.head.child): false
```
Control, same plan without `32 AS w`: ordering `transformexpression(bucket,
32, pk#3)`, and `contains` is true.
That last line is the consequence. `GroupPartitionsExec.outputOrdering`'s
coalescing branch keeps exactly the sort orders whose expression is a partition
key expression:
```scala
val keyExprs = ExpressionSet(keyedPartitionings.flatMap(_.expressions))
child.outputOrdering.filter(order => keyExprs.contains(order.child))
```
and its comment states the premise this breaks: "The child's outputOrdering
should already be in sync with the partitioning". With the literal substituted
the filter drops the key-derived ordering, so a downstream sort that would have
been eliminated is kept. A `GroupPartitionsExec` over a `ProjectExec` over a
`BatchScanExec` is the ordinary shape of a join leg, so this is the
default-config path. The claim itself stays value-correct, since `w` is the
constant 32 in every row, which is why nothing fails. It just stops matching.
The fix I would suggest is central rather than per-site, at
`sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/AliasAwareOutputExpression.scala:74`:
```scala
case e: Expression if !e.isInstanceOf[Literal] &&
aliasMap.contains(e.canonicalized) =>
aliasMap(e.canonicalized).toSeq ++ (if (e.containsChild.nonEmpty)
Seq(e) else Seq.empty)
```
A `Literal` then stays put for every consumer, which is what you want
everywhere: an alias over a constant is not another name for the same value, it
is a column that happens to hold it. `projectPartitionExpression` can then go
away entirely, 18 lines lighter.
I ran it, together with finding 4's change, on this head. Green:
`TransformExpressionSuite`, `ShuffleSpecSuite`, `KeyGroupedPartitioningSuite`,
`ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`,
`EnsureRequirementsSuite`, `PlannerSuite` (479 tests), plus
`ReplaceIntegerLiteralsWithOrdinalsDataframeSuite` and
`RemoveRedundantSortsSuite`. No test in `sql/core` partitions or orders by a
bare literal, so nothing depended on the old behaviour.
Either way the new `ProjectedOrderingAndPartitioningSuite` test wants the
ordering half asserted next to the partitioning one, since that is the pair
that has to stay in sync:
```scala
val orderedChild = DummyLeafExecWithPartitioningAndOrdering(
output = Seq(id),
partitioning = KeyedPartitioning(Seq(bucketExpr), keys1d),
ordering = Seq(SortOrder(bucketExpr, Ascending)))
val orderedProject = ProjectExec(Seq(pk, w), orderedChild)
val projectedOrdering = orderedProject.outputOrdering
assert(projectedOrdering.head.child.asInstanceOf[TransformExpression].children.head
=== Literal(32),
"the literal parameter must survive the ordering projection too")
val projectedKp =
orderedProject.outputPartitioning.asInstanceOf[KeyedPartitioning]
assert(ExpressionSet(projectedKp.expressions).contains(projectedOrdering.head.child),
"the projected ordering must stay in sync with the projected
partitioning")
```
If you prefer to keep the change narrow instead, give
`AliasAwareQueryOutputOrdering` the same `TransformExpression` case
`projectPartitionExpression` has.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -6206,6 +6211,655 @@ 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)))
Review Comment:
**Finding 2.** Both new end-to-end truncate tests set
`V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS`, which is off by default, so between
them they only cover the reducer path. The case the description leads with,
identical widths matched by `isSameFunction` with no reducer at all, is covered
only by the single-table `checkQueryPlan` at line 549. That is the path the
default config takes and the one most users will hit.
It works. The test is one copy of this one without the `withSQLConf`.
Measured on this head: 0 shuffles and the right rows, with
`allowCompatibleTransforms` left at `false`.
```scala
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)))
}
```
It cannot pass on base: `supportsExpressions` required `children.size == 1`
there, which `truncate(data, 3)` fails, and the pre-PR version of the test at
line 549 asserted `UnknownPartitioning(0)` for exactly that reason.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -133,18 +182,178 @@ case class TransformExpression(
def withReference(attr: Attribute): TransformExpression =
transform { case _: AttributeReference => attr
}.asInstanceOf[TransformExpression]
- // Return a Reducer for a reducible function on another reducible function
- private def reducer(
- thisFunction: ReducibleFunction[_, _],
- 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)
+ /**
+ * Rewrites this transform's column arguments with `rewriteColumn`, leaving
its literal parameters
+ * untouched. Unlike [[withReference]], which swaps the attribute *inside* a
column argument (so a
+ * `GetStructField` path above it is kept), this replaces the whole argument.
+ *
+ * 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. Every such path should go through here rather than reimplement the
skip.
+ *
+ * Callers: `KeyedShuffleSpec.createPartitioning`, which retargets a
transform at the other side's
+ * clustering key, and `PartitioningPreservingUnaryExecNode`, which
retargets it at an aliased
+ * output attribute.
+ */
+ 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. [[withReference]]
retargets a transform at
+ * another key, and a [[GetStructField]] column slot retargeted at a
non-struct attribute -- an
+ * `identity` column on the other side of a join, say -- leaves
`GetStructField(intCol, 0)`, whose
+ * `dataType` raises `INTERNAL_ERROR: GetStructField requires a StructType
child`. 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))
+ }
+ }
+
+ /**
+ * 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 or a column reference
on both sides.
+ * 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) => TransformExpression.isColumnRef(c1) &&
TransformExpression.isColumnRef(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[_, _]] = {
+ 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) {
+ return None
+ }
+
+ val thisParams = extractParameters
+ val otherParams = other.extractParameters
+ val thisName = function.canonicalName()
+
+ // Only a single non-null IntegerType parameter per side may use the
deprecated int overload; a
+ // typed null would otherwise be read as 0.
+ def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+ p.length == 1 && p(0).dataType == IntegerType && p(0).value() != null
+ }
+
+ // 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.name() == other.function.name() =>
Review Comment:
**Finding 5.** `name()` has no uniqueness contract. `Function.name()` is a
display name, and Spark's own fixtures return it both bare (`BucketFunction`,
`"bucket"`) and catalog-qualified (`ShuffleSpecSuite.FakeBucket`,
`"test.fakeBucket"`). `canonicalName()` is the one documented for this
question, and it is what `functionId` already compares two hundred lines up.
The two disagree in both directions.
- Over-admits. Two different bound functions may share a `name()`. This PR's
own `UnboundTruncateFunction` binds `TruncateFunction` or
`IntegerTruncateFunction` by column type, and both return `"truncate"`, so a
pair of those passes this clause.
- Under-admits. `BoundFunction.canonicalName`'s javadoc exists for the case
where one implementation is loaded under two catalog names: "loaded using
different names, like `test.func_name` and `prod.func_name`". A connector whose
`name()` carries the catalog then fails this clause for two equivalent
functions, and loses the fallback the commit was protecting.
```suggestion
function.canonicalName() == other.function.canonicalName() =>
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -133,18 +182,178 @@ case class TransformExpression(
def withReference(attr: Attribute): TransformExpression =
transform { case _: AttributeReference => attr
}.asInstanceOf[TransformExpression]
- // Return a Reducer for a reducible function on another reducible function
- private def reducer(
- thisFunction: ReducibleFunction[_, _],
- 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)
+ /**
+ * Rewrites this transform's column arguments with `rewriteColumn`, leaving
its literal parameters
+ * untouched. Unlike [[withReference]], which swaps the attribute *inside* a
column argument (so a
+ * `GetStructField` path above it is kept), this replaces the whole argument.
+ *
+ * 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. Every such path should go through here rather than reimplement the
skip.
+ *
+ * Callers: `KeyedShuffleSpec.createPartitioning`, which retargets a
transform at the other side's
+ * clustering key, and `PartitioningPreservingUnaryExecNode`, which
retargets it at an aliased
+ * output attribute.
+ */
+ 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. [[withReference]]
retargets a transform at
+ * another key, and a [[GetStructField]] column slot retargeted at a
non-struct attribute -- an
+ * `identity` column on the other side of a join, say -- leaves
`GetStructField(intCol, 0)`, whose
+ * `dataType` raises `INTERNAL_ERROR: GetStructField requires a StructType
child`. 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))
+ }
+ }
+
+ /**
+ * 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 or a column reference
on both sides.
+ * 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) => TransformExpression.isColumnRef(c1) &&
TransformExpression.isColumnRef(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[_, _]] = {
+ 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) {
+ return None
+ }
+
+ val thisParams = extractParameters
+ val otherParams = other.extractParameters
+ val thisName = function.canonicalName()
+
+ // Only a single non-null IntegerType parameter per side may use the
deprecated int overload; a
+ // typed null would otherwise be read as 0.
+ def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+ p.length == 1 && p(0).dataType == IntegerType && p(0).value() != null
+ }
+
+ // 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
Review Comment:
**Finding 7.** Optional, and a design point rather than a defect.
The dispatch reads "the connector did not implement this overload" off a
caught `UnsupportedOperationException`, and the new javadoc now states that
convention. But it is not a property of the overload, it is a property of one
call. An implemented generalized reducer that throws
`UnsupportedOperationException` from inside, say a connector operation it
cannot serve for these parameters or a `require` in a helper, is read as
unimplemented. The pair then falls through to the deprecated overload, which
may hand back a reducer for parameters the generalized one refused. Silent, and
the "implements no reducer" warning that would otherwise appear is suppressed
by the very fallback that fired.
Whether the method is overridden is answerable directly, and there is
precedent right next door: `V2ExpressionUtils.findMethod` already does this for
`ScalarFunction`'s magic method. Sketch:
```scala
// Memoized per BoundFunction class, since this is asked per pair.
private def implementsGeneralizedReducer(f: ReducibleFunction[_, _]):
Boolean =
f.getClass
.getMethod("reducer", classOf[Array[V2Literal[_]]],
classOf[ReducibleFunction[_, _]],
classOf[Array[V2Literal[_]]])
.getDeclaringClass != classOf[ReducibleFunction[_, _]]
```
`Unimplemented` would then be decided before the call rather than inferred
from it, and an `UnsupportedOperationException` out of an implemented reducer
would join `Threw`.
Counter-arguments, so this is a choice and not a request. Reflection on a
connector class costs something, even memoized per class. A Scala `object`
implementing the Java interface makes `getDeclaringClass` less obvious than it
reads. And the UOE convention is what the other `default` methods in this
package already use, so changing it only here makes the package inconsistent.
Fine to leave as is. If you do, the javadoc should say that an
`UnsupportedOperationException` thrown from inside an implemented reducer is
indistinguishable from not implementing it, since an implementor cannot
otherwise guess that.
##########
sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/functions/ReducibleFunction.java:
##########
@@ -101,6 +155,6 @@ default Reducer<I, O> reducer(
* @return a reduction function if it is reducible, null if not.
*/
default Reducer<I, O> reducer(ReducibleFunction<?, ?> otherFunction) {
- throw new UnsupportedOperationException();
+ return reducer(new Literal<?>[0], otherFunction, new Literal<?>[0]);
Review Comment:
**Finding 9.** With this delegation the two defaults can call each other. A
connector migrating to the generalized overload writes the natural shim for its
zero-parameter transforms:
```java
@Override
public Reducer<I, O> reducer(
Literal<?>[] thisParams, ReducibleFunction<?, ?> other, Literal<?>[]
otherParams) {
if (thisParams.length == 0 && otherParams.length == 0) {
return reducer(other); // keep the old no-parameter logic
}
...
}
```
`reducer(other)` is not overridden, so it lands back here, which calls the
override again. `TransformExpression.reducers` calls `reducer(otherFunction)`
for exactly the zero-parameter pair, so the recursion is reachable, and the
resulting `StackOverflowError` is a `VirtualMachineError`: `Try`'s `NonFatal`
does not catch it, so it takes the driver thread down rather than costing a
shuffle.
An `@implNote` is enough, since the direction is not guessable from the
signatures:
```
* @implNote This default delegates to
* {@link #reducer(Literal[], ReducibleFunction, Literal[])}, so an
implementation of that
* overload must not call back into this method.
```
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -52,23 +87,46 @@ case class TransformFunctionId(canonicalName: String,
numBucketsOpt: Option[Int]
case class TransformExpression(
function: BoundFunction,
children: Seq[Expression],
- numBucketsOpt: Option[Int] = None,
- reducedWith: Option[TransformFunctionId] = None) extends Expression {
+ reducedWith: Option[TransformFunctionId] = None) extends Expression with
Logging {
override def nullable: Boolean = true
/** Drops the trailing `reducedWith` when it is unset, so the ordinary form
is unchanged. */
override protected def stringArgs: Iterator[Any] =
if (reducedWith.isEmpty) super.stringArgs.take(productArity - 1) else
super.stringArgs
- /** The identity of this expression's transform function. */
- lazy val functionId: TransformFunctionId =
- TransformFunctionId(function.canonicalName(), numBucketsOpt)
+ /**
+ * Extract literal children (constant parameters) from this transform. These
are constant
+ * arguments like the bucket count in `bucket(n, col)` or the width in
`truncate(col, width)`.
+ * Positions are dropped, so this is for handing parameters to a connector
reducer, NOT for
+ * identity -- see [[functionId]] and [[TransformFunctionId.argumentShapes]].
+ */
+ private lazy val literalChildren: Seq[Literal] =
+ children.collect { case l: Literal => l }
/**
- * Whether this [[TransformExpression]] has the same semantics as `other`.
- * For instance, `bucket(32, c)` is equal to `bucket(32, d)`, but not to
`bucket(16, d)` or
- * `year(c)`.
+ * This transform's identity, or None if some argument is not a literal, a
nested transform or a
+ * column reference (e.g. `c + 1`); such a transform is not the same as any
other.
+ */
+ lazy val functionId: Option[TransformFunctionId] = {
+ import TransformFunctionId.ArgumentShape
+ val shapes = children.map {
+ case l: Literal => Some(ArgumentShape.Param(l))
+ case t: TransformExpression => t.functionId.map(ArgumentShape.Nested(_))
+ case c if TransformExpression.isColumnRef(c) =>
Some(ArgumentShape.Column)
Review Comment:
**Finding 4.** This arm gives up the identity when an argument is not a
literal, a nested transform or a plain column ref, so `functionId` is `None`
and `isSameFunction` answers false for such a transform against itself.
`ShuffleSpec.isCompatibleWith` documents the opposite at
`sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:1586`:
"Note that Spark assumes this to be reflexive, symmetric and transitive". The
new `TransformExpressionSuite` test pins the non-reflexivity ("never the same,
not even as itself").
It is reachable under `v2BucketingShuffleEnabled`.
`KeyedShuffleSpec.createPartitioning` builds the shuffled side's expression
from the *other* side's clustering key, so a join `ON t1.id = t2.b + 1` over a
`bucket(4, id)` table yields `bucket(4, b + 1)` on the shuffled side, paired
with `bucket(4, id)` on the keyed one. `keyPositions` reconciles them, one
reference each, and the two are co-located by construction, since
`createPartitioning` built the expression for exactly that. Master's
`isSameFunction` said true for that pair; this head says false.
The root cause is that identity is being asked two questions. "Which
transform is this" is identity's; "may SPJ reason about these argument shapes"
is `KeyedPartitioning.supportsExpressions`', which already asks it and is the
load-bearing gate on the scan. Once identity stops re-asking it, the whole
thing gets shorter:
```scala
lazy val functionId: TransformFunctionId = {
import TransformFunctionId.ArgumentShape
TransformFunctionId(function.canonicalName(), children.map {
case l: Literal => ArgumentShape.Param(l)
case t: TransformExpression => ArgumentShape.Nested(t.functionId)
case _ => ArgumentShape.Slot
})
}
```
with `Column` renamed to `Slot`, since it now covers any non-parameter
argument. What that discriminates is unchanged where it matters:
- `truncate(id, 2)` and `truncate(2, store_id)` stay apart, the `Param`
positions differ
- `bucket(4, years(c))` and `bucket(4, days(c))` stay apart, `Nested` is
still matched above
- `bucket(32, c)` and `bucket(16, c)` stay apart
and the knock-on simplifications fall out:
- `isSameFunction` becomes `functionId == other.functionId`
- `reducedKeySpace` goes back from the for-comprehension to
`reducedWith.map(partner => Set(functionId, partner))`
- `reducedTogetherWith` loses its `SparkException.internalError` arm, which
its own comment already called unreachable, so it is a one-liner again
- the `SparkException` import goes
Net `-17` lines in `TransformExpression.scala`.
On whether the wider `Slot` is safe. Identity is column-blind by
construction, and the safety comes from `keyPositions` plus
`supportsExpressions`, not from identity. For two non-column-slot transforms to
be compared at all, both have to be `createPartitioning` products, since the
scan gate refuses such a shape, and those are built from one spec onto one key
set, so "same function" is the right answer there. I could not construct a case
where the wider slot co-locates rows that do not belong together; if you see
one, that settles it the other way.
I ran this on this head. Green: `TransformExpressionSuite`,
`ShuffleSpecSuite` (41), then `KeyGroupedPartitioningSuite`,
`ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`,
`EnsureRequirementsSuite`, `PlannerSuite` (438). Four assertions had to flip,
and three of them are the ones that enshrine the non-reflexivity:
- `TransformExpressionSuite`, "a transform with a non-reference argument has
no identity" -- becomes "a non-parameter argument is a slot, whatever its shape"
- the `plusOne` assertion in "transform identity is per-argument-position"
- the `add` assertion in `KeyGroupedPartitioningSuite`'s "isSameFunction is
a structural per-position walk"
- the `shapes.forall(_.functionId.isDefined)` fixture guard in
"isSameFunction and hasSameReducedKeys agree on every pair", which can instead
gain a non-reference shape to the fixture list, since the agreement now holds
over those too
I have this and finding 1 as one patch against `18f6f8f` (75 insertions, 78
deletions over 6 files, including the test adjustments). Happy to push it to a
branch on my fork for you to cherry-pick if that is easier than redoing it --
just say so.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:
##########
@@ -133,18 +182,178 @@ case class TransformExpression(
def withReference(attr: Attribute): TransformExpression =
transform { case _: AttributeReference => attr
}.asInstanceOf[TransformExpression]
- // Return a Reducer for a reducible function on another reducible function
- private def reducer(
- thisFunction: ReducibleFunction[_, _],
- 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)
+ /**
+ * Rewrites this transform's column arguments with `rewriteColumn`, leaving
its literal parameters
+ * untouched. Unlike [[withReference]], which swaps the attribute *inside* a
column argument (so a
+ * `GetStructField` path above it is kept), this replaces the whole argument.
+ *
+ * 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. Every such path should go through here rather than reimplement the
skip.
+ *
+ * Callers: `KeyedShuffleSpec.createPartitioning`, which retargets a
transform at the other side's
+ * clustering key, and `PartitioningPreservingUnaryExecNode`, which
retargets it at an aliased
+ * output attribute.
+ */
+ 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. [[withReference]]
retargets a transform at
+ * another key, and a [[GetStructField]] column slot retargeted at a
non-struct attribute -- an
+ * `identity` column on the other side of a join, say -- leaves
`GetStructField(intCol, 0)`, whose
+ * `dataType` raises `INTERNAL_ERROR: GetStructField requires a StructType
child`. 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))
+ }
+ }
+
+ /**
+ * 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 or a column reference
on both sides.
+ * 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) => TransformExpression.isColumnRef(c1) &&
TransformExpression.isColumnRef(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[_, _]] = {
+ 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) {
+ return None
+ }
+
+ val thisParams = extractParameters
+ val otherParams = other.extractParameters
+ val thisName = function.canonicalName()
+
+ // Only a single non-null IntegerType parameter per side may use the
deprecated int overload; a
+ // typed null would otherwise be read as 0.
+ def isSingleInt(p: Array[V2Literal[_]]): Boolean = {
+ p.length == 1 && p(0).dataType == IntegerType && p(0).value() != null
Review Comment:
**Finding 3.** This clause keeps a typed null out of the deprecated
overload, which is what
[r3503138262](https://github.com/apache/spark/pull/55885#discussion_r3503138262)
asked for. The generalized overload still receives it:
`literalParamsMatchInputTypes` compares only `dataType`, and
`noComplexLiteralParams` looks only at `dataType` too, so `Literal(null,
IntegerType)` passes both gates.
All three bundled reducers then read the value with
`.value().asInstanceOf[Int]`, and in Scala `null.asInstanceOf[Int]` is `0`.
Measured on this head:
```
bucket(null, id).isSameFunction(bucket(4, id)): false
bucket(null, id).literalParamsMatchInputTypes: true
bucket(null, id).reducers(bucket(4, id)): Some(BucketReducer(4))
bucket(null, id).isCompatible(bucket(4, id)): true
```
`BucketFunction.reducer` read the null as `0`, computed `gcd(0, 4) = 4`, and
returned a reducer, so Spark declares the pair reducible and reduces the null
side by `% 4`. That is the same fabricated zero as the deprecated path, one
overload up.
A null parameter belongs with the other things the gate refuses, rather than
with something the javadoc warns about. Then `isSingleInt` needs no null clause
of its own:
```scala
if (!sameArgumentLayout(other) ||
!literalParamsMatchInputTypes || !other.literalParamsMatchInputTypes
||
!noComplexLiteralParams || !other.noComplexLiteralParams ||
literalChildren.exists(_.value == null) ||
other.literalChildren.exists(_.value == null)) {
return None
}
```
If you would rather let a null through, the javadoc needs to say so, because
right now it tells an implementor to read `value()` as "Spark's internal
representation" and only warns about array lengths.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/catalog/functions/transformFunctions.scala:
##########
@@ -279,20 +409,372 @@ object SignedZerosFunction extends
ScalarFunction[Double] {
}
object UnboundTruncateFunction extends UnboundFunction {
- override def bind(inputType: StructType): BoundFunction = TruncateFunction
+ override def bind(inputType: StructType): BoundFunction = {
+ if (inputType.size == 2) {
+ inputType.head.dataType match {
+ case StringType => TruncateFunction
+ case IntegerType => IntegerTruncateFunction
+ case _ =>
+ throw new UnsupportedOperationException(
+ s"'truncate' does not support data type:
${inputType.head.dataType}")
+ }
+ } else {
+ throw new UnsupportedOperationException(
+ "'truncate' requires exactly 2 arguments: (column, width)")
+ }
+ }
+
override def description(): String = name()
override def name(): String = "truncate"
}
-object TruncateFunction extends ScalarFunction[UTF8String] {
+/**
+ * Truncate transform for String type.
+ * Follows Iceberg spec: truncate(str, L) = str[0:L]
+ *
+ * Implements ReducibleFunction: ANY two different widths are compatible.
+ * The reducer uses the smaller width.
+ */
+object TruncateFunction
+ extends ScalarFunction[UTF8String]
+ with ReducibleFunction[UTF8String, UTF8String] {
override def inputTypes(): Array[DataType] = Array(StringType, IntegerType)
override def resultType(): DataType = StringType
override def name(): String = "truncate"
override def canonicalName(): String = name()
override def toString: String = name()
override def produceResult(input: InternalRow): UTF8String = {
val str = input.getUTF8String(0)
- val length = input.getInt(1)
- str.substring(0, length)
+ val width = input.getInt(1)
+ str.substring(0, width)
+ }
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[UTF8String, UTF8String] = {
+
+ if (otherFunc == TruncateFunction && thisParams.length == 1 &&
otherParams.length == 1) {
+ val thisWidth = thisParams(0).value().asInstanceOf[Int]
+ val otherWidth = otherParams(0).value().asInstanceOf[Int]
+ val smallerWidth = math.min(thisWidth, otherWidth)
+
+ if (smallerWidth != thisWidth) {
+ return TruncateReducer(smallerWidth)
+ }
+ }
+ null
+ }
+}
+
+case class TruncateReducer(width: Int) extends Reducer[UTF8String, UTF8String]
{
+ override def reduce(value: UTF8String): UTF8String = {
+ value.substring(0, width)
+ }
+ override def resultType(): DataType = StringType
+ override def displayName(): String = s"truncate($width)"
+}
+
+/**
+ * Truncate transform for Integer type.
+ * Follows Iceberg spec: truncate(value, W) = value - (((value % W) + W) % W),
which snaps `value`
+ * down to a multiple of `W`.
+ *
+ * Implements ReducibleFunction: truncate(v, W1) and truncate(v, W2) are
always reducible onto a
+ * common coarser grid of multiples of lcm(W1, W2). The finer side (whose
width does not already
+ * equal the lcm) reduces by snapping to that grid; when W2 is a multiple of
W1 the lcm is simply
+ * the coarser width W2.
+ */
+object IntegerTruncateFunction
+ extends ScalarFunction[Int]
+ with ReducibleFunction[Int, Int] {
+ override def inputTypes(): Array[DataType] = Array(IntegerType, IntegerType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "truncate"
+ override def canonicalName(): String = name()
Review Comment:
**Finding 6.** `TruncateFunction` above returns the same `"truncate"` here,
and the two partition data differently: one takes a string prefix, the other
snaps an int down to a grid. `BoundFunction.canonicalName`'s javadoc rules that
out: "Two functions that partition data differently must not return the same
name; Spark may otherwise treat unrelated data as co-partitioned."
`isSameFunction` keys on the canonical name plus the argument shapes, so
`truncate(strCol, 3)` and `truncate(intCol, 3)` are the same transform today.
Nothing in the suite joins those two tables, so it is latent. But these
fixtures are what a connector author reads as the reference implementation of
the API this PR is generalizing, and the contract they break is the one SPJ
rests on.
```suggestion
override def canonicalName(): String = "truncate(int)"
```
and `"truncate(string)"` on `TruncateFunction`. Leave both `name()`s as
`"truncate"`, so the function catalog still resolves them the same way.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/catalog/functions/transformFunctions.scala:
##########
@@ -279,20 +409,372 @@ object SignedZerosFunction extends
ScalarFunction[Double] {
}
object UnboundTruncateFunction extends UnboundFunction {
- override def bind(inputType: StructType): BoundFunction = TruncateFunction
+ override def bind(inputType: StructType): BoundFunction = {
+ if (inputType.size == 2) {
+ inputType.head.dataType match {
+ case StringType => TruncateFunction
+ case IntegerType => IntegerTruncateFunction
+ case _ =>
+ throw new UnsupportedOperationException(
+ s"'truncate' does not support data type:
${inputType.head.dataType}")
+ }
+ } else {
+ throw new UnsupportedOperationException(
+ "'truncate' requires exactly 2 arguments: (column, width)")
+ }
+ }
+
override def description(): String = name()
override def name(): String = "truncate"
}
-object TruncateFunction extends ScalarFunction[UTF8String] {
+/**
+ * Truncate transform for String type.
+ * Follows Iceberg spec: truncate(str, L) = str[0:L]
+ *
+ * Implements ReducibleFunction: ANY two different widths are compatible.
+ * The reducer uses the smaller width.
+ */
+object TruncateFunction
+ extends ScalarFunction[UTF8String]
+ with ReducibleFunction[UTF8String, UTF8String] {
override def inputTypes(): Array[DataType] = Array(StringType, IntegerType)
override def resultType(): DataType = StringType
override def name(): String = "truncate"
override def canonicalName(): String = name()
override def toString: String = name()
override def produceResult(input: InternalRow): UTF8String = {
val str = input.getUTF8String(0)
- val length = input.getInt(1)
- str.substring(0, length)
+ val width = input.getInt(1)
+ str.substring(0, width)
+ }
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[UTF8String, UTF8String] = {
+
+ if (otherFunc == TruncateFunction && thisParams.length == 1 &&
otherParams.length == 1) {
+ val thisWidth = thisParams(0).value().asInstanceOf[Int]
+ val otherWidth = otherParams(0).value().asInstanceOf[Int]
+ val smallerWidth = math.min(thisWidth, otherWidth)
+
+ if (smallerWidth != thisWidth) {
+ return TruncateReducer(smallerWidth)
+ }
+ }
+ null
+ }
+}
+
+case class TruncateReducer(width: Int) extends Reducer[UTF8String, UTF8String]
{
+ override def reduce(value: UTF8String): UTF8String = {
+ value.substring(0, width)
+ }
+ override def resultType(): DataType = StringType
+ override def displayName(): String = s"truncate($width)"
+}
+
+/**
+ * Truncate transform for Integer type.
+ * Follows Iceberg spec: truncate(value, W) = value - (((value % W) + W) % W),
which snaps `value`
+ * down to a multiple of `W`.
+ *
+ * Implements ReducibleFunction: truncate(v, W1) and truncate(v, W2) are
always reducible onto a
+ * common coarser grid of multiples of lcm(W1, W2). The finer side (whose
width does not already
+ * equal the lcm) reduces by snapping to that grid; when W2 is a multiple of
W1 the lcm is simply
+ * the coarser width W2.
+ */
+object IntegerTruncateFunction
+ extends ScalarFunction[Int]
+ with ReducibleFunction[Int, Int] {
+ override def inputTypes(): Array[DataType] = Array(IntegerType, IntegerType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "truncate"
+ override def canonicalName(): String = name()
+ override def toString: String = name()
+ override def produceResult(input: InternalRow): Int = {
+ val value = input.getInt(0)
+ val width = input.getInt(1)
+ value - (((value % width) + width) % width)
+ }
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[Int, Int] = {
+ if (otherFunc == IntegerTruncateFunction && thisParams.length == 1 &&
otherParams.length == 1) {
+ val thisWidth = thisParams(0).value().asInstanceOf[Int]
+ val otherWidth = otherParams(0).value().asInstanceOf[Int]
+ val common = lcm(thisWidth, otherWidth)
+ // Only the finer side reduces; if `common == thisWidth` this side is
already the common grid.
+ if (common != thisWidth) {
+ return IntTruncateReducer(common)
+ }
+ }
+ null
+ }
+
+ private def lcm(a: Int, b: Int): Int = {
+ val g = BigInt(a).gcd(BigInt(b))
+ (BigInt(a) / g * BigInt(b)).toInt
+ }
+}
+
+case class IntTruncateReducer(width: Int) extends Reducer[Int, Int] {
+ override def reduce(value: Int): Int = value - (((value % width) + width) %
width)
+ override def resultType(): DataType = IntegerType
+ override def displayName(): String = s"truncate($width)"
+}
+
+/**
+ * A transform whose reducer is defined across a zero-parameter vs
one-parameter shape, e.g.
+ * `zero_or_one(col)` reducing onto `zero_or_one(col, 2)`. Used to verify the
dispatch does not
+ * globally require equal child counts before invoking the reducer.
+ */
+object ZeroOrOneParamFunction extends ScalarFunction[Int] with
ReducibleFunction[Int, Int] {
+ override def inputTypes(): Array[DataType] = Array(IntegerType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "zero_or_one"
+ override def canonicalName(): String = name()
+ override def toString: String = name()
+ override def produceResult(input: InternalRow): Int = input.getInt(0)
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[Int, Int] = {
+ if (otherFunc == ZeroOrOneParamFunction && thisParams.length !=
otherParams.length) {
+ BucketReducer(1)
+ } else {
+ null
+ }
+ }
+}
+
+/**
+ * A transform with a `CalendarIntervalType` literal parameter (which is
non-complex but not an
+ * `AtomicType`). Used to verify such a parameter is not rejected as a complex
container before its
+ * reducer is consulted.
+ */
+object IntervalParamFunction extends ScalarFunction[Int] with
ReducibleFunction[Int, Int] {
+ override def inputTypes(): Array[DataType] = Array(IntegerType,
CalendarIntervalType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "interval_param"
+ override def canonicalName(): String = name()
+ override def toString: String = name()
+ override def produceResult(input: InternalRow): Int = input.getInt(0)
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[Int, Int] = {
+ if (otherFunc == IntervalParamFunction) BucketReducer(1) else null
+ }
+}
+
+/** A user type whose UDT serializes to a struct (an [[InternalRow]]). */
+class StructBacked(val n: Int) extends Serializable
+
+/**
+ * A UDT whose `sqlType` is a [[StructType]]: a literal of this type carries
an [[InternalRow]]
+ * value, yet its `dataType` is the UDT, not `StructType`. This is the case a
`DataType`-based
+ * container check misses but a value-based one catches.
+ */
+class StructBackedUDT extends UserDefinedType[StructBacked] {
+ override def sqlType: DataType = StructType(Seq(StructField("n",
IntegerType, nullable = false)))
+ override def serialize(obj: StructBacked): InternalRow = new
GenericInternalRow(Array[Any](obj.n))
+ override def deserialize(datum: Any): StructBacked = datum match {
+ case row: InternalRow => new StructBacked(row.getInt(0))
+ }
+ override def userClass: Class[StructBacked] = classOf[StructBacked]
+}
+
+/**
+ * A transform whose declared input type at the literal position is a UDT
([[StructBackedUDT]]).
+ * Used to make the value-based `noComplexLiteralParams` guard load-bearing: a
UDT-over-struct
+ * literal matches the declared input type (so `literalParamsMatchInputTypes`
passes), yet its value
+ * is an [[InternalRow]], so only the value-based guard can reject it. The
reducer returns
+ * unconditionally, so reaching it at all is the leak.
+ */
+object UdtParamFunction extends ScalarFunction[Int] with
ReducibleFunction[Int, Int] {
+ override def inputTypes(): Array[DataType] = Array(new StructBackedUDT,
LongType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "udt_param"
+ override def canonicalName(): String = name()
+ override def toString: String = name()
+ override def produceResult(input: InternalRow): Int = input.getInt(1)
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[Int, Int] = BucketReducer(1)
+}
+
+/**
+ * A string truncate whose reducer reads its width type-tolerantly (via
[[Number]], so it accepts a
+ * boxed Short or Integer). Used to verify that the literal-param-type gate --
not an incidental
+ * ClassCastException in the connector -- is what makes a mismatched-type
(e.g. ShortType) width
+ * non-reducible. With the gate removed, this reducer WOULD reduce a
ShortType-width pair.
+ */
+object TypeTolerantTruncateFunction
+ extends ScalarFunction[UTF8String]
+ with ReducibleFunction[UTF8String, UTF8String] {
+ override def inputTypes(): Array[DataType] = Array(StringType, IntegerType)
+ override def resultType(): DataType = StringType
+ override def name(): String = "tolerant_truncate"
+ override def canonicalName(): String = name()
+ override def toString: String = name()
+ override def produceResult(input: InternalRow): UTF8String =
+ input.getUTF8String(0).substring(0, input.getInt(1))
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[UTF8String, UTF8String] = {
+ if (otherFunc == TypeTolerantTruncateFunction &&
+ thisParams.length == 1 && otherParams.length == 1) {
+ val thisWidth = thisParams(0).value().asInstanceOf[Number].intValue()
+ val otherWidth = otherParams(0).value().asInstanceOf[Number].intValue()
+ val smaller = math.min(thisWidth, otherWidth)
+ if (smaller != thisWidth) return TruncateReducer(smaller)
+ }
+ null
+ }
+}
+
+/**
+ * A function whose generalized reducer throws an unexpected
(non-UnsupportedOperationException)
+ * exception. Used to verify the dispatch logs it and treats the pair as not
reducible (a shuffle),
+ * rather than crashing or emitting the misleading "implements no reducer"
hint (it does implement
+ * the overload -- it threw a bug, which is a different signal than
UOE-means-unimplemented).
+ */
+object ThrowingReducerFunction extends ScalarFunction[Int] with
ReducibleFunction[Int, Int] {
+ override def inputTypes(): Array[DataType] = Array(IntegerType, IntegerType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "throwing_reducer"
+ override def canonicalName(): String = name()
+ override def toString: String = name()
+ override def produceResult(input: InternalRow): Int = input.getInt(1)
+
+ override def reducer(
+ thisParams: Array[Literal[_]],
+ otherFunc: ReducibleFunction[_, _],
+ otherParams: Array[Literal[_]]): Reducer[Int, Int] =
+ throw new RuntimeException("boom from reducer")
+}
+
+/**
+ * A bucket-like function implementing BOTH reducer overloads: the deprecated
int overload succeeds
+ * (returns a reducer), while the generalized Literal[] overload throws. Used
to verify the
+ * single-int dispatch is lazy -- it must not invoke (and log the throw from)
the generalized
Review Comment:
**Finding 8.** This one contradicts the test that uses it. The fixture doc
says the dispatch "must not invoke (and log the throw from) the generalized
overload once the deprecated one already produced a reducer", while the test "a
throwing generalized overload is surfaced, not masked by the deprecated one"
asserts the reverse: the generalized throw is authoritative and there is no
fallback.
Three more sites still describe a shape that changed.
- `KeyGroupedPartitioningSuite.scala:6343` says "the single-int dispatch
tries reducer(int, ...) first (UOE), then the Literal[]". It has been
generalized-first since the dispatch was redesigned, and `BucketFunction`
overrides only the new overload, so no fallback is involved in that test at all.
- `transformFunctions.scala:313`, `DualApiBucketFunction`, says it verifies
"that the dispatch falls back to the generalized overload when the deprecated
one returns null". The deprecated overload is never consulted there, so its
null is irrelevant, which is what the test body now says.
- `transformFunctions.scala:576` and `:595`, `StructBackedUDT` and
`UdtParamFunction`, describe `noComplexLiteralParams` as value-based and call
the UDT "the case a `DataType`-based container check misses but a value-based
one catches". The implemented guard is `DataType`-based and lists `_:
UserDefinedType[_]` explicitly, so it catches it head-on.
`22cd902c0d4` fixed a fourth site of this kind. These are the rest.
--
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]