Skip to main content

datafusion_catalog_listing/
options.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use arrow::datatypes::{DataType, SchemaRef};
19use datafusion_catalog::Session;
20use datafusion_common::plan_err;
21use datafusion_datasource::ListingTableUrl;
22use datafusion_datasource::file_format::FileFormat;
23use datafusion_expr::{Partitioning, SortExpr};
24use futures::StreamExt;
25use futures::TryStreamExt;
26use itertools::AllEqualValueError;
27use itertools::Itertools;
28use std::sync::Arc;
29
30/// Options for creating a [`crate::ListingTable`]
31#[derive(Clone, Debug)]
32pub struct ListingOptions {
33    /// A suffix on which files should be filtered (leave empty to
34    /// keep all files on the path)
35    pub file_extension: String,
36    /// The file format
37    pub format: Arc<dyn FileFormat>,
38    /// The expected partition column names in the folder structure.
39    /// See [Self::with_table_partition_cols] for details
40    pub table_partition_cols: Vec<(String, DataType)>,
41    /// Optional pre-known sort order(s). Must be `SortExpr`s.
42    ///
43    /// DataFusion may take advantage of this ordering to omit sorts
44    /// or use more efficient algorithms. Currently sortedness must be
45    /// provided if it is known by some external mechanism, but may in
46    /// the future be automatically determined, for example using
47    /// parquet metadata.
48    ///
49    /// See <https://github.com/apache/datafusion/issues/4177>
50    ///
51    /// NOTE: This attribute stores all equivalent orderings (the outer `Vec`)
52    ///       where each ordering consists of an individual lexicographic
53    ///       ordering (encapsulated by a `Vec<Expr>`). If there aren't
54    ///       multiple equivalent orderings, the outer `Vec` will have a
55    ///       single element.
56    pub file_sort_order: Vec<Vec<SortExpr>>,
57    /// Declared output partitioning for scans from this table.
58    ///
59    /// Expressions are logical expressions over the full table schema. When set,
60    /// [`ListingTable`](crate::ListingTable) creates one file group per
61    /// declared output partition. When unset, file grouping uses the scan-time
62    /// [`SessionConfig::target_partitions`](datafusion_execution::config::SessionConfig::target_partitions).
63    ///
64    /// Files are listed in path order, split into whole-file groups across the
65    /// declared partition count, and then padded with trailing empty groups when
66    /// needed. DataFusion does not route files by partition values or validate
67    /// row placement, so callers must ensure file group `i` contains rows for
68    /// partition `i`. Layouts that require explicit file-to-partition assignment
69    /// are not supported.
70    ///
71    /// For example, range partitioning on column `a` with split points
72    /// `[10, 20, 30]` declares four output partitions. With three path-ordered
73    /// files, the trailing partition is preserved as empty:
74    ///
75    /// ```text
76    /// files in path order: f0, f1, f2
77    ///
78    /// file groups:
79    ///   partition 0: [f0]
80    ///   partition 1: [f1]
81    ///   partition 2: [f2]
82    ///   partition 3: []
83    /// ```
84    ///
85    /// With five path-ordered files, a partition can contain multiple files:
86    ///
87    /// ```text
88    /// files in path order: f0, f1, f2, f3, f4
89    ///
90    /// file groups:
91    ///   partition 0: [f0, f1]
92    ///   partition 1: [f2, f3]
93    ///   partition 2: [f4]
94    ///   partition 3: []
95    /// ```
96    pub output_partitioning: Option<Partitioning>,
97}
98
99impl ListingOptions {
100    /// Creates an options instance with the given format
101    /// Default values:
102    /// - use default file extension filter
103    /// - no input partition to discover
104    pub fn new(format: Arc<dyn FileFormat>) -> Self {
105        Self {
106            file_extension: format.get_ext(),
107            format,
108            table_partition_cols: vec![],
109            file_sort_order: vec![],
110            output_partitioning: None,
111        }
112    }
113
114    /// Set file extension on [`ListingOptions`] and returns self.
115    ///
116    /// # Example
117    /// ```
118    /// # use std::sync::Arc;
119    /// # use datafusion_catalog_listing::ListingOptions;
120    /// # use datafusion_datasource_parquet::file_format::ParquetFormat;
121    ///
122    /// let listing_options = ListingOptions::new(Arc::new(ParquetFormat::default()))
123    ///     .with_file_extension(".parquet");
124    ///
125    /// assert_eq!(listing_options.file_extension, ".parquet");
126    /// ```
127    pub fn with_file_extension(mut self, file_extension: impl Into<String>) -> Self {
128        self.file_extension = file_extension.into();
129        self
130    }
131
132    /// Optionally set file extension on [`ListingOptions`] and returns self.
133    ///
134    /// If `file_extension` is `None`, the file extension will not be changed
135    ///
136    /// # Example
137    /// ```
138    /// # use std::sync::Arc;
139    /// # use datafusion_catalog_listing::ListingOptions;
140    /// # use datafusion_datasource_parquet::file_format::ParquetFormat;
141    ///
142    /// let extension = Some(".parquet");
143    /// let listing_options = ListingOptions::new(Arc::new(ParquetFormat::default()))
144    ///     .with_file_extension_opt(extension);
145    ///
146    /// assert_eq!(listing_options.file_extension, ".parquet");
147    /// ```
148    pub fn with_file_extension_opt<S>(mut self, file_extension: Option<S>) -> Self
149    where
150        S: Into<String>,
151    {
152        if let Some(file_extension) = file_extension {
153            self.file_extension = file_extension.into();
154        }
155        self
156    }
157
158    /// Set declared output partitioning.
159    ///
160    /// See [`Self::output_partitioning`] for the contract.
161    pub fn with_output_partitioning(
162        mut self,
163        output_partitioning: Option<Partitioning>,
164    ) -> Self {
165        self.output_partitioning = output_partitioning;
166        self
167    }
168
169    /// Set `table partition columns` on [`ListingOptions`] and returns self.
170    ///
171    /// "partition columns," used to support [Hive Partitioning], are
172    /// columns added to the data that is read, based on the folder
173    /// structure where the data resides.
174    ///
175    /// For example, give the following files in your filesystem:
176    ///
177    /// ```text
178    /// /mnt/nyctaxi/year=2022/month=01/tripdata.parquet
179    /// /mnt/nyctaxi/year=2021/month=12/tripdata.parquet
180    /// /mnt/nyctaxi/year=2021/month=11/tripdata.parquet
181    /// ```
182    ///
183    /// A [`crate::ListingTable`] created at `/mnt/nyctaxi/` with partition
184    /// columns "year" and "month" will include new `year` and `month`
185    /// columns while reading the files. The `year` column would have
186    /// value `2022` and the `month` column would have value `01` for
187    /// the rows read from
188    /// `/mnt/nyctaxi/year=2022/month=01/tripdata.parquet`
189    ///
190    ///# Notes
191    ///
192    /// - If only one level (e.g. `year` in the example above) is
193    ///   specified, the other levels are ignored but the files are
194    ///   still read.
195    ///
196    /// - Files that don't follow this partitioning scheme will be
197    ///   ignored.
198    ///
199    /// - Since the columns have the same value for all rows read from
200    ///   each individual file (such as dates), they are typically
201    ///   dictionary encoded for efficiency. You may use
202    ///   [`wrap_partition_type_in_dict`] to request a
203    ///   dictionary-encoded type.
204    ///
205    /// - The partition columns are solely extracted from the file path. Especially they are NOT part of the parquet files itself.
206    ///
207    /// # Example
208    ///
209    /// ```
210    /// # use std::sync::Arc;
211    /// # use arrow::datatypes::DataType;
212    /// # use datafusion_expr::col;
213    /// # use datafusion_catalog_listing::ListingOptions;
214    /// # use datafusion_datasource_parquet::file_format::ParquetFormat;
215    ///
216    /// // listing options for files with paths such as  `/mnt/data/col_a=x/col_b=y/data.parquet`
217    /// // `col_a` and `col_b` will be included in the data read from those files
218    /// let listing_options = ListingOptions::new(Arc::new(
219    ///     ParquetFormat::default()
220    ///   ))
221    ///   .with_table_partition_cols(vec![("col_a".to_string(), DataType::Utf8),
222    ///       ("col_b".to_string(), DataType::Utf8)]);
223    ///
224    /// assert_eq!(listing_options.table_partition_cols, vec![("col_a".to_string(), DataType::Utf8),
225    ///     ("col_b".to_string(), DataType::Utf8)]);
226    /// ```
227    ///
228    /// [Hive Partitioning]: https://docs.cloudera.com/HDPDocuments/HDP2/HDP-2.1.3/bk_system-admin-guide/content/hive_partitioned_tables.html
229    /// [`wrap_partition_type_in_dict`]: datafusion_datasource::file_scan_config::wrap_partition_type_in_dict
230    pub fn with_table_partition_cols(
231        mut self,
232        table_partition_cols: Vec<(String, DataType)>,
233    ) -> Self {
234        self.table_partition_cols = table_partition_cols;
235        self
236    }
237
238    /// Set file sort order on [`ListingOptions`] and returns self.
239    ///
240    /// ```
241    /// # use std::sync::Arc;
242    /// # use datafusion_expr::col;
243    /// # use datafusion_catalog_listing::ListingOptions;
244    /// # use datafusion_datasource_parquet::file_format::ParquetFormat;
245    ///
246    /// // Tell datafusion that the files are sorted by column "a"
247    /// let file_sort_order = vec![vec![col("a").sort(true, true)]];
248    ///
249    /// let listing_options = ListingOptions::new(Arc::new(ParquetFormat::default()))
250    ///     .with_file_sort_order(file_sort_order.clone());
251    ///
252    /// assert_eq!(listing_options.file_sort_order, file_sort_order);
253    /// ```
254    pub fn with_file_sort_order(mut self, file_sort_order: Vec<Vec<SortExpr>>) -> Self {
255        self.file_sort_order = file_sort_order;
256        self
257    }
258
259    /// Infer the schema of the files at the given path on the provided object store.
260    ///
261    /// If the table_path contains one or more files (i.e. it is a directory /
262    /// prefix of files) their schema is merged by calling [`FileFormat::infer_schema`].
263    ///
264    /// Returns a `Plan` error if `table_path` contains no files at all (e.g. an
265    /// empty or non-existent directory), since an inferred schema with zero
266    /// columns produces confusing "column not found" errors at query time.
267    /// Callers that need to support empty locations must declare an explicit
268    /// schema instead of relying on inference. Locations that contain files
269    /// which all happen to be 0-byte are still accepted — the empty files are
270    /// filtered out before format-specific inference runs.
271    ///
272    /// Note: The inferred schema does not include any partitioning columns.
273    ///
274    /// This method is called as part of creating a [`crate::ListingTable`].
275    pub async fn infer_schema<'a>(
276        &'a self,
277        state: &dyn Session,
278        table_path: &'a ListingTableUrl,
279    ) -> datafusion_common::Result<SchemaRef> {
280        let store = state.runtime_env().object_store(table_path)?;
281
282        let all_files: Vec<_> = table_path
283            .list_all_files(state, store.as_ref(), &self.file_extension)
284            .await?
285            .try_collect()
286            .await?;
287
288        if all_files.is_empty() {
289            return plan_err!(
290                "No files found at {}. \
291                 Cannot infer schema from an empty location; either add data files \
292                 or declare an explicit schema for the table.",
293                table_path
294            );
295        }
296
297        // Empty files cannot affect schema but may throw when trying to read for it
298        let files: Vec<_> = all_files
299            .into_iter()
300            .filter(|object_meta| object_meta.size > 0)
301            .collect();
302
303        let schema = self.format.infer_schema(state, &store, &files).await?;
304
305        Ok(schema)
306    }
307
308    /// Infers the partition columns stored in `LOCATION` and compares
309    /// them with the columns provided in `PARTITIONED BY` to help prevent
310    /// accidental corrupts of partitioned tables.
311    ///
312    /// Allows specifying partial partitions.
313    pub async fn validate_partitions(
314        &self,
315        state: &dyn Session,
316        table_path: &ListingTableUrl,
317    ) -> datafusion_common::Result<()> {
318        if self.table_partition_cols.is_empty() {
319            return Ok(());
320        }
321
322        if !table_path.is_collection() {
323            return plan_err!(
324                "Can't create a partitioned table backed by a single file, \
325                perhaps the URL is missing a trailing slash?"
326            );
327        }
328
329        let inferred = self.infer_partitions(state, table_path).await?;
330
331        // no partitioned files found on disk
332        if inferred.is_empty() {
333            return Ok(());
334        }
335
336        let table_partition_names = self
337            .table_partition_cols
338            .iter()
339            .map(|(col_name, _)| col_name.clone())
340            .collect_vec();
341
342        if inferred.len() < table_partition_names.len() {
343            return plan_err!(
344                "Inferred partitions to be {:?}, but got {:?}",
345                inferred,
346                table_partition_names
347            );
348        }
349
350        // match prefix to allow creating tables with partial partitions
351        for (idx, col) in table_partition_names.iter().enumerate() {
352            if &inferred[idx] != col {
353                return plan_err!(
354                    "Inferred partitions to be {:?}, but got {:?}",
355                    inferred,
356                    table_partition_names
357                );
358            }
359        }
360
361        Ok(())
362    }
363
364    /// Infer the partitioning at the given path on the provided object store.
365    /// For performance reasons, it doesn't read all the files on disk
366    /// and therefore may fail to detect invalid partitioning.
367    pub async fn infer_partitions(
368        &self,
369        state: &dyn Session,
370        table_path: &ListingTableUrl,
371    ) -> datafusion_common::Result<Vec<String>> {
372        let store = state.runtime_env().object_store(table_path)?;
373
374        // only use 10 files for inference
375        // This can fail to detect inconsistent partition keys
376        // A DFS traversal approach of the store can help here
377        let files: Vec<_> = table_path
378            .list_all_files(state, store.as_ref(), &self.file_extension)
379            .await?
380            .take(10)
381            .try_collect()
382            .await?;
383
384        let stripped_path_parts = files.iter().map(|file| {
385            table_path
386                .strip_prefix(&file.location)
387                .unwrap()
388                .collect_vec()
389        });
390
391        let partition_keys = stripped_path_parts
392            .map(|path_parts| {
393                path_parts
394                    .into_iter()
395                    .rev()
396                    .skip(1) // get parents only; skip the file itself
397                    .rev()
398                    // Partitions are expected to follow the format "column_name=value", so we
399                    // should ignore any path part that cannot be parsed into the expected format
400                    .filter(|s| s.contains('='))
401                    .map(|s| s.split('=').take(1).collect())
402                    .collect_vec()
403            })
404            .collect_vec();
405
406        match partition_keys.into_iter().all_equal_value() {
407            Ok(v) => Ok(v),
408            Err(AllEqualValueError(None)) => Ok(vec![]),
409            Err(AllEqualValueError(Some(mut diff))) => {
410                diff.sort();
411                plan_err!("Found mixed partition values on disk {:?}", diff)
412            }
413        }
414    }
415}