Skip to main content

datafusion_datasource/file_scan_config/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! [`FileScanConfig`] to configure scanning of possibly partitioned
19//! file sources.
20
21pub(crate) mod sort_pushdown;
22
23use crate::file_groups::FileGroup;
24use crate::{
25    PartitionedFile, display::FileGroupsDisplay, file::FileSource,
26    file_compression_type::FileCompressionType, file_stream::FileStreamBuilder,
27    file_stream::work_source::SharedWorkSource, source::DataSource,
28    statistics::MinMaxStatistics,
29};
30use arrow::datatypes::FieldRef;
31use arrow::datatypes::{DataType, Schema, SchemaRef};
32use datafusion_common::config::ConfigOptions;
33use datafusion_common::{
34    Constraints, Result, ScalarValue, Statistics, internal_datafusion_err, internal_err,
35};
36use datafusion_execution::{
37    SendableRecordBatchStream, TaskContext, object_store::ObjectStoreUrl,
38};
39use datafusion_expr::Operator;
40
41use crate::source::OpenArgs;
42use datafusion_physical_expr::expressions::{BinaryExpr, Column};
43use datafusion_physical_expr::projection::ProjectionExprs;
44use datafusion_physical_expr::utils::reassign_expr_columns;
45use datafusion_physical_expr::{EquivalenceProperties, Partitioning, split_conjunction};
46use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory;
47use datafusion_physical_expr_common::physical_expr::{PhysicalExpr, is_volatile};
48use datafusion_physical_expr_common::sort_expr::{LexOrdering, PhysicalSortExpr};
49use datafusion_physical_plan::SortOrderPushdownResult;
50use datafusion_physical_plan::coop::cooperative;
51use datafusion_physical_plan::execution_plan::SchedulingType;
52use datafusion_physical_plan::{
53    DisplayAs, DisplayFormatType,
54    display::{ProjectSchemaDisplay, display_orderings},
55    filter_pushdown::FilterPushdownPropagation,
56    metrics::ExecutionPlanMetricsSet,
57};
58use log::{debug, warn};
59use std::any::Any;
60use std::{fmt::Debug, fmt::Formatter, fmt::Result as FmtResult, sync::Arc};
61
62/// [`FileScanConfig`] represents scanning data from a group of files
63///
64/// `FileScanConfig` is used to create a [`DataSourceExec`], the physical plan
65/// for scanning files with a particular file format.
66///
67/// The [`FileSource`] (e.g. `ParquetSource`, `CsvSource`, etc.) is responsible
68/// for creating the actual execution plan to read the files based on a
69/// `FileScanConfig`. Fields in a `FileScanConfig` such as Statistics represent
70/// information about the files **before** any projection or filtering is
71/// applied in the file source.
72///
73/// Use [`FileScanConfigBuilder`] to construct a `FileScanConfig`.
74///
75/// Use [`DataSourceExec::from_data_source`] to create a [`DataSourceExec`] from
76/// a `FileScanConfig`.
77///
78/// # Example
79/// ```
80/// # use std::sync::Arc;
81/// # use arrow::datatypes::{Field, Fields, DataType, Schema, SchemaRef};
82/// # use object_store::ObjectStore;
83/// # use datafusion_common::Result;
84/// # use datafusion_datasource::file::FileSource;
85/// # use datafusion_datasource::file_groups::FileGroup;
86/// # use datafusion_datasource::PartitionedFile;
87/// # use datafusion_datasource::file_scan_config::{FileScanConfig, FileScanConfigBuilder};
88/// # use datafusion_datasource::file_stream::FileOpener;
89/// # use datafusion_datasource::source::DataSourceExec;
90/// # use datafusion_datasource::table_schema::TableSchema;
91/// # use datafusion_execution::object_store::ObjectStoreUrl;
92/// # use datafusion_physical_expr::projection::ProjectionExprs;
93/// # use datafusion_physical_plan::ExecutionPlan;
94/// # use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
95/// # let file_schema = Arc::new(Schema::new(vec![
96/// #  Field::new("c1", DataType::Int32, false),
97/// #  Field::new("c2", DataType::Int32, false),
98/// #  Field::new("c3", DataType::Int32, false),
99/// #  Field::new("c4", DataType::Int32, false),
100/// # ]));
101/// # // Note: crate mock ParquetSource, as ParquetSource is not in the datasource crate
102/// #[derive(Clone)]
103/// # struct ParquetSource {
104/// #    table_schema: TableSchema,
105/// # };
106/// # impl FileSource for ParquetSource {
107/// #  fn create_file_opener(&self, _: Arc<dyn ObjectStore>, _: &FileScanConfig, _: usize) -> Result<Arc<dyn FileOpener>> { unimplemented!() }
108/// #  fn table_schema(&self) -> &TableSchema { &self.table_schema }
109/// #  fn with_batch_size(&self, _: usize) -> Arc<dyn FileSource> { unimplemented!() }
110/// #  fn metrics(&self) -> &ExecutionPlanMetricsSet { unimplemented!() }
111/// #  fn file_type(&self) -> &str { "parquet" }
112/// #  // Note that this implementation drops the projection on the floor, it is not complete!
113/// #  fn try_pushdown_projection(&self, projection: &ProjectionExprs) -> Result<Option<Arc<dyn FileSource>>> { Ok(Some(Arc::new(self.clone()) as Arc<dyn FileSource>)) }
114/// #  }
115/// # impl ParquetSource {
116/// #  fn new(table_schema: impl Into<TableSchema>) -> Self { Self {table_schema: table_schema.into()} }
117/// # }
118/// // create FileScan config for reading parquet files from file://
119/// let object_store_url = ObjectStoreUrl::local_filesystem();
120/// let file_source = Arc::new(ParquetSource::new(file_schema.clone()));
121/// let config = FileScanConfigBuilder::new(object_store_url, file_source)
122///   .with_limit(Some(1000))            // read only the first 1000 records
123///   .with_projection_indices(Some(vec![2, 3])) // project columns 2 and 3
124///   .expect("Failed to push down projection")
125///    // Read /tmp/file1.parquet with known size of 1234 bytes in a single group
126///   .with_file(PartitionedFile::new("file1.parquet", 1234))
127///   // Read /tmp/file2.parquet 56 bytes and /tmp/file3.parquet 78 bytes
128///   // in a  single row group
129///   .with_file_group(FileGroup::new(vec![
130///    PartitionedFile::new("file2.parquet", 56),
131///    PartitionedFile::new("file3.parquet", 78),
132///   ])).build();
133/// // create an execution plan from the config
134/// let plan: Arc<dyn ExecutionPlan> = DataSourceExec::from_data_source(config);
135/// ```
136///
137/// [`DataSourceExec`]: crate::source::DataSourceExec
138/// [`DataSourceExec::from_data_source`]: crate::source::DataSourceExec::from_data_source
139#[derive(Clone)]
140pub struct FileScanConfig {
141    /// Object store URL, used to get an [`ObjectStore`] instance from
142    /// [`RuntimeEnv::object_store`]
143    ///
144    /// This `ObjectStoreUrl` should be the prefix of the absolute url for files
145    /// as `file://` or `s3://my_bucket`. It should not include the path to the
146    /// file itself. The relevant URL prefix must be registered via
147    /// [`RuntimeEnv::register_object_store`]
148    ///
149    /// [`ObjectStore`]: object_store::ObjectStore
150    /// [`RuntimeEnv::register_object_store`]: datafusion_execution::runtime_env::RuntimeEnv::register_object_store
151    /// [`RuntimeEnv::object_store`]: datafusion_execution::runtime_env::RuntimeEnv::object_store
152    pub object_store_url: ObjectStoreUrl,
153    /// List of files to be processed, grouped into partitions
154    ///
155    /// Each file must have a schema of `file_schema` or a subset. If
156    /// a particular file has a subset, the missing columns are
157    /// padded with NULLs.
158    ///
159    /// DataFusion may attempt to read each partition of files
160    /// concurrently, however files *within* a partition will be read
161    /// sequentially, one after the next.
162    ///
163    /// Note that when `datafusion.execution.enable_file_stream_work_stealing`
164    /// is enabled (the default), files may be reassigned to a different
165    /// partition at runtime unless `preserve_order` or
166    /// `partitioned_by_file_group` is set, so a file is not guaranteed to be
167    /// read by the partition it is grouped under here.
168    pub file_groups: Vec<FileGroup>,
169    /// Table constraints
170    pub constraints: Constraints,
171    /// The maximum number of records to read from this plan. If `None`,
172    /// all records after filtering are returned.
173    pub limit: Option<usize>,
174    /// Whether the scan's limit is order sensitive
175    /// When `true`, files must be read in the exact order specified to produce
176    /// correct results (e.g., for `ORDER BY ... LIMIT` queries). When `false`,
177    /// DataFusion may reorder file processing for optimization without affecting correctness.
178    pub preserve_order: bool,
179    /// All equivalent lexicographical output orderings of this file scan, in terms of
180    /// [`FileSource::table_schema`]. See [`FileScanConfigBuilder::with_output_ordering`] for more
181    /// details.
182    ///
183    /// [`Self::eq_properties`] uses this information along with projection
184    /// and filtering information to compute the effective
185    /// [`EquivalenceProperties`]
186    pub output_ordering: Vec<LexOrdering>,
187    /// File compression type
188    pub file_compression_type: FileCompressionType,
189    /// File source such as `ParquetSource`, `CsvSource`, `JsonSource`, etc.
190    pub file_source: Arc<dyn FileSource>,
191    /// Batch size while creating new batches
192    /// Defaults to [`datafusion_common::config::ExecutionOptions`] batch_size.
193    pub batch_size: Option<usize>,
194    /// Expression adapter used to adapt filters and projections that are pushed down into the scan
195    /// from the logical schema to the physical schema of the file.
196    pub expr_adapter_factory: Option<Arc<dyn PhysicalExprAdapterFactory>>,
197    /// Statistics for the entire table (file schema + partition columns).
198    /// See [`FileScanConfigBuilder::with_statistics`] for more details.
199    ///
200    /// The effective statistics are computed on-demand via
201    /// [`ProjectionExprs::project_statistics`].
202    ///
203    /// Note that this field is pub(crate) because accessing it directly from outside
204    /// would be incorrect if there are filters being applied, thus this should be accessed
205    /// via [`FileScanConfig::statistics`].
206    pub(crate) statistics: Statistics,
207    /// When true, file_groups are organized by partition column values
208    /// and output_partitioning will return Hash partitioning on partition columns.
209    /// This allows the optimizer to skip hash repartitioning for aggregates and joins
210    /// on partition columns.
211    ///
212    /// If the number of file partitions > target_partitions, the file partitions will be grouped
213    /// in a round-robin fashion such that number of file partitions = target_partitions.
214    pub partitioned_by_file_group: bool,
215}
216
217/// A builder for [`FileScanConfig`]'s.
218///
219/// Example:
220///
221/// ```rust
222/// # use std::sync::Arc;
223/// # use arrow::datatypes::{DataType, Field, Schema};
224/// # use datafusion_datasource::file_scan_config::{FileScanConfigBuilder, FileScanConfig};
225/// # use datafusion_datasource::file_compression_type::FileCompressionType;
226/// # use datafusion_datasource::file_groups::FileGroup;
227/// # use datafusion_datasource::PartitionedFile;
228/// # use datafusion_datasource::table_schema::TableSchema;
229/// # use datafusion_execution::object_store::ObjectStoreUrl;
230/// # use datafusion_common::Statistics;
231/// # use datafusion_datasource::file::FileSource;
232///
233/// # fn main() {
234/// # fn with_source(file_source: Arc<dyn FileSource>) {
235///     // Create a schema for our Parquet files
236///     let file_schema = Arc::new(Schema::new(vec![
237///         Field::new("id", DataType::Int32, false),
238///         Field::new("value", DataType::Utf8, false),
239///     ]));
240///
241///     // Create partition columns
242///     let partition_cols = vec![
243///         Arc::new(Field::new("date", DataType::Utf8, false)),
244///     ];
245///
246///     // Create table schema with file schema and partition columns
247///     let table_schema = TableSchema::new(file_schema, partition_cols);
248///
249///     // Create a builder for scanning Parquet files from a local filesystem
250///     let config = FileScanConfigBuilder::new(
251///         ObjectStoreUrl::local_filesystem(),
252///         file_source,
253///     )
254///     // Set a limit of 1000 rows
255///     .with_limit(Some(1000))
256///     // Project only the first column
257///     .with_projection_indices(Some(vec![0]))
258///     .expect("Failed to push down projection")
259///     // Add a file group with two files
260///     .with_file_group(FileGroup::new(vec![
261///         PartitionedFile::new("data/date=2024-01-01/file1.parquet", 1024),
262///         PartitionedFile::new("data/date=2024-01-01/file2.parquet", 2048),
263///     ]))
264///     // Set compression type
265///     .with_file_compression_type(FileCompressionType::UNCOMPRESSED)
266///     // Build the final config
267///     .build();
268/// # }
269/// # }
270/// ```
271#[derive(Clone)]
272pub struct FileScanConfigBuilder {
273    object_store_url: ObjectStoreUrl,
274    file_source: Arc<dyn FileSource>,
275    limit: Option<usize>,
276    preserve_order: bool,
277    constraints: Option<Constraints>,
278    file_groups: Vec<FileGroup>,
279    statistics: Option<Statistics>,
280    output_ordering: Vec<LexOrdering>,
281    file_compression_type: Option<FileCompressionType>,
282    batch_size: Option<usize>,
283    expr_adapter_factory: Option<Arc<dyn PhysicalExprAdapterFactory>>,
284    partitioned_by_file_group: bool,
285}
286
287impl FileScanConfigBuilder {
288    /// Create a new [`FileScanConfigBuilder`] with default settings for scanning files.
289    ///
290    /// # Parameters:
291    /// * `object_store_url`: See [`FileScanConfig::object_store_url`]
292    /// * `file_source`: See [`FileScanConfig::file_source`]. The file source must have
293    ///   a schema set via its constructor.
294    pub fn new(
295        object_store_url: ObjectStoreUrl,
296        file_source: Arc<dyn FileSource>,
297    ) -> Self {
298        Self {
299            object_store_url,
300            file_source,
301            file_groups: vec![],
302            statistics: None,
303            output_ordering: vec![],
304            file_compression_type: None,
305            limit: None,
306            preserve_order: false,
307            constraints: None,
308            batch_size: None,
309            expr_adapter_factory: None,
310            partitioned_by_file_group: false,
311        }
312    }
313
314    /// Set the maximum number of records to read from this plan.
315    ///
316    /// If `None`, all records after filtering are returned.
317    pub fn with_limit(mut self, limit: Option<usize>) -> Self {
318        self.limit = limit;
319        self
320    }
321
322    /// Set whether the limit should be order-sensitive.
323    ///
324    /// When `true`, files must be read in the exact order specified to produce
325    /// correct results (e.g., for `ORDER BY ... LIMIT` queries). When `false`,
326    /// DataFusion may reorder file processing for optimization without
327    /// affecting correctness.
328    pub fn with_preserve_order(mut self, order_sensitive: bool) -> Self {
329        self.preserve_order = order_sensitive;
330        self
331    }
332
333    /// Set the file source for scanning files.
334    ///
335    /// This method allows you to change the file source implementation (e.g.
336    /// ParquetSource, CsvSource, etc.) after the builder has been created.
337    pub fn with_source(mut self, file_source: Arc<dyn FileSource>) -> Self {
338        self.file_source = file_source;
339        self
340    }
341
342    /// Return the table schema
343    pub fn table_schema(&self) -> &SchemaRef {
344        self.file_source.table_schema().table_schema()
345    }
346
347    /// Set the columns on which to project the data. Indexes that are higher than the
348    /// number of columns of `file_schema` refer to `table_partition_cols`.
349    ///
350    /// # Deprecated
351    /// Use [`Self::with_projection_indices`] instead. This method will be removed in a future release.
352    #[deprecated(since = "51.0.0", note = "Use with_projection_indices instead")]
353    pub fn with_projection(self, indices: Option<Vec<usize>>) -> Self {
354        match self.clone().with_projection_indices(indices) {
355            Ok(builder) => builder,
356            Err(e) => {
357                warn!(
358                    "Failed to push down projection in FileScanConfigBuilder::with_projection: {e}"
359                );
360                self
361            }
362        }
363    }
364
365    /// Set the columns on which to project the data using column indices.
366    ///
367    /// This method attempts to push down the projection to the underlying file
368    /// source if supported. If the file source does not support projection
369    /// pushdown, an error is returned.
370    ///
371    /// Indexes that are higher than the number of columns of `file_schema`
372    /// refer to `table_partition_cols`.
373    pub fn with_projection_indices(
374        mut self,
375        indices: Option<Vec<usize>>,
376    ) -> Result<Self> {
377        let projection_exprs = indices.map(|indices| {
378            ProjectionExprs::from_indices(
379                &indices,
380                self.file_source.table_schema().table_schema(),
381            )
382        });
383        let Some(projection_exprs) = projection_exprs else {
384            return Ok(self);
385        };
386        let new_source = self
387            .file_source
388            .try_pushdown_projection(&projection_exprs)
389            .map_err(|e| {
390                internal_datafusion_err!(
391                    "Failed to push down projection in FileScanConfigBuilder::build: {e}"
392                )
393            })?;
394        if let Some(new_source) = new_source {
395            self.file_source = new_source;
396        } else {
397            internal_err!(
398                "FileSource {} does not support projection pushdown",
399                self.file_source.file_type()
400            )?;
401        }
402        Ok(self)
403    }
404
405    /// Set the table constraints
406    pub fn with_constraints(mut self, constraints: Constraints) -> Self {
407        self.constraints = Some(constraints);
408        self
409    }
410
411    /// Set the statistics of the files, including partition
412    /// columns. Defaults to [`Statistics::new_unknown`].
413    ///
414    /// These statistics are for the entire table (file schema + partition
415    /// columns) before any projection or filtering is applied. Projections are
416    /// applied when statistics are retrieved, and if a filter is present,
417    /// [`FileScanConfig::statistics`] will mark the statistics as inexact
418    /// (counts are not adjusted).
419    ///
420    /// Projections and filters may be applied by the file source, either by
421    /// [`Self::with_projection_indices`] or a preexisting
422    /// [`FileSource::projection`] or [`FileSource::filter`].
423    pub fn with_statistics(mut self, statistics: Statistics) -> Self {
424        self.statistics = Some(statistics);
425        self
426    }
427
428    /// Set the list of files to be processed, grouped into partitions.
429    ///
430    /// Each file must have a schema of `file_schema` or a subset. If
431    /// a particular file has a subset, the missing columns are
432    /// padded with NULLs.
433    ///
434    /// DataFusion may attempt to read each partition of files
435    /// concurrently, however files *within* a partition will be read
436    /// sequentially, one after the next.
437    pub fn with_file_groups(mut self, file_groups: Vec<FileGroup>) -> Self {
438        self.file_groups = file_groups;
439        self
440    }
441
442    /// Add a new file group
443    ///
444    /// See [`Self::with_file_groups`] for more information
445    pub fn with_file_group(mut self, file_group: FileGroup) -> Self {
446        self.file_groups.push(file_group);
447        self
448    }
449
450    /// Add a file as a single group
451    ///
452    /// See [`Self::with_file_groups`] for more information.
453    pub fn with_file(self, partitioned_file: PartitionedFile) -> Self {
454        self.with_file_group(FileGroup::new(vec![partitioned_file]))
455    }
456
457    /// Set the output ordering of the files
458    ///
459    /// The expressions are in terms of the entire table schema (file schema +
460    /// partition columns), before any projection or filtering from the file
461    /// scan is applied.
462    ///
463    /// This is used for optimization purposes, e.g. to determine if a file scan
464    /// can satisfy an `ORDER BY` without an additional sort.
465    pub fn with_output_ordering(mut self, output_ordering: Vec<LexOrdering>) -> Self {
466        self.output_ordering = output_ordering;
467        self
468    }
469
470    /// Set the file compression type
471    pub fn with_file_compression_type(
472        mut self,
473        file_compression_type: FileCompressionType,
474    ) -> Self {
475        self.file_compression_type = Some(file_compression_type);
476        self
477    }
478
479    /// Set the batch_size property
480    pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
481        self.batch_size = batch_size;
482        self
483    }
484
485    /// Register an expression adapter used to adapt filters and projections that are pushed down into the scan
486    /// from the logical schema to the physical schema of the file.
487    /// This can include things like:
488    /// - Column ordering changes
489    /// - Handling of missing columns
490    /// - Rewriting expression to use pre-computed values or file format specific optimizations
491    pub fn with_expr_adapter(
492        mut self,
493        expr_adapter: Option<Arc<dyn PhysicalExprAdapterFactory>>,
494    ) -> Self {
495        self.expr_adapter_factory = expr_adapter;
496        self
497    }
498
499    /// Set whether file groups are organized by partition column values.
500    ///
501    /// When set to true, the output partitioning will be declared as Hash partitioning
502    /// on the partition columns.
503    pub fn with_partitioned_by_file_group(
504        mut self,
505        partitioned_by_file_group: bool,
506    ) -> Self {
507        self.partitioned_by_file_group = partitioned_by_file_group;
508        self
509    }
510
511    /// Build the final [`FileScanConfig`] with all the configured settings.
512    ///
513    /// This method takes ownership of the builder and returns the constructed `FileScanConfig`.
514    /// Any unset optional fields will use their default values.
515    ///
516    /// # Errors
517    /// Returns an error if projection pushdown fails or if schema operations fail.
518    pub fn build(self) -> FileScanConfig {
519        let Self {
520            object_store_url,
521            file_source,
522            limit,
523            preserve_order,
524            constraints,
525            file_groups,
526            statistics,
527            output_ordering,
528            file_compression_type,
529            batch_size,
530            expr_adapter_factory: expr_adapter,
531            partitioned_by_file_group,
532        } = self;
533
534        let constraints = constraints.unwrap_or_default();
535        let statistics = statistics.unwrap_or_else(|| {
536            Statistics::new_unknown(file_source.table_schema().table_schema())
537        });
538        let file_compression_type =
539            file_compression_type.unwrap_or(FileCompressionType::UNCOMPRESSED);
540
541        // If there is an output ordering, we should preserve it.
542        let preserve_order = preserve_order || !output_ordering.is_empty();
543
544        FileScanConfig {
545            object_store_url,
546            file_source,
547            limit,
548            preserve_order,
549            constraints,
550            file_groups,
551            output_ordering,
552            file_compression_type,
553            batch_size,
554            expr_adapter_factory: expr_adapter,
555            statistics,
556            partitioned_by_file_group,
557        }
558    }
559}
560
561impl From<FileScanConfig> for FileScanConfigBuilder {
562    fn from(config: FileScanConfig) -> Self {
563        Self {
564            object_store_url: config.object_store_url,
565            file_source: Arc::<dyn FileSource>::clone(&config.file_source),
566            file_groups: config.file_groups,
567            statistics: Some(config.statistics),
568            output_ordering: config.output_ordering,
569            file_compression_type: Some(config.file_compression_type),
570            limit: config.limit,
571            preserve_order: config.preserve_order,
572            constraints: Some(config.constraints),
573            batch_size: config.batch_size,
574            expr_adapter_factory: config.expr_adapter_factory,
575            partitioned_by_file_group: config.partitioned_by_file_group,
576        }
577    }
578}
579
580/// Returns `true` if merging `outer` into `inner` would duplicate a volatile
581/// expression; the caller should then decline the merge.
582///
583/// `inner` is the scan's current projection and `outer` the projection being
584/// pushed into it; merging substitutes each `inner` expression into every
585/// `outer` reference to it. If a volatile `inner` expression (e.g. `random()`,
586/// `uuid()`) is referenced more than once, that single value gets inlined at
587/// each site and re-evaluated independently, so references meant to share a
588/// "locked-in" value diverge. This is the volatility guard the physical
589/// `ProjectionPushdown` and `FilterPushdown` rules already apply (see
590/// `datafusion_physical_expr_common::physical_expr::is_volatile`).
591///
592/// References are counted with multiplicity by walking each `outer` expression
593/// (as `try_collapse_projection_chain` does), so a self-duplicating expression
594/// such as `r + r` counts as two references. A volatile expression referenced
595/// exactly once has nothing to duplicate and is left to merge.
596fn would_duplicate_volatile_exprs(
597    inner: &ProjectionExprs,
598    outer: &ProjectionExprs,
599) -> bool {
600    use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
601
602    let inner_exprs = inner.as_ref();
603
604    let mut ref_counts = vec![0usize; inner_exprs.len()];
605    for proj_expr in outer.as_ref() {
606        proj_expr
607            .expr
608            .apply(|e| {
609                if let Some(col) = e.as_ref().downcast_ref::<Column>()
610                    && let Some(count) = ref_counts.get_mut(col.index())
611                {
612                    *count += 1;
613                }
614                Ok(TreeNodeRecursion::Continue)
615            })
616            .expect("infallible closure should not fail");
617    }
618
619    ref_counts
620        .iter()
621        .enumerate()
622        .any(|(idx, &count)| count > 1 && is_volatile(&inner_exprs[idx].expr))
623}
624
625impl DataSource for FileScanConfig {
626    fn open(
627        &self,
628        partition: usize,
629        context: Arc<TaskContext>,
630    ) -> Result<SendableRecordBatchStream> {
631        self.open_with_args(OpenArgs::new(partition, context))
632    }
633
634    fn open_with_args(&self, args: OpenArgs) -> Result<SendableRecordBatchStream> {
635        let OpenArgs {
636            partition,
637            context,
638            sibling_state,
639        } = args;
640        let object_store = context.runtime_env().object_store(&self.object_store_url)?;
641        let batch_size = self
642            .batch_size
643            .unwrap_or_else(|| context.session_config().batch_size());
644
645        let source = self.file_source.with_batch_size(batch_size);
646
647        let morselizer = source.create_morselizer(object_store, self, partition)?;
648
649        // Extract the shared work source from the sibling state if it exists.
650        // This allows multiple sibling streams to steal work from a single
651        // shared queue of unopened files.
652        let shared_work_source = sibling_state
653            .as_ref()
654            .and_then(|state| state.downcast_ref::<SharedWorkSource>())
655            .cloned();
656
657        let stream = FileStreamBuilder::new(self)
658            .with_partition(partition)
659            .with_shared_work_source(shared_work_source)
660            .with_morselizer(morselizer)
661            .with_metrics(source.metrics())
662            .build()?;
663        Ok(Box::pin(cooperative(stream)))
664    }
665
666    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter) -> FmtResult {
667        match t {
668            DisplayFormatType::Default | DisplayFormatType::Verbose => {
669                let schema = self.projected_schema().map_err(|_| std::fmt::Error {})?;
670                let orderings =
671                    sort_pushdown::get_projected_output_ordering(self, &schema);
672
673                write!(f, "file_groups=")?;
674                FileGroupsDisplay(&self.file_groups).fmt_as(t, f)?;
675
676                if !schema.fields().is_empty() {
677                    if let Some(projection) = self.file_source.projection() {
678                        // This matches what ProjectionExec does.
679                        // TODO: can we put this into ProjectionExprs so that it's shared code?
680                        let expr: Vec<String> = projection
681                            .as_ref()
682                            .iter()
683                            .map(|proj_expr| {
684                                if let Some(column) =
685                                    proj_expr.expr.downcast_ref::<Column>()
686                                {
687                                    if column.name() == proj_expr.alias {
688                                        column.name().to_string()
689                                    } else {
690                                        format!(
691                                            "{} as {}",
692                                            proj_expr.expr, proj_expr.alias
693                                        )
694                                    }
695                                } else {
696                                    format!("{} as {}", proj_expr.expr, proj_expr.alias)
697                                }
698                            })
699                            .collect();
700                        write!(f, ", projection=[{}]", expr.join(", "))?;
701                    } else {
702                        write!(f, ", projection={}", ProjectSchemaDisplay(&schema))?;
703                    }
704                }
705
706                if let Some(limit) = self.limit {
707                    write!(f, ", limit={limit}")?;
708                }
709
710                display_orderings(f, &orderings)?;
711
712                if !self.constraints.is_empty() {
713                    write!(f, ", {}", self.constraints)?;
714                }
715
716                self.fmt_file_source(t, f)
717            }
718            DisplayFormatType::TreeRender => {
719                writeln!(f, "format={}", self.file_source.file_type())?;
720                self.file_source.fmt_extra(t, f)?;
721                let num_files = self.file_groups.iter().map(|fg| fg.len()).sum::<usize>();
722                writeln!(f, "files={num_files}")?;
723                Ok(())
724            }
725        }
726    }
727
728    /// If supported by the underlying [`FileSource`], redistribute files across partitions according to their size.
729    fn repartitioned(
730        &self,
731        target_partitions: usize,
732        repartition_file_min_size: usize,
733        output_ordering: Option<LexOrdering>,
734    ) -> Result<Option<Arc<dyn DataSource>>> {
735        // When files are grouped by partition values, we cannot allow byte-range
736        // splitting. It would mix rows from different partition values across
737        // file groups, breaking the Hash partitioning.
738        if self.partitioned_by_file_group {
739            return Ok(None);
740        }
741
742        let source = self.file_source.repartitioned(
743            target_partitions,
744            repartition_file_min_size,
745            output_ordering,
746            self,
747        )?;
748
749        Ok(source.map(|s| Arc::new(s) as _))
750    }
751
752    /// Returns the output partitioning for this file scan.
753    ///
754    /// When `partitioned_by_file_group` is true, this returns `Partitioning::Hash` on
755    /// the Hive partition columns, allowing the optimizer to skip hash repartitioning
756    /// for aggregates and joins on those columns.
757    ///
758    /// Tradeoffs
759    /// - Benefit: Eliminates `RepartitionExec` and `SortExec` for queries with
760    ///   `GROUP BY` or `ORDER BY` on partition columns.
761    /// - Cost: Files are grouped by partition values rather than split by byte
762    ///   ranges, which may reduce I/O parallelism when partition sizes are uneven.
763    ///   For simple aggregations without `ORDER BY`, this cost may outweigh the benefit.
764    ///
765    /// Follow-up Work
766    /// - Idea: Could allow byte-range splitting within partition-aware groups,
767    ///   preserving I/O parallelism while maintaining partition semantics.
768    fn output_partitioning(&self) -> Partitioning {
769        if self.partitioned_by_file_group {
770            let partition_cols = self.table_partition_cols();
771            if !partition_cols.is_empty() {
772                let projected_schema = match self.projected_schema() {
773                    Ok(schema) => schema,
774                    Err(_) => {
775                        debug!(
776                            "Could not get projected schema, falling back to UnknownPartitioning."
777                        );
778                        return Partitioning::UnknownPartitioning(self.file_groups.len());
779                    }
780                };
781
782                // Build Column expressions for partition columns based on their
783                // position in the projected schema
784                let mut exprs: Vec<Arc<dyn PhysicalExpr>> = Vec::new();
785                for partition_col in partition_cols {
786                    if let Some((idx, _)) = projected_schema
787                        .fields()
788                        .iter()
789                        .enumerate()
790                        .find(|(_, f)| f.name() == partition_col.name())
791                    {
792                        exprs.push(Arc::new(Column::new(partition_col.name(), idx)));
793                    }
794                }
795
796                if exprs.len() == partition_cols.len() {
797                    return Partitioning::Hash(exprs, self.file_groups.len());
798                }
799            }
800        }
801        Partitioning::UnknownPartitioning(self.file_groups.len())
802    }
803
804    /// Computes the effective equivalence properties of this file scan, taking
805    /// into account the file schema, any projections or filters applied by the
806    /// file source, and the output ordering.
807    fn eq_properties(&self) -> EquivalenceProperties {
808        let schema = self.file_source.table_schema().table_schema();
809        let mut eq_properties = EquivalenceProperties::new_with_orderings(
810            Arc::clone(schema),
811            self.validated_output_ordering(),
812        )
813        .with_constraints(self.constraints.clone());
814
815        if let Some(filter) = self.file_source.filter() {
816            // We need to remap column indexes to match the projected schema since that's what the equivalence properties deal with.
817            // Note that this will *ignore* any non-projected columns: these don't factor into ordering / equivalence.
818            match Self::add_filter_equivalence_info(&filter, &mut eq_properties, schema) {
819                Ok(()) => {}
820                Err(e) => {
821                    warn!("Failed to add filter equivalence info: {e}");
822                    #[cfg(debug_assertions)]
823                    panic!("Failed to add filter equivalence info: {e}");
824                }
825            }
826        }
827
828        if let Some(projection) = self.file_source.projection() {
829            match (
830                projection.project_schema(schema),
831                projection.projection_mapping(schema),
832            ) {
833                (Ok(output_schema), Ok(mapping)) => {
834                    eq_properties =
835                        eq_properties.project(&mapping, Arc::new(output_schema));
836                }
837                (Err(e), _) | (_, Err(e)) => {
838                    warn!("Failed to project equivalence properties: {e}");
839                    #[cfg(debug_assertions)]
840                    panic!("Failed to project equivalence properties: {e}");
841                }
842            }
843        }
844
845        eq_properties
846    }
847
848    fn scheduling_type(&self) -> SchedulingType {
849        SchedulingType::Cooperative
850    }
851
852    fn partition_statistics(&self, partition: Option<usize>) -> Result<Arc<Statistics>> {
853        if let Some(partition) = partition {
854            // Get statistics for a specific partition
855            // Note: FileGroup statistics include partition columns (computed from partition_values)
856            if let Some(file_group) = self.file_groups.get(partition)
857                && let Some(stat) = file_group.file_statistics(None)
858            {
859                // Project the statistics based on the projection
860                let output_schema = self.projected_schema()?;
861                return if let Some(projection) = self.file_source.projection() {
862                    Ok(Arc::new(
863                        projection.project_statistics(stat.clone(), &output_schema)?,
864                    ))
865                } else {
866                    Ok(Arc::new(stat.clone()))
867                };
868            }
869            // If no statistics available for this partition, return unknown
870            Ok(Arc::new(Statistics::new_unknown(
871                self.projected_schema()?.as_ref(),
872            )))
873        } else {
874            // Return aggregate statistics across all partitions
875            let statistics = self.statistics();
876            let projection = self.file_source.projection();
877            let output_schema = self.projected_schema()?;
878            if let Some(projection) = &projection {
879                Ok(Arc::new(
880                    projection.project_statistics(statistics.clone(), &output_schema)?,
881                ))
882            } else {
883                Ok(Arc::new(statistics))
884            }
885        }
886    }
887
888    fn with_fetch(&self, limit: Option<usize>) -> Option<Arc<dyn DataSource>> {
889        let source = FileScanConfigBuilder::from(self.clone())
890            .with_limit(limit)
891            .build();
892        Some(Arc::new(source))
893    }
894
895    fn fetch(&self) -> Option<usize> {
896        self.limit
897    }
898
899    fn metrics(&self) -> ExecutionPlanMetricsSet {
900        self.file_source.metrics().clone()
901    }
902
903    fn try_swapping_with_projection(
904        &self,
905        projection: &ProjectionExprs,
906    ) -> Result<Option<Arc<dyn DataSource>>> {
907        // Don't merge a projection into the scan if it would inline a volatile
908        // expression that the outer projection references, which would turn a
909        // single "locked-in" value (e.g. `random()` aliased in a subquery) into
910        // multiple independent evaluations. See #23220.
911        if let Some(inner) = self.file_source.projection()
912            && would_duplicate_volatile_exprs(inner, projection)
913        {
914            return Ok(None);
915        }
916        match self.file_source.try_pushdown_projection(projection)? {
917            Some(new_source) => {
918                let mut new_file_scan_config = self.clone();
919                new_file_scan_config.file_source = new_source;
920                Ok(Some(Arc::new(new_file_scan_config) as Arc<dyn DataSource>))
921            }
922            None => Ok(None),
923        }
924    }
925
926    fn try_pushdown_filters(
927        &self,
928        filters: Vec<Arc<dyn PhysicalExpr>>,
929        config: &ConfigOptions,
930    ) -> Result<FilterPushdownPropagation<Arc<dyn DataSource>>> {
931        // Remap filter Column indices to match the table schema (file + partition columns).
932        // This is necessary because filters refer to the output schema of this `DataSource`
933        // (e.g., after projection pushdown has been applied) and need to be remapped to the table schema
934        // before being passed to the file source
935        //
936        // For example, consider a filter `c1_c2 > 5` being pushed down. If the
937        // `DataSource` has a projection `c1 + c2 as c1_c2`, the filter must be rewritten
938        // to refer to the table schema `c1 + c2 > 5`
939        let table_schema = self.file_source.table_schema().table_schema();
940        let filters_to_remap = if let Some(projection) = self.file_source.projection() {
941            filters
942                .into_iter()
943                .map(|filter| projection.unproject_expr(&filter))
944                .collect::<Result<Vec<_>>>()?
945        } else {
946            filters
947        };
948        // Now remap column indices to match the table schema.
949        let remapped_filters = filters_to_remap
950            .into_iter()
951            .map(|filter| reassign_expr_columns(filter, table_schema))
952            .collect::<Result<Vec<_>>>()?;
953
954        let result = self
955            .file_source
956            .try_pushdown_filters(remapped_filters, config)?;
957        match result.updated_node {
958            Some(new_file_source) => {
959                let mut new_file_scan_config = self.clone();
960                new_file_scan_config.file_source = new_file_source;
961                Ok(FilterPushdownPropagation {
962                    filters: result.filters,
963                    updated_node: Some(Arc::new(new_file_scan_config) as _),
964                })
965            }
966            None => {
967                // If the file source does not support filter pushdown, return the original config
968                Ok(FilterPushdownPropagation {
969                    filters: result.filters,
970                    updated_node: None,
971                })
972            }
973        }
974    }
975
976    /// Push sort requirements into file-based data sources.
977    ///
978    /// # Sort Pushdown Architecture
979    ///
980    /// When a partition (file group) contains multiple files in wrong order,
981    /// `validated_output_ordering()` strips the ordering and `EnforceSorting`
982    /// inserts a `SortExec`. This optimizer fixes the file order by sorting
983    /// files within each group by min/max statistics, enabling sort elimination.
984    ///
985    /// This applies to both single-partition and multi-partition plans — any
986    /// file group with multiple files in wrong order benefits.
987    ///
988    /// ```text
989    /// PushdownSort optimizer finds SortExec
990    ///   │
991    ///   ▼
992    /// FileScanConfig::try_pushdown_sort()
993    ///   │
994    ///   ├─► FileSource returns Exact
995    ///   │     (natural ordering satisfies request)
996    ///   │     → rebuild_with_source: sort files by stats, verify non-overlapping
997    ///   │     → SortExec removed, fetch (LIMIT) pushed to DataSourceExec
998    ///   │
999    ///   ├─► FileSource returns Inexact
1000    ///   │     (e.g. column_in_file_schema: opener will reorder RGs at runtime)
1001    ///   │     → rebuild_with_source: sort files by stats; if the post-sort
1002    ///   │       file groups are non-overlapping AND the request now validates
1003    ///   │       AND no NULLs sit in the sort columns of non-last files,
1004    ///   │       upgrade back to Exact (SortExec removed). Otherwise stays
1005    ///   │       Inexact and SortExec is kept while the scan is still
1006    ///   │       optimised via `sort_order_for_reorder` / `reverse_row_groups`.
1007    ///   │
1008    ///   └─► FileSource returns Unsupported
1009    ///         (e.g. expression sort key or partition column)
1010    ///         → try_sort_file_groups_by_statistics():
1011    ///           1. Sort files within each group by min/max statistics
1012    ///           2. Re-check: non-overlapping + ordering valid + no NULLs?
1013    ///              YES → Exact → SortExec removed
1014    ///              NO  → Inexact (files reordered, Sort stays)
1015    /// ```
1016    fn try_pushdown_sort(
1017        &self,
1018        order: &[PhysicalSortExpr],
1019    ) -> Result<SortOrderPushdownResult<Arc<dyn DataSource>>> {
1020        let pushdown_result = self
1021            .file_source
1022            .try_pushdown_sort(order, &self.eq_properties())?;
1023
1024        match pushdown_result {
1025            SortOrderPushdownResult::Exact { inner } => {
1026                let config = self.rebuild_with_source(inner, true, order)?;
1027                // rebuild_with_source keeps output_ordering only when all groups
1028                // are non-overlapping. If output_ordering was cleared, files
1029                // overlap despite within-file ordering → downgrade to Inexact.
1030                if config.output_ordering.is_empty() {
1031                    Ok(SortOrderPushdownResult::Inexact {
1032                        inner: Arc::new(config),
1033                    })
1034                } else {
1035                    Ok(SortOrderPushdownResult::Exact {
1036                        inner: Arc::new(config),
1037                    })
1038                }
1039            }
1040            SortOrderPushdownResult::Inexact { inner } => {
1041                let mut config = self.rebuild_with_source(inner, false, order)?;
1042                // `rebuild_with_source` reorders files by stats; if the
1043                // post-sort files are non-overlapping AND the request now
1044                // validates against the new file groups, `output_ordering`
1045                // is preserved and we can upgrade back to Exact. This
1046                // restores the sort-elimination behaviour that lived in
1047                // the `Unsupported` → `try_sort_file_groups_by_statistics`
1048                // path before #21956 routed `column_in_file_schema` cases
1049                // here.
1050                if config.output_ordering.is_empty() {
1051                    return Ok(SortOrderPushdownResult::Inexact {
1052                        inner: Arc::new(config),
1053                    });
1054                }
1055                // Upgrading to Exact: the post-sort file groups are
1056                // non-overlapping and each file's declared ordering
1057                // re-validates, so reading the files in their natural
1058                // (declared-sorted) order already yields the requested
1059                // ordering — exactly like the `Unsupported` → Exact path,
1060                // which reads files in natural order too.
1061                //
1062                // Drop the runtime row-group reorder hints the Inexact
1063                // source carried (`sort_order_for_reorder` /
1064                // `reverse_row_groups`) by restoring the original,
1065                // hint-free source. With the `SortExec` removed those
1066                // hints are not just redundant but unsafe: for a DESC
1067                // request the opener sorts row groups ASC-by-min and then
1068                // reverses them, which reorders two row groups within a
1069                // single file that share the same `min` incorrectly
1070                // (e.g. a file `[10,8,8,8]` whose row groups are
1071                // `[10,8]` and `[8,8]` would stream as `8,8,10,8`).
1072                // The `SortExec` used to mask this; once it is gone the
1073                // reordered stream is the final, wrong answer.
1074                config.file_source = Arc::clone(&self.file_source);
1075                Ok(SortOrderPushdownResult::Exact {
1076                    inner: Arc::new(config),
1077                })
1078            }
1079            SortOrderPushdownResult::Unsupported => {
1080                self.try_sort_file_groups_by_statistics(order)
1081            }
1082        }
1083    }
1084
1085    fn with_preserve_order(&self, preserve_order: bool) -> Option<Arc<dyn DataSource>> {
1086        if self.preserve_order == preserve_order {
1087            return Some(Arc::new(self.clone()));
1088        }
1089
1090        let new_config = FileScanConfig {
1091            preserve_order,
1092            ..self.clone()
1093        };
1094        Some(Arc::new(new_config))
1095    }
1096
1097    /// Create any shared state that should be passed between sibling streams
1098    /// during one execution.
1099    ///
1100    /// This returns `None` when sibling streams must not share work, such as
1101    /// when file order must be preserved, the file groups define the output
1102    /// partitioning needed for the rest of the plan, or work stealing is
1103    /// disabled via
1104    /// `datafusion.execution.enable_file_stream_work_stealing`.
1105    fn create_sibling_state(
1106        &self,
1107        config: &ConfigOptions,
1108    ) -> Option<Arc<dyn Any + Send + Sync>> {
1109        if self.preserve_order
1110            || self.partitioned_by_file_group
1111            || !config.execution.enable_file_stream_work_stealing
1112        {
1113            return None;
1114        }
1115
1116        Some(Arc::new(SharedWorkSource::from_config(self)) as Arc<dyn Any + Send + Sync>)
1117    }
1118}
1119
1120impl FileScanConfig {
1121    /// Returns only the output orderings that are validated against actual
1122    /// file group statistics.
1123    ///
1124    /// For example, individual files may be ordered by `col1 ASC`,
1125    /// but if we have files with these min/max statistics in a single partition / file group:
1126    ///
1127    /// - file1: min(col1) = 10, max(col1) = 20
1128    /// - file2: min(col1) = 5, max(col1) = 15
1129    ///
1130    /// Because reading file1 followed by file2 would produce out-of-order output (there is overlap
1131    /// in the ranges), we cannot retain `col1 ASC` as a valid output ordering.
1132    ///
1133    /// Similarly this would not be a valid order (non-overlapping ranges but not ordered):
1134    ///
1135    /// - file1: min(col1) = 20, max(col1) = 30
1136    /// - file2: min(col1) = 10, max(col1) = 15
1137    ///
1138    /// On the other hand if we had:
1139    ///
1140    /// - file1: min(col1) = 5, max(col1) = 15
1141    /// - file2: min(col1) = 16, max(col1) = 25
1142    ///
1143    /// Then we know that reading file1 followed by file2 will produce ordered output,
1144    /// so `col1 ASC` would be retained.
1145    ///
1146    /// Note that we are checking for ordering *within* *each* file group / partition,
1147    /// files in different partitions are read independently and do not affect each other's ordering.
1148    /// Merging of the multiple partition streams into a single ordered stream is handled
1149    /// upstream e.g. by `SortPreservingMergeExec`.
1150    fn validated_output_ordering(&self) -> Vec<LexOrdering> {
1151        let schema = self.file_source.table_schema().table_schema();
1152        sort_pushdown::validate_orderings(
1153            &self.output_ordering,
1154            schema,
1155            &self.file_groups,
1156            None,
1157        )
1158    }
1159
1160    /// Get the file schema (schema of the files without partition columns)
1161    pub fn file_schema(&self) -> &SchemaRef {
1162        self.file_source.table_schema().file_schema()
1163    }
1164
1165    /// Get the table partition columns
1166    pub fn table_partition_cols(&self) -> &Vec<FieldRef> {
1167        self.file_source.table_schema().table_partition_cols()
1168    }
1169
1170    /// Returns the unprojected table statistics, marking them as inexact if filters are present.
1171    ///
1172    /// When filters are pushed down (including pruning predicates and bloom filters),
1173    /// we can't guarantee the statistics are exact because we don't know how many
1174    /// rows will be filtered out.
1175    pub fn statistics(&self) -> Statistics {
1176        if self.file_source.filter().is_some() {
1177            self.statistics.clone().to_inexact()
1178        } else {
1179            self.statistics.clone()
1180        }
1181    }
1182
1183    pub fn projected_schema(&self) -> Result<Arc<Schema>> {
1184        let schema = self.file_source.table_schema().table_schema();
1185        match self.file_source.projection() {
1186            Some(proj) => Ok(Arc::new(proj.project_schema(schema)?)),
1187            None => Ok(Arc::clone(schema)),
1188        }
1189    }
1190
1191    fn add_filter_equivalence_info(
1192        filter: &Arc<dyn PhysicalExpr>,
1193        eq_properties: &mut EquivalenceProperties,
1194        schema: &Schema,
1195    ) -> Result<()> {
1196        // Gather valid equality pairs from the filter expression
1197        let equal_pairs = split_conjunction(filter).into_iter().filter_map(|expr| {
1198            // Ignore any binary expressions that reference non-existent columns in the current schema
1199            // (e.g. due to unnecessary projections being removed)
1200            reassign_expr_columns(Arc::clone(expr), schema)
1201                .ok()
1202                .and_then(|expr| match expr.downcast_ref::<BinaryExpr>() {
1203                    Some(expr) if expr.op() == &Operator::Eq => {
1204                        Some((Arc::clone(expr.left()), Arc::clone(expr.right())))
1205                    }
1206                    _ => None,
1207                })
1208        });
1209
1210        for (lhs, rhs) in equal_pairs {
1211            eq_properties.add_equal_conditions(lhs, rhs)?
1212        }
1213
1214        Ok(())
1215    }
1216
1217    /// Returns whether newlines in values are supported.
1218    ///
1219    /// This method always returns `false`. The actual newlines_in_values setting
1220    /// has been moved to [`CsvSource`] and should be accessed via
1221    /// [`CsvSource::csv_options()`] instead.
1222    ///
1223    /// [`CsvSource`]: https://docs.rs/datafusion/latest/datafusion/datasource/physical_plan/struct.CsvSource.html
1224    /// [`CsvSource::csv_options()`]: https://docs.rs/datafusion/latest/datafusion/datasource/physical_plan/struct.CsvSource.html#method.csv_options
1225    #[deprecated(
1226        since = "52.0.0",
1227        note = "newlines_in_values has moved to CsvSource. Access it via CsvSource::csv_options().newlines_in_values instead. It will be removed in 58.0.0 or 6 months after 52.0.0 is released, whichever comes first."
1228    )]
1229    pub fn newlines_in_values(&self) -> bool {
1230        false
1231    }
1232
1233    #[deprecated(
1234        since = "52.0.0",
1235        note = "This method is no longer used, use eq_properties instead. It will be removed in 58.0.0 or 6 months after 52.0.0 is released, whichever comes first."
1236    )]
1237    pub fn projected_constraints(&self) -> Constraints {
1238        let props = self.eq_properties();
1239        props.constraints().clone()
1240    }
1241
1242    #[deprecated(
1243        since = "52.0.0",
1244        note = "This method is no longer used, use eq_properties instead. It will be removed in 58.0.0 or 6 months after 52.0.0 is released, whichever comes first."
1245    )]
1246    pub fn file_column_projection_indices(&self) -> Option<Vec<usize>> {
1247        #[expect(deprecated)]
1248        self.file_source.projection().as_ref().map(|p| {
1249            p.ordered_column_indices()
1250                .into_iter()
1251                .filter(|&i| i < self.file_schema().fields().len())
1252                .collect::<Vec<_>>()
1253        })
1254    }
1255
1256    /// Splits file groups into new groups based on statistics to enable efficient parallel processing.
1257    ///
1258    /// The method distributes files across a target number of partitions while ensuring
1259    /// files within each partition maintain sort order based on their min/max statistics.
1260    ///
1261    /// The algorithm works by:
1262    /// 1. Takes files sorted by minimum values
1263    /// 2. For each file:
1264    ///   - Finds eligible groups (empty or where file's min > group's last max)
1265    ///   - Selects the smallest eligible group
1266    ///   - Creates a new group if needed
1267    ///
1268    /// # Parameters
1269    /// * `table_schema`: Schema containing information about the columns
1270    /// * `file_groups`: The original file groups to split
1271    /// * `sort_order`: The lexicographical ordering to maintain within each group
1272    /// * `target_partitions`: The desired number of output partitions
1273    ///
1274    /// # Returns
1275    /// A new set of file groups, where files within each group are non-overlapping with respect to
1276    /// their min/max statistics and maintain the specified sort order.
1277    pub fn split_groups_by_statistics_with_target_partitions(
1278        table_schema: &SchemaRef,
1279        file_groups: &[FileGroup],
1280        sort_order: &LexOrdering,
1281        target_partitions: usize,
1282    ) -> Result<Vec<FileGroup>> {
1283        if target_partitions == 0 {
1284            return Err(internal_datafusion_err!(
1285                "target_partitions must be greater than 0"
1286            ));
1287        }
1288
1289        let flattened_files = file_groups
1290            .iter()
1291            .flat_map(FileGroup::iter)
1292            .collect::<Vec<_>>();
1293
1294        if flattened_files.is_empty() {
1295            return Ok(vec![]);
1296        }
1297
1298        let statistics = MinMaxStatistics::new_from_files(
1299            sort_order,
1300            table_schema,
1301            None,
1302            flattened_files.iter().copied(),
1303        )?;
1304
1305        let indices_sorted_by_min = statistics.min_values_sorted();
1306
1307        // Initialize with target_partitions empty groups
1308        let mut file_groups_indices: Vec<Vec<usize>> = vec![vec![]; target_partitions];
1309
1310        for (idx, min) in indices_sorted_by_min {
1311            if let Some((_, group)) = file_groups_indices
1312                .iter_mut()
1313                .enumerate()
1314                .filter(|(_, group)| {
1315                    group.is_empty()
1316                        || min
1317                            > statistics
1318                                .max(*group.last().expect("groups should not be empty"))
1319                })
1320                .min_by_key(|(_, group)| group.len())
1321            {
1322                group.push(idx);
1323            } else {
1324                // Create a new group if no existing group fits
1325                file_groups_indices.push(vec![idx]);
1326            }
1327        }
1328
1329        // Remove any empty groups
1330        file_groups_indices.retain(|group| !group.is_empty());
1331
1332        // Assemble indices back into groups of PartitionedFiles
1333        Ok(file_groups_indices
1334            .into_iter()
1335            .map(|file_group_indices| {
1336                FileGroup::new(
1337                    file_group_indices
1338                        .into_iter()
1339                        .map(|idx| flattened_files[idx].clone())
1340                        .collect(),
1341                )
1342            })
1343            .collect())
1344    }
1345
1346    /// Attempts to do a bin-packing on files into file groups, such that any two files
1347    /// in a file group are ordered and non-overlapping with respect to their statistics.
1348    /// It will produce the smallest number of file groups possible.
1349    pub fn split_groups_by_statistics(
1350        table_schema: &SchemaRef,
1351        file_groups: &[FileGroup],
1352        sort_order: &LexOrdering,
1353    ) -> Result<Vec<FileGroup>> {
1354        let flattened_files = file_groups
1355            .iter()
1356            .flat_map(FileGroup::iter)
1357            .collect::<Vec<_>>();
1358        // First Fit:
1359        // * Choose the first file group that a file can be placed into.
1360        // * If it fits into no existing file groups, create a new one.
1361        //
1362        // By sorting files by min values and then applying first-fit bin packing,
1363        // we can produce the smallest number of file groups such that
1364        // files within a group are in order and non-overlapping.
1365        //
1366        // Source: Applied Combinatorics (Keller and Trotter), Chapter 6.8
1367        // https://www.appliedcombinatorics.org/book/s_posets_dilworth-intord.html
1368
1369        if flattened_files.is_empty() {
1370            return Ok(vec![]);
1371        }
1372
1373        let statistics = MinMaxStatistics::new_from_files(
1374            sort_order,
1375            table_schema,
1376            None,
1377            flattened_files.iter().copied(),
1378        )
1379        .map_err(|e| {
1380            e.context("construct min/max statistics for split_groups_by_statistics")
1381        })?;
1382
1383        let indices_sorted_by_min = statistics.min_values_sorted();
1384        let mut file_groups_indices: Vec<Vec<usize>> = vec![];
1385
1386        for (idx, min) in indices_sorted_by_min {
1387            let file_group_to_insert = file_groups_indices.iter_mut().find(|group| {
1388                // If our file is non-overlapping and comes _after_ the last file,
1389                // it fits in this file group.
1390                min > statistics.max(
1391                    *group
1392                        .last()
1393                        .expect("groups should be nonempty at construction"),
1394                )
1395            });
1396            match file_group_to_insert {
1397                Some(group) => group.push(idx),
1398                None => file_groups_indices.push(vec![idx]),
1399            }
1400        }
1401
1402        // Assemble indices back into groups of PartitionedFiles
1403        Ok(file_groups_indices
1404            .into_iter()
1405            .map(|file_group_indices| {
1406                file_group_indices
1407                    .into_iter()
1408                    .map(|idx| flattened_files[idx].clone())
1409                    .collect()
1410            })
1411            .collect())
1412    }
1413
1414    /// Write the data_type based on file_source
1415    fn fmt_file_source(&self, t: DisplayFormatType, f: &mut Formatter) -> FmtResult {
1416        write!(f, ", file_type={}", self.file_source.file_type())?;
1417        self.file_source.fmt_extra(t, f)
1418    }
1419
1420    /// Returns the file_source
1421    pub fn file_source(&self) -> &Arc<dyn FileSource> {
1422        &self.file_source
1423    }
1424
1425    // Sort pushdown methods (rebuild_with_source, try_sort_file_groups_by_statistics,
1426    // sort_files_within_groups_by_statistics, any_file_has_nulls_in_sort_columns)
1427    // are in crate::sort_pushdown module.
1428}
1429
1430impl Debug for FileScanConfig {
1431    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
1432        write!(f, "FileScanConfig {{")?;
1433        write!(f, "object_store_url={:?}, ", self.object_store_url)?;
1434
1435        write!(f, "statistics={:?}, ", self.statistics())?;
1436
1437        DisplayAs::fmt_as(self, DisplayFormatType::Verbose, f)?;
1438        write!(f, "}}")
1439    }
1440}
1441
1442impl DisplayAs for FileScanConfig {
1443    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter) -> FmtResult {
1444        let schema = self.projected_schema().map_err(|_| std::fmt::Error {})?;
1445        let orderings = sort_pushdown::get_projected_output_ordering(self, &schema);
1446
1447        write!(f, "file_groups=")?;
1448        FileGroupsDisplay(&self.file_groups).fmt_as(t, f)?;
1449
1450        if !schema.fields().is_empty() {
1451            write!(f, ", projection={}", ProjectSchemaDisplay(&schema))?;
1452        }
1453
1454        if let Some(limit) = self.limit {
1455            write!(f, ", limit={limit}")?;
1456        }
1457
1458        display_orderings(f, &orderings)?;
1459
1460        if !self.constraints.is_empty() {
1461            write!(f, ", {}", self.constraints)?;
1462        }
1463
1464        Ok(())
1465    }
1466}
1467
1468/// Convert type to a type suitable for use as a `ListingTable`
1469/// partition column. Returns `Dictionary(UInt16, val_type)`, which is
1470/// a reasonable trade off between a reasonable number of partition
1471/// values and space efficiency.
1472///
1473/// This use this to specify types for partition columns. However
1474/// you MAY also choose not to dictionary-encode the data or to use a
1475/// different dictionary type.
1476///
1477/// Use [`wrap_partition_value_in_dict`] to wrap a [`ScalarValue`] in the same say.
1478pub fn wrap_partition_type_in_dict(val_type: DataType) -> DataType {
1479    DataType::Dictionary(Box::new(DataType::UInt16), Box::new(val_type))
1480}
1481
1482/// Convert a [`ScalarValue`] of partition columns to a type, as
1483/// described in the documentation of [`wrap_partition_type_in_dict`],
1484/// which can wrap the types.
1485pub fn wrap_partition_value_in_dict(val: ScalarValue) -> ScalarValue {
1486    ScalarValue::Dictionary(Box::new(DataType::UInt16), Box::new(val))
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491    use std::collections::HashMap;
1492
1493    use super::*;
1494    use crate::TableSchema;
1495    use crate::source::DataSourceExec;
1496    use crate::test_util::col;
1497    use crate::{
1498        generate_test_files, test_util::MockSource, tests::aggr_test_schema,
1499        verify_sort_integrity,
1500    };
1501
1502    use arrow::array::{Int32Array, RecordBatch};
1503    use arrow::datatypes::Field;
1504    use datafusion_common::ColumnStatistics;
1505    use datafusion_common::stats::Precision;
1506    use datafusion_common::{Result, assert_batches_eq, internal_err};
1507    use datafusion_execution::TaskContext;
1508    use datafusion_expr::SortExpr;
1509    use datafusion_physical_expr::create_physical_sort_expr;
1510    use datafusion_physical_expr::expressions::Literal;
1511    use datafusion_physical_expr::projection::ProjectionExpr;
1512    use datafusion_physical_expr::projection::ProjectionExprs;
1513    use datafusion_physical_plan::ExecutionPlan;
1514    use datafusion_physical_plan::execution_plan::collect;
1515    use futures::FutureExt as _;
1516    use futures::StreamExt as _;
1517    use futures::stream;
1518    use object_store::ObjectStore;
1519    use std::fmt::Debug;
1520
1521    #[derive(Clone)]
1522    struct InexactSortPushdownSource {
1523        metrics: ExecutionPlanMetricsSet,
1524        table_schema: TableSchema,
1525    }
1526
1527    impl InexactSortPushdownSource {
1528        fn new(table_schema: TableSchema) -> Self {
1529            Self {
1530                metrics: ExecutionPlanMetricsSet::new(),
1531                table_schema,
1532            }
1533        }
1534    }
1535
1536    impl FileSource for InexactSortPushdownSource {
1537        fn create_file_opener(
1538            &self,
1539            _object_store: Arc<dyn ObjectStore>,
1540            _base_config: &FileScanConfig,
1541            _partition: usize,
1542        ) -> Result<Arc<dyn crate::file_stream::FileOpener>> {
1543            unimplemented!()
1544        }
1545
1546        fn table_schema(&self) -> &TableSchema {
1547            &self.table_schema
1548        }
1549
1550        fn with_batch_size(&self, _batch_size: usize) -> Arc<dyn FileSource> {
1551            Arc::new(self.clone())
1552        }
1553
1554        fn metrics(&self) -> &ExecutionPlanMetricsSet {
1555            &self.metrics
1556        }
1557
1558        fn file_type(&self) -> &str {
1559            "mock"
1560        }
1561
1562        fn try_pushdown_sort(
1563            &self,
1564            _order: &[PhysicalSortExpr],
1565            _eq_properties: &EquivalenceProperties,
1566        ) -> Result<SortOrderPushdownResult<Arc<dyn FileSource>>> {
1567            Ok(SortOrderPushdownResult::Inexact {
1568                inner: Arc::new(self.clone()) as Arc<dyn FileSource>,
1569            })
1570        }
1571    }
1572
1573    #[test]
1574    fn physical_plan_config_no_projection_tab_cols_as_field() {
1575        let file_schema = aggr_test_schema();
1576
1577        // make a table_partition_col as a field
1578        let table_partition_col =
1579            Field::new("date", wrap_partition_type_in_dict(DataType::Utf8), true)
1580                .with_metadata(HashMap::from_iter(vec![(
1581                    "key_whatever".to_owned(),
1582                    "value_whatever".to_owned(),
1583                )]));
1584
1585        let conf = config_for_projection(
1586            Arc::clone(&file_schema),
1587            None,
1588            Statistics::new_unknown(&file_schema),
1589            vec![table_partition_col.clone()],
1590        );
1591
1592        // verify the proj_schema includes the last column and exactly the same the field it is defined
1593        let proj_schema = conf.projected_schema().unwrap();
1594        assert_eq!(proj_schema.fields().len(), file_schema.fields().len() + 1);
1595        assert_eq!(
1596            *proj_schema.field(file_schema.fields().len()),
1597            table_partition_col,
1598            "partition columns are the last columns and ust have all values defined in created field"
1599        );
1600    }
1601
1602    #[test]
1603    fn test_split_groups_by_statistics() -> Result<()> {
1604        use chrono::TimeZone;
1605        use datafusion_common::DFSchema;
1606        use datafusion_expr::execution_props::ExecutionProps;
1607        use object_store::{ObjectMeta, path::Path};
1608
1609        struct File {
1610            name: &'static str,
1611            date: &'static str,
1612            statistics: Vec<Option<(Option<f64>, Option<f64>)>>,
1613        }
1614        impl File {
1615            fn new(
1616                name: &'static str,
1617                date: &'static str,
1618                statistics: Vec<Option<(f64, f64)>>,
1619            ) -> Self {
1620                Self::new_nullable(
1621                    name,
1622                    date,
1623                    statistics
1624                        .into_iter()
1625                        .map(|opt| opt.map(|(min, max)| (Some(min), Some(max))))
1626                        .collect(),
1627                )
1628            }
1629
1630            fn new_nullable(
1631                name: &'static str,
1632                date: &'static str,
1633                statistics: Vec<Option<(Option<f64>, Option<f64>)>>,
1634            ) -> Self {
1635                Self {
1636                    name,
1637                    date,
1638                    statistics,
1639                }
1640            }
1641        }
1642
1643        struct TestCase {
1644            name: &'static str,
1645            file_schema: Schema,
1646            files: Vec<File>,
1647            sort: Vec<SortExpr>,
1648            expected_result: Result<Vec<Vec<&'static str>>, &'static str>,
1649        }
1650
1651        use datafusion_expr::col;
1652        let cases = vec![
1653            TestCase {
1654                name: "test sort",
1655                file_schema: Schema::new(vec![Field::new(
1656                    "value".to_string(),
1657                    DataType::Float64,
1658                    false,
1659                )]),
1660                files: vec![
1661                    File::new("0", "2023-01-01", vec![Some((0.00, 0.49))]),
1662                    File::new("1", "2023-01-01", vec![Some((0.50, 1.00))]),
1663                    File::new("2", "2023-01-02", vec![Some((0.00, 1.00))]),
1664                ],
1665                sort: vec![col("value").sort(true, false)],
1666                expected_result: Ok(vec![vec!["0", "1"], vec!["2"]]),
1667            },
1668            // same input but file '2' is in the middle
1669            // test that we still order correctly
1670            TestCase {
1671                name: "test sort with files ordered differently",
1672                file_schema: Schema::new(vec![Field::new(
1673                    "value".to_string(),
1674                    DataType::Float64,
1675                    false,
1676                )]),
1677                files: vec![
1678                    File::new("0", "2023-01-01", vec![Some((0.00, 0.49))]),
1679                    File::new("2", "2023-01-02", vec![Some((0.00, 1.00))]),
1680                    File::new("1", "2023-01-01", vec![Some((0.50, 1.00))]),
1681                ],
1682                sort: vec![col("value").sort(true, false)],
1683                expected_result: Ok(vec![vec!["0", "1"], vec!["2"]]),
1684            },
1685            TestCase {
1686                name: "reverse sort",
1687                file_schema: Schema::new(vec![Field::new(
1688                    "value".to_string(),
1689                    DataType::Float64,
1690                    false,
1691                )]),
1692                files: vec![
1693                    File::new("0", "2023-01-01", vec![Some((0.00, 0.49))]),
1694                    File::new("1", "2023-01-01", vec![Some((0.50, 1.00))]),
1695                    File::new("2", "2023-01-02", vec![Some((0.00, 1.00))]),
1696                ],
1697                sort: vec![col("value").sort(false, true)],
1698                expected_result: Ok(vec![vec!["1", "0"], vec!["2"]]),
1699            },
1700            TestCase {
1701                name: "nullable sort columns, nulls last",
1702                file_schema: Schema::new(vec![Field::new(
1703                    "value".to_string(),
1704                    DataType::Float64,
1705                    true,
1706                )]),
1707                files: vec![
1708                    File::new_nullable(
1709                        "0",
1710                        "2023-01-01",
1711                        vec![Some((Some(0.00), Some(0.49)))],
1712                    ),
1713                    File::new_nullable("1", "2023-01-01", vec![Some((Some(0.50), None))]),
1714                    File::new_nullable("2", "2023-01-02", vec![Some((Some(0.00), None))]),
1715                ],
1716                sort: vec![col("value").sort(true, false)],
1717                expected_result: Ok(vec![vec!["0", "1"], vec!["2"]]),
1718            },
1719            TestCase {
1720                name: "nullable sort columns, nulls first",
1721                file_schema: Schema::new(vec![Field::new(
1722                    "value".to_string(),
1723                    DataType::Float64,
1724                    true,
1725                )]),
1726                files: vec![
1727                    File::new_nullable("0", "2023-01-01", vec![Some((None, Some(0.49)))]),
1728                    File::new_nullable(
1729                        "1",
1730                        "2023-01-01",
1731                        vec![Some((Some(0.50), Some(1.00)))],
1732                    ),
1733                    File::new_nullable("2", "2023-01-02", vec![Some((None, Some(1.00)))]),
1734                ],
1735                sort: vec![col("value").sort(true, true)],
1736                expected_result: Ok(vec![vec!["0", "1"], vec!["2"]]),
1737            },
1738            TestCase {
1739                name: "all three non-overlapping",
1740                file_schema: Schema::new(vec![Field::new(
1741                    "value".to_string(),
1742                    DataType::Float64,
1743                    false,
1744                )]),
1745                files: vec![
1746                    File::new("0", "2023-01-01", vec![Some((0.00, 0.49))]),
1747                    File::new("1", "2023-01-01", vec![Some((0.50, 0.99))]),
1748                    File::new("2", "2023-01-02", vec![Some((1.00, 1.49))]),
1749                ],
1750                sort: vec![col("value").sort(true, false)],
1751                expected_result: Ok(vec![vec!["0", "1", "2"]]),
1752            },
1753            TestCase {
1754                name: "all three overlapping",
1755                file_schema: Schema::new(vec![Field::new(
1756                    "value".to_string(),
1757                    DataType::Float64,
1758                    false,
1759                )]),
1760                files: vec![
1761                    File::new("0", "2023-01-01", vec![Some((0.00, 0.49))]),
1762                    File::new("1", "2023-01-01", vec![Some((0.00, 0.49))]),
1763                    File::new("2", "2023-01-02", vec![Some((0.00, 0.49))]),
1764                ],
1765                sort: vec![col("value").sort(true, false)],
1766                expected_result: Ok(vec![vec!["0"], vec!["1"], vec!["2"]]),
1767            },
1768            TestCase {
1769                name: "empty input",
1770                file_schema: Schema::new(vec![Field::new(
1771                    "value".to_string(),
1772                    DataType::Float64,
1773                    false,
1774                )]),
1775                files: vec![],
1776                sort: vec![col("value").sort(true, false)],
1777                expected_result: Ok(vec![]),
1778            },
1779            TestCase {
1780                name: "one file missing statistics",
1781                file_schema: Schema::new(vec![Field::new(
1782                    "value".to_string(),
1783                    DataType::Float64,
1784                    false,
1785                )]),
1786                files: vec![
1787                    File::new("0", "2023-01-01", vec![Some((0.00, 0.49))]),
1788                    File::new("1", "2023-01-01", vec![Some((0.00, 0.49))]),
1789                    File::new("2", "2023-01-02", vec![None]),
1790                ],
1791                sort: vec![col("value").sort(true, false)],
1792                expected_result: Err(
1793                    "construct min/max statistics for split_groups_by_statistics\ncaused by\ncollect min/max values\ncaused by\nget min/max for column: 'value'\ncaused by\nError during planning: statistics not found",
1794                ),
1795            },
1796        ];
1797
1798        for case in cases {
1799            let table_schema = Arc::new(Schema::new(
1800                case.file_schema
1801                    .fields()
1802                    .clone()
1803                    .into_iter()
1804                    .cloned()
1805                    .chain(Some(Arc::new(Field::new(
1806                        "date".to_string(),
1807                        DataType::Utf8,
1808                        false,
1809                    ))))
1810                    .collect::<Vec<_>>(),
1811            ));
1812            let Some(sort_order) = LexOrdering::new(
1813                case.sort
1814                    .into_iter()
1815                    .map(|expr| {
1816                        create_physical_sort_expr(
1817                            &expr,
1818                            &DFSchema::try_from(Arc::clone(&table_schema))?,
1819                            &ExecutionProps::default(),
1820                        )
1821                    })
1822                    .collect::<Result<Vec<_>>>()?,
1823            ) else {
1824                return internal_err!("This test should always use an ordering");
1825            };
1826
1827            let partitioned_files = FileGroup::new(
1828                case.files.into_iter().map(From::from).collect::<Vec<_>>(),
1829            );
1830            let result = FileScanConfig::split_groups_by_statistics(
1831                &table_schema,
1832                std::slice::from_ref(&partitioned_files),
1833                &sort_order,
1834            );
1835            let results_by_name = result
1836                .as_ref()
1837                .map(|file_groups| {
1838                    file_groups
1839                        .iter()
1840                        .map(|file_group| {
1841                            file_group
1842                                .iter()
1843                                .map(|file| {
1844                                    partitioned_files
1845                                        .iter()
1846                                        .find_map(|f| {
1847                                            if f.object_meta == file.object_meta {
1848                                                Some(
1849                                                    f.object_meta
1850                                                        .location
1851                                                        .as_ref()
1852                                                        .rsplit('/')
1853                                                        .next()
1854                                                        .unwrap()
1855                                                        .trim_end_matches(".parquet"),
1856                                                )
1857                                            } else {
1858                                                None
1859                                            }
1860                                        })
1861                                        .unwrap()
1862                                })
1863                                .collect::<Vec<_>>()
1864                        })
1865                        .collect::<Vec<_>>()
1866                })
1867                .map_err(|e| e.strip_backtrace().leak() as &'static str);
1868
1869            assert_eq!(results_by_name, case.expected_result, "{}", case.name);
1870        }
1871
1872        return Ok(());
1873
1874        impl From<File> for PartitionedFile {
1875            fn from(file: File) -> Self {
1876                let object_meta = ObjectMeta {
1877                    location: Path::from(format!(
1878                        "data/date={}/{}.parquet",
1879                        file.date, file.name
1880                    )),
1881                    last_modified: chrono::Utc.timestamp_nanos(0),
1882                    size: 0,
1883                    e_tag: None,
1884                    version: None,
1885                };
1886                let statistics = Arc::new(Statistics {
1887                    num_rows: Precision::Absent,
1888                    total_byte_size: Precision::Absent,
1889                    column_statistics: file
1890                        .statistics
1891                        .into_iter()
1892                        .map(|stats| {
1893                            stats
1894                                .map(|(min, max)| ColumnStatistics {
1895                                    min_value: Precision::Exact(ScalarValue::Float64(
1896                                        min,
1897                                    )),
1898                                    max_value: Precision::Exact(ScalarValue::Float64(
1899                                        max,
1900                                    )),
1901                                    ..Default::default()
1902                                })
1903                                .unwrap_or_default()
1904                        })
1905                        .collect::<Vec<_>>(),
1906                });
1907                PartitionedFile::new_from_meta(object_meta)
1908                    .with_partition_values(vec![ScalarValue::from(file.date)])
1909                    .with_statistics(statistics)
1910            }
1911        }
1912    }
1913
1914    // sets default for configs that play no role in projections
1915    fn config_for_projection(
1916        file_schema: SchemaRef,
1917        projection: Option<Vec<usize>>,
1918        statistics: Statistics,
1919        table_partition_cols: Vec<Field>,
1920    ) -> FileScanConfig {
1921        let table_schema = TableSchema::new(
1922            file_schema,
1923            table_partition_cols.into_iter().map(Arc::new).collect(),
1924        );
1925        FileScanConfigBuilder::new(
1926            ObjectStoreUrl::parse("test:///").unwrap(),
1927            Arc::new(MockSource::new(table_schema.clone())),
1928        )
1929        .with_projection_indices(projection)
1930        .unwrap()
1931        .with_statistics(statistics)
1932        .build()
1933    }
1934
1935    #[test]
1936    fn test_file_scan_config_builder() {
1937        let file_schema = aggr_test_schema();
1938        let object_store_url = ObjectStoreUrl::parse("test:///").unwrap();
1939
1940        let table_schema = TableSchema::new(
1941            Arc::clone(&file_schema),
1942            vec![Arc::new(Field::new(
1943                "date",
1944                wrap_partition_type_in_dict(DataType::Utf8),
1945                false,
1946            ))],
1947        );
1948
1949        let file_source: Arc<dyn FileSource> =
1950            Arc::new(MockSource::new(table_schema.clone()));
1951
1952        // Create a builder with required parameters
1953        let builder = FileScanConfigBuilder::new(
1954            object_store_url.clone(),
1955            Arc::clone(&file_source),
1956        );
1957
1958        // Build with various configurations
1959        let config = builder
1960            .with_limit(Some(1000))
1961            .with_projection_indices(Some(vec![0, 1]))
1962            .unwrap()
1963            .with_statistics(Statistics::new_unknown(&file_schema))
1964            .with_file_groups(vec![FileGroup::new(vec![PartitionedFile::new(
1965                "test.parquet".to_string(),
1966                1024,
1967            )])])
1968            .with_output_ordering(vec![
1969                [PhysicalSortExpr::new_default(Arc::new(Column::new(
1970                    "date", 0,
1971                )))]
1972                .into(),
1973            ])
1974            .with_file_compression_type(FileCompressionType::UNCOMPRESSED)
1975            .build();
1976
1977        // Verify the built config has all the expected values
1978        assert_eq!(config.object_store_url, object_store_url);
1979        assert_eq!(*config.file_schema(), file_schema);
1980        assert_eq!(config.limit, Some(1000));
1981        assert_eq!(
1982            config
1983                .file_source
1984                .projection()
1985                .as_ref()
1986                .map(|p| p.column_indices()),
1987            Some(vec![0, 1])
1988        );
1989        assert_eq!(config.table_partition_cols().len(), 1);
1990        assert_eq!(config.table_partition_cols()[0].name(), "date");
1991        assert_eq!(config.file_groups.len(), 1);
1992        assert_eq!(config.file_groups[0].len(), 1);
1993        assert_eq!(
1994            config.file_groups[0][0].object_meta.location.as_ref(),
1995            "test.parquet"
1996        );
1997        assert_eq!(
1998            config.file_compression_type,
1999            FileCompressionType::UNCOMPRESSED
2000        );
2001        assert_eq!(config.output_ordering.len(), 1);
2002    }
2003
2004    #[test]
2005    fn equivalence_properties_after_schema_change() {
2006        let file_schema = aggr_test_schema();
2007        let object_store_url = ObjectStoreUrl::parse("test:///").unwrap();
2008
2009        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2010
2011        // Create a file source with a filter
2012        let file_source: Arc<dyn FileSource> = Arc::new(
2013            MockSource::new(table_schema.clone()).with_filter(Arc::new(BinaryExpr::new(
2014                col("c2", &file_schema).unwrap(),
2015                Operator::Eq,
2016                Arc::new(Literal::new(ScalarValue::Int32(Some(10)))),
2017            ))),
2018        );
2019
2020        let config = FileScanConfigBuilder::new(
2021            object_store_url.clone(),
2022            Arc::clone(&file_source),
2023        )
2024        .with_projection_indices(Some(vec![0, 1, 2]))
2025        .unwrap()
2026        .build();
2027
2028        // Simulate projection being updated. Since the filter has already been pushed down,
2029        // the new projection won't include the filtered column.
2030        let exprs = ProjectionExprs::new(vec![ProjectionExpr::new(
2031            col("c1", &file_schema).unwrap(),
2032            "c1",
2033        )]);
2034        let data_source = config
2035            .try_swapping_with_projection(&exprs)
2036            .unwrap()
2037            .unwrap();
2038
2039        // Gather the equivalence properties from the new data source. There should
2040        // be no equivalence class for column c2 since it was removed by the projection.
2041        let eq_properties = data_source.eq_properties();
2042        let eq_group = eq_properties.eq_group();
2043
2044        for class in eq_group.iter() {
2045            for expr in class.iter() {
2046                if let Some(col) = expr.downcast_ref::<Column>() {
2047                    assert_ne!(
2048                        col.name(),
2049                        "c2",
2050                        "c2 should not be present in any equivalence class"
2051                    );
2052                }
2053            }
2054        }
2055    }
2056
2057    #[test]
2058    fn test_file_scan_config_builder_defaults() {
2059        let file_schema = aggr_test_schema();
2060        let object_store_url = ObjectStoreUrl::parse("test:///").unwrap();
2061
2062        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2063
2064        let file_source: Arc<dyn FileSource> =
2065            Arc::new(MockSource::new(table_schema.clone()));
2066
2067        // Create a builder with only required parameters and build without any additional configurations
2068        let config = FileScanConfigBuilder::new(
2069            object_store_url.clone(),
2070            Arc::clone(&file_source),
2071        )
2072        .build();
2073
2074        // Verify default values
2075        assert_eq!(config.object_store_url, object_store_url);
2076        assert_eq!(*config.file_schema(), file_schema);
2077        assert_eq!(config.limit, None);
2078        // When no projection is specified, the file source should have an unprojected projection
2079        // (i.e., all columns)
2080        let expected_projection: Vec<usize> = (0..file_schema.fields().len()).collect();
2081        assert_eq!(
2082            config
2083                .file_source
2084                .projection()
2085                .as_ref()
2086                .map(|p| p.column_indices()),
2087            Some(expected_projection)
2088        );
2089        assert!(config.table_partition_cols().is_empty());
2090        assert!(config.file_groups.is_empty());
2091        assert_eq!(
2092            config.file_compression_type,
2093            FileCompressionType::UNCOMPRESSED
2094        );
2095        assert!(config.output_ordering.is_empty());
2096        assert!(config.constraints.is_empty());
2097
2098        // Verify statistics are set to unknown
2099        assert_eq!(config.statistics().num_rows, Precision::Absent);
2100        assert_eq!(config.statistics().total_byte_size, Precision::Absent);
2101        assert_eq!(
2102            config.statistics().column_statistics.len(),
2103            file_schema.fields().len()
2104        );
2105        for stat in config.statistics().column_statistics {
2106            assert_eq!(stat.distinct_count, Precision::Absent);
2107            assert_eq!(stat.min_value, Precision::Absent);
2108            assert_eq!(stat.max_value, Precision::Absent);
2109            assert_eq!(stat.null_count, Precision::Absent);
2110        }
2111    }
2112
2113    #[test]
2114    fn test_file_scan_config_builder_new_from() {
2115        let schema = aggr_test_schema();
2116        let object_store_url = ObjectStoreUrl::parse("test:///").unwrap();
2117        let partition_cols = vec![Field::new(
2118            "date",
2119            wrap_partition_type_in_dict(DataType::Utf8),
2120            false,
2121        )];
2122        let file = PartitionedFile::new("test_file.parquet", 100);
2123
2124        let table_schema = TableSchema::new(
2125            Arc::clone(&schema),
2126            partition_cols.iter().map(|f| Arc::new(f.clone())).collect(),
2127        );
2128
2129        let file_source: Arc<dyn FileSource> =
2130            Arc::new(MockSource::new(table_schema.clone()));
2131
2132        // Create a config with non-default values
2133        let original_config = FileScanConfigBuilder::new(
2134            object_store_url.clone(),
2135            Arc::clone(&file_source),
2136        )
2137        .with_projection_indices(Some(vec![0, 2]))
2138        .unwrap()
2139        .with_limit(Some(10))
2140        .with_file(file.clone())
2141        .with_constraints(Constraints::default())
2142        .build();
2143
2144        // Create a new builder from the config
2145        let new_builder = FileScanConfigBuilder::from(original_config);
2146
2147        // Build a new config from this builder
2148        let new_config = new_builder.build();
2149
2150        // Verify properties match
2151        let partition_cols = partition_cols.into_iter().map(Arc::new).collect::<Vec<_>>();
2152        assert_eq!(new_config.object_store_url, object_store_url);
2153        assert_eq!(*new_config.file_schema(), schema);
2154        assert_eq!(
2155            new_config
2156                .file_source
2157                .projection()
2158                .as_ref()
2159                .map(|p| p.column_indices()),
2160            Some(vec![0, 2])
2161        );
2162        assert_eq!(new_config.limit, Some(10));
2163        assert_eq!(*new_config.table_partition_cols(), partition_cols);
2164        assert_eq!(new_config.file_groups.len(), 1);
2165        assert_eq!(new_config.file_groups[0].len(), 1);
2166        assert_eq!(
2167            new_config.file_groups[0][0].object_meta.location.as_ref(),
2168            "test_file.parquet"
2169        );
2170        assert_eq!(new_config.constraints, Constraints::default());
2171    }
2172
2173    #[test]
2174    fn test_split_groups_by_statistics_with_target_partitions() -> Result<()> {
2175        use datafusion_common::DFSchema;
2176        use datafusion_expr::{col, execution_props::ExecutionProps};
2177
2178        let schema = Arc::new(Schema::new(vec![Field::new(
2179            "value",
2180            DataType::Float64,
2181            false,
2182        )]));
2183
2184        // Setup sort expression
2185        let exec_props = ExecutionProps::new();
2186        let df_schema = DFSchema::try_from_qualified_schema("test", schema.as_ref())?;
2187        let sort_expr = [col("value").sort(true, false)];
2188        let sort_ordering = sort_expr
2189            .map(|expr| {
2190                create_physical_sort_expr(&expr, &df_schema, &exec_props).unwrap()
2191            })
2192            .into();
2193
2194        // Test case parameters
2195        struct TestCase {
2196            name: String,
2197            file_count: usize,
2198            overlap_factor: f64,
2199            target_partitions: usize,
2200            expected_partition_count: usize,
2201        }
2202
2203        let test_cases = vec![
2204            // Basic cases
2205            TestCase {
2206                name: "no_overlap_10_files_4_partitions".to_string(),
2207                file_count: 10,
2208                overlap_factor: 0.0,
2209                target_partitions: 4,
2210                expected_partition_count: 4,
2211            },
2212            TestCase {
2213                name: "medium_overlap_20_files_5_partitions".to_string(),
2214                file_count: 20,
2215                overlap_factor: 0.5,
2216                target_partitions: 5,
2217                expected_partition_count: 5,
2218            },
2219            TestCase {
2220                name: "high_overlap_30_files_3_partitions".to_string(),
2221                file_count: 30,
2222                overlap_factor: 0.8,
2223                target_partitions: 3,
2224                expected_partition_count: 7,
2225            },
2226            // Edge cases
2227            TestCase {
2228                name: "fewer_files_than_partitions".to_string(),
2229                file_count: 3,
2230                overlap_factor: 0.0,
2231                target_partitions: 10,
2232                expected_partition_count: 3, // Should only create as many partitions as files
2233            },
2234            TestCase {
2235                name: "single_file".to_string(),
2236                file_count: 1,
2237                overlap_factor: 0.0,
2238                target_partitions: 5,
2239                expected_partition_count: 1, // Should create only one partition
2240            },
2241            TestCase {
2242                name: "empty_files".to_string(),
2243                file_count: 0,
2244                overlap_factor: 0.0,
2245                target_partitions: 3,
2246                expected_partition_count: 0, // Empty result for empty input
2247            },
2248        ];
2249
2250        for case in test_cases {
2251            println!("Running test case: {}", case.name);
2252
2253            // Generate files using bench utility function
2254            let file_groups = generate_test_files(case.file_count, case.overlap_factor);
2255
2256            // Call the function under test
2257            let result =
2258                FileScanConfig::split_groups_by_statistics_with_target_partitions(
2259                    &schema,
2260                    &file_groups,
2261                    &sort_ordering,
2262                    case.target_partitions,
2263                )?;
2264
2265            // Verify results
2266            println!(
2267                "Created {} partitions (target was {})",
2268                result.len(),
2269                case.target_partitions
2270            );
2271
2272            // Check partition count
2273            assert_eq!(
2274                result.len(),
2275                case.expected_partition_count,
2276                "Case '{}': Unexpected partition count",
2277                case.name
2278            );
2279
2280            // Verify sort integrity
2281            assert!(
2282                verify_sort_integrity(&result),
2283                "Case '{}': Files within partitions are not properly ordered",
2284                case.name
2285            );
2286
2287            // Distribution check for partitions
2288            if case.file_count > 1 && case.expected_partition_count > 1 {
2289                let group_sizes: Vec<usize> = result.iter().map(FileGroup::len).collect();
2290                let max_size = *group_sizes.iter().max().unwrap();
2291                let min_size = *group_sizes.iter().min().unwrap();
2292
2293                // Check partition balancing - difference shouldn't be extreme
2294                let avg_files_per_partition =
2295                    case.file_count as f64 / case.expected_partition_count as f64;
2296                assert!(
2297                    (max_size as f64) < 2.0 * avg_files_per_partition,
2298                    "Case '{}': Unbalanced distribution. Max partition size {} exceeds twice the average {}",
2299                    case.name,
2300                    max_size,
2301                    avg_files_per_partition
2302                );
2303
2304                println!("Distribution - min files: {min_size}, max files: {max_size}");
2305            }
2306        }
2307
2308        // Test error case: zero target partitions
2309        let empty_groups: Vec<FileGroup> = vec![];
2310        let err = FileScanConfig::split_groups_by_statistics_with_target_partitions(
2311            &schema,
2312            &empty_groups,
2313            &sort_ordering,
2314            0,
2315        )
2316        .unwrap_err();
2317
2318        assert!(
2319            err.to_string()
2320                .contains("target_partitions must be greater than 0"),
2321            "Expected error for zero target partitions"
2322        );
2323
2324        Ok(())
2325    }
2326
2327    #[test]
2328    fn test_partition_statistics_projection() {
2329        // This test verifies that partition_statistics applies projection correctly.
2330        // The old implementation had a bug where it returned file group statistics
2331        // without applying the projection, returning all column statistics instead
2332        // of just the projected ones.
2333
2334        use crate::source::DataSourceExec;
2335        use datafusion_physical_plan::ExecutionPlan;
2336
2337        // Create a schema with 4 columns
2338        let schema = Arc::new(Schema::new(vec![
2339            Field::new("col0", DataType::Int32, false),
2340            Field::new("col1", DataType::Int32, false),
2341            Field::new("col2", DataType::Int32, false),
2342            Field::new("col3", DataType::Int32, false),
2343        ]));
2344
2345        // Create statistics for all 4 columns
2346        let file_group_stats = Statistics {
2347            num_rows: Precision::Exact(100),
2348            total_byte_size: Precision::Exact(1024),
2349            column_statistics: vec![
2350                ColumnStatistics {
2351                    null_count: Precision::Exact(0),
2352                    ..ColumnStatistics::new_unknown()
2353                },
2354                ColumnStatistics {
2355                    null_count: Precision::Exact(5),
2356                    ..ColumnStatistics::new_unknown()
2357                },
2358                ColumnStatistics {
2359                    null_count: Precision::Exact(10),
2360                    ..ColumnStatistics::new_unknown()
2361                },
2362                ColumnStatistics {
2363                    null_count: Precision::Exact(15),
2364                    ..ColumnStatistics::new_unknown()
2365                },
2366            ],
2367        };
2368
2369        // Create a file group with statistics
2370        let file_group = FileGroup::new(vec![PartitionedFile::new("test.parquet", 1024)])
2371            .with_statistics(Arc::new(file_group_stats));
2372
2373        let table_schema = TableSchema::new(Arc::clone(&schema), vec![]);
2374
2375        // Create a FileScanConfig with projection: only keep columns 0 and 2
2376        let config = FileScanConfigBuilder::new(
2377            ObjectStoreUrl::parse("test:///").unwrap(),
2378            Arc::new(MockSource::new(table_schema.clone())),
2379        )
2380        .with_projection_indices(Some(vec![0, 2]))
2381        .unwrap() // Only project columns 0 and 2
2382        .with_file_groups(vec![file_group])
2383        .build();
2384
2385        // Create a DataSourceExec from the config
2386        let exec = DataSourceExec::from_data_source(config);
2387
2388        // Get statistics for partition 0
2389        let partition_stats = exec.partition_statistics(Some(0)).unwrap();
2390
2391        // Verify that only 2 columns are in the statistics (the projected ones)
2392        assert_eq!(
2393            partition_stats.column_statistics.len(),
2394            2,
2395            "Expected 2 column statistics (projected), but got {}",
2396            partition_stats.column_statistics.len()
2397        );
2398
2399        // Verify the column statistics are for columns 0 and 2
2400        assert_eq!(
2401            partition_stats.column_statistics[0].null_count,
2402            Precision::Exact(0),
2403            "First projected column should be col0 with 0 nulls"
2404        );
2405        assert_eq!(
2406            partition_stats.column_statistics[1].null_count,
2407            Precision::Exact(10),
2408            "Second projected column should be col2 with 10 nulls"
2409        );
2410
2411        // Verify row count and byte size
2412        assert_eq!(partition_stats.num_rows, Precision::Exact(100));
2413        assert_eq!(partition_stats.total_byte_size, Precision::Exact(800));
2414    }
2415
2416    /// Regression test for reusing a `DataSourceExec` after its execution-local
2417    /// shared work queue has been drained.
2418    ///
2419    /// This test uses a single file group with two files so the scan creates a
2420    /// shared unopened-file queue. Executing after `reset_state` must recreate
2421    /// the shared queue and return the same rows again.
2422    #[tokio::test]
2423    async fn reset_state_recreates_shared_work_source() -> Result<()> {
2424        let schema = Arc::new(Schema::new(vec![Field::new(
2425            "value",
2426            DataType::Int32,
2427            false,
2428        )]));
2429        let file_source = Arc::new(
2430            MockSource::new(Arc::clone(&schema))
2431                .with_file_opener(Arc::new(ResetStateTestFileOpener { schema })),
2432        );
2433
2434        let config =
2435            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2436                .with_file_group(FileGroup::new(vec![
2437                    PartitionedFile::new("file1.parquet", 100),
2438                    PartitionedFile::new("file2.parquet", 100),
2439                ]))
2440                .build();
2441
2442        let exec: Arc<dyn ExecutionPlan> = DataSourceExec::from_data_source(config);
2443        let task_ctx = Arc::new(TaskContext::default());
2444
2445        // Running the same scan after resetting the state, should
2446        // produce the same answer.
2447        let first_run = collect(Arc::clone(&exec), Arc::clone(&task_ctx)).await?;
2448        let reset_exec = exec.reset_state()?;
2449        let second_run = collect(reset_exec, task_ctx).await?;
2450
2451        let expected = [
2452            "+-------+",
2453            "| value |",
2454            "+-------+",
2455            "| 1     |",
2456            "| 2     |",
2457            "+-------+",
2458        ];
2459        assert_batches_eq!(expected, &first_run);
2460        assert_batches_eq!(expected, &second_run);
2461
2462        Ok(())
2463    }
2464
2465    /// Test-only `FileOpener` that turns file names like `file1.parquet` into a
2466    /// single-batch stream containing that numeric value
2467    #[derive(Debug)]
2468    struct ResetStateTestFileOpener {
2469        schema: SchemaRef,
2470    }
2471
2472    impl crate::file_stream::FileOpener for ResetStateTestFileOpener {
2473        fn open(
2474            &self,
2475            file: PartitionedFile,
2476        ) -> Result<crate::file_stream::FileOpenFuture> {
2477            let value = file
2478                .object_meta
2479                .location
2480                .as_ref()
2481                .trim_start_matches("file")
2482                .trim_end_matches(".parquet")
2483                .parse::<i32>()
2484                .expect("invalid test file name");
2485            let schema = Arc::clone(&self.schema);
2486            Ok(async move {
2487                let batch = RecordBatch::try_new(
2488                    schema,
2489                    vec![Arc::new(Int32Array::from(vec![value]))],
2490                )
2491                .expect("test batch should be valid");
2492                Ok(stream::iter(vec![Ok(batch)]).boxed())
2493            }
2494            .boxed())
2495        }
2496    }
2497
2498    #[test]
2499    fn test_output_partitioning_not_partitioned_by_file_group() {
2500        let file_schema = aggr_test_schema();
2501        let partition_col =
2502            Field::new("date", wrap_partition_type_in_dict(DataType::Utf8), false);
2503
2504        let config = config_for_projection(
2505            Arc::clone(&file_schema),
2506            None,
2507            Statistics::new_unknown(&file_schema),
2508            vec![partition_col],
2509        );
2510
2511        // partitioned_by_file_group defaults to false
2512        let partitioning = config.output_partitioning();
2513        assert!(matches!(partitioning, Partitioning::UnknownPartitioning(_)));
2514    }
2515
2516    #[test]
2517    fn test_output_partitioning_no_partition_columns() {
2518        let file_schema = aggr_test_schema();
2519        let mut config = config_for_projection(
2520            Arc::clone(&file_schema),
2521            None,
2522            Statistics::new_unknown(&file_schema),
2523            vec![], // No partition columns
2524        );
2525        config.partitioned_by_file_group = true;
2526
2527        let partitioning = config.output_partitioning();
2528        assert!(matches!(partitioning, Partitioning::UnknownPartitioning(_)));
2529    }
2530
2531    #[test]
2532    fn test_output_partitioning_with_partition_columns() {
2533        let file_schema = aggr_test_schema();
2534
2535        // Test single partition column
2536        let single_partition_col = vec![Field::new(
2537            "date",
2538            wrap_partition_type_in_dict(DataType::Utf8),
2539            false,
2540        )];
2541
2542        let mut config = config_for_projection(
2543            Arc::clone(&file_schema),
2544            None,
2545            Statistics::new_unknown(&file_schema),
2546            single_partition_col,
2547        );
2548        config.partitioned_by_file_group = true;
2549        config.file_groups = vec![
2550            FileGroup::new(vec![PartitionedFile::new("f1.parquet".to_string(), 1024)]),
2551            FileGroup::new(vec![PartitionedFile::new("f2.parquet".to_string(), 1024)]),
2552            FileGroup::new(vec![PartitionedFile::new("f3.parquet".to_string(), 1024)]),
2553        ];
2554
2555        let partitioning = config.output_partitioning();
2556        match partitioning {
2557            Partitioning::Hash(exprs, num_partitions) => {
2558                assert_eq!(num_partitions, 3);
2559                assert_eq!(exprs.len(), 1);
2560                assert_eq!(exprs[0].downcast_ref::<Column>().unwrap().name(), "date");
2561            }
2562            _ => panic!("Expected Hash partitioning"),
2563        }
2564
2565        // Test multiple partition columns
2566        let multiple_partition_cols = vec![
2567            Field::new("year", wrap_partition_type_in_dict(DataType::Utf8), false),
2568            Field::new("month", wrap_partition_type_in_dict(DataType::Utf8), false),
2569        ];
2570
2571        config = config_for_projection(
2572            Arc::clone(&file_schema),
2573            None,
2574            Statistics::new_unknown(&file_schema),
2575            multiple_partition_cols,
2576        );
2577        config.partitioned_by_file_group = true;
2578        config.file_groups = vec![
2579            FileGroup::new(vec![PartitionedFile::new("f1.parquet".to_string(), 1024)]),
2580            FileGroup::new(vec![PartitionedFile::new("f2.parquet".to_string(), 1024)]),
2581        ];
2582
2583        let partitioning = config.output_partitioning();
2584        match partitioning {
2585            Partitioning::Hash(exprs, num_partitions) => {
2586                assert_eq!(num_partitions, 2);
2587                assert_eq!(exprs.len(), 2);
2588                let col_names: Vec<_> = exprs
2589                    .iter()
2590                    .map(|e| e.downcast_ref::<Column>().unwrap().name())
2591                    .collect();
2592                assert_eq!(col_names, vec!["year", "month"]);
2593            }
2594            _ => panic!("Expected Hash partitioning"),
2595        }
2596    }
2597
2598    #[test]
2599    fn try_pushdown_sort_reverses_file_groups_only_when_requested_is_reverse()
2600    -> Result<()> {
2601        let file_schema =
2602            Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, true)]));
2603
2604        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2605        let file_source = Arc::new(InexactSortPushdownSource::new(table_schema));
2606
2607        let file_groups = vec![FileGroup::new(vec![
2608            PartitionedFile::new("file1", 1),
2609            PartitionedFile::new("file2", 1),
2610        ])];
2611
2612        let sort_expr_asc = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2613        let config =
2614            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2615                .with_file_groups(file_groups)
2616                .with_output_ordering(vec![
2617                    LexOrdering::new(vec![sort_expr_asc.clone()]).unwrap(),
2618                ])
2619                .build();
2620
2621        let requested_asc = vec![sort_expr_asc.clone()];
2622        let result = config.try_pushdown_sort(&requested_asc)?;
2623        let SortOrderPushdownResult::Inexact { inner } = result else {
2624            panic!("Expected Inexact result");
2625        };
2626        let pushed_config = inner
2627            .downcast_ref::<FileScanConfig>()
2628            .expect("Expected FileScanConfig");
2629        let pushed_files = pushed_config.file_groups[0].files();
2630        assert_eq!(pushed_files[0].object_meta.location.as_ref(), "file1");
2631        assert_eq!(pushed_files[1].object_meta.location.as_ref(), "file2");
2632
2633        let requested_desc = vec![sort_expr_asc.reverse()];
2634        let result = config.try_pushdown_sort(&requested_desc)?;
2635        let SortOrderPushdownResult::Inexact { inner } = result else {
2636            panic!("Expected Inexact result");
2637        };
2638        let pushed_config = inner
2639            .downcast_ref::<FileScanConfig>()
2640            .expect("Expected FileScanConfig");
2641        let pushed_files = pushed_config.file_groups[0].files();
2642        assert_eq!(pushed_files[0].object_meta.location.as_ref(), "file2");
2643        assert_eq!(pushed_files[1].object_meta.location.as_ref(), "file1");
2644
2645        Ok(())
2646    }
2647
2648    fn make_file_with_stats(name: &str, min: f64, max: f64) -> PartitionedFile {
2649        PartitionedFile::new(name.to_string(), 1024).with_statistics(Arc::new(
2650            Statistics {
2651                num_rows: Precision::Exact(100),
2652                total_byte_size: Precision::Exact(1024),
2653                column_statistics: vec![ColumnStatistics {
2654                    null_count: Precision::Exact(0),
2655                    min_value: Precision::Exact(ScalarValue::Float64(Some(min))),
2656                    max_value: Precision::Exact(ScalarValue::Float64(Some(max))),
2657                    ..Default::default()
2658                }],
2659            },
2660        ))
2661    }
2662
2663    #[derive(Clone)]
2664    struct ExactSortPushdownSource {
2665        metrics: ExecutionPlanMetricsSet,
2666        table_schema: TableSchema,
2667    }
2668
2669    impl ExactSortPushdownSource {
2670        fn new(table_schema: TableSchema) -> Self {
2671            Self {
2672                metrics: ExecutionPlanMetricsSet::new(),
2673                table_schema,
2674            }
2675        }
2676    }
2677
2678    impl FileSource for ExactSortPushdownSource {
2679        fn create_file_opener(
2680            &self,
2681            _object_store: Arc<dyn ObjectStore>,
2682            _base_config: &FileScanConfig,
2683            _partition: usize,
2684        ) -> Result<Arc<dyn crate::file_stream::FileOpener>> {
2685            unimplemented!()
2686        }
2687
2688        fn table_schema(&self) -> &TableSchema {
2689            &self.table_schema
2690        }
2691
2692        fn with_batch_size(&self, _batch_size: usize) -> Arc<dyn FileSource> {
2693            Arc::new(self.clone())
2694        }
2695
2696        fn metrics(&self) -> &ExecutionPlanMetricsSet {
2697            &self.metrics
2698        }
2699
2700        fn file_type(&self) -> &str {
2701            "mock_exact"
2702        }
2703
2704        fn try_pushdown_sort(
2705            &self,
2706            _order: &[PhysicalSortExpr],
2707            _eq_properties: &EquivalenceProperties,
2708        ) -> Result<SortOrderPushdownResult<Arc<dyn FileSource>>> {
2709            Ok(SortOrderPushdownResult::Exact {
2710                inner: Arc::new(self.clone()) as Arc<dyn FileSource>,
2711            })
2712        }
2713    }
2714
2715    #[test]
2716    fn sort_pushdown_unsupported_source_files_get_sorted() -> Result<()> {
2717        let file_schema =
2718            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2719        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2720        let file_source = Arc::new(MockSource::new(table_schema));
2721
2722        let file_groups = vec![FileGroup::new(vec![
2723            make_file_with_stats("file3", 20.0, 30.0),
2724            make_file_with_stats("file1", 0.0, 9.0),
2725            make_file_with_stats("file2", 10.0, 19.0),
2726        ])];
2727
2728        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2729        let config =
2730            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2731                .with_file_groups(file_groups)
2732                .build();
2733
2734        let result = config.try_pushdown_sort(&[sort_expr])?;
2735        let SortOrderPushdownResult::Inexact { inner } = result else {
2736            panic!("Expected Inexact result, got {result:?}");
2737        };
2738        let pushed_config = inner
2739            .downcast_ref::<FileScanConfig>()
2740            .expect("Expected FileScanConfig");
2741        let files = pushed_config.file_groups[0].files();
2742        assert_eq!(files[0].object_meta.location.as_ref(), "file1");
2743        assert_eq!(files[1].object_meta.location.as_ref(), "file2");
2744        assert_eq!(files[2].object_meta.location.as_ref(), "file3");
2745        assert!(pushed_config.output_ordering.is_empty());
2746        Ok(())
2747    }
2748
2749    #[test]
2750    fn sort_pushdown_unsupported_source_already_sorted() -> Result<()> {
2751        let file_schema =
2752            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2753        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2754        let file_source = Arc::new(MockSource::new(table_schema));
2755
2756        let file_groups = vec![FileGroup::new(vec![
2757            make_file_with_stats("file1", 0.0, 9.0),
2758            make_file_with_stats("file2", 10.0, 19.0),
2759            make_file_with_stats("file3", 20.0, 30.0),
2760        ])];
2761
2762        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2763        let config =
2764            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2765                .with_file_groups(file_groups)
2766                .build();
2767
2768        let result = config.try_pushdown_sort(&[sort_expr])?;
2769        assert!(matches!(result, SortOrderPushdownResult::Unsupported));
2770        Ok(())
2771    }
2772
2773    #[test]
2774    fn sort_pushdown_unsupported_source_descending_sort() -> Result<()> {
2775        let file_schema =
2776            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2777        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2778        let file_source = Arc::new(MockSource::new(table_schema));
2779
2780        let file_groups = vec![FileGroup::new(vec![
2781            make_file_with_stats("file1", 0.0, 9.0),
2782            make_file_with_stats("file3", 20.0, 30.0),
2783            make_file_with_stats("file2", 10.0, 19.0),
2784        ])];
2785
2786        let sort_expr = PhysicalSortExpr::new(
2787            Arc::new(Column::new("a", 0)),
2788            arrow::compute::SortOptions {
2789                descending: true,
2790                nulls_first: true,
2791            },
2792        );
2793        let config =
2794            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2795                .with_file_groups(file_groups)
2796                .build();
2797
2798        let result = config.try_pushdown_sort(&[sort_expr])?;
2799        let SortOrderPushdownResult::Inexact { inner } = result else {
2800            panic!("Expected Inexact result");
2801        };
2802        let pushed_config = inner
2803            .downcast_ref::<FileScanConfig>()
2804            .expect("Expected FileScanConfig");
2805        let files = pushed_config.file_groups[0].files();
2806        assert_eq!(files[0].object_meta.location.as_ref(), "file3");
2807        assert_eq!(files[1].object_meta.location.as_ref(), "file2");
2808        assert_eq!(files[2].object_meta.location.as_ref(), "file1");
2809        Ok(())
2810    }
2811
2812    #[test]
2813    fn sort_pushdown_exact_source_non_overlapping_returns_exact() -> Result<()> {
2814        let file_schema =
2815            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2816        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2817        let file_source = Arc::new(ExactSortPushdownSource::new(table_schema));
2818
2819        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2820
2821        let file_groups = vec![FileGroup::new(vec![
2822            make_file_with_stats("file1", 0.0, 9.0),
2823            make_file_with_stats("file2", 10.0, 19.0),
2824            make_file_with_stats("file3", 20.0, 30.0),
2825        ])];
2826
2827        let config =
2828            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2829                .with_file_groups(file_groups)
2830                .with_output_ordering(vec![
2831                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
2832                ])
2833                .build();
2834
2835        let result = config.try_pushdown_sort(&[sort_expr])?;
2836        let SortOrderPushdownResult::Exact { inner } = result else {
2837            panic!("Expected Exact result, got {result:?}");
2838        };
2839        let pushed_config = inner
2840            .downcast_ref::<FileScanConfig>()
2841            .expect("Expected FileScanConfig");
2842        assert!(!pushed_config.output_ordering.is_empty());
2843        Ok(())
2844    }
2845
2846    #[test]
2847    fn sort_pushdown_exact_source_overlapping_downgraded_to_inexact() -> Result<()> {
2848        let file_schema =
2849            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2850        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2851        let file_source = Arc::new(ExactSortPushdownSource::new(table_schema));
2852
2853        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2854
2855        let file_groups = vec![FileGroup::new(vec![
2856            make_file_with_stats("file1", 0.0, 15.0),
2857            make_file_with_stats("file2", 10.0, 25.0),
2858            make_file_with_stats("file3", 20.0, 30.0),
2859        ])];
2860
2861        let config =
2862            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2863                .with_file_groups(file_groups)
2864                .with_output_ordering(vec![
2865                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
2866                ])
2867                .build();
2868
2869        let result = config.try_pushdown_sort(&[sort_expr])?;
2870        let SortOrderPushdownResult::Inexact { inner } = result else {
2871            panic!("Expected Inexact (downgraded), got {result:?}");
2872        };
2873        let pushed_config = inner
2874            .downcast_ref::<FileScanConfig>()
2875            .expect("Expected FileScanConfig");
2876        assert!(pushed_config.output_ordering.is_empty());
2877        Ok(())
2878    }
2879
2880    #[test]
2881    fn sort_pushdown_exact_source_out_of_order_returns_exact() -> Result<()> {
2882        let file_schema =
2883            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2884        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2885        let file_source = Arc::new(ExactSortPushdownSource::new(table_schema));
2886
2887        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2888
2889        let file_groups = vec![FileGroup::new(vec![
2890            make_file_with_stats("file3", 20.0, 30.0),
2891            make_file_with_stats("file1", 0.0, 9.0),
2892            make_file_with_stats("file2", 10.0, 19.0),
2893        ])];
2894
2895        let config =
2896            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2897                .with_file_groups(file_groups)
2898                .with_output_ordering(vec![
2899                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
2900                ])
2901                .build();
2902
2903        let result = config.try_pushdown_sort(&[sort_expr])?;
2904        let SortOrderPushdownResult::Exact { inner } = result else {
2905            panic!("Expected Exact result, got {result:?}");
2906        };
2907        let pushed_config = inner
2908            .downcast_ref::<FileScanConfig>()
2909            .expect("Expected FileScanConfig");
2910        let files = pushed_config.file_groups[0].files();
2911        assert_eq!(files[0].object_meta.location.as_ref(), "file1");
2912        assert_eq!(files[1].object_meta.location.as_ref(), "file2");
2913        assert_eq!(files[2].object_meta.location.as_ref(), "file3");
2914        assert!(!pushed_config.output_ordering.is_empty());
2915        Ok(())
2916    }
2917
2918    #[test]
2919    fn sort_pushdown_unsupported_source_single_file_groups() -> Result<()> {
2920        let file_schema =
2921            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2922        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2923        let file_source = Arc::new(MockSource::new(table_schema));
2924
2925        let file_groups = vec![
2926            FileGroup::new(vec![make_file_with_stats("file1", 0.0, 9.0)]),
2927            FileGroup::new(vec![make_file_with_stats("file2", 10.0, 19.0)]),
2928        ];
2929
2930        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2931        let config =
2932            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2933                .with_file_groups(file_groups)
2934                .build();
2935
2936        let result = config.try_pushdown_sort(&[sort_expr])?;
2937        assert!(
2938            matches!(result, SortOrderPushdownResult::Unsupported),
2939            "Expected Unsupported for single-file groups"
2940        );
2941        Ok(())
2942    }
2943
2944    #[test]
2945    fn sort_pushdown_unsupported_source_multiple_groups() -> Result<()> {
2946        let file_schema =
2947            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2948        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2949        let file_source = Arc::new(MockSource::new(table_schema));
2950
2951        let file_groups = vec![
2952            FileGroup::new(vec![
2953                make_file_with_stats("file_b", 10.0, 19.0),
2954                make_file_with_stats("file_a", 0.0, 9.0),
2955            ]),
2956            FileGroup::new(vec![
2957                make_file_with_stats("file_d", 30.0, 39.0),
2958                make_file_with_stats("file_c", 20.0, 29.0),
2959            ]),
2960        ];
2961
2962        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
2963        let config =
2964            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
2965                .with_file_groups(file_groups)
2966                .build();
2967
2968        let result = config.try_pushdown_sort(&[sort_expr])?;
2969        let SortOrderPushdownResult::Inexact { inner } = result else {
2970            panic!("Expected Inexact result");
2971        };
2972        let pushed_config = inner
2973            .downcast_ref::<FileScanConfig>()
2974            .expect("Expected FileScanConfig");
2975        let files0 = pushed_config.file_groups[0].files();
2976        assert_eq!(files0[0].object_meta.location.as_ref(), "file_a");
2977        assert_eq!(files0[1].object_meta.location.as_ref(), "file_b");
2978        let files1 = pushed_config.file_groups[1].files();
2979        assert_eq!(files1[0].object_meta.location.as_ref(), "file_c");
2980        assert_eq!(files1[1].object_meta.location.as_ref(), "file_d");
2981        Ok(())
2982    }
2983
2984    #[test]
2985    fn sort_pushdown_unsupported_source_partial_statistics() -> Result<()> {
2986        let file_schema =
2987            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
2988        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
2989        let file_source = Arc::new(MockSource::new(table_schema));
2990
2991        let file_groups = vec![
2992            FileGroup::new(vec![
2993                make_file_with_stats("file_b", 10.0, 19.0),
2994                make_file_with_stats("file_a", 0.0, 9.0),
2995            ]),
2996            FileGroup::new(vec![
2997                PartitionedFile::new("file_d".to_string(), 1024),
2998                PartitionedFile::new("file_c".to_string(), 1024),
2999            ]),
3000        ];
3001
3002        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
3003        let config =
3004            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
3005                .with_file_groups(file_groups)
3006                .build();
3007
3008        let result = config.try_pushdown_sort(&[sort_expr])?;
3009        let SortOrderPushdownResult::Inexact { inner } = result else {
3010            panic!("Expected Inexact result");
3011        };
3012        let pushed_config = inner
3013            .downcast_ref::<FileScanConfig>()
3014            .expect("Expected FileScanConfig");
3015        let files0 = pushed_config.file_groups[0].files();
3016        assert_eq!(files0[0].object_meta.location.as_ref(), "file_a");
3017        assert_eq!(files0[1].object_meta.location.as_ref(), "file_b");
3018        let files1 = pushed_config.file_groups[1].files();
3019        assert_eq!(files1[0].object_meta.location.as_ref(), "file_d");
3020        assert_eq!(files1[1].object_meta.location.as_ref(), "file_c");
3021        Ok(())
3022    }
3023
3024    #[test]
3025    fn sort_pushdown_inexact_source_with_statistics_sorting() -> Result<()> {
3026        let file_schema =
3027            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
3028        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
3029        let file_source = Arc::new(InexactSortPushdownSource::new(table_schema));
3030
3031        let file_groups = vec![FileGroup::new(vec![
3032            make_file_with_stats("file2", 10.0, 19.0),
3033            make_file_with_stats("file1", 0.0, 9.0),
3034        ])];
3035
3036        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
3037        let config =
3038            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
3039                .with_file_groups(file_groups)
3040                .build();
3041
3042        let result = config.try_pushdown_sort(&[sort_expr])?;
3043        let SortOrderPushdownResult::Inexact { inner } = result else {
3044            panic!("Expected Inexact result");
3045        };
3046        let pushed_config = inner
3047            .downcast_ref::<FileScanConfig>()
3048            .expect("Expected FileScanConfig");
3049        let files = pushed_config.file_groups[0].files();
3050        assert_eq!(files[0].object_meta.location.as_ref(), "file1");
3051        assert_eq!(files[1].object_meta.location.as_ref(), "file2");
3052        assert!(pushed_config.output_ordering.is_empty());
3053        Ok(())
3054    }
3055
3056    #[test]
3057    fn sort_pushdown_exact_multi_group_preserves_parallelism() -> Result<()> {
3058        // ExactSortPushdownSource + 4 non-overlapping files in 2 interleaved groups.
3059        // Groups should NOT be redistributed — interleaved groups allow SPM to
3060        // pull from both partitions concurrently, keeping parallel I/O active.
3061        // Redistributing consecutively would make SPM read one partition at a
3062        // time (all values in group 0 < group 1), degrading to single-threaded I/O.
3063        let file_schema =
3064            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
3065        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
3066        let file_source = Arc::new(ExactSortPushdownSource::new(table_schema));
3067
3068        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
3069
3070        // 2 groups with interleaved ranges (simulating bin-packing result):
3071        // Group 0: [file_01(0-9), file_03(20-29)]
3072        // Group 1: [file_02(10-19), file_04(30-39)]
3073        let file_groups = vec![
3074            FileGroup::new(vec![
3075                make_file_with_stats("file_01", 0.0, 9.0),
3076                make_file_with_stats("file_03", 20.0, 29.0),
3077            ]),
3078            FileGroup::new(vec![
3079                make_file_with_stats("file_02", 10.0, 19.0),
3080                make_file_with_stats("file_04", 30.0, 39.0),
3081            ]),
3082        ];
3083
3084        let config =
3085            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
3086                .with_file_groups(file_groups)
3087                .with_output_ordering(vec![
3088                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
3089                ])
3090                .build();
3091
3092        let result = config.try_pushdown_sort(&[sort_expr])?;
3093        let SortOrderPushdownResult::Exact { inner } = result else {
3094            panic!("Expected Exact result, got {result:?}");
3095        };
3096        let pushed_config = inner
3097            .downcast_ref::<FileScanConfig>()
3098            .expect("Expected FileScanConfig");
3099
3100        // 2 groups preserved (parallelism maintained)
3101        assert_eq!(pushed_config.file_groups.len(), 2);
3102
3103        // Files within each group are sorted by stats, but groups are NOT
3104        // redistributed — interleaved assignment from bin-packing is kept
3105        let files0 = pushed_config.file_groups[0].files();
3106        assert_eq!(files0[0].object_meta.location.as_ref(), "file_01");
3107        assert_eq!(files0[1].object_meta.location.as_ref(), "file_03");
3108        let files1 = pushed_config.file_groups[1].files();
3109        assert_eq!(files1[0].object_meta.location.as_ref(), "file_02");
3110        assert_eq!(files1[1].object_meta.location.as_ref(), "file_04");
3111
3112        // output_ordering preserved (Exact, each group internally non-overlapping)
3113        assert!(!pushed_config.output_ordering.is_empty());
3114        Ok(())
3115    }
3116
3117    #[test]
3118    fn sort_pushdown_reverse_preserves_file_order_with_stats() -> Result<()> {
3119        // Reverse scan should reverse file order but NOT apply statistics-based
3120        // sorting (which would undo the reversal). The result is Inexact.
3121        let file_schema =
3122            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, false)]));
3123        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
3124        let file_source = Arc::new(InexactSortPushdownSource::new(table_schema));
3125
3126        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
3127
3128        // Files with stats, in ASC order. Output ordering is [a ASC].
3129        let file_groups = vec![FileGroup::new(vec![
3130            make_file_with_stats("file1", 0.0, 9.0),
3131            make_file_with_stats("file2", 10.0, 19.0),
3132            make_file_with_stats("file3", 20.0, 30.0),
3133        ])];
3134
3135        let config =
3136            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
3137                .with_file_groups(file_groups)
3138                .with_output_ordering(vec![
3139                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
3140                ])
3141                .build();
3142
3143        // Request DESC → reverse path
3144        let result = config.try_pushdown_sort(&[sort_expr.reverse()])?;
3145        let SortOrderPushdownResult::Inexact { inner } = result else {
3146            panic!("Expected Inexact for reverse scan, got {result:?}");
3147        };
3148        let pushed_config = inner
3149            .downcast_ref::<FileScanConfig>()
3150            .expect("Expected FileScanConfig");
3151
3152        // Files should be reversed (not re-sorted by stats)
3153        let files = pushed_config.file_groups[0].files();
3154        assert_eq!(files[0].object_meta.location.as_ref(), "file3");
3155        assert_eq!(files[1].object_meta.location.as_ref(), "file2");
3156        assert_eq!(files[2].object_meta.location.as_ref(), "file1");
3157
3158        // output_ordering cleared (Inexact)
3159        assert!(pushed_config.output_ordering.is_empty());
3160        Ok(())
3161    }
3162
3163    /// Helper: create a PartitionedFile with stats including null count
3164    fn make_file_with_null_stats(
3165        name: &str,
3166        min: f64,
3167        max: f64,
3168        null_count: usize,
3169    ) -> PartitionedFile {
3170        PartitionedFile::new(name.to_string(), 1024).with_statistics(Arc::new(
3171            Statistics {
3172                num_rows: Precision::Exact(100),
3173                total_byte_size: Precision::Exact(1024),
3174                column_statistics: vec![ColumnStatistics {
3175                    null_count: Precision::Exact(null_count),
3176                    min_value: Precision::Exact(ScalarValue::Float64(Some(min))),
3177                    max_value: Precision::Exact(ScalarValue::Float64(Some(max))),
3178                    ..Default::default()
3179                }],
3180            },
3181        ))
3182    }
3183
3184    #[test]
3185    fn sort_pushdown_unsupported_with_nulls_does_not_upgrade_to_exact() -> Result<()> {
3186        // Files are non-overlapping but one has NULLs.
3187        // Should NOT upgrade to Exact — NULLs would appear in wrong position.
3188        let file_schema =
3189            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, true)]));
3190        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
3191        let file_source = Arc::new(MockSource::new(table_schema));
3192
3193        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
3194
3195        // Files in wrong order (high min first) to trigger reordering
3196        let file_groups = vec![FileGroup::new(vec![
3197            make_file_with_null_stats("b_no_nulls", 10.0, 19.0, 0),
3198            make_file_with_null_stats("a_with_nulls", 0.0, 9.0, 5), // has NULLs
3199        ])];
3200
3201        let config =
3202            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
3203                .with_file_groups(file_groups)
3204                .with_output_ordering(vec![
3205                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
3206                ])
3207                .build();
3208
3209        let result = config.try_pushdown_sort(&[sort_expr])?;
3210        // Should be Inexact (not Exact) because of NULLs
3211        assert!(
3212            matches!(result, SortOrderPushdownResult::Inexact { .. }),
3213            "Expected Inexact due to NULLs, got {result:?}"
3214        );
3215        Ok(())
3216    }
3217
3218    #[test]
3219    fn sort_pushdown_unsupported_no_nulls_upgrades_to_exact() -> Result<()> {
3220        // Files are non-overlapping, no NULLs → should upgrade to Exact
3221        let file_schema =
3222            Arc::new(Schema::new(vec![Field::new("a", DataType::Float64, true)]));
3223        let table_schema = TableSchema::new(Arc::clone(&file_schema), vec![]);
3224        let file_source = Arc::new(MockSource::new(table_schema));
3225
3226        let sort_expr = PhysicalSortExpr::new_default(Arc::new(Column::new("a", 0)));
3227
3228        let file_groups = vec![FileGroup::new(vec![
3229            make_file_with_null_stats("b_high", 10.0, 19.0, 0),
3230            make_file_with_null_stats("a_low", 0.0, 9.0, 0),
3231        ])];
3232
3233        let config =
3234            FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(), file_source)
3235                .with_file_groups(file_groups)
3236                .with_output_ordering(vec![
3237                    LexOrdering::new(vec![sort_expr.clone()]).unwrap(),
3238                ])
3239                .build();
3240
3241        let result = config.try_pushdown_sort(&[sort_expr])?;
3242        assert!(
3243            matches!(result, SortOrderPushdownResult::Exact { .. }),
3244            "Expected Exact (no NULLs), got {result:?}"
3245        );
3246        Ok(())
3247    }
3248
3249    /// Helper: build a `ProjectionExprs` from `(expr, alias)` pairs.
3250    fn make_projection(pairs: Vec<(Arc<dyn PhysicalExpr>, &str)>) -> ProjectionExprs {
3251        ProjectionExprs::new(
3252            pairs
3253                .into_iter()
3254                .map(|(expr, alias)| ProjectionExpr::new(expr, alias)),
3255        )
3256    }
3257
3258    /// Helper: create a volatile (non-deterministic) function expression,
3259    /// e.g. `random()`.
3260    fn make_volatile_expr() -> Arc<dyn PhysicalExpr> {
3261        use datafusion_common::config::ConfigOptions;
3262        use datafusion_expr::ScalarUDF;
3263        use datafusion_functions::math::random::RandomFunc;
3264        use datafusion_physical_expr::ScalarFunctionExpr;
3265
3266        Arc::new(ScalarFunctionExpr::new(
3267            "random",
3268            Arc::new(ScalarUDF::from(RandomFunc::new())),
3269            vec![],
3270            Arc::new(Field::new("random", DataType::Float64, false)),
3271            Arc::new(ConfigOptions::default()),
3272        ))
3273    }
3274
3275    /// Column-only inner projections always merge safely, even when
3276    /// the outer projection references them multiple times.
3277    #[test]
3278    fn test_would_duplicate_allows_column_only_inner() {
3279        let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3280        let col_b: Arc<dyn PhysicalExpr> = Arc::new(Column::new("b", 1));
3281
3282        let inner =
3283            make_projection(vec![(Arc::clone(&col_a), "a"), (Arc::clone(&col_b), "b")]);
3284
3285        // Outer references col 0 twice
3286        let outer = make_projection(vec![
3287            (Arc::new(Column::new("a", 0)), "x"),
3288            (Arc::new(Column::new("a", 0)), "y"),
3289        ]);
3290
3291        assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3292    }
3293
3294    /// Deterministic computed expressions (arithmetic) referenced multiple
3295    /// times are allowed to merge — only volatile expressions are protected.
3296    #[test]
3297    fn test_would_duplicate_allows_deterministic_computed_multi_ref() {
3298        let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3299        let col_b: Arc<dyn PhysicalExpr> = Arc::new(Column::new("b", 1));
3300        // Inner: [a + b, b]  (index 0 is deterministic computed)
3301        let inner = make_projection(vec![
3302            (
3303                Arc::new(BinaryExpr::new(
3304                    Arc::clone(&col_a),
3305                    Operator::Plus,
3306                    Arc::clone(&col_b),
3307                )),
3308                "sum",
3309            ),
3310            (Arc::clone(&col_b), "b"),
3311        ]);
3312
3313        // Outer references index 0 twice
3314        let outer = make_projection(vec![
3315            (Arc::new(Column::new("sum", 0)), "x"),
3316            (Arc::new(Column::new("sum", 0)), "y"),
3317        ]);
3318
3319        // Deterministic arithmetic → allow merge even though duplicated
3320        assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3321    }
3322
3323    /// A volatile expression the outer projection does not reference is
3324    /// safe to merge (it is projected away, not duplicated).
3325    #[test]
3326    fn test_would_duplicate_allows_unreferenced_volatile() {
3327        let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3328        // Inner: [random(), a]
3329        let inner =
3330            make_projection(vec![(make_volatile_expr(), "r"), (Arc::clone(&col_a), "a")]);
3331
3332        // Outer references only index 1 (the column), not the volatile expr
3333        let outer = make_projection(vec![(Arc::new(Column::new("a", 1)), "a")]);
3334
3335        assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3336    }
3337
3338    /// A volatile expression referenced multiple times must block merge:
3339    /// this is the #23220 regression (`random()` aliased then referenced as
3340    /// `x` and `y`).
3341    #[test]
3342    fn test_would_duplicate_blocks_multi_ref_volatile() {
3343        // Inner: [random()]
3344        let inner = make_projection(vec![(make_volatile_expr(), "r")]);
3345
3346        // Outer references index 0 twice
3347        let outer = make_projection(vec![
3348            (Arc::new(Column::new("r", 0)), "x"),
3349            (Arc::new(Column::new("r", 0)), "y"),
3350        ]);
3351
3352        assert!(would_duplicate_volatile_exprs(&inner, &outer));
3353    }
3354
3355    /// A volatile expression referenced exactly once has nothing to duplicate,
3356    /// so the merge is allowed.
3357    #[test]
3358    fn test_would_duplicate_allows_single_ref_volatile() {
3359        let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3360        // Inner: [random(), a]
3361        let inner =
3362            make_projection(vec![(make_volatile_expr(), "r"), (Arc::clone(&col_a), "a")]);
3363
3364        // Outer references the volatile expression exactly once
3365        let outer = make_projection(vec![
3366            (Arc::new(Column::new("r", 0)), "x"),
3367            (Arc::new(Column::new("a", 1)), "a"),
3368        ]);
3369
3370        assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3371    }
3372
3373    /// References are counted with multiplicity, so a single outer expression
3374    /// that duplicates the value (e.g. `r + r`) still blocks the merge.
3375    #[test]
3376    fn test_would_duplicate_blocks_single_expr_self_ref_volatile() {
3377        // Inner: [random()]
3378        let inner = make_projection(vec![(make_volatile_expr(), "r")]);
3379
3380        // Outer: [r + r] — one expression referencing `random()` twice
3381        let outer = make_projection(vec![(
3382            Arc::new(BinaryExpr::new(
3383                Arc::new(Column::new("r", 0)),
3384                Operator::Plus,
3385                Arc::new(Column::new("r", 0)),
3386            )),
3387            "x",
3388        )]);
3389
3390        assert!(would_duplicate_volatile_exprs(&inner, &outer));
3391    }
3392
3393    /// A volatile expression buried inside a larger expression (e.g.
3394    /// `random() + 1`) is still detected and blocks merge.
3395    #[test]
3396    fn test_would_duplicate_blocks_volatile_nested_in_arithmetic() {
3397        // Inner: [random() + 1]
3398        let inner = make_projection(vec![(
3399            Arc::new(BinaryExpr::new(
3400                make_volatile_expr(),
3401                Operator::Plus,
3402                Arc::new(Literal::new(ScalarValue::Float64(Some(1.0)))),
3403            )),
3404            "expr",
3405        )]);
3406
3407        // Outer references index 0 twice
3408        let outer = make_projection(vec![
3409            (Arc::new(Column::new("expr", 0)), "x"),
3410            (Arc::new(Column::new("expr", 0)), "y"),
3411        ]);
3412
3413        assert!(would_duplicate_volatile_exprs(&inner, &outer));
3414    }
3415
3416    /// Empty projections should not block merging.
3417    #[test]
3418    fn test_would_duplicate_empty_projections() {
3419        let inner = make_projection(vec![]);
3420        let outer = make_projection(vec![]);
3421        assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3422    }
3423}