This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 23656594 fix(spec): match Java whitespace for CHAR/VARCHAR partition 
values (#985)
23656594 is described below

commit 236565941a07f29e6193fb27a414a0b6ec4547b0
Author: jackylee <[email protected]>
AuthorDate: Sun Oct 4 22:23:58 2026 +0800

    fix(spec): match Java whitespace for CHAR/VARCHAR partition values (#985)
---
 .../datafusion/src/format_partition_ddl.rs         | 15 +++--
 .../tests/format_table_partition_filters.rs        | 51 +++++++++++++++
 .../datafusion/tests/rest_format_partition_sql.rs  | 14 +++-
 crates/paimon/src/spec/mod.rs                      |  1 +
 crates/paimon/src/spec/partition_utils.rs          | 74 ++++++++++++++++++++--
 crates/paimon/src/table/format_partition.rs        | 73 +++++++++++++++++++--
 6 files changed, 212 insertions(+), 16 deletions(-)

diff --git a/crates/integrations/datafusion/src/format_partition_ddl.rs 
b/crates/integrations/datafusion/src/format_partition_ddl.rs
index 7d0c5bed..c2d4eeac 100644
--- a/crates/integrations/datafusion/src/format_partition_ddl.rs
+++ b/crates/integrations/datafusion/src/format_partition_ddl.rs
@@ -34,7 +34,7 @@ use datafusion::sql::sqlparser::keywords::Keyword;
 use datafusion::sql::sqlparser::parser::Parser;
 use datafusion::sql::sqlparser::tokenizer::{Token, Tokenizer};
 use paimon::catalog::{Catalog, Identifier};
-use paimon::spec::{CoreOptions, DataType as PaimonDataType};
+use paimon::spec::{is_java_whitespace_only, CoreOptions, DataType as 
PaimonDataType};
 use paimon::table::{
     format_partition_value, parse_format_partition_value, 
FormatTablePartitionPaths,
 };
@@ -487,10 +487,17 @@ pub(crate) fn parse_format_partition_spec(
             Some(text) => {
                 let text = match data_type {
                     PaimonDataType::Char(_) | PaimonDataType::VarChar(_) => {
-                        // A blank string would address the default partition; 
Java
-                        // `PaimonFormatTable.requireNameablePartitionValues` 
refuses it too.
+                        // A value that folds to the default partition would 
address
+                        // the NULL partition, so Java `PaimonFormatTable.
+                        // requireNameablePartitionValues` refuses it for a 
mutation.
+                        // Classify with the same Java-whitespace predicate the
+                        // formatter folds by (`is_java_whitespace_only`), not 
Rust
+                        // `str::trim`, whose set differs (trim keeps 
U+001C-U+001F,
+                        // which Java folds): a bare `trim().is_empty()` would 
let
+                        // `DROP PARTITION (c = '<U+001C>')` through and 
destructively
+                        // address the default partition.
                         if let Some(operation) = mutating_operation {
-                            if text.trim().is_empty() {
+                            if is_java_whitespace_only(&text) {
                                 return Err(DataFusionError::Plan(format!(
                                     "{operation} does not support an empty or 
whitespace-only \
                                      string for partition column '{column}' of 
Format Table {}. \
diff --git 
a/crates/integrations/datafusion/tests/format_table_partition_filters.rs 
b/crates/integrations/datafusion/tests/format_table_partition_filters.rs
index fc302776..26e4410d 100644
--- a/crates/integrations/datafusion/tests/format_table_partition_filters.rs
+++ b/crates/integrations/datafusion/tests/format_table_partition_filters.rs
@@ -137,3 +137,54 @@ async fn 
test_filter_on_a_partition_column_reads_the_directory_value() {
     }
     assert!(mismatches.is_empty(), "{mismatches:#?}");
 }
+
+/// U+180E (MONGOLIAN VOWEL SEPARATOR) is `Character.isWhitespace` only on 
legacy
+/// JDK 8 (Unicode 6.2); on the supported modern JVMs (Java 11/17, Unicode >= 
6.3)
+/// it is a format character. A modern JVM therefore keeps it as a literal
+/// partition value and writes a `dt=<U+180E>` directory. The reader must 
format
+/// the query literal the same way; folding it to the default-partition name 
(the
+/// legacy JDK 8 behavior) prunes to the wrong directory and misses the row.
+#[tokio::test]
+async fn test_u180e_partition_value_is_read_as_a_modern_jvm_literal() {
+    let tmp = TempDir::new().expect("Failed to create temp dir");
+    let mut options = Options::new();
+    options.set(
+        CatalogOptions::WAREHOUSE,
+        format!("file:{}", tmp.path().display()),
+    );
+    let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed to 
create catalog"));
+    catalog
+        .create_database(DATABASE, false, Default::default())
+        .await
+        .expect("CREATE DATABASE failed");
+    let schema = Schema::builder()
+        .column("dt", DataType::VarChar(VarCharType::new(32).unwrap()))
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(vec!["dt".to_string()])
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    catalog
+        .create_table(&Identifier::new(DATABASE, TABLE), schema, false)
+        .await
+        .expect("CREATE TABLE failed");
+    let table_dir = tmp.path().join(format!("{DATABASE}.db")).join(TABLE);
+    // U+180E is not escaped by the Hive path-escaping rules, so a modern JVM
+    // writes the raw code point into the directory name.
+    write_ids(&table_dir.join("dt=\u{180E}"), &[7]);
+
+    let mut context = SQLContext::new();
+    context.register_catalog("paimon", catalog).await.unwrap();
+
+    let got = ids(
+        &context,
+        &format!("SELECT id FROM paimon.{DATABASE}.{TABLE} WHERE dt = 
'\u{180E}'"),
+    )
+    .await;
+    assert_eq!(
+        got,
+        vec![7],
+        "an equality filter on a U+180E partition value must find the literal 
modern-JVM directory"
+    );
+}
diff --git a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs 
b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
index 78c2cdc5..12fabbfa 100644
--- a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
+++ b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
@@ -305,8 +305,12 @@ async fn test_partition_literals() {
     }
 }
 
-/// A blank string for a string partition column is written to the default 
partition, so ADD and
-/// DROP refuse it rather than address the NULL partition, as Java does.
+/// A value that folds to the default partition for a string column is written 
to the default
+/// partition, so ADD and DROP refuse it rather than address the NULL 
partition, as Java does.
+/// This covers Rust-blank strings and the control characters U+001C-U+001F, 
which Java's
+/// `Character.isWhitespace` folds but Rust `str::trim` keeps: the mutating 
guard must classify
+/// with the same Java-whitespace predicate the formatter folds by, or `DROP 
PARTITION
+/// (label = '<U+001C>')` would silently delete the default partition's 
registration and files.
 #[cfg(not(windows))]
 #[tokio::test]
 async fn test_partition_ddl_refuses_blank_string_values() {
@@ -326,6 +330,12 @@ async fn test_partition_ddl_refuses_blank_string_values() {
         "DROP IF EXISTS PARTITION (label = '   ')",
         "ADD PARTITION (label = '')",
         "ADD IF NOT EXISTS PARTITION (label = ' ')",
+        // Control characters that are Java whitespace but not Rust `str::trim`
+        // whitespace: a `trim().is_empty()` guard would let these address the
+        // default partition and (for DROP) destroy it.
+        "DROP PARTITION (label = '\u{001C}')",
+        "DROP IF EXISTS PARTITION (label = '\u{001F}')",
+        "ADD PARTITION (label = '\u{001E}')",
     ] {
         common::assert_sql_error(
             &context,
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 2c8be11b..e7b2a9af 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -104,6 +104,7 @@ pub use types::*;
 mod partition;
 pub use partition::Partition;
 mod partition_utils;
+pub use partition_utils::is_java_whitespace_only;
 pub(crate) use partition_utils::{
     bucket_path, bucket_path_under, data_file_path, escape_path_name, 
relative_bucket_path,
     unescape_path_name, PartitionComputer,
diff --git a/crates/paimon/src/spec/partition_utils.rs 
b/crates/paimon/src/spec/partition_utils.rs
index 7c3dda8c..d219d928 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -393,7 +393,12 @@ fn format_partition_value(
 
         DataType::Char(_) | DataType::VarChar(_) => {
             let s = row.get_string(pos)?;
-            if s.trim().is_empty() {
+            // Java folds a partition value to the default name when
+            // `StringUtils.isNullOrWhitespaceOnly` holds. Rust `str::trim` 
uses
+            // a different whitespace set (e.g. it trims NBSP / U+2007 / 
U+202F,
+            // which Java does not, and keeps U+001C-U+001F, which Java trims),
+            // so reuse the Java-matching predicate the Binary arm already 
uses.
+            if is_java_whitespace_only(s) {
                 return Ok(default_partition_name.to_string());
             }
             s.to_string()
@@ -519,14 +524,26 @@ fn decode_java_utf8(mut bytes: &[u8]) -> String {
 
 /// Java `StringUtils.isNullOrWhitespaceOnly` checks each UTF-16 code unit with
 /// `Character.isWhitespace`; its whitespace set differs from Rust `str::trim`.
-fn is_java_whitespace_only(value: &str) -> bool {
+///
+/// The set targets the supported modern JVMs (Java 11/17). The only code point
+/// whose `Character.isWhitespace` result is JVM-version dependent is U+180E
+/// (MONGOLIAN VOWEL SEPARATOR): it was a space separator under the Unicode 6.2
+/// bundled with JDK 8, but Unicode 6.3 reclassified it as a format character, 
so
+/// Java 9+ returns `false`. We follow the modern result and keep U+180E as a
+/// literal partition value — a `dt=<U+180E>` directory written by a modern 
JVM is
+/// then preserved and matched by an equality filter instead of being folded to
+/// the default partition.
+///
+/// Public so the DataFusion partition-DDL layer validates mutating partition
+/// values with the same classification that this crate folds by, matching Java
+/// `PaimonFormatTable.requireNameablePartitionValues`.
+pub fn is_java_whitespace_only(value: &str) -> bool {
     value.chars().all(|ch| {
         matches!(
             ch,
             '\u{0009}'..='\u{000D}'
                 | '\u{001C}'..='\u{0020}'
                 | '\u{1680}'
-                | '\u{180E}'
                 | '\u{2000}'..='\u{2006}'
                 | '\u{2008}'..='\u{200A}'
                 | '\u{2028}'..='\u{2029}'
@@ -1169,6 +1186,49 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_string_partition_whitespace_matches_java() {
+        // U+001C (file separator) is whitespace to Java's 
`Character.isWhitespace`
+        // but not to Rust `str::trim`; Java folds such a value to the default
+        // partition name, so we must too.
+        assert_single_partition(
+            "dt",
+            DataType::VarChar(VarCharType::default()),
+            |b| b.write_string(0, "\u{001C}"),
+            "dt=__DEFAULT_PARTITION__/",
+            true,
+        );
+
+        // A non-breaking space (U+00A0) is whitespace to Rust `str::trim` but 
not
+        // to Java, so Java keeps it as the partition value rather than 
folding it.
+        let fields = vec![make_field("dt", 
DataType::VarChar(VarCharType::default()))];
+        let keys = vec!["dt".to_string()];
+        let computer =
+            PartitionComputer::new(&keys, &fields, 
TEST_DEFAULT_PARTITION_NAME, true).unwrap();
+        let mut builder = TestRowBuilder::new(1);
+        builder.write_string(0, "\u{00A0}");
+        let row = builder.build();
+        let result = computer.generate_partition_path(&row).unwrap();
+        assert_ne!(
+            result, "dt=__DEFAULT_PARTITION__/",
+            "a non-breaking space must not fold to the default partition (Java 
keeps it)"
+        );
+
+        // U+180E is whitespace only on legacy JDK 8 (Unicode 6.2); a modern 
JVM
+        // (Java 11/17) treats it as a format character and keeps it as a 
literal
+        // partition value, so we must not fold it to the default partition.
+        let computer =
+            PartitionComputer::new(&keys, &fields, 
TEST_DEFAULT_PARTITION_NAME, true).unwrap();
+        let mut builder = TestRowBuilder::new(1);
+        builder.write_string(0, "\u{180E}");
+        let row = builder.build();
+        let result = computer.generate_partition_path(&row).unwrap();
+        assert_eq!(
+            result, "dt=\u{180E}/",
+            "U+180E must stay a literal partition value on modern JVMs"
+        );
+    }
+
     #[test]
     fn test_boolean_partition() {
         assert_single_partition(
@@ -1497,11 +1557,13 @@ mod tests {
 
     #[test]
     fn test_binary_partition_matches_java_whitespace() {
-        // JDK 8 Character.isWhitespace includes U+001C and U+180E, but not
-        // U+00A0 or U+2007. Rust str::trim differs for controls and NBSP.
+        // Modern JVMs (Java 11/17) treat U+001C as whitespace but U+180E as a
+        // format character, and never fold U+00A0 or U+2007. Rust str::trim
+        // differs for controls and NBSP, so we match Java explicitly.
         for (value, expected) in [
             (b"\x1c".as_slice(), "bin=__DEFAULT_PARTITION__/"),
-            ("\u{180E}".as_bytes(), "bin=__DEFAULT_PARTITION__/"),
+            // U+180E is whitespace only on legacy JDK 8; a modern JVM keeps 
it.
+            ("\u{180E}".as_bytes(), "bin=\u{180E}/"),
             ("\u{00A0}".as_bytes(), "bin=\u{00A0}/"),
             ("\u{2007}".as_bytes(), "bin=\u{2007}/"),
         ] {
diff --git a/crates/paimon/src/table/format_partition.rs 
b/crates/paimon/src/table/format_partition.rs
index 748c8b28..8b1879e8 100644
--- a/crates/paimon/src/table/format_partition.rs
+++ b/crates/paimon/src/table/format_partition.rs
@@ -23,7 +23,7 @@ use std::collections::HashMap;
 use chrono::NaiveDate;
 
 use crate::io::FileIO;
-use crate::spec::{escape_path_name, unescape_path_name, DataType, Datum};
+use crate::spec::{escape_path_name, is_java_whitespace_only, 
unescape_path_name, DataType, Datum};
 
 const UNIX_EPOCH_DAYS_FROM_CE: i32 = 719_163;
 
@@ -78,7 +78,13 @@ impl FormatTablePartitionPaths {
         }
         let mut segments = Vec::with_capacity(leading_values.len());
         for (key, value) in self.partition_keys.iter().zip(leading_values) {
-            if value.trim().is_empty() {
+            // A value that folds to the default partition name (Java
+            // `StringUtils.isNullOrWhitespaceOnly`) is not stored under its
+            // literal spelling, so a prefix pattern built from it would miss 
the
+            // data; skip pushdown and let the caller list every partition.
+            // Mirrors Java `buildPartitionNamePrefixPattern`, which returns 
null
+            // for such a value; `str::trim` folds a different whitespace set.
+            if is_java_whitespace_only(value) {
                 return None;
             }
             segments.push(format!(
@@ -309,7 +315,11 @@ pub fn format_partition_value(
         (Datum::Int(value), DataType::Int(_)) => Some(value.to_string()),
         (Datum::Long(value), DataType::BigInt(_)) => Some(value.to_string()),
         (Datum::String(value), DataType::Char(_) | DataType::VarChar(_)) => {
-            if value.trim().is_empty() {
+            // Fold to the default partition name exactly when Java
+            // `InternalRowPartitionComputer` does (`isNullOrWhitespaceOnly`), 
so
+            // the directory matches cross-engine; `str::trim` uses a different
+            // whitespace set (folds NBSP, keeps U+001C-U+001F).
+            if is_java_whitespace_only(value) {
                 Some(default_partition_name.to_string())
             } else {
                 Some(value.clone())
@@ -378,7 +388,7 @@ fn last_path_segment(path: &str) -> Option<&str> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::spec::{BooleanType, DateType};
+    use crate::spec::{BooleanType, DateType, VarCharType};
 
     #[test]
     fn test_parse_format_partition_value() {
@@ -524,4 +534,59 @@ mod tests {
             assert_eq!(is_storage_not_found(&error), expected);
         }
     }
+
+    #[test]
+    fn test_format_partition_value_folds_java_whitespace_only() {
+        let varchar = DataType::VarChar(VarCharType::string_type());
+        let default = "__DEFAULT_PARTITION__";
+
+        // U+00A0 (non-breaking space) is not `Character.isWhitespace` in 
Java, so
+        // it stays a real partition value. `str::trim` would wrongly fold it 
to
+        // the default and diverge from a Java-written directory.
+        assert_eq!(
+            format_partition_value(
+                &Datum::String("\u{00A0}".to_string()),
+                &varchar,
+                default,
+                false
+            ),
+            Some("\u{00A0}".to_string())
+        );
+
+        // U+001C (file separator) is `Character.isWhitespace` in Java, so it 
folds
+        // to the default. `str::trim` keeps it, which would produce a literal
+        // directory Java never writes.
+        assert_eq!(
+            format_partition_value(
+                &Datum::String("\u{001C}".to_string()),
+                &varchar,
+                default,
+                false
+            ),
+            Some(default.to_string())
+        );
+
+        // U+180E is whitespace only on legacy JDK 8 (Unicode 6.2); a modern 
JVM
+        // (Java 11/17) treats it as a format character and keeps it literal, 
so a
+        // `dt=<U+180E>` directory written by a modern JVM stays matchable.
+        assert_eq!(
+            format_partition_value(
+                &Datum::String("\u{180E}".to_string()),
+                &varchar,
+                default,
+                false
+            ),
+            Some("\u{180E}".to_string())
+        );
+
+        // An ASCII-blank value folds under both rules; a normal value is kept.
+        assert_eq!(
+            format_partition_value(&Datum::String("   ".to_string()), 
&varchar, default, false),
+            Some(default.to_string())
+        );
+        assert_eq!(
+            format_partition_value(&Datum::String("dt".to_string()), &varchar, 
default, false),
+            Some("dt".to_string())
+        );
+    }
 }

Reply via email to