This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new fc732b08 feat(read): complete nested projection pushdown for Parquet 
(#1248)
fc732b08 is described below

commit fc732b08961c020ecf3c6654d5d2b0b222394bb7
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 29 08:44:19 2026 +0800

    feat(read): complete nested projection pushdown for Parquet (#1248)
---
 crates/paimon/src/arrow/format/avro.rs             |  35 +-
 crates/paimon/src/arrow/format/mod.rs              |  82 +-
 crates/paimon/src/arrow/format/parquet.rs          | 106 ++-
 crates/paimon/src/arrow/format/shredding.rs        |  13 +
 crates/paimon/src/arrow/format/text.rs             |   2 +-
 crates/paimon/src/arrow/format/vortex.rs           | 152 ++++
 crates/paimon/src/arrow/format/vortex/nested.rs    | 278 +++++++
 crates/paimon/src/arrow/shredding/map.rs           |  31 +-
 crates/paimon/src/table/data_file_reader.rs        | 223 +++---
 .../data_file_reader/nested_projection_tests.rs    | 835 +++++++++++++++++++++
 .../nested_projection/GenerateLegacyLists.java     |  61 ++
 .../testdata/nested_projection/array.parquet       | Bin 0 -> 550 bytes
 .../testdata/nested_projection/generate_orc.py     |  45 ++
 .../paimon/testdata/nested_projection/nested.orc   | Bin 0 -> 4018 bytes
 .../testdata/nested_projection/repeated.parquet    | Bin 0 -> 520 bytes
 .../testdata/nested_projection/standard.parquet    | Bin 0 -> 578 bytes
 .../testdata/nested_projection/tuple.parquet       | Bin 0 -> 568 bytes
 17 files changed, 1706 insertions(+), 157 deletions(-)

diff --git a/crates/paimon/src/arrow/format/avro.rs 
b/crates/paimon/src/arrow/format/avro.rs
index 37f01c51..2b593559 100644
--- a/crates/paimon/src/arrow/format/avro.rs
+++ b/crates/paimon/src/arrow/format/avro.rs
@@ -119,14 +119,15 @@ impl FormatFileReader for AvroFormatReader {
 // Value access helpers — work directly with apache_avro::types::Value
 // ---------------------------------------------------------------------------
 
-/// Find the position of a named field from the first record.
+/// Find the position of a named field from a populated record.
 /// All records in an Avro file share the same schema, so the index is valid
 /// for every record.
 fn field_index(records: &[Value], name: &str) -> Option<usize> {
-    match records.first() {
-        Some(Value::Record(fields)) => fields.iter().position(|(n, _)| n == 
name),
+    // Null parent ROWs are represented by empty records while building 
children.
+    records.iter().find_map(|record| match record {
+        Value::Record(fields) => fields.iter().position(|(n, _)| n == name),
         _ => None,
-    }
+    })
 }
 
 /// Look up a field by cached index, unwrapping unions.
@@ -829,6 +830,32 @@ mod tests {
     // unwrap_value
     // -----------------------------------------------------------------------
 
+    #[test]
+    fn first_null_nested_row_does_not_hide_later_children() {
+        let typ = DataType::Row(RowType::new(vec![DataField::new(
+            1,
+            "keep".into(),
+            DataType::Int(IntType::new()),
+        )]));
+        let records = vec![
+            Value::Record(vec![("profile".into(), Value::Null)]),
+            Value::Record(vec![(
+                "profile".into(),
+                Value::Record(vec![("keep".into(), Value::Int(7))]),
+            )]),
+        ];
+        let array = build_column(&records, "profile", &typ, 
records.len()).unwrap();
+        let rows = array.as_any().downcast_ref::<StructArray>().unwrap();
+        assert!(rows.is_null(0));
+        let child = rows
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        assert!(child.is_valid(1));
+        assert_eq!(child.value(1), 7);
+    }
+
     #[test]
     fn test_unwrap_value_null() {
         assert!(unwrap_value(&av_null()).is_none());
diff --git a/crates/paimon/src/arrow/format/mod.rs 
b/crates/paimon/src/arrow/format/mod.rs
index 3de32c7e..c85c0578 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -69,15 +69,24 @@ pub(crate) struct FilePredicates {
 /// - Row range selection
 #[async_trait]
 pub(crate) trait FormatFileReader: Send + Sync {
+    /// Whether the decoder can prune fields inside ROW, ARRAY and MAP types.
+    /// Other formats read complete top-level fields and let the caller 
project.
+    fn supports_nested_projection(&self) -> bool {
+        false
+    }
+
     /// Choose the fields the decoder must actually read. Most columnar formats
     /// can read the projection, while positional formats need the complete
     /// physical data schema to decode each row.
     fn select_read_fields(
         &self,
-        _data_schema_fields: &[DataField],
+        data_schema_fields: &[DataField],
         projected_fields: &[DataField],
     ) -> Vec<DataField> {
-        projected_fields.to_vec()
+        if self.supports_nested_projection() {
+            return projected_fields.to_vec();
+        }
+        top_level_read_fields(data_schema_fields, projected_fields)
     }
 
     /// Read a single data file, returning a stream of RecordBatches containing
@@ -367,6 +376,29 @@ pub(crate) fn create_format_reader(
     .map(|configured| configured.reader)
 }
 
+/// Expand nested projections for readers that only select whole root fields.
+/// Keep selected-key instructions so the outer reader can assemble their ROW.
+fn top_level_read_fields(
+    data_schema_fields: &[DataField],
+    projected_fields: &[DataField],
+) -> Vec<DataField> {
+    projected_fields
+        .iter()
+        .map(|projected| {
+            let Some(source) = data_schema_fields.iter().find(|f| f.id() == 
projected.id()) else {
+                return projected.clone();
+            };
+            let mut source = source.clone();
+            if projected.description().is_some_and(|description| {
+                
description.starts_with(crate::spec::MAP_SELECTED_KEYS_METADATA_KEY)
+            }) {
+                source = 
source.with_description(projected.description().map(str::to_string));
+            }
+            source
+        })
+        .collect()
+}
+
 pub(crate) struct ConfiguredFormatReader {
     pub reader: Box<dyn FormatFileReader>,
     pub read_fields: Vec<DataField>,
@@ -773,6 +805,52 @@ mod tests {
         }
     }
 
+    #[test]
+    fn nested_projection_is_expanded_for_formats_without_support() {
+        use crate::spec::{ArrayType, RowType};
+        let child = |id, name: &str| DataField::new(id, name.into(), 
DataType::Int(IntType::new()));
+        let field = |children| {
+            DataField::new(
+                0,
+                "items".into(),
+                
DataType::Array(ArrayType::new(DataType::Row(RowType::new(children)))),
+            )
+        };
+        let physical = vec![field(vec![child(1, "first"), child(2, 
"second")])];
+        let projected = vec![field(vec![child(2, "second")])];
+        for path in [
+            "data.parquet",
+            "data.orc",
+            "data.avro",
+            "data.json",
+            "data.row",
+        ] {
+            let configured = create_format_reader_with_budget(
+                path,
+                false,
+                FormatReadFields {
+                    data_schema: &physical,
+                    projected: &projected,
+                },
+                &HashMap::new(),
+                None,
+                blob::DEFAULT_BLOB_READ_PARALLELISM,
+                MosaicPrefetchOptions::default(),
+            )
+            .unwrap();
+            assert_eq!(
+                configured.read_fields,
+                if path.ends_with("parquet") {
+                    &projected
+                } else {
+                    &physical
+                }
+                .clone(),
+                "{path}"
+            );
+        }
+    }
+
     #[tokio::test]
     async fn 
direct_writer_rejects_unsupported_map_shredding_before_creating_file() {
         use crate::spec::{MapType, VarCharType};
diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index c678b529..0ade1fbf 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -882,6 +882,10 @@ fn dictionary_variant_metadata_schema(
 
 #[async_trait]
 impl FormatFileReader for ParquetFormatReader {
+    fn supports_nested_projection(&self) -> bool {
+        true
+    }
+
     async fn read_batch_stream(
         &self,
         reader: Box<dyn FileRead>,
@@ -1378,6 +1382,15 @@ fn read_type_projection_mask(
     predicates: &[Predicate],
     null_residuals: &std::collections::HashSet<Vec<String>>,
 ) -> ProjectionMask {
+    fn is_list(physical: &parquet::schema::types::Type) -> bool {
+        let info = physical.get_basic_info();
+        !physical.is_primitive()
+            && match info.logical_type_ref() {
+                Some(logical) => logical == &parquet::basic::LogicalType::List,
+                None => info.converted_type() == 
parquet::basic::ConvertedType::LIST,
+            }
+    }
+
     fn requested_paths(
         field: &DataField,
         physical: &parquet::schema::types::Type,
@@ -1393,18 +1406,54 @@ fn read_type_projection_mask(
             return;
         }
         let data_type = field.data_type();
+        if let DataType::Array(array) = data_type {
+            let element =
+                DataField::new(field.id(), "element".into(), 
array.element_type().clone());
+            if is_list(physical) && physical.get_fields().len() == 1 {
+                // Follow arrow-rs's LIST conversion rules, including legacy
+                // two-level lists of ROWs named `array` or `<name>_tuple`.
+                let repeated = &physical.get_fields()[0];
+                path.push(repeated.name().to_string());
+                if !repeated.is_primitive()
+                    && repeated.get_fields().len() == 1
+                    && (is_list(repeated)
+                        || 
repeated.get_fields()[0].get_basic_info().repetition()
+                            == parquet::basic::Repetition::REPEATED
+                        || (repeated.name() != "array"
+                            && repeated.name() != format!("{}_tuple", 
physical.name())))
+                {
+                    let child = &repeated.get_fields()[0];
+                    path.push(child.name().to_string());
+                    requested_paths(&element, child, path, output, 
null_residuals);
+                    path.pop();
+                } else {
+                    requested_paths(&element, repeated, path, output, 
null_residuals);
+                }
+                path.pop();
+                return;
+            }
+            if physical.get_basic_info().has_repetition()
+                && physical.get_basic_info().repetition() == 
parquet::basic::Repetition::REPEATED
+            {
+                // Unannotated repeated fields are also decoded as lists.
+                requested_paths(&element, physical, path, output, 
null_residuals);
+                return;
+            }
+        }
         if let DataType::Map(map) = data_type {
             if !physical.is_primitive() && physical.get_fields().len() == 1 {
                 let entries = &physical.get_fields()[0];
-                if !entries.is_primitive()
-                    && entries.get_fields().len() == 2
-                    && entries.get_fields()[0].name() == "key"
-                    && entries.get_fields()[1].name() == "value"
-                {
+                if !entries.is_primitive() && entries.get_fields().len() == 2 {
                     path.push(entries.name().to_string());
                     let key = &entries.get_fields()[0];
                     path.push(key.name().to_string());
-                    super::variant_projection::all_leaves(key, path, output);
+                    requested_paths(
+                        &DataField::new(field.id(), key.name().into(), 
map.key_type().clone()),
+                        key,
+                        path,
+                        output,
+                        null_residuals,
+                    );
                     path.pop();
                     let value = &entries.get_fields()[1];
                     path.push(value.name().to_string());
@@ -1476,18 +1525,16 @@ fn read_type_projection_mask(
             .any(|(column, _)| *column == field.name())
         {
             super::variant_projection::all_leaves(root, &mut path, &mut 
selected);
-        } else if let Some(children) =
-            map_plan.and_then(|plan| 
plan.projected_physical_children(field.name()))
+        } else if let Some(physical_field) =
+            map_plan.and_then(|plan| 
plan.projected_physical_field(field.name()))
         {
-            for child in root
-                .get_fields()
-                .iter()
-                .filter(|child| children.contains(child.name()))
-            {
-                path.push(child.name().to_string());
-                super::variant_projection::all_leaves(child, &mut path, &mut 
selected);
-                path.pop();
-            }
+            requested_paths(
+                &physical_field,
+                root,
+                &mut path,
+                &mut selected,
+                null_residuals,
+            );
         } else {
             requested_paths(field, root, &mut path, &mut selected, 
null_residuals);
         }
@@ -3409,6 +3456,31 @@ mod tests {
         }
     }
 
+    #[test]
+    fn array_read_type_masks_standard_and_legacy_list_leaves() {
+        use crate::spec::RowType;
+        let requested = vec![DataField::new(
+            0,
+            "items".into(),
+            DataType::Array(ArrayType::new(DataType::Row(RowType::new(vec![
+                DataField::new(2, "wanted".into(), 
DataType::Int(IntType::new())),
+            ])))),
+        )];
+        for layout in [
+            "optional group items (LIST) { repeated group list { optional 
group element { optional int32 unused; optional int32 wanted; } } }",
+            "optional group items (LIST) { repeated group array { optional 
int32 unused; optional int32 wanted; } }",
+            "optional group items (LIST) { repeated group items_tuple { 
optional int32 unused; optional int32 wanted; } }",
+            "repeated group items { optional int32 unused; optional int32 
wanted; }",
+        ] {
+            let descriptor = SchemaDescriptor::new(Arc::new(
+                parse_message_type(&format!("message schema {{ {layout} 
}}")).unwrap()));
+            let mask = super::read_type_projection_mask(
+                &descriptor, &requested, None, &[], &Default::default());
+            assert!(!mask.leaf_included(0), "{layout}");
+            assert!(mask.leaf_included(1), "{layout}");
+        }
+    }
+
     #[test]
     fn selected_map_rows_keep_all_map_leaves_without_a_shredding_plan() {
         use crate::spec::RowType;
diff --git a/crates/paimon/src/arrow/format/shredding.rs 
b/crates/paimon/src/arrow/format/shredding.rs
index 5a341a8e..981e2ecc 100644
--- a/crates/paimon/src/arrow/format/shredding.rs
+++ b/crates/paimon/src/arrow/format/shredding.rs
@@ -148,6 +148,19 @@ impl ShreddingFormatReader {
 
 #[async_trait]
 impl FormatFileReader for ShreddingFormatReader {
+    fn supports_nested_projection(&self) -> bool {
+        self.inner.supports_nested_projection()
+    }
+
+    fn select_read_fields(
+        &self,
+        data_schema_fields: &[DataField],
+        projected_fields: &[DataField],
+    ) -> Vec<DataField> {
+        self.inner
+            .select_read_fields(data_schema_fields, projected_fields)
+    }
+
     async fn read_batch_stream(
         &self,
         reader: Box<dyn FileRead>,
diff --git a/crates/paimon/src/arrow/format/text.rs 
b/crates/paimon/src/arrow/format/text.rs
index 61f1ce0f..c2e431e5 100644
--- a/crates/paimon/src/arrow/format/text.rs
+++ b/crates/paimon/src/arrow/format/text.rs
@@ -633,7 +633,7 @@ impl FormatFileReader for TextFormatReader {
         // still has to decode their original physical column positions.
         match self.kind {
             TextKind::Csv | TextKind::Text => data_schema_fields.to_vec(),
-            TextKind::Json => projected_fields.to_vec(),
+            TextKind::Json => super::top_level_read_fields(data_schema_fields, 
projected_fields),
         }
     }
 
diff --git a/crates/paimon/src/arrow/format/vortex.rs 
b/crates/paimon/src/arrow/format/vortex.rs
index 2075b3ab..92fb7bf9 100644
--- a/crates/paimon/src/arrow/format/vortex.rs
+++ b/crates/paimon/src/arrow/format/vortex.rs
@@ -42,6 +42,8 @@ use vortex::scan::selection::Selection;
 use vortex::session::VortexSession;
 use vortex::VortexSessionDefault;
 
+mod nested;
+
 async fn acquire_vortex_io_permit() -> 
crate::Result<tokio::sync::SemaphorePermit<'static>> {
     static SEMAPHORE: OnceLock<tokio::sync::Semaphore> = OnceLock::new();
     SEMAPHORE
@@ -174,6 +176,49 @@ fn read_vortex_batches(
         return Ok(vec![batch]);
     }
 
+    if scan_schema
+        .fields()
+        .iter()
+        .any(|field| matches!(field.data_type(), ArrowDataType::Struct(_)))
+    {
+        // Vortex 0.75 can apply a child ROW's validity to its parent while
+        // projecting recursive struct layouts. Read complete ROWs and their
+        // independent validity before applying selection and outer projection.
+        let names = scan_schema
+            .fields()
+            .iter()
+            .map(|field| field.name().clone())
+            .collect::<Vec<_>>();
+        let mut array = nested::read_array(runtime, &session, &vortex_file, 
&names)?;
+        if let Some(ranges) = row_selection.as_ref() {
+            let Selection::IncludeRoaring(rows) =
+                row_ranges_to_selection(ranges, vortex_file.row_count())
+            else {
+                unreachable!()
+            };
+            let indices = 
arrow_array::UInt64Array::from_iter_values(rows.iter());
+            let indices =
+                ArrayRef::from_arrow(&indices, false).map_err(|error| 
Error::DataInvalid {
+                    message: format!("Failed to build Vortex row selection: 
{error}"),
+                    source: None,
+                })?;
+            // Take before Arrow export so sparse reads do not expand every
+            // unselected ROW into Arrow buffers.
+            array = array.take(indices).map_err(|error| Error::DataInvalid {
+                message: format!("Failed to select Vortex rows: {error}"),
+                source: None,
+            })?;
+        }
+        let batch = vortex_array_to_record_batch(&session, array, 
&scan_schema)?;
+        return Ok(vec![filter_and_project_batch(
+            batch,
+            &target_schema,
+            &read_fields,
+            &scan_fields,
+            predicates.as_ref(),
+        )?]);
+    }
+
     // Build projection expression for requested fields.
     let projected_names: Vec<&str> = scan_fields.iter().map(|f| 
f.name()).collect();
 
@@ -614,6 +659,113 @@ mod tests {
         ]))
     }
 
+    #[tokio::test]
+    async fn nullable_single_child_row_preserves_parent_validity() {
+        use crate::spec::{IntType, RowType};
+        use arrow_array::{Array, StructArray};
+        use arrow_buffer::NullBuffer;
+        use futures::TryStreamExt;
+        let fields = vec![DataField::new(
+            0,
+            "profile".into(),
+            DataType::Row(RowType::new(vec![DataField::new(
+                1,
+                "detail".into(),
+                DataType::Row(RowType::new(vec![DataField::new(
+                    2,
+                    "keep".into(),
+                    DataType::Int(IntType::new()),
+                )])),
+            )])),
+        )];
+        let schema = crate::arrow::build_target_arrow_schema(&fields).unwrap();
+        let ArrowDataType::Struct(profile_fields) = 
schema.field(0).data_type() else {
+            unreachable!()
+        };
+        let ArrowDataType::Struct(detail_fields) = 
profile_fields[0].data_type() else {
+            unreachable!()
+        };
+        let detail = StructArray::new(
+            detail_fields.clone(),
+            vec![Arc::new(Int32Array::from(vec![Some(7), None, None]))],
+            Some(NullBuffer::from(vec![true, false, false])),
+        );
+        let profile = StructArray::new(
+            profile_fields.clone(),
+            vec![Arc::new(detail)],
+            Some(NullBuffer::from(vec![true, true, false])),
+        );
+        let original = RecordBatch::try_new(schema.clone(), 
vec![Arc::new(profile)]).unwrap();
+        let memory = vortex_array_to_record_batch(
+            &VortexSession::default(),
+            ArrayRef::from_arrow(original.clone(), false).unwrap(),
+            &schema,
+        )
+        .unwrap();
+        assert!(memory.column(0).is_valid(1), "in-memory conversion");
+        for repeats in [1, 3000] {
+            let input =
+                arrow_select::concat::concat_batches(&schema, 
(0..repeats).map(|_| &original))
+                    .unwrap();
+            let io = FileIOBuilder::new("memory").build().unwrap();
+            let path = 
format!("memory:/nullable_single_child_{repeats}.vortex");
+            let output = io.new_output(&path).unwrap();
+            let mut writer = VortexFormatWriter::new(&output, schema.clone())
+                .await
+                .unwrap();
+            writer.write(&input).await.unwrap();
+            let size = Box::new(writer).close().await.unwrap().file_size;
+            let reader = VortexFormatReader;
+            let batches = reader
+                .read_batch_stream(
+                    
Box::new(io.new_input(&path).unwrap().reader().await.unwrap()),
+                    size,
+                    &fields,
+                    None,
+                    None,
+                    None,
+                )
+                .await
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            let actual = arrow_select::concat::concat_batches(&schema, 
&batches).unwrap();
+            assert_eq!(actual, input, "{repeats} repeats");
+            if repeats == 3000 {
+                let ranges = vec![
+                    RowRange::new(-2, 1),
+                    RowRange::new(1, 1),
+                    RowRange::new(8191, 8192),
+                    RowRange::new(8999, 10000),
+                ];
+                let batches = reader
+                    .read_batch_stream(
+                        
Box::new(io.new_input(&path).unwrap().reader().await.unwrap()),
+                        size,
+                        &fields,
+                        None,
+                        None,
+                        Some(ranges),
+                    )
+                    .await
+                    .unwrap()
+                    .try_collect::<Vec<_>>()
+                    .await
+                    .unwrap();
+                let expected = arrow_select::take::take_record_batch(
+                    &input,
+                    &arrow_array::UInt64Array::from(vec![0, 1, 8191, 8192, 
8999]),
+                )
+                .unwrap();
+                assert_eq!(
+                    arrow_select::concat::concat_batches(&schema, 
&batches).unwrap(),
+                    expected
+                );
+            }
+        }
+    }
+
     fn test_batch(schema: &Arc<ArrowSchema>, ids: Vec<i32>, values: Vec<i32>) 
-> RecordBatch {
         RecordBatch::try_new(
             schema.clone(),
diff --git a/crates/paimon/src/arrow/format/vortex/nested.rs 
b/crates/paimon/src/arrow/format/vortex/nested.rs
new file mode 100644
index 00000000..b19dfd2a
--- /dev/null
+++ b/crates/paimon/src/arrow/format/vortex/nested.rs
@@ -0,0 +1,278 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Read complete ROWs without Vortex 0.75's recursive StructReader projection.
+//! Each ROW has independent validity; a null child must not null its parent.
+
+use super::CurrentThreadRuntime;
+use crate::{Error, Result};
+use std::sync::Arc;
+use vortex::array::arrays::struct_::StructArrayExt;
+use vortex::array::arrays::{ChunkedArray, DictArray, StructArray};
+use vortex::array::dtype::{DType, FieldNames, StructFields};
+use vortex::array::validity::Validity;
+use vortex::array::{ArrayRef, IntoArray, VortexSessionExecute};
+use vortex::file::VortexFile;
+use vortex::io::runtime::BlockingRuntime;
+use vortex::layout::layouts::{chunked::Chunked, dict::Dict, struct_::Struct};
+use vortex::layout::scan::{scan_builder::ScanBuilder, split_by::SplitBy};
+use vortex::layout::segments::SegmentSource;
+use vortex::layout::{LayoutChildType, LayoutRef};
+use vortex::session::VortexSession;
+
+fn invalid(error: impl std::fmt::Display) -> Error {
+    Error::DataInvalid {
+        message: format!("Failed to read complete Vortex ROW: {error}"),
+        source: None,
+    }
+}
+
+pub(super) fn read_array(
+    runtime: &CurrentThreadRuntime,
+    session: &VortexSession,
+    file: &VortexFile,
+    names: &[String],
+) -> Result<ArrayRef> {
+    read_layout(
+        runtime,
+        session,
+        file.segment_source(),
+        file.footer().layout(),
+        Some(names),
+    )
+}
+
+fn projected_dtype(dtype: &DType, names: Option<&[String]>) -> Result<DType> {
+    let Some(names) = names else {
+        return Ok(dtype.clone());
+    };
+    let DType::Struct(fields, nullable) = dtype else {
+        return Err(invalid("expected a root ROW"));
+    };
+    let types = names
+        .iter()
+        .map(|name| {
+            fields
+                .field(name.as_str())
+                .ok_or_else(|| invalid(format!("missing root field {name}")))
+        })
+        .collect::<Result<Vec<_>>>()?;
+    Ok(DType::Struct(
+        StructFields::new(
+            FieldNames::from_iter(names.iter().map(String::as_str)),
+            types,
+        ),
+        *nullable,
+    ))
+}
+
+fn read_layout(
+    runtime: &CurrentThreadRuntime,
+    session: &VortexSession,
+    source: Arc<dyn SegmentSource>,
+    layout: &LayoutRef,
+    roots: Option<&[String]>,
+) -> Result<ArrayRef> {
+    let dtype = projected_dtype(layout.dtype(), roots)?;
+    if layout.is::<Struct>() {
+        let DType::Struct(fields, _) = &dtype else {
+            return Err(invalid("invalid struct layout"));
+        };
+        let DType::Struct(original, _) = layout.dtype() else {
+            return Err(invalid("invalid struct layout"));
+        };
+        let nullable = layout.dtype().is_nullable();
+        let validity = if nullable {
+            Validity::Array(read_layout(
+                runtime,
+                session,
+                source.clone(),
+                &layout.child(0).map_err(invalid)?,
+                None,
+            )?)
+        } else {
+            Validity::NonNullable
+        };
+        let mut columns = Vec::with_capacity(fields.nfields());
+        for name in fields.names().iter() {
+            let index = original
+                .find(name)
+                .ok_or_else(|| invalid(format!("missing root field {name}")))?;
+            let child = layout
+                .child(index + usize::from(nullable))
+                .map_err(invalid)?;
+            columns.push(read_layout(runtime, session, source.clone(), &child, 
None)?);
+        }
+        return StructArray::try_new(
+            fields.names().clone(),
+            columns,
+            usize::try_from(layout.row_count()).map_err(invalid)?,
+            validity,
+        )
+        .map(IntoArray::into_array)
+        .map_err(invalid);
+    }
+    if layout.is::<Chunked>() {
+        let mut chunks = Vec::with_capacity(layout.nchildren());
+        for index in 0..layout.nchildren() {
+            chunks.push(read_layout(
+                runtime,
+                session,
+                source.clone(),
+                &layout.child(index).map_err(invalid)?,
+                roots,
+            )?);
+        }
+        return ChunkedArray::try_new(chunks, dtype)
+            .map(IntoArray::into_array)
+            .map_err(invalid);
+    }
+    if layout.is::<Dict>() {
+        // Values use dictionary coordinates, codes use row coordinates.
+        let values = read_layout(
+            runtime,
+            session,
+            source.clone(),
+            &layout.child(0).map_err(invalid)?,
+            roots,
+        )?;
+        let codes = read_layout(
+            runtime,
+            session,
+            source,
+            &layout.child(1).map_err(invalid)?,
+            None,
+        )?;
+        return DictArray::try_new(codes, values)
+            .map(IntoArray::into_array)
+            .map_err(invalid);
+    }
+    // Zone maps wrap data without changing its schema or row coordinates.
+    for (index, kind) in layout.child_types().enumerate() {
+        if matches!(kind, LayoutChildType::Transparent(_)) {
+            let child = layout.child(index).map_err(invalid)?;
+            if child.dtype() == layout.dtype() && child.row_count() == 
layout.row_count() {
+                return read_layout(runtime, session, source, &child, roots);
+            }
+        }
+    }
+    let reader = layout
+        .new_reader("".into(), source, session, &Default::default())
+        .map_err(invalid)?;
+    let tasks = ScanBuilder::new(session.clone(), reader)
+        .with_concurrency(1)
+        .with_split_by(SplitBy::RowCount(
+            usize::try_from(layout.row_count()).map_err(invalid)?.max(1),
+        ))
+        .build()
+        .map_err(invalid)?;
+    let mut chunks = Vec::new();
+    for task in tasks {
+        if let Some(array) = runtime.block_on(task).map_err(invalid)? {
+            chunks.push(array);
+        }
+    }
+    let array = ChunkedArray::try_new(chunks, layout.dtype().clone())
+        .map(IntoArray::into_array)
+        .map_err(invalid)?;
+    let Some(names) = roots else {
+        return Ok(array);
+    };
+    // A flat root cannot skip decoding fields, but selecting from the decoded
+    // array preserves validity and does not invoke layout projection.
+    let mut ctx = session.create_execution_ctx();
+    let rows = array.execute::<StructArray>(&mut ctx).map_err(invalid)?;
+    let columns = names
+        .iter()
+        .map(|name| {
+            rows.unmasked_field_by_name(name.as_str())
+                .cloned()
+                .map_err(invalid)
+        })
+        .collect::<Result<Vec<_>>>()?;
+    StructArray::try_new(
+        FieldNames::from_iter(names.iter().map(String::as_str)),
+        columns,
+        rows.len(),
+        rows.validity().map_err(invalid)?,
+    )
+    .map(IntoArray::into_array)
+    .map_err(invalid)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use vortex::array::dtype::arrow::FromArrowType;
+    use vortex::io::session::RuntimeSessionExt;
+    use vortex::layout::layouts::chunked::ChunkedLayout;
+    use vortex::layout::segments::{SegmentFuture, SegmentId};
+    use vortex::layout::IntoLayout;
+    use vortex::layout::LayoutChildren;
+    use vortex::VortexSessionDefault;
+
+    #[derive(Clone)]
+    struct Empty;
+    impl SegmentSource for Empty {
+        fn request(&self, _: SegmentId) -> SegmentFuture {
+            panic!("empty layout must not read segments")
+        }
+    }
+    impl LayoutChildren for Empty {
+        fn to_arc(&self) -> Arc<dyn LayoutChildren> {
+            Arc::new(self.clone())
+        }
+        fn child(&self, _: usize, _: &DType) -> 
vortex::error::VortexResult<LayoutRef> {
+            panic!("empty layout has no children")
+        }
+        fn child_row_count(&self, _: usize) -> u64 {
+            panic!("empty layout has no children")
+        }
+        fn nchildren(&self) -> usize {
+            0
+        }
+    }
+
+    #[test]
+    fn empty_chunked_layout_preserves_projected_row_schema() {
+        use arrow_schema::{DataType, Field, Schema};
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("unused", DataType::Int32, true),
+            Field::new(
+                "profile",
+                DataType::Struct(vec![Field::new("keep", DataType::Int32, 
true)].into()),
+                true,
+            ),
+        ]));
+        let layout =
+            ChunkedLayout::new(0, DType::from_arrow(schema.clone()), 
Arc::new(Empty)).into_layout();
+        let runtime = CurrentThreadRuntime::new();
+        let session = VortexSession::default().with_handle(runtime.handle());
+        let array = read_layout(
+            &runtime,
+            &session,
+            Arc::new(Empty),
+            &layout,
+            Some(&["profile".into()]),
+        )
+        .unwrap();
+        let schema = Arc::new(schema.project(&[1]).unwrap());
+        let batch = super::super::vortex_array_to_record_batch(&session, 
array, &schema).unwrap();
+        assert_eq!(batch.num_rows(), 0);
+        assert_eq!(batch.schema(), schema);
+    }
+}
diff --git a/crates/paimon/src/arrow/shredding/map.rs 
b/crates/paimon/src/arrow/shredding/map.rs
index 7a2af66a..7bacb3e2 100644
--- a/crates/paimon/src/arrow/shredding/map.rs
+++ b/crates/paimon/src/arrow/shredding/map.rs
@@ -1115,9 +1115,7 @@ fn map_array_to_physical(
 /// shared-shredding physical ROW values.
 pub(crate) struct MapShreddingReadPlan {
     logical_fields: Vec<DataField>,
-    /// Physical fields decoded from the file; part of the mirrored
-    /// [`ShreddingReadPlan`] API surface.
-    #[allow(dead_code)]
+    /// Physical read types, including projections inside each MAP value.
     physical_fields: Vec<DataField>,
     logical_schema: arrow_schema::SchemaRef,
     contexts: HashMap<usize, MapReadContext>,
@@ -1209,16 +1207,17 @@ impl MapShreddingReadPlan {
         }))
     }
 
-    /// Return the physical struct children needed for one logical field.
-    /// `None` means the whole root field must be decoded.
-    pub(crate) fn projected_physical_children(&self, field_name: &str) -> 
Option<HashSet<String>> {
+    /// Map logical value projections onto every direct and overflow source.
+    pub(crate) fn projected_physical_field(&self, field_name: &str) -> 
Option<DataField> {
         let field_index = self
             .logical_fields
             .iter()
             .position(|field| field.name() == field_name)?;
         let ctx = self.contexts.get(&field_index)?;
-        ctx.selected_names.as_ref()?;
-
+        let physical = &self.physical_fields[field_index];
+        if ctx.selected_names.is_none() {
+            return Some(physical.clone());
+        }
         let mut children = HashSet::from([FIELD_MAPPING_NAME.to_string()]);
         children.extend(
             ctx.selected_columns
@@ -1228,7 +1227,21 @@ impl MapShreddingReadPlan {
         if ctx.include_overflow {
             children.insert(OVERFLOW_NAME.to_string());
         }
-        Some(children)
+        let DataType::Row(row) = physical.data_type() else {
+            return None;
+        };
+        Some(DataField::new(
+            physical.id(),
+            physical.name().into(),
+            DataType::Row(RowType::with_nullable(
+                physical.data_type().is_nullable(),
+                row.fields()
+                    .iter()
+                    .filter(|child| children.contains(child.name()))
+                    .cloned()
+                    .collect(),
+            )),
+        ))
     }
 }
 
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 9d548321..7b775da3 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -698,23 +698,8 @@ impl DataFileReader {
 
         let target_schema = crate::arrow::build_read_arrow_schema(&read_type)?;
         // What the reader is asked for.
-        let projected_read_fields: Vec<DataField> = if data_fields.is_some()
-            || file_meta.write_cols.is_some()
-            || read_type.iter().any(contains_selected_map)
-        {
-            read_data_fields_for_format(
-                &data_schema_fields,
-                &read_type,
-                self.nested_field_enabled,
-                file_meta.file_name.ends_with(".parquet"),
-            )?
-        } else {
-            read_type
-                .iter()
-                .filter(|field| field.name() != ROW_ID_FIELD_NAME)
-                .cloned()
-                .collect()
-        };
+        let projected_read_fields =
+            read_data_fields(&data_schema_fields, &read_type, 
self.nested_field_enabled)?;
         let path_to_read = split.data_file_path(&file_meta);
         let configured_reader = create_format_reader_with_budget(
             &path_to_read,
@@ -771,17 +756,9 @@ impl DataFileReader {
             // The decoded batch is described by `format_read_fields`, so map
             // `read_type` onto *that* list: its entries carry the types the 
columns
             // actually come back as, which is what reconciling them needs.
-            let (index_mapping, source_fields) = if data_fields.is_some()
-                || file_meta.write_cols.is_some()
-                || read_type.iter().any(contains_selected_map)
-            {
-                (
-                    create_index_mapping(&read_type, &format_read_fields),
-                    Some(format_read_fields.clone()),
-                )
-            } else {
-                (None, None)
-            };
+            let source_fields = projected_source_fields(&format_read_fields, 
&projected_read_fields);
+            let index_mapping = create_index_mapping(&read_type, 
&source_fields);
+            let source_fields = Some(source_fields);
 
             let input_file = file_io.new_input(&read_path)?;
             let open_start = read_timing.as_ref().map(|_| Instant::now());
@@ -1031,23 +1008,8 @@ impl DataFileReader {
         let file_fields = data_fields.clone().unwrap_or_else(|| 
table_fields.clone());
         let data_schema_fields = self.physical_data_fields(&file_meta, 
&file_fields, None)?;
         // What the reader is asked for.
-        let projected_read_fields: Vec<DataField> = if data_fields.is_some()
-            || file_meta.write_cols.is_some()
-            || read_type.iter().any(contains_selected_map)
-        {
-            read_data_fields_for_format(
-                &data_schema_fields,
-                &read_type,
-                self.nested_field_enabled,
-                file_meta.file_name.ends_with(".parquet"),
-            )?
-        } else {
-            read_type
-                .iter()
-                .filter(|field| field.name() != ROW_ID_FIELD_NAME)
-                .cloned()
-                .collect()
-        };
+        let projected_read_fields =
+            read_data_fields(&data_schema_fields, &read_type, 
self.nested_field_enabled)?;
         let path_to_read = split.data_file_path(&file_meta);
         let configured_reader = create_format_reader_with_budget(
             &path_to_read,
@@ -1066,17 +1028,9 @@ impl DataFileReader {
         // The decoded batch is described by `format_read_fields`, so map
         // `read_type` onto *that* list: its entries carry the types the 
columns
         // actually come back as, which is what reconciling them needs.
-        let (index_mapping, source_fields) = if data_fields.is_some()
-            || file_meta.write_cols.is_some()
-            || read_type.iter().any(contains_selected_map)
-        {
-            (
-                create_index_mapping(&read_type, &format_read_fields),
-                Some(format_read_fields.clone()),
-            )
-        } else {
-            (None, None)
-        };
+        let source_fields = projected_source_fields(&format_read_fields, 
&projected_read_fields);
+        let index_mapping = create_index_mapping(&read_type, &source_fields);
+        let source_fields = Some(source_fields);
 
         // Remap predicates from table-level to file-level indices.
         let file_predicates = {
@@ -1297,11 +1251,6 @@ fn project_file_batch(
     })
 }
 
-fn contains_selected_map(field: &DataField) -> bool {
-    crate::spec::is_map_selected_keys_field(field)
-        || matches!(field.data_type(), DataType::Row(row) if 
row.fields().iter().any(contains_selected_map))
-}
-
 fn row_tracking_sequence_column(
     physical: Option<&Arc<dyn Array>>,
     max_sequence_number: i64,
@@ -1327,25 +1276,55 @@ fn row_tracking_sequence_column(
     ))))
 }
 
-#[cfg(test)]
-fn read_data_fields(
-    all_data_fields: &[DataField],
-    expected_fields: &[DataField],
-    nested_field_enabled: bool,
-) -> crate::Result<Vec<DataField>> {
-    read_data_fields_for_format(
-        all_data_fields,
-        expected_fields,
-        nested_field_enabled,
-        false,
-    )
+/// Fallback formats decode complete roots. Keep the decoder's actual types,
+/// but do not resurrect descendants omitted by Java-compatible schema pruning.
+fn projected_source_fields(decoded: &[DataField], projected: &[DataField]) -> 
Vec<DataField> {
+    decoded
+        .iter()
+        .filter_map(|source| {
+            let projected = projected.iter().find(|field| field.id() == 
source.id())?;
+            Some(data_field_with_type(
+                source,
+                projected_source_type(source.data_type(), 
projected.data_type()),
+            ))
+        })
+        .collect()
 }
 
-fn read_data_fields_for_format(
+fn projected_source_type(decoded: &DataType, projected: &DataType) -> DataType 
{
+    // Synthetic ROW children describe extraction instructions, not physical
+    // field IDs. Reconciliation must see the decoder's VARIANT/MAP context.
+    if is_variant_extraction_row_type(projected) {
+        return decoded.clone();
+    }
+    match (decoded, projected) {
+        (DataType::Row(source), DataType::Row(target)) => {
+            DataType::Row(crate::spec::RowType::with_nullable(
+                decoded.is_nullable(),
+                projected_source_fields(source.fields(), target.fields()),
+            ))
+        }
+        (DataType::Array(source), DataType::Array(target)) => {
+            DataType::Array(crate::spec::ArrayType::with_nullable(
+                decoded.is_nullable(),
+                projected_source_type(source.element_type(), 
target.element_type()),
+            ))
+        }
+        (DataType::Map(source), DataType::Map(target)) => {
+            DataType::Map(crate::spec::MapType::with_nullable(
+                decoded.is_nullable(),
+                projected_source_type(source.key_type(), target.key_type()),
+                projected_source_type(source.value_type(), 
target.value_type()),
+            ))
+        }
+        _ => decoded.clone(),
+    }
+}
+
+fn read_data_fields(
     all_data_fields: &[DataField],
     expected_fields: &[DataField],
     nested_field_enabled: bool,
-    prune_map_values: bool,
 ) -> crate::Result<Vec<DataField>> {
     let mut read_fields = Vec::new();
     for data_field in all_data_fields {
@@ -1353,11 +1332,10 @@ fn read_data_fields_for_format(
             .iter()
             .find(|field| field.id() == data_field.id())
         {
-            if let Some(pruned_type) = prune_data_type_for_format(
+            if let Some(pruned_type) = prune_data_type(
                 expected.data_type(),
                 data_field.data_type(),
                 nested_field_enabled,
-                prune_map_values,
             )? {
                 let mut field = data_field_with_type(data_field, pruned_type);
                 if crate::spec::is_map_selected_keys_field(expected) {
@@ -1393,39 +1371,49 @@ fn is_physical_system_field(field: &DataField) -> bool {
     )
 }
 
-#[cfg(test)]
 fn prune_data_type(
     read_type: &DataType,
     data_type: &DataType,
     nested_field_enabled: bool,
-) -> crate::Result<Option<DataType>> {
-    prune_data_type_for_format(read_type, data_type, nested_field_enabled, 
false)
-}
-
-fn prune_data_type_for_format(
-    read_type: &DataType,
-    data_type: &DataType,
-    nested_field_enabled: bool,
-    prune_map_values: bool,
 ) -> crate::Result<Option<DataType>> {
     match read_type {
-        DataType::Map(read_map) if prune_map_values => {
+        DataType::Array(read_array) => {
+            let DataType::Array(data_array) = data_type else {
+                return Ok(Some(data_type.clone()));
+            };
+            Ok(prune_data_type(
+                read_array.element_type(),
+                data_array.element_type(),
+                nested_field_enabled,
+            )?
+            .map(|element| {
+                DataType::Array(crate::spec::ArrayType::with_nullable(
+                    data_type.is_nullable(),
+                    element,
+                ))
+            }))
+        }
+        DataType::Map(read_map) => {
             let DataType::Map(data_map) = data_type else {
                 return Ok(Some(data_type.clone()));
             };
-            // Keep file field IDs and ordering; reconciliation handles 
renames.
-            let value = prune_data_type_for_format(
+            let key = prune_data_type(
+                read_map.key_type(),
+                data_map.key_type(),
+                nested_field_enabled,
+            )?;
+            let value = prune_data_type(
                 read_map.value_type(),
                 data_map.value_type(),
-                true,
-                true,
-            )?
-            .unwrap_or_else(|| data_map.value_type().clone());
-            Ok(Some(DataType::Map(crate::spec::MapType::with_nullable(
-                data_type.is_nullable(),
-                data_map.key_type().clone(),
-                value,
-            ))))
+                nested_field_enabled,
+            )?;
+            Ok(key.zip(value).map(|(key, value)| {
+                DataType::Map(crate::spec::MapType::with_nullable(
+                    data_type.is_nullable(),
+                    key,
+                    value,
+                ))
+            }))
         }
         DataType::Row(read_row) if is_variant_extraction_row_type(read_type) 
=> {
             Ok(Some(DataType::Row(read_row.clone())))
@@ -1434,12 +1422,8 @@ fn prune_data_type_for_format(
             let DataType::Row(data_row) = data_type else {
                 return Ok(Some(data_type.clone()));
             };
-            let mut fields = read_data_fields_for_format(
-                data_row.fields(),
-                read_row.fields(),
-                nested_field_enabled,
-                prune_map_values,
-            )?;
+            let mut fields =
+                read_data_fields(data_row.fields(), read_row.fields(), 
nested_field_enabled)?;
             if fields.is_empty() {
                 if !nested_field_enabled {
                     return Ok(None);
@@ -1451,17 +1435,10 @@ fn prune_data_type_for_format(
                 }
             }
             Ok(Some(DataType::Row(crate::spec::RowType::with_nullable(
-                read_type.is_nullable(),
+                data_type.is_nullable(),
                 fields,
             ))))
         }
-        // ARRAY and MAP are deliberately NOT descended, even though Java's
-        // `pruneDataType` does: the pruned type is also what the Vortex 
reader is
-        // asked for, and it projects top-level columns only, then 
reinterprets a
-        // decoded nested struct against the requested Arrow type 
positionally, so
-        // a container pruned into read order would silently relabel its 
children.
-        // Nested containers are reconciled after decoding instead, by
-        // `arrow::nested_evolution::evolve_column`, which pairs by field id.
         _ => Ok(Some(data_type.clone())),
     }
 }
@@ -1469,6 +1446,7 @@ fn prune_data_type_for_format(
 fn data_field_with_type(field: &DataField, data_type: DataType) -> DataField {
     DataField::new(field.id(), field.name().to_string(), data_type)
         .with_description(field.description().map(ToString::to_string))
+        .with_default_value(field.default_value().map(ToString::to_string))
 }
 
 fn data_schema_fields_for_file(
@@ -4627,6 +4605,9 @@ mod vector_parquet_tests {
     }
 }
 
+#[cfg(test)]
+mod nested_projection_tests;
+
 #[cfg(test)]
 mod prune_container_tests {
     use super::*;
@@ -4645,28 +4626,25 @@ mod prune_container_tests {
     }
 
     #[test]
-    fn keeps_an_array_element_type_verbatim() {
-        // Not descended on purpose — see the comment in `prune_data_type`: the
-        // Vortex reader would relabel a reordered container positionally. The
-        // element is reconciled after decoding instead.
+    fn prunes_an_array_element_by_field_id() {
         let data = DataType::Array(ArrayType::new(row(vec![
             f(2, "name", str_t()),
             f(3, "lang", str_t()),
         ])));
         let read = DataType::Array(ArrayType::new(row(vec![f(3, "lang", 
str_t())])));
 
-        assert_eq!(prune_data_type(&read, &data, false).unwrap().unwrap(), 
data);
+        assert_eq!(prune_data_type(&read, &data, false).unwrap().unwrap(), 
read);
     }
 
     #[test]
-    fn keeps_a_map_value_type_verbatim() {
+    fn prunes_a_map_value_by_field_id() {
         let data = DataType::Map(MapType::new(
             str_t(),
             row(vec![f(5, "v", str_t()), f(6, "unit", str_t())]),
         ));
         let read = DataType::Map(MapType::new(str_t(), row(vec![f(5, "v", 
str_t())])));
 
-        assert_eq!(prune_data_type(&read, &data, false).unwrap().unwrap(), 
data);
+        assert_eq!(prune_data_type(&read, &data, false).unwrap().unwrap(), 
read);
     }
 
     #[test]
@@ -4688,11 +4666,8 @@ mod prune_container_tests {
             row(vec![f(5, "old_name", str_t()), f(7, "last", str_t())]),
         ));
         assert_eq!(
-            prune_data_type_for_format(&read, &data, false, true)
-                .unwrap()
-                .unwrap(),
+            prune_data_type(&read, &data, false).unwrap().unwrap(),
             expected
         );
-        assert_eq!(prune_data_type(&read, &data, false).unwrap().unwrap(), 
data);
     }
 }
diff --git 
a/crates/paimon/src/table/data_file_reader/nested_projection_tests.rs 
b/crates/paimon/src/table/data_file_reader/nested_projection_tests.rs
new file mode 100644
index 00000000..06a72690
--- /dev/null
+++ b/crates/paimon/src/table/data_file_reader/nested_projection_tests.rs
@@ -0,0 +1,835 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::*;
+use crate::arrow::format::{
+    create_format_reader_with_budget, create_format_writer, FormatReadFields, 
MosaicPrefetchOptions,
+};
+use crate::arrow::{build_read_arrow_schema, build_target_arrow_schema};
+use crate::io::FileIOBuilder;
+use crate::spec::stats::BinaryTableStats;
+use crate::spec::{ArrayType, BinaryRow, DataFileMeta, IntType, MapType, 
RowType, VarCharType};
+use crate::table::source::DataSplitBuilder;
+use arrow_array::UInt32Array;
+use arrow_select::concat::concat_batches;
+use bytes::Bytes;
+use futures::TryStreamExt;
+use std::io::Cursor;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+fn f(id: i32, name: &str, typ: DataType) -> DataField {
+    DataField::new(id, name.into(), typ)
+}
+fn int() -> DataType {
+    DataType::Int(IntType::new())
+}
+fn string() -> DataType {
+    DataType::VarChar(VarCharType::string_type())
+}
+fn row(fields: Vec<DataField>) -> DataType {
+    DataType::Row(RowType::new(fields))
+}
+fn array(element: DataType) -> DataType {
+    DataType::Array(ArrayType::new(element))
+}
+fn map(value: DataType) -> DataType {
+    DataType::Map(MapType::new(string(), value))
+}
+fn leaves(base: i32, projected: bool) -> DataType {
+    if projected {
+        row(vec![
+            f(
+                base + 2,
+                "renamed_tail",
+                DataType::BigInt(crate::spec::BigIntType::new()),
+            ),
+            f(base + 1, "keep", int()),
+        ])
+    } else {
+        row(vec![
+            f(base + 1, "keep", int()),
+            f(base + 2, "tail", int()),
+            f(base + 3, "unused", string()),
+        ])
+    }
+}
+fn fields(projected: bool) -> Vec<DataField> {
+    vec![
+        f(
+            10,
+            "profile",
+            row(vec![f(11, "detail", leaves(11, projected))]),
+        ),
+        f(20, "items", array(leaves(20, projected))),
+        f(30, "groups", array(array(leaves(30, projected)))),
+        f(40, "attrs", map(leaves(40, projected))),
+        f(
+            50,
+            "complex",
+            row(vec![f(51, "lookup", map(array(leaves(51, projected))))]),
+        ),
+        f(60, "array_maps", array(map(leaves(60, projected)))),
+    ]
+}
+fn input() -> RecordBatch {
+    let leaf = serde_json::json!({"keep": 7, "tail": 9, "unused": 
"discard".repeat(2048)});
+    let rows = [
+        serde_json::json!({"profile":{"detail":leaf}, 
"items":[leaf,null,leaf], "groups":[[leaf,null],[],null], 
"attrs":{"a":leaf,"b":null}, 
"complex":{"lookup":{"x":[leaf,null],"y":[]}},"array_maps":[{"k":leaf},null,{}]}),
+        
serde_json::json!({"profile":null,"items":null,"groups":null,"attrs":null,"complex":null,"array_maps":null}),
+        
serde_json::json!({"profile":{"detail":null},"items":[],"groups":[],"attrs":{},"complex":{"lookup":{}},"array_maps":[]}),
+        
serde_json::json!({"profile":{"detail":{"keep":null,"tail":3}},"items":[{"keep":null,"tail":4}],"groups":[[null]],"attrs":{"z":{"keep":null,"tail":5}},"complex":{"lookup":null},"array_maps":[{"z":null}]}),
+        
serde_json::json!({"profile":{"detail":{"unused":"present"}},"items":[{"unused":"present"}],"groups":[[{"unused":"present"}]],"attrs":{"k":{"unused":"present"}},"complex":{"lookup":{"k":[{"unused":"present"}]}},"array_maps":[{"k":{"unused":"present"}}]}),
+    ];
+    let json = rows
+        .iter()
+        .map(|row| row.to_string())
+        .collect::<Vec<_>>()
+        .join("\n");
+    
arrow_json::ReaderBuilder::new(build_target_arrow_schema(&fields(false)).unwrap())
+        .build(Cursor::new(json))
+        .unwrap()
+        .next()
+        .unwrap()
+        .unwrap()
+}
+fn expected(input: &RecordBatch, source: &[DataField], target: &[DataField]) 
-> RecordBatch {
+    let columns = target
+        .iter()
+        .map(|target| {
+            let index = source
+                .iter()
+                .position(|source| source.id() == target.id())
+                .unwrap();
+            crate::arrow::nested_evolution::evolve_field(
+                input.column(index),
+                &source[index],
+                target,
+            )
+            .unwrap()
+        })
+        .collect();
+    RecordBatch::try_new(build_read_arrow_schema(target).unwrap(), 
columns).unwrap()
+}
+
+// Avro's native MAP has unordered entries. Compare mappings recursively while
+// retaining ARRAY order, ROW nullness and empty-versus-null containers.
+fn json_rows(batch: &RecordBatch) -> serde_json::Value {
+    let mut writer = arrow_json::ArrayWriter::new(Vec::new());
+    writer.write_batches(&[batch]).unwrap();
+    writer.finish().unwrap();
+    serde_json::from_slice(&writer.into_inner()).unwrap()
+}
+fn meta(name: &str, size: u64, rows: usize) -> DataFileMeta {
+    DataFileMeta {
+        file_name: name.into(),
+        file_size: size as i64,
+        row_count: rows as i64,
+        min_key: vec![],
+        max_key: vec![],
+        key_stats: BinaryTableStats::empty(),
+        value_stats: BinaryTableStats::empty(),
+        min_sequence_number: 0,
+        max_sequence_number: 0,
+        schema_id: 1,
+        level: 0,
+        extra_files: vec![],
+        creation_time: None,
+        delete_row_count: None,
+        embedded_index: None,
+        file_source: None,
+        value_stats_cols: None,
+        external_path: None,
+        first_row_id: None,
+        write_cols: None,
+        column_max_sequence_numbers: None,
+    }
+}
+
+#[tokio::test]
+async fn nested_projection_roundtrips_parquet_and_fallback_formats() {
+    let source = fields(false);
+    let input = input();
+    let target = fields(true).into_iter().rev().collect::<Vec<_>>();
+    for extension in ["parquet", "orc", "avro", "row", "json"]
+        .into_iter()
+        .chain(cfg!(feature = "vortex").then_some("vortex"))
+    {
+        let source = if extension == "vortex" {
+            source[..3].to_vec()
+        } else {
+            source.clone()
+        };
+        let input = if extension == "vortex" {
+            input.project(&[0, 1, 2]).unwrap()
+        } else {
+            input.clone()
+        };
+        let target = target
+            .iter()
+            .filter(|field| source.iter().any(|source| source.id() == 
field.id()))
+            .cloned()
+            .collect::<Vec<_>>();
+        let expected = expected(&input, &source, &target);
+        assert_eq!(
+            json_rows(&expected)[0]["profile"]["detail"],
+            serde_json::json!({"renamed_tail":9,"keep":7})
+        );
+        let io = FileIOBuilder::new("memory").build().unwrap();
+        let table_path = format!("memory:/nested_projection_{extension}");
+        let bucket = format!("{table_path}/bucket-0");
+        let name = format!("part.{extension}");
+        let output = io.new_output(&format!("{bucket}/{name}")).unwrap();
+        let size = if extension == "orc" {
+            // orc-rust's writer cannot encode nested types. This fixture was
+            // written by Apache ORC through PyArrow; tests need no Python.
+            let bytes = 
include_bytes!("../../../testdata/nested_projection/nested.orc");
+            output.write(Bytes::from_static(bytes)).await.unwrap();
+            bytes.len() as u64
+        } else {
+            let mut writer = create_format_writer(
+                &output,
+                input.schema(),
+                "none",
+                1,
+                None,
+                Some(&source),
+                None,
+            )
+            .await
+            .unwrap();
+            writer.write(&input).await.unwrap();
+            writer.close().await.unwrap().file_size
+        };
+        let file = meta(&name, size, input.num_rows());
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket)
+            .with_total_buckets(1)
+            .with_data_files(vec![file.clone()])
+            .build()
+            .unwrap();
+        let reader = DataFileReader::new(
+            io.clone(),
+            SchemaManager::new(io, table_path),
+            1,
+            source.clone(),
+            |fields| Ok(fields.to_vec()),
+            target.clone(),
+            vec![],
+        );
+        for schema in [None, Some(source.clone())] {
+            let batches = reader
+                .read_single_file_stream(&split, file.clone(), schema.clone(), 
None, None)
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap_or_else(|err| panic!("{extension}: {err}"));
+            let actual = concat_batches(&expected.schema(), &batches).unwrap();
+            assert_eq!(actual.schema(), expected.schema());
+            assert_eq!(
+                json_rows(&actual),
+                json_rows(&expected),
+                "{extension}, explicit schema: {}",
+                schema.is_some()
+            );
+            if extension == "json" {
+                continue;
+            } // line-oriented formats have no positional reads
+            let batches = reader
+                .read_single_file_stream_local(&split, file.clone(), schema, 
None, vec![0, 2])
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap_or_else(|err| panic!("{extension}: {err}"));
+            let selected =
+                arrow_select::take::take_record_batch(&expected, 
&UInt32Array::from(vec![0, 2]))
+                    .unwrap();
+            assert_eq!(
+                json_rows(&concat_batches(&selected.schema(), 
&batches).unwrap()),
+                json_rows(&selected),
+                "local {extension}"
+            );
+        }
+        let missing = row(vec![f(100, "added", int())]);
+        let requested = vec![
+            f(20, "items", array(missing.clone())),
+            f(40, "attrs", map(missing)),
+        ]
+        .into_iter()
+        .filter(|field| source.iter().any(|source| source.id() == field.id()))
+        .collect::<Vec<_>>();
+        for nested_enabled in [false, true] {
+            let reader = reader
+                .clone()
+                .with_read_type(requested.clone())
+                .with_nested_field_enabled(nested_enabled);
+            let batches = reader
+                .read_single_file_stream(&split, file.clone(), 
Some(source.clone()), None, None)
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            let mut wanted = self::expected(&input, &source, &requested);
+            if !nested_enabled {
+                wanted = RecordBatch::try_new(
+                    wanted.schema(),
+                    wanted
+                        .schema()
+                        .fields()
+                        .iter()
+                        .map(|field| {
+                            arrow_array::new_null_array(field.data_type(), 
input.num_rows())
+                        })
+                        .collect(),
+                )
+                .unwrap();
+            }
+            let actual = concat_batches(&wanted.schema(), &batches).unwrap();
+            assert_eq!(
+                json_rows(&actual),
+                json_rows(&wanted),
+                "{extension}: all-missing, nested={nested_enabled}"
+            );
+        }
+    }
+}
+
+#[derive(Clone)]
+struct CountingRead {
+    data: Bytes,
+    bytes: Arc<AtomicU64>,
+}
+#[async_trait::async_trait]
+impl FileRead for CountingRead {
+    async fn read(&self, range: std::ops::Range<u64>) -> crate::Result<Bytes> {
+        self.bytes
+            .fetch_add(range.end - range.start, Ordering::Relaxed);
+        Ok(self.data.slice(range.start as usize..range.end as usize))
+    }
+}
+
+#[tokio::test]
+async fn nested_projection_reduces_parquet_data_io_in_every_container() {
+    let source = fields(false);
+    let original = input();
+    let input = concat_batches(&original.schema(), (0..64).map(|_| 
&original)).unwrap();
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let output = io
+        .new_output("memory:/nested_projection_io.parquet")
+        .unwrap();
+    let options = HashMap::from([("parquet.enable.dictionary".into(), 
"false".into())]);
+    let mut writer = create_format_writer(
+        &output,
+        input.schema(),
+        "none",
+        1,
+        None,
+        Some(&source),
+        Some(&options),
+    )
+    .await
+    .unwrap();
+    writer.write(&input).await.unwrap();
+    let size = writer.close().await.unwrap().file_size;
+    let data = io
+        .new_input(output.location())
+        .unwrap()
+        .read()
+        .await
+        .unwrap();
+    for (full, projected) in source.iter().zip(fields(true)) {
+        let mut read_bytes = vec![];
+        for requested in [full, &projected] {
+            let physical =
+                read_data_fields(&source, std::slice::from_ref(requested), 
false).unwrap();
+            let configured = create_format_reader_with_budget(
+                output.location(),
+                false,
+                FormatReadFields {
+                    data_schema: &source,
+                    projected: &physical,
+                },
+                &HashMap::new(),
+                None,
+                1,
+                MosaicPrefetchOptions::default(),
+            )
+            .unwrap();
+            let count = Arc::new(AtomicU64::new(0));
+            let stream = configured
+                .reader
+                .read_batch_stream(
+                    Box::new(CountingRead {
+                        data: data.clone(),
+                        bytes: count.clone(),
+                    }),
+                    size,
+                    &configured.read_fields,
+                    None,
+                    None,
+                    None,
+                )
+                .await
+                .unwrap();
+            count.store(0, Ordering::Relaxed);
+            let batches = stream.try_collect::<Vec<_>>().await.unwrap();
+            let actual = concat_batches(&batches[0].schema(), 
&batches).unwrap();
+            let wanted = expected(&input, &source, 
std::slice::from_ref(requested));
+            let normalized = expected(
+                &actual,
+                &configured.read_fields,
+                std::slice::from_ref(requested),
+            );
+            assert_eq!(normalized, wanted, "{}", requested.name());
+            read_bytes.push(count.load(Ordering::Relaxed));
+        }
+        assert!(
+            read_bytes[1] * 2 < read_bytes[0],
+            "{}: projected {} bytes, full {} bytes",
+            full.name(),
+            read_bytes[1],
+            read_bytes[0]
+        );
+    }
+}
+
+#[test]
+fn missing_container_leaves_follow_java_nested_field_mode() {
+    let source = row(vec![f(1, "old", int())]);
+    let requested = row(vec![f(2, "added", int())]);
+    for (source, requested) in [
+        (array(source.clone()), array(requested.clone())),
+        (map(source.clone()), map(requested.clone())),
+        (
+            DataType::Map(MapType::new(source.clone(), int())),
+            DataType::Map(MapType::new(requested.clone(), int())),
+        ),
+    ] {
+        // FormatReaderMapping returns null for the whole type when a ROW has
+        // no surviving children outside nested-field mode. In nested-field
+        // mode it keeps a physical anchor, recursively through containers.
+        assert_eq!(prune_data_type(&requested, &source, false).unwrap(), None);
+        assert_eq!(
+            prune_data_type(&requested, &source, true).unwrap(),
+            Some(source)
+        );
+    }
+}
+
+#[tokio::test]
+async fn 
shared_map_value_nested_projection_prunes_direct_and_overflow_sources() {
+    let source = vec![fields(false)[3].clone()];
+    let target = vec![fields(true)[3].clone()];
+    let original = input();
+    let input = RecordBatch::try_new(
+        build_target_arrow_schema(&source).unwrap(),
+        vec![original.column(3).clone()],
+    )
+    .unwrap();
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let output = io
+        .new_output("memory:/shared_map_value_nested_projection.parquet")
+        .unwrap();
+    let options = HashMap::from([
+        (
+            "fields.attrs.map.storage-layout".into(),
+            "shared-shredding".into(),
+        ),
+        (
+            "fields.attrs.map.shared-shredding.max-columns".into(),
+            "1".into(),
+        ),
+    ]);
+    let mut writer = create_format_writer(
+        &output,
+        input.schema(),
+        "none",
+        1,
+        None,
+        Some(&source),
+        Some(&options),
+    )
+    .await
+    .unwrap();
+    writer.write(&input).await.unwrap();
+    let size = writer.close().await.unwrap().file_size;
+    let physical = read_data_fields(&source, &target, false).unwrap();
+    let configured = create_format_reader_with_budget(
+        output.location(),
+        false,
+        FormatReadFields {
+            data_schema: &source,
+            projected: &physical,
+        },
+        &HashMap::new(),
+        None,
+        1,
+        MosaicPrefetchOptions::default(),
+    )
+    .unwrap();
+    let batches = configured
+        .reader
+        .read_batch_stream(
+            Box::new(
+                io.new_input(output.location())
+                    .unwrap()
+                    .reader()
+                    .await
+                    .unwrap(),
+            ),
+            size,
+            &configured.read_fields,
+            None,
+            None,
+            None,
+        )
+        .await
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    let decoded = concat_batches(&batches[0].schema(), &batches).unwrap();
+    assert_eq!(
+        expected(&decoded, &physical, &target),
+        expected(&input, &source, &target)
+    );
+    let values = decoded
+        .column(0)
+        .as_any()
+        .downcast_ref::<arrow_array::MapArray>()
+        .unwrap()
+        .values();
+    assert_eq!(
+        values
+            .as_any()
+            .downcast_ref::<arrow_array::StructArray>()
+            .unwrap()
+            .num_columns(),
+        2
+    );
+}
+
+#[tokio::test]
+async fn 
map_row_key_projection_preserves_entries_even_when_keys_become_equal() {
+    use arrow_array::{Int32Array, MapArray, StringArray, StructArray};
+    use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
+    use arrow_schema::DataType as ArrowType;
+    let map_field = |children| {
+        f(
+            0,
+            "m",
+            DataType::Map(MapType::new(
+                DataType::Row(RowType::with_nullable(false, children)),
+                int(),
+            )),
+        )
+    };
+    let source = vec![map_field(vec![
+        f(1, "keep", int()),
+        f(2, "unused", string()),
+    ])];
+    let target = vec![map_field(vec![f(1, "keep", int())])];
+    let schema = build_target_arrow_schema(&source).unwrap();
+    let ArrowType::Map(entries_field, _) = schema.field(0).data_type() else {
+        unreachable!()
+    };
+    let ArrowType::Struct(entry_fields) = entries_field.data_type() else {
+        unreachable!()
+    };
+    let ArrowType::Struct(key_fields) = entry_fields[0].data_type() else {
+        unreachable!()
+    };
+    let keys = StructArray::new(
+        key_fields.clone(),
+        vec![
+            Arc::new(Int32Array::from(vec![1, 1, 2])),
+            Arc::new(StringArray::from(vec!["a", "b", "c"])),
+        ],
+        None,
+    );
+    let entries = StructArray::new(
+        entry_fields.clone(),
+        vec![Arc::new(keys), Arc::new(Int32Array::from(vec![10, 20, 30]))],
+        None,
+    );
+    let maps = MapArray::new(
+        entries_field.clone(),
+        OffsetBuffer::new(ScalarBuffer::from(vec![0, 2, 2, 3])),
+        entries,
+        Some(NullBuffer::from(vec![true, false, true])),
+        false,
+    );
+    let input = RecordBatch::try_new(schema, vec![Arc::new(maps)]).unwrap();
+    let io = FileIOBuilder::new("memory").build().unwrap();
+    let output = io
+        .new_output("memory:/nested_map_row_keys.parquet")
+        .unwrap();
+    let mut writer = create_format_writer(
+        &output,
+        input.schema(),
+        "none",
+        1,
+        None,
+        Some(&source),
+        None,
+    )
+    .await
+    .unwrap();
+    writer.write(&input).await.unwrap();
+    let size = writer.close().await.unwrap().file_size;
+    let physical = read_data_fields(&source, &target, false).unwrap();
+    let configured = create_format_reader_with_budget(
+        output.location(),
+        false,
+        FormatReadFields {
+            data_schema: &source,
+            projected: &physical,
+        },
+        &HashMap::new(),
+        None,
+        1,
+        MosaicPrefetchOptions::default(),
+    )
+    .unwrap();
+    let batches = configured
+        .reader
+        .read_batch_stream(
+            Box::new(
+                io.new_input(output.location())
+                    .unwrap()
+                    .reader()
+                    .await
+                    .unwrap(),
+            ),
+            size,
+            &configured.read_fields,
+            None,
+            None,
+            None,
+        )
+        .await
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    let decoded = concat_batches(&batches[0].schema(), &batches).unwrap();
+    let maps = decoded
+        .column(0)
+        .as_any()
+        .downcast_ref::<MapArray>()
+        .unwrap();
+    assert_eq!(
+        maps.keys()
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap()
+            .num_columns(),
+        1
+    );
+    assert_eq!(
+        expected(&decoded, &physical, &target),
+        expected(&input, &source, &target)
+    );
+}
+
+#[tokio::test]
+async fn java_list_layouts_decode_the_projected_row_without_shape_changes() {
+    use arrow_array::{Array, Int32Array, ListArray, StructArray};
+    let target = vec![f(0, "items", array(row(vec![f(2, "wanted", int())])))];
+    for (name, bytes, repeated) in [
+        (
+            "standard",
+            
include_bytes!("../../../testdata/nested_projection/standard.parquet").as_slice(),
+            false,
+        ),
+        (
+            "array",
+            
include_bytes!("../../../testdata/nested_projection/array.parquet").as_slice(),
+            false,
+        ),
+        (
+            "tuple",
+            
include_bytes!("../../../testdata/nested_projection/tuple.parquet").as_slice(),
+            false,
+        ),
+        (
+            "repeated",
+            
include_bytes!("../../../testdata/nested_projection/repeated.parquet").as_slice(),
+            true,
+        ),
+    ] {
+        let configured = create_format_reader_with_budget(
+            "data.parquet",
+            false,
+            FormatReadFields {
+                data_schema: &target,
+                projected: &target,
+            },
+            &HashMap::new(),
+            None,
+            1,
+            MosaicPrefetchOptions::default(),
+        )
+        .unwrap();
+        let batches = configured
+            .reader
+            .read_batch_stream(
+                Box::new(CountingRead {
+                    data: Bytes::from_static(bytes),
+                    bytes: Arc::new(AtomicU64::new(0)),
+                }),
+                bytes.len() as u64,
+                &target,
+                None,
+                None,
+                None,
+            )
+            .await
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        let decoded = concat_batches(&batches[0].schema(), &batches).unwrap();
+        let list = decoded
+            .column(0)
+            .as_any()
+            .downcast_ref::<ListArray>()
+            .unwrap();
+        assert_eq!(list.value_offsets(), &[0, 2, 2, 2], "{name}");
+        assert!(list.is_valid(1), "{name}: empty list");
+        assert_eq!(list.is_null(2), !repeated, "{name}: null list");
+        let elements = list
+            .values()
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap();
+        assert_eq!(elements.column_names(), vec!["wanted"], "{name}");
+        assert_eq!(
+            elements
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values(),
+            &[7, 8],
+            "{name}"
+        );
+    }
+}
+
+#[tokio::test]
+async fn fallback_does_not_resurrect_omitted_children_inside_retained_rows() {
+    let nested = |base, projected| {
+        let leaf = |old, new| {
+            row(vec![if projected {
+                f(new, "added", int())
+            } else {
+                f(old, "old", int())
+            }])
+        };
+        row(vec![
+            f(base + 1, "keep", int()),
+            f(base + 2, "inner", leaf(base + 3, base + 4)),
+            f(base + 5, "items", array(leaf(base + 6, base + 7))),
+            f(base + 8, "attrs", map(leaf(base + 9, base + 10))),
+        ])
+    };
+    let schema_fields = |projected| {
+        vec![
+            f(0, "p", nested(10, projected)),
+            f(100, "arr", array(nested(110, projected))),
+            f(200, "m", map(nested(210, projected))),
+        ]
+    };
+    let source = schema_fields(false);
+    let requested = schema_fields(true);
+    let leaf =
+        
serde_json::json!({"keep":9,"inner":{"old":7},"items":[{"old":7}],"attrs":{"a":{"old":7}}});
+    let json = format!(
+        "{}\n{{}}",
+        serde_json::json!({"p":leaf,"arr":[leaf,null],"m":{"x":leaf}})
+    );
+    let input = 
arrow_json::ReaderBuilder::new(build_target_arrow_schema(&source).unwrap())
+        .build(Cursor::new(json))
+        .unwrap()
+        .next()
+        .unwrap()
+        .unwrap();
+    for extension in ["parquet", "avro", "row", "json"] {
+        let io = FileIOBuilder::new("memory").build().unwrap();
+        let table = format!("memory:/deep_missing_{extension}");
+        let bucket = format!("{table}/bucket-0");
+        let name = format!("part.{extension}");
+        let output = io.new_output(&format!("{bucket}/{name}")).unwrap();
+        let mut writer = create_format_writer(
+            &output,
+            input.schema(),
+            "none",
+            1,
+            None,
+            Some(&source),
+            None,
+        )
+        .await
+        .unwrap();
+        writer.write(&input).await.unwrap();
+        let file = meta(
+            &name,
+            writer.close().await.unwrap().file_size,
+            input.num_rows(),
+        );
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket)
+            .with_total_buckets(1)
+            .with_data_files(vec![file.clone()])
+            .build()
+            .unwrap();
+        let reader = DataFileReader::new(
+            io.clone(),
+            SchemaManager::new(io, table),
+            1,
+            source.clone(),
+            |fields| Ok(fields.to_vec()),
+            requested.clone(),
+            vec![],
+        );
+        for enabled in [false, true] {
+            let batches = reader
+                .clone()
+                .with_nested_field_enabled(enabled)
+                .read_single_file_stream(&split, file.clone(), 
Some(source.clone()), None, None)
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            let actual =
+                concat_batches(&build_read_arrow_schema(&requested).unwrap(), 
&batches).unwrap();
+            let leaf = if enabled {
+                
serde_json::json!({"keep":9,"inner":{},"items":[{}],"attrs":{"a":{}}})
+            } else {
+                serde_json::json!({"keep":9})
+            };
+            let wanted = 
serde_json::json!([{"p":leaf,"arr":[leaf,null],"m":{"x":leaf}},{}]);
+            assert_eq!(json_rows(&actual), wanted, "{extension}, 
nested={enabled}");
+        }
+    }
+}
diff --git a/crates/paimon/testdata/nested_projection/GenerateLegacyLists.java 
b/crates/paimon/testdata/nested_projection/GenerateLegacyLists.java
new file mode 100644
index 00000000..803a3a4a
--- /dev/null
+++ b/crates/paimon/testdata/nested_projection/GenerateLegacyLists.java
@@ -0,0 +1,61 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
+
+// Run with parquet-hadoop/example and Hadoop common/mapreduce on the 
classpath.
+// Generates fixtures using Parquet's Java writer, including legacy LIST 
layouts.
+public class GenerateLegacyLists {
+    public static void main(String[] args) throws Exception {
+        String[] names = {"standard", "array", "tuple", "repeated"};
+        String[] layouts = {
+            "optional group items (LIST) { repeated group list { optional 
group element { optional int32 unused; optional int32 wanted; } } }",
+            "optional group items (LIST) { repeated group array { optional 
int32 unused; optional int32 wanted; } }",
+            "optional group items (LIST) { repeated group items_tuple { 
optional int32 unused; optional int32 wanted; } }",
+            "repeated group items { optional int32 unused; optional int32 
wanted; }"
+        };
+        for (int i = 0; i < names.length; i++) {
+            MessageType schema = MessageTypeParser.parseMessageType("message 
schema {" + layouts[i] + "}");
+            SimpleGroupFactory factory = new SimpleGroupFactory(schema);
+            try (ParquetWriter<Group> writer = 
ExampleParquetWriter.builder(new Path(args[0] + "/" + names[i] + ".parquet"))
+                    .withConf(new Configuration()).withType(schema)
+                    
.withCompressionCodec(CompressionCodecName.UNCOMPRESSED).build()) {
+                Group record = factory.newGroup();
+                Group root = i == 3 ? record : record.addGroup("items");
+                for (int value = 7; value <= 8; value++) {
+                    Group element = i == 0 ? 
root.addGroup("list").addGroup("element")
+                        : root.addGroup(i == 1 ? "array" : i == 2 ? 
"items_tuple" : "items");
+                    element.add("unused", 100 + value);
+                    element.add("wanted", value);
+                }
+                writer.write(record);
+                Group empty = factory.newGroup();
+                if (i != 3) empty.addGroup("items");
+                writer.write(empty);
+                writer.write(factory.newGroup());
+            }
+        }
+    }
+}
diff --git a/crates/paimon/testdata/nested_projection/array.parquet 
b/crates/paimon/testdata/nested_projection/array.parquet
new file mode 100644
index 00000000..51e58491
Binary files /dev/null and 
b/crates/paimon/testdata/nested_projection/array.parquet differ
diff --git a/crates/paimon/testdata/nested_projection/generate_orc.py 
b/crates/paimon/testdata/nested_projection/generate_orc.py
new file mode 100644
index 00000000..93b86a73
--- /dev/null
+++ b/crates/paimon/testdata/nested_projection/generate_orc.py
@@ -0,0 +1,45 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Fixture generation only; Rust tests consume the saved file without Python.
+from pathlib import Path
+import pyarrow as pa
+import pyarrow.orc as orc
+
+leaf = pa.struct([('keep', pa.int32()), ('tail', pa.int32()), ('unused', 
pa.string())])
+schema = pa.schema([
+    ('profile', pa.struct([('detail', leaf)])),
+    ('items', pa.list_(leaf)),
+    ('groups', pa.list_(pa.list_(leaf))),
+    ('attrs', pa.map_(pa.string(), leaf)),
+    ('complex', pa.struct([('lookup', pa.map_(pa.string(), pa.list_(leaf)))])),
+    ('array_maps', pa.list_(pa.map_(pa.string(), leaf))),
+])
+x = {'keep': 7, 'tail': 9, 'unused': 'discard'}
+rows = [
+    {'profile': {'detail': x}, 'items': [x, None, x], 'groups': [[x, None], 
[], None],
+     'attrs': [('a', x), ('b', None)], 'complex': {'lookup': [('x', [x, 
None]), ('y', [])]},
+     'array_maps': [[('k', x)], None, []]},
+    {'profile': None, 'items': None, 'groups': None, 'attrs': None, 'complex': 
None, 'array_maps': None},
+    {'profile': {'detail': None}, 'items': [], 'groups': [], 'attrs': [], 
'complex': {'lookup': []}, 'array_maps': []},
+    {'profile': {'detail': {'keep': None, 'tail': 3}}, 'items': [{'keep': 
None, 'tail': 4}], 'groups': [[None]],
+     'attrs': [('z', {'keep': None, 'tail': 5})], 'complex': {'lookup': None}, 
'array_maps': [[('z', None)]]},
+    {'profile': {'detail': {'unused': 'present'}}, 'items': [{'unused': 
'present'}], 'groups': [[{'unused': 'present'}]],
+     'attrs': [('k', {'unused': 'present'})], 'complex': {'lookup': [('k', 
[{'unused': 'present'}])]},
+     'array_maps': [[('k', {'unused': 'present'})]]},
+]
+orc.write_table(pa.Table.from_pylist(rows, schema=schema), 
Path(__file__).with_name('nested.orc'), compression='uncompressed')
diff --git a/crates/paimon/testdata/nested_projection/nested.orc 
b/crates/paimon/testdata/nested_projection/nested.orc
new file mode 100644
index 00000000..c9d9546e
Binary files /dev/null and 
b/crates/paimon/testdata/nested_projection/nested.orc differ
diff --git a/crates/paimon/testdata/nested_projection/repeated.parquet 
b/crates/paimon/testdata/nested_projection/repeated.parquet
new file mode 100644
index 00000000..57b4e3d1
Binary files /dev/null and 
b/crates/paimon/testdata/nested_projection/repeated.parquet differ
diff --git a/crates/paimon/testdata/nested_projection/standard.parquet 
b/crates/paimon/testdata/nested_projection/standard.parquet
new file mode 100644
index 00000000..9d06d8d1
Binary files /dev/null and 
b/crates/paimon/testdata/nested_projection/standard.parquet differ
diff --git a/crates/paimon/testdata/nested_projection/tuple.parquet 
b/crates/paimon/testdata/nested_projection/tuple.parquet
new file mode 100644
index 00000000..0bc59143
Binary files /dev/null and 
b/crates/paimon/testdata/nested_projection/tuple.parquet differ

Reply via email to