Skip to content

[SPARK-54621][SQL] Merge Into Update Set * preserve nested fields if …coerceNestedTypes is enabled - #53360

Closed
szehon-ho wants to merge 6 commits into
apache:masterfrom
szehon-ho:SPARK-54621
Closed

szehon-ho wants to merge 6 commits into
apache:masterfrom
szehon-ho:SPARK-54621

Conversation

@szehon-ho

@szehon-ho szehon-ho commented Dec 6, 2025 •

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

The 'struct coercion' feature for MERGE INTO (allowing it to pass if assigning a struct with less fields into a struct with more fields) is turned off in a flag in #53229 due to some ambiguity in behavior, but was not removed because the community wanted to try it.

We want to still keep it under a flag, but we make a choice about which behavior to support when the flag is on. In particular, we want UPDATE SET * to explode to all nested struct fields, so that in this scenario, existing nested struct fields are preserved.

Why are the changes needed?

@aokolnychyi tested the feature and thinks that even if it is behind the experimental flag, we should take the stance for now that UPDATE SET * should explode to all nested fields vs top level columns.

The rationale being:

  • its always safer to not override user values with null
  • Spark in general tries to treat nested fields like columns
  • there's already a way for the user to override the whole struct (and nullify non-existing fields) by specifying the struct explicitly, ie UPDATE SET struct = source.struct

Does this PR introduce any user-facing change?

No, the whole feature is new and hidden behind an experimental flag.

How was this patch tested?

Existing tests (some output changes to not be null)

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

No

@github-actions github-actions Bot added the SQL label Dec 6, 2025
@szehon-ho

szehon-ho commented Dec 6, 2025 •

Copy link
Copy Markdown
Member Author

Hi @dongjoon-hyun sorry for the back and forth here. As in the description, @aokolnychyi explained he preferred to make the behavior choose UPDATE SET * to refer to nested fields, due to the reasons above. The whole feature (MERGE INTO struct coercion) is still under an experimental flag and off by default, but we want to make this stance if the flag is on.

@szehon-ho

Copy link
Copy Markdown
Member Author

Btw, The code is not new code, its the same code in #53149 which was removed, it is just brought back.

@dongjoon-hyun

Copy link
Copy Markdown
Member

No problem, but I believe this is only applicable for master branch only, @szehon-ho .

@dongjoon-hyun

dongjoon-hyun commented Dec 6, 2025 •

Copy link
Copy Markdown
Member

So, +1 for 4.2.0 for the proposal although I didn't take a look at the code yet.

@szehon-ho

szehon-ho commented Dec 6, 2025 •

Copy link
Copy Markdown
Member Author

Hi, @dongjoon-hyun , @aokolnychyi mentioned it would be good to get into 4.1, because we are still releasing the feature of 'struct coercion' , albeit with a flag. So he wanted to start it off with the better choice. Code-wise its the same as before the revert, although the whole thing has a flag. Seems from the comments of #53229, the community is interested in this feature. Ill ping him to comment as well

@dongjoon-hyun

Copy link
Copy Markdown
Member

Sorry but I still believe this fits for Apache Spark 4.2.0 (after checking the code again). This is only for Apache Spark 4.2.0, @szehon-ho . We are ramping down instead of ramping up.

}
}

private def applyNestedFieldAssignments(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

note: this is like applyFieldAssignment above, but recurses to all nested fields

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.

applyFieldAssignments is not recursive? what's its behavior?

@szehon-ho szehon-ho Dec 12, 2025 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

iiuc, it is not , it just looks at missing assignments for the first level of schema and does some validation (like no two assignments to same field), and then uses TableOutputResolver to fill missing ones.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i just chatted with @aokolnychyi on the difference, and wanted to clarify here as well. I mean, the existing method applyFieldAssignments is obviously recursive, but it's goal is not to make assignment for every nested field, but just missing siblings at the existing assignment levels. For example, we have update x.y.z = source.x.y.z. It will recurse down to evaluate x.y struct, see the assignment for x.y.z. It will fill missing assignments for z's siblings, ie if there was x.y.z1 and x.y.z2 it will fill those

The new method on the other hand 'explodes' an assignment for the top level struct x (if its from UPDATE SET *) into assignment for every single field, ie not only the missing siblings of existing assignments. Hope that is more clear on the difference.

@cloud-fan

Copy link
Copy Markdown
Contributor

Does this change anything when MERGE_INTO_NESTED_TYPE_COERCION_ENABLED is false?

@szehon-ho

Copy link
Copy Markdown
Member Author

Does this change anything when MERGE_INTO_NESTED_TYPE_COERCION_ENABLED is false?

Yea, it should not, that should be the guard for the whole feature (nested type coercion)

@aokolnychyi

Copy link
Copy Markdown
Contributor

This PR actually fixes an issue I discovered while testing MERGE with 4.1 RC in Iceberg. I believe the current logic in Spark 4.1 is a regression and leads to data loss (we replace existing nested fields with nulls, for instance). Spark 4.0 was a lot stricter than 4.1 and some of the 4.1 behavior is invalid.

@szehon-ho

szehon-ho commented Dec 14, 2025 •

Copy link
Copy Markdown
Member Author

Yes after chatting with @aokolnychyi this can be serious as it cause inadvertent data loss. For instance, previously the user doing MERGE has a source struct s with just one nested field, and the target struct s with 100 nested fields. The user doing UPDATE SET * in 4.0 will get a failure, and they will realize their mistake. But now that we relax it in 4.1, a user doing UPDATE SET * with schema evolution will lose the 99 fields not in source struct s (become null). We should instead retain their value.

// As StoreAssignmentPolicy.LEGACY is not allowed in DSv2, always add null check for
// non-nullable column
if (!key.nullable) {
AssertNotNull(value)

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.

This is useless. If we really want to do this optimization, we should add the AssertNotNull before we use the value to construct GetStructField.

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.

And we can do this optimization in the else branch of the If expression as well.

@szehon-ho szehon-ho Dec 15, 2025 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I tried this change, i remember doing this change initially, but it seems its' a must to skip the IF (...) NULL for non-nullable fields.

In this line below,

it checks that the assignment value is non-nullable just like the key is. If there is a IF (...) NULL (even if it never hits), then it returns false, and analyzer runs forever.

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.

ok, then we should give up this optimization for now and return structExpression here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yea maybe we can fix the condition later, possibly? can discuss with @aokolnychyi

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I may have mis-understood your message. I also removed the AssertNotNull

case Some(matchingField) =>
// Found matching field in source, extract it
val fieldIndex = valueStructType.fieldIndex(matchingField.name)
GetStructField(value, fieldIndex, Some(matchingField.name))

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.

See https://github.com/apache/spark/pull/53360/files#r2617734660

This is where we can wrap value with AssertNotNull

@dongjoon-hyun dongjoon-hyun 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.

I'll blocking this PR because we have a discussion the RC3 thread.

As I made a decision last week on this PR, I'm still very relunctant to have this code in 4.1.0, @szehon-ho , @cloud-fan , @aokolnychyi .

Please don't land this to branch-4.1..

@dongjoon-hyun

Copy link
Copy Markdown
Member

I resolved my previous review comment. Thank you, @szehon-ho .

@cloud-fan

Copy link
Copy Markdown
Contributor

thanks, merging to master/4.1!

@cloud-fan cloud-fan closed this in 92e5b36 Dec 18, 2025
cloud-fan pushed a commit that referenced this pull request Dec 18, 2025
……coerceNestedTypes is enabled

### What changes were proposed in this pull request?
The 'struct coercion' feature for MERGE INTO (allowing it to pass if assigning a struct with less fields into a struct with more fields) is turned off in a flag in #53229 due to some ambiguity in behavior, but was not removed because the community wanted to try it.

We want to still keep it under a flag, but we make a choice about which behavior to support when the flag is on.  In particular, we want UPDATE SET * to explode to all nested struct fields, so that in this scenario, existing nested struct fields are preserved.

### Why are the changes needed?

aokolnychyi tested the feature and thinks that even if it is behind the experimental flag, we should take the stance for now that UPDATE SET * should explode to all nested fields vs top level columns.

The rationale being:
* its always safer to not override user values with null
* Spark in general tries to treat nested fields like columns
* there's already a way for the user to override the whole struct (and nullify non-existing fields) by specifying the struct explicitly, ie UPDATE SET struct = source.struct

### Does this PR introduce _any_ user-facing change?
No, the whole feature is new and hidden behind an experimental flag.

### How was this patch tested?
Existing tests (some output changes to not be null)

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

Closes #53360 from szehon-ho/SPARK-54621.

Authored-by: Szehon Ho <szehon.apache@gmail.com>
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
(cherry picked from commit 92e5b36)
Signed-off-by: Wenchen Fan <wenchen@databricks.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants