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}