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