Skip to main content

iceberg/scan/
mod.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//! Table scan api.
19
20mod cache;
21use cache::*;
22mod context;
23use context::*;
24mod task;
25
26use std::collections::HashMap;
27use std::future::Future;
28use std::sync::Arc;
29
30use arrow_array::RecordBatch;
31use futures::channel::mpsc::{Sender, channel};
32use futures::stream::BoxStream;
33use futures::{SinkExt, StreamExt, TryStreamExt};
34pub use task::*;
35
36use crate::arrow::ArrowReaderBuilder;
37pub use crate::arrow::{ScanMetrics, ScanResult};
38use crate::delete_file_index::DeleteFileIndex;
39use crate::error::invalid_data;
40use crate::expr::visitors::inclusive_metrics_evaluator::InclusiveMetricsEvaluator;
41use crate::expr::{Bind, BoundPredicate, Predicate};
42use crate::io::FileIO;
43use crate::metadata_columns::{
44    RESERVED_FIELD_ID_PARTITION, get_metadata_field_id, is_metadata_column_name,
45};
46use crate::runtime::Runtime;
47use crate::spec::{DataContentType, Schema, SchemaRef, SnapshotRef, SortOrderRef, StructType};
48use crate::table::Table;
49use crate::util::available_parallelism;
50use crate::util::snapshot::snapshot_id_as_of_time;
51use crate::{Error, ErrorKind, Result};
52
53/// A stream of arrow [`RecordBatch`]es.
54pub type ArrowRecordBatchStream = BoxStream<'static, Result<RecordBatch>>;
55
56/// Resolves a column name to its field ID, honouring the scan's case sensitivity.
57fn resolve_field_id(
58    schema: &Schema,
59    column_name: &str,
60    case_sensitive: bool,
61) -> Result<Option<i32>> {
62    if case_sensitive {
63        Ok(schema.field_id_by_name(column_name))
64    } else {
65        let field = schema.field_by_name_case_insensitive_checked(column_name)?;
66        Ok(field.map(|field| field.id))
67    }
68}
69
70fn collect_scan_field_ids(
71    schema: &Schema,
72    column_names: Option<&[String]>,
73    case_sensitive: bool,
74) -> Result<Vec<i32>> {
75    let Some(column_names) = column_names else {
76        return Ok(schema.as_struct().fields().iter().map(|f| f.id).collect());
77    };
78
79    column_names
80        .iter()
81        .map(|column_name| {
82            if is_metadata_column_name(column_name) {
83                return get_metadata_field_id(column_name);
84            }
85
86            let field_id = resolve_field_id(schema, column_name, case_sensitive)?.ok_or_else(|| {
87                invalid_data!("Column {column_name} not found in table. Schema: {schema}")
88            })?;
89
90            schema
91                .as_struct()
92                .field_by_id(field_id)
93                .ok_or_else(|| {
94                    Error::new(
95                        ErrorKind::FeatureUnsupported,
96                        format!(
97                            "Column {column_name} is not a direct child of schema but a nested field, which is not supported now. Schema: {schema}"
98                        ),
99                    )
100                })?;
101
102            Ok(field_id)
103        })
104        .collect()
105}
106
107fn bind_scan_predicate(
108    schema: &SchemaRef,
109    predicate: Option<&Predicate>,
110    case_sensitive: bool,
111) -> Result<Option<Arc<BoundPredicate>>> {
112    predicate
113        .map(|predicate| predicate.bind(schema.clone(), case_sensitive))
114        .transpose()
115        .map(|predicate| predicate.map(Arc::new))
116}
117
118fn projected_partition_type(
119    table: &Table,
120    schema: &Schema,
121    field_ids: &[i32],
122) -> Result<Option<Arc<StructType>>> {
123    if !field_ids.contains(&RESERVED_FIELD_ID_PARTITION) {
124        return Ok(None);
125    }
126
127    table
128        .metadata()
129        .unified_partition_type(schema)
130        .map(Arc::new)
131        .map(Some)
132}
133
134/// Selects a snapshot by ID or timestamp.
135enum SnapshotSelection {
136    SnapshotId(i64),
137    AsOfTime(i64),
138}
139
140/// Builder to create table scan.
141pub struct TableScanBuilder<'a> {
142    table: &'a Table,
143    // Defaults to none which means select all columns
144    column_names: Option<Vec<String>>,
145    snapshot_selection: Option<SnapshotSelection>,
146    batch_size: Option<usize>,
147    case_sensitive: bool,
148    filter: Option<Predicate>,
149    concurrency_limit_data_files: usize,
150    concurrency_limit_manifest_entries: usize,
151    concurrency_limit_manifest_files: usize,
152    row_group_filtering_enabled: bool,
153    row_selection_enabled: bool,
154    bloom_filter_enabled: bool,
155}
156
157impl<'a> TableScanBuilder<'a> {
158    pub(crate) fn new(table: &'a Table) -> Self {
159        let num_cpus = available_parallelism().get();
160
161        Self {
162            table,
163            column_names: None,
164            snapshot_selection: None,
165            batch_size: None,
166            case_sensitive: true,
167            filter: None,
168            concurrency_limit_data_files: num_cpus,
169            concurrency_limit_manifest_entries: num_cpus,
170            concurrency_limit_manifest_files: num_cpus,
171            row_group_filtering_enabled: true,
172            row_selection_enabled: false,
173            bloom_filter_enabled: false,
174        }
175    }
176
177    /// Sets the desired size of batches in the response
178    /// to something other than the default
179    pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
180        self.batch_size = batch_size;
181        self
182    }
183
184    /// Sets the scan's case sensitivity
185    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
186        self.case_sensitive = case_sensitive;
187        self
188    }
189
190    /// Specifies a predicate to use as a filter
191    pub fn with_filter(mut self, predicate: Predicate) -> Self {
192        // calls rewrite_not to remove Not nodes, which must be absent
193        // when applying the manifest evaluator
194        self.filter = Some(predicate.rewrite_not());
195        self
196    }
197
198    /// Select all columns.
199    pub fn select_all(mut self) -> Self {
200        self.column_names = None;
201        self
202    }
203
204    /// Select empty columns.
205    pub fn select_empty(mut self) -> Self {
206        self.column_names = Some(vec![]);
207        self
208    }
209
210    /// Select some columns of the table.
211    pub fn select(mut self, column_names: impl IntoIterator<Item = impl ToString>) -> Self {
212        self.column_names = Some(
213            column_names
214                .into_iter()
215                .map(|item| item.to_string())
216                .collect(),
217        );
218        self
219    }
220
221    /// Set the snapshot to scan. The scan projects the schema that snapshot was
222    /// written with.
223    ///
224    /// The last call to this or [`Self::as_of_time`] takes precedence.
225    /// If neither is set, the scan reads the current snapshot with the table's
226    /// current schema.
227    pub fn snapshot_id(mut self, snapshot_id: i64) -> Self {
228        self.snapshot_selection = Some(SnapshotSelection::SnapshotId(snapshot_id));
229        self
230    }
231
232    /// Select a snapshot at or before a timestamp in milliseconds since the Unix epoch.
233    ///
234    /// Uses the table's main history, and projects the schema the selected snapshot
235    /// was written with. The last call to this or [`Self::snapshot_id`] takes precedence.
236    ///
237    /// ```
238    /// # use iceberg::TableIdent;
239    /// # use iceberg::io::FileIO;
240    /// # use iceberg::table::StaticTable;
241    /// # #[tokio::main]
242    /// # async fn main() -> iceberg::Result<()> {
243    /// # let location = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata/example_table_metadata_v2.json");
244    /// # let ident = TableIdent::from_strs(["ns", "t"])?;
245    /// # let table = StaticTable::from_metadata_file(location, ident, FileIO::new_with_fs()).await?;
246    /// // The snapshot log has entries at 1515100955770 and 1555100955770.
247    /// let scan = table.scan().as_of_time(1_555_100_955_769).build()?;
248    /// assert_eq!(scan.snapshot().unwrap().snapshot_id(), 3051729675574597004);
249    /// # Ok(())
250    /// # }
251    /// ```
252    pub fn as_of_time(mut self, timestamp_ms: i64) -> Self {
253        self.snapshot_selection = Some(SnapshotSelection::AsOfTime(timestamp_ms));
254        self
255    }
256
257    /// Sets the concurrency limit for both manifest files and manifest
258    /// entries for this scan
259    pub fn with_concurrency_limit(mut self, limit: usize) -> Self {
260        self.concurrency_limit_manifest_files = limit;
261        self.concurrency_limit_manifest_entries = limit;
262        self.concurrency_limit_data_files = limit;
263        self
264    }
265
266    /// Sets the data file concurrency limit for this scan
267    pub fn with_data_file_concurrency_limit(mut self, limit: usize) -> Self {
268        self.concurrency_limit_data_files = limit;
269        self
270    }
271
272    /// Sets the manifest entry concurrency limit for this scan
273    pub fn with_manifest_entry_concurrency_limit(mut self, limit: usize) -> Self {
274        self.concurrency_limit_manifest_entries = limit;
275        self
276    }
277
278    /// Determines whether to enable row group filtering.
279    /// When enabled, if a read is performed with a filter predicate,
280    /// then the metadata for each row group in the parquet file is
281    /// evaluated against the filter predicate and row groups
282    /// that cant contain matching rows will be skipped entirely.
283    ///
284    /// Defaults to enabled, as it generally improves performance or
285    /// keeps it the same, with performance degradation unlikely.
286    pub fn with_row_group_filtering_enabled(mut self, row_group_filtering_enabled: bool) -> Self {
287        self.row_group_filtering_enabled = row_group_filtering_enabled;
288        self
289    }
290
291    /// Determines whether to enable row selection.
292    /// When enabled, if a read is performed with a filter predicate,
293    /// then (for row groups that have not been skipped) the page index
294    /// for each row group in a parquet file is parsed and evaluated
295    /// against the filter predicate to determine if ranges of rows
296    /// within a row group can be skipped, based upon the page-level
297    /// statistics for each column.
298    ///
299    /// Defaults to being disabled. Enabling requires parsing the parquet page
300    /// index, which can be slow enough that parsing the page index outweighs any
301    /// gains from the reduced number of rows that need scanning.
302    /// It is recommended to experiment with partitioning, sorting, row group size,
303    /// page size, and page row limit Iceberg settings on the table being scanned in
304    /// order to get the best performance from using row selection.
305    pub fn with_row_selection_enabled(mut self, row_selection_enabled: bool) -> Self {
306        self.row_selection_enabled = row_selection_enabled;
307        self
308    }
309
310    /// Determines whether to enable bloom filter-based row group filtering.
311    ///
312    /// When enabled, if a read is performed with an equality or IN predicate,
313    /// the bloom filter for relevant columns in each row group is read and
314    /// checked. Row groups where the bloom filter proves the value is absent
315    /// are skipped entirely.
316    ///
317    /// Defaults to disabled, as reading bloom filters requires additional I/O
318    /// per column per row group.
319    pub fn with_bloom_filter_enabled(mut self, bloom_filter_enabled: bool) -> Self {
320        self.bloom_filter_enabled = bloom_filter_enabled;
321        self
322    }
323
324    /// Build the table scan.
325    pub fn build(self) -> Result<TableScan> {
326        let selected_snapshot = match self.snapshot_selection {
327            Some(SnapshotSelection::SnapshotId(id)) => {
328                Some((id, format!("Snapshot with id {id} not found")))
329            }
330            Some(SnapshotSelection::AsOfTime(timestamp_ms)) => {
331                let id = snapshot_id_as_of_time(&self.table.metadata_ref(), timestamp_ms)?;
332                // History outlives expired snapshots; name the timestamp that resolved to `id`.
333                Some((
334                    id,
335                    format!(
336                        "Snapshot with id {id} resolved from timestamp {timestamp_ms} ms not found; it may have expired"
337                    ),
338                ))
339            }
340            None => None,
341        };
342        let is_time_travel = selected_snapshot.is_some();
343        let snapshot = match selected_snapshot {
344            Some((snapshot_id, not_found_message)) => self
345                .table
346                .metadata()
347                .snapshot_by_id(snapshot_id)
348                .ok_or_else(|| invalid_data!(not_found_message))?
349                .clone(),
350            None => {
351                let Some(current_snapshot_id) = self.table.metadata().current_snapshot() else {
352                    return Ok(TableScan {
353                        batch_size: self.batch_size,
354                        column_names: self.column_names,
355                        file_io: self.table.file_io().clone(),
356                        plan_context: None,
357                        concurrency_limit_data_files: self.concurrency_limit_data_files,
358                        concurrency_limit_manifest_entries: self.concurrency_limit_manifest_entries,
359                        concurrency_limit_manifest_files: self.concurrency_limit_manifest_files,
360                        row_group_filtering_enabled: self.row_group_filtering_enabled,
361                        row_selection_enabled: self.row_selection_enabled,
362                        bloom_filter_enabled: self.bloom_filter_enabled,
363                        runtime: self.table.runtime().clone(),
364                    });
365                };
366                current_snapshot_id.clone()
367            }
368        };
369
370        // A scan of the current state uses the table's current schema; time travel uses the
371        // schema its snapshot was written with.
372        let schema = if is_time_travel {
373            snapshot.schema(self.table.metadata())?
374        } else {
375            self.table.metadata().current_schema().clone()
376        };
377        let field_ids =
378            collect_scan_field_ids(&schema, self.column_names.as_deref(), self.case_sensitive)?;
379        let snapshot_bound_predicate =
380            bind_scan_predicate(&schema, self.filter.as_ref(), self.case_sensitive)?;
381        let name_mapping = self
382            .table
383            .metadata()
384            .table_properties()
385            .default_name_mapping()?
386            .map(Arc::new);
387        let unified_partition_type = projected_partition_type(self.table, &schema, &field_ids)?;
388
389        // Precompute the table's sort orders once, keyed by id, so each manifest-file
390        // context carries only this narrow map instead of the full table metadata.
391        let sort_orders = Arc::new(
392            self.table
393                .metadata()
394                .sort_orders_iter()
395                .map(|order| (order.order_id, order.clone()))
396                .collect::<HashMap<i64, SortOrderRef>>(),
397        );
398
399        let plan_context = PlanContext {
400            snapshot,
401            table_metadata: self.table.metadata_ref(),
402            snapshot_schema: schema,
403            case_sensitive: self.case_sensitive,
404            predicate: self.filter.map(Arc::new),
405            snapshot_bound_predicate,
406            object_cache: self.table.object_cache(),
407            field_ids: Arc::new(field_ids),
408            name_mapping,
409            partition_filter_cache: Arc::new(PartitionFilterCache::new()),
410            manifest_evaluator_cache: Arc::new(ManifestEvaluatorCache::new()),
411            expression_evaluator_cache: Arc::new(ExpressionEvaluatorCache::new()),
412            unified_partition_type,
413            sort_orders,
414        };
415
416        Ok(TableScan {
417            batch_size: self.batch_size,
418            column_names: self.column_names,
419            file_io: self.table.file_io().clone(),
420            plan_context: Some(plan_context),
421            concurrency_limit_data_files: self.concurrency_limit_data_files,
422            concurrency_limit_manifest_entries: self.concurrency_limit_manifest_entries,
423            concurrency_limit_manifest_files: self.concurrency_limit_manifest_files,
424            row_group_filtering_enabled: self.row_group_filtering_enabled,
425            row_selection_enabled: self.row_selection_enabled,
426            bloom_filter_enabled: self.bloom_filter_enabled,
427            runtime: self.table.runtime().clone(),
428        })
429    }
430}
431
432/// Table scan.
433#[derive(Debug)]
434pub struct TableScan {
435    /// A [PlanContext], if this table has at least one snapshot, otherwise None.
436    ///
437    /// If this is None, then the scan contains no rows.
438    plan_context: Option<PlanContext>,
439    batch_size: Option<usize>,
440    file_io: FileIO,
441    column_names: Option<Vec<String>>,
442    /// The maximum number of manifest files that will be
443    /// retrieved from [`FileIO`] concurrently
444    concurrency_limit_manifest_files: usize,
445
446    /// The maximum number of [`ManifestEntry`]s that will
447    /// be processed in parallel
448    concurrency_limit_manifest_entries: usize,
449
450    /// The maximum number of [`ManifestEntry`]s that will
451    /// be processed in parallel
452    concurrency_limit_data_files: usize,
453
454    row_group_filtering_enabled: bool,
455    row_selection_enabled: bool,
456    bloom_filter_enabled: bool,
457
458    runtime: Runtime,
459}
460
461impl TableScan {
462    /// Returns a stream of [`FileScanTask`]s.
463    pub async fn plan_files(&self) -> Result<FileScanTaskStream> {
464        self.plan_data_files(|ctx| ctx.into_file_scan_task()).await
465    }
466
467    pub(crate) async fn plan_cow_rewrite_files(
468        &self,
469    ) -> Result<BoxStream<'static, Result<crate::cow_rewrite::CowRewriteFile>>> {
470        self.plan_data_files(|ctx| ctx.into_cow_rewrite_file())
471            .await
472    }
473
474    async fn plan_data_files<T, F, Fut>(
475        &self,
476        build_result: F,
477    ) -> Result<BoxStream<'static, Result<T>>>
478    where
479        T: Send + 'static,
480        F: Fn(ManifestEntryContext) -> Fut + Copy + Send + Sync + 'static,
481        Fut: Future<Output = Result<T>> + Send + 'static,
482    {
483        let Some(plan_context) = self.plan_context.as_ref() else {
484            return Ok(Box::pin(futures::stream::empty()));
485        };
486
487        let concurrency_limit_manifest_files = self.concurrency_limit_manifest_files;
488        let concurrency_limit_manifest_entries = self.concurrency_limit_manifest_entries;
489
490        // used to stream ManifestEntryContexts between stages of the file plan operation
491        let (manifest_entry_data_ctx_tx, manifest_entry_data_ctx_rx) =
492            channel(concurrency_limit_manifest_files);
493        let (manifest_entry_delete_ctx_tx, manifest_entry_delete_ctx_rx) =
494            channel(concurrency_limit_manifest_files);
495
496        // used to stream the planned data file results back to the caller
497        let (planned_file_tx, planned_file_rx) = channel(concurrency_limit_manifest_entries);
498
499        let (delete_file_idx, delete_file_tx) = DeleteFileIndex::new(self.runtime.clone());
500
501        let manifest_list = plan_context.get_manifest_list().await?;
502
503        // get the [`ManifestFile`]s from the [`ManifestList`], filtering out any
504        // whose partitions cannot match this
505        // scan's filter
506        let manifest_file_contexts = plan_context.build_manifest_file_contexts(
507            manifest_list,
508            manifest_entry_data_ctx_tx,
509            delete_file_idx.clone(),
510            manifest_entry_delete_ctx_tx,
511        )?;
512
513        let mut channel_for_manifest_error = planned_file_tx.clone();
514        let mut channel_for_data_manifest_entry_error = planned_file_tx.clone();
515        let mut channel_for_delete_manifest_entry_error = planned_file_tx.clone();
516
517        let rt = self.runtime.clone();
518
519        // Concurrently load all [`Manifest`]s and stream their [`ManifestEntry`]s
520        rt.io().spawn(async move {
521            let result = futures::stream::iter(manifest_file_contexts)
522                .try_for_each_concurrent(concurrency_limit_manifest_files, |ctx| async move {
523                    ctx.fetch_manifest_and_stream_manifest_entries().await
524                })
525                .await;
526
527            if let Err(error) = result {
528                let _ = channel_for_manifest_error.send(Err(error)).await;
529            }
530        });
531
532        // Process the delete file [`ManifestEntry`] stream in parallel
533        {
534            let rt = rt.clone();
535            let rt_inner = rt.clone();
536            rt.cpu().spawn(async move {
537                let result = manifest_entry_delete_ctx_rx
538                    .map(|me_ctx| Ok((me_ctx, delete_file_tx.clone())))
539                    .try_for_each_concurrent(
540                        concurrency_limit_manifest_entries,
541                        |(manifest_entry_context, tx)| {
542                            let rt_inner = rt_inner.clone();
543                            async move {
544                                rt_inner
545                                    .cpu()
546                                    .spawn(async move {
547                                        Self::process_delete_manifest_entry(
548                                            manifest_entry_context,
549                                            tx,
550                                        )
551                                        .await
552                                    })
553                                    .await?
554                            }
555                        },
556                    )
557                    .await;
558
559                if let Err(error) = result {
560                    let _ = channel_for_delete_manifest_entry_error
561                        .send(Err(error))
562                        .await;
563                }
564            });
565        }
566
567        // Process the data file [`ManifestEntry`] stream in parallel
568        {
569            let rt_inner = rt.clone();
570            rt.cpu().spawn(async move {
571                let result = manifest_entry_data_ctx_rx
572                    .map(|me_ctx| Ok((me_ctx, planned_file_tx.clone())))
573                    .try_for_each_concurrent(
574                        concurrency_limit_manifest_entries,
575                        |(manifest_entry_context, tx)| {
576                            let rt_inner = rt_inner.clone();
577                            async move {
578                                rt_inner
579                                    .cpu()
580                                    .spawn(async move {
581                                        Self::process_data_manifest_entry(
582                                            manifest_entry_context,
583                                            tx,
584                                            build_result,
585                                        )
586                                        .await
587                                    })
588                                    .await?
589                            }
590                        },
591                    )
592                    .await;
593
594                if let Err(error) = result {
595                    let _ = channel_for_data_manifest_entry_error.send(Err(error)).await;
596                }
597            });
598        }
599
600        Ok(planned_file_rx.boxed())
601    }
602
603    /// Returns an [`ArrowRecordBatchStream`].
604    pub async fn to_arrow(&self) -> Result<ArrowRecordBatchStream> {
605        let mut arrow_reader_builder =
606            ArrowReaderBuilder::new(self.file_io.clone(), self.runtime.clone())
607                .with_data_file_concurrency_limit(self.concurrency_limit_data_files)
608                .with_row_group_filtering_enabled(self.row_group_filtering_enabled)
609                .with_row_selection_enabled(self.row_selection_enabled)
610                .with_bloom_filter_enabled(self.bloom_filter_enabled);
611
612        if let Some(batch_size) = self.batch_size {
613            arrow_reader_builder = arrow_reader_builder.with_batch_size(batch_size);
614        }
615
616        arrow_reader_builder
617            .build()
618            .read(self.plan_files().await?)
619            .map(|result| result.stream())
620    }
621
622    /// Returns a reference to the column names of the table scan.
623    pub fn column_names(&self) -> Option<&[String]> {
624        self.column_names.as_deref()
625    }
626
627    /// Returns a reference to the snapshot of the table scan.
628    pub fn snapshot(&self) -> Option<&SnapshotRef> {
629        self.plan_context.as_ref().map(|x| &x.snapshot)
630    }
631
632    async fn process_data_manifest_entry<T, F, Fut>(
633        manifest_entry_context: ManifestEntryContext,
634        mut planned_file_tx: Sender<Result<T>>,
635        build_result: F,
636    ) -> Result<()>
637    where
638        F: Fn(ManifestEntryContext) -> Fut + Send + Sync + 'static,
639        Fut: Future<Output = Result<T>> + Send + 'static,
640    {
641        // abort the plan if we encounter a manifest entry for a delete file
642        if manifest_entry_context.manifest_entry.content_type() != DataContentType::Data {
643            return Err(Error::new(
644                ErrorKind::FeatureUnsupported,
645                "Encountered an entry for a delete file in a data file manifest",
646            ));
647        }
648
649        if let Some(ref bound_predicates) = manifest_entry_context.bound_predicates {
650            let BoundPredicates {
651                snapshot_bound_predicate,
652                partition_bound_predicate,
653            } = bound_predicates.as_ref();
654
655            let expression_evaluator_cache =
656                manifest_entry_context.expression_evaluator_cache.as_ref();
657
658            let expression_evaluator = expression_evaluator_cache.get(
659                manifest_entry_context.partition_spec_id,
660                partition_bound_predicate,
661            )?;
662
663            // skip any data file whose partition data indicates that it can't contain
664            // any data that matches this scan's filter
665            if !expression_evaluator.eval(manifest_entry_context.manifest_entry.data_file())? {
666                return Ok(());
667            }
668
669            // skip any data file whose metrics don't match this scan's filter
670            if !InclusiveMetricsEvaluator::eval(
671                snapshot_bound_predicate,
672                manifest_entry_context.manifest_entry.data_file(),
673                false,
674            )? {
675                return Ok(());
676            }
677        }
678
679        // congratulations! the manifest entry has made its way through the
680        // entire plan without getting filtered out. Build the planned file before
681        // sending so delete-file lookup preserves the original scan timing.
682        planned_file_tx
683            .send(Ok(build_result(manifest_entry_context).await?))
684            .await?;
685
686        Ok(())
687    }
688
689    async fn process_delete_manifest_entry(
690        manifest_entry_context: ManifestEntryContext,
691        mut delete_file_ctx_tx: Sender<DeleteFileContext>,
692    ) -> Result<()> {
693        // abort the plan if we encounter a manifest entry that is not for a delete file
694        if manifest_entry_context.manifest_entry.content_type() == DataContentType::Data {
695            return Err(Error::new(
696                ErrorKind::FeatureUnsupported,
697                "Encountered an entry for a data file in a delete manifest",
698            ));
699        }
700
701        if let Some(ref bound_predicates) = manifest_entry_context.bound_predicates {
702            let expression_evaluator_cache =
703                manifest_entry_context.expression_evaluator_cache.as_ref();
704
705            let expression_evaluator = expression_evaluator_cache.get(
706                manifest_entry_context.partition_spec_id,
707                &bound_predicates.partition_bound_predicate,
708            )?;
709
710            // skip any data file whose partition data indicates that it can't contain
711            // any data that matches this scan's filter
712            if !expression_evaluator.eval(manifest_entry_context.manifest_entry.data_file())? {
713                return Ok(());
714            }
715        }
716
717        delete_file_ctx_tx
718            .send(DeleteFileContext {
719                manifest_entry: manifest_entry_context.manifest_entry.clone(),
720                partition_spec_id: manifest_entry_context.partition_spec_id,
721            })
722            .await?;
723
724        Ok(())
725    }
726}
727
728pub(crate) struct BoundPredicates {
729    partition_bound_predicate: BoundPredicate,
730    snapshot_bound_predicate: BoundPredicate,
731}
732
733#[cfg(test)]
734mod tests {
735    //! shared tests for the table scan API
736
737    use std::collections::HashMap;
738    use std::fs;
739    use std::fs::File;
740    use std::sync::Arc;
741
742    use arrow_array::cast::AsArray;
743    use arrow_array::types::Int32Type;
744    use arrow_array::{
745        Array, ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, RecordBatch, RunArray,
746        StringArray,
747    };
748    use futures::{TryStreamExt, stream};
749    use parquet::arrow::ArrowWriter;
750    use uuid::Uuid;
751
752    use super::resolve_field_id;
753    use crate::arrow::ArrowReaderBuilder;
754    use crate::expr::{BoundPredicate, Reference};
755    use crate::io::FileIO;
756    use crate::metadata_columns::{
757        RESERVED_COL_NAME_FILE, RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER,
758        RESERVED_COL_NAME_POS, RESERVED_COL_NAME_SPEC_ID, RESERVED_FIELD_ID_POS,
759    };
760    use crate::scan::{FileScanTask, FileScanTaskDeleteFile};
761    use crate::spec::{
762        DataContentType, DataFileBuilder, DataFileFormat, Datum, Literal, MAIN_BRANCH,
763        ManifestEntry, ManifestListWriter, ManifestStatus, ManifestWriterBuilder, MappedField,
764        NameMapping, NestedField, NullOrder, Operation, PartitionSpec, PrimitiveType, Schema,
765        Snapshot, SortDirection, SortField, SortOrder, Struct, Summary, TableMetadataBuilder,
766        TableProperties, Transform, Type, UnboundPartitionSpec,
767    };
768    use crate::table::Table;
769    use crate::test_utils::scan::{
770        ROWS_PER_FIXTURE_FILE, TableTestFixture, assert_last_updated_seq_all,
771    };
772    use crate::test_utils::test_runtime;
773    use crate::util::snapshot::snapshot_id_as_of_time;
774    use crate::{ErrorKind, TableIdent};
775
776    #[tokio::test]
777    async fn test_table_scan_columns() {
778        let table = TableTestFixture::new().table;
779
780        let table_scan = table.scan().select(["x", "y"]).build().unwrap();
781        assert_eq!(
782            Some(vec!["x".to_string(), "y".to_string()]),
783            table_scan.column_names
784        );
785
786        let table_scan = table
787            .scan()
788            .select(["x", "y"])
789            .select(["z"])
790            .build()
791            .unwrap();
792        assert_eq!(Some(vec!["z".to_string()]), table_scan.column_names);
793    }
794
795    #[tokio::test]
796    async fn test_select_all() {
797        let table = TableTestFixture::new().table;
798
799        let table_scan = table.scan().select_all().build().unwrap();
800        assert!(table_scan.column_names.is_none());
801    }
802
803    #[test]
804    fn test_select_no_exist_column() {
805        let table = TableTestFixture::new().table;
806
807        let table_scan = table.scan().select(["x", "y", "z", "a", "b"]).build();
808        assert!(table_scan.is_err());
809    }
810
811    #[test]
812    fn test_case_sensitive_scan_rejects_mismatched_column_case() {
813        let table = TableTestFixture::new().table;
814
815        // Case sensitivity defaults to true, so "X" must not resolve to "x".
816        assert!(table.scan().select(["X"]).build().is_err());
817        assert!(
818            table
819                .scan()
820                .with_filter(Reference::new("X").greater_than(Datum::long(1)))
821                .build()
822                .is_err()
823        );
824    }
825
826    #[test]
827    fn test_resolve_field_id_case_insensitive_rejects_ambiguous_name() {
828        let schema = Schema::builder()
829            .with_fields(vec![
830                NestedField::optional(1, "id", PrimitiveType::Int).into(),
831                NestedField::optional(2, "ID", PrimitiveType::Int).into(),
832                NestedField::optional(3, "data", PrimitiveType::Int).into(),
833            ])
834            .build()
835            .unwrap();
836
837        assert_eq!(resolve_field_id(&schema, "id", true).unwrap(), Some(1));
838        assert_eq!(resolve_field_id(&schema, "DATA", false).unwrap(), Some(3));
839        let error = resolve_field_id(&schema, "Id", false).unwrap_err();
840        assert_eq!(error.kind(), ErrorKind::DataInvalid);
841        assert!(error.message().contains("case-insensitively"), "{error}");
842    }
843
844    #[test]
845    fn test_case_insensitive_scan_with_ambiguous_schema() {
846        let fixture = TableTestFixture::new();
847        let mut metadata = fixture.table.metadata().clone();
848        let schema = Schema::builder()
849            .with_schema_id(metadata.current_schema_id())
850            .with_fields(vec![
851                NestedField::optional(1, "id", PrimitiveType::Int).into(),
852                NestedField::optional(2, "ID", PrimitiveType::Int).into(),
853                NestedField::optional(3, "data", PrimitiveType::Int).into(),
854            ])
855            .build()
856            .unwrap();
857        metadata
858            .schemas
859            .insert(metadata.current_schema_id(), Arc::new(schema));
860        let table = fixture.table.with_metadata(Arc::new(metadata));
861
862        table
863            .scan()
864            .with_case_sensitive(false)
865            .select(["DATA"])
866            .build()
867            .unwrap();
868        let error = table
869            .scan()
870            .with_case_sensitive(false)
871            .select(["Id"])
872            .build()
873            .unwrap_err();
874        assert_eq!(error.kind(), ErrorKind::DataInvalid);
875        assert!(error.message().contains("case-insensitively"), "{error}");
876    }
877
878    #[tokio::test]
879    async fn test_case_insensitive_scan_resolves_mismatched_column_case() {
880        let mut fixture = TableTestFixture::new();
881        fixture.setup_manifest_files().await;
882
883        // The schema declares lowercase "x" and "z"; a case-insensitive scan must
884        // resolve the upper-cased names in both the projection and the filter.
885        let table_scan = fixture
886            .table
887            .scan()
888            .with_case_sensitive(false)
889            .select(["X", "Z"])
890            .with_filter(Reference::new("Y").greater_than(Datum::long(1)))
891            .build()
892            .unwrap();
893
894        let batches: Vec<_> = table_scan
895            .to_arrow()
896            .await
897            .unwrap()
898            .try_collect()
899            .await
900            .unwrap();
901
902        assert_eq!(batches[0].num_columns(), 2);
903        assert_eq!(
904            batches[0]
905                .column_by_name("x")
906                .unwrap()
907                .as_any()
908                .downcast_ref::<Int64Array>()
909                .unwrap()
910                .value(0),
911            1
912        );
913        assert_eq!(
914            batches[0]
915                .column_by_name("z")
916                .unwrap()
917                .as_any()
918                .downcast_ref::<Int64Array>()
919                .unwrap()
920                .value(0),
921            3
922        );
923    }
924
925    #[tokio::test]
926    async fn test_table_scan_default_snapshot_id() {
927        let table = TableTestFixture::new().table;
928
929        let table_scan = table.scan().build().unwrap();
930        assert_eq!(
931            table.metadata().current_snapshot().unwrap().snapshot_id(),
932            table_scan.snapshot().unwrap().snapshot_id()
933        );
934    }
935
936    #[test]
937    fn test_table_scan_non_exist_snapshot_id() {
938        let table = TableTestFixture::new().table;
939
940        let table_scan = table.scan().snapshot_id(1024).build();
941        assert!(table_scan.is_err());
942    }
943
944    #[tokio::test]
945    async fn test_table_scan_with_snapshot_id() {
946        let table = TableTestFixture::new().table;
947
948        let table_scan = table
949            .scan()
950            .snapshot_id(3051729675574597004)
951            .with_row_selection_enabled(true)
952            .build()
953            .unwrap();
954        assert_eq!(
955            table_scan.snapshot().unwrap().snapshot_id(),
956            3051729675574597004
957        );
958    }
959
960    #[test]
961    fn test_table_scan_as_of_time_after_rollback() {
962        for elapsed_ms in [0, 1000] {
963            let table = TableTestFixture::new().table;
964            let mut metadata = table.metadata().clone();
965            let original = metadata.history()[0].snapshot_id;
966            let previous = metadata.history()[1].clone();
967            let rollback_time = previous.timestamp_ms + elapsed_ms;
968            metadata.snapshot_log.push(crate::spec::SnapshotLog {
969                timestamp_ms: rollback_time,
970                snapshot_id: original,
971            });
972            metadata.current_snapshot_id = Some(original);
973            metadata.refs.get_mut(MAIN_BRANCH).unwrap().snapshot_id = original;
974            let table = table.with_metadata(Arc::new(metadata));
975            let scan = table.scan().as_of_time(rollback_time).build().unwrap();
976            // A rollback at the same timestamp keeps the earlier history entry.
977            let expected = if elapsed_ms == 0 {
978                previous.snapshot_id
979            } else {
980                original
981            };
982            assert_eq!(scan.snapshot().unwrap().snapshot_id(), expected);
983        }
984    }
985
986    #[test]
987    fn test_table_scan_as_of_time_rejects_empty_history() {
988        let table = TableTestFixture::new_empty().table;
989        assert!(table.scan().build().unwrap().snapshot().is_none());
990        let err = table.scan().as_of_time(0).build().unwrap_err();
991        assert_eq!(err.kind(), ErrorKind::DataInvalid);
992        assert!(err.message().contains("No snapshot history"));
993    }
994
995    #[test]
996    fn test_table_scan_as_of_time_rejects_before_history() {
997        let table = TableTestFixture::new().table;
998        let before = table.metadata().history()[0].timestamp_ms - 1;
999        let err = table.scan().as_of_time(before).build().unwrap_err();
1000        assert_eq!(err.kind(), ErrorKind::DataInvalid);
1001        assert!(err.message().contains(&before.to_string()));
1002    }
1003
1004    #[test]
1005    fn test_table_scan_as_of_time_rejects_all_qualifying_snapshots_expired() {
1006        let table = TableTestFixture::new_with_deep_history().table;
1007        let mut metadata = table.metadata().clone();
1008        let entry = metadata.history()[3].clone();
1009        let expired_ids: Vec<_> = metadata
1010            .history()
1011            .iter()
1012            .filter(|log| log.timestamp_ms <= entry.timestamp_ms)
1013            .map(|log| log.snapshot_id)
1014            .collect();
1015        assert_eq!(expired_ids.len(), 4);
1016        for snapshot_id in expired_ids {
1017            metadata.snapshots.remove(&snapshot_id);
1018        }
1019        let table = table.with_metadata(Arc::new(metadata));
1020        // History resolution succeeds even though every qualifying snapshot expired.
1021        assert_eq!(
1022            snapshot_id_as_of_time(&table.metadata_ref(), entry.timestamp_ms).unwrap(),
1023            entry.snapshot_id
1024        );
1025        assert!(table.scan().build().unwrap().snapshot().is_some());
1026        let err = table
1027            .scan()
1028            .as_of_time(entry.timestamp_ms)
1029            .build()
1030            .unwrap_err();
1031        assert_eq!(err.kind(), ErrorKind::DataInvalid);
1032        assert_eq!(
1033            err.message(),
1034            format!(
1035                "Snapshot with id {} resolved from timestamp {} ms not found; it may have expired",
1036                entry.snapshot_id, entry.timestamp_ms
1037            )
1038        );
1039    }
1040
1041    #[test]
1042    fn test_table_scan_last_snapshot_selection_wins() {
1043        let table = TableTestFixture::new().table;
1044        let first = &table.metadata().history()[0];
1045        let last = &table.metadata().history()[1];
1046        let cases = [
1047            (
1048                table
1049                    .scan()
1050                    .snapshot_id(last.snapshot_id)
1051                    .as_of_time(first.timestamp_ms),
1052                first.snapshot_id,
1053            ),
1054            (
1055                table
1056                    .scan()
1057                    .as_of_time(first.timestamp_ms)
1058                    .snapshot_id(last.snapshot_id),
1059                last.snapshot_id,
1060            ),
1061            // An overwritten selector is not resolved or validated.
1062            (
1063                table.scan().snapshot_id(-1).as_of_time(first.timestamp_ms),
1064                first.snapshot_id,
1065            ),
1066            (
1067                table
1068                    .scan()
1069                    .as_of_time(i64::MIN)
1070                    .snapshot_id(last.snapshot_id),
1071                last.snapshot_id,
1072            ),
1073            (
1074                table
1075                    .scan()
1076                    .snapshot_id(first.snapshot_id)
1077                    .snapshot_id(last.snapshot_id),
1078                last.snapshot_id,
1079            ),
1080            (
1081                table
1082                    .scan()
1083                    .as_of_time(i64::MAX)
1084                    .as_of_time(first.timestamp_ms),
1085                first.snapshot_id,
1086            ),
1087        ];
1088        for (builder, expected) in cases {
1089            let scan = builder.build().unwrap();
1090            assert_eq!(scan.snapshot().unwrap().snapshot_id(), expected);
1091        }
1092    }
1093
1094    #[test]
1095    fn test_table_scan_invalid_last_snapshot_selection() {
1096        let table = TableTestFixture::new().table;
1097        let first = &table.metadata().history()[0];
1098        let cases = [
1099            (
1100                table
1101                    .scan()
1102                    .snapshot_id(first.snapshot_id)
1103                    .as_of_time(first.timestamp_ms - 1),
1104                "No snapshot history",
1105            ),
1106            (
1107                table.scan().as_of_time(first.timestamp_ms).snapshot_id(-1),
1108                "Snapshot with id -1 not found",
1109            ),
1110        ];
1111        for (builder, expected) in cases {
1112            let err = builder.build().unwrap_err();
1113            assert_eq!(err.kind(), ErrorKind::DataInvalid);
1114            assert!(err.message().contains(expected));
1115        }
1116    }
1117
1118    #[test]
1119    fn test_table_scan_as_of_time_uses_snapshot_schema() {
1120        for select_current in [false, true] {
1121            let table = TableTestFixture::new().table;
1122            let mut metadata = table.metadata().clone();
1123            let entry = metadata.history()[usize::from(select_current)].clone();
1124            // Simulate a schema-only update after this snapshot: schema 0 has x only,
1125            // while the current table schema also contains y and other columns.
1126            Arc::make_mut(metadata.snapshots.get_mut(&entry.snapshot_id).unwrap()).schema_id =
1127                Some(0);
1128            let table = table.with_metadata(Arc::new(metadata));
1129            let scan = table
1130                .scan()
1131                .as_of_time(entry.timestamp_ms)
1132                .select(["x"])
1133                .with_filter(Reference::new("x").greater_than(Datum::long(0)))
1134                .build()
1135                .unwrap();
1136            let context = scan.plan_context.as_ref().unwrap();
1137            assert_eq!(context.snapshot_schema.schema_id(), 0);
1138            assert!(context.snapshot_bound_predicate.is_some());
1139            assert!(
1140                table
1141                    .scan()
1142                    .as_of_time(entry.timestamp_ms)
1143                    .select(["y"])
1144                    .build()
1145                    .is_err()
1146            );
1147            assert!(
1148                table
1149                    .scan()
1150                    .as_of_time(entry.timestamp_ms)
1151                    .with_filter(Reference::new("y").greater_than(Datum::long(0)))
1152                    .build()
1153                    .is_err()
1154            );
1155        }
1156    }
1157
1158    #[test]
1159    fn test_table_scan_as_of_time_schema_compatibility() {
1160        let table = TableTestFixture::new().table;
1161        let entry = table.metadata().history()[0].clone();
1162        // Older snapshots may omit schema-id and use the current-schema fallback.
1163        assert!(
1164            table
1165                .metadata()
1166                .snapshot_by_id(entry.snapshot_id)
1167                .unwrap()
1168                .schema_id()
1169                .is_none()
1170        );
1171        let scan = table.scan().as_of_time(entry.timestamp_ms).build().unwrap();
1172        assert_eq!(
1173            scan.plan_context.as_ref().unwrap().snapshot_schema,
1174            *table.metadata().current_schema()
1175        );
1176
1177        let mut metadata = table.metadata().clone();
1178        Arc::make_mut(metadata.snapshots.get_mut(&entry.snapshot_id).unwrap()).schema_id =
1179            Some(1234);
1180        let table = table.with_metadata(Arc::new(metadata));
1181        let err = table
1182            .scan()
1183            .as_of_time(entry.timestamp_ms)
1184            .build()
1185            .unwrap_err();
1186        assert_eq!(err.kind(), ErrorKind::DataInvalid);
1187        assert!(err.message().contains("Schema with id 1234 not found"));
1188    }
1189
1190    #[tokio::test]
1191    async fn test_table_scan_as_of_time_reads_historical_rows() {
1192        let mut fixture = TableTestFixture::new();
1193        fixture.setup_manifest_files().await;
1194        let entry = fixture.table.metadata().history()[0].clone();
1195
1196        // The older snapshot has only x, while the current snapshot has eight columns.
1197        let mut metadata = fixture.table.metadata().clone();
1198        Arc::make_mut(metadata.snapshots.get_mut(&entry.snapshot_id).unwrap()).schema_id = Some(0);
1199        fixture.table = fixture.table.with_metadata(Arc::new(metadata));
1200        let snapshot = fixture
1201            .table
1202            .metadata()
1203            .snapshot_by_id(entry.snapshot_id)
1204            .unwrap();
1205        let schema = snapshot.schema(fixture.table.metadata()).unwrap();
1206        let arrow_schema = Arc::new(crate::arrow::schema_to_arrow_schema(&schema).unwrap());
1207
1208        // Write distinct historical data: x = 100, versus x = 1 in the current files.
1209        let path = format!("{}/historical.parquet", fixture.table_location);
1210        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![Arc::new(Int64Array::from(
1211            vec![100],
1212        ))])
1213        .unwrap();
1214        let mut writer =
1215            ArrowWriter::try_new(File::create(&path).unwrap(), arrow_schema, None).unwrap();
1216        writer.write(&batch).unwrap();
1217        writer.close().unwrap();
1218
1219        let mut manifest_writer = ManifestWriterBuilder::new(
1220            fixture.next_manifest_file(),
1221            Some(snapshot.snapshot_id()),
1222            schema,
1223            fixture
1224                .table
1225                .metadata()
1226                .default_partition_spec()
1227                .as_ref()
1228                .clone(),
1229        )
1230        .build_v2_data();
1231        manifest_writer
1232            .add_entry(
1233                ManifestEntry::builder()
1234                    .status(ManifestStatus::Added)
1235                    .data_file(
1236                        DataFileBuilder::default()
1237                            .partition_spec_id(0)
1238                            .content(DataContentType::Data)
1239                            .file_format(DataFileFormat::Parquet)
1240                            .file_size_in_bytes(fs::metadata(&path).unwrap().len())
1241                            .file_path(path)
1242                            .record_count(1)
1243                            .partition(Struct::from_iter([Some(Literal::long(100))]))
1244                            .build()
1245                            .unwrap(),
1246                    )
1247                    .build(),
1248            )
1249            .unwrap();
1250        let manifest = manifest_writer.write_manifest_file().await.unwrap();
1251        let output = fixture
1252            .table
1253            .file_io()
1254            .new_output(snapshot.manifest_list())
1255            .unwrap()
1256            .writer()
1257            .await
1258            .unwrap();
1259        let mut list_writer = ManifestListWriter::v2(
1260            output,
1261            snapshot.snapshot_id(),
1262            snapshot.parent_snapshot_id(),
1263            snapshot.sequence_number(),
1264        );
1265        list_writer.add_manifests([manifest].into_iter()).unwrap();
1266        list_writer.close().await.unwrap();
1267
1268        let table = fixture.table;
1269        let [by_time, by_id] = [
1270            table.scan().as_of_time(entry.timestamp_ms),
1271            table.scan().snapshot_id(entry.snapshot_id),
1272        ]
1273        .map(|scan| {
1274            scan.with_filter(Reference::new("x").greater_than_or_equal_to(Datum::long(100)))
1275                .build()
1276                .unwrap()
1277        });
1278        let current = table.scan().build().unwrap();
1279        let mut planned_files = Vec::new();
1280        let mut rows = Vec::new();
1281        let mut schemas = Vec::new();
1282        for scan in [by_time, by_id, current] {
1283            let mut tasks: Vec<_> = scan
1284                .plan_files()
1285                .await
1286                .unwrap()
1287                .try_collect()
1288                .await
1289                .unwrap();
1290            tasks.sort_by_key(|task| task.data_file_path().to_string());
1291            planned_files.push(tasks);
1292            let batches: Vec<_> = scan.to_arrow().await.unwrap().try_collect().await.unwrap();
1293            assert!(!batches.is_empty());
1294            schemas.push(batches[0].schema());
1295            let mut values: Vec<i64> = batches
1296                .iter()
1297                .flat_map(|batch| {
1298                    batch
1299                        .column_by_name("x")
1300                        .unwrap()
1301                        .as_any()
1302                        .downcast_ref::<Int64Array>()
1303                        .unwrap()
1304                        .values()
1305                        .iter()
1306                        .copied()
1307                })
1308                .collect();
1309            values.sort_unstable();
1310            rows.push(values);
1311        }
1312        assert!(!planned_files[0].is_empty());
1313        assert_eq!(planned_files[0], planned_files[1]);
1314        assert_eq!(rows[0], vec![100]);
1315        assert_eq!(rows[0], rows[1]);
1316        // The current snapshot contains two live fixture files.
1317        assert_eq!(rows[2], vec![1; 2 * ROWS_PER_FIXTURE_FILE]);
1318        assert_eq!(schemas[0], schemas[1]);
1319        assert_eq!(schemas[0].fields().len(), 1);
1320        assert_eq!(schemas[2].fields().len(), 8);
1321    }
1322
1323    /// A scan of the current state reads the current snapshot with the table's
1324    /// current schema, not the older one that snapshot was written with.
1325    #[test]
1326    fn test_scan_of_current_state_uses_current_schema() {
1327        let table = TableTestFixture::new_with_column_added_after_last_write().table;
1328
1329        let table_scan = table.scan().build().unwrap();
1330        assert_eq!(table_scan.snapshot(), table.metadata().current_snapshot());
1331        assert_eq!(
1332            table_scan.plan_context.as_ref().unwrap().snapshot_schema,
1333            *table.metadata().current_schema()
1334        );
1335    }
1336
1337    /// A scan pinned to a snapshot uses the schema that snapshot was written
1338    /// with, even when it is the current snapshot.
1339    #[test]
1340    fn test_time_travel_scan_uses_snapshot_schema() {
1341        let table = TableTestFixture::new_with_column_added_after_last_write().table;
1342        let snapshot = table.metadata().current_snapshot().unwrap();
1343
1344        let table_scan = table
1345            .scan()
1346            .snapshot_id(snapshot.snapshot_id())
1347            .build()
1348            .unwrap();
1349        assert_eq!(table_scan.snapshot(), Some(snapshot));
1350        assert_eq!(
1351            table_scan.plan_context.as_ref().unwrap().snapshot_schema,
1352            snapshot.schema(table.metadata()).unwrap()
1353        );
1354    }
1355
1356    fn table_with_property(key: &str, value: &str) -> Table {
1357        let fixture = TableTestFixture::new();
1358        let mut metadata = fixture.table.metadata().clone();
1359        metadata
1360            .properties
1361            .insert(key.to_string(), value.to_string());
1362        Table::builder()
1363            .metadata(metadata)
1364            .identifier(fixture.table.identifier().clone())
1365            .file_io(fixture.table.file_io().clone())
1366            .metadata_location(fixture.table.metadata_location().unwrap().to_string())
1367            .runtime(test_runtime())
1368            .build()
1369            .unwrap()
1370    }
1371
1372    #[test]
1373    fn test_table_scan_without_name_mapping_property() {
1374        let table = TableTestFixture::new().table;
1375
1376        let table_scan = table.scan().build().unwrap();
1377        assert!(
1378            table_scan
1379                .plan_context
1380                .as_ref()
1381                .unwrap()
1382                .name_mapping
1383                .is_none()
1384        );
1385    }
1386
1387    #[test]
1388    fn test_table_scan_with_name_mapping_property() {
1389        let mapping_json = r#"[{"field-id":1,"names":["id","record_id"]}]"#;
1390        let table =
1391            table_with_property(TableProperties::PROPERTY_DEFAULT_NAME_MAPPING, mapping_json);
1392
1393        let table_scan = table.scan().build().unwrap();
1394        let mapping = table_scan
1395            .plan_context
1396            .as_ref()
1397            .unwrap()
1398            .name_mapping
1399            .as_ref()
1400            .expect("name_mapping should be parsed from the table property");
1401        let fields = mapping.fields();
1402        assert_eq!(fields.len(), 1);
1403        assert_eq!(fields[0].field_id(), Some(1));
1404        assert_eq!(fields[0].names(), &[
1405            "id".to_string(),
1406            "record_id".to_string()
1407        ]);
1408    }
1409
1410    #[test]
1411    fn test_table_scan_with_malformed_name_mapping_property() {
1412        let table = table_with_property(
1413            TableProperties::PROPERTY_DEFAULT_NAME_MAPPING,
1414            "{ not valid json",
1415        );
1416
1417        let err = table
1418            .scan()
1419            .build()
1420            .expect_err("malformed name mapping should fail to parse");
1421        assert_eq!(err.kind(), ErrorKind::DataInvalid);
1422    }
1423
1424    #[tokio::test]
1425    async fn test_plan_files_carries_name_mapping_into_file_scan_task() {
1426        let mut fixture = TableTestFixture::new();
1427        fixture.setup_manifest_files().await;
1428
1429        let mapping_json = r#"[{"field-id":1,"names":["id","record_id"]}]"#;
1430        let mut metadata = fixture.table.metadata().clone();
1431        metadata.properties.insert(
1432            TableProperties::PROPERTY_DEFAULT_NAME_MAPPING.to_string(),
1433            mapping_json.to_string(),
1434        );
1435        let table = Table::builder()
1436            .metadata(metadata)
1437            .identifier(fixture.table.identifier().clone())
1438            .file_io(fixture.table.file_io().clone())
1439            .metadata_location(fixture.table.metadata_location().unwrap().to_string())
1440            .runtime(test_runtime())
1441            .build()
1442            .unwrap();
1443
1444        let tasks: Vec<_> = table
1445            .scan()
1446            .build()
1447            .unwrap()
1448            .plan_files()
1449            .await
1450            .unwrap()
1451            .try_collect()
1452            .await
1453            .unwrap();
1454
1455        assert!(!tasks.is_empty(), "expected at least one FileScanTask");
1456        for task in &tasks {
1457            let mapping = task
1458                .name_mapping()
1459                .expect("name_mapping should reach the FileScanTask");
1460            assert_eq!(mapping.fields().len(), 1);
1461            assert_eq!(mapping.fields()[0].field_id(), Some(1));
1462        }
1463    }
1464
1465    #[tokio::test]
1466    async fn test_plan_files_carries_sort_order_into_file_scan_task() {
1467        let mut fixture = TableTestFixture::new();
1468
1469        // Inject the reserved unsorted order (id 0) inline rather than editing the shared
1470        // testdata fixture, so the id-0 file below exercises the `!is_unsorted()` filter
1471        // branch instead of the `and_then` short-circuit an absent entry would take.
1472        let mut metadata = fixture.table.metadata().clone();
1473        metadata
1474            .sort_orders
1475            .insert(0, Arc::new(SortOrder::unsorted_order()));
1476        fixture.table = fixture.table.with_metadata(Arc::new(metadata));
1477
1478        let expected_sort_order = fixture
1479            .table
1480            .metadata()
1481            .sort_order_by_id(3)
1482            .unwrap()
1483            .clone();
1484
1485        // sort_order_ids: resolvable (3), absent, unresolvable (99), reserved unsorted (0).
1486        fixture
1487            .setup_manifest_files_with_sort_order_ids([Some(3), None, Some(99), Some(0)])
1488            .await;
1489
1490        let tasks: Vec<_> = fixture
1491            .table
1492            .scan()
1493            .build()
1494            .unwrap()
1495            .plan_files()
1496            .await
1497            .unwrap()
1498            .try_collect()
1499            .await
1500            .unwrap();
1501
1502        assert_eq!(tasks.len(), 4, "expected all four FileScanTasks");
1503
1504        // Aggregates catch a systemic regression (every entry resolving to id 3, or
1505        // resolution dropping entirely) that the per-file checks below would each still pass.
1506        assert_eq!(
1507            tasks.iter().filter(|t| t.sort_order().is_some()).count(),
1508            1,
1509            "exactly one file resolves to a sort order"
1510        );
1511        assert_eq!(
1512            tasks.iter().filter(|t| t.sort_order_id().is_some()).count(),
1513            3,
1514            "three files carry a raw sort_order_id"
1515        );
1516
1517        let resolved = tasks
1518            .iter()
1519            .find(|t| t.data_file_path().ends_with("1.parquet"))
1520            .unwrap();
1521        assert_eq!(resolved.sort_order_id(), Some(3));
1522        assert_eq!(
1523            resolved.sort_order(),
1524            Some(&expected_sort_order),
1525            "sort_order_id 3 should resolve to the table's sort order at id 3"
1526        );
1527
1528        let missing = tasks
1529            .iter()
1530            .find(|t| t.data_file_path().ends_with("2.parquet"))
1531            .unwrap();
1532        assert_eq!(missing.sort_order_id(), None);
1533        assert!(
1534            missing.sort_order().is_none(),
1535            "a file with no sort_order_id carries no sort_order"
1536        );
1537
1538        let unresolvable = tasks
1539            .iter()
1540            .find(|t| t.data_file_path().ends_with("3.parquet"))
1541            .unwrap();
1542        assert_eq!(
1543            unresolvable.sort_order_id(),
1544            Some(99),
1545            "the raw id is preserved even when it does not resolve"
1546        );
1547        assert!(
1548            unresolvable.sort_order().is_none(),
1549            "an unresolvable sort_order_id resolves to no sort_order"
1550        );
1551
1552        let unsorted = tasks
1553            .iter()
1554            .find(|t| t.data_file_path().ends_with("4.parquet"))
1555            .unwrap();
1556        assert_eq!(unsorted.sort_order_id(), Some(0));
1557        assert!(
1558            unsorted.sort_order().is_none(),
1559            "the reserved unsorted order (id 0) resolves to no sort_order"
1560        );
1561    }
1562
1563    #[tokio::test]
1564    async fn test_plan_files_on_table_without_any_snapshots() {
1565        let table = TableTestFixture::new_empty().table;
1566        let batch_stream = table.scan().build().unwrap().to_arrow().await.unwrap();
1567        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
1568        assert!(batches.is_empty());
1569    }
1570
1571    #[tokio::test]
1572    async fn test_plan_files_no_deletions() {
1573        let mut fixture = TableTestFixture::new();
1574        fixture.setup_manifest_files().await;
1575
1576        // Create table scan for current snapshot and plan files
1577        let table_scan = fixture
1578            .table
1579            .scan()
1580            .with_row_selection_enabled(true)
1581            .build()
1582            .unwrap();
1583
1584        let mut tasks = table_scan
1585            .plan_files()
1586            .await
1587            .unwrap()
1588            .try_fold(vec![], |mut acc, task| async move {
1589                acc.push(task);
1590                Ok(acc)
1591            })
1592            .await
1593            .unwrap();
1594
1595        assert_eq!(tasks.len(), 2);
1596
1597        tasks.sort_by_key(|t| t.data_file_path().to_string());
1598
1599        // Check first task is added data file
1600        assert_eq!(
1601            tasks[0].data_file_path(),
1602            format!("{}/1.parquet", &fixture.table_location)
1603        );
1604
1605        // Check second task is existing data file
1606        assert_eq!(
1607            tasks[1].data_file_path(),
1608            format!("{}/3.parquet", &fixture.table_location)
1609        );
1610    }
1611
1612    #[tokio::test]
1613    async fn test_scan_widens_old_partition_so_pruning_and_deletes_match() {
1614        // Guards the reader-side normalization through a real scan: a data manifest
1615        // written when the partition source `x` was `int` must have its `int(7)`
1616        // partition widened to `long(7)` during planning, so (a) a `x = 7` (long)
1617        // filter keeps the file and (b) the equality delete keyed by `long(7)`
1618        // attaches. Without the normalization the int/long mismatch drops the delete
1619        // (and prunes the file under the filter).
1620        let mut fixture = TableTestFixture::new();
1621        fixture.setup_promoted_partition_with_eq_delete(false).await;
1622
1623        let tasks: Vec<FileScanTask> = fixture
1624            .table
1625            .scan()
1626            .build()
1627            .unwrap()
1628            .plan_files()
1629            .await
1630            .unwrap()
1631            .try_collect()
1632            .await
1633            .unwrap();
1634        assert_eq!(tasks.len(), 1);
1635        assert_eq!(
1636            tasks[0].deletes().len(),
1637            1,
1638            "equality delete must attach after widening the old int partition to long"
1639        );
1640        assert_eq!(
1641            tasks[0].partition(),
1642            Some(&Struct::from_iter([Some(Literal::long(7))])),
1643            "the widened long(7) tuple must reach FileScanTask::partition()"
1644        );
1645
1646        let tasks: Vec<FileScanTask> = fixture
1647            .table
1648            .scan()
1649            .with_filter(Reference::new("x").equal_to(Datum::long(7)))
1650            .build()
1651            .unwrap()
1652            .plan_files()
1653            .await
1654            .unwrap()
1655            .try_collect()
1656            .await
1657            .unwrap();
1658        assert_eq!(
1659            tasks.len(),
1660            1,
1661            "filter on the promoted type must not prune the file"
1662        );
1663        assert_eq!(tasks[0].deletes().len(), 1);
1664
1665        let tasks: Vec<FileScanTask> = fixture
1666            .table
1667            .scan()
1668            .with_filter(Reference::new("x").equal_to(Datum::long(8)))
1669            .build()
1670            .unwrap()
1671            .plan_files()
1672            .await
1673            .unwrap()
1674            .try_collect()
1675            .await
1676            .unwrap();
1677        assert!(
1678            tasks.is_empty(),
1679            "a file in partition x=7 must be pruned by an x=8 filter"
1680        );
1681    }
1682
1683    #[tokio::test]
1684    async fn test_scan_widens_old_partition_with_pre_promotion_delete() {
1685        // Data file and equality delete both written before the promotion (int
1686        // partition). They matched on main; after both sides widen to long they must
1687        // still match, so the delete stays attached.
1688        let mut fixture = TableTestFixture::new();
1689        fixture.setup_promoted_partition_with_eq_delete(true).await;
1690
1691        let tasks: Vec<FileScanTask> = fixture
1692            .table
1693            .scan()
1694            .build()
1695            .unwrap()
1696            .plan_files()
1697            .await
1698            .unwrap()
1699            .try_collect()
1700            .await
1701            .unwrap();
1702        assert_eq!(tasks.len(), 1);
1703        assert_eq!(tasks[0].deletes().len(), 1);
1704    }
1705
1706    #[tokio::test]
1707    async fn test_scan_widens_old_partition_so_deletion_vector_matches() {
1708        // A v3 deletion vector written after the promotion (long partition) references
1709        // an older data file (int partition). The widening must make the partitions
1710        // agree so the DV attaches, instead of failing the DV partition check.
1711        let mut fixture = TableTestFixture::new();
1712        fixture.setup_promoted_partition_with_dv().await;
1713
1714        let tasks: Vec<FileScanTask> = fixture
1715            .table
1716            .scan()
1717            .build()
1718            .unwrap()
1719            .plan_files()
1720            .await
1721            .unwrap()
1722            .try_collect()
1723            .await
1724            .unwrap();
1725        assert_eq!(tasks.len(), 1);
1726        assert_eq!(tasks[0].deletes().len(), 1);
1727    }
1728
1729    #[tokio::test]
1730    async fn test_scan_keeps_eq_delete_after_dropping_promoted_source() {
1731        // Partition source `x` promoted int -> long, then dropped. The data file (int)
1732        // and its equality delete (long) must still normalize to the same long partition
1733        // so the delete stays attached; otherwise it silently detaches.
1734        let mut fixture = TableTestFixture::new();
1735        fixture.setup_promoted_partition_with_eq_delete(false).await;
1736        fixture.drop_partition_source(1);
1737        let table = fixture.table;
1738
1739        let tasks: Vec<FileScanTask> = table
1740            .scan()
1741            .build()
1742            .unwrap()
1743            .plan_files()
1744            .await
1745            .unwrap()
1746            .try_collect()
1747            .await
1748            .unwrap();
1749        assert_eq!(tasks.len(), 1);
1750        assert_eq!(tasks[0].deletes().len(), 1);
1751    }
1752
1753    #[tokio::test]
1754    async fn test_scan_keeps_deletion_vector_after_dropping_promoted_source() {
1755        // Same as above for a deletion vector: without widening the dropped numeric
1756        // source the DV partition check errors (long(7) vs int(7)).
1757        let mut fixture = TableTestFixture::new();
1758        fixture.setup_promoted_partition_with_dv().await;
1759        fixture.drop_partition_source(1);
1760        let table = fixture.table;
1761
1762        let tasks: Vec<FileScanTask> = table
1763            .scan()
1764            .build()
1765            .unwrap()
1766            .plan_files()
1767            .await
1768            .unwrap()
1769            .try_collect()
1770            .await
1771            .unwrap();
1772        assert_eq!(tasks.len(), 1);
1773        assert_eq!(tasks[0].deletes().len(), 1);
1774    }
1775
1776    #[tokio::test]
1777    async fn test_scan_keeps_eq_delete_when_dropped_source_is_date() {
1778        // A promoted numeric source `x` sits beside a dropped `date` source `d`. The gate
1779        // fires on `x`'s int -> long widening, but `d` (stored as an int) must be copied
1780        // through, not widened to long, or the data file's partition diverges from the
1781        // equality delete's and detaches it.
1782        let mut fixture = TableTestFixture::new();
1783        fixture
1784            .setup_promoted_partition_with_eq_delete_and_dropped_date()
1785            .await;
1786
1787        let tasks: Vec<FileScanTask> = fixture
1788            .table
1789            .scan()
1790            .build()
1791            .unwrap()
1792            .plan_files()
1793            .await
1794            .unwrap()
1795            .try_collect()
1796            .await
1797            .unwrap();
1798        assert_eq!(tasks.len(), 1);
1799        assert_eq!(tasks[0].deletes().len(), 1);
1800    }
1801
1802    #[tokio::test]
1803    async fn test_plan_files_carries_row_lineage_into_file_scan_task() {
1804        let mut fixture = TableTestFixture::new();
1805        fixture.setup_manifest_files().await;
1806
1807        let mut tasks: Vec<_> = fixture
1808            .table
1809            .scan()
1810            .build()
1811            .unwrap()
1812            .plan_files()
1813            .await
1814            .unwrap()
1815            .try_collect()
1816            .await
1817            .unwrap();
1818
1819        assert_eq!(tasks.len(), 2);
1820        tasks.sort_by_key(|task| task.data_file_path().to_string());
1821
1822        // The added file inherits the current snapshot's data sequence number,
1823        // the existing file keeps the one it was written with.
1824        assert_eq!(
1825            tasks[0].data_file_path(),
1826            format!("{}/1.parquet", &fixture.table_location)
1827        );
1828        assert_eq!(tasks[0].data_sequence_number(), Some(1));
1829        assert_eq!(
1830            tasks[1].data_file_path(),
1831            format!("{}/3.parquet", &fixture.table_location)
1832        );
1833        assert_eq!(tasks[1].data_sequence_number(), Some(0));
1834
1835        // first_row_id is a v3 concept; a v2 manifest carries none.
1836        assert!(tasks.iter().all(|task| task.first_row_id().is_none()));
1837    }
1838
1839    #[tokio::test]
1840    async fn test_plan_files_carries_row_lineage_from_v3_manifest() {
1841        let mut fixture = TableTestFixture::new();
1842        fixture.setup_v3_manifest_files().await;
1843
1844        let task = fixture
1845            .table
1846            .scan()
1847            .build()
1848            .unwrap()
1849            .plan_files()
1850            .await
1851            .unwrap()
1852            .try_collect::<Vec<_>>()
1853            .await
1854            .unwrap()
1855            .into_iter()
1856            .next()
1857            .expect("expected one FileScanTask");
1858
1859        // The manifest-level first_row_id (42) is inherited onto the entry on
1860        // read, then carried onto the task.
1861        assert_eq!(task.first_row_id(), Some(42));
1862        // The data sequence number is threaded through the same v3 read path.
1863        assert_eq!(task.data_sequence_number(), Some(1));
1864    }
1865
1866    #[tokio::test]
1867    async fn test_filtered_scan_with_dropped_partition_source_column() {
1868        let mut fixture = TableTestFixture::new();
1869        fixture.setup_manifest_files().await;
1870
1871        // Baseline: the same filtered scan against the table before evolution.
1872        let baseline = scan_y_gte_5(&fixture.table).await;
1873        assert!(!baseline.is_empty());
1874        assert!(baseline.iter().all(|y| *y >= 5));
1875
1876        // Evolve the table so that the manifests reference a historical spec whose source
1877        // column is no longer in the current schema: make an unpartitioned spec the
1878        // default, then drop the original spec's source column from the schema.
1879        let current_schema = fixture.table.metadata().current_schema();
1880        let evolved_schema = Schema::builder()
1881            .with_fields(
1882                current_schema
1883                    .as_struct()
1884                    .fields()
1885                    .iter()
1886                    .filter(|field| field.id != 1)
1887                    .cloned(),
1888            )
1889            .with_identifier_field_ids(vec![2])
1890            .build()
1891            .unwrap();
1892        let evolved =
1893            TableMetadataBuilder::new_from_metadata(fixture.table.metadata().clone(), None)
1894                .add_default_partition_spec(UnboundPartitionSpec::builder().build())
1895                .unwrap()
1896                .add_current_schema(evolved_schema)
1897                .unwrap()
1898                .build()
1899                .unwrap()
1900                .metadata;
1901
1902        // a commit after the evolution carries the previous manifests forward: the new
1903        // snapshot uses the evolved schema while its manifests still use historical spec 0
1904        let parent = evolved.current_snapshot().unwrap().clone();
1905        let snapshot = Snapshot::builder()
1906            .with_snapshot_id(parent.snapshot_id() + 1)
1907            .with_parent_snapshot_id(Some(parent.snapshot_id()))
1908            .with_sequence_number(evolved.last_sequence_number() + 1)
1909            .with_timestamp_ms(evolved.last_updated_ms + 1)
1910            .with_schema_id(evolved.current_schema_id())
1911            .with_manifest_list(parent.manifest_list())
1912            .with_summary(Summary {
1913                operation: Operation::Append,
1914                additional_properties: HashMap::new(),
1915            })
1916            .build();
1917        let metadata = TableMetadataBuilder::new_from_metadata(evolved, None)
1918            .set_branch_snapshot(snapshot, MAIN_BRANCH)
1919            .unwrap()
1920            .build()
1921            .unwrap()
1922            .metadata;
1923        let table = fixture.table.clone().with_metadata(Arc::new(metadata));
1924
1925        // Planning and reading must succeed, and the results must match the table before
1926        // evolution: no rows wrongly pruned and none returned unfiltered.
1927        let evolved = scan_y_gte_5(&table).await;
1928        assert_eq!(evolved, baseline);
1929    }
1930
1931    async fn scan_y_gte_5(table: &Table) -> Vec<i64> {
1932        let table_scan = table
1933            .scan()
1934            .select(["y"])
1935            .with_filter(Reference::new("y").greater_than_or_equal_to(Datum::long(5)))
1936            .build()
1937            .unwrap();
1938        let batches: Vec<_> = table_scan
1939            .to_arrow()
1940            .await
1941            .unwrap()
1942            .try_collect()
1943            .await
1944            .unwrap();
1945
1946        let mut values: Vec<i64> = batches
1947            .iter()
1948            .flat_map(|batch| {
1949                let col = batch.column_by_name("y").unwrap();
1950                let arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
1951                (0..arr.len()).map(|i| arr.value(i)).collect::<Vec<_>>()
1952            })
1953            .collect();
1954        values.sort_unstable();
1955        values
1956    }
1957
1958    #[tokio::test]
1959    async fn test_open_parquet_no_deletions() {
1960        let mut fixture = TableTestFixture::new();
1961        fixture.setup_manifest_files().await;
1962
1963        // Create table scan for current snapshot and plan files
1964        let table_scan = fixture
1965            .table
1966            .scan()
1967            .with_row_selection_enabled(true)
1968            .build()
1969            .unwrap();
1970
1971        let batch_stream = table_scan.to_arrow().await.unwrap();
1972
1973        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
1974
1975        let col = batches[0].column_by_name("x").unwrap();
1976
1977        let int64_arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
1978        assert_eq!(int64_arr.value(0), 1);
1979    }
1980
1981    #[tokio::test]
1982    async fn test_open_parquet_no_deletions_by_separate_reader() {
1983        let mut fixture = TableTestFixture::new();
1984        fixture.setup_manifest_files().await;
1985
1986        // Create table scan for current snapshot and plan files
1987        let table_scan = fixture
1988            .table
1989            .scan()
1990            .with_row_selection_enabled(true)
1991            .build()
1992            .unwrap();
1993
1994        let mut plan_task: Vec<_> = table_scan
1995            .plan_files()
1996            .await
1997            .unwrap()
1998            .try_collect()
1999            .await
2000            .unwrap();
2001        assert_eq!(plan_task.len(), 2);
2002
2003        let reader = ArrowReaderBuilder::new(
2004            fixture.table.file_io().clone(),
2005            fixture.table.runtime().clone(),
2006        )
2007        .build();
2008        let batch_stream = reader
2009            .clone()
2010            .read(Box::pin(stream::iter(vec![Ok(plan_task.remove(0))])))
2011            .unwrap()
2012            .stream();
2013        let batch_1: Vec<_> = batch_stream.try_collect().await.unwrap();
2014
2015        let reader = ArrowReaderBuilder::new(
2016            fixture.table.file_io().clone(),
2017            fixture.table.runtime().clone(),
2018        )
2019        .build();
2020        let batch_stream = reader
2021            .read(Box::pin(stream::iter(vec![Ok(plan_task.remove(0))])))
2022            .unwrap()
2023            .stream();
2024        let batch_2: Vec<_> = batch_stream.try_collect().await.unwrap();
2025
2026        assert_eq!(batch_1, batch_2);
2027    }
2028
2029    #[tokio::test]
2030    async fn test_open_parquet_with_projection() {
2031        let mut fixture = TableTestFixture::new();
2032        fixture.setup_manifest_files().await;
2033
2034        // Create table scan for current snapshot and plan files
2035        let table_scan = fixture
2036            .table
2037            .scan()
2038            .select(["x", "z"])
2039            .with_row_selection_enabled(true)
2040            .build()
2041            .unwrap();
2042
2043        let batch_stream = table_scan.to_arrow().await.unwrap();
2044
2045        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2046
2047        assert_eq!(batches[0].num_columns(), 2);
2048
2049        let col1 = batches[0].column_by_name("x").unwrap();
2050        let int64_arr = col1.as_any().downcast_ref::<Int64Array>().unwrap();
2051        assert_eq!(int64_arr.value(0), 1);
2052
2053        let col2 = batches[0].column_by_name("z").unwrap();
2054        let int64_arr = col2.as_any().downcast_ref::<Int64Array>().unwrap();
2055        assert_eq!(int64_arr.value(0), 3);
2056
2057        // test empty scan
2058        let table_scan = fixture.table.scan().select_empty().build().unwrap();
2059        let batch_stream = table_scan.to_arrow().await.unwrap();
2060        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2061
2062        assert_eq!(batches[0].num_columns(), 0);
2063        assert_eq!(batches[0].num_rows(), 1024);
2064    }
2065
2066    #[tokio::test]
2067    async fn test_filter_on_arrow_lt() {
2068        let mut fixture = TableTestFixture::new();
2069        fixture.setup_manifest_files().await;
2070
2071        // Filter: y < 3
2072        let mut builder = fixture.table.scan();
2073        let predicate = Reference::new("y").less_than(Datum::long(3));
2074        builder = builder
2075            .with_filter(predicate)
2076            .with_row_selection_enabled(true);
2077        let table_scan = builder.build().unwrap();
2078
2079        let batch_stream = table_scan.to_arrow().await.unwrap();
2080
2081        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2082
2083        assert_eq!(batches[0].num_rows(), 512);
2084
2085        let col = batches[0].column_by_name("x").unwrap();
2086        let int64_arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
2087        assert_eq!(int64_arr.value(0), 1);
2088
2089        let col = batches[0].column_by_name("y").unwrap();
2090        let int64_arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
2091        assert_eq!(int64_arr.value(0), 2);
2092    }
2093
2094    #[tokio::test]
2095    async fn test_filter_on_arrow_gt_eq() {
2096        let mut fixture = TableTestFixture::new();
2097        fixture.setup_manifest_files().await;
2098
2099        // Filter: y >= 5
2100        let mut builder = fixture.table.scan();
2101        let predicate = Reference::new("y").greater_than_or_equal_to(Datum::long(5));
2102        builder = builder
2103            .with_filter(predicate)
2104            .with_row_selection_enabled(true);
2105        let table_scan = builder.build().unwrap();
2106
2107        let batch_stream = table_scan.to_arrow().await.unwrap();
2108
2109        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2110
2111        assert_eq!(batches[0].num_rows(), 12);
2112
2113        let col = batches[0].column_by_name("x").unwrap();
2114        let int64_arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
2115        assert_eq!(int64_arr.value(0), 1);
2116
2117        let col = batches[0].column_by_name("y").unwrap();
2118        let int64_arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
2119        assert_eq!(int64_arr.value(0), 5);
2120    }
2121
2122    #[tokio::test]
2123    async fn test_filter_double_eq() {
2124        let mut fixture = TableTestFixture::new();
2125        fixture.setup_manifest_files().await;
2126
2127        // Filter: dbl == 150.0
2128        let mut builder = fixture.table.scan();
2129        let predicate = Reference::new("dbl").equal_to(Datum::double(150.0f64));
2130        builder = builder
2131            .with_filter(predicate)
2132            .with_row_selection_enabled(true);
2133        let table_scan = builder.build().unwrap();
2134
2135        let batch_stream = table_scan.to_arrow().await.unwrap();
2136
2137        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2138
2139        assert_eq!(batches.len(), 2);
2140        assert_eq!(batches[0].num_rows(), 12);
2141
2142        let col = batches[0].column_by_name("dbl").unwrap();
2143        let f64_arr = col.as_any().downcast_ref::<Float64Array>().unwrap();
2144        assert_eq!(f64_arr.value(1), 150.0f64);
2145    }
2146
2147    #[tokio::test]
2148    async fn test_filter_int_eq() {
2149        let mut fixture = TableTestFixture::new();
2150        fixture.setup_manifest_files().await;
2151
2152        // Filter: i32 == 150
2153        let mut builder = fixture.table.scan();
2154        let predicate = Reference::new("i32").equal_to(Datum::int(150i32));
2155        builder = builder
2156            .with_filter(predicate)
2157            .with_row_selection_enabled(true);
2158        let table_scan = builder.build().unwrap();
2159
2160        let batch_stream = table_scan.to_arrow().await.unwrap();
2161
2162        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2163
2164        assert_eq!(batches.len(), 2);
2165        assert_eq!(batches[0].num_rows(), 12);
2166
2167        let col = batches[0].column_by_name("i32").unwrap();
2168        let i32_arr = col.as_any().downcast_ref::<Int32Array>().unwrap();
2169        assert_eq!(i32_arr.value(1), 150i32);
2170    }
2171
2172    #[tokio::test]
2173    async fn test_filter_long_eq() {
2174        let mut fixture = TableTestFixture::new();
2175        fixture.setup_manifest_files().await;
2176
2177        // Filter: i64 == 150
2178        let mut builder = fixture.table.scan();
2179        let predicate = Reference::new("i64").equal_to(Datum::long(150i64));
2180        builder = builder
2181            .with_filter(predicate)
2182            .with_row_selection_enabled(true);
2183        let table_scan = builder.build().unwrap();
2184
2185        let batch_stream = table_scan.to_arrow().await.unwrap();
2186
2187        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2188
2189        assert_eq!(batches.len(), 2);
2190        assert_eq!(batches[0].num_rows(), 12);
2191
2192        let col = batches[0].column_by_name("i64").unwrap();
2193        let i64_arr = col.as_any().downcast_ref::<Int64Array>().unwrap();
2194        assert_eq!(i64_arr.value(1), 150i64);
2195    }
2196
2197    #[tokio::test]
2198    async fn test_filter_bool_eq() {
2199        let mut fixture = TableTestFixture::new();
2200        fixture.setup_manifest_files().await;
2201
2202        // Filter: bool == true
2203        let mut builder = fixture.table.scan();
2204        let predicate = Reference::new("bool").equal_to(Datum::bool(true));
2205        builder = builder
2206            .with_filter(predicate)
2207            .with_row_selection_enabled(true);
2208        let table_scan = builder.build().unwrap();
2209
2210        let batch_stream = table_scan.to_arrow().await.unwrap();
2211
2212        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2213
2214        assert_eq!(batches.len(), 2);
2215        assert_eq!(batches[0].num_rows(), 512);
2216
2217        let col = batches[0].column_by_name("bool").unwrap();
2218        let bool_arr = col.as_any().downcast_ref::<BooleanArray>().unwrap();
2219        assert!(bool_arr.value(1));
2220    }
2221
2222    #[tokio::test]
2223    async fn test_filter_on_arrow_is_null() {
2224        let mut fixture = TableTestFixture::new();
2225        fixture.setup_manifest_files().await;
2226
2227        // Filter: y is null
2228        let mut builder = fixture.table.scan();
2229        let predicate = Reference::new("y").is_null();
2230        builder = builder
2231            .with_filter(predicate)
2232            .with_row_selection_enabled(true);
2233        let table_scan = builder.build().unwrap();
2234
2235        let batch_stream = table_scan.to_arrow().await.unwrap();
2236
2237        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2238        assert_eq!(batches.len(), 0);
2239    }
2240
2241    #[tokio::test]
2242    async fn test_filter_on_arrow_is_not_null() {
2243        let mut fixture = TableTestFixture::new();
2244        fixture.setup_manifest_files().await;
2245
2246        // Filter: y is not null
2247        let mut builder = fixture.table.scan();
2248        let predicate = Reference::new("y").is_not_null();
2249        builder = builder
2250            .with_filter(predicate)
2251            .with_row_selection_enabled(true);
2252        let table_scan = builder.build().unwrap();
2253
2254        let batch_stream = table_scan.to_arrow().await.unwrap();
2255
2256        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2257        assert_eq!(batches[0].num_rows(), 1024);
2258    }
2259
2260    #[tokio::test]
2261    async fn test_filter_on_arrow_lt_and_gt() {
2262        let mut fixture = TableTestFixture::new();
2263        fixture.setup_manifest_files().await;
2264
2265        // Filter: y < 5 AND z >= 4
2266        let mut builder = fixture.table.scan();
2267        let predicate = Reference::new("y")
2268            .less_than(Datum::long(5))
2269            .and(Reference::new("z").greater_than_or_equal_to(Datum::long(4)));
2270        builder = builder
2271            .with_filter(predicate)
2272            .with_row_selection_enabled(true);
2273        let table_scan = builder.build().unwrap();
2274
2275        let batch_stream = table_scan.to_arrow().await.unwrap();
2276
2277        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2278        assert_eq!(batches[0].num_rows(), 500);
2279
2280        let col = batches[0].column_by_name("x").unwrap();
2281        let expected_x = Arc::new(Int64Array::from_iter_values(vec![1; 500])) as ArrayRef;
2282        assert_eq!(col, &expected_x);
2283
2284        let col = batches[0].column_by_name("y").unwrap();
2285        let mut values = vec![];
2286        values.append(vec![3; 200].as_mut());
2287        values.append(vec![4; 300].as_mut());
2288        let expected_y = Arc::new(Int64Array::from_iter_values(values)) as ArrayRef;
2289        assert_eq!(col, &expected_y);
2290
2291        let col = batches[0].column_by_name("z").unwrap();
2292        let expected_z = Arc::new(Int64Array::from_iter_values(vec![4; 500])) as ArrayRef;
2293        assert_eq!(col, &expected_z);
2294    }
2295
2296    #[tokio::test]
2297    async fn test_filter_on_arrow_lt_or_gt() {
2298        let mut fixture = TableTestFixture::new();
2299        fixture.setup_manifest_files().await;
2300
2301        // Filter: y < 5 AND z >= 4
2302        let mut builder = fixture.table.scan();
2303        let predicate = Reference::new("y")
2304            .less_than(Datum::long(5))
2305            .or(Reference::new("z").greater_than_or_equal_to(Datum::long(4)));
2306        builder = builder
2307            .with_filter(predicate)
2308            .with_row_selection_enabled(true);
2309        let table_scan = builder.build().unwrap();
2310
2311        let batch_stream = table_scan.to_arrow().await.unwrap();
2312
2313        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2314        assert_eq!(batches[0].num_rows(), 1024);
2315
2316        let col = batches[0].column_by_name("x").unwrap();
2317        let expected_x = Arc::new(Int64Array::from_iter_values(vec![1; 1024])) as ArrayRef;
2318        assert_eq!(col, &expected_x);
2319
2320        let col = batches[0].column_by_name("y").unwrap();
2321        let mut values = vec![2; 512];
2322        values.append(vec![3; 200].as_mut());
2323        values.append(vec![4; 300].as_mut());
2324        values.append(vec![5; 12].as_mut());
2325        let expected_y = Arc::new(Int64Array::from_iter_values(values)) as ArrayRef;
2326        assert_eq!(col, &expected_y);
2327
2328        let col = batches[0].column_by_name("z").unwrap();
2329        let mut values = vec![3; 512];
2330        values.append(vec![4; 512].as_mut());
2331        let expected_z = Arc::new(Int64Array::from_iter_values(values)) as ArrayRef;
2332        assert_eq!(col, &expected_z);
2333    }
2334
2335    #[tokio::test]
2336    async fn test_filter_on_arrow_startswith() {
2337        let mut fixture = TableTestFixture::new();
2338        fixture.setup_manifest_files().await;
2339
2340        // Filter: a STARTSWITH "Ice"
2341        let mut builder = fixture.table.scan();
2342        let predicate = Reference::new("a").starts_with(Datum::string("Ice"));
2343        builder = builder
2344            .with_filter(predicate)
2345            .with_row_selection_enabled(true);
2346        let table_scan = builder.build().unwrap();
2347
2348        let batch_stream = table_scan.to_arrow().await.unwrap();
2349
2350        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2351
2352        assert_eq!(batches[0].num_rows(), 512);
2353
2354        let col = batches[0].column_by_name("a").unwrap();
2355        let string_arr = col.as_any().downcast_ref::<StringArray>().unwrap();
2356        assert_eq!(string_arr.value(0), "Iceberg");
2357    }
2358
2359    #[tokio::test]
2360    async fn test_filter_on_arrow_not_startswith() {
2361        let mut fixture = TableTestFixture::new();
2362        fixture.setup_manifest_files().await;
2363
2364        // Filter: a NOT STARTSWITH "Ice"
2365        let mut builder = fixture.table.scan();
2366        let predicate = Reference::new("a").not_starts_with(Datum::string("Ice"));
2367        builder = builder
2368            .with_filter(predicate)
2369            .with_row_selection_enabled(true);
2370        let table_scan = builder.build().unwrap();
2371
2372        let batch_stream = table_scan.to_arrow().await.unwrap();
2373
2374        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2375
2376        assert_eq!(batches[0].num_rows(), 512);
2377
2378        let col = batches[0].column_by_name("a").unwrap();
2379        let string_arr = col.as_any().downcast_ref::<StringArray>().unwrap();
2380        assert_eq!(string_arr.value(0), "Apache");
2381    }
2382
2383    #[tokio::test]
2384    async fn test_filter_on_arrow_in() {
2385        let mut fixture = TableTestFixture::new();
2386        fixture.setup_manifest_files().await;
2387
2388        // Filter: a IN ("Sioux", "Iceberg")
2389        let mut builder = fixture.table.scan();
2390        let predicate =
2391            Reference::new("a").is_in([Datum::string("Sioux"), Datum::string("Iceberg")]);
2392        builder = builder
2393            .with_filter(predicate)
2394            .with_row_selection_enabled(true);
2395        let table_scan = builder.build().unwrap();
2396
2397        let batch_stream = table_scan.to_arrow().await.unwrap();
2398
2399        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2400
2401        assert_eq!(batches[0].num_rows(), 512);
2402
2403        let col = batches[0].column_by_name("a").unwrap();
2404        let string_arr = col.as_any().downcast_ref::<StringArray>().unwrap();
2405        assert_eq!(string_arr.value(0), "Iceberg");
2406    }
2407
2408    #[tokio::test]
2409    async fn test_filter_on_arrow_not_in() {
2410        let mut fixture = TableTestFixture::new();
2411        fixture.setup_manifest_files().await;
2412
2413        // Filter: a NOT IN ("Sioux", "Iceberg")
2414        let mut builder = fixture.table.scan();
2415        let predicate =
2416            Reference::new("a").is_not_in([Datum::string("Sioux"), Datum::string("Iceberg")]);
2417        builder = builder
2418            .with_filter(predicate)
2419            .with_row_selection_enabled(true);
2420        let table_scan = builder.build().unwrap();
2421
2422        let batch_stream = table_scan.to_arrow().await.unwrap();
2423
2424        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2425
2426        assert_eq!(batches[0].num_rows(), 512);
2427
2428        let col = batches[0].column_by_name("a").unwrap();
2429        let string_arr = col.as_any().downcast_ref::<StringArray>().unwrap();
2430        assert_eq!(string_arr.value(0), "Apache");
2431    }
2432
2433    fn file_scan_task_test_schema(primitive_type: PrimitiveType) -> Arc<Schema> {
2434        Arc::new(
2435            Schema::builder()
2436                .with_fields(vec![Arc::new(NestedField::required(
2437                    1,
2438                    "x",
2439                    Type::Primitive(primitive_type),
2440                ))])
2441                .build()
2442                .unwrap(),
2443        )
2444    }
2445
2446    fn assert_file_scan_task_serde_round_trip(task: FileScanTask) {
2447        // Regression test for https://github.com/apache/iceberg-rust/issues/3089.
2448        let serialized = serde_json::to_string(&task).unwrap();
2449        let deserialized: FileScanTask = serde_json::from_str(&serialized).unwrap();
2450
2451        assert_eq!(task, deserialized);
2452    }
2453
2454    fn file_scan_task_with_partition(
2455        primitive_type: PrimitiveType,
2456        transform: Transform,
2457        partition_value: Literal,
2458    ) -> FileScanTask {
2459        let schema = file_scan_task_test_schema(primitive_type);
2460        let partition_spec = Arc::new(
2461            PartitionSpec::builder(schema.clone())
2462                .add_partition_field("x", "x_partition", transform)
2463                .unwrap()
2464                .build()
2465                .unwrap(),
2466        );
2467        FileScanTask::builder()
2468            .with_data_file_path("data_file_path".to_string())
2469            .with_file_size_in_bytes(123)
2470            .with_start(10)
2471            .with_length(100)
2472            .with_project_field_ids(vec![1])
2473            .with_schema(schema)
2474            .with_data_file_format(DataFileFormat::Parquet)
2475            .with_partition(Some(Struct::from_iter([Some(partition_value)])))
2476            .with_partition_spec(Some(partition_spec))
2477            .with_case_sensitive(true)
2478            .build()
2479            .unwrap()
2480    }
2481
2482    #[test]
2483    fn test_file_scan_task_serde_without_predicate() {
2484        let task = FileScanTask::builder()
2485            .with_data_file_path("data_file_path".to_string())
2486            .with_file_size_in_bytes(0)
2487            .with_start(0)
2488            .with_length(100)
2489            .with_project_field_ids(vec![1, 2, 3])
2490            .with_schema(file_scan_task_test_schema(PrimitiveType::Binary))
2491            .with_record_count(Some(100))
2492            .with_first_row_id(Some(1000))
2493            .with_data_sequence_number(Some(5))
2494            .with_data_file_format(DataFileFormat::Parquet)
2495            .with_case_sensitive(false)
2496            .build()
2497            .unwrap();
2498        assert_file_scan_task_serde_round_trip(task);
2499    }
2500
2501    #[test]
2502    fn test_file_scan_task_serde_with_predicate() {
2503        let task = FileScanTask::builder()
2504            .with_data_file_path("data_file_path".to_string())
2505            .with_file_size_in_bytes(0)
2506            .with_start(0)
2507            .with_length(100)
2508            .with_project_field_ids(vec![1, 2, 3])
2509            .with_predicate(Some(BoundPredicate::AlwaysTrue))
2510            .with_schema(file_scan_task_test_schema(PrimitiveType::Binary))
2511            .with_data_file_format(DataFileFormat::Avro)
2512            .with_case_sensitive(false)
2513            .build()
2514            .unwrap();
2515
2516        let serialized = serde_json::to_value(&task).unwrap();
2517        assert!(serialized.get("record_count").is_none());
2518        assert_file_scan_task_serde_round_trip(task);
2519    }
2520
2521    #[test]
2522    fn test_unpartitioned_file_scan_task_serde() {
2523        let task = FileScanTask::builder()
2524            .with_data_file_path("data_file_path".to_string())
2525            .with_file_size_in_bytes(0)
2526            .with_start(0)
2527            .with_length(100)
2528            .with_project_field_ids(vec![1, 2, 3])
2529            .with_schema(file_scan_task_test_schema(PrimitiveType::Binary))
2530            .with_data_file_format(DataFileFormat::Parquet)
2531            .with_partition(Some(Struct::empty()))
2532            .with_case_sensitive(false)
2533            .build()
2534            .unwrap();
2535        assert_file_scan_task_serde_round_trip(task);
2536    }
2537
2538    #[test]
2539    fn test_file_scan_task_serde_with_all_optional_fields() {
2540        let schema = file_scan_task_test_schema(PrimitiveType::Long);
2541        let partition_spec = Arc::new(
2542            PartitionSpec::builder(schema.clone())
2543                .add_partition_field("x", "x", Transform::Identity)
2544                .unwrap()
2545                .build()
2546                .unwrap(),
2547        );
2548        let unified_partition_type = Arc::new(partition_spec.partition_type(&schema).unwrap());
2549        let sort_order = Arc::new(
2550            SortOrder::builder()
2551                .with_order_id(1)
2552                .with_sort_field(
2553                    SortField::builder()
2554                        .source_id(1)
2555                        .transform(Transform::Identity)
2556                        .direction(SortDirection::Ascending)
2557                        .null_order(NullOrder::First)
2558                        .build(),
2559                )
2560                .build(&schema)
2561                .unwrap(),
2562        );
2563        let task = FileScanTask::builder()
2564            .with_data_file_path("data_file_path".to_string())
2565            .with_file_size_in_bytes(123)
2566            .with_start(10)
2567            .with_length(100)
2568            .with_project_field_ids(vec![1])
2569            .with_schema(schema)
2570            .with_data_file_format(DataFileFormat::Parquet)
2571            .with_deletes(vec![
2572                FileScanTaskDeleteFile::builder()
2573                    .with_file_path("delete_file_path".to_string())
2574                    .with_file_size_in_bytes(23)
2575                    .with_file_type(DataContentType::EqualityDeletes)
2576                    .with_file_format(DataFileFormat::Parquet)
2577                    .with_partition_spec_id(0)
2578                    .with_equality_ids(Some(vec![1]))
2579                    .with_referenced_data_file(Some("data_file_path".to_string()))
2580                    .with_content_offset(Some(12))
2581                    .with_content_size_in_bytes(Some(34))
2582                    .with_record_count(Some(5))
2583                    .with_key_metadata(Some(vec![4, 5, 6].into_boxed_slice()))
2584                    .build()
2585                    .unwrap(),
2586            ])
2587            .with_partition(Some(Struct::from_iter([Some(Literal::long(42))])))
2588            .with_partition_spec(Some(partition_spec))
2589            .with_name_mapping(Some(Arc::new(NameMapping::new(vec![MappedField::new(
2590                Some(1),
2591                vec!["x".to_string()],
2592                vec![],
2593            )]))))
2594            .with_unified_partition_type(Some(unified_partition_type))
2595            .with_sort_order_id(Some(1))
2596            .with_sort_order(Some(sort_order))
2597            .with_case_sensitive(true)
2598            .with_key_metadata(Some(vec![1, 2, 3].into_boxed_slice()))
2599            .build()
2600            .unwrap();
2601        assert_file_scan_task_serde_round_trip(task);
2602    }
2603
2604    #[test]
2605    fn test_file_scan_task_serde_with_date_partition() {
2606        let task = file_scan_task_with_partition(
2607            PrimitiveType::Date,
2608            Transform::Identity,
2609            Literal::date(19_000),
2610        );
2611        assert_file_scan_task_serde_round_trip(task);
2612    }
2613
2614    #[test]
2615    fn test_file_scan_task_serde_with_timestamp_ns_partition() {
2616        let task = file_scan_task_with_partition(
2617            PrimitiveType::TimestampNs,
2618            Transform::Identity,
2619            Literal::timestamp_nano(1_510_871_468_123_456_789),
2620        );
2621        assert_file_scan_task_serde_round_trip(task);
2622    }
2623
2624    #[test]
2625    fn test_file_scan_task_serde_with_timestamptz_ns_partition() {
2626        let task = file_scan_task_with_partition(
2627            PrimitiveType::TimestamptzNs,
2628            Transform::Identity,
2629            Literal::timestamptz_nano(1_510_871_468_123_456_789),
2630        );
2631        assert_file_scan_task_serde_round_trip(task);
2632    }
2633
2634    #[test]
2635    fn test_file_scan_task_serde_with_decimal_partition() {
2636        let task = file_scan_task_with_partition(
2637            PrimitiveType::Decimal {
2638                precision: 9,
2639                scale: 2,
2640            },
2641            Transform::Identity,
2642            Literal::decimal(12_345),
2643        );
2644        assert_file_scan_task_serde_round_trip(task);
2645    }
2646
2647    #[test]
2648    fn test_file_scan_task_serde_with_uuid_partition() {
2649        let task = file_scan_task_with_partition(
2650            PrimitiveType::Uuid,
2651            Transform::Identity,
2652            Literal::uuid(Uuid::from_u128(0x12345678_90ab_cdef_1234_567890abcdef)),
2653        );
2654        assert_file_scan_task_serde_round_trip(task);
2655    }
2656
2657    #[test]
2658    fn test_file_scan_task_serde_with_fixed_partition() {
2659        let task = file_scan_task_with_partition(
2660            PrimitiveType::Fixed(4),
2661            Transform::Identity,
2662            Literal::fixed([1, 2, 3, 4]),
2663        );
2664        assert_file_scan_task_serde_round_trip(task);
2665    }
2666
2667    #[test]
2668    fn test_file_scan_task_serde_with_bucket_partition() {
2669        let task = file_scan_task_with_partition(
2670            PrimitiveType::String,
2671            Transform::Bucket(4),
2672            Literal::int(2),
2673        );
2674        assert_file_scan_task_serde_round_trip(task);
2675    }
2676
2677    #[tokio::test]
2678    async fn test_select_with_file_column() {
2679        let mut fixture = TableTestFixture::new();
2680        fixture.setup_manifest_files().await;
2681
2682        // Select regular columns plus the _file column
2683        let table_scan = fixture
2684            .table
2685            .scan()
2686            .select(["x", RESERVED_COL_NAME_FILE])
2687            .with_row_selection_enabled(true)
2688            .build()
2689            .unwrap();
2690
2691        let batch_stream = table_scan.to_arrow().await.unwrap();
2692        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2693
2694        // Verify we have 2 columns: x and _file
2695        assert_eq!(batches[0].num_columns(), 2);
2696
2697        // Verify the x column exists and has correct data
2698        let x_col = batches[0].column_by_name("x").unwrap();
2699        let x_arr = x_col.as_primitive::<arrow_array::types::Int64Type>();
2700        assert_eq!(x_arr.value(0), 1);
2701
2702        // Verify the _file column exists
2703        let file_col = batches[0].column_by_name(RESERVED_COL_NAME_FILE);
2704        assert!(
2705            file_col.is_some(),
2706            "_file column should be present in the batch"
2707        );
2708
2709        // Verify the _file column contains a file path
2710        let file_col = file_col.unwrap();
2711        assert!(
2712            matches!(
2713                file_col.data_type(),
2714                arrow_schema::DataType::RunEndEncoded(_, _)
2715            ),
2716            "_file column should use RunEndEncoded type"
2717        );
2718
2719        // Decode the RunArray to verify it contains the file path
2720        let run_array = file_col
2721            .as_any()
2722            .downcast_ref::<RunArray<Int32Type>>()
2723            .expect("_file column should be a RunArray");
2724
2725        let values = run_array.values();
2726        let string_values = values.as_string::<i32>();
2727        assert_eq!(string_values.len(), 1, "Should have a single file path");
2728
2729        let file_path = string_values.value(0);
2730        assert!(
2731            file_path.ends_with(".parquet"),
2732            "File path should end with .parquet, got: {file_path}"
2733        );
2734    }
2735
2736    #[tokio::test]
2737    async fn test_select_file_column_position() {
2738        let mut fixture = TableTestFixture::new();
2739        fixture.setup_manifest_files().await;
2740
2741        // Select columns in specific order: x, _file, z
2742        let table_scan = fixture
2743            .table
2744            .scan()
2745            .select(["x", RESERVED_COL_NAME_FILE, "z"])
2746            .with_row_selection_enabled(true)
2747            .build()
2748            .unwrap();
2749
2750        let batch_stream = table_scan.to_arrow().await.unwrap();
2751        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2752
2753        assert_eq!(batches[0].num_columns(), 3);
2754
2755        // Verify column order: x at position 0, _file at position 1, z at position 2
2756        let schema = batches[0].schema();
2757        assert_eq!(schema.field(0).name(), "x");
2758        assert_eq!(schema.field(1).name(), RESERVED_COL_NAME_FILE);
2759        assert_eq!(schema.field(2).name(), "z");
2760
2761        // Verify columns by name also works
2762        assert!(batches[0].column_by_name("x").is_some());
2763        assert!(batches[0].column_by_name(RESERVED_COL_NAME_FILE).is_some());
2764        assert!(batches[0].column_by_name("z").is_some());
2765    }
2766
2767    #[tokio::test]
2768    async fn test_select_file_column_only() {
2769        let mut fixture = TableTestFixture::new();
2770        fixture.setup_manifest_files().await;
2771
2772        // Select only the _file column
2773        let table_scan = fixture
2774            .table
2775            .scan()
2776            .select([RESERVED_COL_NAME_FILE])
2777            .with_row_selection_enabled(true)
2778            .build()
2779            .unwrap();
2780
2781        let batch_stream = table_scan.to_arrow().await.unwrap();
2782        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2783
2784        // Should have exactly 1 column
2785        assert_eq!(batches[0].num_columns(), 1);
2786
2787        // Verify it's the _file column
2788        let schema = batches[0].schema();
2789        assert_eq!(schema.field(0).name(), RESERVED_COL_NAME_FILE);
2790
2791        // Verify the batch has the correct number of rows
2792        // The scan reads files 1.parquet and 3.parquet (2.parquet is deleted)
2793        // Each file has 1024 rows, so total is 2048 rows
2794        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
2795        assert_eq!(total_rows, 2048);
2796    }
2797
2798    #[tokio::test]
2799    async fn test_file_column_with_multiple_files() {
2800        use std::collections::HashSet;
2801
2802        let mut fixture = TableTestFixture::new();
2803        fixture.setup_manifest_files().await;
2804
2805        // Select x and _file columns
2806        let table_scan = fixture
2807            .table
2808            .scan()
2809            .select(["x", RESERVED_COL_NAME_FILE])
2810            .with_row_selection_enabled(true)
2811            .build()
2812            .unwrap();
2813
2814        let batch_stream = table_scan.to_arrow().await.unwrap();
2815        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2816
2817        // Collect all unique file paths from the batches
2818        let mut file_paths = HashSet::new();
2819        for batch in &batches {
2820            let file_col = batch.column_by_name(RESERVED_COL_NAME_FILE).unwrap();
2821            let run_array = file_col
2822                .as_any()
2823                .downcast_ref::<RunArray<Int32Type>>()
2824                .expect("_file column should be a RunArray");
2825
2826            let values = run_array.values();
2827            let string_values = values.as_string::<i32>();
2828            for i in 0..string_values.len() {
2829                file_paths.insert(string_values.value(i).to_string());
2830            }
2831        }
2832
2833        // We should have multiple files (the test creates 1.parquet and 3.parquet)
2834        assert!(!file_paths.is_empty(), "Should have at least one file path");
2835
2836        // All paths should end with .parquet
2837        for path in &file_paths {
2838            assert!(
2839                path.ends_with(".parquet"),
2840                "All file paths should end with .parquet, got: {path}"
2841            );
2842        }
2843    }
2844
2845    #[tokio::test]
2846    async fn test_file_column_at_start() {
2847        let mut fixture = TableTestFixture::new();
2848        fixture.setup_manifest_files().await;
2849
2850        // Select _file at the start
2851        let table_scan = fixture
2852            .table
2853            .scan()
2854            .select([RESERVED_COL_NAME_FILE, "x", "y"])
2855            .with_row_selection_enabled(true)
2856            .build()
2857            .unwrap();
2858
2859        let batch_stream = table_scan.to_arrow().await.unwrap();
2860        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2861
2862        assert_eq!(batches[0].num_columns(), 3);
2863
2864        // Verify _file is at position 0
2865        let schema = batches[0].schema();
2866        assert_eq!(schema.field(0).name(), RESERVED_COL_NAME_FILE);
2867        assert_eq!(schema.field(1).name(), "x");
2868        assert_eq!(schema.field(2).name(), "y");
2869    }
2870
2871    #[tokio::test]
2872    async fn test_file_column_at_end() {
2873        let mut fixture = TableTestFixture::new();
2874        fixture.setup_manifest_files().await;
2875
2876        // Select _file at the end
2877        let table_scan = fixture
2878            .table
2879            .scan()
2880            .select(["x", "y", RESERVED_COL_NAME_FILE])
2881            .with_row_selection_enabled(true)
2882            .build()
2883            .unwrap();
2884
2885        let batch_stream = table_scan.to_arrow().await.unwrap();
2886        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2887
2888        assert_eq!(batches[0].num_columns(), 3);
2889
2890        // Verify _file is at position 2 (the end)
2891        let schema = batches[0].schema();
2892        assert_eq!(schema.field(0).name(), "x");
2893        assert_eq!(schema.field(1).name(), "y");
2894        assert_eq!(schema.field(2).name(), RESERVED_COL_NAME_FILE);
2895    }
2896
2897    #[tokio::test]
2898    async fn test_select_with_repeated_column_names() {
2899        let mut fixture = TableTestFixture::new();
2900        fixture.setup_manifest_files().await;
2901
2902        // Select with repeated column names - both regular columns and virtual columns
2903        // Repeated columns should appear multiple times in the result (duplicates are allowed)
2904        let table_scan = fixture
2905            .table
2906            .scan()
2907            .select([
2908                "x",
2909                RESERVED_COL_NAME_FILE,
2910                "x", // x repeated
2911                "y",
2912                RESERVED_COL_NAME_FILE, // _file repeated
2913                "y",                    // y repeated
2914            ])
2915            .with_row_selection_enabled(true)
2916            .build()
2917            .unwrap();
2918
2919        let batch_stream = table_scan.to_arrow().await.unwrap();
2920        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
2921
2922        // Verify we have exactly 6 columns (duplicates are allowed and preserved)
2923        assert_eq!(
2924            batches[0].num_columns(),
2925            6,
2926            "Should have exactly 6 columns with duplicates"
2927        );
2928
2929        let schema = batches[0].schema();
2930
2931        // Verify columns appear in the exact order requested: x, _file, x, y, _file, y
2932        assert_eq!(schema.field(0).name(), "x", "Column 0 should be x");
2933        assert_eq!(
2934            schema.field(1).name(),
2935            RESERVED_COL_NAME_FILE,
2936            "Column 1 should be _file"
2937        );
2938        assert_eq!(
2939            schema.field(2).name(),
2940            "x",
2941            "Column 2 should be x (duplicate)"
2942        );
2943        assert_eq!(schema.field(3).name(), "y", "Column 3 should be y");
2944        assert_eq!(
2945            schema.field(4).name(),
2946            RESERVED_COL_NAME_FILE,
2947            "Column 4 should be _file (duplicate)"
2948        );
2949        assert_eq!(
2950            schema.field(5).name(),
2951            "y",
2952            "Column 5 should be y (duplicate)"
2953        );
2954
2955        // Verify all columns have correct data types
2956        assert!(
2957            matches!(schema.field(0).data_type(), arrow_schema::DataType::Int64),
2958            "Column x should be Int64"
2959        );
2960        assert!(
2961            matches!(schema.field(2).data_type(), arrow_schema::DataType::Int64),
2962            "Column x (duplicate) should be Int64"
2963        );
2964        assert!(
2965            matches!(schema.field(3).data_type(), arrow_schema::DataType::Int64),
2966            "Column y should be Int64"
2967        );
2968        assert!(
2969            matches!(schema.field(5).data_type(), arrow_schema::DataType::Int64),
2970            "Column y (duplicate) should be Int64"
2971        );
2972        assert!(
2973            matches!(
2974                schema.field(1).data_type(),
2975                arrow_schema::DataType::RunEndEncoded(_, _)
2976            ),
2977            "_file column should use RunEndEncoded type"
2978        );
2979        assert!(
2980            matches!(
2981                schema.field(4).data_type(),
2982                arrow_schema::DataType::RunEndEncoded(_, _)
2983            ),
2984            "_file column (duplicate) should use RunEndEncoded type"
2985        );
2986    }
2987
2988    /// Builds a minimal single-snapshot table (no manifests on disk) whose schema
2989    /// contains a column with the given name, so scan planning resolves column names
2990    /// against a real schema. `TableScan::build()` only reads metadata, not manifest
2991    /// files, so a snapshot pointing at a dummy manifest list is enough. Used to
2992    /// reproduce issue #2837.
2993    fn table_with_data_column(column_name: &str) -> Table {
2994        use crate::spec::{
2995            FormatVersion, MAIN_BRANCH, Operation, Snapshot, SnapshotReference, SnapshotRetention,
2996            SortOrder, Summary, TableMetadataBuilder, UnboundPartitionSpec,
2997        };
2998
2999        let schema = Schema::builder()
3000            .with_schema_id(0)
3001            .with_fields(vec![
3002                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
3003                NestedField::required(2, column_name, Type::Primitive(PrimitiveType::Int)).into(),
3004            ])
3005            .build()
3006            .unwrap();
3007
3008        let snapshot = Snapshot::builder()
3009            .with_snapshot_id(1)
3010            .with_timestamp_ms(1)
3011            .with_sequence_number(0)
3012            .with_schema_id(0)
3013            .with_manifest_list("/snap-1.avro")
3014            .with_summary(Summary {
3015                operation: Operation::Append,
3016                additional_properties: HashMap::new(),
3017            })
3018            .build();
3019
3020        let metadata = TableMetadataBuilder::new(
3021            schema,
3022            UnboundPartitionSpec::builder().with_spec_id(0).build(),
3023            SortOrder::unsorted_order(),
3024            "s3://bucket/table".to_string(),
3025            FormatVersion::V2,
3026            HashMap::new(),
3027        )
3028        .unwrap()
3029        .add_snapshot(snapshot)
3030        .unwrap()
3031        .set_ref(MAIN_BRANCH, SnapshotReference {
3032            snapshot_id: 1,
3033            retention: SnapshotRetention::Branch {
3034                min_snapshots_to_keep: None,
3035                max_snapshot_age_ms: None,
3036                max_ref_age_ms: None,
3037            },
3038        })
3039        .unwrap()
3040        .build()
3041        .unwrap()
3042        .metadata;
3043
3044        Table::builder()
3045            .metadata(metadata)
3046            .identifier(TableIdent::from_strs(["db", "table1"]).unwrap())
3047            .file_io(FileIO::new_with_fs())
3048            .runtime(test_runtime())
3049            .build()
3050            .unwrap()
3051    }
3052
3053    /// A user data column named `pos` (a delete-file internal column name that is not a
3054    /// data-table metadata column) must be projectable rather than shadowed. Regression
3055    /// test for issue #2837.
3056    #[test]
3057    fn test_scan_projects_data_column_named_like_delete_file_column() {
3058        for column_name in ["pos", "file_path"] {
3059            let table = table_with_data_column(column_name);
3060
3061            // Projecting the data column must succeed and resolve to its real field id (2),
3062            // not the reserved delete-file field id.
3063            let table_scan = table
3064                .scan()
3065                .select([column_name])
3066                .build()
3067                .unwrap_or_else(|e| panic!("scan of data column `{column_name}` failed: {e}"));
3068
3069            assert_eq!(
3070                table_scan.plan_context.as_ref().unwrap().field_ids.as_ref(),
3071                &[2]
3072            );
3073
3074            // The default projection (all columns) must resolve to the real field ids
3075            // too, not shadow the data column with a reserved delete-file id.
3076            let default_scan = table.scan().build().unwrap();
3077            assert_eq!(
3078                default_scan
3079                    .plan_context
3080                    .as_ref()
3081                    .unwrap()
3082                    .field_ids
3083                    .as_ref(),
3084                &[1, 2]
3085            );
3086        }
3087    }
3088
3089    /// Projecting a genuinely absent column still fails with a clear "not found" error
3090    /// rather than being silently accepted as a metadata column.
3091    #[test]
3092    fn test_scan_rejects_unknown_column_named_like_delete_file_column() {
3093        // This table has no `pos` column (only `id` and `file_path`).
3094        let table = table_with_data_column("file_path");
3095
3096        let err = table
3097            .scan()
3098            .select(["pos"])
3099            .build()
3100            .expect_err("projecting an absent column should fail");
3101        assert_eq!(err.kind(), ErrorKind::DataInvalid);
3102        assert!(err.to_string().contains("not found"));
3103    }
3104
3105    #[tokio::test]
3106    async fn test_scan_deadlock() {
3107        let mut fixture = TableTestFixture::new();
3108        fixture.setup_deadlock_manifests().await;
3109
3110        // Create table scan with concurrency limit 1
3111        // This sets channel size to 1.
3112        // Data manifest has 10 entries -> will block producer.
3113        // Delete manifest is 2nd in list -> won't be processed.
3114        // Consumer 2 (Data) not started -> blocked.
3115        // Consumer 1 (Delete) waiting -> blocked.
3116        let table_scan = fixture
3117            .table
3118            .scan()
3119            .with_concurrency_limit(1)
3120            .build()
3121            .unwrap();
3122
3123        // This should timeout/hang if deadlock exists
3124        // We can use tokio::time::timeout
3125        let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3126            table_scan
3127                .plan_files()
3128                .await
3129                .unwrap()
3130                .try_collect::<Vec<_>>()
3131                .await
3132        })
3133        .await;
3134
3135        // Assert it finished (didn't timeout)
3136        assert!(result.is_ok(), "Scan timed out - deadlock detected");
3137    }
3138
3139    #[tokio::test]
3140    async fn test_select_with_spec_id_column() {
3141        let mut fixture = TableTestFixture::new();
3142        fixture.setup_manifest_files().await;
3143
3144        // Select regular columns plus the _spec_id column
3145        let table_scan = fixture
3146            .table
3147            .scan()
3148            .select(["x", RESERVED_COL_NAME_SPEC_ID, "z"])
3149            .with_row_selection_enabled(true)
3150            .build()
3151            .unwrap();
3152
3153        let batch_stream = table_scan.to_arrow().await.unwrap();
3154        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
3155
3156        // Verify we have 3 columns: x, _spec_id, and z
3157        assert_eq!(batches[0].num_columns(), 3);
3158
3159        // Verify the x column exists and has correct data
3160        let col1 = batches[0].column_by_name("x").unwrap();
3161        let int64_arr = col1.as_any().downcast_ref::<Int64Array>().unwrap();
3162        assert_eq!(int64_arr.value(0), 1);
3163
3164        // Verify the _spec_id column exists
3165        let spec_id_col = batches[0].column_by_name(RESERVED_COL_NAME_SPEC_ID);
3166        assert!(
3167            spec_id_col.is_some(),
3168            "_spec_id column should be present in the batch"
3169        );
3170
3171        // Verify the _spec_id data type
3172        let spec_id_col = spec_id_col.unwrap();
3173        assert!(
3174            matches!(
3175                spec_id_col.data_type(),
3176                arrow_schema::DataType::RunEndEncoded(_, _)
3177            ),
3178            "_spec_id column should use RunEndEncoded type"
3179        );
3180
3181        // Decode the RunArray to verify it contains the spec id
3182        let run_array = spec_id_col
3183            .as_any()
3184            .downcast_ref::<RunArray<Int32Type>>()
3185            .expect("_spec_id column should be a RunArray");
3186
3187        let values = run_array.values();
3188        let int_values = values.as_primitive::<Int32Type>();
3189        assert_eq!(int_values.len(), 1, "Should have a single _spec_id");
3190
3191        let spec_id = int_values.value(0);
3192        assert_eq!(spec_id, 0, "_spec_id should be 0, got: {spec_id}");
3193
3194        // Verify 'z' column exists
3195        assert!(batches[0].column_by_name("z").is_some());
3196    }
3197
3198    #[tokio::test]
3199    async fn test_select_with_last_updated_sequence_number_column() {
3200        // A v2 fixture: data files have a null first_row_id. Per the spec's Row
3201        // Lineage read rules, a file with a null first_row_id produces a null
3202        // _last_updated_sequence_number for all rows; both lineage columns are
3203        // gated on first_row_id.
3204        let mut fixture = TableTestFixture::new();
3205        fixture.setup_manifest_files().await;
3206
3207        let table_scan = fixture
3208            .table
3209            .scan()
3210            .select(["x", RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER])
3211            .with_row_selection_enabled(true)
3212            .build()
3213            .unwrap();
3214
3215        let batches: Vec<_> = table_scan
3216            .to_arrow()
3217            .await
3218            .unwrap()
3219            .try_collect()
3220            .await
3221            .unwrap();
3222
3223        // Every row's value is null (v2 files have no first_row_id).
3224        assert_last_updated_seq_all(&batches, None);
3225    }
3226
3227    #[tokio::test]
3228    async fn test_select_with_last_updated_sequence_number_column_v3() {
3229        // A v3 fixture: the data file inherits a first_row_id and a data sequence
3230        // number through manifest read. End to end, the projected
3231        // _last_updated_sequence_number materializes to the file's data sequence
3232        // number for every row, exercising the full inherit, populate and
3233        // materialize wiring, not just a hand-built task.
3234        let mut fixture = TableTestFixture::new();
3235        fixture.setup_v3_manifest_files().await;
3236
3237        // The added file inherits the current snapshot's sequence number; derive it
3238        // from the fixture rather than hardcoding so the assertion tracks the fixture.
3239        let expected_seq = fixture
3240            .table
3241            .metadata()
3242            .current_snapshot()
3243            .unwrap()
3244            .sequence_number();
3245
3246        let table_scan = fixture
3247            .table
3248            .scan()
3249            .select(["x", RESERVED_COL_NAME_LAST_UPDATED_SEQUENCE_NUMBER])
3250            .with_row_selection_enabled(true)
3251            .build()
3252            .unwrap();
3253
3254        let batches: Vec<_> = table_scan
3255            .to_arrow()
3256            .await
3257            .unwrap()
3258            .try_collect()
3259            .await
3260            .unwrap();
3261
3262        assert_last_updated_seq_all(&batches, Some(expected_seq));
3263    }
3264
3265    #[tokio::test]
3266    async fn test_select_with_spec_id_column_from_unpartitioned_table() {
3267        let mut fixture = TableTestFixture::new_unpartitioned();
3268        fixture.setup_unpartitioned_manifest_files().await;
3269
3270        // Select regular columns plus the _spec_id column
3271        let table_scan = fixture
3272            .table
3273            .scan()
3274            .select(["x", RESERVED_COL_NAME_SPEC_ID])
3275            .with_row_selection_enabled(true)
3276            .build()
3277            .unwrap();
3278
3279        let batch_stream = table_scan.to_arrow().await.unwrap();
3280        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
3281
3282        // Verify we have 2 columns: x and _spec_id
3283        assert_eq!(batches[0].num_columns(), 2);
3284
3285        // Verify the _spec_id column exists
3286        let spec_id_col = batches[0].column_by_name(RESERVED_COL_NAME_SPEC_ID);
3287        assert!(
3288            spec_id_col.is_some(),
3289            "_spec_id column should be present in the batch"
3290        );
3291
3292        // Verify the _spec_id data type
3293        let spec_id_col = spec_id_col.unwrap();
3294        assert!(
3295            matches!(
3296                spec_id_col.data_type(),
3297                arrow_schema::DataType::RunEndEncoded(_, _)
3298            ),
3299            "_spec_id column should use RunEndEncoded type"
3300        );
3301
3302        // Decode the RunArray to verify it contains the spec id
3303        let run_array = spec_id_col
3304            .as_any()
3305            .downcast_ref::<RunArray<Int32Type>>()
3306            .expect("_spec_id column should be a RunArray");
3307
3308        let values = run_array.values();
3309        let int_values = values.as_primitive::<Int32Type>();
3310        assert_eq!(int_values.len(), 1, "Should have a single _spec_id");
3311
3312        let spec_id = int_values.value(0);
3313        assert_eq!(spec_id, 0, "_spec_id should be 0, got: {spec_id}");
3314    }
3315
3316    #[tokio::test]
3317    async fn test_select_with_spec_id_column_with_partition_evolution() {
3318        let mut fixture = TableTestFixture::new_with_partition_evolution();
3319        fixture
3320            .setup_manifest_files_with_partition_evolution()
3321            .await;
3322
3323        // Select regular columns plus the _spec_id column
3324        let table_scan = fixture
3325            .table
3326            .scan()
3327            .select(["x", RESERVED_COL_NAME_SPEC_ID, "z"])
3328            .with_row_selection_enabled(true)
3329            .build()
3330            .unwrap();
3331
3332        let batch_stream = table_scan.to_arrow().await.unwrap();
3333        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
3334
3335        // Verify the x column exists and has correct data
3336        let col1 = batches[0].column_by_name("x").unwrap();
3337        let int64_arr = col1.as_any().downcast_ref::<Int64Array>().unwrap();
3338        assert_eq!(int64_arr.value(0), 1);
3339
3340        // Verify the _spec_id column exists
3341        let spec_id_col = batches[0].column_by_name(RESERVED_COL_NAME_SPEC_ID);
3342        assert!(
3343            spec_id_col.is_some(),
3344            "_spec_id column should be present in the batch"
3345        );
3346
3347        // Verify the _spec_id data type
3348        let spec_id_col = spec_id_col.unwrap();
3349        assert!(
3350            matches!(
3351                spec_id_col.data_type(),
3352                arrow_schema::DataType::RunEndEncoded(_, _)
3353            ),
3354            "_spec_id column should use RunEndEncoded type"
3355        );
3356
3357        // Decode the RunArray to verify it contains the spec id
3358        let run_array = spec_id_col
3359            .as_any()
3360            .downcast_ref::<RunArray<Int32Type>>()
3361            .expect("_spec_id column should be a RunArray");
3362
3363        let values = run_array.values();
3364        let int_values = values.as_primitive::<Int32Type>();
3365        assert_eq!(int_values.len(), 1, "Should have a single _spec_id");
3366
3367        let spec_id = int_values.value(0);
3368        assert_eq!(spec_id, 2, "_spec_id should be 2, got: {spec_id}");
3369    }
3370
3371    #[tokio::test]
3372    async fn test_select_with_pos_and_file_columns() {
3373        use arrow_array::cast::AsArray;
3374
3375        let mut fixture = TableTestFixture::new();
3376        fixture.setup_manifest_files().await;
3377
3378        // Select regular columns plus the _pos column
3379        let table_scan = fixture
3380            .table
3381            .scan()
3382            .select(["x", RESERVED_COL_NAME_POS, RESERVED_COL_NAME_FILE])
3383            .with_row_selection_enabled(true)
3384            .build()
3385            .unwrap();
3386
3387        let batch_stream = table_scan.to_arrow().await.unwrap();
3388        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
3389        assert_eq!(batches.len(), 2);
3390
3391        // Examine batches are 1.paruqet and 3.parquet, 2.parquet is deleted.
3392        for batch in batches.iter() {
3393            // Verify we have 3 columns: x, _pos and _file
3394            assert_eq!(batch.num_columns(), 3);
3395
3396            // Verify the x column exists and has correct data
3397            let x_col = batch.column_by_name("x").unwrap();
3398            let x_arr = x_col.as_primitive::<arrow_array::types::Int64Type>();
3399            assert_eq!(x_arr.value(0), 1);
3400
3401            // The _pos column exists and verify it is Int64Array with the expected values
3402            let pos_col = batch.column(1);
3403            let pos_array: &Int64Array = pos_col
3404                .as_any()
3405                .downcast_ref::<Int64Array>()
3406                .expect("_pos column should be a Int64Array");
3407            assert_eq!(*pos_array, Int64Array::from_iter_values(0i64..1024));
3408
3409            // Verify the _file column exists
3410            let file_col = batch.column_by_name(RESERVED_COL_NAME_FILE);
3411            assert!(
3412                file_col.is_some(),
3413                "_file column should be present in the batch"
3414            );
3415        }
3416    }
3417
3418    #[tokio::test]
3419    async fn test_pos_column_at_start_with_filters() {
3420        let mut fixture = TableTestFixture::new();
3421        fixture.setup_manifest_files().await;
3422
3423        // y is in [4, 5)
3424        let predicate = Reference::new("y")
3425            .greater_than(Datum::long(4i64))
3426            .and(Reference::new("y").less_than_or_equal_to(Datum::long(5i64)));
3427        // Select _pos at the start
3428        let table_scan = fixture
3429            .table
3430            .scan()
3431            .select([RESERVED_COL_NAME_POS, "x", "y"])
3432            .with_filter(predicate)
3433            .with_row_selection_enabled(true)
3434            .build()
3435            .unwrap();
3436
3437        let batch_stream = table_scan.to_arrow().await.unwrap();
3438        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
3439        assert_eq!(batches.len(), 2);
3440
3441        // Examine batches are 1.paruqet and 3.parquet, 2.parquet is deleted.
3442        for batch in batches.iter() {
3443            assert_eq!(batch.num_columns(), 3);
3444            assert_eq!(batch.num_rows(), 12);
3445
3446            // Verify _pos is at position 0
3447            let schema = batch.schema();
3448            assert_eq!(schema.field(0).name(), RESERVED_COL_NAME_POS);
3449            assert_eq!(schema.field(1).name(), "x");
3450            assert_eq!(schema.field(2).name(), "y");
3451
3452            let pos_col = batch.column(0);
3453            let pos_array: &Int64Array = pos_col
3454                .as_any()
3455                .downcast_ref::<Int64Array>()
3456                .expect("_pos column should be a Int64Array");
3457            assert_eq!(*pos_array, Int64Array::from_iter_values(1012i64..1024));
3458        }
3459    }
3460
3461    #[tokio::test]
3462    async fn test_repeated_pos_column_with_filter() {
3463        let mut fixture = TableTestFixture::new();
3464        fixture.setup_manifest_files().await;
3465
3466        // a NOT STARTSWITH "Apa"
3467        let predicate = Reference::new("a").not_starts_with(Datum::string("Apa"));
3468        // Select '_pos' columns twice
3469        let table_scan = fixture
3470            .table
3471            .scan()
3472            .select([RESERVED_COL_NAME_POS, "a", RESERVED_COL_NAME_POS, "x"])
3473            .with_row_selection_enabled(true)
3474            .with_filter(predicate)
3475            .build()
3476            .unwrap();
3477
3478        let batch_stream = table_scan.to_arrow().await.unwrap();
3479
3480        let batches: Vec<_> = batch_stream.try_collect().await.unwrap();
3481        assert_eq!(batches.len(), 2);
3482
3483        // Examine batches are 1.paruqet and 3.parquet, 2.parquet is deleted.
3484        for batch in batches.iter() {
3485            assert_eq!(batch.num_rows(), 512);
3486
3487            // fetch the 1st _pos column by name and verify it is Int64Array with the expected values
3488            let pos_col = batch
3489                .column_by_name("_pos")
3490                .expect("_pos column should be present in the batch");
3491            let pos_array: &Int64Array = pos_col
3492                .as_any()
3493                .downcast_ref::<Int64Array>()
3494                .expect("_pos column should be a Int64Array");
3495            assert_eq!(*pos_array, Int64Array::from_iter_values(512i64..1024));
3496
3497            // fetch the 2nd _pos column by index and verify it is Int64Array with the expected values
3498            let pos_col = batch.column(2);
3499            let pos_array: &Int64Array = pos_col
3500                .as_any()
3501                .downcast_ref::<Int64Array>()
3502                .expect("_pos column should be a Int64Array");
3503            assert_eq!(*pos_array, Int64Array::from_iter_values(512i64..1024));
3504        }
3505    }
3506
3507    /// End-to-end through `TableScan`: a data file with three row groups planned
3508    /// as a single whole-file `FileScanTask` must yield contiguous, file-absolute
3509    /// `_pos` values (0..300) across the row-group boundaries.
3510    #[tokio::test]
3511    async fn test_pos_across_row_groups_via_table_scan() {
3512        let mut fixture = TableTestFixture::new();
3513        fixture.setup_multi_row_group_manifest(&[]).await;
3514
3515        // Planning must produce exactly one whole-file task with _pos projected and
3516        // no delete files, confirming TableScan does not sub-split the file.
3517        let tasks: Vec<_> = fixture
3518            .table
3519            .scan()
3520            .select(["x", RESERVED_COL_NAME_POS])
3521            .build()
3522            .unwrap()
3523            .plan_files()
3524            .await
3525            .unwrap()
3526            .try_collect()
3527            .await
3528            .unwrap();
3529        assert_eq!(tasks.len(), 1, "expected a single FileScanTask");
3530        let task = &tasks[0];
3531        assert!(
3532            task.project_field_ids().contains(&RESERVED_FIELD_ID_POS),
3533            "_pos field id must be projected into the FileScanTask"
3534        );
3535        assert_eq!(task.start(), 0, "TableScan should plan whole-file tasks");
3536        assert_eq!(task.length(), task.file_size_in_bytes());
3537        assert!(task.deletes().is_empty());
3538
3539        // Reading that task yields absolute _pos 0..300 in order.
3540        let batches: Vec<_> = fixture
3541            .table
3542            .scan()
3543            .select(["x", RESERVED_COL_NAME_POS])
3544            .build()
3545            .unwrap()
3546            .to_arrow()
3547            .await
3548            .unwrap()
3549            .try_collect()
3550            .await
3551            .unwrap();
3552
3553        let pos: Vec<i64> = batches
3554            .iter()
3555            .flat_map(|b| {
3556                b.column_by_name(RESERVED_COL_NAME_POS)
3557                    .expect("_pos column should be present")
3558                    .as_any()
3559                    .downcast_ref::<Int64Array>()
3560                    .expect("_pos column should be a Int64Array")
3561                    .values()
3562                    .to_vec()
3563            })
3564            .collect();
3565        assert_eq!(pos, (0..300).collect::<Vec<i64>>());
3566
3567        // Sanity: x == 1000 + _pos, proving _pos aligns with the actual rows read.
3568        let x: Vec<i64> = batches
3569            .iter()
3570            .flat_map(|b| {
3571                b.column_by_name("x")
3572                    .unwrap()
3573                    .as_primitive::<arrow_array::types::Int64Type>()
3574                    .values()
3575                    .to_vec()
3576            })
3577            .collect();
3578        assert_eq!(x, (1000..1300).collect::<Vec<i64>>());
3579    }
3580
3581    /// A positional delete file registered in the manifest must be attached to the planned
3582    /// `FileScanTask` and applied on read, while surviving `_pos` values stay file-absolute.
3583    #[tokio::test]
3584    async fn test_pos_with_positional_deletes_via_table_scan() {
3585        let mut fixture = TableTestFixture::new();
3586        // Delete file-absolute positions 150 (middle row group) and 299 (last row).
3587        fixture.setup_multi_row_group_manifest(&[150, 299]).await;
3588
3589        // Planning must attach the positional delete file to the task.
3590        let tasks: Vec<_> = fixture
3591            .table
3592            .scan()
3593            .select(["x", RESERVED_COL_NAME_POS])
3594            .build()
3595            .unwrap()
3596            .plan_files()
3597            .await
3598            .unwrap()
3599            .try_collect()
3600            .await
3601            .unwrap();
3602        assert_eq!(tasks.len(), 1);
3603        assert_eq!(
3604            tasks[0].deletes().len(),
3605            1,
3606            "positional delete file should be planned into the task"
3607        );
3608        assert_eq!(
3609            tasks[0].deletes()[0].file_type(),
3610            DataContentType::PositionDeletes
3611        );
3612
3613        // Reading applies the deletes; _pos must skip 150 and 299 and stay absolute.
3614        let batches: Vec<_> = fixture
3615            .table
3616            .scan()
3617            .select(["x", RESERVED_COL_NAME_POS])
3618            .build()
3619            .unwrap()
3620            .to_arrow()
3621            .await
3622            .unwrap()
3623            .try_collect()
3624            .await
3625            .unwrap();
3626
3627        let pos: Vec<i64> = batches
3628            .iter()
3629            .flat_map(|b| {
3630                b.column_by_name(RESERVED_COL_NAME_POS)
3631                    .expect("_pos column should be present")
3632                    .as_any()
3633                    .downcast_ref::<Int64Array>()
3634                    .expect("_pos column should be a Int64Array")
3635                    .values()
3636                    .to_vec()
3637            })
3638            .collect();
3639
3640        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
3641        assert_eq!(
3642            total, 298,
3643            "two rows should be removed by positional deletes"
3644        );
3645        assert!(!pos.contains(&150) && !pos.contains(&299), "got {pos:?}");
3646        let expected: Vec<i64> = (0..150).chain(151..299).collect();
3647        assert_eq!(pos, expected);
3648    }
3649
3650    /// A filter that only matches the middle row group (`y` in [1100, 1200)) and
3651    /// prunes the other two row groups by statistics, so only the middle row group
3652    /// is read. `_pos` must report the file-absolute positions 100..200 for those
3653    /// rows, not values reset to 0..100.
3654    ///
3655    /// `y` is a non-partition column, so pruning here is driven purely by Parquet
3656    /// row-group statistics (the TableTestFixture's partition column is `x`).
3657    #[tokio::test]
3658    async fn test_pos_reads_only_middle_row_group_via_filter() {
3659        let mut fixture = TableTestFixture::new();
3660        fixture.setup_multi_row_group_manifest(&[]).await;
3661
3662        // Middle row group holds y = 1100..1200 at file positions 100..200.
3663        let predicate = Reference::new("y")
3664            .greater_than_or_equal_to(Datum::long(1100))
3665            .and(Reference::new("y").less_than(Datum::long(1200)));
3666
3667        let batches: Vec<_> = fixture
3668            .table
3669            .scan()
3670            .select(["y", RESERVED_COL_NAME_POS])
3671            .with_filter(predicate)
3672            .with_row_group_filtering_enabled(true)
3673            .build()
3674            .unwrap()
3675            .to_arrow()
3676            .await
3677            .unwrap()
3678            .try_collect()
3679            .await
3680            .unwrap();
3681
3682        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
3683        assert_eq!(total, 100, "only the middle row group should be read");
3684
3685        let pos: Vec<i64> = batches
3686            .iter()
3687            .flat_map(|b| {
3688                b.column_by_name(RESERVED_COL_NAME_POS)
3689                    .expect("_pos column should be present")
3690                    .as_any()
3691                    .downcast_ref::<Int64Array>()
3692                    .expect("_pos column should be a Int64Array")
3693                    .values()
3694                    .to_vec()
3695            })
3696            .collect();
3697        assert_eq!(
3698            pos,
3699            (100..200).collect::<Vec<i64>>(),
3700            "_pos must be file-absolute for the middle row group"
3701        );
3702
3703        // Cross-check: y == 1000 + _pos for every surviving row.
3704        let y: Vec<i64> = batches
3705            .iter()
3706            .flat_map(|b| {
3707                b.column_by_name("y")
3708                    .unwrap()
3709                    .as_primitive::<arrow_array::types::Int64Type>()
3710                    .values()
3711                    .to_vec()
3712            })
3713            .collect();
3714        assert_eq!(y, (1100..1200).collect::<Vec<i64>>());
3715    }
3716}