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

sunchao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 4eeb1f80f0 fix: check nested TIMESTAMP_MILLIS overflow in unfiltered 
scans (#5740)
4eeb1f80f0 is described below

commit 4eeb1f80f0541f72389a11e6e2d0ee269d648c23
Author: Peter Lee <[email protected]>
AuthorDate: Wed Sep 9 14:12:31 2026 +0800

    fix: check nested TIMESTAMP_MILLIS overflow in unfiltered scans (#5740)
    
    * fix: check nested TIMESTAMP_MILLIS overflow in unfiltered scans
    
    * Update native/core/src/parquet/parquet_support.rs
    
    * Avoid visibility masks for unrelated Parquet siblings
---
 native/core/Cargo.toml                             |   4 +
 .../core/benches/parquet_timestamp_conversion.rs   | 160 ++++++++++
 native/core/src/parquet/parquet_support.rs         | 347 +++++++++++++++++++--
 .../apache/comet/parquet/ParquetReadSuite.scala    |  24 +-
 4 files changed, 509 insertions(+), 26 deletions(-)

diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml
index 76964978cc..9ebb69b2f6 100644
--- a/native/core/Cargo.toml
+++ b/native/core/Cargo.toml
@@ -127,3 +127,7 @@ harness = false
 [[bench]]
 name = "explode"
 harness = false
+
+[[bench]]
+name = "parquet_timestamp_conversion"
+harness = false
diff --git a/native/core/benches/parquet_timestamp_conversion.rs 
b/native/core/benches/parquet_timestamp_conversion.rs
new file mode 100644
index 0000000000..d090fc7969
--- /dev/null
+++ b/native/core/benches/parquet_timestamp_conversion.rs
@@ -0,0 +1,160 @@
+// 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.
+
+//! Parquet timestamp conversion with nullable containers and unchanged array 
siblings.
+//! Run the same benchmark on the reviewed head and the candidate with 
Criterion baselines.
+
+use std::{hint::black_box, sync::Arc};
+
+use arrow::array::{
+    Array, ArrayRef, Int32Array, ListArray, MapArray, StructArray, 
TimestampMillisecondArray,
+};
+use arrow::buffer::{NullBuffer, OffsetBuffer};
+use arrow::datatypes::{DataType, Field, TimeUnit};
+use comet::parquet::parquet_support::{spark_parquet_convert, 
SparkParquetOptions};
+use criterion::{criterion_group, criterion_main, Criterion};
+use datafusion::physical_plan::ColumnarValue;
+use datafusion_comet_spark_expr::EvalMode;
+
+const ROWS: usize = 1024;
+
+// Null rows have empty ranges, as in a decoded Parquet list/map. Their 
presence still
+// caused the original repeated_visibility implementation to scan all backing 
items.
+fn offsets(width: usize) -> (OffsetBuffer<i32>, NullBuffer, usize) {
+    let valid: Vec<bool> = (0..ROWS).map(|row| row % 8 != 0).collect();
+    let mut offsets = vec![0_i32];
+    for &present in &valid {
+        offsets.push(offsets.last().unwrap() + if present { width as i32 } 
else { 0 });
+    }
+    let len = *offsets.last().unwrap() as usize;
+    (
+        OffsetBuffer::new(offsets.into()),
+        NullBuffer::from(valid),
+        len,
+    )
+}
+
+fn timestamp_field(unit: TimeUnit) -> Arc<Field> {
+    Arc::new(Field::new("ts", DataType::Timestamp(unit, None), true))
+}
+
+fn array_sibling(width: usize) -> (ArrayRef, DataType) {
+    let (offsets, nulls, len) = offsets(width);
+    let ints: ArrayRef = Arc::new(ListArray::new(
+        Arc::new(Field::new("item", DataType::Int32, false)),
+        offsets,
+        Arc::new(Int32Array::from(vec![7; len])),
+        Some(nulls),
+    ));
+    let field = Arc::new(Field::new("ints", ints.data_type().clone(), true));
+    let target =
+        DataType::Struct(vec![timestamp_field(TimeUnit::Microsecond), 
Arc::clone(&field)].into());
+    let input = StructArray::new(
+        vec![timestamp_field(TimeUnit::Millisecond), field].into(),
+        vec![
+            Arc::new(TimestampMillisecondArray::from(vec![7; ROWS])),
+            ints,
+        ],
+        None,
+    );
+    (Arc::new(input), target)
+}
+
+fn timestamp_containers() -> [(ArrayRef, DataType); 2] {
+    let (offsets, nulls, len) = offsets(32);
+    let timestamps: ArrayRef = 
Arc::new(TimestampMillisecondArray::from(vec![7; len]));
+    let list = ListArray::new(
+        timestamp_field(TimeUnit::Millisecond),
+        offsets.clone(),
+        Arc::clone(&timestamps),
+        Some(nulls.clone()),
+    );
+    let key = Arc::new(Field::new("key", DataType::Int32, false));
+    let entries = StructArray::new(
+        vec![Arc::clone(&key), timestamp_field(TimeUnit::Millisecond)].into(),
+        vec![
+            Arc::new(Int32Array::from(
+                (0..len).map(|i| (i % 32) as i32).collect::<Vec<_>>(),
+            )),
+            timestamps,
+        ],
+        None,
+    );
+    let map = MapArray::new(
+        Arc::new(Field::new("entries", entries.data_type().clone(), false)),
+        offsets,
+        entries,
+        Some(nulls),
+        false,
+    );
+    let target_map = DataType::Map(
+        Arc::new(Field::new(
+            "entries",
+            DataType::Struct(vec![key, 
timestamp_field(TimeUnit::Microsecond)].into()),
+            false,
+        )),
+        false,
+    );
+    [
+        (
+            Arc::new(list),
+            DataType::List(timestamp_field(TimeUnit::Microsecond)),
+        ),
+        (Arc::new(map), target_map),
+    ]
+}
+
+fn benchmark(c: &mut Criterion) {
+    let options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false);
+    let mut group = c.benchmark_group("parquet_timestamp_conversion");
+    for width in [8, 1024] {
+        let (array, target) = array_sibling(width);
+        group.bench_function(format!("array_sibling_width_{width}"), |b| {
+            b.iter(|| {
+                spark_parquet_convert(
+                    ColumnarValue::Array(Arc::clone(black_box(&array))),
+                    black_box(&target),
+                    black_box(&options),
+                )
+                .unwrap()
+            })
+        });
+    }
+    for (kind, (array, target)) in ["list", 
"map"].into_iter().zip(timestamp_containers()) {
+        for sliced in [false, true] {
+            let input = if sliced {
+                array.slice(ROWS / 4, ROWS / 2)
+            } else {
+                Arc::clone(&array)
+            };
+            group.bench_function(format!("timestamp_{kind}_sliced_{sliced}"), 
|b| {
+                b.iter(|| {
+                    spark_parquet_convert(
+                        ColumnarValue::Array(Arc::clone(black_box(&input))),
+                        black_box(&target),
+                        black_box(&options),
+                    )
+                    .unwrap()
+                })
+            });
+        }
+    }
+    group.finish();
+}
+
+criterion_group!(benches, benchmark);
+criterion_main!(benches);
diff --git a/native/core/src/parquet/parquet_support.rs 
b/native/core/src/parquet/parquet_support.rs
index b601f984d6..a461e8a16c 100644
--- a/native/core/src/parquet/parquet_support.rs
+++ b/native/core/src/parquet/parquet_support.rs
@@ -102,7 +102,7 @@ pub struct SparkParquetOptions {
     /// (Spark 3.x, SPARK-36182). Mirrors Comet's per-Spark-version constant
     /// in ShimCometConf.
     pub allow_timestamp_ltz_to_ntz: bool,
-    /// When true (the default), a top-level TIMESTAMP_MILLIS column that 
overflows during
+    /// When true (the default), a TIMESTAMP_MILLIS field that overflows during
     /// the millis->micros upscale raises an error, matching Spark's checked
     /// `millisToMicros`. Filtered scans set this to false and retain the safe 
cast
     /// (overflow -> NULL), because Spark may discard values through pruning 
paths that
@@ -179,17 +179,27 @@ fn parquet_convert_array(
     to_type: &DataType,
     parquet_options: &SparkParquetOptions,
 ) -> DataFusionResult<ArrayRef> {
-    parquet_convert_array_impl(array, to_type, parquet_options, true)
+    parquet_convert_array_impl(array, to_type, parquet_options, None)
 }
 
 fn parquet_convert_array_impl(
     array: ArrayRef,
     to_type: &DataType,
     parquet_options: &SparkParquetOptions,
-    top_level: bool,
+    parent_nulls: Option<&NullBuffer>,
 ) -> DataFusionResult<ArrayRef> {
     use DataType::*;
     let from_type = array.data_type();
+    // Only checked millis-to-micros casts consume ancestor visibility. In 
particular,
+    // unchanged array/map siblings must not expand a mask over their backing 
values.
+    let checked_timestamp_overflow = parquet_options.checked_timestamp_overflow
+        && has_timestamp_unit(from_type, TimeUnit::Millisecond)
+        && has_timestamp_unit(to_type, TimeUnit::Microsecond);
+    let visible = if checked_timestamp_overflow {
+        NullBuffer::union(array.nulls(), parent_nulls)
+    } else {
+        None
+    };
 
     // Try Comet specific handlers first, then arrow-rs cast if supported,
     // return uncasted data otherwise
@@ -199,14 +209,21 @@ fn parquet_convert_array_impl(
             from_type,
             to_type,
             parquet_options,
+            visible.as_ref(),
         )?),
         (List(_), List(to_inner_type)) => {
             let list_arr: &ListArray = array.as_list();
+            let child_visibility = if checked_timestamp_overflow {
+                repeated_visibility(
+                    list_arr.value_offsets(), list_arr.values().len(), 
visible.as_ref())
+            } else {
+                None
+            };
             let cast_field = parquet_convert_array_impl(
                 Arc::clone(list_arr.values()),
                 to_inner_type.data_type(),
                 parquet_options,
-                false,
+                child_visibility.as_ref(),
             )?;
 
             Ok(Arc::new(ListArray::new(
@@ -219,22 +236,23 @@ fn parquet_convert_array_impl(
         (
             Timestamp(TimeUnit::Millisecond, _),
             Timestamp(TimeUnit::Microsecond, target_tz),
-        ) if top_level && parquet_options.checked_timestamp_overflow => {
+        ) if checked_timestamp_overflow => {
             // Spark's Parquet reader calls the checked `millisToMicros` 
conversion for both
             // direct and dictionary values, independent of CAST evaluation 
mode:
             // 
https://github.com/apache/spark/blob/v4.2.0/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java#L817-L833
             // `millisToMicros` uses `Math.multiplyExact`:
             // 
https://github.com/apache/spark/blob/v4.2.0/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala#L103-L108
             //
-            // The checked conversion is limited to TOP-LEVEL columns. Spark 
only avoids the
-            // error for filtered-out values through row-group statistics 
pruning, and
-            // DataFusion's PruningPredicate does not support nested fields 
yet, so a checked
-            // conversion on a nested field would fail queries whose 
predicates Spark prunes
-            // (e.g. `WHERE s.ts < X` over an all-overflowing file). Nested 
fields keep the
-            // pre-existing safe-cast behavior below (overflow -> NULL).
-            let micros = array
-                .as_primitive::<TimestampMillisecondType>()
-                .try_unary::<_, TimestampMicrosecondType, _>(|value| 
value.mul_checked(1_000))?
+            // Filtered scans retain safe conversion until DataFusion can 
mirror Spark's
+            // pruning paths, including nested predicates.
+            let millis = array.as_primitive::<TimestampMillisecondType>();
+            // Ignore values hidden by null ancestors or by sliced list/map 
offsets.
+            // Restore the original child validity: required fields must 
remain non-null.
+            let micros = arrow::array::TimestampMillisecondArray::new(
+                millis.values().clone(), visible)
+                .try_unary::<_, TimestampMicrosecondType, _>(|value| 
value.mul_checked(1_000))?;
+            let micros = arrow::array::TimestampMicrosecondArray::new(
+                micros.values().clone(), millis.nulls().cloned())
                 .with_timezone_opt(target_tz.clone());
             Ok(Arc::new(micros))
         }
@@ -247,7 +265,7 @@ fn parquet_convert_array_impl(
             ))
         }
         (Map(_, ordered_from), Map(_, ordered_to)) if ordered_from == 
ordered_to =>
-            parquet_convert_map_to_map(array.as_map(), to_type, 
parquet_options, *ordered_to)
+            parquet_convert_map_to_map(array.as_map(), to_type, 
parquet_options, *ordered_to, visible.as_ref(), checked_timestamp_overflow)
             ,
         // Iceberg stores UUIDs as 16-byte fixed binary but Spark expects 
string representation.
         // Arrow doesn't support casting FixedSizeBinary to Utf8, so we handle 
it manually.
@@ -279,6 +297,39 @@ fn parquet_convert_array_impl(
     }
 }
 
+// Struct fields are matched by name/field ID later. This type-only check is 
conservative
+// until that matching occurs; each selected child is checked again before 
conversion.
+fn has_timestamp_unit(data_type: &DataType, unit: TimeUnit) -> bool {
+    match data_type {
+        DataType::Timestamp(timestamp_unit, _) => *timestamp_unit == unit,
+        DataType::Struct(fields) => fields
+            .iter()
+            .any(|field| has_timestamp_unit(field.data_type(), unit)),
+        DataType::List(field) | DataType::Map(field, _) => {
+            has_timestamp_unit(field.data_type(), unit)
+        }
+        _ => false,
+    }
+}
+
+// List/map values can include entries outside a slice or beneath a null 
parent.
+fn repeated_visibility(
+    offsets: &[i32],
+    len: usize,
+    nulls: Option<&NullBuffer>,
+) -> Option<NullBuffer> {
+    if nulls.is_none() && offsets[0] == 0 && *offsets.last().unwrap() as usize 
== len {
+        return None;
+    }
+    let mut valid = vec![false; len];
+    for (row, range) in offsets.windows(2).enumerate() {
+        if nulls.is_none_or(|nulls| nulls.is_valid(row)) {
+            valid[range[0] as usize..range[1] as usize].fill(true);
+        }
+    }
+    Some(NullBuffer::from(valid))
+}
+
 /// Read the Parquet field id stored under arrow-rs's 
`PARQUET_FIELD_ID_META_KEY`.
 fn field_id(field: &arrow::datatypes::Field) -> Option<i32> {
     field
@@ -294,6 +345,7 @@ fn parquet_convert_struct_to_struct(
     from_type: &DataType,
     to_type: &DataType,
     parquet_options: &SparkParquetOptions,
+    parent_nulls: Option<&NullBuffer>,
 ) -> DataFusionResult<ArrayRef> {
     match (from_type, to_type) {
         (DataType::Struct(from_fields), DataType::Struct(to_fields)) => {
@@ -373,7 +425,7 @@ fn parquet_convert_struct_to_struct(
                         Arc::clone(array.column(from_index)),
                         to_field.data_type(),
                         parquet_options,
-                        false,
+                        parent_nulls,
                     )?);
                     field_overlap = true;
                 } else {
@@ -411,6 +463,8 @@ fn parquet_convert_map_to_map(
     to_data_type: &DataType,
     parquet_options: &SparkParquetOptions,
     to_ordered: bool,
+    parent_nulls: Option<&NullBuffer>,
+    checked_timestamp_overflow: bool,
 ) -> Result<ArrayRef, DataFusionError> {
     match to_data_type {
         DataType::Map(entries_field, _) => {
@@ -421,17 +475,22 @@ fn parquet_convert_map_to_map(
                 "map is missing value field".to_string(),
             ))?;
 
+            let child_visibility = if checked_timestamp_overflow {
+                repeated_visibility(from.value_offsets(), from.keys().len(), 
parent_nulls)
+            } else {
+                None
+            };
             let key_array = parquet_convert_array_impl(
                 Arc::clone(from.keys()),
                 key_field.data_type(),
                 parquet_options,
-                false,
+                child_visibility.as_ref(),
             )?;
             let value_array = parquet_convert_array_impl(
                 Arc::clone(from.values()),
                 value_field.data_type(),
                 parquet_options,
-                false,
+                child_visibility.as_ref(),
             )?;
 
             Ok(Arc::new(MapArray::new(
@@ -731,7 +790,7 @@ mod tests {
     }
 
     #[test]
-    fn test_millis_to_micros_overflow_checked_only_at_top_level() {
+    fn test_millis_to_micros_overflow_checked_in_nested_fields() {
         use crate::parquet::parquet_support::{parquet_convert_array, 
SparkParquetOptions};
         use arrow::array::{Array, ArrayRef, StructArray, 
TimestampMillisecondArray};
         use arrow::datatypes::{DataType, Field, Fields, TimeUnit};
@@ -764,10 +823,7 @@ mod tests {
         assert!(converted.is_null(0), "overflow must become NULL");
         assert!(converted.is_null(1));
 
-        // Nested: DataFusion's PruningPredicate cannot prune nested fields, 
so a
-        // checked conversion would fail queries whose predicates Spark 
satisfies via
-        // row-group statistics pruning. The nested field keeps the safe-cast 
behavior:
-        // overflow becomes NULL.
+        // Unfiltered nested reads must also report overflow.
         let child_field = Arc::new(Field::new(
             "ts",
             DataType::Timestamp(TimeUnit::Millisecond, None),
@@ -783,8 +839,9 @@ mod tests {
             micros_type.clone(),
             true,
         ))]));
-        let converted = parquet_convert_array(strukt, &target, &options)
-            .expect("nested overflow must not error");
+        assert!(parquet_convert_array(Arc::clone(&strukt), &target, 
&options).is_err());
+        let converted = parquet_convert_array(strukt, &target, 
&unchecked_options)
+            .expect("filtered nested overflow must not error");
         let converted_child = Arc::clone(
             converted
                 .as_any()
@@ -797,6 +854,246 @@ mod tests {
         assert!(converted_child.is_null(1));
     }
 
+    #[test]
+    fn test_millis_to_micros_preserves_unchanged_siblings() {
+        use super::{parquet_convert_array, SparkParquetOptions};
+        use arrow::array::{
+            cast::AsArray, Array, ArrayRef, Int32Array, ListArray, MapArray, 
StructArray,
+            TimestampMicrosecondArray, TimestampMillisecondArray,
+        };
+        use arrow::buffer::{NullBuffer, OffsetBuffer};
+        use arrow::datatypes::{DataType, Field, TimeUnit};
+        use datafusion_comet_spark_expr::EvalMode;
+        use std::sync::Arc;
+
+        let values: ArrayRef = Arc::new(Int32Array::from_iter_values(0..16));
+        let offsets = OffsetBuffer::new(vec![0, 0, 16].into());
+        let nulls = NullBuffer::from(vec![false, true]);
+        let ints: ArrayRef = Arc::new(ListArray::new(
+            Arc::new(Field::new("item", DataType::Int32, false)),
+            offsets.clone(),
+            Arc::clone(&values),
+            Some(nulls.clone()),
+        ));
+        let entries = StructArray::new(
+            vec![
+                Arc::new(Field::new("key", DataType::Int32, false)),
+                Arc::new(Field::new("value", DataType::Int32, false)),
+            ]
+            .into(),
+            vec![Arc::clone(&values), Arc::clone(&values)],
+            None,
+        );
+        let map: ArrayRef = Arc::new(MapArray::new(
+            Arc::new(Field::new("entries", entries.data_type().clone(), 
false)),
+            offsets,
+            entries,
+            Some(nulls.clone()),
+            false,
+        ));
+        let input = StructArray::new(
+            vec![
+                Arc::new(Field::new(
+                    "ts",
+                    DataType::Timestamp(TimeUnit::Millisecond, None),
+                    true,
+                )),
+                Arc::new(Field::new("ints", ints.data_type().clone(), true)),
+                Arc::new(Field::new("map", map.data_type().clone(), true)),
+            ]
+            .into(),
+            vec![
+                Arc::new(TimestampMillisecondArray::from(vec![i64::MAX, 7])),
+                ints,
+                map,
+            ],
+            Some(nulls),
+        );
+        let mut target_fields = input.fields().to_vec();
+        target_fields[0] = Arc::new(Field::new(
+            "ts",
+            DataType::Timestamp(TimeUnit::Microsecond, None),
+            true,
+        ));
+        let target = DataType::Struct(target_fields.into());
+        let input: ArrayRef = Arc::new(input);
+        let options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false);
+        let output = parquet_convert_array(Arc::clone(&input), &target, 
&options).unwrap();
+        assert_eq!(output.data_type(), &target);
+        assert!(output.is_null(0));
+        let output = output.as_struct();
+        assert_eq!(
+            output
+                .column(0)
+                .as_any()
+                .downcast_ref::<TimestampMicrosecondArray>()
+                .unwrap()
+                .value(1),
+            7000
+        );
+        // Unchanged siblings keep their value buffers, offsets, and parent 
validity.
+        assert_eq!(
+            output.column(1).to_data(),
+            input.as_struct().column(1).to_data()
+        );
+        assert_eq!(
+            output.column(2).to_data(),
+            input.as_struct().column(2).to_data()
+        );
+        for child in [
+            output.column(1).as_list::<i32>().values(),
+            output.column(2).as_map().keys(),
+            output.column(2).as_map().values(),
+        ] {
+            assert_eq!(
+                child.to_data().buffers()[0].as_ptr(),
+                values.to_data().buffers()[0].as_ptr()
+            );
+        }
+    }
+
+    #[test]
+    fn test_millis_to_micros_nested_visibility() {
+        use super::{parquet_convert_array, SparkParquetOptions};
+        use arrow::array::{
+            Array, ArrayRef, ListArray, MapArray, StructArray, 
TimestampMicrosecondArray,
+            TimestampMillisecondArray,
+        };
+        use arrow::buffer::{NullBuffer, OffsetBuffer};
+        use arrow::datatypes::{DataType, Field, TimeUnit};
+        use datafusion_comet_spark_expr::EvalMode;
+        use std::sync::Arc;
+
+        for timezone in [None::<Arc<str>>, Some(Arc::from("UTC"))] {
+            for overflow in [i64::MAX, i64::MIN] {
+                let options = SparkParquetOptions::new(EvalMode::Legacy, 
"UTC", false);
+                let millis: ArrayRef = Arc::new(
+                    TimestampMillisecondArray::from(vec![overflow, 7, 
overflow])
+                        .with_timezone_opt(timezone.clone()),
+                );
+                let field = Arc::new(Field::new("ts", 
millis.data_type().clone(), false));
+                let target_field = Arc::new(Field::new(
+                    "ts",
+                    DataType::Timestamp(TimeUnit::Microsecond, 
timezone.clone()),
+                    false,
+                ));
+                let validity = Some(NullBuffer::from(vec![false, true, 
false]));
+                let strukt: ArrayRef = Arc::new(StructArray::new(
+                    vec![Arc::clone(&field)].into(),
+                    vec![Arc::clone(&millis)],
+                    validity.clone(),
+                ));
+                let list: ArrayRef = Arc::new(ListArray::new(
+                    Arc::clone(&field),
+                    OffsetBuffer::new(vec![0, 1, 2, 3].into()),
+                    Arc::clone(&millis),
+                    validity.clone(),
+                ));
+                let entries = StructArray::new(
+                    vec![
+                        Arc::clone(&field),
+                        Arc::new(Field::new("value", 
millis.data_type().clone(), false)),
+                    ]
+                    .into(),
+                    vec![Arc::clone(&millis), Arc::clone(&millis)],
+                    None,
+                );
+                let map: ArrayRef = Arc::new(MapArray::new(
+                    Arc::new(Field::new("entries", 
entries.data_type().clone(), false)),
+                    OffsetBuffer::new(vec![0, 1, 2, 3].into()),
+                    entries,
+                    validity,
+                    false,
+                ));
+                let target_map = DataType::Map(
+                    Arc::new(Field::new(
+                        "entries",
+                        DataType::Struct(
+                            vec![
+                                Arc::clone(&target_field),
+                                Arc::new(Field::new(
+                                    "value",
+                                    target_field.data_type().clone(),
+                                    false,
+                                )),
+                            ]
+                            .into(),
+                        ),
+                        false,
+                    )),
+                    false,
+                );
+                for (array, target) in [
+                    (
+                        strukt,
+                        
DataType::Struct(vec![Arc::clone(&target_field)].into()),
+                    ),
+                    (list, DataType::List(Arc::clone(&target_field))),
+                    (map, target_map),
+                ] {
+                    // Overflow beneath null parents is not a value Spark 
reads. Required
+                    // children remain non-null, and slicing must not expose 
hidden entries.
+                    for input in [Arc::clone(&array), array.slice(1, 1)] {
+                        let converted = parquet_convert_array(input, &target, 
&options).unwrap();
+                        let values = match converted.data_type() {
+                            DataType::Struct(_) => Arc::clone(
+                                converted
+                                    .as_any()
+                                    .downcast_ref::<StructArray>()
+                                    .unwrap()
+                                    .column(0),
+                            ),
+                            DataType::List(_) => converted
+                                .as_any()
+                                .downcast_ref::<ListArray>()
+                                .unwrap()
+                                .value(if converted.len() == 1 { 0 } else { 1 
}),
+                            DataType::Map(_, _) => Arc::clone(
+                                converted
+                                    .as_any()
+                                    .downcast_ref::<MapArray>()
+                                    .unwrap()
+                                    .value(if converted.len() == 1 { 0 } else 
{ 1 })
+                                    .column(0),
+                            ),
+                            _ => unreachable!(),
+                        };
+                        let values = values
+                            .as_any()
+                            .downcast_ref::<TimestampMicrosecondArray>()
+                            .unwrap();
+                        assert_eq!(values.value(if values.len() == 3 { 1 } 
else { 0 }), 7000);
+                        assert_eq!(values.null_count(), 0);
+                    }
+                    // Removing parent nulls makes the overflow visible and 
must fail.
+                    let visible = arrow::array::make_array(
+                        
array.to_data().into_builder().nulls(None).build().unwrap(),
+                    );
+                    assert!(
+                        parquet_convert_array(Arc::clone(&visible), &target, 
&options).is_err()
+                    );
+                    // Propagate nullness through more than one level of 
nesting.
+                    let outer: ArrayRef = Arc::new(StructArray::new(
+                        vec![Arc::new(Field::new(
+                            "nested",
+                            visible.data_type().clone(),
+                            false,
+                        ))]
+                        .into(),
+                        vec![Arc::clone(&visible)],
+                        array.nulls().cloned(),
+                    ));
+                    let outer_target = DataType::Struct(
+                        vec![Arc::new(Field::new("nested", target.clone(), 
false))].into(),
+                    );
+                    parquet_convert_array(outer, &outer_target, 
&options).unwrap();
+                    // A slice of a non-null list/map also hides its backing 
prefix/suffix.
+                    parquet_convert_array(visible.slice(1, 1), &target, 
&options).unwrap();
+                }
+            }
+        }
+    }
+
     #[cfg(not(feature = "hdfs-opendal"))]
     #[test]
     #[cfg_attr(miri, ignore)] // AWS credential providers and object_store 
call foreign functions
diff --git 
a/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala 
b/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
index 799197bf33..2f434aac92 100644
--- a/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
@@ -285,6 +285,21 @@ abstract class ParquetReadSuite extends CometTestBase {
             |message root {
             |  optional int64 ts(TIMESTAMP_MILLIS);
             |  optional int64 ts_ntz(TIMESTAMP(MILLIS,false));
+            |  optional group s {
+            |    optional int64 ts(TIMESTAMP_MILLIS);
+            |    optional int64 ts_ntz(TIMESTAMP(MILLIS,false));
+            |  }
+            |  optional group a (LIST) {
+            |    repeated group list {
+            |      optional int64 element(TIMESTAMP_MILLIS);
+            |    }
+            |  }
+            |  optional group m (MAP) {
+            |    repeated group key_value {
+            |      required int64 key(TIMESTAMP_MILLIS);
+            |      optional int64 value(TIMESTAMP(MILLIS,false));
+            |    }
+            |  }
             |}
             |""".stripMargin)
           val writer = createParquetWriter(schema, path, dictionaryEnabled)
@@ -295,6 +310,13 @@ abstract class ParquetReadSuite extends CometTestBase {
             val record = new SimpleGroup(schema)
             record.add(0, millis)
             record.add(1, millis)
+            val nested = record.addGroup(2)
+            nested.add(0, millis)
+            nested.add(1, millis)
+            record.addGroup(3).addGroup(0).add(0, millis)
+            val entry = record.addGroup(4).addGroup(0)
+            entry.add(0, millis)
+            entry.add(1, millis)
             writer.write(record)
           }
           writer.close()
@@ -318,7 +340,7 @@ abstract class ParquetReadSuite extends CometTestBase {
           Seq(false, true).foreach { ansiEnabled =>
             withSQLConf(SQLConf.ANSI_ENABLED.key -> ansiEnabled.toString) {
               readParquetFile(path.toString) { df =>
-                Seq("ts", "ts_ntz").foreach { column =>
+                Seq("ts", "ts_ntz", "s", "s.ts", "s.ts_ntz", "a", "m").foreach 
{ column =>
                   val selected = df.select(column)
                   assert(collect(selected.queryExecution.executedPlan) {
                     case _: CometNativeScanExec => true


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to