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::{
31    PARQUET_FIELD_ID_META_KEY, ParquetRecordBatchStreamBuilder, ProjectionMask, RowNumber,
32};
33use parquet::encryption::decrypt::FileDecryptionProperties;
34
35use super::{
36    ArrowFileReader, ArrowReader, ParquetReadOptions, add_fallback_field_ids_to_arrow_schema,
37    apply_name_mapping_to_arrow_schema, build_field_id_map,
38};
39use crate::arrow::build_partition_constant;
40use crate::arrow::caching_delete_file_loader::CachingDeleteFileLoader;
41use crate::arrow::int96::coerce_int96_timestamps;
42use crate::arrow::record_batch_transformer::RecordBatchTransformerBuilder;
43use crate::arrow::scan_metrics::{CountingFileRead, ScanMetrics, ScanResult};
44use crate::encryption::StandardKeyMetadata;
45use crate::error::Result;
46use crate::io::{FileIO, FileMetadata, FileRead};
47use crate::metadata_columns::{
48    RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER, RESERVED_COL_NAME_POS, RESERVED_FIELD_ID_FILE,
49    RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER, RESERVED_FIELD_ID_PARTITION,
50    RESERVED_FIELD_ID_POS, RESERVED_FIELD_ID_SPEC_ID, is_metadata_field,
51};
52use crate::scan::{ArrowRecordBatchStream, FileScanTask, FileScanTaskStream};
53use crate::spec::{Datum, PartitionSpec, Struct};
54use crate::{Error, ErrorKind};
55
56impl ArrowReader {
57    /// Take a stream of FileScanTasks and reads all the files.
58    /// Returns a [`ScanResult`] containing the record batch stream and scan metrics.
59    pub fn read(self, tasks: FileScanTaskStream) -> Result<ScanResult> {
60        let concurrency_limit_data_files = self.concurrency_limit_data_files;
61        let scan_metrics = ScanMetrics::new();
62
63        let task_reader = FileScanTaskReader {
64            batch_size: self.batch_size,
65            file_io: self.file_io,
66            delete_file_loader: self
67                .delete_file_loader
68                .with_scan_metrics(scan_metrics.clone()),
69            row_group_filtering_enabled: self.row_group_filtering_enabled,
70            row_selection_enabled: self.row_selection_enabled,
71            parquet_read_options: self.parquet_read_options,
72            scan_metrics: scan_metrics.clone(),
73        };
74
75        // Fast-path for single concurrency to avoid overhead of try_flatten_unordered
76        let stream: ArrowRecordBatchStream = if concurrency_limit_data_files == 1 {
77            Box::pin(
78                tasks
79                    .and_then(move |task| task_reader.clone().process(task))
80                    .map_err(|err| {
81                        Error::new(ErrorKind::Unexpected, "file scan task generate failed")
82                            .with_source(err)
83                    })
84                    .try_flatten(),
85            )
86        } else {
87            Box::pin(
88                tasks
89                    .map_ok(move |task| task_reader.clone().process(task))
90                    .map_err(|err| {
91                        Error::new(ErrorKind::Unexpected, "file scan task generate failed")
92                            .with_source(err)
93                    })
94                    .try_buffer_unordered(concurrency_limit_data_files)
95                    .try_flatten_unordered(concurrency_limit_data_files),
96            )
97        };
98
99        Ok(ScanResult::new(stream, scan_metrics))
100    }
101}
102
103/// Per-scan state for processing [`FileScanTask`]s. Created once per
104/// [`ArrowReader::read`] call and cloned per task.
105#[derive(Clone)]
106struct FileScanTaskReader {
107    batch_size: Option<usize>,
108    file_io: FileIO,
109    delete_file_loader: CachingDeleteFileLoader,
110    row_group_filtering_enabled: bool,
111    row_selection_enabled: bool,
112    parquet_read_options: ParquetReadOptions,
113    scan_metrics: ScanMetrics,
114}
115
116impl FileScanTaskReader {
117    async fn process(self, task: FileScanTask) -> Result<ArrowRecordBatchStream> {
118        let should_load_page_index =
119            (self.row_selection_enabled && task.predicate.is_some()) || !task.deletes.is_empty();
120        let mut parquet_read_options = self.parquet_read_options;
121        parquet_read_options.preload_page_index = should_load_page_index;
122
123        let delete_filter_rx = self
124            .delete_file_loader
125            .load_deletes(&task.deletes, Arc::clone(&task.schema));
126
127        // Open the Parquet file once, loading its metadata
128        let (parquet_file_reader, arrow_metadata) = ArrowReader::open_parquet_file(
129            &task.data_file_path,
130            &self.file_io,
131            task.file_size_in_bytes,
132            parquet_read_options,
133            self.scan_metrics.bytes_read_counter(),
134            task.key_metadata.as_deref(),
135        )
136        .await?;
137
138        // Check if Parquet file has embedded field IDs
139        // Corresponds to Java's ParquetSchemaUtil.hasIds()
140        // Reference: parquet/src/main/java/org/apache/iceberg/parquet/ParquetSchemaUtil.java:118
141        let missing_field_ids = arrow_metadata
142            .schema()
143            .fields()
144            .iter()
145            .next()
146            .is_some_and(|f| f.metadata().get(PARQUET_FIELD_ID_META_KEY).is_none());
147
148        // Position-based fallback applies only when the file has no embedded field IDs
149        // AND no name mapping is available. With a name mapping, field IDs are assigned
150        // to the Arrow schema below, and projection/predicate planning must use them
151        // (see #2403).
152        let use_position_fallback = missing_field_ids && task.name_mapping.is_none();
153
154        // Three-branch schema resolution strategy matching Java's ReadConf constructor
155        //
156        // Per Iceberg spec Column Projection rules:
157        // "Columns in Iceberg data files are selected by field id. The table schema's column
158        //  names and order may change after a data file is written, and projection must be done
159        //  using field ids."
160        // https://iceberg.apache.org/spec/#column-projection
161        //
162        // When Parquet files lack field IDs (e.g., Hive/Spark migrations via add_files),
163        // we must assign field IDs BEFORE reading data to enable correct projection.
164        //
165        // Java's ReadConf determines field ID strategy:
166        // - Branch 1: hasIds(fileSchema) → trust embedded field IDs, use pruneColumns()
167        // - Branch 2: nameMapping present → applyNameMapping(), then pruneColumns()
168        // - Branch 3: fallback → addFallbackIds(), then pruneColumnsFallback()
169        let arrow_metadata = if missing_field_ids {
170            // Parquet file lacks field IDs - must assign them before reading
171            let arrow_schema = if let Some(name_mapping) = &task.name_mapping {
172                // Branch 2: Apply name mapping to assign correct Iceberg field IDs
173                // Per spec rule #2: "Use schema.name-mapping.default metadata to map field id
174                // to columns without field id"
175                // Corresponds to Java's ParquetSchemaUtil.applyNameMapping()
176                apply_name_mapping_to_arrow_schema(
177                    Arc::clone(arrow_metadata.schema()),
178                    name_mapping,
179                )?
180            } else {
181                // Branch 3: No name mapping - use position-based fallback IDs
182                // Corresponds to Java's ParquetSchemaUtil.addFallbackIds()
183                add_fallback_field_ids_to_arrow_schema(arrow_metadata.schema())
184            };
185
186            let options = ArrowReaderOptions::new().with_schema(arrow_schema);
187            ArrowReaderMetadata::try_new(Arc::clone(arrow_metadata.metadata()), options).map_err(
188                |e| {
189                    Error::new(
190                        ErrorKind::Unexpected,
191                        "Failed to create ArrowReaderMetadata with field ID schema",
192                    )
193                    .with_source(e)
194                },
195            )?
196        } else {
197            // Branch 1: File has embedded field IDs - trust them
198            arrow_metadata
199        };
200
201        // Coerce INT96 timestamp columns to the resolution specified by the Iceberg schema.
202        // This must happen before building the stream reader to avoid i64 overflow in arrow-rs.
203        let arrow_metadata = if let Some(coerced_schema) =
204            coerce_int96_timestamps(arrow_metadata.schema(), &task.schema)
205        {
206            let options = ArrowReaderOptions::new().with_schema(Arc::clone(&coerced_schema));
207            ArrowReaderMetadata::try_new(Arc::clone(arrow_metadata.metadata()), options).map_err(
208                |e| {
209                    Error::new(
210                        ErrorKind::Unexpected,
211                        format!(
212                            "Failed to create ArrowReaderMetadata with INT96-coerced schema: {coerced_schema}"
213                        ),
214                    )
215                    .with_source(e)
216                },
217            )?
218        } else {
219            arrow_metadata
220        };
221
222        let project_pos = task.project_field_ids().contains(&RESERVED_FIELD_ID_POS);
223
224        let arrow_metadata = if project_pos {
225            let row_number_field = Arc::new(
226                Field::new(RESERVED_COL_NAME_POS, DataType::Int64, false)
227                    .with_metadata(HashMap::from([(
228                        PARQUET_FIELD_ID_META_KEY.to_string(),
229                        RESERVED_FIELD_ID_POS.to_string(),
230                    )]))
231                    .with_extension_type(RowNumber),
232            );
233
234            let options = ArrowReaderOptions::new()
235                .with_schema(Arc::clone(arrow_metadata.schema()))
236                .with_virtual_columns(vec![row_number_field])?;
237
238            ArrowReaderMetadata::try_new(Arc::clone(arrow_metadata.metadata()), options).map_err(
239                |e| {
240                    Error::new(
241                        ErrorKind::Unexpected,
242                        "Failed to create ArrowReaderMetadata with the 'row_number' virtual_column",
243                    )
244                    .with_source(e)
245                },
246            )?
247        } else {
248            arrow_metadata
249        };
250
251        // Build the stream reader, reusing the already-opened file reader
252        let mut record_batch_stream_builder =
253            ParquetRecordBatchStreamBuilder::new_with_metadata(parquet_file_reader, arrow_metadata);
254
255        // Whether the file physically carries the `_last_updated_sequence_number` column
256        // (some engines, e.g. Iceberg Java on rewrite, write it per-row), resolved by its
257        // embedded field id against the Parquet schema.
258        let project_last_updated_seq = task
259            .project_field_ids()
260            .contains(&RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER);
261
262        // Parquet leaf index of the physical column, if present by embedded field id.
263        // `build_field_id_map` is all-or-nothing: a file mixing id-bearing and id-less
264        // columns yields `None` and is rejected below rather than coalesced, even if the
265        // physical column itself carries its reserved id.
266        let phys_last_updated_seq_leaf = if project_last_updated_seq {
267            build_field_id_map(record_batch_stream_builder.parquet_schema())?.and_then(|m| {
268                m.get(&RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER)
269                    .copied()
270            })
271        } else {
272            None
273        };
274
275        // Present by name but not by the embedded id (only meaningful when no by-id column
276        // was found). An unthreadable shape we reject rather than coalesce incorrectly.
277        let last_updated_seq_present_by_name_only = project_last_updated_seq
278            && phys_last_updated_seq_leaf.is_none()
279            && record_batch_stream_builder
280                .schema()
281                .fields()
282                .iter()
283                .any(|f| f.name() == RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER);
284
285        // Read the physical column only when first_row_id is set (with a data sequence
286        // number to fall back to). A null first_row_id drops the leaf and nulls the whole
287        // column below, discarding any per-row values the file carries -- matching Java
288        // (`ValueReaders.lastUpdated` nulls when the base row id is null).
289        let coalesce_last_updated_seq_leaf = phys_last_updated_seq_leaf
290            .filter(|_| task.first_row_id.is_some() && task.data_sequence_number.is_some());
291
292        // Filter out metadata fields for Parquet projection (they don't exist in files)
293        let project_field_ids_without_metadata: Vec<i32> = task
294            .project_field_ids
295            .iter()
296            .filter(|&&id| !is_metadata_field(id))
297            .copied()
298            .collect();
299
300        // Create projection mask based on field IDs
301        // - If file has embedded IDs: field-ID-based projection
302        // - If name mapping applied: field-ID-based projection using the IDs the name
303        //   mapping assigned to the Arrow schema
304        // - Otherwise: position-based fallback projection
305        let mut projection_mask = ArrowReader::get_arrow_projection_mask(
306            &project_field_ids_without_metadata,
307            &task.schema,
308            record_batch_stream_builder.parquet_schema(),
309            record_batch_stream_builder.schema(),
310            use_position_fallback, // Whether to use position-based (true) or field-ID-based (false) projection
311        )?;
312
313        // A metadata-only projection leaves `project_field_ids_without_metadata` empty,
314        // which `get_arrow_projection_mask` maps to "read all columns" (so `COUNT(*)` still
315        // gets a row count). Downgrade that to "read no data columns" when a row-count
316        // source exists independently of the data columns: the RowNumber virtual column
317        // (installed above under `project_pos`) or a physical metadata leaf unioned in
318        // below. Pure-constant / `COUNT(*)` projections have neither and must keep reading
319        // all columns to preserve the row count. Any future physical metadata leaf (e.g. a
320        // `_row_id` read path) is likewise a row source.
321        //
322        // This runs BEFORE the union so the physical leaf is added onto a `none` base,
323        // pruning the read to just that leaf (`union` with an `all` base stays `all`).
324        if project_field_ids_without_metadata.is_empty()
325            && (project_pos || coalesce_last_updated_seq_leaf.is_some())
326        {
327            projection_mask =
328                ProjectionMask::none(record_batch_stream_builder.parquet_schema().num_columns());
329        }
330
331        // Union in the physical `_last_updated_sequence_number` column when we will
332        // coalesce it. The metadata field id is not in the task schema, so it can't be
333        // requested through `get_arrow_projection_mask` (which resolves ids against the
334        // task schema); add its Parquet leaf directly.
335        if let Some(leaf) = coalesce_last_updated_seq_leaf {
336            let phys_mask =
337                ProjectionMask::leaves(record_batch_stream_builder.parquet_schema(), vec![leaf]);
338            projection_mask.union(&phys_mask);
339        }
340
341        record_batch_stream_builder =
342            record_batch_stream_builder.with_projection(projection_mask.clone());
343
344        // RecordBatchTransformer performs any transformations required on the RecordBatches
345        // that come back from the file, such as type promotion, default column insertion,
346        // column re-ordering, partition constants, and virtual field addition (like _file)
347        let mut record_batch_transformer_builder =
348            RecordBatchTransformerBuilder::new(task.schema_ref(), task.project_field_ids());
349
350        // Add the _file metadata column if it's in the projected fields
351        if task.project_field_ids().contains(&RESERVED_FIELD_ID_FILE) {
352            let file_datum = Datum::string(task.data_file_path.clone());
353            record_batch_transformer_builder =
354                record_batch_transformer_builder.with_constant(RESERVED_FIELD_ID_FILE, file_datum);
355        }
356
357        if task
358            .project_field_ids()
359            .contains(&RESERVED_FIELD_ID_SPEC_ID)
360        {
361            let partition_spec = task
362                .partition_spec
363                .as_ref()
364                .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Partition spec is missing"))?;
365
366            let spec_id_datum = Datum::int(partition_spec.spec_id());
367            record_batch_transformer_builder = record_batch_transformer_builder
368                .with_constant(RESERVED_FIELD_ID_SPEC_ID, spec_id_datum);
369        }
370
371        if project_last_updated_seq {
372            // Materialize the column, gated on the data file's `first_row_id`. Java gates
373            // it this way (`ValueReaders.lastUpdated` returns nulls when the base row id is
374            // null); the spec itself only says the column is assigned the manifest entry's
375            // sequence number on read.
376            record_batch_transformer_builder = match (task.first_row_id, task.data_sequence_number)
377            {
378                (Some(_), Some(seq)) => {
379                    let datum = Datum::long(seq);
380                    if coalesce_last_updated_seq_leaf.is_some() {
381                        // The file physically carries the column: read the per-row value,
382                        // falling back to the data sequence number only where null.
383                        record_batch_transformer_builder.with_coalesced_last_updated_seq_column(
384                            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
385                            datum,
386                        )
387                    } else if last_updated_seq_present_by_name_only {
388                        // Present by name but without the embedded field id (name mapping /
389                        // positional fallback). The transformer keys the source column by
390                        // field id, so we can't thread it; no real writer produces this, so
391                        // reject loudly rather than silently overwrite with the constant.
392                        // Arm-local by design: only this arm reads the physical column, so
393                        // only here can a name-only column defeat us. The `(None, _)` arm
394                        // nulls the column without reading it, so it needs no such guard.
395                        return Err(Error::new(
396                            ErrorKind::FeatureUnsupported,
397                            "Reading a physically-stored _last_updated_sequence_number column \
398                             without an embedded field id is not supported",
399                        ));
400                    } else {
401                        // Column absent: derive it from the data sequence number.
402                        record_batch_transformer_builder
403                            .with_constant(RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER, datum)
404                    }
405                }
406                // Null first_row_id (v1/v2, or a pre-upgrade v3 snapshot): the column is null.
407                (None, _) => record_batch_transformer_builder
408                    .with_null_metadata_column(RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER)?,
409                // first_row_id present but no data sequence number: after manifest
410                // inheritance a committed entry always has one, so this is a malformed
411                // manifest rather than a legitimate null.
412                (Some(_), None) => {
413                    return Err(Error::new(
414                        ErrorKind::DataInvalid,
415                        format!(
416                            "Data file {} has a first_row_id but no data sequence number",
417                            task.data_file_path
418                        ),
419                    ));
420                }
421            };
422        }
423
424        if let (Some(partition_spec), Some(partition_data)) =
425            (task.partition_spec.clone(), task.partition.clone())
426        {
427            record_batch_transformer_builder =
428                record_batch_transformer_builder.with_partition(partition_spec, partition_data)?;
429        }
430
431        if project_pos {
432            record_batch_transformer_builder =
433                record_batch_transformer_builder.with_virtual_field(RESERVED_FIELD_ID_POS);
434        }
435
436        // Add the _partition metadata struct column if it's in the projected fields.
437        // Computed lazily here at read time from the unified partition type + task's spec + data.
438        if task
439            .project_field_ids()
440            .contains(&RESERVED_FIELD_ID_PARTITION)
441            && let Some(unified_type) = &task.unified_partition_type
442        {
443            let (spec, partition_data) = match (&task.partition_spec, &task.partition) {
444                (Some(spec), Some(data)) => (spec.clone(), data.clone()),
445                // A missing spec/data is only acceptable when there are no partition
446                // fields to fill (unpartitioned table). If the unified type has fields
447                // but we lack a spec or data, the task is inconsistent and we cannot
448                // build the _partition column.
449                _ if unified_type.fields().is_empty() => {
450                    (Arc::new(PartitionSpec::unpartition_spec()), Struct::empty())
451                }
452                _ => {
453                    return Err(Error::new(
454                        ErrorKind::Unexpected,
455                        "cannot build _partition column: unified partition type has fields \
456                         but the scan task is missing its partition spec or data",
457                    ));
458                }
459            };
460            let constant = build_partition_constant(unified_type, &spec, &partition_data)?;
461            record_batch_transformer_builder =
462                record_batch_transformer_builder.with_partition_constant(constant);
463        }
464
465        let mut record_batch_transformer = record_batch_transformer_builder.build();
466
467        if let Some(batch_size) = self.batch_size {
468            record_batch_stream_builder = record_batch_stream_builder.with_batch_size(batch_size);
469        }
470
471        let delete_filter = delete_filter_rx.await.unwrap()?;
472        let delete_predicate = delete_filter.build_equality_delete_predicate(&task).await?;
473
474        // In addition to the optional predicate supplied in the `FileScanTask`,
475        // we also have an optional predicate resulting from equality delete files.
476        // If both are present, we logical-AND them together to form a single filter
477        // predicate that we can pass to the `RecordBatchStreamBuilder`.
478        let final_predicate = match (&task.predicate, delete_predicate) {
479            (None, None) => None,
480            (Some(predicate), None) => Some(predicate.clone()),
481            (None, Some(ref predicate)) => Some(predicate.clone()),
482            (Some(filter_predicate), Some(delete_predicate)) => {
483                Some(filter_predicate.clone().and(delete_predicate))
484            }
485        };
486
487        // There are three possible sources for potential lists of selected RowGroup indices,
488        // and two for `RowSelection`s.
489        // Selected RowGroup index lists can come from three sources:
490        //   * When task.start and task.length specify a byte range (file splitting);
491        //   * When there are equality delete files that are applicable;
492        //   * When there is a scan predicate and row_group_filtering_enabled = true.
493        // `RowSelection`s can be created in either or both of the following cases:
494        //   * When there are positional delete files that are applicable;
495        //   * When there is a scan predicate and row_selection_enabled = true
496        // Note that row group filtering from predicates only happens when
497        // there is a scan predicate AND row_group_filtering_enabled = true,
498        // but we perform row selection filtering if there are applicable
499        // equality delete files OR (there is a scan predicate AND row_selection_enabled),
500        // since the only implemented method of applying positional deletes is
501        // by using a `RowSelection`.
502        let mut selected_row_group_indices = None;
503        let mut row_selection = None;
504
505        // Filter row groups based on byte range from task.start and task.length.
506        // If both start and length are 0, read the entire file (backwards compatibility).
507        if task.start != 0 || task.length != 0 {
508            let byte_range_filtered_row_groups = ArrowReader::filter_row_groups_by_byte_range(
509                record_batch_stream_builder.metadata(),
510                task.start,
511                task.length,
512            )?;
513            selected_row_group_indices = Some(byte_range_filtered_row_groups);
514        }
515
516        if let Some(predicate) = final_predicate {
517            let (iceberg_field_ids, field_id_map) = ArrowReader::build_field_id_set_and_map(
518                record_batch_stream_builder.parquet_schema(),
519                record_batch_stream_builder.schema(),
520                &predicate,
521                use_position_fallback,
522            )?;
523
524            let row_filter = ArrowReader::get_row_filter(
525                &predicate,
526                record_batch_stream_builder.parquet_schema(),
527                &iceberg_field_ids,
528                &field_id_map,
529            )?;
530            record_batch_stream_builder = record_batch_stream_builder.with_row_filter(row_filter);
531
532            if self.row_group_filtering_enabled {
533                let predicate_filtered_row_groups = ArrowReader::get_selected_row_group_indices(
534                    &predicate,
535                    record_batch_stream_builder.metadata(),
536                    &field_id_map,
537                    &task.schema,
538                )?;
539
540                // Merge predicate-based filtering with byte range filtering (if present)
541                // by taking the intersection of both filters
542                selected_row_group_indices = match selected_row_group_indices {
543                    Some(byte_range_filtered) => {
544                        // Keep only row groups that are in both filters
545                        let intersection: Vec<usize> = byte_range_filtered
546                            .into_iter()
547                            .filter(|idx| predicate_filtered_row_groups.contains(idx))
548                            .collect();
549                        Some(intersection)
550                    }
551                    None => Some(predicate_filtered_row_groups),
552                };
553            }
554
555            if self.row_selection_enabled {
556                row_selection = ArrowReader::get_row_selection_for_filter_predicate(
557                    &predicate,
558                    record_batch_stream_builder.metadata(),
559                    &selected_row_group_indices,
560                    &field_id_map,
561                    &task.schema,
562                )?;
563            }
564        }
565
566        let positional_delete_indexes = delete_filter.get_delete_vector(&task);
567
568        if let Some(positional_delete_indexes) = positional_delete_indexes {
569            let delete_row_selection = {
570                let positional_delete_indexes = positional_delete_indexes.lock().unwrap();
571
572                ArrowReader::build_deletes_row_selection(
573                    record_batch_stream_builder.metadata().row_groups(),
574                    &selected_row_group_indices,
575                    &positional_delete_indexes,
576                )
577            }?;
578
579            // merge the row selection from the delete files with the row selection
580            // from the filter predicate, if there is one from the filter predicate
581            row_selection = match row_selection {
582                None => Some(delete_row_selection),
583                Some(filter_row_selection) => {
584                    Some(filter_row_selection.intersection(&delete_row_selection))
585                }
586            };
587        }
588
589        if let Some(row_selection) = row_selection {
590            record_batch_stream_builder =
591                record_batch_stream_builder.with_row_selection(row_selection);
592        }
593
594        if let Some(selected_row_group_indices) = selected_row_group_indices {
595            record_batch_stream_builder =
596                record_batch_stream_builder.with_row_groups(selected_row_group_indices);
597        }
598
599        // Build the batch stream and send all the RecordBatches that it generates
600        // to the requester.
601        let record_batch_stream =
602            record_batch_stream_builder
603                .build()?
604                .map(move |batch| match batch {
605                    Ok(batch) => {
606                        // Process the record batch (type promotion, column reordering, virtual fields, etc.)
607                        record_batch_transformer.process_record_batch(batch)
608                    }
609                    Err(err) => Err(err.into()),
610                });
611
612        Ok(Box::pin(record_batch_stream) as ArrowRecordBatchStream)
613    }
614}
615
616impl ArrowReader {
617    /// Opens a Parquet file and loads its metadata, wrapping the reader with
618    /// [`CountingFileRead`] so all I/O is accumulated into `bytes_read`.
619    pub(crate) async fn open_parquet_file(
620        data_file_path: &str,
621        file_io: &FileIO,
622        file_size_in_bytes: u64,
623        parquet_read_options: ParquetReadOptions,
624        bytes_read: &Arc<AtomicU64>,
625        key_metadata: Option<&[u8]>,
626    ) -> Result<(ArrowFileReader, ArrowReaderMetadata)> {
627        let parquet_file = file_io.new_input(data_file_path)?;
628        let counting_reader =
629            CountingFileRead::new(parquet_file.reader().await?, Arc::clone(bytes_read));
630        Self::build_parquet_reader(
631            Box::new(counting_reader),
632            file_size_in_bytes,
633            parquet_read_options,
634            key_metadata,
635        )
636        .await
637    }
638
639    async fn build_parquet_reader(
640        parquet_reader: Box<dyn FileRead>,
641        file_size_in_bytes: u64,
642        parquet_read_options: ParquetReadOptions,
643        key_metadata: Option<&[u8]>,
644    ) -> Result<(ArrowFileReader, ArrowReaderMetadata)> {
645        let mut reader = ArrowFileReader::new(
646            FileMetadata {
647                size: file_size_in_bytes,
648            },
649            parquet_reader,
650        )
651        .with_parquet_read_options(parquet_read_options);
652
653        let arrow_reader_options = Self::build_arrow_reader_options(key_metadata)?;
654
655        let arrow_metadata = ArrowReaderMetadata::load_async(&mut reader, arrow_reader_options)
656            .await
657            .map_err(|e| {
658                Error::new(ErrorKind::Unexpected, "Failed to load Parquet metadata").with_source(e)
659            })?;
660
661        Ok((reader, arrow_metadata))
662    }
663
664    /// Builds `ArrowReaderOptions`, adding `FileDecryptionProperties` when
665    /// key metadata is present for Parquet Modular Encryption.
666    fn build_arrow_reader_options(key_metadata: Option<&[u8]>) -> Result<ArrowReaderOptions> {
667        match key_metadata {
668            Some(km) => {
669                let standard_key_metadata = StandardKeyMetadata::decode(km)?;
670                let mut builder = FileDecryptionProperties::builder(
671                    standard_key_metadata.encryption_key().as_bytes().to_vec(),
672                );
673                if let Some(aad) = standard_key_metadata.aad_prefix() {
674                    builder = builder.with_aad_prefix(aad.to_vec());
675                }
676                let decryption_properties = builder.build().map_err(|e| {
677                    Error::new(
678                        ErrorKind::Unexpected,
679                        "Failed to build Parquet file decryption properties",
680                    )
681                    .with_source(e)
682                })?;
683                Ok(
684                    ArrowReaderOptions::new()
685                        .with_file_decryption_properties(decryption_properties),
686                )
687            }
688            None => Ok(ArrowReaderOptions::default()),
689        }
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use std::collections::HashMap;
696    use std::fs::File;
697    use std::sync::Arc;
698
699    use arrow_array::cast::AsArray;
700    use arrow_array::{Array, ArrayRef, Int32Array, Int64Array, RecordBatch, StringArray};
701    use arrow_cast::cast;
702    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
703    use futures::TryStreamExt;
704    use parquet::arrow::{ArrowWriter, PARQUET_FIELD_ID_META_KEY};
705    use parquet::basic::Compression;
706    use parquet::file::properties::WriterProperties;
707    use tempfile::TempDir;
708
709    use crate::Runtime;
710    use crate::arrow::ArrowReaderBuilder;
711    use crate::arrow::test_utils::write_encrypted_parquet;
712    use crate::io::FileIO;
713    use crate::metadata_columns::{
714        RESERVED_COL_NAME_POS, RESERVED_FIELD_ID_FILE, RESERVED_FIELD_ID_POS,
715    };
716    use crate::scan::{FileScanTask, FileScanTaskStream};
717    use crate::spec::{DataFileFormat, NestedField, PrimitiveType, Schema, SchemaRef, Type};
718
719    // INT96 encoding: [nanos_low_u32, nanos_high_u32, julian_day_u32]
720    // Julian day 2_440_588 = Unix epoch (1970-01-01)
721    const UNIX_EPOCH_JULIAN: i64 = 2_440_588;
722    const MICROS_PER_DAY: i64 = 86_400_000_000;
723    // Noon on 3333-01-01 (Julian day 2_953_529) — outside the i64 nanosecond range (~1677-2262).
724    const INT96_TEST_NANOS_WITHIN_DAY: u64 = 43_200_000_000_000;
725    const INT96_TEST_JULIAN_DAY: u32 = 2_953_529;
726
727    fn make_int96_test_value() -> (parquet::data_type::Int96, i64) {
728        let mut val = parquet::data_type::Int96::new();
729        val.set_data(
730            (INT96_TEST_NANOS_WITHIN_DAY & 0xFFFFFFFF) as u32,
731            (INT96_TEST_NANOS_WITHIN_DAY >> 32) as u32,
732            INT96_TEST_JULIAN_DAY,
733        );
734        let expected_micros = (INT96_TEST_JULIAN_DAY as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY
735            + (INT96_TEST_NANOS_WITHIN_DAY / 1_000) as i64;
736        (val, expected_micros)
737    }
738
739    async fn read_int96_batches(
740        file_path: &str,
741        schema: SchemaRef,
742        project_field_ids: Vec<i32>,
743    ) -> Vec<RecordBatch> {
744        let file_io = FileIO::new_with_fs();
745        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
746
747        let file_size = std::fs::metadata(file_path).unwrap().len();
748        let task = FileScanTask::builder()
749            .with_file_size_in_bytes(file_size)
750            .with_start(0)
751            .with_length(file_size)
752            .with_data_file_path(file_path.to_string())
753            .with_data_file_format(DataFileFormat::Parquet)
754            .with_schema(schema)
755            .with_project_field_ids(project_field_ids)
756            .with_case_sensitive(false)
757            .build();
758
759        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
760        reader
761            .read(tasks)
762            .unwrap()
763            .stream()
764            .try_collect()
765            .await
766            .unwrap()
767    }
768
769    // ArrowWriter cannot write INT96, so we use SerializedFileWriter directly.
770    fn write_int96_parquet_file(
771        table_location: &str,
772        filename: &str,
773        with_field_ids: bool,
774    ) -> (String, Vec<i64>) {
775        use parquet::basic::{Repetition, Type as PhysicalType};
776        use parquet::data_type::{Int32Type, Int96, Int96Type};
777        use parquet::file::writer::SerializedFileWriter;
778        use parquet::schema::types::Type as SchemaType;
779
780        let file_path = format!("{table_location}/{filename}");
781
782        let mut ts_builder = SchemaType::primitive_type_builder("ts", PhysicalType::INT96)
783            .with_repetition(Repetition::OPTIONAL);
784        let mut id_builder = SchemaType::primitive_type_builder("id", PhysicalType::INT32)
785            .with_repetition(Repetition::REQUIRED);
786
787        if with_field_ids {
788            ts_builder = ts_builder.with_id(Some(1));
789            id_builder = id_builder.with_id(Some(2));
790        }
791
792        let schema = SchemaType::group_type_builder("schema")
793            .with_fields(vec![
794                Arc::new(ts_builder.build().unwrap()),
795                Arc::new(id_builder.build().unwrap()),
796            ])
797            .build()
798            .unwrap();
799
800        // Dates outside the i64 nanosecond range (~1677-2262) overflow without coercion.
801        const NOON_NANOS: u64 = INT96_TEST_NANOS_WITHIN_DAY;
802        const JULIAN_3333: u32 = INT96_TEST_JULIAN_DAY;
803        const JULIAN_2100: u32 = 2_488_070;
804
805        let test_data: Vec<(u32, u32, u32, i64)> = vec![
806            // 3333-01-01 00:00:00
807            (
808                0,
809                0,
810                JULIAN_3333,
811                (JULIAN_3333 as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY,
812            ),
813            // 3333-01-01 12:00:00
814            (
815                (NOON_NANOS & 0xFFFFFFFF) as u32,
816                (NOON_NANOS >> 32) as u32,
817                JULIAN_3333,
818                (JULIAN_3333 as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY
819                    + (NOON_NANOS / 1_000) as i64,
820            ),
821            // 2100-01-01 00:00:00
822            (
823                0,
824                0,
825                JULIAN_2100,
826                (JULIAN_2100 as i64 - UNIX_EPOCH_JULIAN) * MICROS_PER_DAY,
827            ),
828        ];
829
830        let int96_values: Vec<Int96> = test_data
831            .iter()
832            .map(|(lo, hi, day, _)| {
833                let mut v = Int96::new();
834                v.set_data(*lo, *hi, *day);
835                v
836            })
837            .collect();
838
839        let id_values: Vec<i32> = (0..test_data.len() as i32).collect();
840        let expected_micros: Vec<i64> = test_data.iter().map(|(_, _, _, m)| *m).collect();
841
842        let file = File::create(&file_path).unwrap();
843        let mut writer =
844            SerializedFileWriter::new(file, Arc::new(schema), Default::default()).unwrap();
845
846        let mut row_group = writer.next_row_group().unwrap();
847        {
848            // def=1: ts is OPTIONAL and present. No repetition levels (top-level columns).
849            let mut col = row_group.next_column().unwrap().unwrap();
850            col.typed::<Int96Type>()
851                .write_batch(&int96_values, Some(&vec![1; test_data.len()]), None)
852                .unwrap();
853            col.close().unwrap();
854        }
855        {
856            let mut col = row_group.next_column().unwrap().unwrap();
857            col.typed::<Int32Type>()
858                .write_batch(&id_values, None, None)
859                .unwrap();
860            col.close().unwrap();
861        }
862        row_group.close().unwrap();
863        writer.close().unwrap();
864
865        (file_path, expected_micros)
866    }
867
868    async fn assert_int96_read_matches(
869        file_path: &str,
870        schema: SchemaRef,
871        project_field_ids: Vec<i32>,
872        expected_micros: &[i64],
873    ) {
874        use arrow_array::TimestampMicrosecondArray;
875
876        let batches = read_int96_batches(file_path, schema, project_field_ids).await;
877
878        assert_eq!(batches.len(), 1);
879        let ts_array = batches[0]
880            .column(0)
881            .as_any()
882            .downcast_ref::<TimestampMicrosecondArray>()
883            .expect("Expected TimestampMicrosecondArray");
884
885        for (i, expected) in expected_micros.iter().enumerate() {
886            assert_eq!(
887                ts_array.value(i),
888                *expected,
889                "Row {i}: got {}, expected {expected}",
890                ts_array.value(i)
891            );
892        }
893    }
894
895    /// Writes a single-column Parquet file encrypted with `encryption_key`, then reads it
896    /// back through `ArrowReader` and asserts the round-tripped values. The key length
897    /// selects the AES-GCM variant in arrow-rs (16 -> AES-128, 32 -> AES-256).
898    async fn assert_encrypted_parquet_roundtrip(encryption_key: &[u8]) {
899        let aad_prefix = b"aad_prefix";
900
901        let schema = Arc::new(
902            Schema::builder()
903                .with_schema_id(1)
904                .with_fields(vec![
905                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
906                ])
907                .build()
908                .unwrap(),
909        );
910
911        let arrow_schema = Arc::new(ArrowSchema::new(vec![
912            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
913                PARQUET_FIELD_ID_META_KEY.to_string(),
914                "1".to_string(),
915            )])),
916        ]));
917
918        let tmp_dir = TempDir::new().unwrap();
919        let table_location = tmp_dir.path().to_str().unwrap().to_string();
920        let file_io = FileIO::new_with_fs();
921
922        let id_data = Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef;
923        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![id_data]).unwrap();
924
925        let file_path = format!("{table_location}/encrypted.parquet");
926        write_encrypted_parquet(&file_path, &batch, encryption_key, Some(aad_prefix));
927
928        let key_metadata = crate::encryption::StandardKeyMetadata::try_new(encryption_key)
929            .unwrap()
930            .with_aad_prefix(aad_prefix)
931            .encode()
932            .unwrap();
933
934        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
935
936        let task = FileScanTask::builder()
937            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
938            .with_start(0)
939            .with_length(0)
940            .with_data_file_path(file_path)
941            .with_data_file_format(DataFileFormat::Parquet)
942            .with_schema(schema)
943            .with_project_field_ids(vec![1])
944            .with_case_sensitive(false)
945            .with_key_metadata(Some(key_metadata))
946            .build();
947
948        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
949        let batches: Vec<RecordBatch> = reader
950            .read(tasks)
951            .unwrap()
952            .stream()
953            .try_collect()
954            .await
955            .unwrap();
956
957        assert_eq!(batches.len(), 1);
958        let ids = batches[0]
959            .column(0)
960            .as_any()
961            .downcast_ref::<Int32Array>()
962            .unwrap();
963        assert_eq!(ids.values(), &[10, 20, 30]);
964    }
965
966    #[tokio::test]
967    async fn test_read_encrypted_parquet_aes_128() {
968        assert_encrypted_parquet_roundtrip(b"0123456789abcdef").await;
969    }
970
971    #[tokio::test]
972    async fn test_read_encrypted_parquet_aes_256() {
973        assert_encrypted_parquet_roundtrip(b"0123456789abcdef0123456789abcdef").await;
974    }
975
976    #[tokio::test]
977    async fn test_read_encrypted_parquet_without_key_metadata_fails() {
978        let encryption_key = b"0123456789abcdef";
979
980        let schema = Arc::new(
981            Schema::builder()
982                .with_schema_id(1)
983                .with_fields(vec![
984                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
985                ])
986                .build()
987                .unwrap(),
988        );
989
990        let arrow_schema = Arc::new(ArrowSchema::new(vec![
991            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
992                PARQUET_FIELD_ID_META_KEY.to_string(),
993                "1".to_string(),
994            )])),
995        ]));
996
997        let tmp_dir = TempDir::new().unwrap();
998        let table_location = tmp_dir.path().to_str().unwrap().to_string();
999        let file_io = FileIO::new_with_fs();
1000
1001        let id_data = Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef;
1002        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![id_data]).unwrap();
1003
1004        let file_path = format!("{table_location}/encrypted_no_key.parquet");
1005        write_encrypted_parquet(&file_path, &batch, encryption_key, None);
1006
1007        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
1008
1009        let task = FileScanTask::builder()
1010            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
1011            .with_start(0)
1012            .with_length(0)
1013            .with_data_file_path(file_path)
1014            .with_data_file_format(DataFileFormat::Parquet)
1015            .with_schema(schema)
1016            .with_project_field_ids(vec![1])
1017            .with_case_sensitive(false)
1018            .build();
1019
1020        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1021        let result: Result<Vec<RecordBatch>, _> =
1022            reader.read(tasks).unwrap().stream().try_collect().await;
1023
1024        let err = result.unwrap_err();
1025        assert_eq!(err.kind(), crate::ErrorKind::Unexpected);
1026        let err_str = format!("{err}");
1027        assert!(
1028            err_str.contains("encrypted footer"),
1029            "Expected error about encrypted footer, got: {err_str}"
1030        );
1031        assert!(
1032            err_str.contains("decryption properties were not provided"),
1033            "Expected error about missing decryption properties, got: {err_str}"
1034        );
1035    }
1036
1037    /// Writes a plain (unencrypted) single-column Int32 "id" parquet file with the
1038    /// given extra Arrow fields/columns appended, returning the file path.
1039    fn write_plain_parquet(
1040        dir: &str,
1041        name: &str,
1042        extra_fields: Vec<Field>,
1043        extra_columns: Vec<ArrayRef>,
1044    ) -> String {
1045        let mut fields =
1046            vec![
1047                Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1048                    PARQUET_FIELD_ID_META_KEY.to_string(),
1049                    "1".to_string(),
1050                )])),
1051            ];
1052        fields.extend(extra_fields);
1053        let arrow_schema = Arc::new(ArrowSchema::new(fields));
1054
1055        let mut columns: Vec<ArrayRef> = vec![Arc::new(Int32Array::from(vec![1, 2, 3]))];
1056        columns.extend(extra_columns);
1057        let batch = RecordBatch::try_new(arrow_schema.clone(), columns).unwrap();
1058
1059        let file_path = format!("{dir}/{name}");
1060        let file = File::create(&file_path).unwrap();
1061        let props = WriterProperties::builder()
1062            .set_compression(Compression::SNAPPY)
1063            .build();
1064        let mut writer = ArrowWriter::try_new(file, arrow_schema, Some(props)).unwrap();
1065        writer.write(&batch).unwrap();
1066        writer.close().unwrap();
1067        file_path
1068    }
1069
1070    fn last_updated_seq_task(
1071        file_path: String,
1072        first_row_id: Option<i64>,
1073        data_sequence_number: Option<i64>,
1074    ) -> FileScanTask {
1075        use crate::metadata_columns::RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER;
1076
1077        let schema = Arc::new(
1078            Schema::builder()
1079                .with_schema_id(1)
1080                .with_fields(vec![
1081                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1082                ])
1083                .build()
1084                .unwrap(),
1085        );
1086
1087        FileScanTask::builder()
1088            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
1089            .with_start(0)
1090            .with_length(0)
1091            .with_data_file_path(file_path)
1092            .with_data_file_format(DataFileFormat::Parquet)
1093            .with_schema(schema)
1094            .with_project_field_ids(vec![1, RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER])
1095            .with_first_row_id(first_row_id)
1096            .with_data_sequence_number(data_sequence_number)
1097            .with_case_sensitive(false)
1098            .build()
1099    }
1100
1101    /// Asserts the logical per-row values of the `_last_updated_sequence_number`
1102    /// column across all batches, independent of the physical (run-end) encoding.
1103    fn assert_last_updated_seq_column(batches: &[RecordBatch], expected: &[Option<i64>]) {
1104        use arrow_array::cast::AsArray;
1105        use arrow_cast::cast;
1106        use arrow_schema::DataType;
1107
1108        use crate::metadata_columns::RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER;
1109
1110        let mut actual = Vec::new();
1111        for batch in batches {
1112            let col = batch
1113                .column_by_name(RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER)
1114                .expect("_last_updated_sequence_number column should be present");
1115            let logical = cast(col, &DataType::Int64).unwrap();
1116            let values = logical.as_primitive::<arrow_array::types::Int64Type>();
1117            for i in 0..values.len() {
1118                actual.push((!values.is_null(i)).then(|| values.value(i)));
1119            }
1120        }
1121        assert_eq!(actual, expected);
1122    }
1123
1124    #[tokio::test]
1125    async fn test_last_updated_sequence_number_null_when_no_first_row_id() {
1126        let tmp_dir = TempDir::new().unwrap();
1127        let dir = tmp_dir.path().to_str().unwrap();
1128        let file_path = write_plain_parquet(dir, "no_first_row_id.parquet", vec![], vec![]);
1129
1130        // A file with a null first_row_id (v1/v2, or a pre-upgrade v3 snapshot) produces
1131        // a null _last_updated_sequence_number column, even though it has a data
1132        // sequence number; the spec gates both lineage columns on first_row_id.
1133        let task = last_updated_seq_task(file_path, None, Some(9));
1134
1135        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1136        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1137        let batches: Vec<RecordBatch> = reader
1138            .read(tasks)
1139            .unwrap()
1140            .stream()
1141            .try_collect()
1142            .await
1143            .unwrap();
1144
1145        assert_last_updated_seq_column(&batches, &[None, None, None]);
1146    }
1147
1148    #[tokio::test]
1149    async fn test_last_updated_sequence_number_error_when_no_data_seq() {
1150        let tmp_dir = TempDir::new().unwrap();
1151        let dir = tmp_dir.path().to_str().unwrap();
1152        let file_path = write_plain_parquet(dir, "no_data_seq.parquet", vec![], vec![]);
1153
1154        // first_row_id present but data_sequence_number absent: after manifest
1155        // inheritance a committed entry always has one, so this is a malformed
1156        // manifest and must error rather than fabricate or null the column.
1157        let task = last_updated_seq_task(file_path, Some(42), None);
1158
1159        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1160        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1161        let result: Result<Vec<RecordBatch>, _> =
1162            reader.read(tasks).unwrap().stream().try_collect().await;
1163
1164        let err = result.unwrap_err();
1165        assert_eq!(err.kind(), crate::ErrorKind::DataInvalid);
1166        assert!(
1167            format!("{err}").contains("no data sequence number"),
1168            "unexpected error: {err}"
1169        );
1170    }
1171
1172    #[tokio::test]
1173    async fn test_last_updated_sequence_number_derived_from_data_seq() {
1174        let tmp_dir = TempDir::new().unwrap();
1175        let dir = tmp_dir.path().to_str().unwrap();
1176        let file_path = write_plain_parquet(dir, "with_first_row_id.parquet", vec![], vec![]);
1177
1178        // Non-null first_row_id + data sequence number -> the derived value (the data
1179        // sequence number) for every row. This is the only value-producing arm.
1180        let task = last_updated_seq_task(file_path, Some(42), Some(7));
1181
1182        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1183        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1184        let batches: Vec<RecordBatch> = reader
1185            .read(tasks)
1186            .unwrap()
1187            .stream()
1188            .try_collect()
1189            .await
1190            .unwrap();
1191
1192        assert_last_updated_seq_column(&batches, &[Some(7), Some(7), Some(7)]);
1193    }
1194
1195    #[tokio::test]
1196    async fn test_last_updated_sequence_number_mixed_files_share_schema() {
1197        use arrow_select::concat::concat_batches;
1198
1199        let tmp_dir = TempDir::new().unwrap();
1200        let dir = tmp_dir.path().to_str().unwrap();
1201
1202        // Three files in one scan exercising all three column paths, which must all
1203        // produce the SAME Arrow type (run-end-encoded) or concatenation fails:
1204        //   - constant: first_row_id set, no physical column -> derived constant
1205        //   - null gate: no first_row_id -> null column
1206        //   - coalesce: first_row_id set, physical column present -> per-row + fallback
1207        let constant = last_updated_seq_task(
1208            write_plain_parquet(dir, "constant.parquet", vec![], vec![]),
1209            Some(42),
1210            Some(7),
1211        );
1212        let nulled = last_updated_seq_task(
1213            write_plain_parquet(dir, "nulled.parquet", vec![], vec![]),
1214            None,
1215            Some(7),
1216        );
1217        let coalesced = last_updated_seq_task(
1218            write_plain_parquet(
1219                dir,
1220                "coalesced.parquet",
1221                vec![physical_last_updated_seq_field()],
1222                vec![Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef],
1223            ),
1224            Some(50),
1225            Some(7),
1226        );
1227
1228        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1229        let tasks = Box::pin(futures::stream::iter(vec![
1230            Ok(constant),
1231            Ok(nulled),
1232            Ok(coalesced),
1233        ])) as FileScanTaskStream;
1234        let batches: Vec<RecordBatch> = reader
1235            .read(tasks)
1236            .unwrap()
1237            .stream()
1238            .try_collect()
1239            .await
1240            .unwrap();
1241
1242        assert_eq!(batches.len(), 3);
1243        // Identical schema across all three paths -> concat succeeds.
1244        let schema = batches[0].schema();
1245        concat_batches(&schema, &batches)
1246            .expect("constant, null and coalesce files must share one column type");
1247    }
1248
1249    /// A parquet field carrying the embedded `_last_updated_sequence_number` field id.
1250    fn physical_last_updated_seq_field() -> Field {
1251        use crate::metadata_columns::{
1252            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
1253            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
1254        };
1255        Field::new(
1256            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
1257            DataType::Int64,
1258            true,
1259        )
1260        .with_metadata(HashMap::from([(
1261            PARQUET_FIELD_ID_META_KEY.to_string(),
1262            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER.to_string(),
1263        )]))
1264    }
1265
1266    #[tokio::test]
1267    async fn test_last_updated_sequence_number_physical_column_coalesced() {
1268        let tmp_dir = TempDir::new().unwrap();
1269        let dir = tmp_dir.path().to_str().unwrap();
1270        // A file that physically carries the column, as Iceberg Java writes when
1271        // carrying rows forward across a rewrite: some rows have a stored value, some
1272        // are null (added/modified rows, inherited on read).
1273        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef;
1274        let file_path = write_plain_parquet(
1275            dir,
1276            "with_seq.parquet",
1277            vec![physical_last_updated_seq_field()],
1278            vec![seq_col],
1279        );
1280
1281        let task = last_updated_seq_task(file_path, Some(100), Some(9));
1282
1283        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1284        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1285        let batches: Vec<RecordBatch> = reader
1286            .read(tasks)
1287            .unwrap()
1288            .stream()
1289            .try_collect()
1290            .await
1291            .unwrap();
1292
1293        // Per-row value where non-null; the data sequence number (9) where null.
1294        assert_last_updated_seq_column(&batches, &[Some(5), Some(9), Some(8)]);
1295    }
1296
1297    #[tokio::test]
1298    async fn test_last_updated_sequence_number_coalesced_with_pos_column() {
1299        use crate::metadata_columns::{
1300            RESERVED_COL_NAME_POS, RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
1301            RESERVED_FIELD_ID_POS,
1302        };
1303
1304        let tmp_dir = TempDir::new().unwrap();
1305        let dir = tmp_dir.path().to_str().unwrap();
1306        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef;
1307        let file_path = write_plain_parquet(
1308            dir,
1309            "with_seq_and_pos.parquet",
1310            vec![physical_last_updated_seq_field()],
1311            vec![seq_col],
1312        );
1313
1314        // Co-project `_pos` (a virtual column appended to the Arrow output schema) with the
1315        // physical coalesce column. This guards that the physical column's index is
1316        // resolved in the Parquet schema, not the Arrow schema (whose indices shift once
1317        // virtual columns are appended).
1318        let schema = Arc::new(
1319            Schema::builder()
1320                .with_schema_id(1)
1321                .with_fields(vec![
1322                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1323                ])
1324                .build()
1325                .unwrap(),
1326        );
1327        let task = FileScanTask::builder()
1328            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
1329            .with_start(0)
1330            .with_length(0)
1331            .with_data_file_path(file_path)
1332            .with_data_file_format(DataFileFormat::Parquet)
1333            .with_schema(schema)
1334            .with_project_field_ids(vec![
1335                1,
1336                RESERVED_FIELD_ID_POS,
1337                RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
1338            ])
1339            .with_first_row_id(Some(100))
1340            .with_data_sequence_number(Some(9))
1341            .with_case_sensitive(false)
1342            .build();
1343
1344        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1345        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1346        let batches: Vec<RecordBatch> = reader
1347            .read(tasks)
1348            .unwrap()
1349            .stream()
1350            .try_collect()
1351            .await
1352            .unwrap();
1353
1354        // The seq column still coalesces correctly...
1355        assert_last_updated_seq_column(&batches, &[Some(5), Some(9), Some(8)]);
1356        // ...and `_pos` is the row position, unaffected by the physical-column union.
1357        let pos_col = batches[0]
1358            .column_by_name(RESERVED_COL_NAME_POS)
1359            .expect("_pos column should be present")
1360            .as_primitive::<arrow_array::types::Int64Type>();
1361        assert_eq!(pos_col.values(), &[0, 1, 2]);
1362    }
1363
1364    #[tokio::test]
1365    async fn test_last_updated_sequence_number_physical_column_nulled_without_first_row_id() {
1366        let tmp_dir = TempDir::new().unwrap();
1367        let dir = tmp_dir.path().to_str().unwrap();
1368        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef;
1369        let file_path = write_plain_parquet(
1370            dir,
1371            "with_seq_no_first_row_id.parquet",
1372            vec![physical_last_updated_seq_field()],
1373            vec![seq_col],
1374        );
1375
1376        // Null first_row_id: the whole column is null even though the file physically
1377        // carries per-row values -- the gate wins, and the physical column is not read.
1378        let task = last_updated_seq_task(file_path, None, Some(9));
1379
1380        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1381        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1382        let batches: Vec<RecordBatch> = reader
1383            .read(tasks)
1384            .unwrap()
1385            .stream()
1386            .try_collect()
1387            .await
1388            .unwrap();
1389
1390        assert_last_updated_seq_column(&batches, &[None, None, None]);
1391    }
1392
1393    #[tokio::test]
1394    async fn test_last_updated_sequence_number_present_by_name_without_id_unsupported() {
1395        let tmp_dir = TempDir::new().unwrap();
1396        let dir = tmp_dir.path().to_str().unwrap();
1397        // Column present by name but WITHOUT the embedded field id (e.g. name mapping /
1398        // positional fallback). The transformer keys the source column by field id, so
1399        // this shape can't be threaded and is rejected loudly.
1400        let seq_field = Field::new(
1401            crate::metadata_columns::RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
1402            DataType::Int64,
1403            true,
1404        );
1405        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef;
1406        let file_path =
1407            write_plain_parquet(dir, "with_seq_by_name.parquet", vec![seq_field], vec![
1408                seq_col,
1409            ]);
1410
1411        let task = last_updated_seq_task(file_path, Some(100), Some(9));
1412
1413        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1414        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1415        let result: Result<Vec<RecordBatch>, _> =
1416            reader.read(tasks).unwrap().stream().try_collect().await;
1417
1418        let err = result.unwrap_err();
1419        assert_eq!(err.kind(), crate::ErrorKind::FeatureUnsupported);
1420        assert!(
1421            format!("{err}").contains("without an embedded field id"),
1422            "unexpected error: {err}"
1423        );
1424    }
1425
1426    #[tokio::test]
1427    async fn test_last_updated_sequence_number_physical_column_first_row_id_without_data_seq() {
1428        let tmp_dir = TempDir::new().unwrap();
1429        let dir = tmp_dir.path().to_str().unwrap();
1430        let seq_col = Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef;
1431        let file_path = write_plain_parquet(
1432            dir,
1433            "with_seq_no_data_seq.parquet",
1434            vec![physical_last_updated_seq_field()],
1435            vec![seq_col],
1436        );
1437
1438        // first_row_id set but no data sequence number: after manifest inheritance a
1439        // committed entry always has one, so this is a malformed manifest, rejected loudly
1440        // rather than nulled.
1441        let task = last_updated_seq_task(file_path, Some(100), None);
1442
1443        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
1444        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1445        let result: Result<Vec<RecordBatch>, _> =
1446            reader.read(tasks).unwrap().stream().try_collect().await;
1447
1448        let err = result.unwrap_err();
1449        assert_eq!(err.kind(), crate::ErrorKind::DataInvalid);
1450        assert!(
1451            format!("{err}").contains("no data sequence number"),
1452            "unexpected error: {err}"
1453        );
1454    }
1455
1456    #[tokio::test]
1457    async fn test_read_encrypted_parquet_with_wrong_key_fails() {
1458        let encryption_key = b"0123456789abcdef";
1459        let wrong_key = b"fedcba9876543210";
1460
1461        let schema = Arc::new(
1462            Schema::builder()
1463                .with_schema_id(1)
1464                .with_fields(vec![
1465                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1466                ])
1467                .build()
1468                .unwrap(),
1469        );
1470
1471        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1472            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1473                PARQUET_FIELD_ID_META_KEY.to_string(),
1474                "1".to_string(),
1475            )])),
1476        ]));
1477
1478        let tmp_dir = TempDir::new().unwrap();
1479        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1480        let file_io = FileIO::new_with_fs();
1481
1482        let id_data = Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef;
1483        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![id_data]).unwrap();
1484
1485        let file_path = format!("{table_location}/encrypted_wrong_key.parquet");
1486        write_encrypted_parquet(&file_path, &batch, encryption_key, None);
1487
1488        let wrong_key_metadata = crate::encryption::StandardKeyMetadata::try_new(wrong_key)
1489            .unwrap()
1490            .encode()
1491            .unwrap();
1492
1493        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
1494
1495        let task = FileScanTask::builder()
1496            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
1497            .with_start(0)
1498            .with_length(0)
1499            .with_data_file_path(file_path)
1500            .with_data_file_format(DataFileFormat::Parquet)
1501            .with_schema(schema)
1502            .with_project_field_ids(vec![1])
1503            .with_case_sensitive(false)
1504            .with_key_metadata(Some(wrong_key_metadata))
1505            .build();
1506
1507        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
1508        let result: Result<Vec<RecordBatch>, _> =
1509            reader.read(tasks).unwrap().stream().try_collect().await;
1510
1511        let err = result.unwrap_err();
1512        assert_eq!(err.kind(), crate::ErrorKind::Unexpected);
1513        let err_str = format!("{err}");
1514        assert!(
1515            err_str.contains("unable to decrypt parquet footer"),
1516            "Expected error about decryption failure, got: {err_str}"
1517        );
1518    }
1519
1520    /// Test that concurrency=1 reads all files correctly and in deterministic order.
1521    /// This verifies the fast-path optimization for single concurrency.
1522    #[tokio::test]
1523    async fn test_read_with_concurrency_one() {
1524        use arrow_array::Int32Array;
1525
1526        let schema = Arc::new(
1527            Schema::builder()
1528                .with_schema_id(1)
1529                .with_fields(vec![
1530                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1531                    NestedField::required(2, "file_num", Type::Primitive(PrimitiveType::Int))
1532                        .into(),
1533                ])
1534                .build()
1535                .unwrap(),
1536        );
1537
1538        let arrow_schema = Arc::new(ArrowSchema::new(vec![
1539            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
1540                PARQUET_FIELD_ID_META_KEY.to_string(),
1541                "1".to_string(),
1542            )])),
1543            Field::new("file_num", DataType::Int32, false).with_metadata(HashMap::from([(
1544                PARQUET_FIELD_ID_META_KEY.to_string(),
1545                "2".to_string(),
1546            )])),
1547        ]));
1548
1549        let tmp_dir = TempDir::new().unwrap();
1550        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1551        let file_io = FileIO::new_with_fs();
1552
1553        // Create 3 parquet files with different data
1554        let props = WriterProperties::builder()
1555            .set_compression(Compression::SNAPPY)
1556            .build();
1557
1558        for file_num in 0..3 {
1559            let id_data = Arc::new(Int32Array::from_iter_values(
1560                file_num * 10..(file_num + 1) * 10,
1561            )) as ArrayRef;
1562            let file_num_data = Arc::new(Int32Array::from(vec![file_num; 10])) as ArrayRef;
1563
1564            let to_write =
1565                RecordBatch::try_new(arrow_schema.clone(), vec![id_data, file_num_data]).unwrap();
1566
1567            let file = File::create(format!("{table_location}/file_{file_num}.parquet")).unwrap();
1568            let mut writer =
1569                ArrowWriter::try_new(file, to_write.schema(), Some(props.clone())).unwrap();
1570            writer.write(&to_write).expect("Writing batch");
1571            writer.close().unwrap();
1572        }
1573
1574        // Read with concurrency=1 (fast-path)
1575        let reader = ArrowReaderBuilder::new(file_io, Runtime::current())
1576            .with_data_file_concurrency_limit(1)
1577            .build();
1578
1579        // Create tasks in a specific order: file_0, file_1, file_2
1580        let tasks = vec![
1581            Ok(FileScanTask::builder()
1582                .with_file_size_in_bytes(
1583                    std::fs::metadata(format!("{table_location}/file_0.parquet"))
1584                        .unwrap()
1585                        .len(),
1586                )
1587                .with_start(0)
1588                .with_length(0)
1589                .with_data_file_path(format!("{table_location}/file_0.parquet"))
1590                .with_data_file_format(DataFileFormat::Parquet)
1591                .with_schema(schema.clone())
1592                .with_project_field_ids(vec![1, 2])
1593                .with_case_sensitive(false)
1594                .build()),
1595            Ok(FileScanTask::builder()
1596                .with_file_size_in_bytes(
1597                    std::fs::metadata(format!("{table_location}/file_1.parquet"))
1598                        .unwrap()
1599                        .len(),
1600                )
1601                .with_start(0)
1602                .with_length(0)
1603                .with_data_file_path(format!("{table_location}/file_1.parquet"))
1604                .with_data_file_format(DataFileFormat::Parquet)
1605                .with_schema(schema.clone())
1606                .with_project_field_ids(vec![1, 2])
1607                .with_case_sensitive(false)
1608                .build()),
1609            Ok(FileScanTask::builder()
1610                .with_file_size_in_bytes(
1611                    std::fs::metadata(format!("{table_location}/file_2.parquet"))
1612                        .unwrap()
1613                        .len(),
1614                )
1615                .with_start(0)
1616                .with_length(0)
1617                .with_data_file_path(format!("{table_location}/file_2.parquet"))
1618                .with_data_file_format(DataFileFormat::Parquet)
1619                .with_schema(schema.clone())
1620                .with_project_field_ids(vec![1, 2])
1621                .with_case_sensitive(false)
1622                .build()),
1623        ];
1624
1625        let tasks_stream = Box::pin(futures::stream::iter(tasks)) as FileScanTaskStream;
1626
1627        let result = reader
1628            .read(tasks_stream)
1629            .unwrap()
1630            .stream()
1631            .try_collect::<Vec<RecordBatch>>()
1632            .await
1633            .unwrap();
1634
1635        // Verify we got all 30 rows (10 from each file)
1636        let total_rows: usize = result.iter().map(|b| b.num_rows()).sum();
1637        assert_eq!(total_rows, 30, "Should have 30 total rows");
1638
1639        // Collect all ids and file_nums to verify data
1640        let mut all_ids = Vec::new();
1641        let mut all_file_nums = Vec::new();
1642
1643        for batch in &result {
1644            let id_col = batch
1645                .column(0)
1646                .as_primitive::<arrow_array::types::Int32Type>();
1647            let file_num_col = batch
1648                .column(1)
1649                .as_primitive::<arrow_array::types::Int32Type>();
1650
1651            for i in 0..batch.num_rows() {
1652                all_ids.push(id_col.value(i));
1653                all_file_nums.push(file_num_col.value(i));
1654            }
1655        }
1656
1657        assert_eq!(all_ids.len(), 30);
1658        assert_eq!(all_file_nums.len(), 30);
1659
1660        // With concurrency=1 and sequential processing, files should be processed in order
1661        // file_0: ids 0-9, file_num=0
1662        // file_1: ids 10-19, file_num=1
1663        // file_2: ids 20-29, file_num=2
1664        for i in 0..10 {
1665            assert_eq!(all_file_nums[i], 0, "First 10 rows should be from file_0");
1666            assert_eq!(all_ids[i], i as i32, "IDs should be 0-9");
1667        }
1668        for i in 10..20 {
1669            assert_eq!(all_file_nums[i], 1, "Next 10 rows should be from file_1");
1670            assert_eq!(all_ids[i], i as i32, "IDs should be 10-19");
1671        }
1672        for i in 20..30 {
1673            assert_eq!(all_file_nums[i], 2, "Last 10 rows should be from file_2");
1674            assert_eq!(all_ids[i], i as i32, "IDs should be 20-29");
1675        }
1676    }
1677
1678    #[tokio::test]
1679    async fn test_read_int96_timestamps_with_field_ids() {
1680        let schema = Arc::new(
1681            Schema::builder()
1682                .with_schema_id(1)
1683                .with_fields(vec![
1684                    NestedField::optional(1, "ts", Type::Primitive(PrimitiveType::Timestamp))
1685                        .into(),
1686                    NestedField::required(2, "id", Type::Primitive(PrimitiveType::Int)).into(),
1687                ])
1688                .build()
1689                .unwrap(),
1690        );
1691
1692        let tmp_dir = TempDir::new().unwrap();
1693        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1694        let (file_path, expected_micros) =
1695            write_int96_parquet_file(&table_location, "with_ids.parquet", true);
1696
1697        assert_int96_read_matches(&file_path, schema, vec![1, 2], &expected_micros).await;
1698    }
1699
1700    #[tokio::test]
1701    async fn test_read_int96_timestamps_without_field_ids() {
1702        let schema = Arc::new(
1703            Schema::builder()
1704                .with_schema_id(1)
1705                .with_fields(vec![
1706                    NestedField::optional(1, "ts", Type::Primitive(PrimitiveType::Timestamp))
1707                        .into(),
1708                    NestedField::required(2, "id", Type::Primitive(PrimitiveType::Int)).into(),
1709                ])
1710                .build()
1711                .unwrap(),
1712        );
1713
1714        let tmp_dir = TempDir::new().unwrap();
1715        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1716        let (file_path, expected_micros) =
1717            write_int96_parquet_file(&table_location, "no_ids.parquet", false);
1718
1719        assert_int96_read_matches(&file_path, schema, vec![1, 2], &expected_micros).await;
1720    }
1721
1722    #[tokio::test]
1723    async fn test_read_int96_timestamps_in_struct() {
1724        use arrow_array::{StructArray, TimestampMicrosecondArray};
1725        use parquet::basic::{Repetition, Type as PhysicalType};
1726        use parquet::data_type::Int96Type;
1727        use parquet::file::writer::SerializedFileWriter;
1728        use parquet::schema::types::Type as SchemaType;
1729
1730        let tmp_dir = TempDir::new().unwrap();
1731        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1732        let file_path = format!("{table_location}/struct_int96.parquet");
1733
1734        let ts_type = SchemaType::primitive_type_builder("ts", PhysicalType::INT96)
1735            .with_repetition(Repetition::OPTIONAL)
1736            .with_id(Some(2))
1737            .build()
1738            .unwrap();
1739
1740        let struct_type = SchemaType::group_type_builder("data")
1741            .with_repetition(Repetition::REQUIRED)
1742            .with_id(Some(1))
1743            .with_fields(vec![Arc::new(ts_type)])
1744            .build()
1745            .unwrap();
1746
1747        let parquet_schema = SchemaType::group_type_builder("schema")
1748            .with_fields(vec![Arc::new(struct_type)])
1749            .build()
1750            .unwrap();
1751
1752        let (int96_val, expected_micros) = make_int96_test_value();
1753
1754        let file = File::create(&file_path).unwrap();
1755        let mut writer =
1756            SerializedFileWriter::new(file, Arc::new(parquet_schema), Default::default()).unwrap();
1757
1758        // def=1: struct is REQUIRED so no level, ts is OPTIONAL and present (1).
1759        // No repetition levels needed (no repeated groups).
1760        let mut row_group = writer.next_row_group().unwrap();
1761        {
1762            let mut col = row_group.next_column().unwrap().unwrap();
1763            col.typed::<Int96Type>()
1764                .write_batch(&[int96_val], Some(&[1]), None)
1765                .unwrap();
1766            col.close().unwrap();
1767        }
1768        row_group.close().unwrap();
1769        writer.close().unwrap();
1770
1771        let iceberg_schema = Arc::new(
1772            Schema::builder()
1773                .with_schema_id(1)
1774                .with_fields(vec![
1775                    NestedField::required(
1776                        1,
1777                        "data",
1778                        Type::Struct(crate::spec::StructType::new(vec![
1779                            NestedField::optional(
1780                                2,
1781                                "ts",
1782                                Type::Primitive(PrimitiveType::Timestamp),
1783                            )
1784                            .into(),
1785                        ])),
1786                    )
1787                    .into(),
1788                ])
1789                .build()
1790                .unwrap(),
1791        );
1792
1793        let batches = read_int96_batches(&file_path, iceberg_schema, vec![1]).await;
1794
1795        assert_eq!(batches.len(), 1);
1796        let struct_array = batches[0]
1797            .column(0)
1798            .as_any()
1799            .downcast_ref::<StructArray>()
1800            .expect("Expected StructArray");
1801        let ts_array = struct_array
1802            .column(0)
1803            .as_any()
1804            .downcast_ref::<TimestampMicrosecondArray>()
1805            .expect("Expected TimestampMicrosecondArray inside struct");
1806
1807        assert_eq!(
1808            ts_array.value(0),
1809            expected_micros,
1810            "INT96 in struct: got {}, expected {expected_micros}",
1811            ts_array.value(0)
1812        );
1813    }
1814
1815    #[tokio::test]
1816    async fn test_read_int96_timestamps_in_list() {
1817        use arrow_array::{ListArray, TimestampMicrosecondArray};
1818        use parquet::basic::{Repetition, Type as PhysicalType};
1819        use parquet::data_type::Int96Type;
1820        use parquet::file::writer::SerializedFileWriter;
1821        use parquet::schema::types::Type as SchemaType;
1822
1823        let tmp_dir = TempDir::new().unwrap();
1824        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1825        let file_path = format!("{table_location}/list_int96.parquet");
1826
1827        // 3-level LIST encoding:
1828        //   optional group timestamps (LIST) {
1829        //     repeated group list {
1830        //       optional int96 element;
1831        //     }
1832        //   }
1833        let element_type = SchemaType::primitive_type_builder("element", PhysicalType::INT96)
1834            .with_repetition(Repetition::OPTIONAL)
1835            .with_id(Some(2))
1836            .build()
1837            .unwrap();
1838
1839        let list_group = SchemaType::group_type_builder("list")
1840            .with_repetition(Repetition::REPEATED)
1841            .with_fields(vec![Arc::new(element_type)])
1842            .build()
1843            .unwrap();
1844
1845        let list_type = SchemaType::group_type_builder("timestamps")
1846            .with_repetition(Repetition::OPTIONAL)
1847            .with_id(Some(1))
1848            .with_logical_type(Some(parquet::basic::LogicalType::List))
1849            .with_fields(vec![Arc::new(list_group)])
1850            .build()
1851            .unwrap();
1852
1853        let parquet_schema = SchemaType::group_type_builder("schema")
1854            .with_fields(vec![Arc::new(list_type)])
1855            .build()
1856            .unwrap();
1857
1858        let (int96_val, expected_micros) = make_int96_test_value();
1859
1860        let file = File::create(&file_path).unwrap();
1861        let mut writer =
1862            SerializedFileWriter::new(file, Arc::new(parquet_schema), Default::default()).unwrap();
1863
1864        // Write a single row with a list containing one INT96 element.
1865        // def=3: list present (1) + repeated group (2) + element present (3)
1866        // rep=0: start of a new list
1867        let mut row_group = writer.next_row_group().unwrap();
1868        {
1869            let mut col = row_group.next_column().unwrap().unwrap();
1870            col.typed::<Int96Type>()
1871                .write_batch(&[int96_val], Some(&[3]), Some(&[0]))
1872                .unwrap();
1873            col.close().unwrap();
1874        }
1875        row_group.close().unwrap();
1876        writer.close().unwrap();
1877
1878        let iceberg_schema = Arc::new(
1879            Schema::builder()
1880                .with_schema_id(1)
1881                .with_fields(vec![
1882                    NestedField::optional(
1883                        1,
1884                        "timestamps",
1885                        Type::List(crate::spec::ListType {
1886                            element_field: NestedField::optional(
1887                                2,
1888                                "element",
1889                                Type::Primitive(PrimitiveType::Timestamp),
1890                            )
1891                            .into(),
1892                        }),
1893                    )
1894                    .into(),
1895                ])
1896                .build()
1897                .unwrap(),
1898        );
1899
1900        let batches = read_int96_batches(&file_path, iceberg_schema, vec![1]).await;
1901
1902        assert_eq!(batches.len(), 1);
1903        let list_array = batches[0]
1904            .column(0)
1905            .as_any()
1906            .downcast_ref::<ListArray>()
1907            .expect("Expected ListArray");
1908        let ts_array = list_array
1909            .values()
1910            .as_any()
1911            .downcast_ref::<TimestampMicrosecondArray>()
1912            .expect("Expected TimestampMicrosecondArray inside list");
1913
1914        assert_eq!(
1915            ts_array.value(0),
1916            expected_micros,
1917            "INT96 in list: got {}, expected {expected_micros}",
1918            ts_array.value(0)
1919        );
1920    }
1921
1922    #[tokio::test]
1923    async fn test_read_int96_timestamps_in_map() {
1924        use arrow_array::{MapArray, TimestampMicrosecondArray};
1925        use parquet::basic::{Repetition, Type as PhysicalType};
1926        use parquet::data_type::{ByteArrayType, Int96Type};
1927        use parquet::file::writer::SerializedFileWriter;
1928        use parquet::schema::types::Type as SchemaType;
1929
1930        let tmp_dir = TempDir::new().unwrap();
1931        let table_location = tmp_dir.path().to_str().unwrap().to_string();
1932        let file_path = format!("{table_location}/map_int96.parquet");
1933
1934        // MAP encoding:
1935        //   optional group ts_map (MAP) {
1936        //     repeated group key_value {
1937        //       required binary key (UTF8);
1938        //       optional int96 value;
1939        //     }
1940        //   }
1941        let key_type = SchemaType::primitive_type_builder("key", PhysicalType::BYTE_ARRAY)
1942            .with_repetition(Repetition::REQUIRED)
1943            .with_logical_type(Some(parquet::basic::LogicalType::String))
1944            .with_id(Some(2))
1945            .build()
1946            .unwrap();
1947
1948        let value_type = SchemaType::primitive_type_builder("value", PhysicalType::INT96)
1949            .with_repetition(Repetition::OPTIONAL)
1950            .with_id(Some(3))
1951            .build()
1952            .unwrap();
1953
1954        let key_value_group = SchemaType::group_type_builder("key_value")
1955            .with_repetition(Repetition::REPEATED)
1956            .with_fields(vec![Arc::new(key_type), Arc::new(value_type)])
1957            .build()
1958            .unwrap();
1959
1960        let map_type = SchemaType::group_type_builder("ts_map")
1961            .with_repetition(Repetition::OPTIONAL)
1962            .with_id(Some(1))
1963            .with_logical_type(Some(parquet::basic::LogicalType::Map))
1964            .with_fields(vec![Arc::new(key_value_group)])
1965            .build()
1966            .unwrap();
1967
1968        let parquet_schema = SchemaType::group_type_builder("schema")
1969            .with_fields(vec![Arc::new(map_type)])
1970            .build()
1971            .unwrap();
1972
1973        let (int96_val, expected_micros) = make_int96_test_value();
1974
1975        let file = File::create(&file_path).unwrap();
1976        let mut writer =
1977            SerializedFileWriter::new(file, Arc::new(parquet_schema), Default::default()).unwrap();
1978
1979        // Write a single row with a map containing one key-value pair.
1980        // rep=0 for both columns: start of a new map.
1981        // key def=2: map present (1) + key_value entry present (2), key is REQUIRED.
1982        // value def=3: map present (1) + key_value entry present (2) + value present (3).
1983        let mut row_group = writer.next_row_group().unwrap();
1984        {
1985            let mut col = row_group.next_column().unwrap().unwrap();
1986            col.typed::<ByteArrayType>()
1987                .write_batch(
1988                    &[parquet::data_type::ByteArray::from("event_time")],
1989                    Some(&[2]),
1990                    Some(&[0]),
1991                )
1992                .unwrap();
1993            col.close().unwrap();
1994        }
1995        {
1996            let mut col = row_group.next_column().unwrap().unwrap();
1997            col.typed::<Int96Type>()
1998                .write_batch(&[int96_val], Some(&[3]), Some(&[0]))
1999                .unwrap();
2000            col.close().unwrap();
2001        }
2002        row_group.close().unwrap();
2003        writer.close().unwrap();
2004
2005        let iceberg_schema = Arc::new(
2006            Schema::builder()
2007                .with_schema_id(1)
2008                .with_fields(vec![
2009                    NestedField::optional(
2010                        1,
2011                        "ts_map",
2012                        Type::Map(crate::spec::MapType {
2013                            key_field: NestedField::required(
2014                                2,
2015                                "key",
2016                                Type::Primitive(PrimitiveType::String),
2017                            )
2018                            .into(),
2019                            value_field: NestedField::optional(
2020                                3,
2021                                "value",
2022                                Type::Primitive(PrimitiveType::Timestamp),
2023                            )
2024                            .into(),
2025                        }),
2026                    )
2027                    .into(),
2028                ])
2029                .build()
2030                .unwrap(),
2031        );
2032
2033        let batches = read_int96_batches(&file_path, iceberg_schema, vec![1]).await;
2034
2035        assert_eq!(batches.len(), 1);
2036        let map_array = batches[0]
2037            .column(0)
2038            .as_any()
2039            .downcast_ref::<MapArray>()
2040            .expect("Expected MapArray");
2041        let ts_array = map_array
2042            .values()
2043            .as_any()
2044            .downcast_ref::<TimestampMicrosecondArray>()
2045            .expect("Expected TimestampMicrosecondArray as map values");
2046
2047        assert_eq!(
2048            ts_array.value(0),
2049            expected_micros,
2050            "INT96 in map: got {}, expected {expected_micros}",
2051            ts_array.value(0)
2052        );
2053    }
2054
2055    /// Writes `id` (Int32) plus a wide string column (field id 2) whose bytes dominate
2056    /// the file, so that reading it is visible in `bytes_read`.
2057    ///
2058    /// `extra_fields`/`extra_columns` (e.g. a physical metadata leaf) are appended after
2059    /// the `id` and wide columns, mirroring `write_plain_parquet`'s shape.
2060    fn write_parquet_with_wide_column(
2061        dir: &str,
2062        name: &str,
2063        extra_fields: Vec<Field>,
2064        extra_columns: Vec<ArrayRef>,
2065    ) -> String {
2066        let wide_field =
2067            Field::new("wide", DataType::Utf8, false).with_metadata(HashMap::from([(
2068                PARQUET_FIELD_ID_META_KEY.to_string(),
2069                "2".to_string(),
2070            )]));
2071        // Varied bytes so the column chunk does not compress away under SNAPPY, keeping
2072        // the `bytes_read` difference between projecting it and not unambiguous.
2073        let wide_values: Vec<String> = (0..3)
2074            .map(|i| {
2075                (0..2048)
2076                    .map(|j| ((i * 2048 + j) % 251) as u8 as char)
2077                    .collect()
2078            })
2079            .collect();
2080
2081        let mut fields = vec![wide_field];
2082        fields.extend(extra_fields);
2083        let mut columns: Vec<ArrayRef> = vec![Arc::new(StringArray::from(wide_values))];
2084        columns.extend(extra_columns);
2085        write_plain_parquet(dir, name, fields, columns)
2086    }
2087
2088    /// Schema with `id` (field 1, Int) and `wide` (field 2, String), matching
2089    /// `write_parquet_with_wide_column`.
2090    fn id_and_wide_schema() -> SchemaRef {
2091        Arc::new(
2092            Schema::builder()
2093                .with_schema_id(1)
2094                .with_fields(vec![
2095                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2096                    NestedField::required(2, "wide", Type::Primitive(PrimitiveType::String)).into(),
2097                ])
2098                .build()
2099                .unwrap(),
2100        )
2101    }
2102
2103    /// Builds a scan task over `file_path` projecting `project_field_ids`.
2104    fn metadata_projection_task(
2105        file_path: String,
2106        schema: SchemaRef,
2107        project_field_ids: Vec<i32>,
2108    ) -> FileScanTask {
2109        FileScanTask::builder()
2110            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
2111            .with_start(0)
2112            .with_length(0)
2113            .with_data_file_path(file_path)
2114            .with_data_file_format(DataFileFormat::Parquet)
2115            .with_schema(schema)
2116            .with_project_field_ids(project_field_ids)
2117            .with_case_sensitive(false)
2118            .build()
2119    }
2120
2121    /// Runs a single-task scan and returns the batches plus the bytes read from storage.
2122    async fn scan_task(task: FileScanTask) -> (Vec<RecordBatch>, u64) {
2123        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current()).build();
2124        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
2125        let scan = reader.read(tasks).unwrap();
2126        let metrics = scan.metrics().clone();
2127        let batches = scan.stream().try_collect().await.unwrap();
2128        (batches, metrics.bytes_read())
2129    }
2130
2131    #[tokio::test]
2132    async fn test_pos_only_projection_reads_no_data_columns() {
2133        let tmp_dir = TempDir::new().unwrap();
2134        let dir = tmp_dir.path().to_str().unwrap();
2135
2136        let pos_only = metadata_projection_task(
2137            write_parquet_with_wide_column(dir, "pos_only.parquet", vec![], vec![]),
2138            id_and_wide_schema(),
2139            vec![RESERVED_FIELD_ID_POS],
2140        );
2141        let (batches, pos_only_bytes) = scan_task(pos_only).await;
2142
2143        // Only `_pos` is materialized -- no data columns.
2144        assert_eq!(batches[0].num_columns(), 1);
2145        let pos_col = batches[0]
2146            .column_by_name(RESERVED_COL_NAME_POS)
2147            .expect("_pos column should be present")
2148            .as_primitive::<arrow_array::types::Int64Type>();
2149        assert_eq!(pos_col.values(), &[0, 1, 2]);
2150
2151        // A scan of the same-shaped file that also projects the wide data column must read
2152        // materially more, proving the wide column chunk was not fetched above.
2153        let with_data = metadata_projection_task(
2154            write_parquet_with_wide_column(dir, "pos_only_ref.parquet", vec![], vec![]),
2155            id_and_wide_schema(),
2156            vec![2, RESERVED_FIELD_ID_POS],
2157        );
2158        let (_, with_data_bytes) = scan_task(with_data).await;
2159
2160        assert!(
2161            pos_only_bytes < with_data_bytes,
2162            "_pos-only scan should read fewer bytes than a scan of the wide column: \
2163             {pos_only_bytes} vs {with_data_bytes}"
2164        );
2165    }
2166
2167    #[tokio::test]
2168    async fn test_pos_only_projection_keeps_absolute_pos_under_predicate() {
2169        use crate::expr::{Bind, Reference};
2170        use crate::spec::Datum;
2171
2172        let tmp_dir = TempDir::new().unwrap();
2173        let dir = tmp_dir.path().to_str().unwrap();
2174        // id = [1, 2, 3]; drop the middle physical row via a predicate + row selection.
2175        let file_path = write_plain_parquet(dir, "pos_only_predicate.parquet", vec![], vec![]);
2176
2177        let schema = Arc::new(
2178            Schema::builder()
2179                .with_schema_id(1)
2180                .with_fields(vec![
2181                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2182                ])
2183                .build()
2184                .unwrap(),
2185        );
2186        let bound = Reference::new("id")
2187            .not_equal_to(Datum::int(2))
2188            .bind(Arc::clone(&schema), false)
2189            .unwrap();
2190        let task = FileScanTask::builder()
2191            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
2192            .with_start(0)
2193            .with_length(0)
2194            .with_data_file_path(file_path)
2195            .with_data_file_format(DataFileFormat::Parquet)
2196            .with_schema(schema)
2197            .with_project_field_ids(vec![RESERVED_FIELD_ID_POS])
2198            .with_predicate(Some(bound))
2199            .with_case_sensitive(false)
2200            .build();
2201
2202        // Row selection must be enabled for the predicate to filter rows. The row filter
2203        // reads `id` for its own evaluation even though `id` is not projected; the surviving
2204        // rows must keep their ABSOLUTE positions (0 and 2), not renumbered (0 and 1).
2205        let reader = ArrowReaderBuilder::new(FileIO::new_with_fs(), Runtime::current())
2206            .with_row_selection_enabled(true)
2207            .build();
2208        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
2209        let batches: Vec<RecordBatch> = reader
2210            .read(tasks)
2211            .unwrap()
2212            .stream()
2213            .try_collect()
2214            .await
2215            .unwrap();
2216
2217        let pos: Vec<i64> = batches
2218            .iter()
2219            .flat_map(|b| {
2220                b.column_by_name(RESERVED_COL_NAME_POS)
2221                    .expect("_pos column should be present")
2222                    .as_primitive::<arrow_array::types::Int64Type>()
2223                    .values()
2224                    .to_vec()
2225            })
2226            .collect();
2227        assert_eq!(pos, vec![0, 2]);
2228    }
2229
2230    #[tokio::test]
2231    async fn test_pos_and_file_projection() {
2232        use crate::metadata_columns::RESERVED_COL_NAME_FILE;
2233
2234        let tmp_dir = TempDir::new().unwrap();
2235        let dir = tmp_dir.path().to_str().unwrap();
2236        // The motivating row-lineage shape: a synthesized position column (mask -> none)
2237        // alongside a materialized per-file constant.
2238        let file_path = write_parquet_with_wide_column(dir, "pos_and_file.parquet", vec![], vec![]);
2239        let task = metadata_projection_task(file_path.clone(), id_and_wide_schema(), vec![
2240            RESERVED_FIELD_ID_POS,
2241            RESERVED_FIELD_ID_FILE,
2242        ]);
2243        let (batches, _) = scan_task(task).await;
2244
2245        // Both metadata columns materialize; no data column is read.
2246        assert_eq!(batches[0].num_columns(), 2);
2247        let pos_col = batches[0]
2248            .column_by_name(RESERVED_COL_NAME_POS)
2249            .expect("_pos column should be present")
2250            .as_primitive::<arrow_array::types::Int64Type>();
2251        assert_eq!(pos_col.values(), &[0, 1, 2]);
2252        let file_col = batches[0]
2253            .column_by_name(RESERVED_COL_NAME_FILE)
2254            .expect("_file column should be present");
2255        let file_col = cast(file_col, &DataType::Utf8).unwrap();
2256        let file_col = file_col.as_any().downcast_ref::<StringArray>().unwrap();
2257        assert_eq!(file_col.value(0), file_path);
2258    }
2259
2260    #[tokio::test]
2261    async fn test_pos_and_physical_seq_projection_reads_only_the_leaf() {
2262        use crate::metadata_columns::{
2263            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
2264            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2265        };
2266
2267        // A v3 rewrite that carried rows forward stores `_last_updated_sequence_number`
2268        // per-row. Projecting only `_pos` + the sequence column must read just that one
2269        // physical leaf, not every data column.
2270        let tmp_dir = TempDir::new().unwrap();
2271        let dir = tmp_dir.path().to_str().unwrap();
2272
2273        // File: id (1), wide data column (2), physical _last_updated_sequence_number.
2274        let write = |name: &str| {
2275            write_parquet_with_wide_column(
2276                dir,
2277                name,
2278                vec![physical_last_updated_seq_field()],
2279                vec![Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef],
2280            )
2281        };
2282
2283        let seq_task = |path: String, ids: Vec<i32>| {
2284            FileScanTask::builder()
2285                .with_file_size_in_bytes(std::fs::metadata(&path).unwrap().len())
2286                .with_start(0)
2287                .with_length(0)
2288                .with_data_file_path(path)
2289                .with_data_file_format(DataFileFormat::Parquet)
2290                .with_schema(id_and_wide_schema())
2291                .with_project_field_ids(ids)
2292                .with_first_row_id(Some(100))
2293                .with_data_sequence_number(Some(9))
2294                .with_case_sensitive(false)
2295                .build()
2296        };
2297
2298        let meta_only = seq_task(write("pos_seq.parquet"), vec![
2299            RESERVED_FIELD_ID_POS,
2300            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2301        ]);
2302        let (batches, meta_only_bytes) = scan_task(meta_only).await;
2303
2304        // `_pos` and the coalesced sequence column materialize; the wide column does not.
2305        let pos_col = batches[0]
2306            .column_by_name(RESERVED_COL_NAME_POS)
2307            .expect("_pos column should be present")
2308            .as_primitive::<arrow_array::types::Int64Type>();
2309        assert_eq!(pos_col.values(), &[0, 1, 2]);
2310        let seq_col = batches[0]
2311            .column_by_name(RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER)
2312            .expect("_last_updated_sequence_number column should be present");
2313        let seq_col = cast(seq_col, &DataType::Int64).unwrap();
2314        let seq_col = seq_col.as_any().downcast_ref::<Int64Array>().unwrap();
2315        // Per-row stored value where non-null, else the data sequence number (9).
2316        assert_eq!(seq_col.value(0), 5);
2317        assert_eq!(seq_col.value(1), 9);
2318        assert_eq!(seq_col.value(2), 8);
2319        assert!(batches[0].column_by_name("wide").is_none());
2320
2321        // A scan that also projects the wide data column must read materially more,
2322        // proving the metadata-only scan pruned to just the sequence leaf.
2323        let with_data = seq_task(write("pos_seq_ref.parquet"), vec![
2324            2,
2325            RESERVED_FIELD_ID_POS,
2326            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2327        ]);
2328        let (_, with_data_bytes) = scan_task(with_data).await;
2329
2330        assert!(
2331            meta_only_bytes < with_data_bytes,
2332            "_pos + physical sequence scan should read fewer bytes than one that also \
2333             reads the wide column: {meta_only_bytes} vs {with_data_bytes}"
2334        );
2335    }
2336
2337    #[tokio::test]
2338    async fn test_seq_only_projection_reads_only_the_leaf() {
2339        use crate::metadata_columns::{
2340            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
2341            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2342        };
2343
2344        // `_last_updated_sequence_number` alone (no `_pos`, no data column). The physical
2345        // leaf is the sole row source, so it -- not the RowNumber virtual column -- must
2346        // drive the `none()` downgrade and prune the read to just that leaf.
2347        let tmp_dir = TempDir::new().unwrap();
2348        let dir = tmp_dir.path().to_str().unwrap();
2349        let write = |name: &str| {
2350            write_parquet_with_wide_column(
2351                dir,
2352                name,
2353                vec![physical_last_updated_seq_field()],
2354                vec![Arc::new(Int64Array::from(vec![Some(5), None, Some(8)])) as ArrayRef],
2355            )
2356        };
2357        let seq_task = |path: String, ids: Vec<i32>| {
2358            FileScanTask::builder()
2359                .with_file_size_in_bytes(std::fs::metadata(&path).unwrap().len())
2360                .with_start(0)
2361                .with_length(0)
2362                .with_data_file_path(path)
2363                .with_data_file_format(DataFileFormat::Parquet)
2364                .with_schema(id_and_wide_schema())
2365                .with_project_field_ids(ids)
2366                .with_first_row_id(Some(100))
2367                .with_data_sequence_number(Some(9))
2368                .with_case_sensitive(false)
2369                .build()
2370        };
2371
2372        let meta_only = seq_task(write("seq_only.parquet"), vec![
2373            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2374        ]);
2375        let (batches, meta_only_bytes) = scan_task(meta_only).await;
2376
2377        let seq_col = batches[0]
2378            .column_by_name(RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER)
2379            .expect("_last_updated_sequence_number column should be present");
2380        let seq_col = cast(seq_col, &DataType::Int64).unwrap();
2381        let seq_col = seq_col.as_any().downcast_ref::<Int64Array>().unwrap();
2382        assert_eq!(seq_col.value(0), 5);
2383        assert_eq!(seq_col.value(1), 9);
2384        assert_eq!(seq_col.value(2), 8);
2385        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
2386        assert_eq!(total_rows, 3);
2387        assert!(batches[0].column_by_name("wide").is_none());
2388
2389        let with_data = seq_task(write("seq_only_ref.parquet"), vec![
2390            2,
2391            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2392        ]);
2393        let (_, with_data_bytes) = scan_task(with_data).await;
2394
2395        assert!(
2396            meta_only_bytes < with_data_bytes,
2397            "seq-only scan should read fewer bytes than one that also reads the wide \
2398             column: {meta_only_bytes} vs {with_data_bytes}"
2399        );
2400    }
2401
2402    #[tokio::test]
2403    async fn test_seq_only_projection_null_first_row_id_preserves_row_count() {
2404        use crate::metadata_columns::{
2405            RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
2406            RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER,
2407        };
2408
2409        // Seq-only projection with a null first_row_id: the column is nulled and the
2410        // physical leaf is NOT read (the gated `coalesce_last_updated_seq_leaf` is None).
2411        // The downgrade must therefore not fire -- keying off the raw `project_*` flag
2412        // instead would drop the only readable column and lose the row count.
2413        let tmp_dir = TempDir::new().unwrap();
2414        let dir = tmp_dir.path().to_str().unwrap();
2415        let file_path = write_parquet_with_wide_column(
2416            dir,
2417            "seq_only_null_first.parquet",
2418            vec![physical_last_updated_seq_field()],
2419            vec![Arc::new(Int64Array::from(vec![Some(5), Some(6), Some(7)])) as ArrayRef],
2420        );
2421        let task = FileScanTask::builder()
2422            .with_file_size_in_bytes(std::fs::metadata(&file_path).unwrap().len())
2423            .with_start(0)
2424            .with_length(0)
2425            .with_data_file_path(file_path)
2426            .with_data_file_format(DataFileFormat::Parquet)
2427            .with_schema(id_and_wide_schema())
2428            .with_project_field_ids(vec![RESERVED_FIELD_ID_LAST_UPDATED_SEQUENCE_NUMBER])
2429            .with_first_row_id(None)
2430            .with_data_sequence_number(Some(9))
2431            .with_case_sensitive(false)
2432            .build();
2433        let (batches, _) = scan_task(task).await;
2434
2435        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
2436        assert_eq!(total_rows, 3);
2437        let seq_col = batches[0]
2438            .column_by_name(RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER)
2439            .expect("_last_updated_sequence_number column should be present");
2440        let seq_col = cast(seq_col, &DataType::Int64).unwrap();
2441        let seq_col = seq_col.as_any().downcast_ref::<Int64Array>().unwrap();
2442        assert!((0..3).all(|i| seq_col.is_null(i)));
2443    }
2444
2445    #[tokio::test]
2446    async fn test_file_only_projection_preserves_row_count() {
2447        use crate::metadata_columns::RESERVED_COL_NAME_FILE;
2448
2449        let tmp_dir = TempDir::new().unwrap();
2450        let dir = tmp_dir.path().to_str().unwrap();
2451        let file_path = write_plain_parquet(dir, "file_only.parquet", vec![], vec![]);
2452        let schema = Arc::new(
2453            Schema::builder()
2454                .with_schema_id(1)
2455                .with_fields(vec![
2456                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2457                ])
2458                .build()
2459                .unwrap(),
2460        );
2461        let task =
2462            metadata_projection_task(file_path.clone(), schema, vec![RESERVED_FIELD_ID_FILE]);
2463        let (batches, _) = scan_task(task).await;
2464
2465        // A pure-constant projection has no independent row source, so the row count must
2466        // still come from the file (the `empty -> all()` path is preserved for this case).
2467        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
2468        assert_eq!(total_rows, 3);
2469        let file_col = batches[0]
2470            .column_by_name(RESERVED_COL_NAME_FILE)
2471            .expect("_file column should be present");
2472        let file_col = cast(file_col, &DataType::Utf8).unwrap();
2473        let file_col = file_col.as_any().downcast_ref::<StringArray>().unwrap();
2474        assert_eq!(file_col.value(0), file_path);
2475    }
2476
2477    #[tokio::test]
2478    async fn test_empty_projection_preserves_row_count() {
2479        let tmp_dir = TempDir::new().unwrap();
2480        let dir = tmp_dir.path().to_str().unwrap();
2481        let file_path = write_plain_parquet(dir, "empty_projection.parquet", vec![], vec![]);
2482        let schema = Arc::new(
2483            Schema::builder()
2484                .with_schema_id(1)
2485                .with_fields(vec![
2486                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2487                ])
2488                .build()
2489                .unwrap(),
2490        );
2491        let task = metadata_projection_task(file_path, schema, vec![]);
2492        let (batches, _) = scan_task(task).await;
2493
2494        // A bare COUNT(*)-style empty projection must still report the row count.
2495        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
2496        assert_eq!(total_rows, 3);
2497    }
2498}