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 2ec825906d [spark] Support decimal columns in zorder clustering 
(#10196)
2ec825906d is described below

commit 2ec825906d8970ac23be8ef40fff5907c06c19f4
Author: jackylee <[email protected]>
AuthorDate: Sat Oct 3 17:08:30 2026 +0800

    [spark] Support decimal columns in zorder clustering (#10196)
---
 .../apache/paimon/spark/sort/SparkZOrderUDF.java   |  50 ++++++++
 .../paimon/spark/sort/SparkZOrderUDFTest.java      | 127 +++++++++++++++++++++
 2 files changed, 177 insertions(+)

diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/sort/SparkZOrderUDF.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/sort/SparkZOrderUDF.java
index 7bb80daaf3..9abdc4b83c 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/sort/SparkZOrderUDF.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/sort/SparkZOrderUDF.java
@@ -29,6 +29,7 @@ import org.apache.spark.sql.types.ByteType;
 import org.apache.spark.sql.types.DataType;
 import org.apache.spark.sql.types.DataTypes;
 import org.apache.spark.sql.types.DateType;
+import org.apache.spark.sql.types.DecimalType;
 import org.apache.spark.sql.types.DoubleType;
 import org.apache.spark.sql.types.FloatType;
 import org.apache.spark.sql.types.IntegerType;
@@ -39,9 +40,12 @@ import org.apache.spark.sql.types.TimestampNTZType;
 import org.apache.spark.sql.types.TimestampType;
 
 import java.io.Serializable;
+import java.math.BigDecimal;
+import java.math.BigInteger;
 import java.nio.ByteBuffer;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
+import java.util.Arrays;
 
 import scala.collection.JavaConverters;
 import scala.collection.Seq;
@@ -256,6 +260,25 @@ public class SparkZOrderUDF implements Serializable {
         return udf;
     }
 
+    private UserDefinedFunction decimalToOrderedBytesUDF(int width) {
+        UserDefinedFunction udf =
+                functions
+                        .udf(
+                                (BigDecimal value) -> {
+                                    if (value == null) {
+                                        return new byte[width];
+                                    }
+                                    return 
decimalToOrderedBytes(value.unscaledValue(), width);
+                                },
+                                DataTypes.BinaryType)
+                        .withName("DECIMAL_ORDERED_BYTES");
+
+        this.inputCol++;
+        increaseOutputSize(width);
+
+        return udf;
+    }
+
     private UserDefinedFunction booleanToOrderedBytesUDF() {
         int position = inputCol;
         UserDefinedFunction udf =
@@ -364,6 +387,13 @@ public class SparkZOrderUDF implements Serializable {
             return 
longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType));
         } else if (type instanceof TimestampNTZType) {
             return 
longToOrderedBytesUDF().apply(timestampNtzToLongUDF().apply(column));
+        } else if (type instanceof DecimalType) {
+            // Encode the unscaled value as fixed-width, sign-flipped two's 
complement (mirroring
+            // the core ZIndexer). Casting to LongType instead would raise 
CAST_OVERFLOW under ANSI
+            // (Spark 4's default) for values outside the BIGINT range, 
aborting the z-order
+            // compact.
+            int width = decimalBytes(((DecimalType) type).precision());
+            return decimalToOrderedBytesUDF(width).apply(column);
         } else if (type instanceof DateType) {
             return 
longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType));
         } else {
@@ -377,4 +407,24 @@ public class SparkZOrderUDF implements Serializable {
     private void increaseOutputSize(int bytes) {
         totalOutputBytes = Math.min(totalOutputBytes + bytes, maxOutputSize);
     }
+
+    /** Two's-complement byte width that holds any unscaled value with {@code 
precision} digits. */
+    static int decimalBytes(int precision) {
+        return BigInteger.TEN.pow(precision).bitLength() / 8 + 1;
+    }
+
+    /**
+     * Fixed-width, sign-flipped big-endian two's complement of {@code 
unscaled}, so signed order
+     * becomes the unsigned lexicographic order a z-value compares with. 
{@code width} is wide
+     * enough for any value of the column's precision, so values past the long 
range keep distinct
+     * keys.
+     */
+    static byte[] decimalToOrderedBytes(BigInteger unscaled, int width) {
+        byte[] buffer = new byte[width];
+        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);
+        buffer[0] ^= (byte) 0x80;
+        return buffer;
+    }
 }
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/sort/SparkZOrderUDFTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/sort/SparkZOrderUDFTest.java
index 7a091f8b63..4c57893f93 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/sort/SparkZOrderUDFTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/sort/SparkZOrderUDFTest.java
@@ -29,6 +29,7 @@ import org.apache.spark.sql.types.StructField;
 import org.apache.spark.sql.types.StructType;
 import org.junit.jupiter.api.Test;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -152,4 +153,130 @@ public class SparkZOrderUDFTest {
             SparkSession.clearDefaultSession();
         }
     }
+
+    @Test
+    void testDecimalColumnIsSupported() {
+        SparkSession spark =
+                SparkSession.builder()
+                        .master("local[1]")
+                        .appName("spark-zorder-udf-decimal-test")
+                        .config("spark.ui.enabled", "false")
+                        .getOrCreate();
+        try {
+            StructType schema =
+                    new StructType(
+                            new StructField[] {
+                                new StructField(
+                                        "a",
+                                        DataTypes.createDecimalType(10, 2),
+                                        true,
+                                        Metadata.empty())
+                            });
+            Dataset<Row> df =
+                    spark.createDataFrame(
+                            Arrays.asList(
+                                    RowFactory.create(new BigDecimal("1.50")),
+                                    RowFactory.create(new 
BigDecimal("100.25")),
+                                    RowFactory.create(new 
BigDecimal("12345.67")),
+                                    RowFactory.create((BigDecimal) null)),
+                            schema);
+
+            SparkZOrderUDF udf = new SparkZOrderUDF(1, 8, Integer.MAX_VALUE);
+            // A decimal column used to fall through to the default branch and 
throw "the type is
+            // unsupported". It now encodes the unscaled value as fixed-width 
ordered bytes.
+            List<Row> rows =
+                    df.select(
+                                    df.col("a"),
+                                    functions
+                                            .hex(
+                                                    
udf.sortedLexicographically(
+                                                            df.col("a"),
+                                                            
DataTypes.createDecimalType(10, 2)))
+                                            .as("zvalue"))
+                            .orderBy(df.col("a").asc_nulls_first())
+                            .collectAsList();
+
+            assertThat(rows).hasSize(4);
+            assertThat(rows.get(0).isNullAt(0)).isTrue();
+            // NULL is the all-zero sentinel, width bytes wide (two hex chars 
each).
+            int width = SparkZOrderUDF.decimalBytes(10);
+            assertThat(rows.get(0).getString(1))
+                    .isEqualTo(String.join("", 
java.util.Collections.nCopies(width, "00")));
+
+            // the z-value rises with the decimal value
+            List<String> nonNull = new ArrayList<>();
+            for (Row row : rows.subList(1, rows.size())) {
+                nonNull.add(row.getString(1));
+            }
+            for (int i = 1; i < nonNull.size(); i++) {
+                assertThat(nonNull.get(i)).isGreaterThan(nonNull.get(i - 1));
+            }
+        } finally {
+            spark.stop();
+            SparkSession.clearActiveSession();
+            SparkSession.clearDefaultSession();
+        }
+    }
+
+    @Test
+    void testDecimalOutsideBigIntRangeIsOrderedWithoutOverflow() {
+        SparkSession spark =
+                SparkSession.builder()
+                        .master("local[1]")
+                        .appName("spark-zorder-udf-decimal-wide-test")
+                        .config("spark.ui.enabled", "false")
+                        // ANSI (Spark 4's default) would make a decimal->long 
cast raise
+                        // CAST_OVERFLOW on these values; the fixed-width 
encoding must not.
+                        .config("spark.sql.ansi.enabled", "true")
+                        .getOrCreate();
+        try {
+            StructType schema =
+                    new StructType(
+                            new StructField[] {
+                                new StructField(
+                                        "a",
+                                        DataTypes.createDecimalType(38, 2),
+                                        true,
+                                        Metadata.empty())
+                            });
+            // Values straddling the BIGINT range, including both just past 
Long.MAX and Long.MIN.
+            List<BigDecimal> values =
+                    Arrays.asList(
+                            new BigDecimal("-9223372036854775809.00"),
+                            new BigDecimal("-1.00"),
+                            new BigDecimal("0.00"),
+                            new BigDecimal("1.00"),
+                            new BigDecimal("9223372036854775808.00"),
+                            new BigDecimal("9223372036854775809.00"));
+            List<Row> input = new ArrayList<>();
+            for (BigDecimal v : values) {
+                input.add(RowFactory.create(v));
+            }
+            Dataset<Row> df = spark.createDataFrame(input, schema);
+
+            SparkZOrderUDF udf = new SparkZOrderUDF(1, 8, Integer.MAX_VALUE);
+            List<Row> rows =
+                    df.select(
+                                    df.col("a"),
+                                    functions
+                                            .hex(
+                                                    
udf.sortedLexicographically(
+                                                            df.col("a"),
+                                                            
DataTypes.createDecimalType(38, 2)))
+                                            .as("zvalue"))
+                            .orderBy(df.col("a").asc())
+                            .collectAsList();
+
+            // No CAST_OVERFLOW, and the z-value is strictly increasing with 
the decimal across the
+            // whole range (negatives, zero, and both out-of-long positives 
stay distinct/ordered).
+            assertThat(rows).hasSize(values.size());
+            for (int i = 1; i < rows.size(); i++) {
+                assertThat(rows.get(i).getString(1)).isGreaterThan(rows.get(i 
- 1).getString(1));
+            }
+        } finally {
+            spark.stop();
+            SparkSession.clearActiveSession();
+            SparkSession.clearDefaultSession();
+        }
+    }
 }

Reply via email to