Skip to content

Commit 34c4849

Browse files
yinli-systemsKevin-Li-2025
andauthored
Decode Hive partition values in listing tables (#23226)
## Which issue does this PR close? - Closes #19650. ## Rationale for this change Hive-style partition values can contain percent-encoded characters in object-store paths, such as `%2F` for `/` or `%20` for a space. `parse_partitions_for_path` currently returns those encoded bytes literally, so listing tables expose `foo%2Fbar` instead of `foo/bar`. ## What changes are included in this PR? - Percent-decode extracted partition values in `parse_partitions_for_path`. - Return `Cow<str>` from the parser so unchanged values keep the borrowed fast path and decoded values can be owned only when needed. - Fall back to the original raw partition value if percent decoding does not produce valid UTF-8, rather than dropping the file from listing results. - Add helper-level and `PartitionedFile` conversion tests for decoded partition values. ## Are these changes tested? - `cargo fmt --all --check` - `cargo test -p datafusion-catalog-listing` --------- Signed-off-by: Kevin-Li-2025 <2242139@qq.com> Co-authored-by: Kevin-Li-2025 <2242139@qq.com>
1 parent 36d1c98 commit 34c4849

4 files changed

Lines changed: 123 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ parquet = { version = "59.1.0", default-features = false, features = [
185185
] }
186186
pbjson = { version = "0.9.0" }
187187
pbjson-types = "0.9"
188+
percent-encoding = "2.3"
188189
pin-project = "1"
189190
# Should match arrow-flight's version of prost.
190191
prost = "0.14.1"

datafusion/catalog-listing/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ futures = { workspace = true }
4646
itertools = { workspace = true }
4747
log = { workspace = true }
4848
object_store = { workspace = true }
49+
percent-encoding = { workspace = true }
4950

5051
[dev-dependencies]
5152
chrono = { workspace = true }

datafusion/catalog-listing/src/helpers.rs

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
//! Helper functions for the table implementation
1919
20+
use std::borrow::Cow;
2021
use std::sync::Arc;
2122

2223
use datafusion_catalog::Session;
@@ -43,6 +44,10 @@ use datafusion_expr::{Expr, Volatility};
4344
use datafusion_physical_expr::create_physical_expr;
4445
use object_store::path::Path;
4546
use 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+
292310
pub 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.
439460
pub 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>>>
444465
where
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

Comments
 (0)