Visitar URL original
[SPARK-60038][SQL] Merge nested nullability of all values in UNPIVOT value columns by mihailoale-db · Pull Request #59271 · apache/spark · GitHub
Skip to content

[SPARK-60038][SQL] Merge nested nullability of all values in UNPIVOT value columns - #59271

Open
mihailoale-db wants to merge 4 commits into
apache:masterfrom
mihailoale-db:unpivot-nested-nullability
Open

mihailoale-db wants to merge 4 commits into
apache:masterfrom
mihailoale-db:unpivot-nested-nullability

Conversation

@mihailoale-db

@mihailoale-db mihailoale-db commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Unpivot.valuesTypeCoercioned decides whether UnpivotCoercion still has to cast the unpivoted values to a common type. It compared the value types with DataTypeUtils.sameType, which ignores nullability. When the values at a position differed only in nested
nullability (array containsNull, map valueContainsNull, struct field nullable), the check treated them as already coerced, UnpivotCoercion did not run, and UnpivotTransformer typed the value column with the first value's data type
(values.head(index).dataType). Only top-level nullability was merged across the values.

This PR compares the value types with exact equality. UnpivotCoercion then widens them with findWiderTypeWithoutStringPromotion, which merges nested nullability, and casts the narrower values:

Expand [[store#x, h1, cast(h1#x as struct<lo:int,hi:int>)], [store#x, h2, h2#x]], [store#x, half#x, b#x]

This matches the single-pass UnpivotResolver, which already applies UnpivotTypeCoercion unconditionally. Value types that are already identical, the common case, are unaffected.

Why are the changes needed?

To fix the correctness issue.

Does this PR introduce any user-facing change?

Yes, it changes the end result.

How was this patch tested?

Added tests.

Was this patch authored or co-authored using generative AI tooling?

Yes.

@mihailoale-db mihailoale-db changed the title [SPARK-XXXXX][SQL] Merge nested nullability of all values in UNPIVOT value columns [SPARK-60038][SQL] Merge nested nullability of all values in UNPIVOT value columns Oct 7, 2026

@HyukjinKwon HyukjinKwon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The core fix looks correct. After UnpivotTypeCoercion runs, every value at a position has exactly the wider type from findWiderTypeWithoutStringPromotion, so the stricter predicate converges in one pass. No well-formed input that the old sameType check accepted now fails analysis, and the fixed-point and single-pass analyzers now agree. The new tests assert the merged nested types and the IS NULL results, and both would fail under the old behavior.

One minor regression: malformed multi-value UNPIVOT queries whose groups differ only in nested nullability now reach the unguarded coercion rule and fail with an internal IndexOutOfBoundsException instead of UNPIVOT_VALUE_SIZE_MISMATCH (see the inline comment). A size guard in UnpivotCoercion would restore the clear error.

A side effect I looked at and do not think needs action: exact equality also distinguishes nested field metadata and field-name case, so such values now get a cast and lose nested field metadata. This matches what the single-pass resolver already does and is low impact.

Findings

1 total: 0 P0, 0 P1, 0 P2, 1 P3.

Nit (P3)

  • Ragged multi-value UNPIVOT now hits an IndexOutOfBoundsException instead of UNPIVOT_VALUE_SIZE_MISMATCH — sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala:1770 — see inline.

Decision challenges

Should the upgrade note also cover streaming checkpoints, or add a legacy flag?

The migration note covers persistent views that recorded the old non-nullable nested type. The same type change can also break stateful streaming queries restarted from a checkpoint written by an earlier version. If an unpivoted value column such as struct<lo:int NOT NULL, hi:int NOT NULL> is part of a state key or value (for example groupBy on it, or dropDuplicates), the stored schema keeps the non-nullable nested fields while the new analyzed type is struct<lo:int, hi:int>. StateSchemaCompatibilityChecker.check calls DataType.equalsIgnoreNameAndCompatibleNullability(newSchema, storedSchema), which rejects a nullable new field against a non-nullable stored one, so the restart fails with STATE_STORE_KEY_SCHEMA_NOT_COMPATIBLE. The preconditions are narrow, but recovering means discarding state. Would you mention streaming checkpoints in the migration note as well? Alternatively, is a spark.sql.legacy.* flag warranted, like the escape hatches in the neighboring 4.4 entries, or is the correctness fix intended to land without one?

Generated by Omnigent on Databricks.

mihailoale-db and others added 2 commits October 9, 2026 09:56
…value columns

`Unpivot.valuesTypeCoercioned` compared the value types with `DataTypeUtils.sameType`,
which ignores nullability. When the values at a position differed only in nested
nullability, `UnpivotCoercion` did not run and the value column took the nested
nullability of the first value, so NULL array elements, map values, or struct fields of
later values were read as non-NULL. Compare the types exactly so that `UnpivotCoercion`
casts the values to their wider type, as the single-pass `UnpivotResolver` already does.

Co-authored-by: Isaac <no-reply@databricks.com>
…d a legacy flag

- Skip UNPIVOT type coercion unless every value group has one value per value
  column, so CheckAnalysis reports UNPIVOT_VALUE_SIZE_MISMATCH instead of an
  internal IndexOutOfBoundsException.
- Add spark.sql.legacy.unpivot.useFirstValueNestedNullability to restore the
  previous behavior of taking the nested nullability of the first value.

Co-authored-by: Isaac <no-reply@databricks.com>
@mihailoale-db
mihailoale-db force-pushed the unpivot-nested-nullability branch from 1771694 to 8d7ba56 Compare October 9, 2026 09:57
@mihailoale-db

Copy link
Copy Markdown
Contributor Author

@HyukjinKwon Addressed your comment. PTAL when you find time. Thanks!

mihailoale-db and others added 2 commits October 9, 2026 10:24
Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>

@cloud-fan cloud-fan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The malformed multi-value case raised in the earlier review is addressed by the equal-width coercion guard and its order/type regression cases. The current revision still has three independent issues: the predicate comment overstates the legacy branch's type contract, nested-nullability coercion drops StructField metadata, and the newly retained complex casts perform payload-sized copies for a logical nullability widening.

Cross-cutting observations

  • The exact-equality change exposes two independent assumptions in the existing generic coercion stack: common-type construction discards nested metadata, and nullability-only Cast evaluation rebuilds unchanged complex payloads.
    Separation: The shared trigger does not establish shared ownership: metadata is determined when the target type is built, while allocation is determined when Cast evaluates that already-built type, and each repair has an independent success condition.

Findings

3 total: 0 P0, 0 P1, 2 P2, 1 P3.

Non-blocking (P2)

  • UNPIVOT drops nested field metadata while widening nullability — sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala:1776 — see inline.
  • Nullability-only casts rebuild complex values — sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala:1776 — see inline.

Nit (P3)

  • Legacy mode contradicts the exact-type comment — sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala:1770 — see inline.

Existing discussions

def valuesTypeCoercioned: Boolean = canBeCoercioned &&
// all inner values at position idx must have the same data type
def valuesTypeCoercioned: Boolean = canBeCoercioned && {
// all inner values at position idx must have exactly the same data type, including nested

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (P3): This comment describes exact equality as an unconditional invariant, but the legacy branch below deliberately uses DataTypeUtils.sameType, which ignores nested nullability. In that mode valuesTypeCoercioned can therefore be true for types that are not exactly equal, so the current wording gives maintainers the wrong contract.

Verification:

  • Inspection: The predicate documentation accurately states both configured comparison modes and does not imply that the legacy sameType result is exact equality.

if (conf.getConf(SQLConf.LEGACY_UNPIVOT_USE_FIRST_VALUE_NESTED_NULLABILITY)) {
DataTypeUtils.sameType
} else {
_ == _

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (P2): For struct values that differ only in nested nullability, this comparison now activates common-type coercion. findTypeForComplex rebuilds the target StructFields through the metadata-free add overload, and UnpivotTransformer exposes that rebuilt type, so nested comments and custom metadata from the first value are silently lost even though the field contents are unchanged.

Verification:

  • Regression: Dataset.unpivot over metadata-bearing struct columns preserves the selected nested metadata while widening nullable fields, including both value orders.
  • Behavior: Struct, array, and map nested-nullability results and NULL values remain correct in default mode.
  • Compatibility: The legacy configuration continues to derive nested nullability from the first value.

if (conf.getConf(SQLConf.LEGACY_UNPIVOT_USE_FIRST_VALUE_NESTED_NULLABILITY)) {
DataTypeUtils.sameType
} else {
_ == _

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (P2): This also inserts runtime Casts when only nested nullability differs. SimplifyCasts leaves struct and deeper collection cases intact, and Cast's identity check still compares nullability, so every affected Expand branch rebuilds the full complex value per row. For wide structs or large nested collections that adds payload-linear CPU and allocation even though the physical representation and contents are unchanged.

Recommended change: Treat casts whose source and target complex types are structurally identical when nullability is ignored as representation-preserving in both interpreted evaluation and code generation, while retaining the Cast node and its widened target DataType; add expression-level coverage for structs and recursively nested arrays and maps.

Why this works: Extend Cast's structural identity test to ignore nullability for physical evaluation. The interpreted cast function returns the input complex value, and the code-generated path emits the child expression directly, while the unresolved and analyzed Cast expression continues to advertise the wider target type to UnpivotTransformer and downstream consumers.

Scope: Keep nullability-only Casts as logical type declarations but make their physical evaluation an identity operation.

Compatibility: Default UNPIVOT continues to merge nested nullability and return nested NULL values correctly, and the legacy switch continues to preserve first-value nested nullability.

Risks: The Cast-level optimization applies beyond UNPIVOT, so coverage must confirm that forwarding the physical value is safe for every complex container shape and for both interpreted and generated execution. A too-broad structural predicate could suppress a real nested conversion or an ANSI/try-cast failure.

Constraints: Apply the identity path only when source and target are structurally equal after ignoring nullability; field, element, key, or value type conversions must still execute. Preserve the target DataType and nullable contract even when evaluation forwards the original physical value. Keep legacy, ANSI, and try-cast error behavior unchanged for conversions that can fail.

Success: Nullability-only complex casts retain their target logical DataType without rebuilding InternalRow, ArrayData, or MapData values. Wide-struct and deep-collection UNPIVOT plans no longer add payload-linear copy work solely to widen nested nullability. Casts that change an actual nested data type continue to perform conversion and preserve existing failure semantics.

Verification:

  • Regression: Nullability-only casts of structs and recursively nested arrays and maps evaluate without allocating replacement complex containers in both interpreted and generated paths.
  • Behavior: The forwarded values remain readable under the widened target schema and retain correct NULL contents.
  • Compatibility: Representative complex casts with real nested type conversions and failure cases continue to convert or fail exactly as before.

@cloud-fan

Copy link
Copy Markdown
Contributor

Thanks - the equal-width guard and the new regression cases address the malformed multi-value UNPIVOT error I raised. I have completed another pass on the current revision.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants