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

wgtmac pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/parquet-java.git


The following commit(s) were added to refs/heads/master by this push:
     new b98dd9bb4 GH-3735: Use unsigned UTF-8 byte order for Variant object 
field keys (#3746)
b98dd9bb4 is described below

commit b98dd9bb4d49beb933912cfde2525fe507b77a88
Author: Peter Lee <[email protected]>
AuthorDate: Sun Sep 20 13:53:29 2026 +0800

    GH-3735: Use unsigned UTF-8 byte order for Variant object field keys (#3746)
---
 .../java/org/apache/parquet/variant/Variant.java   |  53 ++++--
 .../org/apache/parquet/variant/VariantBuilder.java |   2 +-
 .../org/apache/parquet/variant/VariantUtil.java    |  43 +++++
 .../parquet/variant/TestVariantObjectBuilder.java  | 191 +++++++++++++++++++++
 4 files changed, 270 insertions(+), 19 deletions(-)

diff --git 
a/parquet-variant/src/main/java/org/apache/parquet/variant/Variant.java 
b/parquet-variant/src/main/java/org/apache/parquet/variant/Variant.java
index 3fdfc0060..d20e75eb0 100644
--- a/parquet-variant/src/main/java/org/apache/parquet/variant/Variant.java
+++ b/parquet-variant/src/main/java/org/apache/parquet/variant/Variant.java
@@ -273,24 +273,41 @@ public final class Variant {
         }
       }
     } else {
-      int low = 0;
-      int high = info.numElements - 1;
-      while (low <= high) {
-        // Use unsigned right shift to compute the middle of `low` and `high`. 
This is not only a
-        // performance optimization, because it can properly handle the case 
where `low + high`
-        // overflows int.
-        int mid = (low + high) >>> 1;
-        int midId = VariantUtil.readUnsignedLittleEndian(value, idStart + 
info.idSize * mid, info.idSize);
-        String midKey = getMetadataKeyCached(midId);
-        int cmp = midKey.compareTo(key);
-        if (cmp < 0) {
-          low = mid + 1;
-        } else if (cmp > 0) {
-          high = mid - 1;
-        } else {
-          int offset = VariantUtil.readUnsignedLittleEndian(
-              value, offsetStart + info.offsetSize * mid, info.offsetSize);
-          return childVariant(VariantUtil.slice(value, dataStart + offset));
+      // UTF-8 and UTF-16 order can only disagree at a code unit at or above 
U+D800. A lookup key
+      // without one compares identically under either order, so a single 
`String.compareTo`
+      // search navigates both spec-ordered and legacy UTF-16-ordered objects. 
A key that has one
+      // searches in the spec's UTF-8 byte order first, then retries in the 
UTF-16 order written
+      // by versions that sorted object fields with `String.compareTo`, so 
those objects remain
+      // readable.
+      boolean needsUtf8 = false;
+      for (int i = 0; i < key.length(); ++i) {
+        if (key.charAt(i) >= Character.MIN_SURROGATE) {
+          needsUtf8 = true;
+          break;
+        }
+      }
+      int maxAttempts = needsUtf8 ? 2 : 1;
+      for (int attempt = 0; attempt < maxAttempts; ++attempt) {
+        int low = 0;
+        int high = info.numElements - 1;
+        while (low <= high) {
+          // Use unsigned right shift to compute the middle of `low` and 
`high`. This is not only a
+          // performance optimization, because it can properly handle the case 
where `low + high`
+          // overflows int.
+          int mid = (low + high) >>> 1;
+          int midId = VariantUtil.readUnsignedLittleEndian(value, idStart + 
info.idSize * mid, info.idSize);
+          String midKey = getMetadataKeyCached(midId);
+          int cmp =
+              (needsUtf8 && attempt == 0) ? VariantUtil.compareKeys(midKey, 
key) : midKey.compareTo(key);
+          if (cmp < 0) {
+            low = mid + 1;
+          } else if (cmp > 0) {
+            high = mid - 1;
+          } else {
+            int offset = VariantUtil.readUnsignedLittleEndian(
+                value, offsetStart + info.offsetSize * mid, info.offsetSize);
+            return childVariant(VariantUtil.slice(value, dataStart + offset));
+          }
         }
       }
     }
diff --git 
a/parquet-variant/src/main/java/org/apache/parquet/variant/VariantBuilder.java 
b/parquet-variant/src/main/java/org/apache/parquet/variant/VariantBuilder.java
index c692d3119..61ee7782c 100644
--- 
a/parquet-variant/src/main/java/org/apache/parquet/variant/VariantBuilder.java
+++ 
b/parquet-variant/src/main/java/org/apache/parquet/variant/VariantBuilder.java
@@ -691,7 +691,7 @@ public class VariantBuilder {
 
     @Override
     public int compareTo(FieldEntry other) {
-      return key.compareTo(other.key);
+      return VariantUtil.compareKeys(key, other.key);
     }
   }
 
diff --git 
a/parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java 
b/parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java
index ad7165fcf..97e497a17 100644
--- a/parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java
+++ b/parquet-variant/src/main/java/org/apache/parquet/variant/VariantUtil.java
@@ -301,6 +301,49 @@ class VariantUtil {
     return result;
   }
 
+  /**
+   * Compares two object field names by the unsigned lexicographic byte order 
of their UTF-8
+   * encodings, as required by the Variant spec for object field ordering, 
without encoding
+   * either name. UTF-8 byte order is exactly code point order, so this 
compares the strings'
+   * code points via {@link #codePointOrderRank}.
+   *
+   * <p>This intentionally differs from {@link String#compareTo}, which 
compares UTF-16 code
+   * units. The two orderings agree for all names in the Basic Multilingual 
Plane but diverge for
+   * supplementary-plane characters (U+10000 and above): {@code 
String#compareTo} orders a leading
+   * high surrogate (0xD800-0xDBFF) before code points in U+E000..U+FFFF, 
whereas UTF-8 byte order
+   * (and the spec) orders them after. Using UTF-16 order here would produce 
objects whose field
+   * ids are mis-sorted relative to the spec, breaking binary-search lookups 
by any reader that
+   * follows the spec's UTF-8 byte ordering.
+   *
+   * <p>An unpaired surrogate has no UTF-8 encoding, and Java's encoder 
substitutes {@code ?} for
+   * one, so a name containing one is ordered by the surrogate itself rather 
than by the bytes
+   * that would be written for it.
+   */
+  static int compareKeys(String a, String b) {
+    int limit = Math.min(a.length(), b.length());
+    for (int i = 0; i < limit; ++i) {
+      char left = a.charAt(i);
+      char right = b.charAt(i);
+      if (left != right) {
+        return codePointOrderRank(left) - codePointOrderRank(right);
+      }
+    }
+    // All shared code units are equal, so the shorter name is a prefix of the 
longer one.
+    return a.length() - b.length();
+  }
+
+  /**
+   * Maps a UTF-16 code unit to a value ordered like the code point it 
encodes. A surrogate always
+   * encodes a supplementary code point (U+10000 and above), so U+D800..U+DFFF 
must rank above
+   * every other code unit; U+E000..U+FFFF shift down to fill the gap they 
leave behind.
+   */
+  private static int codePointOrderRank(char unit) {
+    if (unit < Character.MIN_SURROGATE) {
+      return unit;
+    }
+    return unit <= Character.MAX_SURROGATE ? unit + 0x2000 : unit - 0x800;
+  }
+
   /**
    * Fast little-endian unsigned read using bulk ByteBuffer operations.
    * Requires the buffer to have {@link java.nio.ByteOrder#LITTLE_ENDIAN} byte 
order.
diff --git 
a/parquet-variant/src/test/java/org/apache/parquet/variant/TestVariantObjectBuilder.java
 
b/parquet-variant/src/test/java/org/apache/parquet/variant/TestVariantObjectBuilder.java
index d739fdba1..69539142c 100644
--- 
a/parquet-variant/src/test/java/org/apache/parquet/variant/TestVariantObjectBuilder.java
+++ 
b/parquet-variant/src/test/java/org/apache/parquet/variant/TestVariantObjectBuilder.java
@@ -23,6 +23,11 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
 import java.util.UUID;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
@@ -85,6 +90,192 @@ public class TestVariantObjectBuilder {
     });
   }
 
+  /**
+   * Object field keys must be ordered by the unsigned byte order of their 
UTF-8 encoding, not by
+   * {@link String#compareTo} (UTF-16 code-unit order). The two orderings 
disagree for
+   * supplementary-plane keys: U+FFFF encodes to UTF-8 {@code EF BF BF} and 
U+10000 to
+   * {@code F0 90 80 80}, so U+FFFF must sort first; but in UTF-16 the leading 
high surrogate
+   * 0xD800 of U+10000 sorts before 0xFFFF, which would wrongly put U+10000 
first. See
+   * {@link VariantUtil#compareKeys}.
+   */
+  @Test
+  public void testObjectKeysSortedByUtf8ByteOrder() {
+    String bmpKey = "�"; // U+FFFF -> UTF-8 EF BF BF
+    String supplementaryKey = new String(Character.toChars(0x10000)); // UTF-8 
F0 90 80 80
+
+    VariantBuilder b = new VariantBuilder();
+    VariantObjectBuilder o = b.startObject();
+    // Appended in the "wrong" order on purpose, to prove the builder sorts 
rather than
+    // preserving insertion order.
+    o.appendKey(supplementaryKey);
+    o.appendLong(2);
+    o.appendKey(bmpKey);
+    o.appendLong(1);
+    b.endObject();
+
+    VariantTestUtil.testVariant(b.build(), v -> {
+      VariantTestUtil.checkType(v, VariantUtil.OBJECT, Variant.Type.OBJECT);
+      assertThat(v.numObjectElements()).isEqualTo(2);
+      // UTF-8 byte order: EF BF BF < F0 90 80 80, so the BMP key comes first.
+      assertThat(v.getFieldAtIndex(0).key).isEqualTo(bmpKey);
+      assertThat(v.getFieldAtIndex(1).key).isEqualTo(supplementaryKey);
+      assertThat(v.getFieldByKey(bmpKey).getLong()).isEqualTo(1);
+      assertThat(v.getFieldByKey(supplementaryKey).getLong()).isEqualTo(2);
+    });
+  }
+
+  /**
+   * A large object (>= BINARY_SEARCH_THRESHOLD) that mixes ASCII keys with 
U+FFFF and a
+   * supplementary-plane key, exercising the reader's binary-search path in
+   * {@link Variant#getFieldByKey}. The binary search must use the same UTF-8 
byte ordering as the
+   * builder's sort; with a UTF-16 comparator on the read side, the 
supplementary key would be
+   * mis-navigated and not found.
+   */
+  @Test
+  public void testLargeObjectBinarySearchWithSupplementaryKey() {
+    String bmpKey = "�"; // UTF-8 EF BF BF
+    String supplementaryKey = new String(Character.toChars(0x10000)); // UTF-8 
F0 90 80 80
+
+    VariantBuilder b = new VariantBuilder();
+    VariantObjectBuilder o = b.startObject();
+    for (int i = 0; i < 40; i++) { // well above BINARY_SEARCH_THRESHOLD (32)
+      o.appendKey(String.format("a%03d", i));
+      o.appendLong(i);
+    }
+    o.appendKey(bmpKey);
+    o.appendLong(998);
+    o.appendKey(supplementaryKey);
+    o.appendLong(999);
+    b.endObject();
+
+    VariantTestUtil.testVariant(b.build(), v -> {
+      assertThat(v.numObjectElements()).isEqualTo(42);
+      assertThat(v.getFieldByKey(bmpKey)).isNotNull();
+      assertThat(v.getFieldByKey(bmpKey).getLong()).isEqualTo(998);
+      assertThat(v.getFieldByKey(supplementaryKey)).isNotNull();
+      assertThat(v.getFieldByKey(supplementaryKey).getLong()).isEqualTo(999);
+      assertThat(v.getFieldByKey("a037").getLong()).isEqualTo(37);
+    });
+  }
+
+  /**
+   * Objects written before the ordering fix sorted field ids by {@link 
String#compareTo} (UTF-16
+   * order). {@link Variant#getFieldByKey} must still find keys in such 
objects: when a key
+   * contains a code unit at or above U+D800, the lookup retries the binary 
search in UTF-16 order
+   * after the spec's UTF-8 order fails.
+   */
+  @Test
+  public void testLegacyUtf16OrderedObjectLookup() {
+    String bmpKey = "�"; // UTF-8 EF BF BF
+    String supplementaryKey = new String(Character.toChars(0x10000)); // UTF-8 
F0 90 80 80
+
+    VariantBuilder b = new VariantBuilder();
+    VariantObjectBuilder o = b.startObject();
+    for (int i = 0; i < 40; i++) {
+      o.appendKey(String.format("a%03d", i));
+      o.appendLong(i);
+    }
+    o.appendKey(bmpKey);
+    o.appendLong(998);
+    o.appendKey(supplementaryKey);
+    o.appendLong(999);
+    b.endObject();
+    Variant canonical = b.build();
+
+    // Reproduce the layout written by older versions: swap the id and offset 
entries of the last
+    // two fields, so the supplementary key precedes the BMP key (UTF-16 
order).
+    ByteBuffer valueBuffer = canonical.getValueBuffer().duplicate();
+    byte[] legacyValue = new byte[valueBuffer.remaining()];
+    valueBuffer.get(legacyValue);
+    VariantUtil.ObjectInfo info =
+        
VariantUtil.getObjectInfo(ByteBuffer.wrap(legacyValue).order(ByteOrder.LITTLE_ENDIAN));
+    swapLastTwoEntries(legacyValue, info.idStartOffset, info.idSize, 
info.numElements);
+    swapLastTwoEntries(legacyValue, info.offsetStartOffset, info.offsetSize, 
info.numElements);
+    Variant legacy = new Variant(ByteBuffer.wrap(legacyValue), 
canonical.getMetadataBuffer());
+
+    assertThat(legacy.getFieldAtIndex(40).key).isEqualTo(supplementaryKey);
+    assertThat(legacy.getFieldAtIndex(41).key).isEqualTo(bmpKey);
+    // ASCII keys are found by the first (UTF-8 order) search.
+    assertThat(legacy.getFieldByKey("a037").getLong()).isEqualTo(37);
+    // Keys at or above U+D800 are found by the UTF-16 order retry.
+    assertThat(legacy.getFieldByKey(bmpKey).getLong()).isEqualTo(998);
+    
assertThat(legacy.getFieldByKey(supplementaryKey).getLong()).isEqualTo(999);
+    // Absent keys stay absent after both attempts.
+    assertThat(legacy.getFieldByKey("missing")).isNull();
+    assertThat(legacy.getFieldByKey(new 
String(Character.toChars(0x10001)))).isNull();
+  }
+
+  /**
+   * {@link VariantUtil#compareKeys} orders field names as their UTF-8 
encodings compare as
+   * unsigned bytes, but reaches that order from the UTF-16 code units without 
encoding either
+   * name. Check it against encoding both and comparing the bytes, over names 
that cover every
+   * UTF-8 length, both sides of the surrogate range, and prefixes.
+   */
+  @Test
+  public void testCompareKeysMatchesUtf8ByteOrder() {
+    List<String> keys = new ArrayList<>(Arrays.asList(
+        "",
+        "a",
+        "ab",
+        "b",
+        "A",
+        "~",
+        "\u007f", // last 1-byte UTF-8
+        "\u0080", // first 2-byte UTF-8
+        "\u00e9",
+        "\u07ff", // last 2-byte UTF-8
+        "\u0800", // first 3-byte UTF-8
+        "\ud7ff", // last code unit below the surrogate range
+        "\ue000", // first code unit above the surrogate range
+        "\uffff", // EF BF BF
+        new String(Character.toChars(0x10000)), // F0 90 80 80, first 4-byte 
UTF-8
+        new String(Character.toChars(0x10ffff)), // F4 8F BF BF, last code 
point
+        "a\uffff",
+        "a" + new String(Character.toChars(0x10000)),
+        new String(Character.toChars(0x10000)) + "a"));
+    // Random names, to cover pairs the hand-picked ones miss.
+    Random random = new Random(2891);
+    for (int i = 0; i < 200; i++) {
+      StringBuilder key = new StringBuilder();
+      for (int c = 0; c < 1 + random.nextInt(3); c++) {
+        // Draw from ASCII, the BMP around the surrogate range, and the 
supplementary planes.
+        switch (random.nextInt(3)) {
+          case 0:
+            key.append((char) ('a' + random.nextInt(3)));
+            break;
+          case 1:
+            // Valid code units either side of the surrogate range, which has 
no UTF-8 encoding.
+            int offset = random.nextInt(6);
+            key.append((char) (offset < 3 ? 0xd7fd + offset : 0xe000 + offset 
- 3));
+            break;
+          default:
+            key.appendCodePoint(0x10000 + random.nextInt(4));
+        }
+      }
+      keys.add(key.toString());
+    }
+
+    for (String left : keys) {
+      for (String right : keys) {
+        int expected = Arrays.compareUnsigned(
+            left.getBytes(StandardCharsets.UTF_8), 
right.getBytes(StandardCharsets.UTF_8));
+        assertThat(Integer.signum(VariantUtil.compareKeys(left, right)))
+            .as("comparing %s against %s", left, right)
+            .isEqualTo(Integer.signum(expected));
+      }
+    }
+  }
+
+  private static void swapLastTwoEntries(byte[] bytes, int start, int width, 
int numElements) {
+    int left = start + (numElements - 2) * width;
+    int right = left + width;
+    ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+    int leftValue = VariantUtil.readUnsignedLittleEndian(buffer, left, width);
+    int rightValue = VariantUtil.readUnsignedLittleEndian(buffer, right, 
width);
+    VariantUtil.writeLong(bytes, left, rightValue, width);
+    VariantUtil.writeLong(bytes, right, leftValue, width);
+  }
+
   @Test
   public void testMixedObjectBuilder() {
     VariantBuilder b = new VariantBuilder();

Reply via email to