Skip to main content

datafusion_catalog_listing/
table.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
18use crate::config::SchemaSource;
19use crate::helpers::{
20    expr_applicable_for_cols, filter_partitioned_file, pruned_partition_list,
21};
22use crate::{ListingOptions, ListingTableConfig};
23use arrow::datatypes::{Field, Schema, SchemaBuilder, SchemaRef};
24use async_trait::async_trait;
25use datafusion_catalog::{ScanArgs, ScanResult, Session, TableProvider};
26use datafusion_common::stats::Precision;
27use datafusion_common::{
28    Constraints, DFSchema, SchemaExt, Statistics, internal_datafusion_err, plan_err,
29    project_schema,
30};
31use datafusion_datasource::file::FileSource;
32use datafusion_datasource::file_groups::FileGroup;
33use datafusion_datasource::file_scan_config::{
34    FileScanConfig, FileScanConfigBuilder, output_partitioning_from_partition_fields,
35};
36use datafusion_datasource::file_sink_config::{FileOutputMode, FileSinkConfig};
37#[expect(deprecated)]
38use datafusion_datasource::schema_adapter::SchemaAdapterFactory;
39use datafusion_datasource::{
40    ListingTableUrl, PartitionedFile, TableSchemaBuilder, compute_all_files_statistics,
41};
42use datafusion_execution::cache::cache_manager::{
43    CachedFileMetadata, FileStatisticsCache, SchemaFingerprint, TableScopedPath,
44};
45use datafusion_expr::dml::InsertOp;
46use datafusion_expr::execution_props::ExecutionProps;
47use datafusion_expr::physical_planning_context::PhysicalPlanningContext;
48use datafusion_expr::{
49    Expr, Partitioning as LogicalPartitioning, TableProviderFilterPushDown, TableType,
50};
51use datafusion_physical_expr::{create_lex_ordering, create_physical_partitioning};
52use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory;
53use datafusion_physical_expr_common::sort_expr::LexOrdering;
54use datafusion_physical_plan::ExecutionPlan;
55use datafusion_physical_plan::empty::EmptyExec;
56use futures::{Stream, StreamExt, TryStreamExt, future, stream};
57use object_store::ObjectStore;
58use std::collections::{HashMap, HashSet};
59use std::sync::Arc;
60
61/// Result of a file listing operation from [`ListingTable::list_files_for_scan`].
62#[derive(Debug)]
63pub struct ListFilesResult {
64    /// File groups organized by the partitioning strategy.
65    pub file_groups: Vec<FileGroup>,
66    /// Aggregated statistics for all files.
67    pub statistics: Statistics,
68    /// Whether files are grouped by partition values.
69    pub grouped_by_partition: bool,
70}
71
72/// Built in [`TableProvider`] that reads data from one or more files as a single table.
73///
74/// The files are read using an  [`ObjectStore`] instance, for example from
75/// local files or objects from AWS S3.
76///
77/// # Features:
78/// * Reading multiple files as a single table
79/// * Hive style partitioning (e.g., directories named `date=2024-06-01`)
80/// * Merges schemas from files with compatible but not identical schemas (see [`ListingTableConfig::file_schema`])
81/// * `limit`, `filter` and `projection` pushdown for formats that support it (e.g.,
82///   Parquet)
83/// * Statistics collection and pruning based on file metadata
84/// * Pre-existing sort order (see [`ListingOptions::file_sort_order`])
85/// * Metadata caching to speed up repeated queries (see [`FileMetadataCache`])
86/// * Statistics caching (see [`FileStatisticsCache`])
87///
88/// [`FileMetadataCache`]: datafusion_execution::cache::cache_manager::FileMetadataCache
89///
90/// # Reading Directories and Hive Style Partitioning
91///
92/// For example, given the `table1` directory (or object store prefix)
93///
94/// ```text
95/// table1
96///  ├── file1.parquet
97///  └── file2.parquet
98/// ```
99///
100/// A `ListingTable` would read the files `file1.parquet` and `file2.parquet` as
101/// a single table, merging the schemas if the files have compatible but not
102/// identical schemas.
103///
104/// Given the `table2` directory (or object store prefix)
105///
106/// ```text
107/// table2
108///  ├── date=2024-06-01
109///  │    ├── file3.parquet
110///  │    └── file4.parquet
111///  └── date=2024-06-02
112///       └── file5.parquet
113/// ```
114///
115/// A `ListingTable` would read the files `file3.parquet`, `file4.parquet`, and
116/// `file5.parquet` as a single table, again merging schemas if necessary.
117///
118/// Given the hive style partitioning structure (e.g,. directories named
119/// `date=2024-06-01` and `date=2026-06-02`), `ListingTable` also adds a `date`
120/// column when reading the table:
121/// * The files in `table2/date=2024-06-01` will have the value `2024-06-01`
122/// * The files in `table2/date=2024-06-02` will have the value `2024-06-02`.
123///
124/// If the query has a predicate like `WHERE date = '2024-06-01'`
125/// only the corresponding directory will be read.
126///
127/// # See Also
128///
129/// 1. [`ListingTableConfig`]: Configuration options
130/// 1. [`DataSourceExec`]: `ExecutionPlan` used by `ListingTable`
131///
132/// [`DataSourceExec`]: datafusion_datasource::source::DataSourceExec
133///
134/// # Caching Metadata
135///
136/// Some formats, such as Parquet, use the `FileMetadataCache` to cache file
137/// metadata that is needed to execute but expensive to read, such as row
138/// groups and statistics. The cache is scoped to the `SessionContext` and can
139/// be configured via the [runtime config options].
140///
141/// [runtime config options]: https://datafusion.apache.org/user-guide/configs.html#runtime-configuration-settings
142///
143/// # Example: Read a directory of parquet files using a [`ListingTable`]
144///
145/// ```no_run
146/// # use datafusion_common::Result;
147/// # use std::sync::Arc;
148/// # use datafusion_catalog::TableProvider;
149/// # use datafusion_catalog_listing::{ListingOptions, ListingTable, ListingTableConfig};
150/// # use datafusion_datasource::ListingTableUrl;
151/// # use datafusion_datasource_parquet::file_format::ParquetFormat;/// #
152/// # use datafusion_catalog::Session;
153/// async fn get_listing_table(session: &dyn Session) -> Result<Arc<dyn TableProvider>> {
154///     let table_path = "/path/to/parquet";
155///
156///     // Parse the path
157///     let table_path = ListingTableUrl::parse(table_path)?;
158///
159///     // Create default parquet options
160///     let file_format = ParquetFormat::new();
161///     let listing_options = ListingOptions::new(Arc::new(file_format))
162///         .with_file_extension(".parquet");
163///
164/// // Resolve the schema
165/// let resolved_schema = listing_options
166///    .infer_schema(session, &table_path)
167///    .await?;
168///
169/// let config = ListingTableConfig::new(table_path)
170///   .with_listing_options(listing_options)
171///   .with_schema(resolved_schema);
172///
173/// // Create a new TableProvider
174/// let provider = Arc::new(ListingTable::try_new(config)?);
175///
176/// Ok(provider)
177/// }
178/// ```
179#[derive(Debug, Clone)]
180pub struct ListingTable {
181    table_paths: Vec<ListingTableUrl>,
182    /// `file_schema` contains only the columns physically stored in the data files themselves.
183    ///     - Represents the actual fields found in files like Parquet, CSV, etc.
184    ///     - Used when reading the raw data from files
185    file_schema: SchemaRef,
186    /// `table_schema` combines `file_schema` + partition columns
187    ///     - Partition columns are derived from directory paths (not stored in files)
188    ///     - These are columns like "year=2022/month=01" in paths like `/data/year=2022/month=01/file.parquet`
189    table_schema: SchemaRef,
190    /// Indicates how the schema was derived (inferred or explicitly specified)
191    schema_source: SchemaSource,
192    /// Options used to configure the listing table such as the file format
193    /// and partitioning information
194    options: ListingOptions,
195    /// The SQL definition for this table, if any
196    definition: Option<String>,
197    /// Cache for collected file statistics
198    collected_statistics: Option<Arc<FileStatisticsCache>>,
199    /// Constraints applied to this table
200    constraints: Constraints,
201    /// Column default expressions for columns that are not physically present in the data files
202    column_defaults: HashMap<String, Expr>,
203    /// Optional [`PhysicalExprAdapterFactory`] for creating physical expression adapters
204    expr_adapter_factory: Option<Arc<dyn PhysicalExprAdapterFactory>>,
205    /// Precomputed fingerprint of `file_schema` for file-statistics cache
206    /// validation. Constant for the table, so computed once here instead of per
207    /// file.
208    file_schema_fingerprint: Arc<SchemaFingerprint>,
209}
210
211impl ListingTable {
212    /// Create new [`ListingTable`]
213    ///
214    /// See documentation and example on [`ListingTable`] and [`ListingTableConfig`]
215    pub fn try_new(config: ListingTableConfig) -> datafusion_common::Result<Self> {
216        // Extract schema_source before moving other parts of the config
217        let schema_source = config.schema_source();
218
219        let file_schema = config
220            .file_schema
221            .ok_or_else(|| internal_datafusion_err!("No schema provided."))?;
222
223        let options = config
224            .options
225            .ok_or_else(|| internal_datafusion_err!("No ListingOptions provided"))?;
226
227        // Add the partition columns to the file schema
228        let mut builder = SchemaBuilder::from(file_schema.as_ref().to_owned());
229        for (part_col_name, part_col_type) in &options.table_partition_cols {
230            builder.push(Field::new(part_col_name, part_col_type.clone(), false));
231        }
232
233        let table_schema = Arc::new(
234            builder
235                .finish()
236                .with_metadata(file_schema.metadata().clone()),
237        );
238
239        let file_schema_fingerprint =
240            Arc::new(SchemaFingerprint::from_schema(&file_schema));
241
242        let table = Self {
243            table_paths: config.table_paths,
244            file_schema,
245            table_schema,
246            schema_source,
247            options,
248            definition: None,
249            collected_statistics: None,
250            constraints: Constraints::default(),
251            column_defaults: HashMap::new(),
252            expr_adapter_factory: config.expr_adapter_factory,
253            file_schema_fingerprint,
254        };
255
256        Ok(table)
257    }
258
259    /// Assign constraints
260    pub fn with_constraints(mut self, constraints: Constraints) -> Self {
261        self.constraints = constraints;
262        self
263    }
264
265    /// Assign column defaults
266    pub fn with_column_defaults(
267        mut self,
268        column_defaults: HashMap<String, Expr>,
269    ) -> Self {
270        self.column_defaults = column_defaults;
271        self
272    }
273
274    /// Set the [`FileStatisticsCache`] used to cache parquet file statistics.
275    ///
276    /// Setting a statistics cache on the `SessionContext` can avoid refetching statistics
277    /// multiple times in the same session.
278    ///
279    pub fn with_cache(mut self, cache: Option<Arc<FileStatisticsCache>>) -> Self {
280        self.collected_statistics = cache;
281        self
282    }
283
284    /// Specify the SQL definition for this table, if any
285    pub fn with_definition(mut self, definition: Option<String>) -> Self {
286        self.definition = definition;
287        self
288    }
289
290    /// Get paths ref
291    pub fn table_paths(&self) -> &Vec<ListingTableUrl> {
292        &self.table_paths
293    }
294
295    /// Get options ref
296    pub fn options(&self) -> &ListingOptions {
297        &self.options
298    }
299
300    /// Get the schema source
301    pub fn schema_source(&self) -> SchemaSource {
302        self.schema_source
303    }
304
305    /// Deprecated: Set the [`SchemaAdapterFactory`] for this [`ListingTable`]
306    ///
307    /// `SchemaAdapterFactory` has been removed. Use [`ListingTableConfig::with_expr_adapter_factory`]
308    /// and `PhysicalExprAdapterFactory` instead. See `upgrading.md` for more details.
309    ///
310    /// This method is a no-op and returns `self` unchanged.
311    #[deprecated(
312        since = "52.0.0",
313        note = "SchemaAdapterFactory has been removed. Use ListingTableConfig::with_expr_adapter_factory and PhysicalExprAdapterFactory instead. See upgrading.md for more details."
314    )]
315    #[expect(deprecated)]
316    pub fn with_schema_adapter_factory(
317        self,
318        _schema_adapter_factory: Arc<dyn SchemaAdapterFactory>,
319    ) -> Self {
320        // No-op - just return self unchanged
321        self
322    }
323
324    /// Deprecated: Returns the [`SchemaAdapterFactory`] used by this [`ListingTable`].
325    ///
326    /// `SchemaAdapterFactory` has been removed. Use `PhysicalExprAdapterFactory` instead.
327    /// See `upgrading.md` for more details.
328    ///
329    /// Always returns `None`.
330    #[deprecated(
331        since = "52.0.0",
332        note = "SchemaAdapterFactory has been removed. Use PhysicalExprAdapterFactory instead. See upgrading.md for more details."
333    )]
334    #[expect(deprecated)]
335    pub fn schema_adapter_factory(&self) -> Option<Arc<dyn SchemaAdapterFactory>> {
336        None
337    }
338
339    /// Creates a file source for this table
340    fn create_file_source(&self) -> Arc<dyn FileSource> {
341        let table_schema = TableSchemaBuilder::from(&self.file_schema)
342            .with_table_partition_cols(
343                self.options
344                    .table_partition_cols
345                    .iter()
346                    .map(|(col, field)| Arc::new(Field::new(col, field.clone(), false)))
347                    .collect::<Vec<_>>(),
348            )
349            .build();
350
351        self.options.format.file_source(table_schema)
352    }
353
354    /// Creates output ordering from user-specified file_sort_order or derives
355    /// from file orderings when user doesn't specify.
356    ///
357    /// If user specified `file_sort_order`, that takes precedence.
358    /// Otherwise, attempts to derive common ordering from file orderings in
359    /// the provided file groups.
360    pub fn try_create_output_ordering(
361        &self,
362        execution_props: &ExecutionProps,
363        file_groups: &[FileGroup],
364    ) -> datafusion_common::Result<Vec<LexOrdering>> {
365        // If user specified sort order, use that
366        if !self.options.file_sort_order.is_empty() {
367            return create_lex_ordering(
368                &self.table_schema,
369                &self.options.file_sort_order,
370                execution_props,
371            );
372        }
373        if let Some(ordering) = derive_common_ordering_from_files(file_groups) {
374            return Ok(vec![ordering]);
375        }
376        Ok(vec![])
377    }
378}
379
380/// Derives a common ordering from file orderings across all file groups.
381///
382/// Returns the common ordering if all files have compatible orderings,
383/// otherwise returns None.
384///
385/// The function finds the longest common prefix among all file orderings.
386/// For example, if files have orderings `[a, b, c]` and `[a, b]`, the common
387/// ordering is `[a, b]`.
388fn derive_common_ordering_from_files(file_groups: &[FileGroup]) -> Option<LexOrdering> {
389    enum CurrentOrderingState {
390        /// Initial state before processing any files
391        FirstFile,
392        /// Some common ordering found so far
393        SomeOrdering(LexOrdering),
394        /// No files have ordering
395        NoOrdering,
396    }
397    let mut state = CurrentOrderingState::FirstFile;
398
399    // Collect file orderings and track counts
400    for group in file_groups {
401        for file in group.iter() {
402            state = match (&state, &file.ordering) {
403                // If this is the first file with ordering, set it as current
404                (CurrentOrderingState::FirstFile, Some(ordering)) => {
405                    CurrentOrderingState::SomeOrdering(ordering.clone())
406                }
407                (CurrentOrderingState::FirstFile, None) => {
408                    CurrentOrderingState::NoOrdering
409                }
410                // If we have an existing ordering, find common prefix with new ordering
411                (CurrentOrderingState::SomeOrdering(current), Some(ordering)) => {
412                    // Find common prefix between current and new ordering
413                    let prefix_len = current
414                        .as_ref()
415                        .iter()
416                        .zip(ordering.as_ref().iter())
417                        .take_while(|(a, b)| a == b)
418                        .count();
419                    if prefix_len == 0 {
420                        log::trace!(
421                            "Cannot derive common ordering: no common prefix between orderings {current:?} and {ordering:?}"
422                        );
423                        return None;
424                    } else {
425                        let ordering =
426                            LexOrdering::new(current.as_ref()[..prefix_len].to_vec())
427                                .expect("prefix_len > 0, so ordering must be valid");
428                        CurrentOrderingState::SomeOrdering(ordering)
429                    }
430                }
431                // If one file has ordering and another doesn't, no common ordering
432                // Return None and log a trace message explaining why
433                (CurrentOrderingState::SomeOrdering(ordering), None)
434                | (CurrentOrderingState::NoOrdering, Some(ordering)) => {
435                    log::trace!(
436                        "Cannot derive common ordering: some files have ordering {ordering:?}, others don't"
437                    );
438                    return None;
439                }
440                // Both have no ordering, remain in NoOrdering state
441                (CurrentOrderingState::NoOrdering, None) => {
442                    CurrentOrderingState::NoOrdering
443                }
444            };
445        }
446    }
447
448    match state {
449        CurrentOrderingState::SomeOrdering(ordering) => Some(ordering),
450        _ => None,
451    }
452}
453
454fn filter_file_group_by_partition_filters(
455    file_group: FileGroup,
456    filters: &[Expr],
457    df_schema: &DFSchema,
458) -> datafusion_common::Result<FileGroup> {
459    let files = file_group
460        .into_inner()
461        .into_iter()
462        .map(|file| filter_partitioned_file(file, filters, df_schema))
463        .filter_map(Result::transpose)
464        .collect::<datafusion_common::Result<Vec<_>>>()?;
465    Ok(FileGroup::new(files))
466}
467
468// Expressions can be used for partition pruning if they can be evaluated using
469// only the partition columns and there are partition columns.
470fn can_be_evaluated_for_partition_pruning(
471    partition_column_names: &[&str],
472    expr: &Expr,
473) -> bool {
474    !partition_column_names.is_empty()
475        && expr_applicable_for_cols(partition_column_names, expr)
476}
477
478#[async_trait]
479impl TableProvider for ListingTable {
480    fn schema(&self) -> SchemaRef {
481        Arc::clone(&self.table_schema)
482    }
483
484    fn constraints(&self) -> Option<&Constraints> {
485        Some(&self.constraints)
486    }
487
488    fn table_type(&self) -> TableType {
489        TableType::Base
490    }
491
492    async fn scan(
493        &self,
494        state: &dyn Session,
495        projection: Option<&Vec<usize>>,
496        filters: &[Expr],
497        limit: Option<usize>,
498    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
499        let options = ScanArgs::default()
500            .with_projection(projection.map(|p| p.as_slice()))
501            .with_filters(Some(filters))
502            .with_limit(limit);
503        Ok(self.scan_with_args(state, options).await?.into_inner())
504    }
505
506    async fn scan_with_args<'a>(
507        &self,
508        state: &dyn Session,
509        args: ScanArgs<'a>,
510    ) -> datafusion_common::Result<ScanResult> {
511        let projection = args.projection().map(|p| p.to_vec());
512        let filters = args.filters().map(|f| f.to_vec()).unwrap_or_default();
513        let limit = args.limit();
514
515        // extract types of partition columns
516        let table_partition_cols = self
517            .options
518            .table_partition_cols
519            .iter()
520            .map(|col| Ok(Arc::new(self.table_schema.field_with_name(&col.0)?.clone())))
521            .collect::<datafusion_common::Result<Vec<_>>>()?;
522
523        let table_partition_col_names = table_partition_cols
524            .iter()
525            .map(|field| field.name().as_str())
526            .collect::<Vec<_>>();
527
528        // If the filters can be resolved using only partition cols, there is no need to
529        // pushdown it to TableScan, otherwise, `unhandled` pruning predicates will be generated
530        let (partition_filters, filters): (Vec<_>, Vec<_>) =
531            filters.iter().cloned().partition(|filter| {
532                can_be_evaluated_for_partition_pruning(&table_partition_col_names, filter)
533            });
534
535        let declared_output_partitioning = self.options.output_partitioning.as_ref();
536
537        // We should not limit files before assigning declared output partitions
538        // or before applying non-partition filters.
539        let statistic_file_limit =
540            if filters.is_empty() && declared_output_partitioning.is_none() {
541                limit
542            } else {
543                None
544            };
545        let file_group_count = declared_output_partitioning
546            .and_then(LogicalPartitioning::partition_count)
547            .unwrap_or_else(|| state.config().target_partitions());
548
549        let ListFilesResult {
550            file_groups: mut partitioned_file_lists,
551            statistics,
552            grouped_by_partition: partitioned_by_file_group,
553        } = self
554            .list_files_for_scan(state, &partition_filters, statistic_file_limit)
555            .await?;
556
557        // if no files need to be read, return an `EmptyExec`
558        if partitioned_file_lists.is_empty() {
559            let projected_schema = project_schema(&self.schema(), projection.as_ref())?;
560            return Ok(ScanResult::new(Arc::new(EmptyExec::new(projected_schema))));
561        }
562
563        let output_ordering = self.try_create_output_ordering(
564            state.execution_props(),
565            &partitioned_file_lists,
566        )?;
567        let split_file_groups_by_statistics = declared_output_partitioning.is_none()
568            && state
569                .config_options()
570                .execution
571                .split_file_groups_by_statistics;
572        match split_file_groups_by_statistics
573            .then(|| {
574                output_ordering.first().map(|output_ordering| {
575                    FileScanConfig::split_groups_by_statistics_with_target_partitions(
576                        &self.table_schema,
577                        &partitioned_file_lists,
578                        output_ordering,
579                        file_group_count,
580                    )
581                })
582            })
583            .flatten()
584        {
585            Some(Err(e)) => log::debug!("failed to split file groups by statistics: {e}"),
586            Some(Ok(new_groups)) => {
587                if new_groups.len() <= file_group_count {
588                    partitioned_file_lists = new_groups;
589                } else {
590                    log::debug!(
591                        "attempted to split file groups by statistics, but there were more file groups than target_partitions; falling back to unordered"
592                    )
593                }
594            }
595            None => {} // no ordering required
596        };
597
598        let output_partitioning = if let Some(output_partitioning) =
599            declared_output_partitioning
600        {
601            let output_partitioning = match output_partitioning {
602                LogicalPartitioning::RoundRobinBatch(_) => {
603                    return datafusion_common::not_impl_err!(
604                        "RoundRobinBatch output partitioning is not supported for ListingTable"
605                    );
606                }
607                LogicalPartitioning::DistributeBy(_) => {
608                    return datafusion_common::not_impl_err!(
609                        "DistributeBy output partitioning is not supported for ListingTable"
610                    );
611                }
612                LogicalPartitioning::Hash(_, _) | LogicalPartitioning::Range(_) => {
613                    let df_schema = DFSchema::try_from(Arc::clone(&self.table_schema))?;
614                    create_physical_partitioning(
615                        output_partitioning,
616                        &df_schema,
617                        state.execution_props(),
618                        &PhysicalPlanningContext::default(),
619                    )?
620                }
621            };
622            let partition_count = output_partitioning.partition_count();
623            if partitioned_file_lists.len() != partition_count {
624                return plan_err!(
625                    "ListingTable output_partitioning has {partition_count} partitions, but the scan has {} file groups",
626                    partitioned_file_lists.len()
627                );
628            }
629            Some(output_partitioning)
630        } else if partitioned_by_file_group {
631            // Files are grouped by partition column values: declare output
632            // partitioning on those columns so the optimizer can skip
633            // repartitioning for aggregates and joins on the partition columns.
634            output_partitioning_from_partition_fields(
635                &self.table_schema,
636                &table_partition_cols.clone().into(),
637                partitioned_file_lists.len(),
638            )
639        } else {
640            None
641        };
642
643        let Some(object_store_url) =
644            self.table_paths.first().map(ListingTableUrl::object_store)
645        else {
646            return Ok(ScanResult::new(Arc::new(EmptyExec::new(Arc::new(
647                Schema::empty(),
648            )))));
649        };
650
651        let file_source = self.create_file_source();
652        let scan_config = FileScanConfigBuilder::new(object_store_url, file_source)
653            .with_file_groups(partitioned_file_lists)
654            .with_constraints(self.constraints.clone())
655            .with_statistics(statistics)
656            .with_projection_indices(projection)?
657            .with_limit(limit)
658            .with_output_ordering(output_ordering)
659            .with_output_partitioning(output_partitioning)
660            .with_expr_adapter(self.expr_adapter_factory.clone())
661            .build();
662
663        // create the execution plan
664        let plan = self
665            .options
666            .format
667            .create_physical_plan(state, scan_config)
668            .await?;
669
670        Ok(ScanResult::new(plan))
671    }
672
673    fn supports_filters_pushdown(
674        &self,
675        filters: &[&Expr],
676    ) -> datafusion_common::Result<Vec<TableProviderFilterPushDown>> {
677        let partition_column_names = self
678            .options
679            .table_partition_cols
680            .iter()
681            .map(|col| col.0.as_str())
682            .collect::<Vec<_>>();
683        filters
684            .iter()
685            .map(|filter| {
686                if can_be_evaluated_for_partition_pruning(&partition_column_names, filter)
687                {
688                    // if filter can be handled by partition pruning, it is exact
689                    return Ok(TableProviderFilterPushDown::Exact);
690                }
691
692                Ok(TableProviderFilterPushDown::Inexact)
693            })
694            .collect()
695    }
696
697    fn get_table_definition(&self) -> Option<&str> {
698        self.definition.as_deref()
699    }
700
701    async fn insert_into(
702        &self,
703        state: &dyn Session,
704        input: Arc<dyn ExecutionPlan>,
705        insert_op: InsertOp,
706    ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
707        // Check that the schema of the plan matches the schema of this table.
708        self.schema()
709            .logically_equivalent_names_and_types(&input.schema())?;
710
711        let table_path = &self.table_paths()[0];
712        if !table_path.is_collection() {
713            return plan_err!(
714                "Inserting into a ListingTable backed by a single file is not supported, URL is possibly missing a trailing `/`. \
715                To append to an existing file use StreamTable, e.g. by using CREATE UNBOUNDED EXTERNAL TABLE"
716            );
717        }
718
719        // Get the object store for the table path.
720        let store = state.runtime_env().object_store(table_path)?;
721
722        let file_list_stream = pruned_partition_list(
723            state,
724            store.as_ref(),
725            table_path,
726            &[],
727            &self.options.file_extension,
728            &self.options.table_partition_cols,
729        )
730        .await?;
731
732        let file_group = file_list_stream.try_collect::<Vec<_>>().await?.into();
733        let keep_partition_by_columns =
734            state.config_options().execution.keep_partition_by_columns;
735
736        // Invalidate cache entries for this table if they exist
737        if let Some(lfc) = state.runtime_env().cache_manager.get_list_files_cache() {
738            let key = TableScopedPath {
739                table: table_path.get_table_ref().clone(),
740                path: table_path.prefix().clone(),
741            };
742            let _ = lfc.remove(&key);
743        }
744
745        // Sink related option, apart from format
746        let config = FileSinkConfig {
747            original_url: String::default(),
748            object_store_url: self.table_paths()[0].object_store(),
749            table_paths: self.table_paths().clone(),
750            file_group,
751            output_schema: self.schema(),
752            table_partition_cols: self.options.table_partition_cols.clone(),
753            insert_op,
754            keep_partition_by_columns,
755            file_extension: self.options().format.get_ext(),
756            file_output_mode: FileOutputMode::Automatic,
757        };
758
759        // For writes, we only use user-specified ordering (no file groups to derive from)
760        let orderings = self.try_create_output_ordering(state.execution_props(), &[])?;
761        // It is sufficient to pass only one of the equivalent orderings:
762        let order_requirements = orderings.into_iter().next().map(Into::into);
763
764        self.options()
765            .format
766            .create_writer_physical_plan(input, state, config, order_requirements)
767            .await
768    }
769
770    fn get_column_default(&self, column: &str) -> Option<&Expr> {
771        self.column_defaults.get(column)
772    }
773}
774
775impl ListingTable {
776    /// Get the list of files for a scan as well as the file level statistics.
777    /// The list is grouped to let the execution plan know how the files should
778    /// be distributed to different threads / executors.
779    ///
780    /// If [`ListingOptions::output_partitioning`] is set, returns one file
781    /// group per declared partition, including empty trailing groups.
782    pub async fn list_files_for_scan<'a>(
783        &'a self,
784        ctx: &'a dyn Session,
785        filters: &'a [Expr],
786        limit: Option<usize>,
787    ) -> datafusion_common::Result<ListFilesResult> {
788        if let Some(output_partitioning) = self.options.output_partitioning.as_ref() {
789            self.list_files_for_declared_output_partitioning(
790                ctx,
791                output_partitioning,
792                filters,
793            )
794            .await
795        } else {
796            self.list_files_for_regular_scan(ctx, filters, limit).await
797        }
798    }
799
800    async fn collect_files_for_scan<'a>(
801        &'a self,
802        ctx: &'a dyn Session,
803        store: &'a Arc<dyn ObjectStore>,
804        listing_time_filters: &'a [Expr],
805        file_limit: Option<usize>,
806    ) -> datafusion_common::Result<(FileGroup, bool)> {
807        // list files (with partitions)
808        let file_list = future::try_join_all(self.table_paths.iter().map(|table_path| {
809            pruned_partition_list(
810                ctx,
811                store.as_ref(),
812                table_path,
813                listing_time_filters,
814                &self.options.file_extension,
815                &self.options.table_partition_cols,
816            )
817        }))
818        .await?;
819        let meta_fetch_concurrency =
820            ctx.config_options().execution.meta_fetch_concurrency.get();
821        // Table paths can overlap, for example when one path is a directory and
822        // another names a file inside it. A ListingTable uses one object store,
823        // so the object path uniquely identifies a file within this scan.
824        let mut seen_files = HashSet::new();
825        let file_list = stream::iter(file_list)
826            .flatten_unordered(meta_fetch_concurrency)
827            .try_filter(move |file| {
828                future::ready(seen_files.insert(file.object_meta.location.clone()))
829            });
830        // collect the statistics and ordering if required by the config
831        let files = file_list
832            .map(|part_file| async {
833                let part_file = part_file?;
834                let (statistics, ordering) = if ctx.config().collect_statistics() {
835                    self.do_collect_statistics_and_ordering(ctx, store, &part_file)
836                        .await?
837                } else {
838                    (Arc::new(Statistics::new_unknown(&self.file_schema)), None)
839                };
840                Ok(part_file
841                    .with_statistics(statistics)
842                    .with_ordering(ordering))
843            })
844            .boxed()
845            .buffer_unordered(
846                ctx.config_options().execution.meta_fetch_concurrency.get(),
847            );
848
849        get_files_with_limit(files, file_limit, ctx.config().collect_statistics()).await
850    }
851
852    async fn list_files_for_regular_scan<'a>(
853        &'a self,
854        ctx: &'a dyn Session,
855        filters: &'a [Expr],
856        limit: Option<usize>,
857    ) -> datafusion_common::Result<ListFilesResult> {
858        let file_group_count = ctx.config().target_partitions();
859        if file_group_count == 0 {
860            return plan_err!(
861                "ListingTable requires target_partitions to be greater than zero"
862            );
863        }
864
865        let store = if let Some(url) = self.table_paths.first() {
866            ctx.runtime_env().object_store(url)?
867        } else {
868            return Ok(ListFilesResult {
869                file_groups: vec![],
870                statistics: Statistics::new_unknown(&self.file_schema),
871                grouped_by_partition: false,
872            });
873        };
874        let (file_group, inexact_stats) = self
875            .collect_files_for_scan(ctx, &store, filters, limit)
876            .await?;
877
878        // Threshold: 0 = disabled, N > 0 = enabled when distinct_keys >= N
879        //
880        // When enabled, files are grouped by their Hive partition column values, allowing
881        // FileScanConfig to declare output partitioning. This enables the optimizer to
882        // skip repartitioning for aggregates and joins on partition columns.
883        let threshold = ctx.config_options().optimizer.preserve_file_partitions;
884
885        let (file_groups, grouped_by_partition) =
886            if threshold > 0 && !self.options.table_partition_cols.is_empty() {
887                let grouped = file_group.group_by_partition_values(file_group_count);
888                if grouped.len() >= threshold {
889                    (grouped, true)
890                } else {
891                    let all_files: Vec<_> =
892                        grouped.into_iter().flat_map(|g| g.into_inner()).collect();
893                    (
894                        FileGroup::new(all_files).split_files(file_group_count),
895                        false,
896                    )
897                }
898            } else {
899                (file_group.split_files(file_group_count), false)
900            };
901
902        self.list_files_result_from_groups(
903            ctx,
904            file_groups,
905            inexact_stats,
906            grouped_by_partition,
907        )
908    }
909
910    async fn list_files_for_declared_output_partitioning<'a>(
911        &'a self,
912        ctx: &'a dyn Session,
913        output_partitioning: &LogicalPartitioning,
914        filters: &'a [Expr],
915    ) -> datafusion_common::Result<ListFilesResult> {
916        let Some(file_group_count) = output_partitioning.partition_count() else {
917            return datafusion_common::not_impl_err!(
918                "DistributeBy output partitioning is not supported for ListingTable"
919            );
920        };
921        if file_group_count == 0 {
922            return plan_err!(
923                "ListingTable output_partitioning requires at least one partition"
924            );
925        }
926
927        let store = if let Some(url) = self.table_paths.first() {
928            ctx.runtime_env().object_store(url)?
929        } else {
930            return Ok(ListFilesResult {
931                file_groups: vec![],
932                statistics: Statistics::new_unknown(&self.file_schema),
933                grouped_by_partition: false,
934            });
935        };
936        let (file_group, inexact_stats) =
937            self.collect_files_for_scan(ctx, &store, &[], None).await?;
938        let mut file_groups = file_group.split_files(file_group_count);
939        if !file_groups.is_empty() {
940            file_groups.resize_with(file_group_count, || FileGroup::new(vec![]));
941        }
942        let file_groups =
943            self.filter_declared_file_groups_by_partition_filters(file_groups, filters)?;
944
945        self.list_files_result_from_groups(ctx, file_groups, inexact_stats, false)
946    }
947
948    fn filter_declared_file_groups_by_partition_filters(
949        &self,
950        file_groups: Vec<FileGroup>,
951        filters: &[Expr],
952    ) -> datafusion_common::Result<Vec<FileGroup>> {
953        if filters.is_empty() {
954            return Ok(file_groups);
955        }
956
957        let df_schema = DFSchema::from_unqualified_fields(
958            self.options
959                .table_partition_cols
960                .iter()
961                .map(|(name, data_type)| Field::new(name, data_type.clone(), true))
962                .collect(),
963            Default::default(),
964        )?;
965
966        file_groups
967            .into_iter()
968            .map(|file_group| {
969                filter_file_group_by_partition_filters(file_group, filters, &df_schema)
970            })
971            .collect::<datafusion_common::Result<Vec<_>>>()
972    }
973
974    fn list_files_result_from_groups(
975        &self,
976        ctx: &dyn Session,
977        file_groups: Vec<FileGroup>,
978        inexact_stats: bool,
979        grouped_by_partition: bool,
980    ) -> datafusion_common::Result<ListFilesResult> {
981        let (file_groups, stats) = compute_all_files_statistics(
982            file_groups,
983            self.schema(),
984            ctx.config().collect_statistics(),
985            inexact_stats,
986        )?;
987
988        // Note: Statistics already include both file columns and partition columns.
989        // PartitionedFile::with_statistics automatically appends exact partition column
990        // statistics (min=max=partition_value, null_count=0, distinct_count=1) computed
991        // from partition_values.
992        Ok(ListFilesResult {
993            file_groups,
994            statistics: stats,
995            grouped_by_partition,
996        })
997    }
998
999    /// Collects statistics and ordering for a given partitioned file.
1000    ///
1001    /// This method checks if statistics are cached. If cached, it returns the
1002    /// cached statistics and infers ordering separately. If not cached, it infers
1003    /// both statistics and ordering in a single metadata read for efficiency.
1004    async fn do_collect_statistics_and_ordering(
1005        &self,
1006        ctx: &dyn Session,
1007        store: &Arc<dyn ObjectStore>,
1008        part_file: &PartitionedFile,
1009    ) -> datafusion_common::Result<(Arc<Statistics>, Option<LexOrdering>)> {
1010        let path = TableScopedPath {
1011            table: part_file.table_reference.clone(),
1012            path: part_file.object_meta.location.clone(),
1013        };
1014        let meta = &part_file.object_meta;
1015
1016        // Check cache first. The key stays `{table, path}` for cheap lookups;
1017        // the cached value carries the schema fingerprint to prevent reusing
1018        // stats computed under a different file schema.
1019        if let Some(cache) = &self.collected_statistics
1020            && let Some(cached) = cache.get(&path)
1021            && cached.is_valid_for(meta, &self.file_schema_fingerprint)
1022        {
1023            // Return cached statistics and ordering
1024            return Ok((Arc::clone(&cached.statistics), cached.ordering.clone()));
1025        }
1026
1027        // Cache miss or invalid: fetch both statistics and ordering in a single metadata read
1028        let file_meta = self
1029            .options
1030            .format
1031            .infer_stats_and_ordering(ctx, store, Arc::clone(&self.file_schema), meta)
1032            .await?;
1033
1034        let statistics = Arc::new(file_meta.statistics);
1035
1036        // Store in cache
1037        if let Some(cache) = &self.collected_statistics {
1038            cache.put(
1039                &path,
1040                CachedFileMetadata::new(
1041                    meta.clone(),
1042                    Arc::clone(&self.file_schema_fingerprint),
1043                    Arc::clone(&statistics),
1044                    file_meta.ordering.clone(),
1045                ),
1046            );
1047        }
1048
1049        Ok((statistics, file_meta.ordering))
1050    }
1051}
1052
1053/// Processes a stream of partitioned files and returns a `FileGroup` containing the files.
1054///
1055/// This function collects files from the provided stream until either:
1056/// 1. The stream is exhausted
1057/// 2. The accumulated number of rows exceeds the provided `limit` (if specified)
1058///
1059/// # Arguments
1060/// * `files` - A stream of `Result<PartitionedFile>` items to process
1061/// * `limit` - An optional row count limit. If provided, the function will stop collecting files
1062///   once the accumulated number of rows exceeds this limit
1063/// * `collect_stats` - Whether to collect and accumulate statistics from the files
1064///
1065/// # Returns
1066/// A `Result` containing a `FileGroup` with the collected files
1067/// and a boolean indicating whether the statistics are inexact.
1068///
1069/// # Note
1070/// The function will continue processing files if statistics are not available or if the
1071/// limit is not provided. If `collect_stats` is false, statistics won't be accumulated
1072/// but files will still be collected.
1073async fn get_files_with_limit(
1074    files: impl Stream<Item = datafusion_common::Result<PartitionedFile>>,
1075    limit: Option<usize>,
1076    collect_stats: bool,
1077) -> datafusion_common::Result<(FileGroup, bool)> {
1078    let mut file_group = FileGroup::default();
1079    // Fusing the stream allows us to call next safely even once it is finished.
1080    let mut all_files = Box::pin(files.fuse());
1081    enum ProcessingState {
1082        ReadingFiles,
1083        ReachedLimit,
1084    }
1085
1086    let mut state = ProcessingState::ReadingFiles;
1087    let mut num_rows = Precision::Absent;
1088
1089    while let Some(file_result) = all_files.next().await {
1090        // Early exit if we've already reached our limit
1091        if matches!(state, ProcessingState::ReachedLimit) {
1092            break;
1093        }
1094
1095        let file = file_result?;
1096
1097        // Update file statistics regardless of state
1098        if collect_stats && let Some(file_stats) = &file.statistics {
1099            num_rows = if file_group.is_empty() {
1100                // For the first file, just take its row count
1101                file_stats.num_rows
1102            } else {
1103                // For subsequent files, accumulate the counts
1104                num_rows.add(&file_stats.num_rows)
1105            };
1106        }
1107
1108        // Always add the file to our group
1109        file_group.push(file);
1110
1111        // Check if we've hit the limit (if one was specified)
1112        if let Some(limit) = limit
1113            && let Precision::Exact(row_count) = num_rows
1114            && row_count > limit
1115        {
1116            state = ProcessingState::ReachedLimit;
1117        }
1118    }
1119    // If we still have files in the stream, it means that the limit kicked
1120    // in, and the statistic could have been different had we processed the
1121    // files in a different order.
1122    let inexact_stats = all_files.next().await.is_some();
1123    Ok((file_group, inexact_stats))
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use arrow::compute::SortOptions;
1130    use datafusion_physical_expr::expressions::Column;
1131    use datafusion_physical_expr_common::sort_expr::PhysicalSortExpr;
1132
1133    /// Helper to create a PhysicalSortExpr
1134    fn sort_expr(
1135        name: &str,
1136        idx: usize,
1137        descending: bool,
1138        nulls_first: bool,
1139    ) -> PhysicalSortExpr {
1140        PhysicalSortExpr::new(
1141            Arc::new(Column::new(name, idx)),
1142            SortOptions {
1143                descending,
1144                nulls_first,
1145            },
1146        )
1147    }
1148
1149    /// Helper to create a LexOrdering (unwraps the Option)
1150    fn lex_ordering(exprs: Vec<PhysicalSortExpr>) -> LexOrdering {
1151        LexOrdering::new(exprs).expect("expected non-empty ordering")
1152    }
1153
1154    /// Helper to create a PartitionedFile with optional ordering
1155    fn create_file(name: &str, ordering: Option<LexOrdering>) -> PartitionedFile {
1156        PartitionedFile::new(name.to_string(), 1024).with_ordering(ordering)
1157    }
1158
1159    #[test]
1160    fn test_derive_common_ordering_all_files_same_ordering() {
1161        // All files have the same ordering -> returns that ordering
1162        let ordering = lex_ordering(vec![
1163            sort_expr("a", 0, false, true),
1164            sort_expr("b", 1, true, false),
1165        ]);
1166
1167        let file_groups = vec![
1168            FileGroup::new(vec![
1169                create_file("f1.parquet", Some(ordering.clone())),
1170                create_file("f2.parquet", Some(ordering.clone())),
1171            ]),
1172            FileGroup::new(vec![create_file("f3.parquet", Some(ordering.clone()))]),
1173        ];
1174
1175        let result = derive_common_ordering_from_files(&file_groups);
1176        assert_eq!(result, Some(ordering));
1177    }
1178
1179    #[test]
1180    fn test_derive_common_ordering_common_prefix() {
1181        // Files have different orderings but share a common prefix
1182        let ordering_abc = lex_ordering(vec![
1183            sort_expr("a", 0, false, true),
1184            sort_expr("b", 1, false, true),
1185            sort_expr("c", 2, false, true),
1186        ]);
1187        let ordering_ab = lex_ordering(vec![
1188            sort_expr("a", 0, false, true),
1189            sort_expr("b", 1, false, true),
1190        ]);
1191
1192        let file_groups = vec![FileGroup::new(vec![
1193            create_file("f1.parquet", Some(ordering_abc)),
1194            create_file("f2.parquet", Some(ordering_ab.clone())),
1195        ])];
1196
1197        let result = derive_common_ordering_from_files(&file_groups);
1198        assert_eq!(result, Some(ordering_ab));
1199    }
1200
1201    #[test]
1202    fn test_derive_common_ordering_no_common_prefix() {
1203        // Files have completely different orderings -> returns None
1204        let ordering_a = lex_ordering(vec![sort_expr("a", 0, false, true)]);
1205        let ordering_b = lex_ordering(vec![sort_expr("b", 1, false, true)]);
1206
1207        let file_groups = vec![FileGroup::new(vec![
1208            create_file("f1.parquet", Some(ordering_a)),
1209            create_file("f2.parquet", Some(ordering_b)),
1210        ])];
1211
1212        let result = derive_common_ordering_from_files(&file_groups);
1213        assert_eq!(result, None);
1214    }
1215
1216    #[test]
1217    fn test_derive_common_ordering_mixed_with_none() {
1218        // Some files have ordering, some don't -> returns None
1219        let ordering = lex_ordering(vec![sort_expr("a", 0, false, true)]);
1220
1221        let file_groups = vec![FileGroup::new(vec![
1222            create_file("f1.parquet", Some(ordering)),
1223            create_file("f2.parquet", None),
1224        ])];
1225
1226        let result = derive_common_ordering_from_files(&file_groups);
1227        assert_eq!(result, None);
1228    }
1229
1230    #[test]
1231    fn test_derive_common_ordering_all_none() {
1232        // No files have ordering -> returns None
1233        let file_groups = vec![FileGroup::new(vec![
1234            create_file("f1.parquet", None),
1235            create_file("f2.parquet", None),
1236        ])];
1237
1238        let result = derive_common_ordering_from_files(&file_groups);
1239        assert_eq!(result, None);
1240    }
1241
1242    #[test]
1243    fn test_derive_common_ordering_empty_groups() {
1244        // Empty file groups -> returns None
1245        let file_groups: Vec<FileGroup> = vec![];
1246        let result = derive_common_ordering_from_files(&file_groups);
1247        assert_eq!(result, None);
1248    }
1249
1250    #[test]
1251    fn test_derive_common_ordering_single_file() {
1252        // Single file with ordering -> returns that ordering
1253        let ordering = lex_ordering(vec![
1254            sort_expr("a", 0, false, true),
1255            sort_expr("b", 1, true, false),
1256        ]);
1257
1258        let file_groups = vec![FileGroup::new(vec![create_file(
1259            "f1.parquet",
1260            Some(ordering.clone()),
1261        )])];
1262
1263        let result = derive_common_ordering_from_files(&file_groups);
1264        assert_eq!(result, Some(ordering));
1265    }
1266}