Skip to main content

datafusion_datasource/
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#![doc(
19    html_logo_url = "https://raw.githubusercontent.com/apache/datafusion/19fe44cf2f30cbdd63d4a4f52c74055163c6cc38/docs/logos/standalone_logo/logo_original.svg",
20    html_favicon_url = "https://raw.githubusercontent.com/apache/datafusion/19fe44cf2f30cbdd63d4a4f52c74055163c6cc38/docs/logos/standalone_logo/logo_original.svg"
21)]
22#![cfg_attr(docsrs, feature(doc_cfg))]
23// Make sure fast / cheap clones on Arc are explicit:
24// https://github.com/apache/datafusion/issues/11143
25#![cfg_attr(not(test), deny(clippy::clone_on_ref_ptr))]
26#![cfg_attr(test, allow(clippy::needless_pass_by_value))]
27
28//! A table that uses the `ObjectStore` listing capability
29//! to get the list of files to process.
30
31pub mod boundary_stream;
32pub mod decoder;
33pub mod display;
34pub mod file;
35pub mod file_compression_type;
36pub mod file_format;
37pub mod file_groups;
38pub mod file_scan_config;
39pub mod file_sink_config;
40pub mod file_stream;
41pub mod memory;
42pub mod morsel;
43pub mod projection;
44/// Protobuf conversions for [`FileRange`], [`PartitionedFile`] and
45/// [`FileGroup`](crate::file_groups::FileGroup), gated on the `proto` feature.
46#[cfg(feature = "proto")]
47mod proto;
48pub mod schema_adapter;
49pub mod sink;
50pub mod source;
51mod statistics;
52pub mod table_schema;
53
54#[cfg(test)]
55pub mod test_util;
56
57pub mod url;
58pub mod write;
59pub use self::file::as_file_source;
60pub use self::url::ListingTableUrl;
61use crate::file_groups::FileGroup;
62use arrow::datatypes::SchemaRef;
63use chrono::TimeZone;
64use datafusion_common::stats::Precision;
65use datafusion_common::{ColumnStatistics, Result, TableReference};
66use datafusion_common::{ScalarValue, Statistics};
67use datafusion_physical_expr::LexOrdering;
68use futures::Stream;
69use object_store::{ObjectMeta, path::Path};
70pub use statistics::compute_all_files_statistics;
71use std::any::Any;
72use std::pin::Pin;
73use std::sync::Arc;
74pub use table_schema::{TableSchema, TableSchemaBuilder};
75
76/// User-defined per-file extension data, keyed by concrete Rust type.
77///
78/// Re-exported from [`datafusion_common::extensions::Extensions`]; the same
79/// type backs `SessionConfig::extensions`, `ExtendedStatistics::extensions`,
80/// and other extension fields throughout DataFusion.
81pub type FileExtensions = datafusion_common::extensions::Extensions;
82
83/// Stream of files get listed from object store
84#[deprecated(
85    since = "54.0.0",
86    note = "This type is unused and will be removed in a future release"
87)]
88pub type PartitionedFileStream =
89    Pin<Box<dyn Stream<Item = Result<PartitionedFile>> + Send + Sync + 'static>>;
90
91/// Only scan a subset of Row Groups from the Parquet file whose data "midpoint"
92/// lies within the [start, end) byte offsets. This option can be used to scan non-overlapping
93/// sections of a Parquet file in parallel.
94#[derive(Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
95pub struct FileRange {
96    /// Range start
97    pub start: i64,
98    /// Range end
99    pub end: i64,
100}
101
102impl FileRange {
103    /// returns true if this file range contains the specified offset
104    pub fn contains(&self, offset: i64) -> bool {
105        offset >= self.start && offset < self.end
106    }
107}
108
109#[derive(Debug, Clone)]
110/// A single file or part of a file that should be read, along with its schema, statistics
111/// and partition column values that need to be appended to each row.
112///
113/// # Statistics
114///
115/// The [`Self::statistics`] field contains statistics for the **full table schema**,
116/// which includes both file columns and partition columns. When statistics are set via
117/// [`Self::with_statistics`], exact statistics for partition columns are automatically
118/// computed from [`Self::partition_values`]:
119///
120/// - `min = max = partition_value` (all rows in a file share the same partition value)
121/// - `null_count = 0` (partition values extracted from paths are never null)
122/// - `distinct_count = 1` (single distinct value per file for each partition column)
123///
124/// This enables query optimizers to use partition column bounds for pruning and planning.
125pub struct PartitionedFile {
126    /// Path for the file (e.g. URL, filesystem path, etc)
127    pub object_meta: ObjectMeta,
128    /// Values of partition columns to be appended to each row.
129    ///
130    /// These MUST have the same count, order, and type than the [`table_partition_cols`].
131    ///
132    /// You may use [`wrap_partition_value_in_dict`] to wrap them if you have used [`wrap_partition_type_in_dict`] to wrap the column type.
133    ///
134    ///
135    /// [`wrap_partition_type_in_dict`]: crate::file_scan_config::wrap_partition_type_in_dict
136    /// [`wrap_partition_value_in_dict`]: crate::file_scan_config::wrap_partition_value_in_dict
137    /// [`table_partition_cols`]: https://github.com/apache/datafusion/blob/main/datafusion/core/src/datasource/file_format/options.rs#L87
138    pub partition_values: Vec<ScalarValue>,
139    /// An optional file range for a more fine-grained parallel execution
140    pub range: Option<FileRange>,
141    /// Optional statistics that describe the data in this file if known.
142    ///
143    /// DataFusion relies on these statistics for planning (in particular to sort file groups),
144    /// so if they are incorrect, incorrect answers may result.
145    ///
146    /// These statistics cover the full table schema: file columns plus partition columns.
147    /// When set via [`Self::with_statistics`], partition column statistics are automatically
148    /// computed from [`Self::partition_values`] with exact min/max/null_count/distinct_count.
149    pub statistics: Option<Arc<Statistics>>,
150    /// The known lexicographical ordering of the rows in this file, if any.
151    ///
152    /// This describes how the data within the file is sorted with respect to one or more
153    /// columns, and is used by the optimizer for planning operations that depend on input
154    /// ordering (e.g. merges, sorts, and certain aggregations).
155    ///
156    /// When available, this is typically inferred from file-level metadata exposed by the
157    /// underlying format (for example, Parquet `sorting_columns`), but it may also be set
158    /// explicitly via [`Self::with_ordering`].
159    pub ordering: Option<LexOrdering>,
160    /// User-defined per-file metadata, keyed by Rust type. Multiple
161    /// independent components can each attach their own data here without
162    /// conflict — see [`FileExtensions`].
163    pub extensions: FileExtensions,
164    /// The estimated size of the parquet metadata, in bytes
165    pub metadata_size_hint: Option<usize>,
166    pub table_reference: Option<TableReference>,
167    /// A user-provided physical Arrow schema for this file.
168    ///
169    /// This schema describes only the columns stored in the file. It must not
170    /// include partition columns; those are represented separately by
171    /// [`Self::partition_values`] and the scan's table partition columns.
172    ///
173    /// When provided, this field will be used by the Parquet reader to avoid
174    /// parsing the Arrow schema from the `ARROW:schema` metadata key. Other
175    /// built-in file sources ignore it for now.
176    pub arrow_schema: Option<SchemaRef>,
177}
178
179impl PartitionedFile {
180    /// Create a simple file without metadata or partition
181    pub fn new(path: impl Into<String>, size: u64) -> Self {
182        Self {
183            arrow_schema: None,
184            object_meta: ObjectMeta {
185                location: Path::from(path.into()),
186                last_modified: chrono::Utc.timestamp_nanos(0),
187                size,
188                e_tag: None,
189                version: None,
190            },
191            partition_values: vec![],
192            range: None,
193            statistics: None,
194            ordering: None,
195            extensions: FileExtensions::new(),
196            metadata_size_hint: None,
197            table_reference: None,
198        }
199    }
200
201    /// Create a file from a known ObjectMeta without partition
202    pub fn new_from_meta(object_meta: ObjectMeta) -> Self {
203        Self {
204            arrow_schema: None,
205            object_meta,
206            partition_values: vec![],
207            range: None,
208            statistics: None,
209            ordering: None,
210            extensions: FileExtensions::new(),
211            metadata_size_hint: None,
212            table_reference: None,
213        }
214    }
215
216    /// Create a file range without metadata or partition
217    pub fn new_with_range(path: String, size: u64, start: i64, end: i64) -> Self {
218        Self {
219            arrow_schema: None,
220            object_meta: ObjectMeta {
221                location: Path::from(path),
222                last_modified: chrono::Utc.timestamp_nanos(0),
223                size,
224                e_tag: None,
225                version: None,
226            },
227            partition_values: vec![],
228            range: Some(FileRange { start, end }),
229            statistics: None,
230            ordering: None,
231            extensions: FileExtensions::new(),
232            metadata_size_hint: None,
233            table_reference: None,
234        }
235        .with_range(start, end)
236    }
237
238    /// Provide a physical Arrow schema for this file.
239    ///
240    /// The schema must describe only columns stored in the file and must not
241    /// include partition columns. See [`Self::arrow_schema`] for details.
242    pub fn with_arrow_schema(mut self, schema: SchemaRef) -> Self {
243        self.arrow_schema = Some(schema);
244        self
245    }
246
247    /// Attach partition values to this file.
248    /// This replaces any existing partition values.
249    pub fn with_partition_values(mut self, partition_values: Vec<ScalarValue>) -> Self {
250        self.partition_values = partition_values;
251        self
252    }
253
254    pub fn with_table_reference(
255        mut self,
256        table_reference: Option<TableReference>,
257    ) -> Self {
258        self.table_reference = table_reference;
259        self
260    }
261
262    /// Size of the file to be scanned (taking into account the range, if present).
263    pub fn effective_size(&self) -> u64 {
264        if let Some(range) = &self.range {
265            (range.end - range.start) as u64
266        } else {
267            self.object_meta.size
268        }
269    }
270
271    /// Effective range of the file to be scanned.
272    pub fn range(&self) -> (u64, u64) {
273        if let Some(range) = &self.range {
274            (range.start as u64, range.end as u64)
275        } else {
276            (0, self.object_meta.size)
277        }
278    }
279
280    /// Provide a hint to the size of the file metadata. If a hint is provided
281    /// the reader will try and fetch the last `size_hint` bytes of the parquet file optimistically.
282    /// Without an appropriate hint, two read may be required to fetch the metadata.
283    pub fn with_metadata_size_hint(mut self, metadata_size_hint: usize) -> Self {
284        self.metadata_size_hint = Some(metadata_size_hint);
285        self
286    }
287
288    /// Return a file reference from the given path
289    pub fn from_path(path: String) -> Result<Self> {
290        let size = std::fs::metadata(path.clone())?.len();
291        Ok(Self::new(path, size))
292    }
293
294    /// Return the path of this partitioned file
295    pub fn path(&self) -> &Path {
296        &self.object_meta.location
297    }
298
299    /// Update the file to only scan the specified range (in bytes)
300    pub fn with_range(mut self, start: i64, end: i64) -> Self {
301        self.range = Some(FileRange { start, end });
302        self
303    }
304
305    /// Attach a typed user-defined extension to this file. Multiple
306    /// independent extensions can be attached, each keyed by its concrete
307    /// Rust type. Inserting a value of a type that already has an extension
308    /// replaces the previous one.
309    ///
310    /// This can be used to pass reader-specific information (e.g. a
311    /// `ParquetAccessPlan`, or a custom index entry).
312    pub fn with_extension<T: Any + Send + Sync>(mut self, value: T) -> Self {
313        self.extensions.insert(value);
314        self
315    }
316
317    /// Borrow the extension of type `T`, if one is attached.
318    pub fn extension<T: Any + Send + Sync>(&self) -> Option<&T> {
319        self.extensions.get::<T>()
320    }
321
322    /// Attach a type-erased extension to this file.
323    ///
324    /// Kept as a backwards-compatible shim; prefer [`Self::with_extension`]
325    /// which keys the extension by its concrete Rust type at the call site.
326    #[deprecated(
327        since = "54.0.0",
328        note = "use `with_extension`; the extension is keyed by its concrete type"
329    )]
330    pub fn with_extensions(mut self, extensions: Arc<dyn Any + Send + Sync>) -> Self {
331        #[expect(deprecated)]
332        self.extensions.insert_dyn(extensions);
333        self
334    }
335
336    /// Update the statistics for this file.
337    ///
338    /// The provided `statistics` should cover only the file schema columns.
339    /// This method will automatically append exact statistics for partition columns
340    /// based on `partition_values`:
341    /// - `min = max = partition_value` (all rows have the same value)
342    /// - `null_count = 0` (partition values from paths are never null)
343    /// - `distinct_count = 1` (all rows have the same partition value)
344    pub fn with_statistics(mut self, file_statistics: Arc<Statistics>) -> Self {
345        if self.partition_values.is_empty() {
346            // No partition columns, use stats as-is
347            self.statistics = Some(file_statistics);
348        } else {
349            // Extend stats with exact partition column statistics
350            let mut stats = Arc::unwrap_or_clone(file_statistics);
351            for partition_value in &self.partition_values {
352                let col_stats = ColumnStatistics {
353                    null_count: Precision::Exact(0),
354                    max_value: Precision::Exact(partition_value.clone()),
355                    min_value: Precision::Exact(partition_value.clone()),
356                    distinct_count: Precision::Exact(1),
357                    sum_value: Precision::Absent,
358                    byte_size: partition_value
359                        .data_type()
360                        .primitive_width()
361                        .map(|w| stats.num_rows.multiply(&Precision::Exact(w)))
362                        .unwrap_or_else(|| Precision::Absent),
363                };
364                stats.column_statistics.push(col_stats);
365            }
366            self.statistics = Some(Arc::new(stats));
367        }
368        self
369    }
370
371    /// Check if this file has any statistics.
372    /// This returns `true` if the file has any Exact or Inexact statistics
373    /// and `false` if all statistics are `Precision::Absent`.
374    pub fn has_statistics(&self) -> bool {
375        if let Some(stats) = &self.statistics {
376            stats.column_statistics.iter().any(|col_stats| {
377                col_stats.null_count != Precision::Absent
378                    || col_stats.max_value != Precision::Absent
379                    || col_stats.min_value != Precision::Absent
380                    || col_stats.sum_value != Precision::Absent
381                    || col_stats.distinct_count != Precision::Absent
382            })
383        } else {
384            false
385        }
386    }
387
388    /// Set the known ordering of data in this file.
389    ///
390    /// The ordering represents the lexicographical sort order of the data,
391    /// typically inferred from file metadata (e.g., Parquet sorting_columns).
392    pub fn with_ordering(mut self, ordering: Option<LexOrdering>) -> Self {
393        self.ordering = ordering;
394        self
395    }
396}
397
398impl From<ObjectMeta> for PartitionedFile {
399    fn from(object_meta: ObjectMeta) -> Self {
400        PartitionedFile {
401            object_meta,
402            arrow_schema: None,
403            partition_values: vec![],
404            range: None,
405            statistics: None,
406            ordering: None,
407            extensions: FileExtensions::new(),
408            metadata_size_hint: None,
409            table_reference: None,
410        }
411    }
412}
413
414/// Generates test files with min-max statistics in different overlap patterns.
415///
416/// Used by tests and benchmarks.
417///
418/// # Overlap Factors
419///
420/// The `overlap_factor` parameter controls how much the value ranges in generated test files overlap:
421/// - `0.0`: No overlap between files (completely disjoint ranges)
422/// - `0.2`: Low overlap (20% of the range size overlaps with adjacent files)
423/// - `0.5`: Medium overlap (50% of ranges overlap)
424/// - `0.8`: High overlap (80% of ranges overlap between files)
425///
426/// # Examples
427///
428/// With 5 files and different overlap factors showing `[min, max]` ranges:
429///
430/// overlap_factor = 0.0 (no overlap):
431///
432/// File 0: [0, 20]
433/// File 1: [20, 40]
434/// File 2: [40, 60]
435/// File 3: [60, 80]
436/// File 4: [80, 100]
437///
438/// overlap_factor = 0.5 (50% overlap):
439///
440/// File 0: [0, 40]
441/// File 1: [20, 60]
442/// File 2: [40, 80]
443/// File 3: [60, 100]
444/// File 4: [80, 120]
445///
446/// overlap_factor = 0.8 (80% overlap):
447///
448/// File 0: [0, 100]
449/// File 1: [20, 120]
450/// File 2: [40, 140]
451/// File 3: [60, 160]
452/// File 4: [80, 180]
453pub fn generate_test_files(num_files: usize, overlap_factor: f64) -> Vec<FileGroup> {
454    let mut files = Vec::with_capacity(num_files);
455    if num_files == 0 {
456        return vec![];
457    }
458    let range_size = if overlap_factor == 0.0 {
459        100 / num_files as i64
460    } else {
461        (100.0 / (overlap_factor * num_files as f64)).max(1.0) as i64
462    };
463
464    for i in 0..num_files {
465        let base = (i as f64 * range_size as f64 * (1.0 - overlap_factor)) as i64;
466        let min = base as f64;
467        let max = (base + range_size) as f64;
468
469        let file = PartitionedFile {
470            arrow_schema: None,
471            object_meta: ObjectMeta {
472                location: Path::from(format!("file_{i}.parquet")),
473                last_modified: chrono::Utc::now(),
474                size: 1000,
475                e_tag: None,
476                version: None,
477            },
478            partition_values: vec![],
479            range: None,
480            statistics: Some(Arc::new(Statistics {
481                num_rows: Precision::Exact(100),
482                total_byte_size: Precision::Exact(1000),
483                column_statistics: vec![ColumnStatistics {
484                    null_count: Precision::Exact(0),
485                    max_value: Precision::Exact(ScalarValue::Float64(Some(max))),
486                    min_value: Precision::Exact(ScalarValue::Float64(Some(min))),
487                    sum_value: Precision::Absent,
488                    distinct_count: Precision::Absent,
489                    byte_size: Precision::Absent,
490                }],
491            })),
492            ordering: None,
493            extensions: FileExtensions::new(),
494            metadata_size_hint: None,
495            table_reference: None,
496        };
497        files.push(file);
498    }
499
500    vec![FileGroup::new(files)]
501}
502
503// Helper function to verify that files within each group maintain sort order
504/// Used by tests and benchmarks
505pub fn verify_sort_integrity(file_groups: &[FileGroup]) -> bool {
506    for group in file_groups {
507        let files = group.iter().collect::<Vec<_>>();
508        for i in 1..files.len() {
509            let prev_file = files[i - 1];
510            let curr_file = files[i];
511
512            // Check if the min value of current file is greater than max value of previous file
513            if let (Some(prev_stats), Some(curr_stats)) =
514                (&prev_file.statistics, &curr_file.statistics)
515            {
516                let prev_max = &prev_stats.column_statistics[0].max_value;
517                let curr_min = &curr_stats.column_statistics[0].min_value;
518                if curr_min.get_value().unwrap() <= prev_max.get_value().unwrap() {
519                    return false;
520                }
521            }
522        }
523    }
524    true
525}
526
527#[cfg(test)]
528mod tests {
529    use super::ListingTableUrl;
530    use arrow::{
531        array::{ArrayRef, Int32Array, RecordBatch},
532        datatypes::{DataType, Field, Schema, SchemaRef},
533    };
534    use datafusion_execution::object_store::{
535        DefaultObjectStoreRegistry, ObjectStoreRegistry,
536    };
537    use object_store::{local::LocalFileSystem, path::Path};
538    use std::{collections::HashMap, ops::Not, sync::Arc};
539    use url::Url;
540
541    /// Return a RecordBatch with a single Int32 array with values (0..sz) in a field named "i"
542    pub fn make_partition(sz: i32) -> RecordBatch {
543        let seq_start = 0;
544        let seq_end = sz;
545        let values = (seq_start..seq_end).collect::<Vec<_>>();
546        let schema = Arc::new(Schema::new(vec![Field::new("i", DataType::Int32, true)]));
547        let arr = Arc::new(Int32Array::from(values));
548
549        RecordBatch::try_new(schema, vec![arr as ArrayRef]).unwrap()
550    }
551
552    /// Get the schema for the aggregate_test_* csv files
553    pub fn aggr_test_schema() -> SchemaRef {
554        let mut f1 = Field::new("c1", DataType::Utf8, false);
555        f1.set_metadata(HashMap::from_iter(vec![("testing".into(), "test".into())]));
556        let schema = Schema::new(vec![
557            f1,
558            Field::new("c2", DataType::UInt32, false),
559            Field::new("c3", DataType::Int8, false),
560            Field::new("c4", DataType::Int16, false),
561            Field::new("c5", DataType::Int32, false),
562            Field::new("c6", DataType::Int64, false),
563            Field::new("c7", DataType::UInt8, false),
564            Field::new("c8", DataType::UInt16, false),
565            Field::new("c9", DataType::UInt32, false),
566            Field::new("c10", DataType::UInt64, false),
567            Field::new("c11", DataType::Float32, false),
568            Field::new("c12", DataType::Float64, false),
569            Field::new("c13", DataType::Utf8, false),
570        ]);
571
572        Arc::new(schema)
573    }
574
575    #[test]
576    fn test_object_store_listing_url() {
577        let listing = ListingTableUrl::parse("file:///").unwrap();
578        let store = listing.object_store();
579        assert_eq!(store.as_str(), "file:///");
580
581        let listing = ListingTableUrl::parse("s3://bucket/").unwrap();
582        let store = listing.object_store();
583        assert_eq!(store.as_str(), "s3://bucket/");
584    }
585
586    #[test]
587    fn test_get_store_hdfs() {
588        let sut = DefaultObjectStoreRegistry::default();
589        let url = Url::parse("hdfs://localhost:8020").unwrap();
590        sut.register_store(&url, Arc::new(LocalFileSystem::new()));
591        let url = ListingTableUrl::parse("hdfs://localhost:8020/key").unwrap();
592        sut.get_store(url.as_ref()).unwrap();
593    }
594
595    #[test]
596    fn test_get_store_s3() {
597        let sut = DefaultObjectStoreRegistry::default();
598        let url = Url::parse("s3://bucket/key").unwrap();
599        sut.register_store(&url, Arc::new(LocalFileSystem::new()));
600        let url = ListingTableUrl::parse("s3://bucket/key").unwrap();
601        sut.get_store(url.as_ref()).unwrap();
602    }
603
604    #[test]
605    fn test_get_store_file() {
606        let sut = DefaultObjectStoreRegistry::default();
607        let url = ListingTableUrl::parse("file:///bucket/key").unwrap();
608        sut.get_store(url.as_ref()).unwrap();
609    }
610
611    #[test]
612    fn test_get_store_local() {
613        let sut = DefaultObjectStoreRegistry::default();
614        let url = ListingTableUrl::parse("../").unwrap();
615        sut.get_store(url.as_ref()).unwrap();
616    }
617
618    #[test]
619    fn test_with_statistics_appends_partition_column_stats() {
620        use crate::PartitionedFile;
621        use datafusion_common::stats::Precision;
622        use datafusion_common::{ColumnStatistics, ScalarValue, Statistics};
623
624        // Create a PartitionedFile with partition values
625        let mut pf = PartitionedFile::new(
626            "test.parquet",
627            100, // file size
628        );
629        pf.partition_values = vec![
630            ScalarValue::Date32(Some(20148)), // 2025-03-01
631        ];
632
633        // Create file-only statistics (1 column for 'id')
634        let file_stats = Arc::new(Statistics {
635            num_rows: Precision::Exact(2),
636            total_byte_size: Precision::Exact(16),
637            column_statistics: vec![ColumnStatistics {
638                null_count: Precision::Exact(0),
639                max_value: Precision::Exact(ScalarValue::Int32(Some(4))),
640                min_value: Precision::Exact(ScalarValue::Int32(Some(3))),
641                sum_value: Precision::Absent,
642                distinct_count: Precision::Absent,
643                byte_size: Precision::Absent,
644            }],
645        });
646
647        // Call with_statistics - should append partition column stats
648        let pf = pf.with_statistics(file_stats);
649
650        // Verify the statistics now have 2 columns
651        let stats = pf.statistics.unwrap();
652        assert_eq!(
653            stats.column_statistics.len(),
654            2,
655            "Expected 2 columns (id + date partition)"
656        );
657
658        // Verify partition column statistics
659        let partition_col_stats = &stats.column_statistics[1];
660        assert_eq!(
661            partition_col_stats.null_count,
662            Precision::Exact(0),
663            "Partition column null_count should be Exact(0)"
664        );
665        assert_eq!(
666            partition_col_stats.min_value,
667            Precision::Exact(ScalarValue::Date32(Some(20148))),
668            "Partition column min should match partition value"
669        );
670        assert_eq!(
671            partition_col_stats.max_value,
672            Precision::Exact(ScalarValue::Date32(Some(20148))),
673            "Partition column max should match partition value"
674        );
675        assert_eq!(
676            partition_col_stats.distinct_count,
677            Precision::Exact(1),
678            "Partition column distinct_count should be Exact(1)"
679        );
680    }
681
682    #[test]
683    fn test_url_contains() {
684        let url = ListingTableUrl::parse("file:///var/data/mytable/").unwrap();
685
686        // standard case with default config
687        assert!(url.contains(
688            &Path::parse("/var/data/mytable/data.parquet").unwrap(),
689            true
690        ));
691
692        // standard case with `ignore_subdirectory` set to false
693        assert!(url.contains(
694            &Path::parse("/var/data/mytable/data.parquet").unwrap(),
695            false
696        ));
697
698        // as per documentation, when `ignore_subdirectory` is true, we should ignore files that aren't
699        // a direct child of the `url`
700        assert!(
701            url.contains(
702                &Path::parse("/var/data/mytable/mysubfolder/data.parquet").unwrap(),
703                true
704            )
705            .not()
706        );
707
708        // when we set `ignore_subdirectory` to false, we should not ignore the file
709        assert!(url.contains(
710            &Path::parse("/var/data/mytable/mysubfolder/data.parquet").unwrap(),
711            false
712        ));
713
714        // as above, `ignore_subdirectory` is false, so we include the file
715        assert!(url.contains(
716            &Path::parse("/var/data/mytable/year=2024/data.parquet").unwrap(),
717            false
718        ));
719
720        // in this case, we include the file even when `ignore_subdirectory` is true because the
721        // path segment is a hive partition which doesn't count as a subdirectory for the purposes
722        // of `Url::contains`
723        assert!(url.contains(
724            &Path::parse("/var/data/mytable/year=2024/data.parquet").unwrap(),
725            true
726        ));
727
728        // testing an empty path with default config
729        assert!(url.contains(&Path::parse("/var/data/mytable/").unwrap(), true));
730
731        // testing an empty path with `ignore_subdirectory` set to false
732        assert!(url.contains(&Path::parse("/var/data/mytable/").unwrap(), false));
733    }
734}