Skip to main content

datafusion_catalog_listing/
helpers.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//! Helper functions for the table implementation
19
20use std::borrow::Cow;
21use std::sync::Arc;
22
23use datafusion_catalog::Session;
24use datafusion_common::{
25    HashMap, Result, ScalarValue, TableReference, assert_or_internal_err,
26};
27use datafusion_datasource::PartitionedFile;
28use datafusion_datasource::{FileExtensions, ListingTableUrl};
29use datafusion_expr::{BinaryExpr, Operator, lit, utils};
30
31use arrow::{
32    array::AsArray,
33    datatypes::{DataType, Field},
34    record_batch::RecordBatch,
35};
36use datafusion_expr::execution_props::ExecutionProps;
37use datafusion_expr::physical_planning_context::PhysicalPlanningContext;
38use futures::stream::FuturesUnordered;
39use futures::{StreamExt, TryStreamExt, stream::BoxStream};
40use log::{debug, trace};
41
42use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
43use datafusion_common::{Column, DFSchema};
44use datafusion_expr::{Expr, Volatility};
45use datafusion_physical_expr::create_physical_expr;
46use object_store::path::Path;
47use object_store::{ObjectMeta, ObjectStore};
48use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
49
50const PARTITION_VALUE_ENCODE_SET: &AsciiSet =
51    &CONTROLS.add(b' ').add(b'%').add(b'/').add(b'?').add(b'#');
52
53/// Check whether the given expression can be resolved using only the columns `col_names`.
54/// This means that if this function returns true:
55/// - the table provider can filter the table partition values with this expression
56/// - the expression can be marked as `TableProviderFilterPushDown::Exact` once this filtering
57///   was performed
58pub fn expr_applicable_for_cols(col_names: &[&str], expr: &Expr) -> bool {
59    let mut is_applicable = true;
60    expr.apply(|expr| match expr {
61        Expr::Column(Column { name, .. }) => {
62            is_applicable &= col_names.contains(&name.as_str());
63            if is_applicable {
64                Ok(TreeNodeRecursion::Jump)
65            } else {
66                Ok(TreeNodeRecursion::Stop)
67            }
68        }
69        Expr::Literal(_, _)
70        | Expr::Alias(_)
71        | Expr::OuterReferenceColumn(_, _)
72        | Expr::ScalarVariable(_, _)
73        | Expr::Not(_)
74        | Expr::IsNotNull(_)
75        | Expr::IsNull(_)
76        | Expr::IsTrue(_)
77        | Expr::IsFalse(_)
78        | Expr::IsUnknown(_)
79        | Expr::IsNotTrue(_)
80        | Expr::IsNotFalse(_)
81        | Expr::IsNotUnknown(_)
82        | Expr::Negative(_)
83        | Expr::Cast(_)
84        | Expr::TryCast(_)
85        | Expr::BinaryExpr(_)
86        | Expr::Between(_)
87        | Expr::Like(_)
88        | Expr::SimilarTo(_)
89        | Expr::InList(_)
90        | Expr::Exists(_)
91        | Expr::InSubquery(_)
92        | Expr::ScalarSubquery(_)
93        | Expr::SetComparison(_)
94        | Expr::GroupingSet(_)
95        | Expr::Case(_)
96        | Expr::Lambda(_)
97        | Expr::LambdaVariable(_) => Ok(TreeNodeRecursion::Continue),
98
99        Expr::ScalarFunction(scalar_function) => {
100            match scalar_function.func.signature().volatility {
101                Volatility::Immutable => Ok(TreeNodeRecursion::Continue),
102                // TODO: Stable functions could be `applicable`, but that would require access to the context
103                // https://github.com/apache/datafusion/issues/21690
104                Volatility::Stable | Volatility::Volatile => {
105                    is_applicable = false;
106                    Ok(TreeNodeRecursion::Stop)
107                }
108            }
109        }
110        Expr::HigherOrderFunction(hof) => {
111            match hof.func.signature().volatility {
112                Volatility::Immutable => Ok(TreeNodeRecursion::Continue),
113                // TODO: Stable functions could be `applicable`, but that would require access to the context
114                // https://github.com/apache/datafusion/issues/21690
115                Volatility::Stable | Volatility::Volatile => {
116                    is_applicable = false;
117                    Ok(TreeNodeRecursion::Stop)
118                }
119            }
120        }
121
122        // TODO other expressions are not handled yet:
123        // - AGGREGATE and WINDOW should not end up in filter conditions, except maybe in some edge cases
124        // - Can `Wildcard` be considered as a `Literal`?
125        // - ScalarVariable could be `applicable`, but that would require access to the context
126        //   https://github.com/apache/datafusion/issues/21690
127        // TODO: remove the next line after `Expr::Wildcard` is removed
128        #[expect(deprecated)]
129        Expr::AggregateFunction { .. }
130        | Expr::WindowFunction { .. }
131        | Expr::Wildcard { .. }
132        | Expr::Unnest { .. }
133        | Expr::Placeholder(_) => {
134            is_applicable = false;
135            Ok(TreeNodeRecursion::Stop)
136        }
137    })
138    .unwrap();
139    is_applicable
140}
141
142/// The maximum number of concurrent listing requests
143const CONCURRENCY_LIMIT: usize = 100;
144
145#[derive(Debug)]
146pub struct Partition {
147    /// The path to the partition, including the table prefix
148    path: Path,
149    /// How many path segments below the table prefix `path` contains
150    /// or equivalently the number of partition values in `path`
151    depth: usize,
152    /// The files contained as direct children of this `Partition` if known
153    files: Option<Vec<ObjectMeta>>,
154}
155
156impl Partition {
157    /// List the direct children of this partition updating `self.files` with
158    /// any child files, and returning a list of child "directories"
159    async fn list(mut self, store: &dyn ObjectStore) -> Result<(Self, Vec<Path>)> {
160        trace!("Listing partition {}", self.path);
161        let prefix = Some(&self.path).filter(|p| !p.as_ref().is_empty());
162        let result = store.list_with_delimiter(prefix).await?;
163        self.files = Some(
164            result
165                .objects
166                .into_iter()
167                .filter(|object_meta| object_meta.size > 0)
168                .collect(),
169        );
170        Ok((self, result.common_prefixes))
171    }
172}
173
174/// Returns a recursive list of the partitions in `table_path` up to `max_depth`
175pub async fn list_partitions(
176    store: &dyn ObjectStore,
177    table_path: &ListingTableUrl,
178    max_depth: usize,
179    partition_prefix: Option<Path>,
180) -> Result<Vec<Partition>> {
181    let partition = Partition {
182        path: match partition_prefix {
183            Some(prefix) => Path::from_iter(
184                Path::from(table_path.prefix().as_ref())
185                    .parts()
186                    .chain(Path::from(prefix.as_ref()).parts()),
187            ),
188            None => table_path.prefix().clone(),
189        },
190        depth: 0,
191        files: None,
192    };
193
194    let mut out = Vec::with_capacity(64);
195
196    let mut pending = vec![];
197    let mut futures = FuturesUnordered::new();
198    futures.push(partition.list(store));
199
200    while let Some((partition, paths)) = futures.next().await.transpose()? {
201        // If pending contains a future it implies prior to this iteration
202        // `futures.len == CONCURRENCY_LIMIT`. We can therefore add a single
203        // future from `pending` to the working set
204        if let Some(next) = pending.pop() {
205            futures.push(next)
206        }
207
208        let depth = partition.depth;
209        out.push(partition);
210        for path in paths {
211            let child = Partition {
212                path,
213                depth: depth + 1,
214                files: None,
215            };
216            match depth < max_depth {
217                true => match futures.len() < CONCURRENCY_LIMIT {
218                    true => futures.push(child.list(store)),
219                    false => pending.push(child.list(store)),
220                },
221                false => out.push(child),
222            }
223        }
224    }
225    Ok(out)
226}
227
228#[derive(Debug)]
229enum PartitionValue {
230    Single(String),
231    Multi,
232}
233
234fn populate_partition_values<'a>(
235    partition_values: &mut HashMap<&'a str, PartitionValue>,
236    filter: &'a Expr,
237) {
238    if let Expr::BinaryExpr(BinaryExpr { left, op, right }) = filter {
239        match op {
240            Operator::Eq => match (left.as_ref(), right.as_ref()) {
241                (Expr::Column(Column { name, .. }), Expr::Literal(val, _))
242                | (Expr::Literal(val, _), Expr::Column(Column { name, .. }))
243                    if partition_values
244                        .insert(name, PartitionValue::Single(val.to_string()))
245                        .is_some() =>
246                {
247                    partition_values.insert(name, PartitionValue::Multi);
248                }
249                (Expr::Column(Column { .. }), Expr::Literal(_, _))
250                | (Expr::Literal(_, _), Expr::Column(Column { .. })) => {}
251                _ => {}
252            },
253            Operator::And => {
254                populate_partition_values(partition_values, left);
255                populate_partition_values(partition_values, right);
256            }
257            _ => {}
258        }
259    }
260}
261
262pub fn evaluate_partition_prefix<'a>(
263    partition_cols: &'a [(String, DataType)],
264    filters: &'a [Expr],
265) -> Option<Path> {
266    let mut partition_values = HashMap::new();
267    for filter in filters {
268        populate_partition_values(&mut partition_values, filter);
269    }
270
271    if partition_values.is_empty() {
272        return None;
273    }
274
275    let mut parts = vec![];
276    for (p, _) in partition_cols {
277        match partition_values.get(p.as_str()) {
278            Some(PartitionValue::Single(val)) => {
279                // if a partition only has a single literal value, then it can be added to the
280                // prefix
281                let encoded = encode_partition_value(val);
282                if encoded != val.as_str() {
283                    // The same decoded value can be represented by both raw and
284                    // percent-encoded partition directories. Prefix pruning is
285                    // an optimization, so stop before this partition rather
286                    // than listing only one spelling and potentially skipping
287                    // valid rows.
288                    break;
289                }
290                parts.push(format!("{p}={encoded}"));
291            }
292            _ => {
293                // break on the first unconstrainted partition to create a common prefix
294                // for all covered partitions.
295                break;
296            }
297        }
298    }
299
300    if parts.is_empty() {
301        None
302    } else {
303        Some(Path::from_iter(parts))
304    }
305}
306
307fn encode_partition_value(value: &str) -> Cow<'_, str> {
308    utf8_percent_encode(value, PARTITION_VALUE_ENCODE_SET).into()
309}
310
311pub fn filter_partitioned_file(
312    pf: PartitionedFile,
313    filters: &[Expr],
314    df_schema: &DFSchema,
315) -> Result<Option<PartitionedFile>> {
316    if pf.partition_values.is_empty() && !filters.is_empty() {
317        return Ok(None);
318    } else if filters.is_empty() {
319        return Ok(Some(pf));
320    }
321
322    let arrays = pf
323        .partition_values
324        .iter()
325        .map(|v| v.to_array())
326        .collect::<Result<_, _>>()?;
327
328    let batch = RecordBatch::try_new(Arc::clone(df_schema.inner()), arrays)?;
329
330    let filter = utils::conjunction(filters.iter().cloned()).unwrap_or_else(|| lit(true));
331    let props = ExecutionProps::new();
332    let expr = create_physical_expr(
333        &filter,
334        df_schema,
335        &props,
336        &PhysicalPlanningContext::default(),
337    )?;
338
339    // Since we're only operating on a single file, our batch and resulting "array" holds only one
340    // value indicating if the input file matches the provided filters
341    let matches = expr.evaluate(&batch)?.into_array(1)?;
342    if matches.as_boolean().value(0) {
343        return Ok(Some(pf));
344    }
345
346    Ok(None)
347}
348
349/// Returns `Ok(None)` when the file is not inside a valid partition path
350/// (e.g. a stale file in the table root directory). Such files are skipped
351/// because hive-style partition values are never null and there is no valid
352/// value to assign for non-partitioned files.
353fn try_into_partitioned_file(
354    object_meta: ObjectMeta,
355    partition_cols: &[(String, DataType)],
356    table_path: &ListingTableUrl,
357) -> Result<Option<PartitionedFile>> {
358    let cols = partition_cols.iter().map(|(name, _)| name.as_str());
359    let parsed = parse_partitions_for_path(table_path, &object_meta.location, cols);
360
361    let Some(parsed) = parsed else {
362        // parse_partitions_for_path already logs a debug message
363        return Ok(None);
364    };
365
366    let partition_values = parsed
367        .into_iter()
368        .zip(partition_cols)
369        .map(|(parsed, (_, datatype))| {
370            ScalarValue::try_from_string(parsed.into_owned(), datatype)
371        })
372        .collect::<Result<Vec<_>>>()?;
373
374    let mut pf: PartitionedFile = object_meta.into();
375    pf.partition_values = partition_values;
376    pf.table_reference.clone_from(table_path.get_table_ref());
377
378    Ok(Some(pf))
379}
380
381/// Discover the partitions on the given path and prune out files
382/// that belong to irrelevant partitions using `filters` expressions.
383/// `filters` should only contain expressions that can be evaluated
384/// using only the partition columns.
385pub async fn pruned_partition_list<'a>(
386    ctx: &'a dyn Session,
387    store: &'a dyn ObjectStore,
388    table_path: &'a ListingTableUrl,
389    filters: &'a [Expr],
390    file_extension: &'a str,
391    partition_cols: &'a [(String, DataType)],
392) -> Result<BoxStream<'a, Result<PartitionedFile>>> {
393    let prefix = if !partition_cols.is_empty() {
394        evaluate_partition_prefix(partition_cols, filters)
395    } else {
396        None
397    };
398
399    let objects = table_path
400        .list_prefixed_files(ctx, store, prefix, file_extension)
401        .await?
402        .try_filter(|object_meta| futures::future::ready(object_meta.size > 0));
403
404    if partition_cols.is_empty() {
405        assert_or_internal_err!(
406            filters.is_empty(),
407            "Got partition filters for unpartitioned table {}",
408            table_path
409        );
410
411        // if no partition col => list all the files
412        Ok(objects
413            .try_filter_map(|object_meta| {
414                futures::future::ready(object_meta_to_partitioned_file(
415                    object_meta,
416                    table_path.get_table_ref(),
417                ))
418            })
419            .boxed())
420    } else {
421        let df_schema = DFSchema::from_unqualified_fields(
422            partition_cols
423                .iter()
424                .map(|(n, d)| Field::new(n, d.clone(), true))
425                .collect(),
426            Default::default(),
427        )?;
428
429        Ok(objects
430            .try_filter_map(|object_meta| {
431                futures::future::ready(try_into_partitioned_file(
432                    object_meta,
433                    partition_cols,
434                    table_path,
435                ))
436            })
437            .try_filter_map(move |pf| {
438                futures::future::ready(filter_partitioned_file(pf, filters, &df_schema))
439            })
440            .boxed())
441    }
442}
443
444fn object_meta_to_partitioned_file(
445    object_meta: ObjectMeta,
446    table_ref: &Option<TableReference>,
447) -> Result<Option<PartitionedFile>> {
448    Ok(Some(PartitionedFile {
449        object_meta,
450        arrow_schema: None,
451        partition_values: vec![],
452        range: None,
453        statistics: None,
454        ordering: None,
455        extensions: FileExtensions::new(),
456        metadata_size_hint: None,
457        table_reference: table_ref.clone(),
458    }))
459}
460
461/// Extract the partition values for the given `file_path` (in the given `table_path`)
462/// associated to the partitions defined by `table_partition_cols`.
463///
464/// Partition values are percent-decoded to match Hive-style object-store paths
465/// that encode special characters in path segments.
466pub fn parse_partitions_for_path<'a, I>(
467    table_path: &ListingTableUrl,
468    file_path: &'a Path,
469    table_partition_cols: I,
470) -> Option<Vec<Cow<'a, str>>>
471where
472    I: IntoIterator<Item = &'a str>,
473{
474    let subpath = table_path.strip_prefix(file_path)?;
475
476    let mut part_values = vec![];
477    for (part, expected_partition) in subpath.zip(table_partition_cols) {
478        match part.split_once('=') {
479            Some((name, val)) if name == expected_partition => {
480                // Preserve the original value if percent-decoding produces invalid UTF-8.
481                let decoded = percent_decode_str(val)
482                    .decode_utf8()
483                    .unwrap_or(Cow::Borrowed(val));
484                part_values.push(decoded);
485            }
486            _ => {
487                debug!(
488                    "Ignoring file: file_path='{file_path}', table_path='{table_path}', part='{part}', partition_col='{expected_partition}'",
489                );
490                return None;
491            }
492        }
493    }
494    Some(part_values)
495}
496/// Describe a partition as a (path, depth, files) tuple for easier assertions
497pub fn describe_partition(partition: &Partition) -> (&str, usize, Vec<&str>) {
498    (
499        partition.path.as_ref(),
500        partition.depth,
501        partition
502            .files
503            .as_ref()
504            .map(|f| f.iter().map(|f| f.location.filename().unwrap()).collect())
505            .unwrap_or_default(),
506    )
507}
508
509#[cfg(test)]
510mod tests {
511    use datafusion_datasource::file_groups::FileGroup;
512    use std::ops::Not;
513
514    use super::*;
515    use datafusion_expr::{case, col};
516
517    #[test]
518    fn test_split_files() {
519        let new_partitioned_file = |path: &str| PartitionedFile::new(path.to_owned(), 10);
520        let files = FileGroup::new(vec![
521            new_partitioned_file("a"),
522            new_partitioned_file("b"),
523            new_partitioned_file("c"),
524            new_partitioned_file("d"),
525            new_partitioned_file("e"),
526        ]);
527
528        let chunks = files.clone().split_files(1);
529        assert_eq!(1, chunks.len());
530        assert_eq!(5, chunks[0].len());
531
532        let chunks = files.clone().split_files(2);
533        assert_eq!(2, chunks.len());
534        assert_eq!(3, chunks[0].len());
535        assert_eq!(2, chunks[1].len());
536
537        let chunks = files.clone().split_files(5);
538        assert_eq!(5, chunks.len());
539        assert_eq!(1, chunks[0].len());
540        assert_eq!(1, chunks[1].len());
541        assert_eq!(1, chunks[2].len());
542        assert_eq!(1, chunks[3].len());
543        assert_eq!(1, chunks[4].len());
544
545        let chunks = files.clone().split_files(123);
546        assert_eq!(5, chunks.len());
547        assert_eq!(1, chunks[0].len());
548        assert_eq!(1, chunks[1].len());
549        assert_eq!(1, chunks[2].len());
550        assert_eq!(1, chunks[3].len());
551        assert_eq!(1, chunks[4].len());
552
553        let empty_group = FileGroup::default();
554        let chunks = empty_group.split_files(2);
555        assert_eq!(0, chunks.len());
556    }
557
558    #[test]
559    fn test_parse_partitions_for_path() {
560        assert_eq!(
561            Some(vec![] as Vec<Cow<'_, str>>),
562            parse_partitions_for_path(
563                &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
564                &Path::from("bucket/mytable/file.csv"),
565                vec![]
566            )
567        );
568        assert_eq!(
569            None,
570            parse_partitions_for_path(
571                &ListingTableUrl::parse("file:///bucket/othertable").unwrap(),
572                &Path::from("bucket/mytable/file.csv"),
573                vec![]
574            )
575        );
576        assert_eq!(
577            None,
578            parse_partitions_for_path(
579                &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
580                &Path::from("bucket/mytable/file.csv"),
581                vec!["mypartition"]
582            )
583        );
584        assert_eq!(
585            Some(vec![Cow::Borrowed("v1")]),
586            parse_partitions_for_path(
587                &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
588                &Path::from("bucket/mytable/mypartition=v1/file.csv"),
589                vec!["mypartition"]
590            )
591        );
592        for (path, column, expected) in [
593            (
594                "bucket/mytable/mypartition=v%2F1/file.csv",
595                "mypartition",
596                "v/1",
597            ),
598            (
599                "bucket/mytable/name=John%20Doe/file.csv",
600                "name",
601                "John Doe",
602            ),
603            (
604                "bucket/mytable/mypartition=test%20dir%2Ffile/file.csv",
605                "mypartition",
606                "test dir/file",
607            ),
608            (
609                "bucket/mytable/mypartition=%C3%A9/file.csv",
610                "mypartition",
611                "é",
612            ),
613            (
614                "bucket/mytable/mypartition=%FF/file.csv",
615                "mypartition",
616                "%FF",
617            ),
618        ] {
619            assert_eq!(
620                Some(vec![Cow::Borrowed(expected)]),
621                parse_partitions_for_path(
622                    &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
623                    &Path::parse(path).unwrap(),
624                    vec![column]
625                )
626            );
627        }
628        assert_eq!(
629            Some(vec![Cow::Borrowed("v1")]),
630            parse_partitions_for_path(
631                &ListingTableUrl::parse("file:///bucket/mytable/").unwrap(),
632                &Path::from("bucket/mytable/mypartition=v1/file.csv"),
633                vec!["mypartition"]
634            )
635        );
636        // Only hive style partitioning supported for now:
637        assert_eq!(
638            None,
639            parse_partitions_for_path(
640                &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
641                &Path::from("bucket/mytable/v1/file.csv"),
642                vec!["mypartition"]
643            )
644        );
645        assert_eq!(
646            Some(vec![Cow::Borrowed("v1"), Cow::Borrowed("v2")]),
647            parse_partitions_for_path(
648                &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
649                &Path::from("bucket/mytable/mypartition=v1/otherpartition=v2/file.csv"),
650                vec!["mypartition", "otherpartition"]
651            )
652        );
653        assert_eq!(
654            Some(vec![Cow::Borrowed("v1")]),
655            parse_partitions_for_path(
656                &ListingTableUrl::parse("file:///bucket/mytable").unwrap(),
657                &Path::from("bucket/mytable/mypartition=v1/otherpartition=v2/file.csv"),
658                vec!["mypartition"]
659            )
660        );
661    }
662
663    #[test]
664    fn test_try_into_partitioned_file_valid_partition() {
665        let table_path = ListingTableUrl::parse("file:///bucket/mytable").unwrap();
666        let partition_cols = vec![("year_month".to_string(), DataType::Utf8)];
667        let meta = ObjectMeta {
668            location: Path::from("bucket/mytable/year_month=2024-01/data.parquet"),
669            last_modified: chrono::Utc::now(),
670            size: 100,
671            e_tag: None,
672            version: None,
673        };
674
675        let result =
676            try_into_partitioned_file(meta, &partition_cols, &table_path).unwrap();
677        assert!(result.is_some());
678        let pf = result.unwrap();
679        assert_eq!(pf.partition_values.len(), 1);
680        assert_eq!(
681            pf.partition_values[0],
682            ScalarValue::Utf8(Some("2024-01".to_string()))
683        );
684    }
685
686    #[test]
687    fn test_try_into_partitioned_file_decodes_partition_value() {
688        let table_path = ListingTableUrl::parse("file:///bucket/mytable").unwrap();
689        let partition_cols = vec![("category".to_string(), DataType::Utf8)];
690        let meta = ObjectMeta {
691            location: Path::parse(
692                "bucket/mytable/category=Electronics%2FComputers/data.parquet",
693            )
694            .unwrap(),
695            last_modified: chrono::Utc::now(),
696            size: 100,
697            e_tag: None,
698            version: None,
699        };
700
701        let result =
702            try_into_partitioned_file(meta, &partition_cols, &table_path).unwrap();
703        assert!(result.is_some());
704        let pf = result.unwrap();
705        assert_eq!(pf.partition_values.len(), 1);
706        assert_eq!(
707            pf.partition_values[0],
708            ScalarValue::Utf8(Some("Electronics/Computers".to_string()))
709        );
710    }
711
712    #[test]
713    fn test_try_into_partitioned_file_root_file_skipped() {
714        // File in root directory (not inside any partition path) should be
715        // skipped — this is the case where a stale file exists from before
716        // hive partitioning was added.
717        let table_path = ListingTableUrl::parse("file:///bucket/mytable").unwrap();
718        let partition_cols = vec![("year_month".to_string(), DataType::Utf8)];
719        let meta = ObjectMeta {
720            location: Path::from("bucket/mytable/data.parquet"),
721            last_modified: chrono::Utc::now(),
722            size: 100,
723            e_tag: None,
724            version: None,
725        };
726
727        let result =
728            try_into_partitioned_file(meta, &partition_cols, &table_path).unwrap();
729        assert!(
730            result.is_none(),
731            "Files outside partition structure should be skipped"
732        );
733    }
734
735    #[test]
736    fn test_try_into_partitioned_file_wrong_partition_name() {
737        // File in a directory that doesn't match the expected partition column
738        let table_path = ListingTableUrl::parse("file:///bucket/mytable").unwrap();
739        let partition_cols = vec![("year_month".to_string(), DataType::Utf8)];
740        let meta = ObjectMeta {
741            location: Path::from("bucket/mytable/wrong_col=2024-01/data.parquet"),
742            last_modified: chrono::Utc::now(),
743            size: 100,
744            e_tag: None,
745            version: None,
746        };
747
748        let result =
749            try_into_partitioned_file(meta, &partition_cols, &table_path).unwrap();
750        assert!(
751            result.is_none(),
752            "Files with wrong partition column name should be skipped"
753        );
754    }
755
756    #[test]
757    fn test_try_into_partitioned_file_multiple_partitions() {
758        let table_path = ListingTableUrl::parse("file:///bucket/mytable").unwrap();
759        let partition_cols = vec![
760            ("year".to_string(), DataType::Utf8),
761            ("month".to_string(), DataType::Utf8),
762        ];
763        let meta = ObjectMeta {
764            location: Path::from("bucket/mytable/year=2024/month=01/data.parquet"),
765            last_modified: chrono::Utc::now(),
766            size: 100,
767            e_tag: None,
768            version: None,
769        };
770
771        let result =
772            try_into_partitioned_file(meta, &partition_cols, &table_path).unwrap();
773        assert!(result.is_some());
774        let pf = result.unwrap();
775        assert_eq!(pf.partition_values.len(), 2);
776        assert_eq!(
777            pf.partition_values[0],
778            ScalarValue::Utf8(Some("2024".to_string()))
779        );
780        assert_eq!(
781            pf.partition_values[1],
782            ScalarValue::Utf8(Some("01".to_string()))
783        );
784    }
785
786    #[test]
787    fn test_try_into_partitioned_file_partial_partition_skipped() {
788        // File has first partition but not second — should be skipped
789        let table_path = ListingTableUrl::parse("file:///bucket/mytable").unwrap();
790        let partition_cols = vec![
791            ("year".to_string(), DataType::Utf8),
792            ("month".to_string(), DataType::Utf8),
793        ];
794        let meta = ObjectMeta {
795            location: Path::from("bucket/mytable/year=2024/data.parquet"),
796            last_modified: chrono::Utc::now(),
797            size: 100,
798            e_tag: None,
799            version: None,
800        };
801
802        let result =
803            try_into_partitioned_file(meta, &partition_cols, &table_path).unwrap();
804        // File has year=2024 but no month= directory — parse_partitions_for_path
805        // returns None because the path component "data.parquet" doesn't match
806        // the expected "month=..." pattern.
807        assert!(
808            result.is_none(),
809            "Files with incomplete partition structure should be skipped"
810        );
811    }
812
813    #[test]
814    fn test_expr_applicable_for_cols() {
815        assert!(expr_applicable_for_cols(
816            &["c1"],
817            &Expr::eq(col("c1"), lit("value"))
818        ));
819        assert!(!expr_applicable_for_cols(
820            &["c1"],
821            &Expr::eq(col("c2"), lit("value"))
822        ));
823        assert!(!expr_applicable_for_cols(
824            &["c1"],
825            &Expr::eq(col("c1"), col("c2"))
826        ));
827        assert!(expr_applicable_for_cols(
828            &["c1", "c2"],
829            &Expr::eq(col("c1"), col("c2"))
830        ));
831        assert!(expr_applicable_for_cols(
832            &["c1", "c2"],
833            &(Expr::eq(col("c1"), col("c2").alias("c2_alias"))).not()
834        ));
835        assert!(expr_applicable_for_cols(
836            &["c1", "c2"],
837            &(case(col("c1"))
838                .when(lit("v1"), lit(true))
839                .otherwise(lit(false))
840                .expect("valid case expr"))
841        ));
842        // static expression not relevant in this context but we
843        // test it as an edge case anyway in case we want to generalize
844        // this helper function
845        assert!(expr_applicable_for_cols(&[], &lit(true)));
846    }
847
848    #[test]
849    fn test_evaluate_partition_prefix() {
850        let partitions = &[
851            ("a".to_string(), DataType::Utf8),
852            ("b".to_string(), DataType::Int16),
853            ("c".to_string(), DataType::Boolean),
854        ];
855
856        assert_eq!(
857            evaluate_partition_prefix(partitions, &[col("a").eq(lit("foo"))]),
858            Some(Path::from("a=foo")),
859        );
860
861        assert_eq!(
862            evaluate_partition_prefix(partitions, &[lit("foo").eq(col("a"))]),
863            Some(Path::from("a=foo")),
864        );
865
866        assert_eq!(
867            evaluate_partition_prefix(
868                partitions,
869                &[col("a").eq(lit("Electronics/Computers"))],
870            ),
871            None,
872        );
873
874        assert_eq!(
875            evaluate_partition_prefix(partitions, &[col("a").eq(lit("John Doe"))]),
876            None,
877        );
878
879        assert_eq!(
880            evaluate_partition_prefix(
881                partitions,
882                &[col("a").eq(lit("foo")).and(col("b").eq(lit("John Doe")))],
883            ),
884            Some(Path::from("a=foo")),
885        );
886
887        assert_eq!(
888            evaluate_partition_prefix(
889                partitions,
890                &[col("a").eq(lit("foo")).and(col("b").eq(lit("bar")))],
891            ),
892            Some(Path::from("a=foo/b=bar")),
893        );
894
895        assert_eq!(
896            evaluate_partition_prefix(
897                partitions,
898                // list of filters should be evaluated as AND
899                &[col("a").eq(lit("foo")), col("b").eq(lit("bar")),],
900            ),
901            Some(Path::from("a=foo/b=bar")),
902        );
903
904        assert_eq!(
905            evaluate_partition_prefix(
906                partitions,
907                &[col("a")
908                    .eq(lit("foo"))
909                    .and(col("b").eq(lit("1")))
910                    .and(col("c").eq(lit("true")))],
911            ),
912            Some(Path::from("a=foo/b=1/c=true")),
913        );
914
915        // no prefix when filter is empty
916        assert_eq!(evaluate_partition_prefix(partitions, &[]), None);
917
918        // b=foo results in no prefix because a is not restricted
919        assert_eq!(
920            evaluate_partition_prefix(partitions, &[Expr::eq(col("b"), lit("foo"))]),
921            None,
922        );
923
924        // a=foo and c=baz only results in preifx a=foo because b is not restricted
925        assert_eq!(
926            evaluate_partition_prefix(
927                partitions,
928                &[col("a").eq(lit("foo")).and(col("c").eq(lit("baz")))],
929            ),
930            Some(Path::from("a=foo")),
931        );
932
933        // partition with multiple values results in no prefix
934        assert_eq!(
935            evaluate_partition_prefix(
936                partitions,
937                &[Expr::and(col("a").eq(lit("foo")), col("a").eq(lit("bar")))],
938            ),
939            None,
940        );
941
942        // no prefix because partition a is not restricted to a single literal
943        assert_eq!(
944            evaluate_partition_prefix(
945                partitions,
946                &[Expr::or(col("a").eq(lit("foo")), col("a").eq(lit("bar")))],
947            ),
948            None,
949        );
950        assert_eq!(
951            evaluate_partition_prefix(partitions, &[col("b").lt(lit(5))],),
952            None,
953        );
954    }
955
956    #[test]
957    fn test_evaluate_date_partition_prefix() {
958        let partitions = &[("a".to_string(), DataType::Date32)];
959        assert_eq!(
960            evaluate_partition_prefix(
961                partitions,
962                &[col("a").eq(Expr::Literal(ScalarValue::Date32(Some(3)), None))],
963            ),
964            Some(Path::from("a=1970-01-04")),
965        );
966
967        let partitions = &[("a".to_string(), DataType::Date64)];
968        assert_eq!(
969            evaluate_partition_prefix(
970                partitions,
971                &[col("a").eq(Expr::Literal(
972                    ScalarValue::Date64(Some(4 * 24 * 60 * 60 * 1000)),
973                    None
974                )),],
975            ),
976            Some(Path::from("a=1970-01-05")),
977        );
978    }
979}