Skip to content

[SPARK-59147][SQL] Preserve struct nullness when SELECT * EXCEPT drops a nested field - #58477

Open
Vivek1106-04 wants to merge 2 commits into
apache:masterfrom
Vivek1106-04:SPARK-59147-except-null-struct
Open

Vivek1106-04 wants to merge 2 commits into
apache:masterfrom
Vivek1106-04:SPARK-59147-except-null-struct

Conversation

@Vivek1106-04

Copy link
Copy Markdown

What changes were proposed in this pull request?

UnresolvedStarExceptOrReplace.filterColumns rewrites a struct column when the EXCEPT list
names a nested field: it extracts the retained fields and wraps them back into a
CreateStruct. CreateStruct resolves to CreateNamedStruct, which is never nullable, so
the rebuilt struct is always non-NULL even when the original struct expression evaluated to
NULL.

This PR guards the reconstruction with If(IsNull(col), Literal(null, dataType), ...) when
the original column is nullable, the same way UpdateFields.evalExpr already does for
DropField.

Why are the changes needed?

Dropping a nested field silently turns a NULL struct into a non-NULL struct whose remaining
fields are NULL. This is a silent correctness bug: nullable structs commonly come from
parsed JSON and from the null-producing side of outer joins, and afterwards IS NULL
checks, filters, joins, COALESCE and serialized output all behave differently.

WITH input AS (
  SELECT id,
    CASE
      WHEN id = 0 THEN CAST(NULL AS STRUCT<a: INT, b: INT>)
      ELSE named_struct('a', id, 'b', id + 10)
    END AS s
  FROM VALUES (0), (1) AS t(id)
),
actual AS (
  SELECT * EXCEPT (s.a) FROM input
)
SELECT i.id, i.s IS NULL AS before_except, a.s IS NULL AS after_except, a.s.b
FROM input i JOIN actual a USING (id) ORDER BY id;

Before:

0  true   false  NULL
1  false  false  11

After:

0  true   true   NULL
1  false  false  11

The same path backs the pipe |> DROP <col>.<field> operator, which had the same problem.

Does this PR introduce any user-facing change?

Yes. SELECT * EXCEPT (col.field) (and |> DROP col.field) now returns NULL for a row whose
col is NULL, instead of a struct of NULL fields. The result type is unchanged. This
corrects wrong results; there is no behavior change for non-nullable structs or for rows
whose struct is not NULL.

How was this patch tested?

Added cases to selectExcept.sql covering a NULL top-level struct, a NULL nested struct, and
struct expansion (SELECT data.* EXCEPT (s2.c)), and regenerated the golden files. The new
cases fail on master and pass with the fix. pipe-operators.sql analyzer results were
regenerated to reflect the added null guard; its query results are unchanged.

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

Generated-by: Claude Code (Claude Opus 5)

…s a nested field

Nested-field `SELECT * EXCEPT (col.field)` rebuilds the enclosing struct with
`CreateStruct`, which is never nullable. A NULL struct was therefore rewritten
into a non-NULL struct whose remaining fields are NULL, silently changing query
results for `IS NULL` checks, filters, joins and serialized output.

Guard the reconstruction with `If(IsNull(col), Literal(null, dataType), ...)`
when the original column is nullable, matching what `UpdateFields.evalExpr`
already does for `DropField`. The pipe `|> DROP <col>.<field>` operator shares
this code path and is fixed as well.

@tdcmeehan tdcmeehan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM, thanks!

@uros-b uros-b 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.

The fix seems correct, minimal, and well-tested to me. Thank you @Vivek1106-04
Please ping @viirya and/or @cloud-fan for additional review!

@Vivek1106-04

Copy link
Copy Markdown
Author

@viirya ! and @cloud-fan - requesting for a review. Thank You

@uros-b
uros-b requested a review from viirya September 8, 2026 09:14

@viirya viirya 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.

The null-preserving fix makes sense when some fields remain. Could we clarify the intended behavior when all fields are excluded?

For s of type STRUCT<a: INT, b: INT>, SELECT * EXCEPT (s.a, s.b) currently produces an empty struct even when s is NULL. This patch changes that result to NULL, while the documentation says excluding all fields produces an empty struct.

I think preserving the empty-struct result is also reasonable: with no fields remaining, the result can be constructed without knowing the original value. Could we keep that behavior and apply the null guard only when fields remain? If changing this behavior is intentional, could we discuss the semantics explicitly and add documentation and tests covering both NULL and non-NULL inputs?

…ry field

Address review feedback: apply the NULL guard only when fields remain.

Excluding every field of a STRUCT produces an empty STRUCT that carries no
information from the input, so it can be built without reading the original
value. Keep that documented behavior for a NULL input instead of rewriting it
to NULL, and restrict the guard to the case where some fields remain.

Note that UpdateFields never reaches this case: it rejects dropping all fields
with CANNOT_DROP_ALL_FIELDS, so it offers no precedent here.

Document both rules and cover the all-excluded case for NULL and non-NULL
inputs, including a nested struct whose every field is excluded.
@Vivek1106-04

Copy link
Copy Markdown
Author

Thanks @viirya — agreed, changed the patch accordingly.

You are right that the UpdateFields analogy in my comment does not cover this case: UpdateFields rejects dropping all fields with CANNOT_DROP_ALL_FIELDS, so it never produces an empty struct and offers no precedent. And the reasoning holds — with no fields remaining the result is a constant that does not depend on the input value, so there is no information to lose.

The latest commit applies the null guard only when fields remain:

  • SELECT * EXCEPT (s.a) on a NULL s → NULL (the fix).
  • SELECT * EXCEPT (s.a, s.b){} for both NULL and non-NULL s (unchanged, matches the docs).

Docs now state both rules explicitly, and selectExcept.sql covers the all-excluded case for NULL and non-NULL inputs, plus a nested struct with every field excluded (outer struct still NULL-preserving, inner one empty). The pipe-operators golden file for drop col.i1, col.i2 goes back to its original output.

@Vivek1106-04
Vivek1106-04 requested a review from viirya September 11, 2026 04:29
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.

4 participants