spark-test commented on code in PR #60061:
URL: https://github.com/apache/spark/pull/60061#discussion_r4471355397
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveWithCTE.scala:
##########
@@ -303,22 +322,52 @@ 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 recursion's and rebuilds
the loop from the
+ // original definition. This repeats until no type widens.
+ private def widenSelfReference(cteDef: CTERelationDef): CTERelationDef = {
+ val loop = cteDef.child.collectFirst { case l: UnionLoop if l.id ==
cteDef.id => l }
+ loop.filter(_.resolved).flatMap(l => l.getTagValue(LOOP_ORIGIN).map(l ->
_)) match {
Review Comment:
**Non-blocking (P2):** `LOOP_ORIGIN` carries the recorded types and the
rebuild source between Resolution-batch iterations, and when it is missing the
`None` branch below returns the definition unchanged. Tags survive only through
the `copyTagsFrom` calls in the transform helpers, so a rule that rebuilds the
`UnionLoop` with a case-class copy drops them. `LimitPushDown` and
`InlineCTE.setUnlimitedRecursion` already rebuild this node that way, although
they run after analysis. No Resolution-batch rule in Spark does this today, but
nothing prevents one, for example a rule injected through
`injectResolutionRule`. If one does, NULL-writing recursions read the NULLs
back as 0 again with no error. In the coercion case the base branch failed with
`CANNOT_MERGE_INCOMPATIBLE_DATA_TYPE`, so tag loss would turn that explicit
error into a silent wrong result.
Reading the current types from the loop's own `UnionLoopRef` output, and
raising an internal error when the loop needs wider types but the rebuild
source is missing, would make this fail closed.
**Verification:**
- **Regression:** Analyzing a recursive CTE whose resolved loop has lost its
rebuild source, while its recursion is wider than its self-reference, fails
with an internal error. It must not produce a plan whose self-reference has the
narrower types.
- **Behavior:** Existing widening cases keep the same self-reference and
output types: a pass-through keeps the anchor's types, and a column that copies
a widened column widens on the second round.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveWithCTE.scala:
##########
@@ -303,22 +322,52 @@ 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 recursion's and rebuilds
the loop from the
+ // original definition. This repeats until no type widens.
+ private def widenSelfReference(cteDef: CTERelationDef): CTERelationDef = {
+ val loop = cteDef.child.collectFirst { case l: UnionLoop if l.id ==
cteDef.id => l }
+ loop.filter(_.resolved).flatMap(l => l.getTagValue(LOOP_ORIGIN).map(l ->
_)) match {
+ case Some((l, (refTypes, child))) =>
+ val widened = refTypes.zip(l.recursion.output).map { case (dt, a) =>
Review Comment:
**Non-blocking (P2):** This comparison runs only in the Resolution batch,
but the later "ScalaUDF Null Handling" batch can still raise the nested
nullability of both loop children. A Scala UDF with primitive types is
non-nullable during resolution (its nullability comes from the output encoder),
and `HandleNullInputsForUDF` only later wraps it in `If(IsNull(input), null,
udf)`. `CreateArray` then re-derives `containsNull = true`, while the
`UnionLoopRef` keeps the types recorded here, and `UpdateAttributeNullability`
skips leaf nodes.
For example, with `spark.udf.register("f", (x: Long) => x + 1)`:
```sql
WITH RECURSIVE t(lvl, arr) AS (
SELECT 0, ARRAY(id) FROM range(200)
UNION ALL
SELECT lvl + 1, ARRAY(f(IF(lvl = 0, CAST(NULL AS BIGINT), arr[0]))) FROM t
WHERE lvl < 2
)
SELECT * FROM t
```
Level 1 writes `[null]`. Level 2 reads `arr[0]` through the narrower
self-reference, and `GetArrayItem` codegen skips `isNullAt` when `containsNull`
is false, so it gets `[1]` instead of `[null]`. A NULL written by a UDF in the
anchor is read back the same way. Small steps that are folded into a local
relation use interpreted evaluation, which still checks `isNullAt`.
Because the analyzed loop then no longer matches the recorded types, a later
analysis of the same plan restores the stored definition from `LOOP_ORIGIN`.
That happens on the deduplicated side of `df.join(df)`, so the two sides of
such a self-join can return different rows. This is traced from the source; it
was not run.
**Recommended change:** Make the self-reference widening see the nullability
that Scala UDF null handling adds later in analysis. Move
HandleNullInputsForUDF out of the Analyzer class into a standalone rule object
in the same package, and keep the 'ScalaUDF Null Handling' batch using it. In
widenSelfReference, when the loop's anchor or recursion contains a Scala UDF,
run HandleNullInputsForUDF and UpdateAttributeNullability on it until neither
changes it, as that batch does. Merge each self-reference type with the
resulting anchor and recursion output types. This is a pure transform of copies
that leaves the live plan unchanged. Add recursive CTE tests in which a
primitive Scala UDF returns NULL inside an array, once in the anchor and once
in the recursion, and a later iteration reads it back.
**Why this works:** The rebuild already runs in the Resolution batch, where
the recursion can be re-resolved. Computing the merge from null-handled copies
of the anchor and recursion makes the recorded self-reference types include the
element, value and field nullability that the later batch introduces. The final
self-reference then covers everything written into it, and generated code keeps
its null checks. Because the analyzed loop then matches its recorded types, a
later analysis of the same plan no longer restores the stored definition.
**Scope:** Expose the UDF null-handling rule for reuse, apply the UDF
null-handling batch to copies of the anchor and recursion when ResolveWithCTE
computes the widened self-reference types, and add end-to-end coverage with
primitive Scala UDFs.
**Compatibility:** The analyzer still applies UDF null handling exactly once
to the real plan, and a recursion that only passes a column through keeps the
anchor's nested types.
**Risks:** Columns that copy a UDF-fed nested column through the
self-reference will now widen their output types, which adds to the
persisted-view schema change raised separately in this review. Reverting the
UDF-aware merge rolls this back. Each widening check now runs the null-handling
rules on copies of the resolved anchor and recursion when they contain Scala
UDFs. Only recursive CTEs with such UDFs pay this extra analysis work, which
grows with their size. Extension post-hoc resolution rules that raise nested
nullability after Resolution stay uncovered, because the widening cannot see
changes made after that batch.
**Constraints:** The live plan must not be rewritten through the copies; the
Analyzer batch still applies UDF null handling exactly once. The merge must
keep only OR-ing nullability, so the widening still terminates. The copies must
go through both rules until neither changes them. A UDF fed by another UDF's
output becomes nullable only after UpdateAttributeNullability updates the
reference between them.
**Exclusions:**
- Outside recursive CTE self-references, nested nullability that UDF null
handling adds is still not propagated to attribute references above the wrapped
UDF, including the main query's reference to the CTE output. That is a general
analyzer behavior and stays outside this change.
**Success:** When the anchor or the recursion builds an array from a
primitive Scala UDF that returns NULL for a NULL input, the self-reference
declares nullable elements, and a later iteration that reads the element gets
NULL rather than 0. After analysis, merging the recorded self-reference types
with the loop's anchor and recursion output types changes nothing, so a later
analysis of the same plan restores no stored definition. Recursions without
Scala UDFs keep exactly the self-reference types this change already produces.
**Verification:**
- **Regression:** Recursive CTEs that wrap a primitive Scala UDF (Long =>
Long) in ARRAY(...) and pass it a NULL return NULL elements at every later
level that reads them, on the materialized-step path with code generation.
Cover the UDF in the anchor, in the recursion, and fed by another UDF's output
in a lower projection.
- **Regression:** A self-join of such a recursive CTE's DataFrame returns
the same values for the CTE on both sides.
- **Behavior:** For those queries, the analyzed self-reference declares a
nullable element type, and recursions without UDFs keep the same analyzed
self-reference and output types.
- **Compatibility:** Queries that use Scala UDFs outside recursive CTEs
analyze and run exactly as before, with UDF null handling applied once.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveWithCTE.scala:
##########
@@ -94,18 +105,20 @@ object ResolveWithCTE extends Rule[LogicalPlan] {
if (!anchor.resolved) {
cteDef
} else {
+ val refTypes = selfReferenceTypes(cteDef, anchor)
// We need to check whether any of the inner CTEs has a self
reference and replace
// it if needed
val newInnerCteDefs = innerCteDefs.map { innerCteDef =>
innerCteDef.copy(child = rewriteRecursiveCTERefs(
- innerCteDef.child, anchor, cteDef.id, None))
+ innerCteDef.child, anchor, cteDef.id, None,
Some(refTypes)))
}
val loop = UnionLoop(
cteDef.id,
anchor,
- rewriteRecursiveCTERefs(recursion, anchor, cteDef.id,
None),
+ rewriteRecursiveCTERefs(recursion, anchor, cteDef.id,
None, Some(refTypes)),
Review Comment:
**Nit (P3):** This arm, and the column-list variant further down, now pass
`refTypes` to the recursion's `UnionLoopRef`, but no test reaches either one
with nested types. The new suite cases build only `SubqueryAlias(Union)`, the
new `cte-recursion.sql` queries use only the column-list shape without an inner
`WITH`, and the existing inner-`WITH` recursive queries use INT columns only.
If either arm went back to the anchor's types, NULLs would read back as 0 again
and CI would stay green.
A `cte-recursion.sql` case like the following, with and without the column
list, would cover them (regenerate the golden files rather than editing them):
```sql
WITH RECURSIVE t(lvl, arr) AS (
WITH x AS (SELECT 1 AS one)
SELECT 0, ARRAY(one) FROM x
UNION ALL
SELECT lvl + 1, IF(lvl = 0, ARRAY(CAST(NULL AS INT)), ARRAY(arr[0] + 1))
FROM t WHERE lvl < 2
)
SELECT * FROM t
```
**Verification:**
- **Regression:** Take a recursive CTE whose definition begins with an inner
WITH, with and without a column list, run on the materialized-step path. A NULL
written into an array element at one level reads back as NULL at the next
level, and the case fails if that arm uses the anchor's types for its
self-reference.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveWithCTE.scala:
##########
@@ -303,22 +322,52 @@ 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 recursion's and rebuilds
the loop from the
+ // original definition. This repeats until no type widens.
+ private def widenSelfReference(cteDef: CTERelationDef): CTERelationDef = {
+ val loop = cteDef.child.collectFirst { case l: UnionLoop if l.id ==
cteDef.id => l }
Review Comment:
**Nit (P3):** This `collectFirst` walks the whole definition without pruning
(`UnionLoop` has no tree pattern). It runs for every definition whenever the
rule visits an unanalyzed `WithCTE`: on every Resolution iteration, and again
for nested `WITH`s, because `WithCTE.children` lists the definitions first. For
non-recursive CTEs, the most common case, it can never match, while the base
branch spent O(1) per stable definition through cached lazy vals. Each visit is
cheap, but the cost scales with definition size times iterations for every CTE
query. Skipping definitions whose cached `hasSelfReferenceAsUnionLoopRef` is
false would give the same result without the repeated walk.
**Verification:**
- **Behavior:** Every recursive CTE widening case (plain, column list, inner
WITH, two-round) keeps the same self-reference and output types.
- **Inspection:** The loop search runs only for definitions whose cached
self-reference check finds a UnionLoopRef. That check runs once per definition
instance, so an unchanged definition without one is not walked again on later
passes.
--
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]