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 d3971a7a feat(core): support Java scalar partition values in native 
table IO (#1083)
d3971a7a is described below

commit d3971a7a087fa27dd526afb0cc5836c2f52883d7
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 16:00:32 2026 +0800

    feat(core): support Java scalar partition values in native table IO (#1083)
---
 Cargo.lock                                |   1 +
 crates/paimon/Cargo.toml                  |   2 +
 crates/paimon/src/spec/partition_utils.rs | 158 ++++++++++++++++++++++++----
 crates/paimon/src/table/table_commit.rs   | 164 +++++++++++++++++++++---------
 4 files changed, 257 insertions(+), 68 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 30b369e4..69911e8b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4708,6 +4708,7 @@ dependencies = [
  "regex",
  "reqwest 0.12.28",
  "roaring",
+ "ryu",
  "serde",
  "serde_bytes",
  "serde_json",
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 72eee590..f9aaffa8 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -109,6 +109,8 @@ roaring = "0.11"
 datasketches = "0.2"
 num-bigint = "0.4"
 num-traits = "0.2"
+# Already in the Arrow dependency tree; shortest decimals use ties-to-even.
+ryu = "1"
 crc32fast = "1"
 zstd = "0.13"
 snap = "1"
diff --git a/crates/paimon/src/spec/partition_utils.rs 
b/crates/paimon/src/spec/partition_utils.rs
index d219d928..b0466eaa 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -390,6 +390,8 @@ fn format_partition_value(
         DataType::SmallInt(_) => row.get_short(pos)?.to_string(),
         DataType::Int(_) => row.get_int(pos)?.to_string(),
         DataType::BigInt(_) => row.get_long(pos)?.to_string(),
+        DataType::Float(_) => format_java_float(row.get_float(pos)?),
+        DataType::Double(_) => format_java_float(row.get_double(pos)?),
 
         DataType::Char(_) | DataType::VarChar(_) => {
             let s = row.get_string(pos)?;
@@ -463,12 +465,7 @@ fn format_partition_value(
             }
         }
 
-        // Float/Double: Rust f32/f64 Display differs from Java 
Float/Double.toString()
-        // on edge-case values. This could silently produce wrong partition 
paths.
-        // Since float partition keys are extremely rare, reject them 
explicitly.
-        DataType::Float(_)
-        | DataType::Double(_)
-        | DataType::Binary(_)
+        DataType::Binary(_)
         | DataType::VarBinary(_)
         | DataType::Variant(_)
         | DataType::Blob(_)
@@ -486,6 +483,90 @@ fn format_partition_value(
     Ok(value)
 }
 
+/// Java's shortest-decimal string contract (JDK 19+), including its notation
+/// thresholds and closest choice among one- and two-digit decimals. Rust's
+/// shortest Display chooses different digits at ties; Ryƫ uses ties-to-even.
+fn format_java_float<T: ryu::Float + std::fmt::LowerExp + Copy + 
Into<f64>>(value: T) -> String {
+    let number = value.into();
+    if number.is_nan() {
+        return "NaN".into();
+    }
+    if number.is_infinite() {
+        return if number.is_sign_negative() {
+            "-Infinity"
+        } else {
+            "Infinity"
+        }
+        .into();
+    }
+    if number == 0.0 {
+        return if number.is_sign_negative() {
+            "-0.0"
+        } else {
+            "0.0"
+        }
+        .into();
+    }
+
+    let mut buffer = ryu::Buffer::new();
+    let (mut negative, mut digits, mut exponent) = 
decimal_parts(buffer.format_finite(value));
+    if digits.len() == 1 {
+        // Padding the shortest digit with zero would miss the closer two-digit
+        // form of small subnormals, e.g. Double.MIN_VALUE -> 4.9E-324.
+        (negative, digits, exponent) = decimal_parts(&format!("{value:.1e}"));
+    }
+    let mut result = if negative {
+        "-".to_string()
+    } else {
+        String::new()
+    };
+    if (-3..7).contains(&exponent) {
+        let point = exponent + 1;
+        if point <= 0 {
+            result.push_str("0.");
+            result.push_str(&"0".repeat((-point) as usize));
+            result.push_str(&digits);
+        } else if point as usize >= digits.len() {
+            result.push_str(&digits);
+            result.push_str(&"0".repeat(point as usize - digits.len()));
+            result.push_str(".0");
+        } else {
+            result.push_str(&digits[..point as usize]);
+            result.push('.');
+            result.push_str(&digits[point as usize..]);
+        }
+    } else {
+        result.push_str(&digits[..1]);
+        result.push('.');
+        result.push_str(if digits.len() > 1 { &digits[1..] } else { "0" });
+        result.push('E');
+        result.push_str(&exponent.to_string());
+    }
+    result
+}
+
+fn decimal_parts(text: &str) -> (bool, String, i32) {
+    let (mantissa, exponent) = text
+        .split_once('e')
+        .map_or((text, 0), |(mantissa, exponent)| {
+            (mantissa, exponent.parse::<i32>().expect("decimal exponent"))
+        });
+    let negative = mantissa.starts_with('-');
+    let mantissa = mantissa.trim_start_matches('-');
+    let point = mantissa.find('.').unwrap_or(mantissa.len()) as i32;
+    let digits = mantissa.replace('.', "");
+    let first = digits
+        .bytes()
+        .position(|byte| byte != b'0')
+        .expect("nonzero decimal");
+    let exponent = exponent + point - first as i32 - 1;
+    let mut digits = digits[first..].to_string();
+    while digits.len() > 1 && digits.ends_with('0') {
+        digits.pop();
+    }
+    (negative, digits, exponent)
+}
+
 /// Decode as Java's UTF-8 decoder does for `BinaryString.toString()`.
 /// Rust's lossy decoder replaces each byte of a UTF-8 encoded surrogate,
 /// while Java replaces the complete malformed surrogate sequence once.
@@ -1514,20 +1595,57 @@ mod tests {
             |b| b.write_int(0, 0),
             true,
         );
-        // Float
-        assert_single_partition_err(
-            "f",
-            DataType::Float(FloatType::new()),
-            |b| b.write_int(0, 0),
-            true,
-        );
-        // Double
-        assert_single_partition_err(
-            "d",
-            DataType::Double(DoubleType::new()),
-            |b| b.write_int(0, 0),
-            true,
-        );
+    }
+
+    #[test]
+    fn test_floating_partition_names_match_java() {
+        // Float.toString / Double.toString on JDK 21. Include decimal / 
exponent
+        // boundaries and shortest-decimal ties where Rust Display differs.
+        for legacy in [false, true] {
+            for (value, expected) in [
+                (1.5_f32, "1.5"),
+                (42.0, "42.0"),
+                (0.001, "0.001"),
+                (0.0001, "1.0E-4"),
+                (1e7, "1.0E7"),
+                (f32::from_bits(3388994202), "-2096723.2"),
+                (f32::from_bits(1208999304), "147318.12"),
+                (f32::MIN_POSITIVE, "1.1754944E-38"),
+                (f32::from_bits(1), "1.4E-45"),
+                (f32::MAX, "3.4028235E38"),
+            ] {
+                assert_single_partition(
+                    "f",
+                    DataType::Float(FloatType::new()),
+                    |b| b.write_int(0, value.to_bits() as i32),
+                    &format!("f={expected}/"),
+                    legacy,
+                );
+            }
+            for (value, expected) in [
+                (2.25_f64, "2.25"),
+                (-42.0, "-42.0"),
+                (0.001, "0.001"),
+                (0.0001, "1.0E-4"),
+                (1e7, "1.0E7"),
+                (1e23, "1.0E23"),
+                (
+                    f64::from_bits(14053136104817660578),
+                    "-8.011083811354442E14",
+                ),
+                (f64::MIN_POSITIVE, "2.2250738585072014E-308"),
+                (f64::from_bits(1), "4.9E-324"),
+                (f64::MAX, "1.7976931348623157E308"),
+            ] {
+                assert_single_partition(
+                    "d",
+                    DataType::Double(DoubleType::new()),
+                    |b| b.write_long(0, value.to_bits() as i64),
+                    &format!("d={expected}/"),
+                    legacy,
+                );
+            }
+        }
     }
 
     #[test]
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 2b65486b..c508694d 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -3170,23 +3170,18 @@ impl TableCommit {
 
     /// Generate per-partition statistics from commit entries.
     ///
-    /// Reference: [pypaimon 
FileStoreCommit._generate_partition_statistics](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/write/file_store_commit.py)
+    /// Java PartitionEntry.toPartitionStatistics uses the same computer as 
paths.
     fn generate_partition_statistics(
         &self,
         entries: &[ManifestEntry],
     ) -> Result<Vec<PartitionStatistics>> {
-        let partition_fields = self.table.schema().partition_fields();
-        let data_types: Vec<_> = partition_fields
-            .iter()
-            .map(|f| f.data_type().clone())
-            .collect();
-        let partition_keys: Vec<_> = self
-            .table
-            .schema()
-            .partition_keys()
-            .iter()
-            .map(|s| s.to_string())
-            .collect();
+        let options = self.table.schema().core_options();
+        let computer = PartitionComputer::new(
+            self.table.schema().partition_keys(),
+            self.table.schema().fields(),
+            options.partition_default_name(),
+            options.legacy_partition_name(),
+        )?;
 
         let mut stats_map: HashMap<Vec<u8>, PartitionStatistics> = 
HashMap::new();
 
@@ -3201,20 +3196,27 @@ impl TableCommit {
                 .map(|t| t.timestamp_millis())
                 .unwrap_or_else(|| current_time_millis() as i64);
 
-            let stats = 
stats_map.entry(partition_bytes.clone()).or_insert_with(|| {
-                // Parse partition spec from BinaryRow
-                let spec = self
-                    .parse_partition_spec(&partition_bytes, &partition_keys, 
&data_types)
-                    .unwrap_or_default();
-                PartitionStatistics {
-                    spec,
-                    record_count: 0,
-                    file_size_in_bytes: 0,
-                    file_count: 0,
-                    last_file_creation_time: 0,
-                    total_buckets: entry.total_buckets(),
+            let stats = match stats_map.entry(partition_bytes) {
+                std::collections::hash_map::Entry::Occupied(entry) => 
entry.into_mut(),
+                std::collections::hash_map::Entry::Vacant(vacant) => {
+                    let spec = if 
self.table.schema().partition_keys().is_empty() {
+                        HashMap::new()
+                    } else {
+                        computer
+                            
.generate_part_values(&BinaryRow::from_serialized_bytes(vacant.key())?)?
+                            .into_iter()
+                            .collect()
+                    };
+                    vacant.insert(PartitionStatistics {
+                        spec,
+                        record_count: 0,
+                        file_size_in_bytes: 0,
+                        file_count: 0,
+                        last_file_creation_time: 0,
+                        total_buckets: entry.total_buckets(),
+                    })
                 }
-            });
+            };
 
             stats.record_count += sign * file.row_count;
             stats.file_size_in_bytes += sign * file.file_size;
@@ -3227,28 +3229,6 @@ impl TableCommit {
         Ok(stats_map.into_values().collect())
     }
 
-    /// Parse partition BinaryRow bytes into a HashMap<String, String>.
-    fn parse_partition_spec(
-        &self,
-        partition_bytes: &[u8],
-        partition_keys: &[String],
-        data_types: &[crate::spec::DataType],
-    ) -> Result<HashMap<String, String>> {
-        let mut spec = HashMap::new();
-        if partition_bytes.is_empty() || partition_keys.is_empty() {
-            return Ok(spec);
-        }
-        let row = BinaryRow::from_serialized_bytes(partition_bytes)?;
-        for (i, key) in partition_keys.iter().enumerate() {
-            let value = match extract_datum(&row, i, &data_types[i])? {
-                Some(datum) => datum.to_string(),
-                None => self.partition_default_name.clone(),
-            };
-            spec.insert(key.clone(), value);
-        }
-        Ok(spec)
-    }
-
     /// Check conflicts from the earliest writer snapshot, validating row-id 
baselines.
     fn check_from_snapshot(messages: &[CommitMessage]) -> Result<Option<i64>> {
         let mut check_from_snapshot: Option<i64> = None;
@@ -3973,6 +3953,94 @@ mod tests {
         TableCommit::new(table, "test-user".to_string())
     }
 
+    #[test]
+    fn partition_statistics_use_java_partition_values() {
+        use crate::spec::{DateType, DecimalType, Schema, VarBinaryType, 
VarCharType};
+
+        for legacy in [false, true] {
+            let schema = Schema::builder()
+                .column("text", DataType::VarChar(VarCharType::string_type()))
+                .column("day", DataType::Date(DateType::new()))
+                .column("amount", DataType::Decimal(DecimalType::new(8, 
2).unwrap()))
+                .partition_keys(["text", "day", "amount"])
+                .option("partition.legacy-name", legacy.to_string())
+                .build()
+                .unwrap();
+            let table = Table::new(
+                test_file_io(),
+                Identifier::new("default", "stats"),
+                "memory:/partition-stat-values".into(),
+                TableSchema::new(0, &schema),
+                None,
+            );
+            let commit = TableCommit::new(table, "user".into());
+            let mut row = BinaryRowBuilder::new(3);
+            row.write_string(0, "a/b");
+            row.write_int(1, 1);
+            row.write_long(2, 1234);
+            let entries = vec![ManifestEntry::new(
+                FileKind::Add,
+                row.build().to_serialized_bytes(),
+                0,
+                1,
+                test_data_file("data.parquet", 5),
+                2,
+            )];
+            let stats = 
commit.generate_partition_statistics(&entries).unwrap();
+            assert_eq!(
+                stats[0].spec,
+                HashMap::from([
+                    ("text".into(), "a/b".into()),
+                    ("day".into(), if legacy { "1" } else { "1970-01-02" 
}.into()),
+                    ("amount".into(), "12.34".into()),
+                ])
+            );
+            assert_eq!(stats[0].record_count, 5);
+        }
+
+        let schema = Schema::builder()
+            .column(
+                "bytes",
+                DataType::VarBinary(VarBinaryType::new(20).unwrap()),
+            )
+            .partition_keys(["bytes"])
+            .option("partition.legacy-name", "false")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            test_file_io(),
+            Identifier::new("default", "binary"),
+            "memory:/binary-partition-stats".into(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let commit = TableCommit::new(table, "user".into());
+        for (value, expected) in [
+            (Some(b"a/b".as_slice()), "a/b"),
+            (Some(b" \t".as_slice()), "__DEFAULT_PARTITION__"),
+            (None, "__DEFAULT_PARTITION__"),
+        ] {
+            let mut row = BinaryRowBuilder::new(1);
+            if let Some(value) = value {
+                row.write_binary(0, value);
+            } else {
+                row.set_null_at(0);
+            }
+            let entries = vec![ManifestEntry::new(
+                FileKind::Add,
+                row.build().to_serialized_bytes(),
+                0,
+                1,
+                test_data_file("data.parquet", 5),
+                2,
+            )];
+            assert_eq!(
+                
commit.generate_partition_statistics(&entries).unwrap()[0].spec["bytes"],
+                expected
+            );
+        }
+    }
+
     fn partition_filter_for(commit: &TableCommit, partitions: Vec<Vec<u8>>) -> 
PartitionFilter {
         PartitionFilter::from_partition_set(
             partitions.into_iter().collect(),

Reply via email to