MaxGekk commented on code in PR #56808:
URL: https://github.com/apache/spark/pull/56808#discussion_r3482875357


##########
sql/core/src/test/scala/org/apache/spark/sql/DataFramePivotSuite.scala:
##########
@@ -241,10 +242,48 @@ class DataFramePivotSuite extends SharedSparkSession {
     )
   }
 
-  test("pivot with null should not throw NPE") {
+  // SPARK-19882: null pivot keys must not NPE; also checks empty count 
buckets return 0.
+  test("pivot count with null pivot value returns 0 for empty buckets") {
     checkAnswer(
       Seq(Tuple1(None), 
Tuple1(Some(1))).toDF("a").groupBy($"a").pivot("a").count(),
-      Row(null, 1, null) :: Row(1, null, 1) :: Nil)
+      Row(null, 1, 0) :: Row(1, 0, 1) :: Nil)
+  }
+
+  // Empty-bucket result coverage is in the pivot.sql golden file. The tests 
below cover what it
+  // cannot: output-schema nullability, the ANSI runtime error path, and the 
empty-bucket flag.
+
+  test("pivot count produces non-nullable column schema") {
+    val df = Seq((1, "a")).toDF("id", "cat")
+      .groupBy("id").pivot("cat", Seq("a", "b")).count()
+    assert(!df.schema("a").nullable, s"expected 'a' column to be non-nullable: 
${df.schema}")
+    assert(!df.schema("b").nullable, s"expected 'b' column to be non-nullable: 
${df.schema}")
+  }
+
+  test("pivot integral division of counts throws on an empty bucket under 
ANSI") {

Review Comment:
   The composite-aggregate coverage here is all never-NULL-on-non-empty shapes 
(`count(v1) div count(v2)` throws under ANSI; the bare-count default), so the 
suite passes even with the composite `Coalesce` issue above present. Worth 
adding a case that exercises a composite which is NULL on a *non-empty* bucket 
— e.g. `nullif(count(v), N)` over a bucket with exactly `N` matching rows 
asserting `NULL`, and `if(count(v1) > 0, sum(v2), 0)` over a non-empty 
all-NULL-`v2` bucket asserting `NULL` — comparing against the slow path / 
flag-off.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/PivotTransformer.scala:
##########
@@ -185,6 +201,42 @@ object PivotTransformer extends AliasHelper with 
SQLConfHelper {
     }
   }
 
+  /**
+   * Empty-input default for a pivot aggregate to coalesce into its extracted 
value, or `None` to
+   * leave the value unchanged. The fast path's [[PivotFirst]] leaves an 
unmatched pivot category's
+   * slot unset, so the caller wraps the result in a [[Coalesce]] to recover 
the value the slow path
+   * produces on an empty bucket (`count` -> 0; `sum`/`avg`/`min`/`max` -> 
NULL).
+   *
+   * Returned unevaluated so later constant folding defers a default that 
throws under ANSI (e.g.
+   * `count(v1) / count(v2)` -> `0 / 0`) to runtime, where [[Coalesce]] only 
evaluates it for
+   * actually-empty buckets -- matching the slow path. Mirrors
+   * `RewriteCorrelatedScalarSubquery.evalAggExprOnZeroTups`.
+   */
+  private def aggregateEmptyInputDefault(aggregate: Expression): 
Option[Expression] = {
+    trimAliases(aggregate) match {
+      // Bare aggregate: use its published default result (count -> 0, 
sum/avg/min/max -> None).
+      case AggregateExpression(aggregateFunction, _, _, _, _) =>
+        aggregateFunction.defaultResult
+      // Composite over aggregate(s): substitute each aggregate/attribute with 
its empty-input
+      // value. Return None for a non-foldable default or a literal NULL 
(nothing to coalesce in). A
+      // default that only folds to NULL (e.g. sum(x) + 1) is still returned; 
its Coalesce evaluates
+      // to NULL on an empty bucket, which is the correct result.
+      case other =>
+        val default = other.transform {
+          case AggregateExpression(aggregateFunction, _, _, _, _) =>
+            aggregateFunction.defaultResult.getOrElse(
+              Literal.create(null, aggregateFunction.dataType))
+          case attribute: AttributeReference =>
+            Literal.create(null, attribute.dataType)
+        }
+        default match {
+          case _ if !trimAliases(default).foldable => None
+          case Literal(null, _) => None

Review Comment:
   Nit: this guard only catches a *syntactic* `Literal(null)`, but a default 
that *evaluates* to null without being a literal — e.g. `CEIL(null)` (from 
`CEIL(sum(x))`) or `(null + cast(1 as bigint))` (from `sum(x) + 1`) — slips 
through and yields `Some(default)`, so a `Coalesce(extractedValue, 
<expr→null>)` wrapper is emitted (you can see it in the analyzer-results 
golden, e.g. `coalesce(__pivot_CEIL(sum…), CEIL(null))`). It's harmless to 
results (`Coalesce(x, null) ≡ x`, and nullability is unchanged) but adds dead 
plan nodes and doesn't match the doc's "Return None for … a literal NULL". 
Folding the default first would catch these: `default.foldable && 
default.eval() == null => None`.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/PivotTransformer.scala:
##########
@@ -185,6 +201,42 @@ object PivotTransformer extends AliasHelper with 
SQLConfHelper {
     }
   }
 
+  /**
+   * Empty-input default for a pivot aggregate to coalesce into its extracted 
value, or `None` to
+   * leave the value unchanged. The fast path's [[PivotFirst]] leaves an 
unmatched pivot category's
+   * slot unset, so the caller wraps the result in a [[Coalesce]] to recover 
the value the slow path
+   * produces on an empty bucket (`count` -> 0; `sum`/`avg`/`min`/`max` -> 
NULL).
+   *
+   * Returned unevaluated so later constant folding defers a default that 
throws under ANSI (e.g.
+   * `count(v1) / count(v2)` -> `0 / 0`) to runtime, where [[Coalesce]] only 
evaluates it for
+   * actually-empty buckets -- matching the slow path. Mirrors
+   * `RewriteCorrelatedScalarSubquery.evalAggExprOnZeroTups`.
+   */
+  private def aggregateEmptyInputDefault(aggregate: Expression): 
Option[Expression] = {
+    trimAliases(aggregate) match {
+      // Bare aggregate: use its published default result (count -> 0, 
sum/avg/min/max -> None).
+      case AggregateExpression(aggregateFunction, _, _, _, _) =>
+        aggregateFunction.defaultResult
+      // Composite over aggregate(s): substitute each aggregate/attribute with 
its empty-input
+      // value. Return None for a non-foldable default or a literal NULL 
(nothing to coalesce in). A
+      // default that only folds to NULL (e.g. sum(x) + 1) is still returned; 
its Coalesce evaluates
+      // to NULL on an empty bucket, which is the correct result.
+      case other =>
+        val default = other.transform {

Review Comment:
   The composite branch can return a non-null `default` for an aggregate that 
is NULL-capable on a *non-empty* bucket, and the caller then wraps it in 
`Coalesce(extractedValue, default)`. The problem is that `PivotFirst.update` 
stores a value only `if (value != null)` and keeps no presence bit, so a buffer 
slot is NULL iff the pivot category is **absent** *or* 
**present-but-the-value-is-NULL** — `ExtractValue` can't distinguish them, and 
the `Coalesce` defaults both. That's correct for bare count-family aggregates 
(never NULL on a non-empty bucket), but wrong for composites over a 
NULL-capable sub-aggregate:
   - `PIVOT(nullif(count(v), 5) ...)` — a bucket with exactly 5 matching 
non-null rows: slow path = `nullif(5,5)` = NULL, fast path = `Coalesce(NULL, 
nullif(0,5)=0)` = `0`.
   - `PIVOT(if(count(v1) > 0, sum(v2), 0) ...)` — a non-empty bucket with 
all-NULL `v2`: slow path = NULL, fast path = `0`.
   Since the flag defaults to `true`, these are live wrong results, and the 
pivoted column is also declared non-nullable while it can be NULL. The simplest 
safe fix is to apply the empty-default only for bare aggregates whose 
`defaultResult` is non-null (count-family, where a NULL slot provably means 
empty) and leave NULL-capable composites to NULL / the slow path; a fuller fix 
would add a per-slot presence signal to `PivotFirst`.



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