This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new de0a6ac84 fix(arrow): read nested struct fields as null for files
without ids or name mapping (#3437)
de0a6ac84 is described below
commit de0a6ac8471da43f63382d66fac27d7e56fd273e
Author: Feng Zhang <[email protected]>
AuthorDate: Tue Oct 27 21:41:05 2026 +0000
fix(arrow): read nested struct fields as null for files without ids or name
mapping (#3437)
* fix(arrow): read nested struct fields as null for files without ids or
name mapping
A Parquet file with no field ids and no name mapping gets fallback ids for
its top-level columns only. RecordBatchTransformer then matched the id-less
nested struct fields to the table's by position, so it returned the stored
values. iceberg-java matches nested fields by id and reads them as absent.
Id-less struct fields now take the absent-field path: identity partition
constant, null, or an error for a required field. Map key and value match
by position, as in iceberg-java's map readers.
Closes #3436
* test(arrow): pin struct validity for id-less struct fields
An id-less struct keeps the file's validity and reads its fields as null,
as Spark's reader does.
* Address review: map entry count error, drop redundant tests, changelog
- Report a map entry field count mismatch with its own error instead of
"expected struct-typed map entries".
- Drop promote_idless_nested_struct_reads_null and
promote_idless_map_keeps_data, covered by other tests.
- Note in the changelog that tables migrated without field ids need a
name mapping covering nested fields.
---
CHANGELOG.md | 1 +
crates/iceberg/src/arrow/reader/projection.rs | 23 +--
.../iceberg/src/arrow/record_batch_transformer.rs | 192 ++++++++-------------
3 files changed, 79 insertions(+), 137 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 728b057e7..b45216caa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@ and this project adheres to [Semantic
Versioning](https://semver.org/).
* AGS1 readers now require `StandardKeyMetadata::file_length` and reject
missing or invalid lengths without falling back to a storage stat.
AGS1-encrypted manifests, manifest lists, and Puffin files written by earlier
development builds without this field must be rewritten using a build that can
still read them before upgrading. This matches the Java client's read contract.
* `iceberg_catalog_loader::BoxedCatalogBuilder` has a new required method,
`with_runtime`. Types implementing `CatalogBuilder` get it through the blanket
impl; types implementing `BoxedCatalogBuilder` directly (for example, a wrapper
around another `Box<dyn BoxedCatalogBuilder>`) must implement it, typically by
forwarding the runtime to the builder they wrap.
* `Transaction::commit` now returns `ErrorKind::PreconditionFailed` for
read-only tables (`Table::readonly()`, e.g. from `StaticTable::into_table` or
`TableBuilder::readonly(true)`), including empty transactions, which previously
returned the table unchanged. Java treats an empty transaction on a static
table as a no-op.
+* `ArrowReader` reads nested struct fields as null for Parquet files that have
neither field ids nor a name mapping, as iceberg-java does. Earlier versions
returned their stored values by position. Tables migrated without field ids
need a name mapping (`schema.name-mapping.default`) that covers nested fields
to read them.
## [v0.10.1] - 2026-07-28
diff --git a/crates/iceberg/src/arrow/reader/projection.rs
b/crates/iceberg/src/arrow/reader/projection.rs
index b76d60c85..3474b2162 100644
--- a/crates/iceberg/src/arrow/reader/projection.rs
+++ b/crates/iceberg/src/arrow/reader/projection.rs
@@ -1690,10 +1690,10 @@ message schema {
}
/// Test reading Parquet files without field IDs with nested types
(struct).
- /// Java's pruneColumnsFallback() projects entire top-level columns
including nested content.
- /// This test verifies that a top-level struct field is projected
correctly with all its nested fields.
+ /// Java's pruneColumnsFallback() projects entire top-level columns
including nested content,
+ /// but only top-level fields get fallback ids, so struct fields read as
null.
#[tokio::test]
- async fn test_read_parquet_without_field_ids_with_struct() {
+ async fn test_read_parquet_without_field_ids_reads_struct_fields_as_null()
{
use arrow_array::{Int32Array, StructArray};
use arrow_schema::Fields;
@@ -1706,13 +1706,13 @@ message schema {
2,
"person",
Type::Struct(StructType::new(vec![
- NestedField::required(
+ NestedField::optional(
3,
"name",
Type::Primitive(PrimitiveType::String),
)
.into(),
- NestedField::required(4, "age",
Type::Primitive(PrimitiveType::Int))
+ NestedField::optional(4, "age",
Type::Primitive(PrimitiveType::Int))
.into(),
])),
)
@@ -1807,16 +1807,9 @@ message schema {
let person_array = batch.column(1).as_struct();
assert_eq!(person_array.num_columns(), 2);
-
- let name_array = person_array.column(0).as_string::<i32>();
- assert_eq!(name_array.value(0), "Alice");
- assert_eq!(name_array.value(1), "Bob");
-
- let age_array = person_array
- .column(1)
- .as_primitive::<arrow_array::types::Int32Type>();
- assert_eq!(age_array.value(0), 30);
- assert_eq!(age_array.value(1), 25);
+ assert_eq!(person_array.null_count(), 0);
+ assert_eq!(person_array.column(0).null_count(), 2);
+ assert_eq!(person_array.column(1).null_count(), 2);
}
/// Test reading Parquet files without field IDs with schema evolution -
column added in the middle.
diff --git a/crates/iceberg/src/arrow/record_batch_transformer.rs
b/crates/iceberg/src/arrow/record_batch_transformer.rs
index d6d08fd80..108dd6ecc 100644
--- a/crates/iceberg/src/arrow/record_batch_transformer.rs
+++ b/crates/iceberg/src/arrow/record_batch_transformer.rs
@@ -270,19 +270,39 @@ impl PromotePlan {
}),
(DataType::Map(source_entries, _), DataType::Map(target_entries,
sorted)) => {
match (source_entries.data_type(), target_entries.data_type())
{
- (DataType::Struct(source_fields),
DataType::Struct(target_fields)) => {
+ // Key and value match by position, as in iceberg-java's
map readers.
+ (DataType::Struct(source_fields),
DataType::Struct(target_fields))
+ if source_fields.len() == target_fields.len() =>
+ {
Ok(PromotePlan::Map {
- entries: Self::build_struct_children(
- source_fields,
- target_fields,
- snapshot_schema,
- constant_fields,
- )?,
+ entries: source_fields
+ .iter()
+ .zip(target_fields.iter())
+ .enumerate()
+ .map(|(source_index, (source_field,
target_field))| {
+ Ok(ChildPlan::FromSource {
+ source_index,
+ plan: Self::build(
+ source_field.data_type(),
+ target_field.data_type(),
+ snapshot_schema,
+ constant_fields,
+ )?,
+ })
+ })
+ .collect::<Result<_>>()?,
entry_fields: target_fields.clone(),
field: target_entries.clone(),
sorted: *sorted,
})
}
+ (DataType::Struct(source_fields),
DataType::Struct(target_fields)) => {
+ Err(invalid_data!(
+ "map entries have {} source fields, expected {}",
+ source_fields.len(),
+ target_fields.len()
+ ))
+ }
_ => Err(invalid_data!(
"expected struct-typed map entries, got
{source_entries:?} and {target_entries:?}"
)),
@@ -312,29 +332,9 @@ impl PromotePlan {
return Err(invalid_data!("duplicate field id {id} in struct"));
}
}
- // Name mapping only assigns top-level ids, so fully id-less children
match
- // by position when the counts line up, and `build` checks each pair.
This is
- // a compatibility fallback until recursive name mapping (#1845): a
same-type
- // reorder reads positionally. Any missing id errors instead of
nulling that child.
- if source_by_id.len() != source_fields.len() {
- if source_by_id.is_empty() && source_fields.len() ==
target_fields.len() {
- return source_fields
- .iter()
- .zip(target_fields.iter())
- .enumerate()
- .map(|(source_index, (source_field, target_field))| {
- Ok(ChildPlan::FromSource {
- source_index,
- plan: Self::build(
- source_field.data_type(),
- target_field.data_type(),
- snapshot_schema,
- constant_fields,
- )?,
- })
- })
- .collect();
- }
+ // Fully id-less children (no field ids and no name mapping) match no
target
+ // field and read as absent, as in iceberg-java. A partial set of ids
errors.
+ if !source_by_id.is_empty() && source_by_id.len() !=
source_fields.len() {
return Err(invalid_data!(
"cannot reconcile struct fields by id: source fields do not
all have field ids"
));
@@ -1427,7 +1427,7 @@ mod test {
use arrow_array::types::Int32Type;
use arrow_array::{
Array, ArrayRef, Date32Array, Float32Array, Float64Array, Int32Array,
Int64Array,
- LargeListArray, ListArray, MapArray, RecordBatch, StringArray,
StructArray,
+ LargeListArray, ListArray, MapArray, RecordBatch, StringArray,
StructArray, new_null_array,
};
use arrow_buffer::{NullBuffer, OffsetBuffer};
use arrow_cast::cast;
@@ -1702,16 +1702,28 @@ mod test {
}
#[test]
- fn promote_struct_without_source_field_ids_errors() {
+ fn promote_struct_without_source_field_ids_reads_null() {
+ let nulls = NullBuffer::from(vec![true, true, false]);
let source = Arc::new(StructArray::new(
Fields::from(vec![Field::new("x", DataType::Int32, true)]),
- vec![Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef],
- None,
+ vec![Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef],
+ Some(nulls.clone()),
)) as ArrayRef;
- let err = promote(&source, &evolved_struct_type(),
&empty_schema()).unwrap_err();
- assert_eq!(err.kind(), ErrorKind::DataInvalid);
- assert!(err.to_string().contains("do not all have field ids"));
+ // Fields read as null and the struct keeps the file's validity, as
Spark's reader does.
+ let out = promote(&source, &evolved_struct_type(),
&empty_schema()).unwrap();
+ let expected = StructArray::new(
+ Fields::from(vec![
+ field_with_id("x", DataType::Int32, true, 5),
+ field_with_id("y", DataType::Int32, true, 6),
+ ]),
+ vec![
+ new_null_array(&DataType::Int32, 3),
+ new_null_array(&DataType::Int32, 3),
+ ],
+ Some(nulls),
+ );
+ assert_eq!(out.as_struct(), &expected);
}
#[test]
@@ -1749,6 +1761,11 @@ mod test {
map_of(DataType::Int64),
"expected struct-typed map entries",
),
+ (
+ map_of(unevolved_struct_type()),
+ map_of(evolved_struct_type()),
+ "map entries have 1 source fields, expected 2",
+ ),
] {
let err =
PromotePlan::build(&source, &target, &empty_schema(),
&HashMap::new()).unwrap_err();
@@ -1855,64 +1872,20 @@ mod test {
}
#[test]
- fn promote_idless_nested_struct_keeps_data() {
- let inner = StructArray::new(
- Fields::from(vec![Field::new("x", DataType::Int32, true)]),
- vec![Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef],
- None,
- );
- let file = StructArray::new(
- Fields::from(vec![Field::new("inner", inner.data_type().clone(),
true)]),
- vec![Arc::new(inner) as ArrayRef],
- None,
- );
- let out = transform_top_level(
- NestedField::optional(
- 2,
- "s",
- Type::Struct(StructType::new(vec![
- NestedField::optional(
- 3,
- "inner",
- Type::Struct(StructType::new(vec![
- NestedField::optional(4, "x",
Type::Primitive(PrimitiveType::Long))
- .into(),
- ])),
- )
- .into(),
- ])),
- ),
- Arc::new(file),
- )
- .unwrap();
- let inner_fields = Fields::from(vec![field_with_id("x",
DataType::Int64, true, 4)]);
- let expected = StructArray::new(
- Fields::from(vec![field_with_id(
- "inner",
- DataType::Struct(inner_fields.clone()),
- true,
- 3,
- )]),
- vec![Arc::new(StructArray::new(
- inner_fields,
- vec![Arc::new(Int64Array::from(vec![10i64, 20, 30])) as
ArrayRef],
- None,
- )) as ArrayRef],
+ fn promote_idless_map_struct_value_reads_null_fields() {
+ let value = StructArray::new(
+ Fields::from(vec![Field::new("v", DataType::Int32, true)]),
+ vec![Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef],
None,
);
- assert_eq!(out.as_struct(), &expected);
- }
-
- #[test]
- fn promote_idless_map_keeps_data() {
let entries = StructArray::new(
Fields::from(vec![
Field::new("key", DataType::Utf8, false),
- Field::new("value", DataType::Int32, true),
+ Field::new("value", value.data_type().clone(), true),
]),
vec![
Arc::new(StringArray::from(vec!["k1", "k2"])) as ArrayRef,
- Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
+ Arc::new(value) as ArrayRef,
],
None,
);
@@ -1931,20 +1904,27 @@ mod test {
3,
Type::Primitive(PrimitiveType::String),
4,
- Type::Primitive(PrimitiveType::Int),
+ Type::Struct(StructType::new(vec![
+ NestedField::optional(5, "v",
Type::Primitive(PrimitiveType::Int)).into(),
+ ])),
)),
),
Arc::new(map),
)
.unwrap();
+ let value_fields = Fields::from(vec![field_with_id("v",
DataType::Int32, true, 5)]);
let expected_entries = StructArray::new(
Fields::from(vec![
field_with_id("key", DataType::Utf8, false, 3),
- field_with_id("value", DataType::Int32, true, 4),
+ field_with_id("value", DataType::Struct(value_fields.clone()),
true, 4),
]),
vec![
Arc::new(StringArray::from(vec!["k1", "k2"])) as ArrayRef,
- Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
+ Arc::new(StructArray::new(
+ value_fields,
+ vec![new_null_array(&DataType::Int32, 2)],
+ None,
+ )) as ArrayRef,
],
None,
);
@@ -1959,42 +1939,10 @@ mod test {
None,
false,
);
- // Map array equality skips the entries field name.
assert_eq!(out.data_type(), expected.data_type());
assert_eq!(out.as_map(), &expected);
}
- #[test]
- fn promote_idless_same_type_reorder_stays_positional() {
- // Without ids a same-type reorder is undetectable; #1845 replaces
this fallback.
- let source = Arc::new(StructArray::new(
- Fields::from(vec![
- Field::new("a", DataType::Int32, true),
- Field::new("b", DataType::Int32, true),
- ]),
- vec![
- Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
- Arc::new(Int32Array::from(vec![10, 20])) as ArrayRef,
- ],
- None,
- )) as ArrayRef;
- let fields = Fields::from(vec![
- field_with_id("b", DataType::Int32, true, 6),
- field_with_id("a", DataType::Int32, true, 5),
- ]);
-
- let out = promote(&source, &DataType::Struct(fields.clone()),
&empty_schema()).unwrap();
- let expected = StructArray::new(
- fields,
- vec![
- Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
- Arc::new(Int32Array::from(vec![10, 20])) as ArrayRef,
- ],
- None,
- );
- assert_eq!(out.as_struct(), &expected);
- }
-
#[test]
fn promote_large_list_file_column_to_list() {
let element = Arc::new(field_with_id("element", DataType::Int32, true,
3));