laskoviymishka commented on code in PR #3384:
URL: https://github.com/apache/iceberg-rust/pull/3384#discussion_r4238615794
##########
crates/iceberg/src/arrow/reader/projection.rs:
##########
@@ -422,43 +427,101 @@ pub(super) fn apply_name_mapping_to_arrow_schema(
"Schema already has field IDs - name mapping should not be applied"
);
- let fields_with_mapped_ids: Vec<_> = arrow_schema
+ let fields_with_mapped_ids = arrow_schema
.fields()
.iter()
.map(|field| {
- // Look up this column name in name mapping to get the Iceberg
field ID.
- // Corresponds to Java's ApplyNameMapping visitor which calls
- // nameMapping.find(currentPath()) and returns field.withId() if
found.
- //
- // If the field isn't in the mapping, leave it WITHOUT assigning
an ID
- // (matching Java's behavior of returning the field unchanged).
- // Later, during projection, fields without IDs are filtered out.
- let mapped_field_opt = name_mapping
- .fields()
- .iter()
- .find(|f| f.names().contains(&field.name().to_string()));
-
- let mut metadata = field.metadata().clone();
-
- if let Some(mapped_field) = mapped_field_opt
- && let Some(field_id) = mapped_field.field_id()
- {
- // Field found in mapping with a field_id → assign it
- metadata.insert(PARQUET_FIELD_ID_META_KEY.to_string(),
field_id.to_string());
- }
- // If field_id is None, leave the field without an ID (will be
filtered by projection)
-
- Field::new(field.name(), field.data_type().clone(),
field.is_nullable())
- .with_metadata(metadata)
+ let mapped_field = find_mapped_field(name_mapping.fields(),
field.name());
+ apply_name_mapping_to_field(field, mapped_field)
})
- .collect();
+ .collect::<Result<Fields>>()?;
Ok(Arc::new(ArrowSchema::new_with_metadata(
fields_with_mapped_ids,
arrow_schema.metadata().clone(),
)))
}
+/// Assigns `field` the ID of `mapped_field`, its entry in the name mapping,
and assigns each
+/// nested field the ID of its entry among `mapped_field`'s children.
+///
+/// Corresponds to Java's ApplyNameMapping visitor, which calls
nameMapping.find(currentPath())
+/// for every field at every depth. As in Java, a list element is looked up as
`element` and a
+/// map key and value as `key` and `value`, whatever the file calls them.
+///
+/// A field the mapping does not cover keeps no ID, and so does everything
nested in it, which
+/// leaves it out of the projection. Java likewise returns such a field
unchanged.
+fn apply_name_mapping_to_field(
+ field: &FieldRef,
+ mapped_field: Option<&MappedField>,
+) -> Result<FieldRef> {
+ let Some(mapped_field) = mapped_field else {
+ return Ok(Arc::clone(field));
+ };
+ let map_nested = |nested: &FieldRef, name: &str| {
+ let mapped_nested =
find_mapped_field(mapped_field.fields().iter().map(Arc::as_ref), name);
+ apply_name_mapping_to_field(nested, mapped_nested)
+ };
+
+ let data_type = match field.data_type() {
+ DataType::Struct(fields) => DataType::Struct(
+ fields
+ .iter()
+ .map(|nested| map_nested(nested, nested.name()))
+ .collect::<Result<_>>()?,
+ ),
+ DataType::List(element) => DataType::List(map_nested(element,
LIST_FIELD_NAME)?),
+ DataType::LargeList(element) =>
DataType::LargeList(map_nested(element, LIST_FIELD_NAME)?),
+ DataType::FixedSizeList(element, size) => {
+ DataType::FixedSizeList(map_nested(element, LIST_FIELD_NAME)?,
*size)
+ }
+ // The key and value sit directly under the map in the mapping,
without the entries
+ // struct that holds them in Arrow.
+ DataType::Map(entries, sorted) => {
+ let DataType::Struct(entry_fields) = entries.data_type() else {
+ return Err(invalid_data!(
+ "Map field `{}` must have struct type",
+ field.name()
+ ));
+ };
+ let [key, value] = &entry_fields[..] else {
Review Comment:
This guard and the non-struct one just above are the only new fallible
paths, and the only reason the function now returns `Result`, but nothing
exercises either — a Parquet-derived Arrow schema can't produce them. I'd add a
small unit test that builds a malformed `DataType::Map` to pin them, or drop a
line saying they're defensive. While we're here, the message could carry
`entry_fields.len()` so a failure reports what it actually saw.
##########
crates/iceberg/src/arrow/reader/projection.rs:
##########
@@ -422,43 +427,101 @@ pub(super) fn apply_name_mapping_to_arrow_schema(
"Schema already has field IDs - name mapping should not be applied"
);
- let fields_with_mapped_ids: Vec<_> = arrow_schema
+ let fields_with_mapped_ids = arrow_schema
.fields()
.iter()
.map(|field| {
- // Look up this column name in name mapping to get the Iceberg
field ID.
- // Corresponds to Java's ApplyNameMapping visitor which calls
- // nameMapping.find(currentPath()) and returns field.withId() if
found.
- //
- // If the field isn't in the mapping, leave it WITHOUT assigning
an ID
- // (matching Java's behavior of returning the field unchanged).
- // Later, during projection, fields without IDs are filtered out.
- let mapped_field_opt = name_mapping
- .fields()
- .iter()
- .find(|f| f.names().contains(&field.name().to_string()));
-
- let mut metadata = field.metadata().clone();
-
- if let Some(mapped_field) = mapped_field_opt
- && let Some(field_id) = mapped_field.field_id()
- {
- // Field found in mapping with a field_id → assign it
- metadata.insert(PARQUET_FIELD_ID_META_KEY.to_string(),
field_id.to_string());
- }
- // If field_id is None, leave the field without an ID (will be
filtered by projection)
-
- Field::new(field.name(), field.data_type().clone(),
field.is_nullable())
- .with_metadata(metadata)
+ let mapped_field = find_mapped_field(name_mapping.fields(),
field.name());
+ apply_name_mapping_to_field(field, mapped_field)
})
- .collect();
+ .collect::<Result<Fields>>()?;
Ok(Arc::new(ArrowSchema::new_with_metadata(
fields_with_mapped_ids,
arrow_schema.metadata().clone(),
)))
}
+/// Assigns `field` the ID of `mapped_field`, its entry in the name mapping,
and assigns each
+/// nested field the ID of its entry among `mapped_field`'s children.
+///
+/// Corresponds to Java's ApplyNameMapping visitor, which calls
nameMapping.find(currentPath())
+/// for every field at every depth. As in Java, a list element is looked up as
`element` and a
+/// map key and value as `key` and `value`, whatever the file calls them.
+///
+/// A field the mapping does not cover keeps no ID, and so does everything
nested in it, which
+/// leaves it out of the projection. Java likewise returns such a field
unchanged.
+fn apply_name_mapping_to_field(
+ field: &FieldRef,
+ mapped_field: Option<&MappedField>,
+) -> Result<FieldRef> {
+ let Some(mapped_field) = mapped_field else {
+ return Ok(Arc::clone(field));
+ };
+ let map_nested = |nested: &FieldRef, name: &str| {
+ let mapped_nested =
find_mapped_field(mapped_field.fields().iter().map(Arc::as_ref), name);
+ apply_name_mapping_to_field(nested, mapped_nested)
+ };
+
+ let data_type = match field.data_type() {
+ DataType::Struct(fields) => DataType::Struct(
+ fields
+ .iter()
+ .map(|nested| map_nested(nested, nested.name()))
+ .collect::<Result<_>>()?,
+ ),
+ DataType::List(element) => DataType::List(map_nested(element,
LIST_FIELD_NAME)?),
+ DataType::LargeList(element) =>
DataType::LargeList(map_nested(element, LIST_FIELD_NAME)?),
+ DataType::FixedSizeList(element, size) => {
+ DataType::FixedSizeList(map_nested(element, LIST_FIELD_NAME)?,
*size)
+ }
+ // The key and value sit directly under the map in the mapping,
without the entries
+ // struct that holds them in Arrow.
+ DataType::Map(entries, sorted) => {
+ let DataType::Struct(entry_fields) = entries.data_type() else {
+ return Err(invalid_data!(
+ "Map field `{}` must have struct type",
+ field.name()
+ ));
+ };
+ let [key, value] = &entry_fields[..] else {
+ return Err(invalid_data!(
+ "Map field `{}` must have exactly 2 fields",
+ field.name()
+ ));
+ };
+ let entry_fields = Fields::from(vec![
+ map_nested(key, MAP_KEY_FIELD_NAME)?,
+ map_nested(value, MAP_VALUE_FIELD_NAME)?,
+ ]);
+ let entries = entries
+ .as_ref()
+ .clone()
+ .with_data_type(DataType::Struct(entry_fields));
+ DataType::Map(Arc::new(entries), *sorted)
+ }
+ other => other.clone(),
+ };
+
+ let mut field = field.as_ref().clone().with_data_type(data_type);
+ if let Some(field_id) = mapped_field.field_id() {
Review Comment:
A mapping entry with `field_id() == None` whose children do have ids
produces an id-less parent sitting over id-bearing leaves — which contradicts
the doc right above ("keeps no ID, and so does everything nested in it"), and
is a shape `filter_leaves`/`arrow_schema_to_schema` can trip over if the parent
survives. We only gate the parent's own id here; the children were already
recursed and stamped up in the match.
It's a rare shape and probably matches Java, so I wouldn't block on it. But
I'd either skip the recursion when `mapped_field.field_id().is_none()` so the
doc holds literally, or keep the behavior and pin it with a test plus a
one-line note.
##########
crates/iceberg/src/arrow/reader/projection.rs:
##########
@@ -422,43 +427,101 @@ pub(super) fn apply_name_mapping_to_arrow_schema(
"Schema already has field IDs - name mapping should not be applied"
);
- let fields_with_mapped_ids: Vec<_> = arrow_schema
+ let fields_with_mapped_ids = arrow_schema
.fields()
.iter()
.map(|field| {
- // Look up this column name in name mapping to get the Iceberg
field ID.
- // Corresponds to Java's ApplyNameMapping visitor which calls
- // nameMapping.find(currentPath()) and returns field.withId() if
found.
- //
- // If the field isn't in the mapping, leave it WITHOUT assigning
an ID
- // (matching Java's behavior of returning the field unchanged).
- // Later, during projection, fields without IDs are filtered out.
- let mapped_field_opt = name_mapping
- .fields()
- .iter()
- .find(|f| f.names().contains(&field.name().to_string()));
-
- let mut metadata = field.metadata().clone();
-
- if let Some(mapped_field) = mapped_field_opt
- && let Some(field_id) = mapped_field.field_id()
- {
- // Field found in mapping with a field_id → assign it
- metadata.insert(PARQUET_FIELD_ID_META_KEY.to_string(),
field_id.to_string());
- }
- // If field_id is None, leave the field without an ID (will be
filtered by projection)
-
- Field::new(field.name(), field.data_type().clone(),
field.is_nullable())
- .with_metadata(metadata)
+ let mapped_field = find_mapped_field(name_mapping.fields(),
field.name());
+ apply_name_mapping_to_field(field, mapped_field)
})
- .collect();
+ .collect::<Result<Fields>>()?;
Ok(Arc::new(ArrowSchema::new_with_metadata(
fields_with_mapped_ids,
arrow_schema.metadata().clone(),
)))
}
+/// Assigns `field` the ID of `mapped_field`, its entry in the name mapping,
and assigns each
+/// nested field the ID of its entry among `mapped_field`'s children.
+///
+/// Corresponds to Java's ApplyNameMapping visitor, which calls
nameMapping.find(currentPath())
+/// for every field at every depth. As in Java, a list element is looked up as
`element` and a
+/// map key and value as `key` and `value`, whatever the file calls them.
+///
+/// A field the mapping does not cover keeps no ID, and so does everything
nested in it, which
+/// leaves it out of the projection. Java likewise returns such a field
unchanged.
+fn apply_name_mapping_to_field(
+ field: &FieldRef,
+ mapped_field: Option<&MappedField>,
+) -> Result<FieldRef> {
+ let Some(mapped_field) = mapped_field else {
+ return Ok(Arc::clone(field));
+ };
+ let map_nested = |nested: &FieldRef, name: &str| {
+ let mapped_nested =
find_mapped_field(mapped_field.fields().iter().map(Arc::as_ref), name);
+ apply_name_mapping_to_field(nested, mapped_nested)
+ };
+
+ let data_type = match field.data_type() {
+ DataType::Struct(fields) => DataType::Struct(
+ fields
+ .iter()
+ .map(|nested| map_nested(nested, nested.name()))
+ .collect::<Result<_>>()?,
+ ),
+ DataType::List(element) => DataType::List(map_nested(element,
LIST_FIELD_NAME)?),
+ DataType::LargeList(element) =>
DataType::LargeList(map_nested(element, LIST_FIELD_NAME)?),
+ DataType::FixedSizeList(element, size) => {
+ DataType::FixedSizeList(map_nested(element, LIST_FIELD_NAME)?,
*size)
+ }
+ // The key and value sit directly under the map in the mapping,
without the entries
+ // struct that holds them in Arrow.
+ DataType::Map(entries, sorted) => {
+ let DataType::Struct(entry_fields) = entries.data_type() else {
+ return Err(invalid_data!(
+ "Map field `{}` must have struct type",
+ field.name()
+ ));
+ };
+ let [key, value] = &entry_fields[..] else {
+ return Err(invalid_data!(
+ "Map field `{}` must have exactly 2 fields",
+ field.name()
+ ));
+ };
+ let entry_fields = Fields::from(vec![
+ map_nested(key, MAP_KEY_FIELD_NAME)?,
+ map_nested(value, MAP_VALUE_FIELD_NAME)?,
+ ]);
+ let entries = entries
+ .as_ref()
+ .clone()
+ .with_data_type(DataType::Struct(entry_fields));
+ DataType::Map(Arc::new(entries), *sorted)
+ }
+ other => other.clone(),
Review Comment:
This arm leaves `ListView`, `LargeListView`, `Union`, `RunEndEncoded` and
dictionary-of-nested unrecursed, so if one ever showed up its children would
get no ids and read back NULL — the same symptom as #3374, just rarer. In
practice the Parquet reader only emits these via an embedded Arrow schema hint,
so a one-line comment saying so is probably enough; adding
`ListView`/`LargeListView` arms next to the list arms would close it fully.
##########
crates/iceberg/src/arrow/reader/projection.rs:
##########
@@ -1072,6 +1135,278 @@ message schema {
assert_eq!(subdept_array.value(0), "Bob");
}
+ /// Regression test for #3374: the name mapping must assign field IDs
below the top level
+ /// too. Otherwise no nested leaf is projected, and a struct, list, or map
read next to a
+ /// top-level column comes back NULL.
+ ///
+ /// The file names the list element `item` and the map entries `keys` and
`values`, while
+ /// the mapping calls them `element`, `key`, and `value`. `s.id` shares
its name with the
+ /// top-level `id`, so each lookup must stay within its parent's mapping.
+ #[tokio::test]
+ async fn test_read_parquet_with_name_mapping_maps_nested_fields() {
+ use arrow_array::builder::{Int32Builder, MapBuilder, MapFieldNames,
StringBuilder};
+ use arrow_array::types::Int32Type;
+ use arrow_array::{Int32Array, ListArray, StructArray};
+
+ use crate::spec::{ListType, MapType};
+
+ let schema = Arc::new(
+ Schema::builder()
+ .with_schema_id(1)
+ .with_fields(vec![
+ NestedField::optional(1, "id",
Type::Primitive(PrimitiveType::Int)).into(),
+ NestedField::optional(
+ 2,
+ "s",
+ Type::Struct(StructType::new(vec![
+ NestedField::optional(3, "id",
Type::Primitive(PrimitiveType::Int))
+ .into(),
+ NestedField::optional(4, "b",
Type::Primitive(PrimitiveType::String))
+ .into(),
+ ])),
+ )
+ .into(),
+ NestedField::optional(
+ 5,
+ "arr",
+ Type::List(ListType::new(
+ NestedField::list_element(
+ 6,
+ Type::Primitive(PrimitiveType::Int),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ NestedField::optional(
+ 7,
+ "m",
+ Type::Map(MapType::new(
+ NestedField::map_key_element(8,
Type::Primitive(PrimitiveType::String))
+ .into(),
+ NestedField::map_value_element(
+ 9,
+ Type::Primitive(PrimitiveType::Int),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ ])
+ .build()
+ .unwrap(),
+ );
+
+ // The mapping Iceberg Java's `MappingUtil.create` writes for that
schema.
+ let name_mapping = Arc::new(NameMapping::new(vec![
+ MappedField::new(Some(1), vec!["id".to_string()], vec![]),
+ MappedField::new(Some(2), vec!["s".to_string()], vec![
+ MappedField::new(Some(3), vec!["id".to_string()], vec![]),
+ MappedField::new(Some(4), vec!["b".to_string()], vec![]),
+ ]),
+ MappedField::new(Some(5), vec!["arr".to_string()],
vec![MappedField::new(
+ Some(6),
+ vec!["element".to_string()],
+ vec![],
+ )]),
+ MappedField::new(Some(7), vec!["m".to_string()], vec![
+ MappedField::new(Some(8), vec!["key".to_string()], vec![]),
+ MappedField::new(Some(9), vec!["value".to_string()], vec![]),
+ ]),
+ ]));
+
+ // A migrated Parquet file with no field IDs at any level.
+ let s = StructArray::from(vec![
+ (
+ Arc::new(Field::new("id", DataType::Int32, true)),
+ Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef,
+ ),
+ (
+ Arc::new(Field::new("b", DataType::Utf8, true)),
+ Arc::new(StringArray::from(vec!["x", "y", "z"])) as ArrayRef,
+ ),
+ ]);
+ let arr = ListArray::from_iter_primitive::<Int32Type, _, _>(vec![
+ Some(vec![Some(1)]),
+ Some(vec![Some(2), Some(3)]),
+ Some(vec![]),
+ ]);
+ let mut m = MapBuilder::new(
+ Some(MapFieldNames {
+ entry: "entries".to_string(),
+ key: "keys".to_string(),
+ value: "values".to_string(),
+ }),
+ StringBuilder::new(),
+ Int32Builder::new(),
+ );
+ for entries in [vec![("a", 100)], vec![("b", 200), ("c", 300)],
vec![]] {
+ for (key, value) in entries {
+ m.keys().append_value(key);
+ m.values().append_value(value);
+ }
+ m.append(true).unwrap();
+ }
+ let to_write = RecordBatch::try_from_iter_with_nullable(vec![
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef,
+ true,
+ ),
+ ("s", Arc::new(s) as ArrayRef, true),
+ ("arr", Arc::new(arr) as ArrayRef, true),
+ ("m", Arc::new(m.finish()) as ArrayRef, true),
+ ])
+ .unwrap();
+
+ let tmp_dir = TempDir::new().unwrap();
+ let file_path = format!("{}/1.parquet",
tmp_dir.path().to_str().unwrap());
+ let file = File::create(&file_path).unwrap();
+ let mut writer = ArrowWriter::try_new(file, to_write.schema(),
None).unwrap();
+ writer.write(&to_write).expect("Writing batch");
+ writer.close().unwrap();
+
+ // Projecting a top-level column next to the nested ones must not
change their values.
+ for project_field_ids in [vec![1, 2, 5, 7], vec![2, 5, 7]] {
+ let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(),
Runtime::current()).build();
+ let tasks = Box::pin(futures::stream::iter(
+ vec![Ok(FileScanTask::builder()
+
.with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
+ .with_start(0)
+ .with_length(0)
+ .with_data_file_path(file_path.clone())
+ .with_data_file_format(DataFileFormat::Parquet)
+ .with_schema(schema.clone())
+ .with_project_field_ids(project_field_ids.clone())
+ .with_case_sensitive(false)
+ .with_name_mapping(Some(name_mapping.clone()))
+ .build()
+ .unwrap())]
+ .into_iter(),
+ )) as FileScanTaskStream;
+
+ let result = reader
+ .read(tasks)
+ .unwrap()
+ .stream()
+ .try_collect::<Vec<RecordBatch>>()
+ .await
+ .unwrap();
+ assert_eq!(result.len(), 1);
+ let column = |name: &str| result[0].column_by_name(name).unwrap();
+
+ let s = column("s").as_struct();
Review Comment:
These `s`/`arr`/`m` shadow the builders of the same names from the top of
the test — the `m` map reader vs the `m` `MapBuilder` especially reads as the
same thing. Renaming the loop-scope ones (`s_col`/`arr_col`/`m_col`) would make
it easier to follow.
##########
crates/iceberg/src/arrow/reader/projection.rs:
##########
@@ -1072,6 +1135,278 @@ message schema {
assert_eq!(subdept_array.value(0), "Bob");
}
+ /// Regression test for #3374: the name mapping must assign field IDs
below the top level
+ /// too. Otherwise no nested leaf is projected, and a struct, list, or map
read next to a
+ /// top-level column comes back NULL.
+ ///
+ /// The file names the list element `item` and the map entries `keys` and
`values`, while
+ /// the mapping calls them `element`, `key`, and `value`. `s.id` shares
its name with the
+ /// top-level `id`, so each lookup must stay within its parent's mapping.
+ #[tokio::test]
+ async fn test_read_parquet_with_name_mapping_maps_nested_fields() {
+ use arrow_array::builder::{Int32Builder, MapBuilder, MapFieldNames,
StringBuilder};
+ use arrow_array::types::Int32Type;
+ use arrow_array::{Int32Array, ListArray, StructArray};
+
+ use crate::spec::{ListType, MapType};
+
+ let schema = Arc::new(
+ Schema::builder()
+ .with_schema_id(1)
+ .with_fields(vec![
+ NestedField::optional(1, "id",
Type::Primitive(PrimitiveType::Int)).into(),
+ NestedField::optional(
+ 2,
+ "s",
+ Type::Struct(StructType::new(vec![
+ NestedField::optional(3, "id",
Type::Primitive(PrimitiveType::Int))
+ .into(),
+ NestedField::optional(4, "b",
Type::Primitive(PrimitiveType::String))
+ .into(),
+ ])),
+ )
+ .into(),
+ NestedField::optional(
+ 5,
+ "arr",
+ Type::List(ListType::new(
+ NestedField::list_element(
+ 6,
+ Type::Primitive(PrimitiveType::Int),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ NestedField::optional(
+ 7,
+ "m",
+ Type::Map(MapType::new(
+ NestedField::map_key_element(8,
Type::Primitive(PrimitiveType::String))
+ .into(),
+ NestedField::map_value_element(
+ 9,
+ Type::Primitive(PrimitiveType::Int),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ ])
+ .build()
+ .unwrap(),
+ );
+
+ // The mapping Iceberg Java's `MappingUtil.create` writes for that
schema.
+ let name_mapping = Arc::new(NameMapping::new(vec![
+ MappedField::new(Some(1), vec!["id".to_string()], vec![]),
+ MappedField::new(Some(2), vec!["s".to_string()], vec![
Review Comment:
`s` maps both `id` and `b` here, so the suite never exercises an unmapped
child under a mapped parent — which is the invariant the new doc leads with. A
variant that drops `b` from the mapping and asserts `s.b` comes back NULL while
`s.id` still reads would pin it, and it's the case most likely to regress
silently if the early-return ever moves.
##########
crates/iceberg/src/arrow/reader/projection.rs:
##########
@@ -1072,6 +1135,278 @@ message schema {
assert_eq!(subdept_array.value(0), "Bob");
}
+ /// Regression test for #3374: the name mapping must assign field IDs
below the top level
+ /// too. Otherwise no nested leaf is projected, and a struct, list, or map
read next to a
+ /// top-level column comes back NULL.
+ ///
+ /// The file names the list element `item` and the map entries `keys` and
`values`, while
+ /// the mapping calls them `element`, `key`, and `value`. `s.id` shares
its name with the
+ /// top-level `id`, so each lookup must stay within its parent's mapping.
+ #[tokio::test]
+ async fn test_read_parquet_with_name_mapping_maps_nested_fields() {
+ use arrow_array::builder::{Int32Builder, MapBuilder, MapFieldNames,
StringBuilder};
+ use arrow_array::types::Int32Type;
+ use arrow_array::{Int32Array, ListArray, StructArray};
+
+ use crate::spec::{ListType, MapType};
+
+ let schema = Arc::new(
+ Schema::builder()
+ .with_schema_id(1)
+ .with_fields(vec![
+ NestedField::optional(1, "id",
Type::Primitive(PrimitiveType::Int)).into(),
+ NestedField::optional(
+ 2,
+ "s",
+ Type::Struct(StructType::new(vec![
+ NestedField::optional(3, "id",
Type::Primitive(PrimitiveType::Int))
+ .into(),
+ NestedField::optional(4, "b",
Type::Primitive(PrimitiveType::String))
+ .into(),
+ ])),
+ )
+ .into(),
+ NestedField::optional(
+ 5,
+ "arr",
+ Type::List(ListType::new(
+ NestedField::list_element(
+ 6,
+ Type::Primitive(PrimitiveType::Int),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ NestedField::optional(
+ 7,
+ "m",
+ Type::Map(MapType::new(
+ NestedField::map_key_element(8,
Type::Primitive(PrimitiveType::String))
+ .into(),
+ NestedField::map_value_element(
+ 9,
+ Type::Primitive(PrimitiveType::Int),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ ])
+ .build()
+ .unwrap(),
+ );
+
+ // The mapping Iceberg Java's `MappingUtil.create` writes for that
schema.
+ let name_mapping = Arc::new(NameMapping::new(vec![
+ MappedField::new(Some(1), vec!["id".to_string()], vec![]),
+ MappedField::new(Some(2), vec!["s".to_string()], vec![
+ MappedField::new(Some(3), vec!["id".to_string()], vec![]),
+ MappedField::new(Some(4), vec!["b".to_string()], vec![]),
+ ]),
+ MappedField::new(Some(5), vec!["arr".to_string()],
vec![MappedField::new(
+ Some(6),
+ vec!["element".to_string()],
+ vec![],
+ )]),
+ MappedField::new(Some(7), vec!["m".to_string()], vec![
+ MappedField::new(Some(8), vec!["key".to_string()], vec![]),
+ MappedField::new(Some(9), vec!["value".to_string()], vec![]),
+ ]),
+ ]));
+
+ // A migrated Parquet file with no field IDs at any level.
+ let s = StructArray::from(vec![
+ (
+ Arc::new(Field::new("id", DataType::Int32, true)),
+ Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef,
+ ),
+ (
+ Arc::new(Field::new("b", DataType::Utf8, true)),
+ Arc::new(StringArray::from(vec!["x", "y", "z"])) as ArrayRef,
+ ),
+ ]);
+ let arr = ListArray::from_iter_primitive::<Int32Type, _, _>(vec![
+ Some(vec![Some(1)]),
+ Some(vec![Some(2), Some(3)]),
+ Some(vec![]),
+ ]);
+ let mut m = MapBuilder::new(
+ Some(MapFieldNames {
+ entry: "entries".to_string(),
+ key: "keys".to_string(),
+ value: "values".to_string(),
+ }),
+ StringBuilder::new(),
+ Int32Builder::new(),
+ );
+ for entries in [vec![("a", 100)], vec![("b", 200), ("c", 300)],
vec![]] {
+ for (key, value) in entries {
+ m.keys().append_value(key);
+ m.values().append_value(value);
+ }
+ m.append(true).unwrap();
+ }
+ let to_write = RecordBatch::try_from_iter_with_nullable(vec![
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef,
+ true,
+ ),
+ ("s", Arc::new(s) as ArrayRef, true),
+ ("arr", Arc::new(arr) as ArrayRef, true),
+ ("m", Arc::new(m.finish()) as ArrayRef, true),
+ ])
+ .unwrap();
+
+ let tmp_dir = TempDir::new().unwrap();
+ let file_path = format!("{}/1.parquet",
tmp_dir.path().to_str().unwrap());
+ let file = File::create(&file_path).unwrap();
+ let mut writer = ArrowWriter::try_new(file, to_write.schema(),
None).unwrap();
+ writer.write(&to_write).expect("Writing batch");
+ writer.close().unwrap();
+
+ // Projecting a top-level column next to the nested ones must not
change their values.
+ for project_field_ids in [vec![1, 2, 5, 7], vec![2, 5, 7]] {
+ let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(),
Runtime::current()).build();
+ let tasks = Box::pin(futures::stream::iter(
+ vec![Ok(FileScanTask::builder()
+
.with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
+ .with_start(0)
+ .with_length(0)
+ .with_data_file_path(file_path.clone())
+ .with_data_file_format(DataFileFormat::Parquet)
+ .with_schema(schema.clone())
+ .with_project_field_ids(project_field_ids.clone())
+ .with_case_sensitive(false)
+ .with_name_mapping(Some(name_mapping.clone()))
+ .build()
+ .unwrap())]
+ .into_iter(),
+ )) as FileScanTaskStream;
+
+ let result = reader
+ .read(tasks)
+ .unwrap()
+ .stream()
+ .try_collect::<Vec<RecordBatch>>()
+ .await
+ .unwrap();
+ assert_eq!(result.len(), 1);
+ let column = |name: &str| result[0].column_by_name(name).unwrap();
+
+ let s = column("s").as_struct();
+ assert_eq!(s.null_count(), 0, "`s` is NULL for
{project_field_ids:?}");
+ assert_eq!(
+ s.column(0).as_primitive::<Int32Type>(),
+ &Int32Array::from(vec![10, 20, 30])
+ );
+ assert_eq!(
+ s.column(1).as_string::<i32>(),
+ &StringArray::from(vec!["x", "y", "z"])
+ );
+
+ let arr = column("arr").as_list::<i32>();
+ assert_eq!(
+ arr.null_count(),
+ 0,
+ "`arr` is NULL for {project_field_ids:?}"
+ );
+ assert_eq!(arr.value_offsets(), &[0, 1, 3, 3]);
+ assert_eq!(
+ arr.values().as_primitive::<Int32Type>(),
+ &Int32Array::from(vec![1, 2, 3])
+ );
+
+ let m = column("m").as_map();
+ assert_eq!(m.null_count(), 0, "`m` is NULL for
{project_field_ids:?}");
+ assert_eq!(m.value_offsets(), &[0, 1, 3, 3]);
+ assert_eq!(
+ m.keys().as_string::<i32>(),
+ &StringArray::from(vec!["a", "b", "c"])
+ );
+ assert_eq!(
+ m.values().as_primitive::<Int32Type>(),
+ &Int32Array::from(vec![100, 200, 300])
+ );
+ }
+ }
+
+ /// Covers `LargeList` and `FixedSizeList`, which a file reads back as
only when its
+ /// embedded Arrow schema asks for them, and a struct inside a list
element.
+ #[test]
+ fn test_apply_name_mapping_to_large_and_fixed_size_lists() {
+ use arrow_schema::Fields;
+
+ use super::apply_name_mapping_to_arrow_schema;
+ use crate::arrow::arrow_schema_to_schema;
+ use crate::spec::ListType;
+
+ let item = |data_type| Arc::new(Field::new("item", data_type, true));
+ let x = Field::new("x", DataType::Int32, true);
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ Field::new(
+ "fixed",
+ DataType::FixedSizeList(item(DataType::Int32), 2),
+ true,
+ ),
+ Field::new(
+ "large",
+
DataType::LargeList(item(DataType::Struct(Fields::from(vec![x])))),
+ true,
+ ),
+ ]));
+ let name_mapping = NameMapping::new(vec![
+ MappedField::new(Some(1), vec!["fixed".to_string()],
vec![MappedField::new(
+ Some(2),
+ vec!["element".to_string()],
+ vec![],
+ )]),
+ MappedField::new(Some(3), vec!["large".to_string()],
vec![MappedField::new(
+ Some(4),
+ vec!["element".to_string()],
+ vec![MappedField::new(Some(5), vec!["x".to_string()], vec![])],
+ )]),
+ ]);
+
+ let mapped = apply_name_mapping_to_arrow_schema(arrow_schema,
&name_mapping).unwrap();
+
+ // The conversion fails on any field without an ID, at any depth.
+ let expected = Schema::builder()
+ .with_fields(vec![
+ NestedField::optional(
+ 1,
+ "fixed",
+ Type::List(ListType::new(
+ NestedField::list_element(2,
Type::Primitive(PrimitiveType::Int), false)
+ .into(),
+ )),
+ )
+ .into(),
+ NestedField::optional(
+ 3,
+ "large",
+ Type::List(ListType::new(
+ NestedField::list_element(
+ 4,
+ Type::Struct(StructType::new(vec![
+ NestedField::optional(5, "x",
Type::Primitive(PrimitiveType::Int))
+ .into(),
+ ])),
+ false,
+ )
+ .into(),
+ )),
+ )
+ .into(),
+ ])
+ .build()
+ .unwrap();
+ assert_eq!(arrow_schema_to_schema(&mapped).unwrap(), expected);
Review Comment:
Using `arrow_schema_to_schema` as the oracle proves every field got an id,
but it won't catch a dropped `FixedSizeList` size or a flipped `Map` `sorted`
flag — the "`*size`/`*sorted` threaded through unchanged" claim has no
assertion behind it. I'd add a direct `mapped.field(0).data_type()` check that
it's still `FixedSizeList(_, 2)`, plus a `sorted = true` map case if it's easy.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]