mbutrovich commented on code in PR #2647:
URL: https://github.com/apache/iceberg-rust/pull/2647#discussion_r4065630679


##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -831,25 +848,26 @@ impl RecordBatchTransformer {
                 //
                 // At this point, all field IDs in the source schema are 
trustworthy.
                 // No conflict detection needed - schema resolution happened 
in reader.rs.
-                let field_by_id = 
field_id_to_source_schema_map.get(field_id).map(
-                    |(source_field, source_index)| {
-                        if 
source_field.data_type().equals_datatype(target_type) {
-                            ColumnSource::PassThrough {
-                                source_index: *source_index,
-                            }
-                        } else {
-                            ColumnSource::Promote {
-                                target_type: target_type.clone(),
-                                source_index: *source_index,
-                            }
-                        }
-                    },
-                );
-
+                //
                 // Apply spec's fallback steps for "not present" fields.
                 // Rule #1 (constants) is handled at the beginning of this 
function
-                let column_source = if let Some(source) = field_by_id {
-                    source
+                let column_source = if let Some((source_field, source_index)) =
+                    field_id_to_source_schema_map.get(field_id)
+                {
+                    if source_field.data_type().equals_datatype(target_type) {

Review Comment:
   The spec allows reordering and renaming fields in nested structs, not just 
top-level ones ([Schema 
Evolution](https://iceberg.apache.org/spec/#schema-evolution): "Any struct, 
including a top-level schema, can evolve through deleting fields, adding new 
fields, renaming existing fields, reordering existing fields"). [Column 
Projection](https://iceberg.apache.org/spec/#column-projection) then requires 
projection by id because "the table schema's column names and order may change 
after a data file is written".
   
   `DataType::equals_datatype` compares struct children by position, type, and 
nullability only. It ignores child names and metadata, so it ignores 
`PARQUET:field_id` too ([arrow-schema 58.4.0 
`datatype.rs`](https://github.com/apache/arrow-rs/blob/58.4.0/arrow-schema/src/datatype.rs#L683-L689)).
 When a nested struct's children are reordered or renamed but their types still 
line up, this check returns true and the column takes 
`ColumnSource::PassThrough` without ever reaching `PromotePlan`. Could you add 
a test for this case? Here's one I ran on the head commit:
   
   ```rust
   #[test]
   fn reorder_nested_struct_fields_with_identical_types() {
       let snapshot_schema = Arc::new(
           Schema::builder()
               .with_schema_id(1)
               .with_fields(vec![
                   NestedField::optional(
                       2,
                       "s",
                       Type::Struct(crate::spec::StructType::new(vec![
                           NestedField::optional(6, "b", 
Type::Primitive(PrimitiveType::Int)).into(),
                           NestedField::optional(5, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
                       ])),
                   )
                   .into(),
               ])
               .build()
               .unwrap(),
       );
       let mut transformer = 
RecordBatchTransformerBuilder::new(snapshot_schema, &[2]).build();
   
       let file_fields = Fields::from(vec![
           field_with_id("a", DataType::Int32, true, 5),
           field_with_id("b", DataType::Int32, true, 6),
       ]);
       let file_schema = Arc::new(ArrowSchema::new(vec![field_with_id(
           "s",
           DataType::Struct(file_fields.clone()),
           true,
           2,
       )]));
       let file_batch = RecordBatch::try_new(file_schema, 
vec![Arc::new(StructArray::new(
           file_fields,
           vec![
               Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
               Arc::new(Int32Array::from(vec![100, 200])) as ArrayRef,
           ],
           None,
       ))])
       .unwrap();
   
       let result = transformer.process_record_batch(file_batch).unwrap();
       let s = result.column(0).as_struct();
       assert_eq!(s.fields()[0].name(), "b");
       assert_eq!(s.column(0).as_primitive::<Int32Type>().values(), &[100, 
200]);
   }
   ```
   
   On the head commit, the batch schema says position 0 is `b` (id 6), but the 
array there holds `a`'s values `[1, 2]`. The struct array also keeps the file's 
field order, so its data type no longer matches the schema. A rename with no 
type change fails the same way: the array keeps the old child name while the 
schema has the new one. Your 
`promote_evolved_nested_struct_via_process_record_batch` test only gets the 
rename right because the int-to-long promotion forces the `Promote` path.
   
   Can we route these columns through `PromotePlan::build` and let the plan 
decide when to pass through? One constraint: files with no field ids rely on 
today's positional pass-through 
([`test_read_parquet_without_field_ids_with_struct`](https://github.com/apache/iceberg-rust/blob/832a4eba7a732baebbbd7f76bb6fd6225f1db1e9/crates/iceberg/src/arrow/reader/projection.rs#L1378)).
 The reader assigns fallback ids to top-level fields only, so routing 
everything through the plan trips the new "no source field carries a field id" 
guard. This version keeps that path working:
   
   ```rust
   // generate_transform_operations
   match PromotePlan::build(source_field.data_type(), target_type, 
snapshot_schema)? {
       PromotePlan::PassThrough => ColumnSource::PassThrough {
           source_index: *source_index,
       },
       plan => ColumnSource::Promote {
           plan,
           source_index: *source_index,
       },
   }
   
   // PromotePlan::build, before the existing struct arm
   (DataType::Struct(source_fields), DataType::Struct(_))
       if source.equals_datatype(target)
           && source_fields
               .iter()
               .all(|f| !f.metadata().contains_key(PARQUET_FIELD_ID_META_KEY)) 
=>
   {
       Ok(PromotePlan::PassThrough)
   }
   ```
   
   With both changes, the test above and a rename-only variant pass, and so 
does all of `cargo test -p iceberg --lib` (1482 tests). The per-batch cost for 
a struct whose ids match but whose `Field`s aren't strictly equal (a `doc` 
metadata entry, for example) is one `StructArray` rebuild from `Arc` clones.



##########
crates/iceberg/src/arrow/record_batch_transformer.rs:
##########
@@ -192,6 +154,272 @@ pub(crate) enum ColumnSource {
     // post-processing step by using the projection mask.
 }
 
+/// Reconciles a source array to the target type by matching nested fields on
+/// field id rather than position, mirroring iceberg-java's nested readers.
+///
+/// The plan is resolved once per file, when the `BatchTransform` is built, so
+/// applying it per batch is just index lookups and array assembly.
+#[derive(Debug)]
+pub(crate) enum PromotePlan {
+    PassThrough,
+    Cast(DataType),
+    Struct {
+        fields: Fields,
+        children: Vec<ChildPlan>,
+    },
+    List {
+        field: FieldRef,
+        element: Box<PromotePlan>,
+    },
+    LargeList {
+        field: FieldRef,
+        element: Box<PromotePlan>,
+    },
+    Map {
+        field: FieldRef,
+        entry_fields: Fields,
+        entries: Vec<ChildPlan>,
+        sorted: bool,
+    },
+}
+
+#[derive(Debug)]
+pub(crate) enum ChildPlan {
+    FromSource {
+        source_index: usize,
+        plan: PromotePlan,
+    },
+    Null {
+        target_type: DataType,
+    },
+}
+
+impl PromotePlan {
+    fn build(
+        source: &DataType,
+        target: &DataType,
+        snapshot_schema: &IcebergSchema,
+    ) -> Result<Self> {
+        if source == target {
+            return Ok(PromotePlan::PassThrough);
+        }
+        match (source, target) {
+            (DataType::Struct(source_fields), DataType::Struct(target_fields)) 
=> {
+                Ok(PromotePlan::Struct {
+                    children: Self::build_struct_children(
+                        source_fields,
+                        target_fields,
+                        snapshot_schema,
+                    )?,
+                    fields: target_fields.clone(),
+                })
+            }
+            (DataType::List(source_field), DataType::List(target_field)) => 
Ok(PromotePlan::List {
+                element: Box::new(Self::build(
+                    source_field.data_type(),
+                    target_field.data_type(),
+                    snapshot_schema,
+                )?),
+                field: target_field.clone(),
+            }),
+            (DataType::LargeList(source_field), 
DataType::LargeList(target_field)) => {
+                Ok(PromotePlan::LargeList {
+                    element: Box::new(Self::build(
+                        source_field.data_type(),
+                        target_field.data_type(),
+                        snapshot_schema,
+                    )?),
+                    field: target_field.clone(),
+                })
+            }
+            (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)) => {
+                        Ok(PromotePlan::Map {
+                            entries: Self::build_struct_children(
+                                source_fields,
+                                target_fields,
+                                snapshot_schema,
+                            )?,
+                            entry_fields: target_fields.clone(),
+                            field: target_entries.clone(),
+                            sorted: *sorted,
+                        })
+                    }
+                    _ => Err(Error::new(
+                        ErrorKind::Unexpected,
+                        format!(
+                            "expected struct-typed map entries, got 
{source_entries:?} and {target_entries:?}"
+                        ),
+                    )),
+                }
+            }
+            (_, DataType::Struct(_) | DataType::List(_) | 
DataType::LargeList(_))
+            | (_, DataType::Map(_, _)) => Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!("cannot promote {source:?} to {target:?}"),
+            )),
+            _ => Ok(PromotePlan::Cast(target.clone())),
+        }
+    }
+
+    fn build_struct_children(
+        source_fields: &Fields,
+        target_fields: &Fields,
+        snapshot_schema: &IcebergSchema,
+    ) -> Result<Vec<ChildPlan>> {
+        let mut source_by_id = HashMap::with_capacity(source_fields.len());
+        for (idx, field) in source_fields.iter().enumerate() {
+            if let Some(id) = try_get_field_id_from_metadata(field)? {
+                source_by_id.insert(id, idx);
+            }
+        }
+        // Reachable for id-less files with nested types because the reader 
only
+        // applies name mapping to top-level fields; error rather than silently
+        // nulling every child.
+        if !source_fields.is_empty() && source_by_id.is_empty() {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                "cannot reconcile struct fields by id: no source field carries 
a field id",
+            ));
+        }
+
+        target_fields
+            .iter()
+            .map(|target_field| {
+                let field_id = try_get_field_id_from_metadata(target_field)?;
+                match field_id.and_then(|id| source_by_id.get(&id).copied()) {
+                    Some(source_index) => Ok(ChildPlan::FromSource {
+                        plan: Self::build(
+                            source_fields[source_index].data_type(),
+                            target_field.data_type(),
+                            snapshot_schema,
+                        )?,
+                        source_index,
+                    }),
+                    None => {
+                        // Nested fields absent from the file only support 
rule #4
+                        // (null) of the spec's Column Projection rules; rule 
#3
+                        // (initial-default) is only wired up for top-level 
columns
+                        // via ColumnSource::Add.
+                        let iceberg_field =
+                            field_id.and_then(|id| 
snapshot_schema.field_by_id(id));
+                        if iceberg_field.is_some_and(|f| 
f.initial_default.is_some()) {
+                            return Err(Error::new(
+                                ErrorKind::FeatureUnsupported,
+                                format!(
+                                    "initial-default of nested field {} is not 
supported",
+                                    target_field.name()
+                                ),
+                            ));

Review Comment:
   Returning `FeatureUnsupported` here is better than a silent null, and it 
keeps the gap visible. The spec does define this case, though. [Default 
values](https://iceberg.apache.org/spec/#default-values) covers sub-field 
defaults in nested structs, and its table gives `{"x": 3}` with `point.y` 
defaulting to `0` as `{"x": 3, "y": 0}`. On a v3 table with a defaulted nested 
field, reads of older files will fail until this is implemented. There's no 
issue for it yet. Could you open one and reference it in this comment and the 
error message, so it doesn't get lost after merge?



-- 
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]

Reply via email to