Skip to content

fix: fill schema-added nested columns with typed NULL arrays on read - #2635

Merged
laskoviymishka merged 7 commits into
apache:mainfrom
viirya:fix/2618-null-fill-nested-columns
Oct 6, 2026
Merged

laskoviymishka merged 7 commits into
apache:mainfrom
viirya:fix/2618-null-fill-nested-columns

Conversation

@viirya

@viirya viirya commented Jun 12, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

What changes are included in this PR?

Reading older data files after optional nested columns are added to the table schema can fail when materializing missing columns as NULL arrays. This PR uses Arrow's new_null_array in both create_primitive_array_single_element and create_primitive_array_repeated, replacing the per-type NULL branches with support for nested types while preserving the requested Arrow data type, including decimal precision/scale, timestamp timezone, and nested field metadata.

Behavior change for initial defaults: When a field is absent from a data file and has a non-primitive initial-default, reading now returns a descriptive FeatureUnsupported error instead of dropping the default and potentially returning an incorrect NULL value. Missing optional fields without a default still receive typed NULLs, and primitive defaults retain their existing behavior.

This error kind is intentional: the Iceberg specification permits a non-null struct default {}, whose values are derived from each child's defaults. Materializing that default is not implemented here; rejecting it avoids silently returning incorrect data without classifying all non-primitive defaults as invalid metadata. This does not imply that arbitrary complex defaults are valid.

Are these changes tested?

  • schema_evolution_adds_list_map_and_nested_struct_columns_with_nulls reads a batch containing only id against an evolved schema with a list, a map, and a struct containing a nested list. It verifies typed NULLs, nested shape, and child field IDs 3/5/6/8/9/10.
  • schema_evolution_absent_struct_with_initial_default_errors verifies that absent structs with an initial default return FeatureUnsupported, for both optional and required fields.
  • After the latest upstream merge, all 1,841 iceberg library tests passed locally, along with clippy and formatting checks.

When a column of a nested type (list, map, or a struct with nested
children) is added to the table schema after data files were written,
reading those older files failed with "unexpected target column type":
the helpers that materialize missing columns only handled primitive
types plus structs with primitive-only children, via hand-written
per-type NULL branches.

Build the all-NULL column with arrow's new_null_array instead, which
supports every Arrow type (including arbitrarily nested ones), and drop
the per-type NULL branches from create_primitive_array_repeated and
create_primitive_array_single_element.

Closes apache#2618
// With no value, the single element is NULL. `new_null_array` supports every
// Arrow type, including nested ones (list/map/struct), which matters for
// columns added by schema evolution after a data file was written (#2618).
if prim_lit.is_none() {

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.

Hi @viirya thanks for the fix. We encountered the same issue with a schema evolution case: a list of binary was added, and the original code cannot handle it. We fixed that internally and are about to contribute it back.

Our fix is similar with yours. However during the internal code review process, we noticed the function name create_primitive_array_single_element is no longer valid, it's not just creating primitive array any more, it also creating list/struct/map arrays now. Considering the default value in Iceberg V3, a struct could have default values. I think it would be best to change the signature to create_array_single_element snd passes prim_lit as &Option<Literal>, WDYT?

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.

I think this fix is valid short-term fix. We can get it merged first, and refactor it in a follow-up PR to address the naming issue and default value support in Iceberg V3.

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.

Thanks @advancedxy — agreed on both counts, and thanks for flagging the V3 angle.

You're right that create_primitive_* is now a misnomer since these functions materialize list/map/struct NULLs too. I dug into the V3 default-value direction a bit, and it's actually a slightly larger refactor than just the helper signatures: the value currently threaded into them is Option<PrimitiveLiteral> (via ColumnSource::Add), and generate_transform_operations deliberately drops any non-primitive initial_default today (if let Literal::Primitive(prim) = lit { .. } else { None } in record_batch_transformer.rs). So supporting a struct/nested default would mean widening ColumnSource::Add.value to Option<Literal> and the transformer's default-extraction alongside the rename to create_array_single_element.

Given that, I'd prefer to keep this PR as the focused wraparound/nested-NULL fix and do the rename + Literal widening + V3 default support together in the follow-up, so the type change lands in one coherent step rather than renaming now and re-touching the signature later. Happy to open the follow-up issue/PR for that.

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.

Given that, I'd prefer to keep this PR as the focused wraparound/nested-NULL fix and do the rename + Literal widening + V3 default support together in the follow-up

Agreed. Let's merge this first.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Given that, I'd prefer to keep this PR as the focused wraparound/nested-NULL fix and do the rename + Literal widening + V3 default support together in the follow-up, so the type change lands in one coherent step rather than renaming now and re-touching the signature later. Happy to open the follow-up issue/PR for that.

Has that follow-up issue been filed? I couldn't find one. Could you open it and reference it in the new TODO so the rename and the struct default work stay connected to this PR? #3261 tracks applying initial-default to a nested field missing from an existing struct. Materializing a {} struct default needs the same per-child default logic, so extending #3261 to cover both would also work.

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.

#3261 currently covers a missing child within an existing struct. I’ll extend the follow-up there to include materializing a missing parent’s {} default, along with the helper rename and Literal support. The TODO now links to #3261 in 6c79fc7.

@viirya

viirya commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

cc @kevinjqliu

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

Thanks! A couple questions from me. Would be good to have others take a look too

BTW I also notice there's another PR addressing the same issue (#2649)

}

#[test]
fn schema_evolution_adds_list_map_and_nested_struct_columns_with_nulls() {

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: this test is a bit difficult to read. do you mind ordering so its easier to decipher the test?

we're using an evolved schema (with new optional list/map/struct types) to read the a data file that has the older schema. And we expect that all new optional list/map/struct types to be null.

should we also test for the shape of the null value? e.g. the null struct still has the expected child fields?

Comment thread crates/iceberg/src/arrow/value.rs Outdated
Comment on lines +630 to +632
// With no value, the single element is NULL. `new_null_array` supports every
// Arrow type, including nested ones (list/map/struct), which matters for
// columns added by schema evolution after a data file was written (#2618).

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: feels like we're over explaining here, wdyt?

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.

maybe we should document the 1, i think its for new_null_array 's num_rows

Ok(Arc::new(array))
}
}
(DataType::Timestamp(TimeUnit::Microsecond, timezone), None) => {

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.

just double checking, do you know if new_null_array handles this and the nanosecond case correctly?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes. new_null_array builds from the DataType, and for timestamps the timezone is part of DataType::Timestamp(unit, tz) (and precision/scale are part of Decimal128), so the null array carries the exact same type the old per-type branches produced, including Timestamp(Nanosecond, tz). The thing it does not preserve is RunEndEncoded wrapping, but the NULL-fill path uses the plain schema type (REE is only applied on the Some/constant path via datum_to_arrow_type_with_ree), so there is no regression. A test asserting the full nested DataType (which kevinjqliu also asked for) would lock this in.

})?;
Ok(Arc::new(array))
}
(DataType::Struct(fields), None) => {

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.

nice, glad we can get rid of these with the helper method!

///
/// This is used for creating non-constant arrays where we need the same value
/// repeated for each row.
pub(crate) fn create_primitive_array_repeated(

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.

maybe a good opportunity for refactoring with create_primitive_array_repeated, i see a lot of repetition. but its outside this pr's scope 😄

@kevinjqliu
kevinjqliu requested review from CTTY and blackmwk June 30, 2026 21:00
@mbutrovich
mbutrovich self-requested a review June 30, 2026 21:21

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Building on kevinjqliu's note that #2649 addresses the same issue: this is the one to take. #2649 is an additive per-type fix (+80/-1); this PR's new_null_array approach is more general, removes the existing per-type branches rather than extending them, and its test covers a struct whose child is itself a list (s: struct<a, ys: list<long>>) that an enumerate-each-type fix would miss. Recommend landing this and closing #2649 as redundant (the bug still gets fixed and #2649's intent is fully covered here).

Thanks @viirya!

Ok(Arc::new(array))
}
}
(DataType::Timestamp(TimeUnit::Microsecond, timezone), None) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes. new_null_array builds from the DataType, and for timestamps the timezone is part of DataType::Timestamp(unit, tz) (and precision/scale are part of Decimal128), so the null array carries the exact same type the old per-type branches produced, including Timestamp(Nanosecond, tz). The thing it does not preserve is RunEndEncoded wrapping, but the NULL-fill path uses the plain schema type (REE is only applied on the Some/constant path via datum_to_arrow_type_with_ree), so there is no regression. A test asserting the full nested DataType (which kevinjqliu also asked for) would lock this in.

Address review feedback on apache#2635:
- Extract the evolved schema into `schema_with_added_nested_columns` and
  reorder the test to read top-to-bottom as a story (old file -> evolved
  schema -> expect typed NULLs), per @kevinjqliu.
- Assert the all-NULL struct still carries its full nested shape (child
  fields `a` and the nested list `ys`), locking in that the type-preserving
  new_null_array fill keeps nested children.
- Trim the over-explanatory comments on the new_null_array NULL-fill paths and
  note that the literal `1` is new_null_array's row count.
@viirya

viirya commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Thanks @kevinjqliu — addressed the feedback in e845ae0:

  • Test readability / ordering. Extracted the evolved schema into schema_with_added_nested_columns() and reordered the test body so it reads top-to-bottom as the story you described: (1) the old data file with only id, (2) read it against the evolved schema, (3) expect the added list/map/struct columns as typed NULLs. The big nested-schema definition no longer sits in front of the actual logic.

  • Null shape assertion. Added an assertion that the all-NULL struct still carries its full nested shape — child fields a and the nested list ys — rather than collapsing to an empty struct. This is exactly the property the new_null_array approach guarantees over an enumerate-each-type fix (and what @mbutrovich noted).

  • new_null_array for timestamp/decimal (your question on the Some timestamp arm). Confirmed: new_null_array builds from the DataType, and timezone is part of DataType::Timestamp(unit, tz) and precision/scale part of Decimal128, so the NULL array carries the exact type the old per-type branches produced. I verified locally:

    new_null_array(Timestamp(Nanosecond, Some("+08:00")), 1) -> dt = Timestamp(Nanosecond, Some("+08:00")), null
    new_null_array(Decimal128(20, 4), 1)                     -> dt = Decimal128(20, 4), null
    

    (REE wrapping isn't preserved, but the NULL-fill path uses the plain schema type — REE is only applied on the constant/Some path — so no regression.)

  • Comments. Trimmed the over-explanatory comments on both NULL-fill paths and noted the literal 1 is new_null_array's row count.

  • Refactoring create_primitive_array_repeated (the repetition you spotted) and the create_primitive_* rename / Literal widening for V3 default values — agreed these are worth doing, keeping them as the follow-up @advancedxy and I discussed above so this PR stays the focused Data files persisted without complex columns in the table schema hit "unexpected target column" on read #2618 fix.

@viirya

viirya commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Thanks @mbutrovich for reviewing.

@viirya
viirya requested a review from kevinjqliu July 8, 2026 07:13
@viirya

viirya commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

@kevinjqliu Could you take another look? Thanks!

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Aug 8, 2026
@viirya

viirya commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@kevinjqliu @blackmwk or @CTTY can you take a look? Thanks!

@github-actions github-actions Bot removed the stale label Aug 9, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Sep 8, 2026

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

agreed with @mbutrovich, this is the one to take. Collapsing the per-type None branches into new_null_array is the right generalization, and the struct-with-a-nested-list test (s: struct<a, ys: list<long>>) pins exactly the case a per-type fix would miss. Happy to see #2649 close as redundant.

One thing worth flagging, and it's explicitly not a blocker: now that the null path works for nested types, a newly-added column whose initial_default is a non-primitive Literal (struct/list/map) gets silently dropped to None by the and_then in record_batch_transformer.rs (~L872) and null-filled — where the spec says a struct added with initial_default = {} should read back as a non-null struct with each child's own default applied. It's a pre-existing gap, just more visible now, and the fix is out of scope here. I'd file a follow-up (materialize complex defaults, or reject them in NestedField::with_initial_default to match Java's castDefault) and drop a // TODO at that and_then so it doesn't get lost.

Left a couple of small inline nits too: the retained (DataType::Null, _) arm reads as if it still handles None, and it'd be nice to pin the child field-ids in the new test.

None of that blocks; this is good to merge, will left soaking for some time before merging.

Comment thread crates/iceberg/src/arrow/value.rs Outdated
Some(NullBuffer::new_null(num_rows)),
))
}
(DataType::Null, _) => Arc::new(arrow_array::NullArray::new(num_rows)),

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.

Now that the early return catches every prim_lit.is_none() case up top, this arm is only reachable with Some(_) — a bare Null type with no literal already returns above. Harmless, but it reads as if it still handles the None case. I'd tighten it to (DataType::Null, Some(_)) or just drop it. wdyt?

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.

Good point — changed this to (DataType::Null, Some(_)). This makes the reachable case explicit while preserving the existing behavior for Null with a supplied literal; dropping the arm would make that case return an error.

NestedField::optional(
7,
"s",
Type::Struct(crate::spec::StructType::new(vec![

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.

s is added here with no initial_default, so null-fill is exactly right. The case this PR quietly makes reachable is a struct added with a non-primitive initial_default (e.g. {}) — the and_then in the transformer drops it to None, so we'd null-fill instead of materializing the spec-mandated non-null struct. Not blocking, but a small pin-test here (even #[ignore]'d) documenting that gap would pair well with the follow-up I noted in the summary.

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.

Agreed — the extraction currently drops non-primitive defaults to None. Added a TODO at that and_then to keep the gap visible. I'd keep the regression test with the complex-default implementation in a follow-up, where we can cover the non-null struct and child-default semantics together.

panic!("field `s` should be a struct");
};
let child_names: Vec<&str> = struct_fields.iter().map(|f| f.name().as_str()).collect();
assert_eq!(child_names, vec!["a", "ys"]);

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.

Nice — pinning that the null struct keeps a and ys (and that ys stays a List) is exactly the shape a per-type fix would flatten. Could we also assert the child fields keep their PARQUET_FIELD_IDs (8/9 here, plus 3/5/6 on the top-level list/map)? That metadata is the load-bearing guarantee of this approach and what downstream name-matching relies on, so it'd be good to guard against a refactor that strips it. wdyt?

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.

Added assertions for PARQUET_FIELD_ID on a/ys (8/9), the top-level list element (3), and the map key/value (5/6). I also included the nested ys element (10), so the test covers metadata preservation through that extra nesting level.

Copilot AI lite review requested due to automatic review settings September 9, 2026 19:52
@viirya

viirya commented Sep 9, 2026 •

Copy link
Copy Markdown
Member Author

Thanks @laskoviymishka for the approval and the detailed feedback! Updated in cc794c8, which also merges the latest upstream main and resolves the conflicts.

I tightened the Null arm to Some(_) and added assertions for the nested field IDs, including the element of s.ys. I also added a TODO at the default extraction to document the existing complex-default gap. The implementation and regression coverage for complex defaults can stay together in a follow-up, consistent with the earlier discussion about widening the helpers to accept Literal.

All 1,675 iceberg library tests pass locally, along with clippy and formatting checks.

Copilot AI 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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Fixes schema evolution reads when newly-added nested columns (list/map/nested struct children) are missing in older files by generating correctly-typed all-NULL Arrow arrays for any DataType.

Changes:

  • Replace per-type None handling with arrow_array::new_null_array for both single-element and repeated NULL materialization.
  • Add regression test covering schema evolution that adds list, map, and a struct containing a nested list.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
crates/iceberg/src/arrow/value.rs Use new_null_array to create typed NULL arrays for any Arrow type, removing many hand-written NULL branches.
crates/iceberg/src/arrow/record_batch_transformer.rs Add regression test ensuring evolved nested columns are filled with typed all-NULL arrays and preserve nested field IDs.
Suppressed comments (1)

crates/iceberg/src/arrow/value.rs:713

  • create_primitive_array_single_element no longer has an explicit DataType::Null arm. If this function is ever called with data_type == DataType::Null and prim_lit.is_some(), it will now error via the catch-all, while create_primitive_array_repeated explicitly accepts (DataType::Null, Some(_)). Consider adding a (DataType::Null, Some(_)) => Ok(Arc::new(arrow_array::NullArray::new(1))) arm (or equivalent) to keep behavior consistent and avoid unexpected failures.
                })?;
            Ok(Arc::new(array))
        }
        _ => Err(Error::new(
            ErrorKind::Unexpected,
            format!("Unsupported constant type combination: {data_type:?} with {prim_lit:?}"),
        )),

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@laskoviymishka

Copy link
Copy Markdown
Contributor

@viirya i think this is good to merge, fix the conflicts and we are good.

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

thanks for working on this, I'd like to see this merged soon.

I haven't took a look at the full PR, just had one note on the behavior change

Comment on lines 872 to 880

@dannycjones dannycjones Sep 22, 2026 •

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.

I hope to follow-up on this soon so not a big deal, but in general I'd prefer we fail closed if we cannot support this.

I was planning to introduce this:

let default_value = match iceberg_field.initial_default.as_ref() {
    None => None,
    Some(Literal::Primitive(prim)) => Some(prim.clone()),
    Some(_) => {
        return Err(Error::new(
            ErrorKind::FeatureUnsupported,
            format!(
                "Cannot read field {} that is absent from the data file: \
                    applying a non-primitive initial-default for {} is not yet \
                    supported",
                iceberg_field.name, iceberg_field.field_type
            ),
        ));
    }
};

i.e. this PR will take an obscure failure reading data files without the complex column, into implementing null filling for complex columns without a default and properly reporting a clear error for those with a default.

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.

Agreed — silently replacing an unsupported default with NULL could return incorrect data. Applied your suggested match in fbc8f72: absent optional fields without a default still get typed NULLs, primitive defaults retain their existing behavior, and non-primitive defaults return FeatureUnsupported with the field name and type.

Added regression coverage for both optional and required structs with an initial default. The TODO now tracks implementing complex defaults, including child defaults.

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.

Thanks for addressing this one!

auto-merge was automatically disabled September 22, 2026 16:35

Head branch was pushed to by a user without write access

@viirya

viirya commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

Thanks! Merged the latest upstream main and resolved the conflicts in fbc8f72.

I also addressed @dannycjones’s suggestion: missing fields with non-primitive initial defaults now return FeatureUnsupported with a descriptive error, instead of silently producing NULLs. Added regression coverage for both optional and required structs.

@viirya
viirya requested a review from laskoviymishka October 4, 2026 05:36

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

The error-path rework all landed — the and_then is now an explicit match that returns FeatureUnsupported instead of silently null-filling, the // TODO: Materialize complex initial defaults is in, the new schema_evolution_absent_struct_with_initial_default_errors test covers both required and optional, and the regression test pins the child field-ids (3/5/6/8/9/10). That's exactly the direction I was hoping for — erroring beats handing back a wrong value for an absent nested-default field.

One framing thing worth settling before merge: the FeatureUnsupported kind + "not yet supported" wording + the TODO read as if materializing a non-primitive initial-default is a feature we'll grow into — but Java's castDefault rejects a non-null default on a nested-type field outright, so it's invalid metadata, not a missing feature. I'd either reword so we're not promising nested-default materialization, or switch to DataInvalid to line up with castDefault. I lean toward the latter, but either's fine if it's deliberate — wdyt?

Agreed with @dannycjones that this flips a previously-successful read (a file predating a struct/list/map initial_default) into a hard error. That's the right trade since the old read was silently wrong, but it's worth a line in the PR description/changelog so it isn't a surprise.

Left a few small nits inline — a direct unit test for the collapsed None paths, a comment trim, and a required= label on the error test. None block. This is good; happy with it. Will keep soak for some time before merge.

.unwrap();

let err = transformer.process_record_batch(file_batch).unwrap_err();
assert_eq!(err.kind(), crate::ErrorKind::FeatureUnsupported);

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.

The loop runs both required values but the asserts don't say which case failed. I'd thread required into the messages ("required={required}") or split into two #[test]s, so a failure points at the right branch.

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.

Added required={required} to the assertions and the unexpected-success message, so failures identify which case was running.

Comment thread crates/iceberg/src/arrow/value.rs Outdated
data_type: &DataType,
prim_lit: Option<&PrimitiveLiteral>,
) -> Result<ArrayRef> {
// No value: a single NULL of any (possibly nested) type (#2618). The `1` is

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.

Small thing while we're here: the inline #2618 goes stale once this merges, and spelling out that 1 is the row count narrates the call. I'd trim to just the intent — "single NULL of any (possibly nested) type."

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.

Trimmed the comment to the intent and removed the issue reference and row-count explanation. Also removed the issue reference from the repeated helper’s comment.

// No value: a single NULL of any (possibly nested) type (#2618). The `1` is
// `new_null_array`'s row count.
if prim_lit.is_none() {
return Ok(new_null_array(data_type, 1));

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 collapses the Decimal / Timestamp-with-tz / Struct / Null None arms into one new_null_array call, but nothing exercises either helper with None directly anymore — the transformer tests only reach it through the full read path. I'd add a small table-driven test over both create_primitive_array_single_element and _repeated for Decimal128(10,2), Timestamp(µs, Some("+00:00")), a nested Struct, and Null, asserting data type, length, and null_count. That re-pins the timezone/precision preservation the deleted arms carried, and covers num_rows == 0.

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.

Added a table-driven test covering both helpers with None for Decimal128(10, 2), a microsecond timestamp with +00:00, a struct containing a list, and Null. It checks the exact data type, length, and NULL count, including repeated arrays with 0, 1, and 3 rows.

One Arrow detail: NullArray has no validity bitmap, so its physical null_count() is zero. The test checks logical_null_count() for every type and additionally checks physical null_count() for the other types.

@viirya

viirya commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Thanks for the follow-up! I checked the spec against Java’s castDefault. Java does reject non-null defaults for nested types, but the spec explicitly permits a non-null struct default {}, with values supplied by each child’s defaults.

I’m keeping FeatureUnsupported because that valid case is not implemented here. I narrowed the TODO to this specific struct-default behavior, rather than suggesting arbitrary complex defaults are valid.

The PR description now calls out the behavior change from potentially returning incorrect NULLs to reporting an explicit error. I also addressed the three inline suggestions.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the updates, @viirya. I agree with keeping FeatureUnsupported. The spec's default values section allows a nested struct's initial-default to be a non-null struct with no field values ({}), so a struct default is a case this reader doesn't implement yet, not invalid metadata.

I also wrote an old Parquet file with only id and read it through ArrowReader against a schema that adds a list, a map, and a struct containing a list. On the head commit the read succeeds, each added column is all NULL, and the batch schema equals schema_to_arrow_schema of the table schema.

Comment on lines +1617 to +1673
// (3b) The added columns carry the evolved schema's Arrow types and are all-NULL.
assert!(matches!(
result.schema().field(1).data_type(),
DataType::List(_)
));
assert!(matches!(
result.schema().field(2).data_type(),
DataType::Map(_, _)
));
for (idx, name) in [(1, "xs"), (2, "props"), (3, "s")] {
assert_eq!(
result.column(idx).null_count(),
3,
"added nested column `{name}` should be all-NULL"
);
}

// (3c) The all-NULL struct still carries its full nested shape (`a` plus the
// nested list `ys`), not a degenerate empty struct — this is what the
// type-preserving NULL fill guarantees over an enumerate-each-type fix.
let result_schema = result.schema();
let DataType::Struct(struct_fields) = result_schema.field(3).data_type() else {
panic!("field `s` should be a struct");
};
let child_names: Vec<&str> = struct_fields.iter().map(|f| f.name().as_str()).collect();
assert_eq!(child_names, vec!["a", "ys"]);

let DataType::List(xs_element) = result_schema.field(1).data_type() else {
panic!("field `xs` should be a list");
};
let DataType::Map(entries, _) = result_schema.field(2).data_type() else {
panic!("field `props` should be a map");
};
let DataType::Struct(map_fields) = entries.data_type() else {
panic!("map entries should be a struct");
};
let DataType::List(ys_element) = struct_fields[1].data_type() else {
panic!("field `ys` should be a list");
};
for (field, expected_id) in [
(xs_element, "3"),
(&map_fields[0], "5"),
(&map_fields[1], "6"),
(&struct_fields[0], "8"),
(&struct_fields[1], "9"),
(ys_element, "10"),
] {
assert_eq!(
field
.metadata()
.get(PARQUET_FIELD_ID_META_KEY)
.map(String::as_str),
Some(expected_id),
"nested field `{}` should preserve its field ID",
field.name()
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What do you think about asserting the whole result schema here instead of checking types, child names, and field IDs one at a time? The transformer builds its target types with schema_to_arrow_schema, and Arrow's Field equality compares names, nullability, data types, and metadata at every nesting level. One assert_eq! covers everything these checks cover (including the field IDs 3/5/6/8/9/10) and also catches nullability changes. I tried it on the head commit: the test passes, and dropping the PARQUET:field_id from the ys element in the expected schema makes the comparison fail.

This needs use super::{field_with_id, schema_to_arrow_schema}; in place of the PARQUET_FIELD_ID_META_KEY import at line 1156, which is otherwise unused after the change.

Suggested change
// (3b) The added columns carry the evolved schema's Arrow types and are all-NULL.
assert!(matches!(
result.schema().field(1).data_type(),
DataType::List(_)
));
assert!(matches!(
result.schema().field(2).data_type(),
DataType::Map(_, _)
));
for (idx, name) in [(1, "xs"), (2, "props"), (3, "s")] {
assert_eq!(
result.column(idx).null_count(),
3,
"added nested column `{name}` should be all-NULL"
);
}
// (3c) The all-NULL struct still carries its full nested shape (`a` plus the
// nested list `ys`), not a degenerate empty struct — this is what the
// type-preserving NULL fill guarantees over an enumerate-each-type fix.
let result_schema = result.schema();
let DataType::Struct(struct_fields) = result_schema.field(3).data_type() else {
panic!("field `s` should be a struct");
};
let child_names: Vec<&str> = struct_fields.iter().map(|f| f.name().as_str()).collect();
assert_eq!(child_names, vec!["a", "ys"]);
let DataType::List(xs_element) = result_schema.field(1).data_type() else {
panic!("field `xs` should be a list");
};
let DataType::Map(entries, _) = result_schema.field(2).data_type() else {
panic!("field `props` should be a map");
};
let DataType::Struct(map_fields) = entries.data_type() else {
panic!("map entries should be a struct");
};
let DataType::List(ys_element) = struct_fields[1].data_type() else {
panic!("field `ys` should be a list");
};
for (field, expected_id) in [
(xs_element, "3"),
(&map_fields[0], "5"),
(&map_fields[1], "6"),
(&struct_fields[0], "8"),
(&struct_fields[1], "9"),
(ys_element, "10"),
] {
assert_eq!(
field
.metadata()
.get(PARQUET_FIELD_ID_META_KEY)
.map(String::as_str),
Some(expected_id),
"nested field `{}` should preserve its field ID",
field.name()
);
}
// (3b) The added columns carry the evolved schema's Arrow types, including nested
// field names and field IDs, and are all-NULL.
let expected_schema = schema_to_arrow_schema(&schema_with_added_nested_columns()).unwrap();
assert_eq!(result.schema().as_ref(), &expected_schema);
for (idx, name) in [(1, "xs"), (2, "props"), (3, "s")] {
assert_eq!(
result.column(idx).null_count(),
3,
"added nested column `{name}` should be all-NULL"
);
}

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.

Agreed — replaced the individual type, name, and field-ID checks with a full schema comparison using schema_to_arrow_schema, while keeping the per-column NULL-count assertions. This also covers nested nullability. Updated the import as suggested; the transformer tests pass.

@viirya

viirya commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Thanks for checking the spec and testing the full ArrowReader path! Updated in 6c79fc7: the regression test now compares the complete result schema, and the struct-default TODO links to #3261.

I’ll use #3261 for the follow-up and add the missing-parent {} case and helper refactoring to its scope, since both cases need the same per-child default logic.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, thanks @viirya!

@laskoviymishka
laskoviymishka added this pull request to the merge queue Oct 6, 2026
Merged via the queue into apache:main with commit f2409e9 Oct 6, 2026
24 checks passed
@viirya
viirya deleted the fix/2618-null-fill-nested-columns branch October 6, 2026 18:12
@viirya

viirya commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Thanks all for reviewing this PR!

ewoodbury added a commit to ewoodbury/iceberg-rust that referenced this pull request Oct 8, 2026
The only conflict was the transformer test imports. Keep schema_to_arrow_schema from apache#2635 and drop the paths that became redundant.
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.

Data files persisted without complex columns in the table schema hit "unexpected target column" on read

7 participants