1717
1818//! Helper functions for the table implementation
1919
20+ use std:: borrow:: Cow ;
2021use std:: sync:: Arc ;
2122
2223use datafusion_catalog:: Session ;
@@ -43,6 +44,10 @@ use datafusion_expr::{Expr, Volatility};
4344use datafusion_physical_expr:: create_physical_expr;
4445use object_store:: path:: Path ;
4546use object_store:: { ObjectMeta , ObjectStore } ;
47+ use percent_encoding:: { AsciiSet , CONTROLS , percent_decode_str, utf8_percent_encode} ;
48+
49+ const PARTITION_VALUE_ENCODE_SET : & AsciiSet =
50+ & CONTROLS . add ( b' ' ) . add ( b'%' ) . add ( b'/' ) . add ( b'?' ) . add ( b'#' ) ;
4651
4752/// Check whether the given expression can be resolved using only the columns `col_names`.
4853/// This means that if this function returns true:
@@ -272,7 +277,16 @@ pub fn evaluate_partition_prefix<'a>(
272277 Some ( PartitionValue :: Single ( val) ) => {
273278 // if a partition only has a single literal value, then it can be added to the
274279 // prefix
275- parts. push ( format ! ( "{p}={val}" ) ) ;
280+ let encoded = encode_partition_value ( val) ;
281+ if encoded != val. as_str ( ) {
282+ // The same decoded value can be represented by both raw and
283+ // percent-encoded partition directories. Prefix pruning is
284+ // an optimization, so stop before this partition rather
285+ // than listing only one spelling and potentially skipping
286+ // valid rows.
287+ break ;
288+ }
289+ parts. push ( format ! ( "{p}={encoded}" ) ) ;
276290 }
277291 _ => {
278292 // break on the first unconstrainted partition to create a common prefix
@@ -289,6 +303,10 @@ pub fn evaluate_partition_prefix<'a>(
289303 }
290304}
291305
306+ fn encode_partition_value ( value : & str ) -> Cow < ' _ , str > {
307+ utf8_percent_encode ( value, PARTITION_VALUE_ENCODE_SET ) . into ( )
308+ }
309+
292310pub fn filter_partitioned_file (
293311 pf : PartitionedFile ,
294312 filters : & [ Expr ] ,
@@ -343,7 +361,7 @@ fn try_into_partitioned_file(
343361 . into_iter ( )
344362 . zip ( partition_cols)
345363 . map ( |( parsed, ( _, datatype) ) | {
346- ScalarValue :: try_from_string ( parsed. to_string ( ) , datatype)
364+ ScalarValue :: try_from_string ( parsed. into_owned ( ) , datatype)
347365 } )
348366 . collect :: < Result < Vec < _ > > > ( ) ?;
349367
@@ -435,12 +453,15 @@ fn object_meta_to_partitioned_file(
435453}
436454
437455/// Extract the partition values for the given `file_path` (in the given `table_path`)
438- /// associated to the partitions defined by `table_partition_cols`
456+ /// associated to the partitions defined by `table_partition_cols`.
457+ ///
458+ /// Partition values are percent-decoded to match Hive-style object-store paths
459+ /// that encode special characters in path segments.
439460pub fn parse_partitions_for_path < ' a , I > (
440461 table_path : & ListingTableUrl ,
441462 file_path : & ' a Path ,
442463 table_partition_cols : I ,
443- ) -> Option < Vec < & ' a str > >
464+ ) -> Option < Vec < Cow < ' a , str > > >
444465where
445466 I : IntoIterator < Item = & ' a str > ,
446467{
@@ -449,7 +470,13 @@ where
449470 let mut part_values = vec ! [ ] ;
450471 for ( part, expected_partition) in subpath. zip ( table_partition_cols) {
451472 match part. split_once ( '=' ) {
452- Some ( ( name, val) ) if name == expected_partition => part_values. push ( val) ,
473+ Some ( ( name, val) ) if name == expected_partition => {
474+ // Preserve the original value if percent-decoding produces invalid UTF-8.
475+ let decoded = percent_decode_str ( val)
476+ . decode_utf8 ( )
477+ . unwrap_or ( Cow :: Borrowed ( val) ) ;
478+ part_values. push ( decoded) ;
479+ }
453480 _ => {
454481 debug ! (
455482 "Ignoring file: file_path='{file_path}', table_path='{table_path}', part='{part}', partition_col='{expected_partition}'" ,
@@ -525,7 +552,7 @@ mod tests {
525552 #[ test]
526553 fn test_parse_partitions_for_path ( ) {
527554 assert_eq ! (
528- Some ( vec![ ] ) ,
555+ Some ( vec![ ] as Vec < Cow < ' _ , str >> ) ,
529556 parse_partitions_for_path(
530557 & ListingTableUrl :: parse( "file:///bucket/mytable" ) . unwrap( ) ,
531558 & Path :: from( "bucket/mytable/file.csv" ) ,
@@ -549,15 +576,51 @@ mod tests {
549576 )
550577 ) ;
551578 assert_eq ! (
552- Some ( vec![ "v1" ] ) ,
579+ Some ( vec![ Cow :: Borrowed ( "v1" ) ] ) ,
553580 parse_partitions_for_path(
554581 & ListingTableUrl :: parse( "file:///bucket/mytable" ) . unwrap( ) ,
555582 & Path :: from( "bucket/mytable/mypartition=v1/file.csv" ) ,
556583 vec![ "mypartition" ]
557584 )
558585 ) ;
586+ for ( path, column, expected) in [
587+ (
588+ "bucket/mytable/mypartition=v%2F1/file.csv" ,
589+ "mypartition" ,
590+ "v/1" ,
591+ ) ,
592+ (
593+ "bucket/mytable/name=John%20Doe/file.csv" ,
594+ "name" ,
595+ "John Doe" ,
596+ ) ,
597+ (
598+ "bucket/mytable/mypartition=test%20dir%2Ffile/file.csv" ,
599+ "mypartition" ,
600+ "test dir/file" ,
601+ ) ,
602+ (
603+ "bucket/mytable/mypartition=%C3%A9/file.csv" ,
604+ "mypartition" ,
605+ "é" ,
606+ ) ,
607+ (
608+ "bucket/mytable/mypartition=%FF/file.csv" ,
609+ "mypartition" ,
610+ "%FF" ,
611+ ) ,
612+ ] {
613+ assert_eq ! (
614+ Some ( vec![ Cow :: Borrowed ( expected) ] ) ,
615+ parse_partitions_for_path(
616+ & ListingTableUrl :: parse( "file:///bucket/mytable" ) . unwrap( ) ,
617+ & Path :: parse( path) . unwrap( ) ,
618+ vec![ column]
619+ )
620+ ) ;
621+ }
559622 assert_eq ! (
560- Some ( vec![ "v1" ] ) ,
623+ Some ( vec![ Cow :: Borrowed ( "v1" ) ] ) ,
561624 parse_partitions_for_path(
562625 & ListingTableUrl :: parse( "file:///bucket/mytable/" ) . unwrap( ) ,
563626 & Path :: from( "bucket/mytable/mypartition=v1/file.csv" ) ,
@@ -574,15 +637,15 @@ mod tests {
574637 )
575638 ) ;
576639 assert_eq ! (
577- Some ( vec![ "v1" , "v2" ] ) ,
640+ Some ( vec![ Cow :: Borrowed ( "v1" ) , Cow :: Borrowed ( "v2" ) ] ) ,
578641 parse_partitions_for_path(
579642 & ListingTableUrl :: parse( "file:///bucket/mytable" ) . unwrap( ) ,
580643 & Path :: from( "bucket/mytable/mypartition=v1/otherpartition=v2/file.csv" ) ,
581644 vec![ "mypartition" , "otherpartition" ]
582645 )
583646 ) ;
584647 assert_eq ! (
585- Some ( vec![ "v1" ] ) ,
648+ Some ( vec![ Cow :: Borrowed ( "v1" ) ] ) ,
586649 parse_partitions_for_path(
587650 & ListingTableUrl :: parse( "file:///bucket/mytable" ) . unwrap( ) ,
588651 & Path :: from( "bucket/mytable/mypartition=v1/otherpartition=v2/file.csv" ) ,
@@ -614,6 +677,32 @@ mod tests {
614677 ) ;
615678 }
616679
680+ #[ test]
681+ fn test_try_into_partitioned_file_decodes_partition_value ( ) {
682+ let table_path = ListingTableUrl :: parse ( "file:///bucket/mytable" ) . unwrap ( ) ;
683+ let partition_cols = vec ! [ ( "category" . to_string( ) , DataType :: Utf8 ) ] ;
684+ let meta = ObjectMeta {
685+ location : Path :: parse (
686+ "bucket/mytable/category=Electronics%2FComputers/data.parquet" ,
687+ )
688+ . unwrap ( ) ,
689+ last_modified : chrono:: Utc :: now ( ) ,
690+ size : 100 ,
691+ e_tag : None ,
692+ version : None ,
693+ } ;
694+
695+ let result =
696+ try_into_partitioned_file ( meta, & partition_cols, & table_path) . unwrap ( ) ;
697+ assert ! ( result. is_some( ) ) ;
698+ let pf = result. unwrap ( ) ;
699+ assert_eq ! ( pf. partition_values. len( ) , 1 ) ;
700+ assert_eq ! (
701+ pf. partition_values[ 0 ] ,
702+ ScalarValue :: Utf8 ( Some ( "Electronics/Computers" . to_string( ) ) )
703+ ) ;
704+ }
705+
617706 #[ test]
618707 fn test_try_into_partitioned_file_root_file_skipped ( ) {
619708 // File in root directory (not inside any partition path) should be
@@ -768,6 +857,27 @@ mod tests {
768857 Some ( Path :: from( "a=foo" ) ) ,
769858 ) ;
770859
860+ assert_eq ! (
861+ evaluate_partition_prefix(
862+ partitions,
863+ & [ col( "a" ) . eq( lit( "Electronics/Computers" ) ) ] ,
864+ ) ,
865+ None ,
866+ ) ;
867+
868+ assert_eq ! (
869+ evaluate_partition_prefix( partitions, & [ col( "a" ) . eq( lit( "John Doe" ) ) ] ) ,
870+ None ,
871+ ) ;
872+
873+ assert_eq ! (
874+ evaluate_partition_prefix(
875+ partitions,
876+ & [ col( "a" ) . eq( lit( "foo" ) ) . and( col( "b" ) . eq( lit( "John Doe" ) ) ) ] ,
877+ ) ,
878+ Some ( Path :: from( "a=foo" ) ) ,
879+ ) ;
880+
771881 assert_eq ! (
772882 evaluate_partition_prefix(
773883 partitions,
0 commit comments