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

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


The following commit(s) were added to refs/heads/master by this push:
     new 3f0eceaedb [common] Order decimal z-values through the signed long 
transform (#9626)
3f0eceaedb is described below

commit 3f0eceaedbe5c9ce3a6b80edd4542ae675e739bf
Author: YangJie <[email protected]>
AuthorDate: Sat Sep 12 09:31:57 2026 -0400

    [common] Order decimal z-values through the signed long transform (#9626)
---
 .../org/apache/paimon/sort/zorder/ZIndexer.java    |  70 +++++++++---
 .../paimon/sort/zorder/TestZOrderByteUtil.java     | 124 +++++++++++++++++++++
 2 files changed, 177 insertions(+), 17 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/sort/zorder/ZIndexer.java 
b/paimon-common/src/main/java/org/apache/paimon/sort/zorder/ZIndexer.java
index 1f21811ed3..ec20e4fe31 100644
--- a/paimon-common/src/main/java/org/apache/paimon/sort/zorder/ZIndexer.java
+++ b/paimon-common/src/main/java/org/apache/paimon/sort/zorder/ZIndexer.java
@@ -51,6 +51,7 @@ import org.apache.paimon.types.VariantType;
 import org.apache.paimon.types.VectorType;
 
 import java.io.Serializable;
+import java.math.BigInteger;
 import java.nio.ByteBuffer;
 import java.util.Arrays;
 import java.util.LinkedHashSet;
@@ -76,7 +77,7 @@ public class ZIndexer implements Serializable {
     public ZIndexer(RowType rowType, List<String> orderColumns, int 
varTypeSize) {
         List<String> fields = rowType.getFieldNames();
         fieldsIndex = new int[orderColumns.size()];
-        int varTypeCount = 0;
+        int total = 0;
         for (int i = 0; i < fieldsIndex.length; i++) {
             int index = fields.indexOf(orderColumns.get(i));
             if (index == -1) {
@@ -87,15 +88,10 @@ public class ZIndexer implements Serializable {
                                 + fields);
             }
             fieldsIndex[i] = index;
-
-            if (isVarType(rowType.getFieldTypes().get(index))) {
-                varTypeCount++;
-            }
+            total += zBytes(rowType.getFieldTypes().get(index), varTypeSize);
         }
         this.functionSet = constructFunctionMap(rowType.getFields(), 
varTypeSize);
-        this.totalBytes =
-                PRIMITIVE_BUFFER_SIZE * (this.fieldsIndex.length - 
varTypeCount)
-                        + varTypeSize * varTypeCount;
+        this.totalBytes = total;
     }
 
     private static boolean isVarType(DataType dataType) {
@@ -105,6 +101,26 @@ public class ZIndexer implements Serializable {
                 || dataType instanceof VarBinaryType;
     }
 
+    /**
+     * Bytes the z-value reserves for a column. A DECIMAL gets a width that 
holds any unscaled value
+     * of its precision, so it keeps full ordering resolution; 8 bytes cannot, 
and projecting into
+     * them collapses ordinary wide-decimal values to one key.
+     */
+    private static int zBytes(DataType type, int varTypeSize) {
+        if (isVarType(type)) {
+            return varTypeSize;
+        }
+        if (type instanceof DecimalType) {
+            return decimalBytes(((DecimalType) type).getPrecision());
+        }
+        return PRIMITIVE_BUFFER_SIZE;
+    }
+
+    /** Two's-complement byte width that holds any unscaled value with {@code 
precision} digits. */
+    private static int decimalBytes(int precision) {
+        return BigInteger.TEN.pow(precision).bitLength() / 8 + 1;
+    }
+
     public void open() {
         this.reuse = ByteBuffer.allocate(totalBytes);
         functionSet.forEach(RowProcessor::open);
@@ -138,8 +154,7 @@ public class ZIndexer implements Serializable {
     public static RowProcessor zmapColumnToCalculator(DataField field, int 
index, int varTypeSize) {
         DataType type = field.type();
         return new RowProcessor(
-                type.accept(new TypeVisitor(index, varTypeSize)),
-                isVarType(type) ? varTypeSize : PRIMITIVE_BUFFER_SIZE);
+                type.accept(new TypeVisitor(index, varTypeSize)), zBytes(type, 
varTypeSize));
     }
 
     /** Type Visitor to generate function map from row column to z-index. */
@@ -239,18 +254,39 @@ public class ZIndexer implements Serializable {
         public ZProcessFunction visit(DecimalType decimalType) {
             final InternalRow.FieldGetter fieldGetter =
                     InternalRow.createFieldGetter(decimalType, fieldIndex);
+            // Encode the unscaled value as a fixed-width, sign-flipped 
big-endian two's complement.
+            // The width holds any value of this precision, so the encoding is 
order-preserving
+            // under the unsigned comparison a z-value gets and keeps full 
resolution: small
+            // separated values, their negatives, and values past the long 
range all get distinct
+            // keys. An 8-byte projection cannot, since one wide-decimal 
column exceeds 64 bits and
+            // every value below the shift collapses to a single key.
+            final int width = decimalBytes(decimalType.getPrecision());
+            final byte[] nullBytes = new byte[width];
             return (row, reuse) -> {
                 Object o = fieldGetter.getFieldOrNull(row);
-                return o == null
-                        ? NULL_BYTES
-                        : ZOrderByteUtils.byteTruncateOrFill(
-                                        ((Decimal) o).toUnscaledBytes(),
-                                        PRIMITIVE_BUFFER_SIZE,
-                                        reuse)
-                                .array();
+                if (o == null) {
+                    return nullBytes;
+                }
+                Decimal decimal = (Decimal) o;
+                BigInteger unscaled =
+                        decimal.isCompact()
+                                ? BigInteger.valueOf(decimal.toUnscaledLong())
+                                : decimal.toBigDecimal().unscaledValue();
+                return orderedDecimalBytes(unscaled, width, reuse);
             };
         }
 
+        private static byte[] orderedDecimalBytes(
+                BigInteger unscaled, int width, ByteBuffer reuse) {
+            byte[] buffer = ZOrderByteUtils.reuse(reuse, width).array();
+            Arrays.fill(buffer, 0, width, unscaled.signum() < 0 ? (byte) 0xFF 
: (byte) 0x00);
+            byte[] magnitude = unscaled.toByteArray();
+            System.arraycopy(magnitude, 0, buffer, width - magnitude.length, 
magnitude.length);
+            // Flip the sign bit so signed order becomes unsigned 
lexicographic order.
+            buffer[0] ^= (byte) 0x80;
+            return buffer;
+        }
+
         @Override
         public ZProcessFunction visit(TinyIntType tinyIntType) {
             return (row, reuse) ->
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/sort/zorder/TestZOrderByteUtil.java
 
b/paimon-common/src/test/java/org/apache/paimon/sort/zorder/TestZOrderByteUtil.java
index 48e4a87467..8a8a6a4bea 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/sort/zorder/TestZOrderByteUtil.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/sort/zorder/TestZOrderByteUtil.java
@@ -18,17 +18,22 @@
 
 package org.apache.paimon.sort.zorder;
 
+import org.apache.paimon.data.Decimal;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.DecimalType;
 import org.apache.paimon.types.RowType;
 
 import org.junit.Test;
 import org.testcontainers.shaded.com.google.common.primitives.UnsignedBytes;
 
+import java.math.BigDecimal;
 import java.nio.ByteBuffer;
 import java.util.Arrays;
 import java.util.Comparator;
+import java.util.List;
 import java.util.Random;
 
 import static org.apache.paimon.utils.RandomUtil.randomBytes;
@@ -94,6 +99,125 @@ public class TestZOrderByteUtil {
         return result.toString();
     }
 
+    /** Decimal z-values must order by value and stay distinct from the null 
sentinel. */
+    @Test
+    public void testZIndexerDecimalOrdering() {
+        RowType rowType =
+                new RowType(
+                        Arrays.asList(
+                                new DataField(0, "a", new DecimalType(20, 2)),
+                                new DataField(1, "b", new DecimalType(20, 
2))));
+        ZIndexer indexer = new ZIndexer(rowType, Arrays.asList("a", "b"));
+        indexer.open();
+
+        // Two rows whose decimal z-values must order by value: (-1, 0) then 
(0, 1).
+        // Old code fed minimal two's-complement arrays into unsigned 
comparison,
+        // making -1 > 1 and 0 collide with the null sentinel.
+        GenericRow row1 = new GenericRow(2);
+        row1.setField(0, Decimal.fromBigDecimal(new BigDecimal("-1.00"), 20, 
2));
+        row1.setField(1, Decimal.fromBigDecimal(new BigDecimal("0.00"), 20, 
2));
+        GenericRow row2 = new GenericRow(2);
+        row2.setField(0, Decimal.fromBigDecimal(new BigDecimal("0.00"), 20, 
2));
+        row2.setField(1, Decimal.fromBigDecimal(new BigDecimal("1.00"), 20, 
2));
+
+        byte[] z1 = Arrays.copyOf(indexer.index(row1), indexer.size());
+        byte[] z2 = Arrays.copyOf(indexer.index(row2), indexer.size());
+        // Interleaved bits: column a dominates the high bits of the z-value.
+        assertThat(compareUnsigned(z1, z2)).isLessThan(0);
+
+        GenericRow rowNull = new GenericRow(2);
+        rowNull.setField(0, null);
+        rowNull.setField(1, null);
+        byte[] zNull = Arrays.copyOf(indexer.index(rowNull), indexer.size());
+        // The null sentinel (all-zero bytes) sorts below every real value.
+        assertThat(compareUnsigned(zNull, z1)).isLessThan(0);
+        assertThat(compareUnsigned(zNull, z2)).isLessThan(0);
+
+        // A large magnitude still orders correctly against the small 
positives: the fixed-width
+        // encoding holds the whole unscaled value rather than projecting it 
into 8 bytes.
+        GenericRow big = new GenericRow(2);
+        big.setField(0, Decimal.fromBigDecimal(new 
BigDecimal("92233720368547758.08"), 20, 2));
+        big.setField(1, Decimal.fromBigDecimal(new BigDecimal("0.00"), 20, 2));
+        byte[] zBig = Arrays.copyOf(indexer.index(big), indexer.size());
+        assertThat(compareUnsigned(zBig, z2)).isGreaterThan(0);
+
+        // Its negative counterpart orders below the small negatives, but 
still above the null
+        // sentinel.
+        Decimal minUnscaled =
+                Decimal.fromBigDecimal(new 
BigDecimal("-92233720368547758.08"), 20, 2);
+        GenericRow negativeBig = new GenericRow(2);
+        negativeBig.setField(0, minUnscaled);
+        negativeBig.setField(1, minUnscaled);
+        byte[] zNegativeBig = Arrays.copyOf(indexer.index(negativeBig), 
indexer.size());
+        assertThat(compareUnsigned(zNull, zNegativeBig)).isLessThan(0);
+        assertThat(compareUnsigned(zNegativeBig, z1)).isLessThan(0);
+    }
+
+    /**
+     * High-precision decimals must keep full clustering resolution: ordinary 
small values, their
+     * negatives, and values past the long range all get distinct, correctly 
ordered z-keys.
+     */
+    @Test
+    public void testZIndexerHighPrecisionDecimalClustering() {
+        RowType rowType =
+                new RowType(
+                        Arrays.asList(
+                                new DataField(0, "a", new DecimalType(38, 18)),
+                                new DataField(1, "b", new DecimalType(38, 
18))));
+        ZIndexer indexer = new ZIndexer(rowType, Arrays.asList("a", "b"));
+        indexer.open();
+
+        // Strictly ascending values, including small ones well below an 
8-byte projection's
+        // overflow point (which collapsed 0..18 to a single key), sub-unit 
fractions, negatives,
+        // and a value past Long.MAX_VALUE. Each must get a strictly greater 
z-key than the last.
+        List<String> ascending =
+                Arrays.asList(
+                        "-90",
+                        "-10",
+                        "-2",
+                        "-1",
+                        "0",
+                        "0.001",
+                        "0.002",
+                        "1",
+                        "2",
+                        "3",
+                        "10",
+                        "20",
+                        "90",
+                        "12345678901234567890.123456789012345678");
+        byte[] previous = null;
+        for (String value : ascending) {
+            byte[] key = highPrecisionKey(indexer, value);
+            if (previous != null) {
+                assertThat(compareUnsigned(previous, key)).isLessThan(0);
+            }
+            previous = key;
+        }
+
+        // Null sorts below every real value.
+        byte[] zNull = Arrays.copyOf(indexer.index(new GenericRow(2)), 
indexer.size());
+        assertThat(compareUnsigned(zNull, highPrecisionKey(indexer, 
"-90"))).isLessThan(0);
+    }
+
+    private static byte[] highPrecisionKey(ZIndexer indexer, String value) {
+        GenericRow row = new GenericRow(2);
+        row.setField(0, Decimal.fromBigDecimal(new BigDecimal(value), 38, 18));
+        row.setField(1, Decimal.fromBigDecimal(new BigDecimal("0"), 38, 18));
+        return Arrays.copyOf(indexer.index(row), indexer.size());
+    }
+
+    private static int compareUnsigned(byte[] left, byte[] right) {
+        for (int i = 0; i < left.length && i < right.length; i++) {
+            int a = left[i] & 0xFF;
+            int b = right[i] & 0xFF;
+            if (a != b) {
+                return Integer.compare(a, b);
+            }
+        }
+        return Integer.compare(left.length, right.length);
+    }
+
     /**
      * Ordered-bytes transforms must preserve value order across negatives for 
floats and doubles.
      */

Reply via email to