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 ced0c86b fix(variant): sort object keys by UTF-8 bytes like Java (#789)
ced0c86b is described below

commit ced0c86b4db76265b5b2aedcbc9909a208f2c130
Author: jackylee <[email protected]>
AuthorDate: Sat Sep 5 07:11:31 2026 +0800

    fix(variant): sort object keys by UTF-8 bytes like Java (#789)
---
 crates/paimon/src/variant.rs | 65 +++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 64 insertions(+), 1 deletion(-)

diff --git a/crates/paimon/src/variant.rs b/crates/paimon/src/variant.rs
index 00f442e6..5f4ed890 100644
--- a/crates/paimon/src/variant.rs
+++ b/crates/paimon/src/variant.rs
@@ -1910,8 +1910,17 @@ fn decimal_to_plain_string(unscaled: i128, scale: i8, 
strip_trailing_zeros: bool
     result
 }
 
+/// Order two variant object keys the way Java does.
+///
+/// Java sorts them with `BinaryString.compareTo` (via
+/// `GenericVariantBuilder.FieldEntry.compareTo`), which compares the UTF-8 
bytes
+/// as unsigned. That is *not* `String.compareTo`'s UTF-16 code-unit order: the
+/// two disagree whenever a non-BMP key, whose surrogates are 
`0xD800..=0xDBFF`,
+/// meets a key in `U+E000..=U+FFFF`, because UTF-16 sorts the surrogate first
+/// while UTF-8 sorts the 4-byte sequence last. Rust `str` is already UTF-8, so
+/// comparing the bytes is the same comparison Java makes.
 fn java_string_cmp(left: &str, right: &str) -> std::cmp::Ordering {
-    left.encode_utf16().cmp(right.encode_utf16())
+    left.as_bytes().cmp(right.as_bytes())
 }
 
 #[derive(Clone, Copy, Debug)]
@@ -3350,6 +3359,60 @@ mod tests {
         assert!(variant.get_path("$.array[9]").unwrap().is_none());
     }
 
+    /// Java sorts variant object keys with `BinaryString.compareTo`, i.e. by
+    /// unsigned UTF-8 bytes. UTF-16 code-unit order disagrees whenever a 
non-BMP
+    /// key (a surrogate pair, code units `0xD800..=0xDBFF`) meets a key in
+    /// `U+E000..=U+FFFF`: UTF-16 puts the surrogate first, UTF-8 puts the 
4-byte
+    /// sequence last.
+    #[test]
+    fn object_keys_are_ordered_by_utf8_bytes_like_java() {
+        // U+FF21 is EF BC A1 in UTF-8 and FF21 in UTF-16.
+        let fullwidth_a = "\u{FF21}";
+        // U+1F600 is F0 9F 98 80 in UTF-8 and D83D DE00 in UTF-16.
+        let emoji = "\u{1F600}";
+        assert!(fullwidth_a.as_bytes() < emoji.as_bytes());
+        assert!(
+            emoji.encode_utf16().cmp(fullwidth_a.encode_utf16()) == 
std::cmp::Ordering::Less,
+            "the two orders must actually disagree for this fixture to prove 
anything"
+        );
+
+        assert_eq!(
+            java_string_cmp(fullwidth_a, emoji),
+            std::cmp::Ordering::Less,
+            "the comparator must follow UTF-8 bytes, not UTF-16 code units"
+        );
+
+        // The writer stores keys in that order, and `to_json` walks the 
object in
+        // stored order, so the emitted key sequence pins the on-disk layout.
+        let json = format!(r#"{{"{emoji}":2,"{fullwidth_a}":1}}"#);
+        let variant = GenericVariant::parse_json(&json).unwrap();
+        assert_eq!(
+            variant.to_json().unwrap(),
+            format!(r#"{{"{fullwidth_a}":1,"{emoji}":2}}"#)
+        );
+
+        // Both keys must still be reachable through the binary search, which 
uses
+        // the same comparator.
+        assert_eq!(
+            variant
+                .get_path(&format!("$.{fullwidth_a}"))
+                .unwrap()
+                .unwrap()
+                .to_json()
+                .unwrap(),
+            "1"
+        );
+        assert_eq!(
+            variant
+                .get_path(&format!("$.{emoji}"))
+                .unwrap()
+                .unwrap()
+                .to_json()
+                .unwrap(),
+            "2"
+        );
+    }
+
     #[test]
     fn parse_json_rejects_duplicate_object_keys() {
         let err = GenericVariant::parse_json(r#"{"a":1,"a":2}"#).unwrap_err();

Reply via email to