Skip to main content

iceberg/arrow/reader/
pipeline.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! The main `ArrowReader` pipeline: reading a stream of `FileScanTask`s,
19//! opening Parquet files and resolving schemas, then wiring projection,
20//! predicates, row-group / row selection, and delete handling into a stream
21//! of transformed Arrow `RecordBatch`es.
22
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::sync::atomic::AtomicU64;
26
27use arrow_schema::{DataType, Field};
28use futures::{StreamExt, TryStreamExt};
29use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions};
30use parquet::arrow::{PARQUET_FIELD_ID_META_KEY, ParquetRecordBatchStreamBuilder, RowNumber};
31use parquet::encryption::decrypt::FileDecryptionProperties;
32
33use super::{
34    ArrowFileReader, ArrowReader, ParquetReadOptions, add_fallback_field_ids_to_arrow_schema,
35    apply_name_mapping_to_arrow_schema,
36};
37use crate::arrow::caching_delete_file_loader::CachingDeleteFileLoader;
38use crate::arrow::int96::coerce_int96_timestamps;
39use crate::arrow::record_batch_transformer::RecordBatchTransformerBuilder;
40use crate::arrow::scan_metrics::{CountingFileRead, ScanMetrics, ScanResult};
41use crate::encryption::StandardKeyMetadata;
42use crate::error::Result;
43use crate::io::{FileIO, FileMetadata, FileRead};
44use crate::metadata_columns::{
45    RESERVED_COL_NAME_POS, RESERVED_FIELD_ID_FILE, RESERVED_FIELD_ID_POS,
46    RESERVED_FIELD_ID_SPEC_ID, is_metadata_field,
47};
48use crate::scan::{ArrowRecordBatchStream, FileScanTask, FileScanTaskStream};
49use crate::spec::Datum;
50use crate::{Error, ErrorKind};
51
52impl ArrowReader {
53    /// Take a stream of FileScanTasks and reads all the files.
54    /// Returns a [`ScanResult`] containing the record batch stream and scan metrics.
55    pub fn read(self, tasks: FileScanTaskStream) -> Result<ScanResult> {
56        let concurrency_limit_data_files = self.concurrency_limit_data_files;
57        let scan_metrics = ScanMetrics::new();
58
59        let task_reader = FileScanTaskReader {
60            batch_size: self.batch_size,
61            file_io: self.file_io,
62            delete_file_loader: self
63                .delete_file_loader
64                .with_scan_metrics(scan_metrics.clone()),
65            row_group_filtering_enabled: self.row_group_filtering_enabled,
66            row_selection_enabled: self.row_selection_enabled,
67            parquet_read_options: self.parquet_read_options,
68            scan_metrics: scan_metrics.clone(),
69        };
70
71        // Fast-path for single concurrency to avoid overhead of try_flatten_unordered
72        let stream: ArrowRecordBatchStream = if concurrency_limit_data_files == 1 {
73            Box::pin(
74                tasks
75                    .and_then(move |task| task_reader.clone().process(task))
76                    .map_err(|err| {
77                        Error::new(ErrorKind::Unexpected, "file scan task generate failed")
78                            .with_source(err)
79                    })
80                    .try_flatten(),
81            )
82        } else {
83            Box::pin(
84                tasks
85                    .map_ok(move |task| task_reader.clone().process(task))
86                    .map_err(|err| {
87                        Error::new(ErrorKind::Unexpected, "file scan task generate failed")
88                            .with_source(err)
89                    })
90                    .try_buffer_unordered(concurrency_limit_data_files)
91                    .try_flatten_unordered(concurrency_limit_data_files),
92            )
93        };
94
95        Ok(ScanResult::new(stream, scan_metrics))
96    }
97}
98
99/// Per-scan state for processing [`FileScanTask`]s. Created once per
100/// [`ArrowReader::read`] call and cloned per task.
101#[derive(Clone)]
102struct FileScanTaskReader {
103    batch_size: Option<usize>,
104    file_io: FileIO,
105    delete_file_loader: CachingDeleteFileLoader,
106    row_group_filtering_enabled: bool,
107    row_selection_enabled: bool,
108    parquet_read_options: ParquetReadOptions,
109    scan_metrics: ScanMetrics,
110}
111
112impl FileScanTaskReader {
113    async fn process(self, task: FileScanTask) -> Result<ArrowRecordBatchStream> {
114        let should_load_page_index =
115            (self.row_selection_enabled && task.predicate.is_some()) || !task.deletes.is_empty();
116        let mut parquet_read_options = self.parquet_read_options;
117        parquet_read_options.preload_page_index = should_load_page_index;
118
119        let delete_filter_rx = self
120            .delete_file_loader
121            .load_deletes(&task.deletes, Arc::clone(&task.schema));
122
123        // Open the Parquet file once, loading its metadata
124        let (parquet_file_reader, arrow_metadata) = ArrowReader::open_parquet_file(
125            &task.data_file_path,
126            &self.file_io,
127            task.file_size_in_bytes,
128            parquet_read_options,
129            self.scan_metrics.bytes_read_counter(),
130            task.key_metadata.as_deref(),
131        )
132        .await?;
133
134        // Check if Parquet file has embedded field IDs
135        // Corresponds to Java's ParquetSchemaUtil.hasIds()
136        // Reference: parquet/src/main/java/org/apache/iceberg/parquet/ParquetSchemaUtil.java:118
137        let missing_field_ids = arrow_metadata
138            .schema()
139            .fields()
140            .iter()
141            .next()
142            .is_some_and(|f| f.metadata().get(PARQUET_FIELD_ID_META_KEY).is_none());
143
144        // Position-based fallback applies only when the file has no embedded field IDs
145        // AND no name mapping is available. With a name mapping, field IDs are assigned
146        // to the Arrow schema below, and projection/predicate planning must use them
147        // (see #2403).
148        let use_position_fallback = missing_field_ids && task.name_mapping.is_none();
149
150        // Three-branch schema resolution strategy matching Java's ReadConf constructor
151        //
152        // Per Iceberg spec Column Projection rules:
153        // "Columns in Iceberg data files are selected by field id. The table schema's column
154        //  names and order may change after a data file is written, and projection must be done
155        //  using field ids."
156        // https://iceberg.apache.org/spec/#column-projection
157        //
158        // When Parquet files lack field IDs (e.g., Hive/Spark migrations via add_files),
159        // we must assign field IDs BEFORE reading data to enable correct projection.
160        //
161        // Java's ReadConf determines field ID strategy:
162        // - Branch 1: hasIds(fileSchema) → trust embedded field IDs, use pruneColumns()
163        // - Branch 2: nameMapping present → applyNameMapping(), then pruneColumns()
164        // - Branch 3: fallback → addFallbackIds(), then pruneColumnsFallback()
165        let arrow_metadata = if missing_field_ids {
166            // Parquet file lacks field IDs - must assign them before reading
167            let arrow_schema = if let Some(name_mapping) = &task.name_mapping {
168                // Branch 2: Apply name mapping to assign correct Iceberg field IDs
169                // Per spec rule #2: "Use schema.name-mapping.default metadata to map field id
170                // to columns without field id"
171                // Corresponds to Java's ParquetSchemaUtil.applyNameMapping()
172                apply_name_mapping_to_arrow_schema(
173                    Arc::clone(arrow_metadata.schema()),
174                    name_mapping,
175                )?
176            } else {
177                // Branch 3: No name mapping - use position-based fallback IDs
178                // Corresponds to Java's ParquetSchemaUtil.addFallbackIds()
179                add_fallback_field_ids_to_arrow_schema(arrow_metadata.schema())
180            };
181
182            let options = ArrowReaderOptions::new().with_schema(arrow_schema);
183            ArrowReaderMetadata::try_new(Arc::clone(arrow_metadata.metadata()), options).map_err(
184                |e| {
185                    Error::new(
186                        ErrorKind::Unexpected,
187                        "Failed to create ArrowReaderMetadata with field ID schema",
188                    )
189                    .with_source(e)
190                },
191            )?
192        } else {
193            // Branch 1: File has embedded field IDs - trust them
194            arrow_metadata
195        };
196
197        // Coerce INT96 timestamp columns to the resolution specified by the Iceberg schema.
198        // This must happen before building the stream reader to avoid i64 overflow in arrow-rs.
199        let arrow_metadata = if let Some(coerced_schema) =
200            coerce_int96_timestamps(arrow_metadata.schema(), &task.schema)
201        {
202            let options = ArrowReaderOptions::new().with_schema(Arc::clone(&coerced_schema));
203            ArrowReaderMetadata::try_new(Arc::clone(arrow_metadata.metadata()), options).map_err(
204                |e| {
205                    Error::new(
206                        ErrorKind::Unexpected,
207                        format!(
208                            "Failed to create ArrowReaderMetadata with INT96-coerced schema: {coerced_schema}"
209                        ),
210                    )
211                    .with_source(e)
212                },
213            )?
214        } else {
215            arrow_metadata
216        };
217
218        let project_pos = task.project_field_ids().contains(&RESERVED_FIELD_ID_POS);
219
220        let arrow_metadata = if project_pos {
221            let row_number_field = Arc::new(
222                Field::new(RESERVED_COL_NAME_POS, DataType::Int64, false)
223                    .with_metadata(HashMap::from([(
224                        PARQUET_FIELD_ID_META_KEY.to_string(),
225                        RESERVED_FIELD_ID_POS.to_string(),
226                    )]))
227                    .with_extension_type(RowNumber),
228            );
229
230            let options = ArrowReaderOptions::new()
231                .with_schema(Arc::clone(arrow_metadata.schema()))
232                .with_virtual_columns(vec![row_number_field])?;
233
234            ArrowReaderMetadata::try_new(Arc::clone(arrow_metadata.metadata()), options).map_err(
235                |e| {
236                    Error::new(
237                        ErrorKind::Unexpected,
238                        "Failed to create ArrowReaderMetadata with the 'row_number' virtual_column",
239                    )
240                    .with_source(e)
241                },
242            )?
243        } else {
244            arrow_metadata
245        };
246
247        // Build the stream reader, reusing the already-opened file reader
248        let mut record_batch_stream_builder =
249            ParquetRecordBatchStreamBuilder::new_with_metadata(parquet_file_reader, arrow_metadata);
250
251        // Filter out metadata fields for Parquet projection (they don't exist in files)
252        let project_field_ids_without_metadata: Vec<i32> = task
253            .project_field_ids
254            .iter()
255            .filter(|&&id| !is_metadata_field(id))
256            .copied()
257            .collect();
258
259        // Create projection mask based on field IDs
260        // - If file has embedded IDs: field-ID-based projection
261        // - If name mapping applied: field-ID-based projection using the IDs the name
262        //   mapping assigned to the Arrow schema
263        // - Otherwise: position-based fallback projection
264        let projection_mask = ArrowReader::get_arrow_projection_mask(
265            &project_field_ids_without_metadata,
266            &task.schema,
267            record_batch_stream_builder.parquet_schema(),
268            record_batch_stream_builder.schema(),
269            use_position_fallback, // Whether to use position-based (true) or field-ID-based (false) projection
270        )?;
271
272        record_batch_stream_builder =
273            record_batch_stream_builder.with_projection(projection_mask.clone());
274
275        // RecordBatchTransformer performs any transformations required on the RecordBatches
276        // that come back from the file, such as type promotion, default column insertion,
277        // column re-ordering, partition constants, and virtual field addition (like _file)
278        let mut record_batch_transformer_builder =
279            RecordBatchTransformerBuilder::new(task.schema_ref(), task.project_field_ids());
280
281        // Add the _file metadata column if it's in the projected fields
282        if task.project_field_ids().contains(&RESERVED_FIELD_ID_FILE) {
283            let file_datum = Datum::string(task.data_file_path.clone());
284            record_batch_transformer_builder =
285                record_batch_transformer_builder.with_constant(RESERVED_FIELD_ID_FILE, file_datum);
286        }
287
288        if task
289            .project_field_ids()
290            .contains(&RESERVED_FIELD_ID_SPEC_ID)
291        {
292            let partition_spec = task
293                .partition_spec
294                .as_ref()
295                .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Partition spec is missing"))?;
296
297            let spec_id_datum = Datum::int(partition_spec.spec_id());
298            record_batch_transformer_builder = record_batch_transformer_builder
299                .with_constant(RESERVED_FIELD_ID_SPEC_ID, spec_id_datum);
300        }
301
302        if let (Some(partition_spec), Some(partition_data)) =
303            (task.partition_spec.clone(), task.partition.clone())
304        {
305            record_batch_transformer_builder =
306                record_batch_transformer_builder.with_partition(partition_spec, partition_data)?;
307        }
308
309        if project_pos {
310            record_batch_transformer_builder =
311                record_batch_transformer_builder.with_virtual_field(RESERVED_FIELD_ID_POS);
312        }
313
314        let mut record_batch_transformer = record_batch_transformer_builder.build();
315
316        if let Some(batch_size) = self.batch_size {
317            record_batch_stream_builder = record_batch_stream_builder.with_batch_size(batch_size);
318        }
319
320        let delete_filter = delete_filter_rx.await.unwrap()?;
321        let delete_predicate = delete_filter.build_equality_delete_predicate(&task).await?;
322
323        // In addition to the optional predicate supplied in the `FileScanTask`,
324        // we also have an optional predicate resulting from equality delete files.
325        // If both are present, we logical-AND them together to form a single filter
326        // predicate that we can pass to the `RecordBatchStreamBuilder`.
327        let final_predicate = match (&task.predicate, delete_predicate) {
328            (None, None) => None,
329            (Some(predicate), None) => Some(predicate.clone()),
330            (None, Some(ref predicate)) => Some(predicate.clone()),
331            (Some(filter_predicate), Some(delete_predicate)) => {
332                Some(filter_predicate.clone().and(delete_predicate))
333            }
334        };
335
336        // There are three possible sources for potential lists of selected RowGroup indices,
337        // and two for `RowSelection`s.
338        // Selected RowGroup index lists can come from three sources:
339        //   * When task.start and task.length specify a byte range (file splitting);
340        //   * When there are equality delete files that are applicable;
341        //   * When there is a scan predicate and row_group_filtering_enabled = true.
342        // `RowSelection`s can be created in either or both of the following cases:
343        //   * When there are positional delete files that are applicable;
344        //   * When there is a scan predicate and row_selection_enabled = true
345        // Note that row group filtering from predicates only happens when
346        // there is a scan predicate AND row_group_filtering_enabled = true,
347        // but we perform row selection filtering if there are applicable
348        // equality delete files OR (there is a scan predicate AND row_selection_enabled),
349        // since the only implemented method of applying positional deletes is
350        // by using a `RowSelection`.
351        let mut selected_row_group_indices = None;
352        let mut row_selection = None;
353
354        // Filter row groups based on byte range from task.start and task.length.
355        // If both start and length are 0, read the entire file (backwards compatibility).
356        if task.start != 0 || task.length != 0 {
357            let byte_range_filtered_row_groups = ArrowReader::filter_row_groups_by_byte_range(
358                record_batch_stream_builder.metadata(),
359                task.start,
360                task.length,
361            )?;
362            selected_row_group_indices = Some(byte_range_filtered_row_groups);
363        }
364
365        if let Some(predicate) = final_predicate {
366            let (iceberg_field_ids, field_id_map) = ArrowReader::build_field_id_set_and_map(
367                record_batch_stream_builder.parquet_schema(),
368                record_batch_stream_builder.schema(),
369                &predicate,
370                use_position_fallback,
371            )?;
372
373            let row_filter = ArrowReader::get_row_filter(
374                &predicate,
375                record_batch_stream_builder.parquet_schema(),
376                &iceberg_field_ids,
377                &field_id_map,
378            )?;
379            record_batch_stream_builder = record_batch_stream_builder.with_row_filter(row_filter);
380
381            if self.row_group_filtering_enabled {
382                let predicate_filtered_row_groups = ArrowReader::get_selected_row_group_indices(
383                    &predicate,
384                    record_batch_stream_builder.metadata(),
385                    &field_id_map,
386                    &task.schema,
387                )?;
388
389                // Merge predicate-based filtering with byte range filtering (if present)
390                // by taking the intersection of both filters
391                selected_row_group_indices = match selected_row_group_indices {
392                    Some(byte_range_filtered) => {
393                        // Keep only row groups that are in both filters
394                        let intersection: Vec<usize> = byte_range_filtered
395                            .into_iter()
396                            .filter(|idx| predicate_filtered_row_groups.contains(idx))
397                            .collect();
398                        Some(intersection)
399                    }
400                    None => Some(predicate_filtered_row_groups),
401                };
402            }
403
404            if self.row_selection_enabled {
405                row_selection = ArrowReader::get_row_selection_for_filter_predicate(
406                    &predicate,
407                    record_batch_stream_builder.metadata(),
408                    &selected_row_group_indices,
409                    &field_id_map,
410                    &task.schema,
411                )?;
412            }
413        }
414
415        let positional_delete_indexes = delete_filter.get_delete_vector(&task);
416
417        if let Some(positional_delete_indexes) = positional_delete_indexes {
418            let delete_row_selection = {
419                let positional_delete_indexes = positional_delete_indexes.lock().unwrap();
420
421                ArrowReader::build_deletes_row_selection(
422                    record_batch_stream_builder.metadata().row_groups(),
423                    &selected_row_group_indices,
424                    &positional_delete_indexes,
425                )
426            }?;
427
428            // merge the row selection from the delete files with the row selection
429            // from the filter predicate, if there is one from the filter predicate
430            row_selection = match row_selection {
431                None => Some(delete_row_selection),
432                Some(filter_row_selection) => {
433                    Some(filter_row_selection.intersection(&delete_row_selection))
434                }
435            };
436        }
437
438        if let Some(row_selection) = row_selection {
439            record_batch_stream_builder =
440                record_batch_stream_builder.with_row_selection(row_selection);
441        }
442
443        if let Some(selected_row_group_indices) = selected_row_group_indices {
444            record_batch_stream_builder =
445                record_batch_stream_builder.with_row_groups(selected_row_group_indices);
446        }
447
448        // Build the batch stream and send all the RecordBatches that it generates
449        // to the requester.
450        let record_batch_stream =
451            record_batch_stream_builder
452                .build()?
453                .map(move |batch| match batch {
454                    Ok(batch) => {
455                        // Process the record batch (type promotion, column reordering, virtual fields, etc.)
456                        record_batch_transformer.process_record_batch(batch)
457                    }
458                    Err(err) => Err(err.into()),
459                });
460
461        Ok(Box::pin(record_batch_stream) as ArrowRecordBatchStream)
462    }
463}
464
465impl ArrowReader {
466    /// Opens a Parquet file and loads its metadata, wrapping the reader with
467    /// [`CountingFileRead`] so all I/O is accumulated into `bytes_read`.
468    pub(crate) async fn open_parquet_file(
469        data_file_path: &str,
470        file_io: &FileIO,
471        file_size_in_bytes: u64,
472        parquet_read_options: ParquetReadOptions,
473        bytes_read: &Arc<AtomicU64>,
474        key_metadata: Option<&[u8]>,
475    ) -> Result<(ArrowFileReader, ArrowReaderMetadata)> {
476        let parquet_file = file_io.new_input(data_file_path)?;
477        let counting_reader =
478            CountingFileRead::new(parquet_file.reader().await?, Arc::clone(bytes_read));
479        Self::build_parquet_reader(
480            Box::new(counting_reader),
481            file_size_in_bytes,
482            parquet_read_options,
483            key_metadata,
484        )
485        .await
486    }
487
488    async fn build_parquet_reader(
489        parquet_reader: Box<dyn FileRead>,
490        file_size_in_bytes: u64,
491        parquet_read_options: ParquetReadOptions,
492        key_metadata: Option<&[u8]>,
493    ) -> Result<(ArrowFileReader, ArrowReaderMetadata)> {
494        let mut reader = ArrowFileReader::new(
495            FileMetadata {
496                size: file_size_in_bytes,
497            },
498            parquet_reader,
499        )
500        .with_parquet_read_options(parquet_read_options);
501
502        let arrow_reader_options = Self::build_arrow_reader_options(key_metadata)?;
503
504        let arrow_metadata = ArrowReaderMetadata::load_async(&mut reader, arrow_reader_options)
505            .await
506            .map_err(|e| {
507                Error::new(ErrorKind::Unexpected, "Failed to load Parquet metadata").with_source(e)
508            })?;
509
510        Ok((reader, arrow_metadata))
511    }
512
513    /// Builds `ArrowReaderOptions`, adding `FileDecryptionProperties` when
514    /// key metadata is present for Parquet Modular Encryption.
515    fn build_arrow_reader_options(key_metadata: Option<&[u8]>) -> Result<ArrowReaderOptions> {
516        match key_metadata {
517            Some(km) => {
518                let standard_key_metadata = StandardKeyMetadata::decode(km)?;
519                let mut builder = FileDecryptionProperties::builder(
520                    standard_key_metadata.encryption_key().as_bytes().to_vec(),
521                );
522                if let Some(aad) = standard_key_metadata.aad_prefix() {
523                    builder = builder.with_aad_prefix(aad.to_vec());
524                }
525                let decryption_properties = builder.build().map_err(|e| {
526                    Error::new(
527                        ErrorKind::Unexpected,
528                        "Failed to build Parquet file decryption properties",
529                    )
530                    .with_source(e)
531                })?;
532                Ok(
533                    ArrowReaderOptions::new()
534                        .with_file_decryption_properties(decryption_properties),
535                )
536            }
537            None => Ok(ArrowReaderOptions::default()),
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use std::collections::HashMap;
545    use std::fs::File;
546    use std::sync::Arc;
547
548    use arrow_array::cast::AsArray;
549    use arrow_array::{Array, ArrayRef, Int32Array, RecordBatch};
550    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
551    use futures::TryStreamExt;
552    use parquet::arrow::{ArrowWriter, PARQUET_FIELD_ID_META_KEY};
553    use parquet::basic::Compression;
554    use parquet::file::properties::WriterProperties;
555    use tempfile::TempDir;
556
557    use crate::Runtime;
558    use crate::arrow::ArrowReaderBuilder;
559    use crate::arrow::test_utils::write_encrypted_parquet;
560    use crate::io::FileIO;
561    use crate::scan::{FileScanTask, FileScanTaskStream};
562    use crate::spec::{DataFileFormat, NestedField, PrimitiveType, Schema, SchemaRef, Type};
563
564    // INT96 encoding: [nanos_low_u32, nanos_high_u32, julian_day_u32]
565    // Julian day 2_440_588 = Unix epoch (1970-01-01)
566    const UNIX_EPOCH_JULIAN: i64 = 2_440_588;
567    const MICROS_PER_DAY: i64 = 86_400_000_000;
568    // Noon on 3333-01-01 (Julian day 2_953_529) — outside the i64 nanosecond range (~1677-2262).
569    const INT96_TEST_NANOS_WITHIN_DAY: u64 = 43_200_000_000_000;
570    const INT96_TEST_JULIAN_DAY: u32 = 2_953_529;
571
572    fn make_int96_test_value() -> (parquet::data_type::Int96, i64) {
573        let mut val = parquet::data_type::Int96::new();
574        val.set_data(
575            (INT96_TEST_NANOS_WITHIN_DAY & 0xFFFFFFFF) as u32,
576            (INT96_TEST_NANOS_WITHIN_DAY >> 32) as u32,
577            INT96_TEST_JULIAN_DAY,
578        );
579        let expected_micros = (INT96_TEST_JULIAN_DAY as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY
580            + (INT96_TEST_NANOS_WITHIN_DAY / 1_000) as i64;
581        (val, expected_micros)
582    }
583
584    async fn read_int96_batches(
585        file_path: &str,
586        schema: SchemaRef,
587        project_field_ids: Vec<i32>,
588    ) -> Vec<RecordBatch> {
589        let file_io = FileIO::new_with_fs();
590        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
591
592        let file_size = std::fs::metadata(file_path).unwrap().len();
593        let task = FileScanTask::builder()
594            .with_file_size_in_bytes(file_size)
595            .with_start(0)
596            .with_length(file_size)
597            .with_data_file_path(file_path.to_string())
598            .with_data_file_format(DataFileFormat::Parquet)
599            .with_schema(schema)
600            .with_project_field_ids(project_field_ids)
601            .with_case_sensitive(false)
602            .build();
603
604        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
605        reader
606            .read(tasks)
607            .unwrap()
608            .stream()
609            .try_collect()
610            .await
611            .unwrap()
612    }
613
614    // ArrowWriter cannot write INT96, so we use SerializedFileWriter directly.
615    fn write_int96_parquet_file(
616        table_location: &str,
617        filename: &str,
618        with_field_ids: bool,
619    ) -> (String, Vec<i64>) {
620        use parquet::basic::{Repetition, Type as PhysicalType};
621        use parquet::data_type::{Int32Type, Int96, Int96Type};
622        use parquet::file::writer::SerializedFileWriter;
623        use parquet::schema::types::Type as SchemaType;
624
625        let file_path = format!("{table_location}/{filename}");
626
627        let mut ts_builder = SchemaType::primitive_type_builder("ts", PhysicalType::INT96)
628            .with_repetition(Repetition::OPTIONAL);
629        let mut id_builder = SchemaType::primitive_type_builder("id", PhysicalType::INT32)
630            .with_repetition(Repetition::REQUIRED);
631
632        if with_field_ids {
633            ts_builder = ts_builder.with_id(Some(1));
634            id_builder = id_builder.with_id(Some(2));
635        }
636
637        let schema = SchemaType::group_type_builder("schema")
638            .with_fields(vec![
639                Arc::new(ts_builder.build().unwrap()),
640                Arc::new(id_builder.build().unwrap()),
641            ])
642            .build()
643            .unwrap();
644
645        // Dates outside the i64 nanosecond range (~1677-2262) overflow without coercion.
646        const NOON_NANOS: u64 = INT96_TEST_NANOS_WITHIN_DAY;
647        const JULIAN_3333: u32 = INT96_TEST_JULIAN_DAY;
648        const JULIAN_2100: u32 = 2_488_070;
649
650        let test_data: Vec<(u32, u32, u32, i64)> = vec![
651            // 3333-01-01 00:00:00
652            (
653                0,
654                0,
655                JULIAN_3333,
656                (JULIAN_3333 as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY,
657            ),
658            // 3333-01-01 12:00:00
659            (
660                (NOON_NANOS & 0xFFFFFFFF) as u32,
661                (NOON_NANOS >> 32) as u32,
662                JULIAN_3333,
663                (JULIAN_3333 as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY
664                    + (NOON_NANOS / 1_000) as i64,
665            ),
666            // 2100-01-01 00:00:00
667            (
668                0,
669                0,
670                JULIAN_2100,
671                (JULIAN_2100 as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY,
672            ),
673        ];
674
675        let int96_values: Vec<Int96> = test_data
676            .iter()
677            .map(|(lo, hi, day, _)| {
678                let mut v = Int96::new();
679                v.set_data(*lo, *hi, *day);
680                v
681            })
682            .collect();
683
684        let id_values: Vec<i32> = (0..test_data.len() as i32).collect();
685        let expected_micros: Vec<i64> = test_data.iter().map(|(_, _, _, m)| *m).collect();
686
687        let file = File::create(&file_path).unwrap();
688        let mut writer =
689            SerializedFileWriter::new(file, Arc::new(schema), Default::default()).unwrap();
690
691        let mut row_group = writer.next_row_group().unwrap();
692        {
693            // def=1: ts is OPTIONAL and present. No repetition levels (top-level columns).
694            let mut col = row_group.next_column().unwrap().unwrap();
695            col.typed::<Int96Type>()
696                .write_batch(&int96_values, Some(&vec![1; test_data.len()]), None)
697                .unwrap();
698            col.close().unwrap();
699        }
700        {
701            let mut col = row_group.next_column().unwrap().unwrap();
702            col.typed::<Int32Type>()
703                .write_batch(&id_values, None, None)
704                .unwrap();
705            col.close().unwrap();
706        }
707        row_group.close().unwrap();
708        writer.close().unwrap();
709
710        (file_path, expected_micros)
711    }
712
713    async fn assert_int96_read_matches(
714        file_path: &str,
715        schema: SchemaRef,
716        project_field_ids: Vec<i32>,
717        expected_micros: &[i64],
718    ) {
719        use arrow_array::TimestampMicrosecondArray;
720
721        let batches = read_int96_batches(file_path, schema, project_field_ids).await;
722
723        assert_eq!(batches.len(), 1);
724        let ts_array = batches[0]
725            .column(0)
726            .as_any()
727            .downcast_ref::<TimestampMicrosecondArray>()
728            .expect("Expected TimestampMicrosecondArray");
729
730        for (i, expected) in expected_micros.iter().enumerate() {
731            assert_eq!(
732                ts_array.value(i),
733                *expected,
734                "Row {i}: got {}, expected {expected}",
735                ts_array.value(i)
736            );
737        }
738    }
739
740    #[tokio::test]
741    async fn test_read_encrypted_parquet() {
742        let encryption_key = b"0123456789abcdef";
743        let aad_prefix = b"aad_prefix";
744
745        let schema = Arc::new(
746            Schema::builder()
747                .with_schema_id(1)
748                .with_fields(vec![
749                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
750                ])
751                .build()
752                .unwrap(),
753        );
754
755        let arrow_schema = Arc::new(ArrowSchema::new(vec![
756            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
757                PARQUET_FIELD_ID_META_KEY.to_string(),
758                "1".to_string(),
759            )])),
760        ]));
761
762        let tmp_dir = TempDir::new().unwrap();
763        let table_location = tmp_dir.path().to_str().unwrap().to_string();
764        let file_io = FileIO::new_with_fs();
765
766        let id_data = Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef;
767        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![id_data]).unwrap();
768
769        let file_path = format!("{table_location}/encrypted.parquet");
770        write_encrypted_parquet(&file_path, &batch, encryption_key, Some(aad_prefix));
771
772        let key_metadata = crate::encryption::StandardKeyMetadata::try_new(encryption_key)
773            .unwrap()
774            .with_aad_prefix(aad_prefix)
775            .encode()
776            .unwrap();
777
778        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
779
780        let task = FileScanTask::builder()
781            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
782            .with_start(0)
783            .with_length(0)
784            .with_data_file_path(file_path)
785            .with_data_file_format(DataFileFormat::Parquet)
786            .with_schema(schema)
787            .with_project_field_ids(vec![1])
788            .with_case_sensitive(false)
789            .with_key_metadata(Some(key_metadata))
790            .build();
791
792        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
793        let batches: Vec<RecordBatch> = reader
794            .read(tasks)
795            .unwrap()
796            .stream()
797            .try_collect()
798            .await
799            .unwrap();
800
801        assert_eq!(batches.len(), 1);
802        let ids = batches[0]
803            .column(0)
804            .as_any()
805            .downcast_ref::<Int32Array>()
806            .unwrap();
807        assert_eq!(ids.values(), &[10, 20, 30]);
808    }
809
810    #[tokio::test]
811    async fn test_read_encrypted_parquet_without_key_metadata_fails() {
812        let encryption_key = b"0123456789abcdef";
813
814        let schema = Arc::new(
815            Schema::builder()
816                .with_schema_id(1)
817                .with_fields(vec![
818                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
819                ])
820                .build()
821                .unwrap(),
822        );
823
824        let arrow_schema = Arc::new(ArrowSchema::new(vec![
825            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
826                PARQUET_FIELD_ID_META_KEY.to_string(),
827                "1".to_string(),
828            )])),
829        ]));
830
831        let tmp_dir = TempDir::new().unwrap();
832        let table_location = tmp_dir.path().to_str().unwrap().to_string();
833        let file_io = FileIO::new_with_fs();
834
835        let id_data = Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef;
836        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![id_data]).unwrap();
837
838        let file_path = format!("{table_location}/encrypted_no_key.parquet");
839        write_encrypted_parquet(&file_path, &batch, encryption_key, None);
840
841        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
842
843        let task = FileScanTask::builder()
844            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
845            .with_start(0)
846            .with_length(0)
847            .with_data_file_path(file_path)
848            .with_data_file_format(DataFileFormat::Parquet)
849            .with_schema(schema)
850            .with_project_field_ids(vec![1])
851            .with_case_sensitive(false)
852            .build();
853
854        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
855        let result: Result<Vec<RecordBatch>, _> =
856            reader.read(tasks).unwrap().stream().try_collect().await;
857
858        let err = result.unwrap_err();
859        assert_eq!(err.kind(), crate::ErrorKind::Unexpected);
860        let err_str = format!("{err}");
861        assert!(
862            err_str.contains("encrypted footer"),
863            "Expected error about encrypted footer, got: {err_str}"
864        );
865        assert!(
866            err_str.contains("decryption properties were not provided"),
867            "Expected error about missing decryption properties, got: {err_str}"
868        );
869    }
870
871    #[tokio::test]
872    async fn test_read_encrypted_parquet_with_wrong_key_fails() {
873        let encryption_key = b"0123456789abcdef";
874        let wrong_key = b"fedcba9876543210";
875
876        let schema = Arc::new(
877            Schema::builder()
878                .with_schema_id(1)
879                .with_fields(vec![
880                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
881                ])
882                .build()
883                .unwrap(),
884        );
885
886        let arrow_schema = Arc::new(ArrowSchema::new(vec![
887            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
888                PARQUET_FIELD_ID_META_KEY.to_string(),
889                "1".to_string(),
890            )])),
891        ]));
892
893        let tmp_dir = TempDir::new().unwrap();
894        let table_location = tmp_dir.path().to_str().unwrap().to_string();
895        let file_io = FileIO::new_with_fs();
896
897        let id_data = Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef;
898        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![id_data]).unwrap();
899
900        let file_path = format!("{table_location}/encrypted_wrong_key.parquet");
901        write_encrypted_parquet(&file_path, &batch, encryption_key, None);
902
903        let wrong_key_metadata = crate::encryption::StandardKeyMetadata::try_new(wrong_key)
904            .unwrap()
905            .encode()
906            .unwrap();
907
908        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
909
910        let task = FileScanTask::builder()
911            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
912            .with_start(0)
913            .with_length(0)
914            .with_data_file_path(file_path)
915            .with_data_file_format(DataFileFormat::Parquet)
916            .with_schema(schema)
917            .with_project_field_ids(vec![1])
918            .with_case_sensitive(false)
919            .with_key_metadata(Some(wrong_key_metadata))
920            .build();
921
922        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
923        let result: Result<Vec<RecordBatch>, _> =
924            reader.read(tasks).unwrap().stream().try_collect().await;
925
926        let err = result.unwrap_err();
927        assert_eq!(err.kind(), crate::ErrorKind::Unexpected);
928        let err_str = format!("{err}");
929        assert!(
930            err_str.contains("unable to decrypt parquet footer"),
931            "Expected error about decryption failure, got: {err_str}"
932        );
933    }
934
935    /// Test that concurrency=1 reads all files correctly and in deterministic order.
936    /// This verifies the fast-path optimization for single concurrency.
937    #[tokio::test]
938    async fn test_read_with_concurrency_one() {
939        use arrow_array::Int32Array;
940
941        let schema = Arc::new(
942            Schema::builder()
943                .with_schema_id(1)
944                .with_fields(vec![
945                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
946                    NestedField::required(2, "file_num", Type::Primitive(PrimitiveType::Int))
947                        .into(),
948                ])
949                .build()
950                .unwrap(),
951        );
952
953        let arrow_schema = Arc::new(ArrowSchema::new(vec![
954            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
955                PARQUET_FIELD_ID_META_KEY.to_string(),
956                "1".to_string(),
957            )])),
958            Field::new("file_num", DataType::Int32, false).with_metadata(HashMap::from([(
959                PARQUET_FIELD_ID_META_KEY.to_string(),
960                "2".to_string(),
961            )])),
962        ]));
963
964        let tmp_dir = TempDir::new().unwrap();
965        let table_location = tmp_dir.path().to_str().unwrap().to_string();
966        let file_io = FileIO::new_with_fs();
967
968        // Create 3 parquet files with different data
969        let props = WriterProperties::builder()
970            .set_compression(Compression::SNAPPY)
971            .build();
972
973        for file_num in 0..3 {
974            let id_data = Arc::new(Int32Array::from_iter_values(
975                file_num * 10..(file_num + 1) * 10,
976            )) as ArrayRef;
977            let file_num_data = Arc::new(Int32Array::from(vec![file_num; 10])) as ArrayRef;
978
979            let to_write =
980                RecordBatch::try_new(arrow_schema.clone(), vec![id_data, file_num_data]).unwrap();
981
982            let file = File::create(format!("{table_location}/file_{file_num}.parquet")).unwrap();
983            let mut writer =
984                ArrowWriter::try_new(file, to_write.schema(), Some(props.clone())).unwrap();
985            writer.write(&to_write).expect("Writing batch");
986            writer.close().unwrap();
987        }
988
989        // Read with concurrency=1 (fast-path)
990        let reader = ArrowReaderBuilder::new(file_io, Runtime::current())
991            .with_data_file_concurrency_limit(1)
992            .build();
993
994        // Create tasks in a specific order: file_0, file_1, file_2
995        let tasks = vec![
996            Ok(FileScanTask::builder()
997                .with_file_size_in_bytes(
998                    std::fs::metadata(format!("{table_location}/file_0.parquet"))
999                        .unwrap()
1000                        .len(),
1001                )
1002                .with_start(0)
1003                .with_length(0)
1004                .with_data_file_path(format!("{table_location}/file_0.parquet"))
1005                .with_data_file_format(DataFileFormat::Parquet)
1006                .with_schema(schema.clone())
1007                .with_project_field_ids(vec![1, 2])
1008                .with_case_sensitive(false)
1009                .build()),
1010            Ok(FileScanTask::builder()
1011                .with_file_size_in_bytes(
1012                    std::fs::metadata(format!("{table_location}/file_1.parquet"))
1013                        .unwrap()
1014                        .len(),
1015                )
1016                .with_start(0)
1017                .with_length(0)
1018                .with_data_file_path(format!("{table_location}/file_1.parquet"))
1019                .with_data_file_format(DataFileFormat::Parquet)
1020                .with_schema(schema.clone())
1021                .with_project_field_ids(vec![1, 2])
1022                .with_case_sensitive(false)
1023                .build()),
1024            Ok(FileScanTask::builder()
1025                .with_file_size_in_bytes(
1026                    std::fs::metadata(format!("{table_location}/file_2.parquet"))
1027                        .unwrap()
1028                        .len(),
1029                )
1030                .with_start(0)
1031                .with_length(0)
1032                .with_data_file_path(format!("{table_location}/file_2.parquet"))
1033                .with_data_file_format(DataFileFormat::Parquet)
1034                .with_schema(schema.clone())
1035                .with_project_field_ids(vec![1, 2])
1036                .with_case_sensitive(false)
1037                .build()),
1038        ];
1039
1040        let tasks_stream = Box::pin(futures::stream::iter(tasks)) as FileScanTaskStream;
1041
1042        let result = reader
1043            .read(tasks_stream)
1044            .unwrap()
1045            .stream()
1046            .try_collect::<Vec<RecordBatch>>()
1047            .await
1048            .unwrap();
1049
1050        // Verify we got all 30 rows (10 from each file)
1051        let total_rows: usize = result.iter().map(|b| b.num_rows()).sum();
1052        assert_eq!(total_rows, 30, "Should have 30 total rows");
1053
1054        // Collect all ids and file_nums to verify data
1055        let mut all_ids = Vec::new();
1056        let mut all_file_nums = Vec::new();
1057
1058        for batch in &result {
1059            let id_col = batch
1060                .column(0)
1061                .as_primitive::<arrow_array::types::Int32Type>();
1062            let file_num_col = batch
1063                .column(1)
1064                .as_primitive::<arrow_array::types::Int32Type>();
1065
1066            for i in 0..batch.num_rows() {
1067                all_ids.push(id_col.value(i));
1068                all_file_nums.push(file_num_col.value(i));
1069            }
1070        }
1071
1072        assert_eq!(all_ids.len(), 30);
1073        assert_eq!(all_file_nums.len(), 30);
1074
1075        // With concurrency=1 and sequential processing, files should be processed in order
1076        // file_0: ids 0-9, file_num=0
1077        // file_1: ids 10-19, file_num=1
1078        // file_2: ids 20-29, file_num=2
1079        for i in 0..10 {
1080            assert_eq!(all_file_nums[i], 0, "First 10 rows should be from file_0");
1081            assert_eq!(all_ids[i], i as i32, "IDs should be 0-9");
1082        }
1083        for i in 10..20 {
1084            assert_eq!(all_file_nums[i], 1, "Next 10 rows should be from file_1");
1085            assert_eq!(all_ids[i], i as i32, "IDs should be 10-19");
1086        }
1087        for i in 20..30 {
1088            assert_eq!(all_file_nums[i], 2, "Last 10 rows should be from file_2");
1089            assert_eq!(all_ids[i], i as i32, "IDs should be 20-29");
1090        }
1091    }
1092
1093    #[tokio::test]
1094    async fn test_read_int96_timestamps_with_field_ids() {
1095        let schema = Arc::new(
1096            Schema::builder()
1097                .with_schema_id(1)
1098                .with_fields(vec![
1099                    NestedField::optional(1, "ts", Type::Primitive(PrimitiveType::Timestamp))
1100                        .into(),
1101                    NestedField::required(2, "id", Type::Primitive(PrimitiveType::Int)).into(),
1102                ])
1103                .build()
1104                .unwrap(),
1105        );
1106
1107        let tmp_dir = TempDir::new().unwrap();
1108        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1109        let (file_path, expected_micros) =
1110            write_int96_parquet_file(&table_location, "with_ids.parquet", true);
1111
1112        assert_int96_read_matches(&file_path, schema, vec![1, 2], &expected_micros).await;
1113    }
1114
1115    #[tokio::test]
1116    async fn test_read_int96_timestamps_without_field_ids() {
1117        let schema = Arc::new(
1118            Schema::builder()
1119                .with_schema_id(1)
1120                .with_fields(vec![
1121                    NestedField::optional(1, "ts", Type::Primitive(PrimitiveType::Timestamp))
1122                        .into(),
1123                    NestedField::required(2, "id", Type::Primitive(PrimitiveType::Int)).into(),
1124                ])
1125                .build()
1126                .unwrap(),
1127        );
1128
1129        let tmp_dir = TempDir::new().unwrap();
1130        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1131        let (file_path, expected_micros) =
1132            write_int96_parquet_file(&table_location, "no_ids.parquet", false);
1133
1134        assert_int96_read_matches(&file_path, schema, vec![1, 2], &expected_micros).await;
1135    }
1136
1137    #[tokio::test]
1138    async fn test_read_int96_timestamps_in_struct() {
1139        use arrow_array::{StructArray, TimestampMicrosecondArray};
1140        use parquet::basic::{Repetition, Type as PhysicalType};
1141        use parquet::data_type::Int96Type;
1142        use parquet::file::writer::SerializedFileWriter;
1143        use parquet::schema::types::Type as SchemaType;
1144
1145        let tmp_dir = TempDir::new().unwrap();
1146        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1147        let file_path = format!("{table_location}/struct_int96.parquet");
1148
1149        let ts_type = SchemaType::primitive_type_builder("ts", PhysicalType::INT96)
1150            .with_repetition(Repetition::OPTIONAL)
1151            .with_id(Some(2))
1152            .build()
1153            .unwrap();
1154
1155        let struct_type = SchemaType::group_type_builder("data")
1156            .with_repetition(Repetition::REQUIRED)
1157            .with_id(Some(1))
1158            .with_fields(vec![Arc::new(ts_type)])
1159            .build()
1160            .unwrap();
1161
1162        let parquet_schema = SchemaType::group_type_builder("schema")
1163            .with_fields(vec![Arc::new(struct_type)])
1164            .build()
1165            .unwrap();
1166
1167        let (int96_val, expected_micros) = make_int96_test_value();
1168
1169        let file = File::create(&file_path).unwrap();
1170        let mut writer =
1171            SerializedFileWriter::new(file, Arc::new(parquet_schema), Default::default()).unwrap();
1172
1173        // def=1: struct is REQUIRED so no level, ts is OPTIONAL and present (1).
1174        // No repetition levels needed (no repeated groups).
1175        let mut row_group = writer.next_row_group().unwrap();
1176        {
1177            let mut col = row_group.next_column().unwrap().unwrap();
1178            col.typed::<Int96Type>()
1179                .write_batch(&[int96_val], Some(&[1]), None)
1180                .unwrap();
1181            col.close().unwrap();
1182        }
1183        row_group.close().unwrap();
1184        writer.close().unwrap();
1185
1186        let iceberg_schema = Arc::new(
1187            Schema::builder()
1188                .with_schema_id(1)
1189                .with_fields(vec![
1190                    NestedField::required(
1191                        1,
1192                        "data",
1193                        Type::Struct(crate::spec::StructType::new(vec![
1194                            NestedField::optional(
1195                                2,
1196                                "ts",
1197                                Type::Primitive(PrimitiveType::Timestamp),
1198                            )
1199                            .into(),
1200                        ])),
1201                    )
1202                    .into(),
1203                ])
1204                .build()
1205                .unwrap(),
1206        );
1207
1208        let batches = read_int96_batches(&file_path, iceberg_schema, vec![1]).await;
1209
1210        assert_eq!(batches.len(), 1);
1211        let struct_array = batches[0]
1212            .column(0)
1213            .as_any()
1214            .downcast_ref::<StructArray>()
1215            .expect("Expected StructArray");
1216        let ts_array = struct_array
1217            .column(0)
1218            .as_any()
1219            .downcast_ref::<TimestampMicrosecondArray>()
1220            .expect("Expected TimestampMicrosecondArray inside struct");
1221
1222        assert_eq!(
1223            ts_array.value(0),
1224            expected_micros,
1225            "INT96 in struct: got {}, expected {expected_micros}",
1226            ts_array.value(0)
1227        );
1228    }
1229
1230    #[tokio::test]
1231    async fn test_read_int96_timestamps_in_list() {
1232        use arrow_array::{ListArray, TimestampMicrosecondArray};
1233        use parquet::basic::{Repetition, Type as PhysicalType};
1234        use parquet::data_type::Int96Type;
1235        use parquet::file::writer::SerializedFileWriter;
1236        use parquet::schema::types::Type as SchemaType;
1237
1238        let tmp_dir = TempDir::new().unwrap();
1239        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1240        let file_path = format!("{table_location}/list_int96.parquet");
1241
1242        // 3-level LIST encoding:
1243        //   optional group timestamps (LIST) {
1244        //     repeated group list {
1245        //       optional int96 element;
1246        //     }
1247        //   }
1248        let element_type = SchemaType::primitive_type_builder("element", PhysicalType::INT96)
1249            .with_repetition(Repetition::OPTIONAL)
1250            .with_id(Some(2))
1251            .build()
1252            .unwrap();
1253
1254        let list_group = SchemaType::group_type_builder("list")
1255            .with_repetition(Repetition::REPEATED)
1256            .with_fields(vec![Arc::new(element_type)])
1257            .build()
1258            .unwrap();
1259
1260        let list_type = SchemaType::group_type_builder("timestamps")
1261            .with_repetition(Repetition::OPTIONAL)
1262            .with_id(Some(1))
1263            .with_logical_type(Some(parquet::basic::LogicalType::List))
1264            .with_fields(vec![Arc::new(list_group)])
1265            .build()
1266            .unwrap();
1267
1268        let parquet_schema = SchemaType::group_type_builder("schema")
1269            .with_fields(vec![Arc::new(list_type)])
1270            .build()
1271            .unwrap();
1272
1273        let (int96_val, expected_micros) = make_int96_test_value();
1274
1275        let file = File::create(&file_path).unwrap();
1276        let mut writer =
1277            SerializedFileWriter::new(file, Arc::new(parquet_schema), Default::default()).unwrap();
1278
1279        // Write a single row with a list containing one INT96 element.
1280        // def=3: list present (1) + repeated group (2) + element present (3)
1281        // rep=0: start of a new list
1282        let mut row_group = writer.next_row_group().unwrap();
1283        {
1284            let mut col = row_group.next_column().unwrap().unwrap();
1285            col.typed::<Int96Type>()
1286                .write_batch(&[int96_val], Some(&[3]), Some(&[0]))
1287                .unwrap();
1288            col.close().unwrap();
1289        }
1290        row_group.close().unwrap();
1291        writer.close().unwrap();
1292
1293        let iceberg_schema = Arc::new(
1294            Schema::builder()
1295                .with_schema_id(1)
1296                .with_fields(vec![
1297                    NestedField::optional(
1298                        1,
1299                        "timestamps",
1300                        Type::List(crate::spec::ListType {
1301                            element_field: NestedField::optional(
1302                                2,
1303                                "element",
1304                                Type::Primitive(PrimitiveType::Timestamp),
1305                            )
1306                            .into(),
1307                        }),
1308                    )
1309                    .into(),
1310                ])
1311                .build()
1312                .unwrap(),
1313        );
1314
1315        let batches = read_int96_batches(&file_path, iceberg_schema, vec![1]).await;
1316
1317        assert_eq!(batches.len(), 1);
1318        let list_array = batches[0]
1319            .column(0)
1320            .as_any()
1321            .downcast_ref::<ListArray>()
1322            .expect("Expected ListArray");
1323        let ts_array = list_array
1324            .values()
1325            .as_any()
1326            .downcast_ref::<TimestampMicrosecondArray>()
1327            .expect("Expected TimestampMicrosecondArray inside list");
1328
1329        assert_eq!(
1330            ts_array.value(0),
1331            expected_micros,
1332            "INT96 in list: got {}, expected {expected_micros}",
1333            ts_array.value(0)
1334        );
1335    }
1336
1337    #[tokio::test]
1338    async fn test_read_int96_timestamps_in_map() {
1339        use arrow_array::{MapArray, TimestampMicrosecondArray};
1340        use parquet::basic::{Repetition, Type as PhysicalType};
1341        use parquet::data_type::{ByteArrayType, Int96Type};
1342        use parquet::file::writer::SerializedFileWriter;
1343        use parquet::schema::types::Type as SchemaType;
1344
1345        let tmp_dir = TempDir::new().unwrap();
1346        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1347        let file_path = format!("{table_location}/map_int96.parquet");
1348
1349        // MAP encoding:
1350        //   optional group ts_map (MAP) {
1351        //     repeated group key_value {
1352        //       required binary key (UTF8);
1353        //       optional int96 value;
1354        //     }
1355        //   }
1356        let key_type = SchemaType::primitive_type_builder("key", PhysicalType::BYTE_ARRAY)
1357            .with_repetition(Repetition::REQUIRED)
1358            .with_logical_type(Some(parquet::basic::LogicalType::String))
1359            .with_id(Some(2))
1360            .build()
1361            .unwrap();
1362
1363        let value_type = SchemaType::primitive_type_builder("value", PhysicalType::INT96)
1364            .with_repetition(Repetition::OPTIONAL)
1365            .with_id(Some(3))
1366            .build()
1367            .unwrap();
1368
1369        let key_value_group = SchemaType::group_type_builder("key_value")
1370            .with_repetition(Repetition::REPEATED)
1371            .with_fields(vec![Arc::new(key_type), Arc::new(value_type)])
1372            .build()
1373            .unwrap();
1374
1375        let map_type = SchemaType::group_type_builder("ts_map")
1376            .with_repetition(Repetition::OPTIONAL)
1377            .with_id(Some(1))
1378            .with_logical_type(Some(parquet::basic::LogicalType::Map))
1379            .with_fields(vec![Arc::new(key_value_group)])
1380            .build()
1381            .unwrap();
1382
1383        let parquet_schema = SchemaType::group_type_builder("schema")
1384            .with_fields(vec![Arc::new(map_type)])
1385            .build()
1386            .unwrap();
1387
1388        let (int96_val, expected_micros) = make_int96_test_value();
1389
1390        let file = File::create(&file_path).unwrap();
1391        let mut writer =
1392            SerializedFileWriter::new(file, Arc::new(parquet_schema), Default::default()).unwrap();
1393
1394        // Write a single row with a map containing one key-value pair.
1395        // rep=0 for both columns: start of a new map.
1396        // key def=2: map present (1) + key_value entry present (2), key is REQUIRED.
1397        // value def=3: map present (1) + key_value entry present (2) + value present (3).
1398        let mut row_group = writer.next_row_group().unwrap();
1399        {
1400            let mut col = row_group.next_column().unwrap().unwrap();
1401            col.typed::<ByteArrayType>()
1402                .write_batch(
1403                    &[parquet::data_type::ByteArray::from("event_time")],
1404                    Some(&[2]),
1405                    Some(&[0]),
1406                )
1407                .unwrap();
1408            col.close().unwrap();
1409        }
1410        {
1411            let mut col = row_group.next_column().unwrap().unwrap();
1412            col.typed::<Int96Type>()
1413                .write_batch(&[int96_val], Some(&[3]), Some(&[0]))
1414                .unwrap();
1415            col.close().unwrap();
1416        }
1417        row_group.close().unwrap();
1418        writer.close().unwrap();
1419
1420        let iceberg_schema = Arc::new(
1421            Schema::builder()
1422                .with_schema_id(1)
1423                .with_fields(vec![
1424                    NestedField::optional(
1425                        1,
1426                        "ts_map",
1427                        Type::Map(crate::spec::MapType {
1428                            key_field: NestedField::required(
1429                                2,
1430                                "key",
1431                                Type::Primitive(PrimitiveType::String),
1432                            )
1433                            .into(),
1434                            value_field: NestedField::optional(
1435                                3,
1436                                "value",
1437                                Type::Primitive(PrimitiveType::Timestamp),
1438                            )
1439                            .into(),
1440                        }),
1441                    )
1442                    .into(),
1443                ])
1444                .build()
1445                .unwrap(),
1446        );
1447
1448        let batches = read_int96_batches(&file_path, iceberg_schema, vec![1]).await;
1449
1450        assert_eq!(batches.len(), 1);
1451        let map_array = batches[0]
1452            .column(0)
1453            .as_any()
1454            .downcast_ref::<MapArray>()
1455            .expect("Expected MapArray");
1456        let ts_array = map_array
1457            .values()
1458            .as_any()
1459            .downcast_ref::<TimestampMicrosecondArray>()
1460            .expect("Expected TimestampMicrosecondArray as map values");
1461
1462        assert_eq!(
1463            ts_array.value(0),
1464            expected_micros,
1465            "INT96 in map: got {}, expected {expected_micros}",
1466            ts_array.value(0)
1467        );
1468    }
1469}