stevomitric commented on code in PR #60061:
URL: https://github.com/apache/spark/pull/60061#discussion_r4502098977


##########
docs/sql-migration-guide.md:
##########
@@ -67,6 +67,7 @@ license: |
 - Since Spark 4.4, 
`spark.sql.sources.v2.bucketing.preserveKeyOrderingOnCoalesce.enabled` defaults 
to `true`. When `GroupPartitionsExec` merges several input partitions that 
share one partition key value into a single output partition, it now keeps sort 
orders over the partition key expressions in the ordering it reports, since 
those expressions are constant within the merged partition. Sort orders over 
partition transforms such as `days(ts)` or `bucket(8, id)` are left out. Orders 
over other columns are still dropped, as the concatenation invalidates them, 
and a join that reduced the partition keys onto a common key space (see 
`spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`) reports no 
order, since the merged partitions then share only the reduced key. This can 
remove a `Sort` downstream of the merge. To restore the previous behavior, set 
`spark.sql.sources.v2.bucketing.preserveKeyOrderingOnCoalesce.enabled` to 
`false`.
 - Since Spark 4.4, when the `from_protobuf` option 
`unwrap.primitive.wrapper.types` is enabled, `from_protobuf` unwraps a present 
Protobuf primitive wrapper (such as `google.protobuf.Int32Value` or 
`StringValue`) whose inner `value` is unset or set to its default to the type's 
default value (`0`, `false`, `""`, or empty binary), regardless of the 
`emit.default.values` option. Previously such a wrapper unwrapped to `NULL` 
unless the `emit.default.values` option was enabled, which could also place a 
`NULL` into a non-nullable array element or map value. An absent wrapper field 
still unwraps to `NULL`.
 - Since Spark 4.4, the value columns of `UNPIVOT` and `Dataset.unpivot` merge 
the nested nullability (array element, map value, and struct field nullability) 
of all unpivoted values. Previously, a value column took the nested nullability 
of the first listed value, so a `NULL` array element, map value, or struct 
field of a later value could be returned as a non-`NULL` value such as `0`, and 
`IS NULL` on it returned `false`. A view created by an earlier Spark version 
over such an `UNPIVOT` may have recorded the non-nullable nested type in its 
schema and can fail to read; recreate it with `CREATE OR REPLACE VIEW`. To 
restore the previous behavior, set 
`spark.sql.legacy.unpivot.useFirstValueNestedNullability` to `true`.
+- Since Spark 4.4, a recursive CTE whose recursive term writes a `NULL` into 
an array element, map value or struct field of a column that the anchor built 
as non-nullable returns that `NULL`. Previously, the next iteration read such a 
value through the anchor's non-nullable nested type and could return a 
non-`NULL` value such as `0`, with `IS NULL` on it returning `false`, or fail 
with a `NullPointerException`. The nested type of such a column, and of columns 
that copy it, is now nullable in the result, so casting it to a type with a 
`NOT NULL` nested type fails. A view created by an earlier Spark version over 
such a recursive CTE may have recorded the non-nullable nested type in its 
schema and can fail to read; recreate it with `CREATE OR REPLACE VIEW`, or run 
`ALTER VIEW ... WITH SCHEMA EVOLUTION`. Columns that the recursive term only 
passes through keep their type.

Review Comment:
   done



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveWithCTE.scala:
##########
@@ -303,22 +323,78 @@ object ResolveWithCTE extends Rule[LogicalPlan] {
   // Assumes that `anchor` is already resolved. If `columnNames` is set (which 
happens if an
   // UnresolvedSubqueryColumnAliases is atop a Union) also places an 
UnresolvedSubqueryColumnAliases
   // node above UnionLoopRef. At some later stage 
UnresolvedSubqueryColumnAliases gets resolved to
-  // a Project node.
+  // a Project node. `refTypes` overrides the anchor's data types for the 
UnionLoopRef output.
   private def rewriteRecursiveCTERefs(
       recursion: LogicalPlan,
       anchor: LogicalPlan,
       cteDefId: Long,
-      columnNames: Option[Seq[String]]) = {
+      columnNames: Option[Seq[String]],
+      refTypes: Option[Seq[DataType]] = None) = {
+    val types = refTypes.getOrElse(anchor.output.map(_.dataType))
     recursion.transformUpWithSubqueriesAndPruning(_.containsPattern(CTE)) {
       case r: CTERelationRef if r.recursive && r.cteId == cteDefId =>
         // We mark the output of UnionLoopRef as nullable as it may become 
NULL in the following
         // iterations.
-        val ref =
-          UnionLoopRef(r.cteId, 
anchor.output.map(_.newInstance().withNullability(true)), false)
+        val output = anchor.output.zip(types).map { case (a, dt) =>
+          a.newInstance().withNullability(true).withDataType(dt)
+        }
+        val ref = UnionLoopRef(r.cteId, output, false)
         columnNames.map(UnresolvedSubqueryColumnAliases(_, ref)).getOrElse(ref)
     }
   }
 
+  // The anchor's types, unless widenSelfReference has found wider ones.
+  private def selfReferenceTypes(cteDef: CTERelationDef, anchor: LogicalPlan): 
Seq[DataType] =
+    
cteDef.getTagValue(SELF_REFERENCE_TYPES).getOrElse(anchor.output.map(_.dataType))
+
+  // A recursion can write NULLs into nested types that the self-reference 
declares non-nullable,
+  // e.g. ARRAY(CAST(NULL AS INT)) for an ARRAY(1) anchor. Once the loop is 
resolved, widens the
+  // nullability of such self-reference types to the anchor's and recursion's 
and rebuilds the
+  // loop from the original definition. This repeats until no type widens.
+  private def widenSelfReference(cteDef: CTERelationDef): CTERelationDef = {
+    val loop = if (cteDef.hasSelfReferenceAsUnionLoopRef) {
+      cteDef.child.collectFirst { case l: UnionLoop if l.id == cteDef.id => l }
+    } else {
+      None
+    }
+    loop.filter(_.resolved) match {
+      case Some(l) =>
+        val refTypes = cteDef.child.collectFirstWithSubqueries {
+          case r: UnionLoopRef if r.loopId == cteDef.id => 
r.output.map(_.dataType)
+        }.get
+        val children = Seq(l.anchor, l.recursion).map(withUdfNullHandling)
+        val widened = refTypes.zipWithIndex.map { case (dt, i) =>
+          children.foldLeft(dt)((t, c) => StructType.unionLikeMerge(t, 
c.output(i).dataType))

Review Comment:
   done



##########
sql/core/src/test/scala/org/apache/spark/sql/UDFSuite.scala:
##########
@@ -480,6 +480,42 @@ class UDFSuite extends SharedSparkSession {
     }
   }
 
+  test("SPARK-60875: NULLs from a primitive Scala UDF in a recursive CTE are 
read back as NULL") {
+    spark.udf.register("f", (x: Long) => x + 1)
+    // Steps of 200 rows are materialized, so the recursion reads them through 
generated code.
+    def nullsPerLevel(anchor: String, recursion: String): DataFrame = sql(
+      s"""WITH RECURSIVE t(lvl, arr, was_null) AS (
+         |  $anchor
+         |  UNION ALL
+         |  $recursion
+         |)
+         |SELECT lvl, count_if(was_null) FROM t GROUP BY lvl ORDER BY 
lvl""".stripMargin)
+
+    // The anchor writes the NULLs.
+    checkAnswer(
+      nullsPerLevel(
+        "SELECT 0, ARRAY(f(IF(id % 2 = 0, CAST(NULL AS BIGINT), id))), false 
FROM range(200)",
+        "SELECT lvl + 1, ARRAY(arr[0] + 1), arr[0] IS NULL FROM t WHERE lvl < 
1"),

Review Comment:
   done



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