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 c07f35f271 [format][python] Support additional MAP BLOB key types
(#8963)
c07f35f271 is described below
commit c07f35f271bbcaa80a14d862bbfcc3fcc9613b61
Author: QuakeWang <[email protected]>
AuthorDate: Mon Aug 3 19:06:24 2026 +0800
[format][python] Support additional MAP BLOB key types (#8963)
---
docs/docs/concepts/spec/fileformat.md | 17 +++-
docs/docs/multimodal-table/blob.mdx | 4 +-
docs/docs/primary-key-table/blob-storage.md | 3 +-
.../test/java/org/apache/paimon/JavaPyE2ETest.java | 88 ++++++++++++++++--
.../format/blob/MapBlobElementSerializer.java | 78 ++++++++++++++++
.../paimon/format/blob/BlobFileFormatTest.java | 53 ++++++++++-
paimon-python/dev/run_mixed_tests.sh | 26 +++---
.../pypaimon/common/map_blob_key_serializer.py | 103 +++++++++++++++++++++
paimon-python/pypaimon/tests/blob_test.py | 64 +++++++++----
.../pypaimon/tests/e2e/java_py_read_write_test.py | 70 ++++++++++++++
10 files changed, 461 insertions(+), 45 deletions(-)
diff --git a/docs/docs/concepts/spec/fileformat.md
b/docs/docs/concepts/spec/fileformat.md
index 8c656874e1..7701184ac9 100644
--- a/docs/docs/concepts/spec/fileformat.md
+++ b/docs/docs/concepts/spec/fileformat.md
@@ -885,9 +885,20 @@ For `MAP<K, BLOB>`, the variable-length data area uses the
following nested payl
```
The key and Blob length indexes are aligned by entry position. A length of `-1`
-represents null, while zero represents an empty key or Blob. Supported key
types are
-the integer family, `CHAR`, and `VARCHAR`. An empty map has an entry count of
zero and
-is distinct from a null map.
+represents null, while zero represents an empty key or Blob. Supported key
types and
+their encodings are:
+
+| Key type | Encoding |
+|----------|----------|
+| `TINYINT`, `SMALLINT`, `INT`, `BIGINT` | Signed integer in little-endian
byte order using the type's fixed width |
+| `BOOLEAN` | One byte: `0` for false and `1` for true |
+| `DECIMAL(p, s)`, `p <= 18` | Eight-byte little-endian signed unscaled
integer |
+| `DECIMAL(p, s)`, `p > 18` | Minimal-length signed big-endian
two's-complement unscaled integer |
+| `DATE` | Four-byte little-endian signed count of days since 1970-01-01 |
+| `CHAR`, `VARCHAR` | UTF-8 bytes |
+
+The DECIMAL scale is defined by the field type and is not stored in each key.
An empty
+map has an entry count of zero and is distinct from a null map.
At the outer file index level, `-1` represents a null field and `-2`
represents a
field placeholder used by data evolution.
diff --git a/docs/docs/multimodal-table/blob.mdx
b/docs/docs/multimodal-table/blob.mdx
index 7d3f14d8c3..400b83766a 100644
--- a/docs/docs/multimodal-table/blob.mdx
+++ b/docs/docs/multimodal-table/blob.mdx
@@ -87,8 +87,8 @@ Paimon supports three storage modes for BLOB fields, selected
via **comment dire
This allows one table to mix different storage modes for different BLOB
columns.
`ARRAY<BLOB>` and `MAP<K, BLOB>` are supported only by `__BLOB_FIELD`;
descriptor-only and blob-view comment directives accept scalar BLOB fields
only.
-Map keys support the integer family, `CHAR`, and `VARCHAR`. Use non-null keys
for
-compatibility across Flink, Spark, and Python.
+Map keys support the integer family, `BOOLEAN`, `DECIMAL`, `DATE`, `CHAR`, and
+`VARCHAR`. Use non-null keys for compatibility across Flink, Spark, and Python.
## Table Options
diff --git a/docs/docs/primary-key-table/blob-storage.md
b/docs/docs/primary-key-table/blob-storage.md
index 51d7fc2756..7469d88044 100644
--- a/docs/docs/primary-key-table/blob-storage.md
+++ b/docs/docs/primary-key-table/blob-storage.md
@@ -94,7 +94,8 @@ array order, a null array, and null elements are preserved.
An empty array write
`MAP<K, BLOB>` is externalized value by value. Keys remain in the normal data
file and every non-null value is replaced
with a descriptor to managed storage. A null map, an empty map, and null
values are preserved. Supported key types are
-the integer family, `CHAR`, and `VARCHAR`; `blob-descriptor-field` and
`blob-view-field` remain scalar-only declarations.
+the integer family, `BOOLEAN`, `DECIMAL`, `DATE`, `CHAR`, and `VARCHAR`;
`blob-descriptor-field` and
+`blob-view-field` remain scalar-only declarations.
`blob.target-file-size` controls when a writer rolls to a new managed payload
pack. A pack can contain payloads from
multiple rows, and a row descriptor records its URI, offset, and length.
diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index bdc963e678..403ec3c2dc 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -29,6 +29,7 @@ import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.BinaryVector;
import org.apache.paimon.data.BlobData;
import org.apache.paimon.data.DataFormatTestUtil;
+import org.apache.paimon.data.Decimal;
import org.apache.paimon.data.GenericArray;
import org.apache.paimon.data.GenericMap;
import org.apache.paimon.data.GenericRow;
@@ -92,6 +93,7 @@ import
org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
@@ -1500,7 +1502,7 @@ public class JavaPyE2ETest {
assertThat(rows.get(4).get(0)).isEqualTo(lastValue.getBytes(StandardCharsets.UTF_8));
}
- /** Java writes a MAP<INT, BLOB> table for Python to read. */
+ /** Java writes MAP<K, BLOB> columns for Python to read. */
@Test
@EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
public void testJavaWriteMapBlobTable() throws Exception {
@@ -1510,6 +1512,16 @@ public class JavaPyE2ETest {
Schema.newBuilder()
.column("id", DataTypes.INT())
.column("payloads", DataTypes.MAP(DataTypes.INT(),
DataTypes.BLOB()))
+ .column(
+ "boolean_payloads",
+ DataTypes.MAP(DataTypes.BOOLEAN(),
DataTypes.BLOB()))
+ .column(
+ "compact_decimal_payloads",
+ DataTypes.MAP(DataTypes.DECIMAL(10, 2),
DataTypes.BLOB()))
+ .column(
+ "high_decimal_payloads",
+ DataTypes.MAP(DataTypes.DECIMAL(20, 2),
DataTypes.BLOB()))
+ .column("date_payloads",
DataTypes.MAP(DataTypes.DATE(), DataTypes.BLOB()))
.option(ROW_TRACKING_ENABLED.key(), "true")
.option(DATA_EVOLUTION_ENABLED.key(), "true")
.option(BUCKET.key(), "-1")
@@ -1522,28 +1534,51 @@ public class JavaPyE2ETest {
first.put(3, new BlobData(new byte[0]));
Map<Object, Object> last = new LinkedHashMap<>();
last.put(4, new
BlobData("java-omega".getBytes(StandardCharsets.UTF_8)));
+ Map<Object, Object> booleanPayloads = new LinkedHashMap<>();
+ booleanPayloads.put(true, new
BlobData("java-boolean".getBytes(StandardCharsets.UTF_8)));
+ Map<Object, Object> compactDecimalPayloads = new LinkedHashMap<>();
+ compactDecimalPayloads.put(
+ Decimal.fromBigDecimal(new BigDecimal("12.34"), 10, 2),
+ new
BlobData("java-compact-decimal".getBytes(StandardCharsets.UTF_8)));
+ Map<Object, Object> highDecimalPayloads = new LinkedHashMap<>();
+ highDecimalPayloads.put(
+ Decimal.fromBigDecimal(new
BigDecimal("123456789012345678.90"), 20, 2),
+ new
BlobData("java-high-decimal".getBytes(StandardCharsets.UTF_8)));
+ Map<Object, Object> datePayloads = new LinkedHashMap<>();
+ datePayloads.put(-1, new
BlobData("java-date".getBytes(StandardCharsets.UTF_8)));
FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
try (BatchTableWrite write = writeBuilder.newWrite();
BatchTableCommit commit = writeBuilder.newCommit()) {
- write.write(GenericRow.of(1, new GenericMap(first)));
- write.write(GenericRow.of(2, new
GenericMap(Collections.emptyMap())));
- write.write(GenericRow.of(3, null));
- write.write(GenericRow.of(4, new GenericMap(last)));
+ write.write(
+ GenericRow.of(
+ 1,
+ new GenericMap(first),
+ new GenericMap(booleanPayloads),
+ new GenericMap(compactDecimalPayloads),
+ new GenericMap(highDecimalPayloads),
+ new GenericMap(datePayloads)));
+ write.write(
+ GenericRow.of(
+ 2, new GenericMap(Collections.emptyMap()), null,
null, null, null));
+ write.write(GenericRow.of(3, null, null, null, null, null));
+ write.write(GenericRow.of(4, new GenericMap(last), null, null,
null, null));
commit.commit(write.prepareCommit());
}
assertMapBlobRows(readMapBlobRows(table), "java-alpha", "java-omega");
+ assertAdditionalMapBlobKeyTypes(table, "java");
}
- /** Java reads a MAP<INT, BLOB> table written by Python. */
+ /** Java reads MAP<K, BLOB> columns written by Python. */
@Test
@EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
public void testJavaReadMapBlobTable() throws Exception {
FileStoreTable table =
(FileStoreTable)
catalog.getTable(identifier("map_blob_python_test"));
assertMapBlobRows(readMapBlobRows(table), "python-alpha",
"python-omega");
+ assertAdditionalMapBlobKeyTypes(table, "python");
}
private Map<Integer, Map<Integer, byte[]>> readMapBlobRows(FileStoreTable
table)
@@ -1588,6 +1623,47 @@ public class JavaPyE2ETest {
assertThat(rows.get(4).get(4)).isEqualTo(lastValue.getBytes(StandardCharsets.UTF_8));
}
+ private void assertAdditionalMapBlobKeyTypes(FileStoreTable table, String
valuePrefix)
+ throws Exception {
+ boolean[] found = new boolean[1];
+ List<Split> splits = new
ArrayList<>(table.newSnapshotReader().read().dataSplits());
+ try (org.apache.paimon.reader.RecordReader<InternalRow> reader =
+ table.newRead().createReader(splits)) {
+ reader.forEachRemaining(
+ row -> {
+ if (row.getInt(0) != 1) {
+ return;
+ }
+ found[0] = true;
+
+ InternalMap booleanMap = row.getMap(2);
+
assertThat(booleanMap.keyArray().getBoolean(0)).isTrue();
+ assertSingleBlobValue(booleanMap, valuePrefix +
"-boolean");
+
+ InternalMap compactDecimalMap = row.getMap(3);
+ assertThat(compactDecimalMap.keyArray().getDecimal(0,
10, 2).toBigDecimal())
+ .isEqualByComparingTo("12.34");
+ assertSingleBlobValue(compactDecimalMap, valuePrefix +
"-compact-decimal");
+
+ InternalMap highDecimalMap = row.getMap(4);
+ assertThat(highDecimalMap.keyArray().getDecimal(0, 20,
2).toBigDecimal())
+ .isEqualByComparingTo("123456789012345678.90");
+ assertSingleBlobValue(highDecimalMap, valuePrefix +
"-high-decimal");
+
+ InternalMap dateMap = row.getMap(5);
+ assertThat(dateMap.keyArray().getInt(0)).isEqualTo(-1);
+ assertSingleBlobValue(dateMap, valuePrefix + "-date");
+ });
+ }
+ assertThat(found[0]).isTrue();
+ }
+
+ private void assertSingleBlobValue(InternalMap map, String expectedValue) {
+ assertThat(map.size()).isOne();
+ assertThat(map.valueArray().getBlob(0).toData())
+ .isEqualTo(expectedValue.getBytes(StandardCharsets.UTF_8));
+ }
+
/** Java writes a VARIANT-column table for Python to read (Java→Python
E2E). */
@Test
@EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
index 1a4b922552..3c67077e3d 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.BlobConsumer;
import org.apache.paimon.data.BlobDescriptor;
import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.data.BlobMapPlaceholder;
+import org.apache.paimon.data.Decimal;
import org.apache.paimon.data.GenericMap;
import org.apache.paimon.data.InternalArray;
import org.apache.paimon.data.InternalMap;
@@ -33,6 +34,7 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.fs.SeekableInputStream;
import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DecimalType;
import org.apache.paimon.utils.DeltaVarintCompressor;
import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.Preconditions;
@@ -460,6 +462,13 @@ final class MapBlobElementSerializer implements
BlobElementSerializer {
return new IntKeySerializer();
case BIGINT:
return new BigIntKeySerializer();
+ case BOOLEAN:
+ return new BooleanKeySerializer();
+ case DECIMAL:
+ DecimalType decimalType = (DecimalType) keyType;
+ return new DecimalKeySerializer(decimalType.getPrecision(),
decimalType.getScale());
+ case DATE:
+ return new IntKeySerializer();
case CHAR:
case VARCHAR:
return new StringKeySerializer();
@@ -566,6 +575,75 @@ final class MapBlobElementSerializer implements
BlobElementSerializer {
}
}
+ /** {@link KeySerializer} for Boolean Type. */
+ private static final class BooleanKeySerializer implements KeySerializer {
+
+ @Override
+ public byte[] serialize(Object key) {
+ return new byte[] {(Boolean) key ? (byte) 1 : (byte) 0};
+ }
+
+ @Override
+ public Object deserialize(byte[] bytes) {
+ checkKeyLength(bytes, Byte.BYTES);
+ if (bytes[0] == 0) {
+ return false;
+ }
+ if (bytes[0] == 1) {
+ return true;
+ }
+ throw new IllegalArgumentException("Invalid MAP<X, BLOB> boolean
key.");
+ }
+
+ @Override
+ public int fixedLength() {
+ return Byte.BYTES;
+ }
+ }
+
+ /** {@link KeySerializer} for Decimal Type. */
+ private static final class DecimalKeySerializer implements KeySerializer {
+
+ private final int precision;
+ private final int scale;
+
+ private DecimalKeySerializer(int precision, int scale) {
+ this.precision = precision;
+ this.scale = scale;
+ }
+
+ @Override
+ public byte[] serialize(Object key) {
+ Decimal decimal = (Decimal) key;
+ return Decimal.isCompact(precision)
+ ? longToLittleEndian(decimal.toUnscaledLong())
+ : decimal.toUnscaledBytes();
+ }
+
+ @Override
+ public Object deserialize(byte[] bytes) {
+ Decimal decimal;
+ if (Decimal.isCompact(precision)) {
+ checkKeyLength(bytes, Long.BYTES);
+ decimal =
+ Decimal.fromUnscaledLong(
+ littleEndianBuffer(bytes).getLong(),
precision, scale);
+ } else {
+ decimal = Decimal.fromUnscaledBytes(bytes, precision, scale);
+ }
+ if (decimal == null) {
+ throw new IllegalArgumentException(
+ "MAP<X, BLOB> decimal key exceeds declared
precision.");
+ }
+ return decimal;
+ }
+
+ @Override
+ public int fixedLength() {
+ return Decimal.isCompact(precision) ? Long.BYTES : -1;
+ }
+ }
+
/** {@link KeySerializer} for String Type. */
private static final class StringKeySerializer implements KeySerializer {
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
index e77cb2a9d6..0c22894ee5 100644
---
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
+++
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFileFormatTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.data.BlobData;
import org.apache.paimon.data.BlobMapPlaceholder;
import org.apache.paimon.data.BlobPlaceholder;
import org.apache.paimon.data.BlobRef;
+import org.apache.paimon.data.Decimal;
import org.apache.paimon.data.GenericArray;
import org.apache.paimon.data.GenericMap;
import org.apache.paimon.data.GenericRow;
@@ -51,6 +52,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
+import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
@@ -493,6 +495,10 @@ public class BlobFileFormatTest {
DataTypes.SMALLINT(),
DataTypes.INT(),
DataTypes.BIGINT(),
+ DataTypes.BOOLEAN(),
+ DataTypes.DECIMAL(10, 2),
+ DataTypes.DECIMAL(20, 2),
+ DataTypes.DATE(),
DataTypes.CHAR(10),
DataTypes.VARCHAR(10)
};
@@ -502,9 +508,36 @@ public class BlobFileFormatTest {
(short) 2,
3,
4L,
+ true,
+ Decimal.fromBigDecimal(new BigDecimal("12.34"), 10, 2),
+ Decimal.fromBigDecimal(new
BigDecimal("123456789012345678.90"), 20, 2),
+ -1,
BinaryString.fromString("char"),
BinaryString.fromString("varchar")
};
+ byte[][] serializedKeys =
+ new byte[][] {
+ {1},
+ {2, 0},
+ {3, 0, 0, 0},
+ {4, 0, 0, 0, 0, 0, 0, 0},
+ {1},
+ {(byte) 0xd2, 0x04, 0, 0, 0, 0, 0, 0},
+ {
+ 0,
+ (byte) 0xab,
+ 0x54,
+ (byte) 0xa9,
+ (byte) 0x8c,
+ (byte) 0xeb,
+ 0x1f,
+ 0x0a,
+ (byte) 0xd2
+ },
+ {(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff},
+ "char".getBytes(),
+ "varchar".getBytes()
+ };
for (int i = 0; i < keyTypes.length; i++) {
Path mapFile = new Path(parent, UUID.randomUUID().toString());
@@ -520,6 +553,24 @@ public class BlobFileFormatTest {
writer.close();
}
+ try (SeekableInputStream input = fileIO.newInputStream(mapFile)) {
+ BlobFileMeta fileMeta = new BlobFileMeta(input,
fileIO.getFileSize(mapFile), null);
+ int keyPosition =
+ Math.toIntExact(
+ fileMeta.blobOffset(0)
+ + Integer.BYTES
+ + Integer.BYTES
+ + Byte.BYTES
+ + Integer.BYTES);
+ byte[] fileBytes =
Files.readAllBytes(Paths.get(mapFile.toUri()));
+ assertThat(
+ Arrays.copyOfRange(
+ fileBytes,
+ keyPosition,
+ keyPosition +
serializedKeys[i].length))
+ .isEqualTo(serializedKeys[i]);
+ }
+
FormatReaderFactory readerFactory =
new BlobFileFormat(false,
BlobFormatWriter.DEFAULT_COPY_BUFFER_SIZE)
.createReaderFactory(null, rowType, null);
@@ -538,7 +589,7 @@ public class BlobFileFormatTest {
assertThatThrownBy(
() ->
BlobElementSerializerFactory.create(
- DataTypes.MAP(DataTypes.BOOLEAN(),
DataTypes.BLOB())))
+ DataTypes.MAP(DataTypes.FLOAT(),
DataTypes.BLOB())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unsupported key type");
assertThatThrownBy(
diff --git a/paimon-python/dev/run_mixed_tests.sh
b/paimon-python/dev/run_mixed_tests.sh
index 057e056591..d9371d4b41 100755
--- a/paimon-python/dev/run_mixed_tests.sh
+++ b/paimon-python/dev/run_mixed_tests.sh
@@ -975,40 +975,40 @@ run_array_blob_interop_test() {
}
run_map_blob_interop_test() {
- echo -e "${YELLOW}=== Running MAP<INT, BLOB> Test (Java Write → Python
Read, Python Write → Java Read) ===${NC}"
+ echo -e "${YELLOW}=== Running MAP<K, BLOB> Test (Java Write → Python Read,
Python Write → Java Read) ===${NC}"
if ! skip_batched_java_write; then
cd "$PROJECT_ROOT"
echo "Running Maven test for
JavaPyE2ETest.testJavaWriteMapBlobTable..."
if ! mvn test
-Dtest=org.apache.paimon.JavaPyE2ETest#testJavaWriteMapBlobTable -pl
paimon-core -q -Drun.e2e.tests=true; then
- echo -e "${RED}✗ Java MAP<INT, BLOB> write test failed${NC}"
+ echo -e "${RED}✗ Java MAP<K, BLOB> write test failed${NC}"
return 1
fi
- echo -e "${GREEN}✓ Java MAP<INT, BLOB> write test completed
successfully${NC}"
+ echo -e "${GREEN}✓ Java MAP<K, BLOB> write test completed
successfully${NC}"
fi
cd "$PAIMON_PYTHON_DIR"
- echo "Running Python MAP<INT, BLOB> read test..."
+ echo "Running Python MAP<K, BLOB> read test..."
if ! python -m pytest
java_py_read_write_test.py::JavaPyReadWriteTest::test_read_map_blob_written_by_java
-v; then
- echo -e "${RED}✗ Python MAP<INT, BLOB> read test failed${NC}"
+ echo -e "${RED}✗ Python MAP<K, BLOB> read test failed${NC}"
return 1
fi
- echo -e "${GREEN}✓ Python MAP<INT, BLOB> read test completed
successfully${NC}"
+ echo -e "${GREEN}✓ Python MAP<K, BLOB> read test completed
successfully${NC}"
- echo "Running Python MAP<INT, BLOB> write test..."
+ echo "Running Python MAP<K, BLOB> write test..."
if ! python -m pytest
java_py_read_write_test.py::JavaPyReadWriteTest::test_write_map_blob_for_java
-v; then
- echo -e "${RED}✗ Python MAP<INT, BLOB> write test failed${NC}"
+ echo -e "${RED}✗ Python MAP<K, BLOB> write test failed${NC}"
return 1
fi
- echo -e "${GREEN}✓ Python MAP<INT, BLOB> write test completed
successfully${NC}"
+ echo -e "${GREEN}✓ Python MAP<K, BLOB> write test completed
successfully${NC}"
cd "$PROJECT_ROOT"
echo "Running Maven test for JavaPyE2ETest.testJavaReadMapBlobTable..."
if ! mvn test
-Dtest=org.apache.paimon.JavaPyE2ETest#testJavaReadMapBlobTable -pl paimon-core
-q -Drun.e2e.tests=true; then
- echo -e "${RED}✗ Java MAP<INT, BLOB> read test failed${NC}"
+ echo -e "${RED}✗ Java MAP<K, BLOB> read test failed${NC}"
return 1
fi
- echo -e "${GREEN}✓ Java MAP<INT, BLOB> read test completed
successfully${NC}"
+ echo -e "${GREEN}✓ Java MAP<K, BLOB> read test completed successfully${NC}"
}
# Function to run VARIANT test (Java write, Python read)
@@ -1568,9 +1568,9 @@ main() {
fi
if [[ $map_blob_interop_result -eq 0 ]]; then
- echo -e "${GREEN}✓ MAP<INT, BLOB> Interoperability Test (Java ↔
Python): PASSED${NC}"
+ echo -e "${GREEN}✓ MAP<K, BLOB> Interoperability Test (Java ↔ Python):
PASSED${NC}"
else
- echo -e "${RED}✗ MAP<INT, BLOB> Interoperability Test (Java ↔ Python):
FAILED${NC}"
+ echo -e "${RED}✗ MAP<K, BLOB> Interoperability Test (Java ↔ Python):
FAILED${NC}"
fi
if [[ $data_evolution_result -eq 0 ]]; then
diff --git a/paimon-python/pypaimon/common/map_blob_key_serializer.py
b/paimon-python/pypaimon/common/map_blob_key_serializer.py
index 7617d4758c..51bc3123c6 100644
--- a/paimon-python/pypaimon/common/map_blob_key_serializer.py
+++ b/paimon-python/pypaimon/common/map_blob_key_serializer.py
@@ -15,11 +15,16 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
import struct
+from decimal import Decimal as BigDecimal
from typing import Optional
+from pypaimon.data.decimal import Decimal
from pypaimon.schema.data_types import AtomicType, DataType
+_EPOCH_DATE = datetime.date(1970, 1, 1)
+
class MapBlobKeySerializer:
@@ -61,6 +66,97 @@ class MapBlobKeySerializer:
return struct.unpack(self._struct_format, data)[0]
+class BooleanMapBlobKeySerializer(MapBlobKeySerializer):
+
+ def __init__(self):
+ self.fixed_length = 1
+
+ def serialize(self, key) -> bytes:
+ if not isinstance(key, bool):
+ raise ValueError("MAP<X, BLOB> BOOLEAN key must be a boolean.")
+ return b'\x01' if key else b'\x00'
+
+ def deserialize(self, data: bytes):
+ if len(data) != self.fixed_length:
+ raise ValueError(
+ f"Expected {self.fixed_length} key bytes, but found
{len(data)}."
+ )
+ if data[0] == 0:
+ return False
+ if data[0] == 1:
+ return True
+ raise ValueError("Invalid MAP<X, BLOB> boolean key.")
+
+
+class DecimalMapBlobKeySerializer(MapBlobKeySerializer):
+
+ def __init__(self, type_name: str, precision: int, scale: int):
+ self._type_name = type_name
+ self._precision = precision
+ self._scale = scale
+ self.fixed_length = 8 if Decimal.is_compact_precision(precision) else
-1
+
+ def serialize(self, key) -> bytes:
+ if not isinstance(key, BigDecimal):
+ raise ValueError(
+ f"MAP<X, BLOB> {self._type_name} key must be a
decimal.Decimal."
+ )
+ decimal = Decimal.from_big_decimal(key, self._precision, self._scale)
+ if decimal is None:
+ raise ValueError(
+ f"MAP<X, BLOB> {self._type_name} key exceeds declared
precision."
+ )
+ if decimal.is_compact():
+ return struct.pack('<q', decimal.to_unscaled_long())
+ return decimal.to_unscaled_bytes()
+
+ def deserialize(self, data: bytes):
+ if self.fixed_length >= 0:
+ if len(data) != self.fixed_length:
+ raise ValueError(
+ f"Expected {self.fixed_length} key bytes, but found
{len(data)}."
+ )
+ decimal = Decimal.from_unscaled_long(
+ struct.unpack('<q', data)[0],
+ self._precision,
+ self._scale,
+ )
+ else:
+ if not data:
+ raise ValueError("Invalid MAP<X, BLOB> decimal key.")
+ decimal = Decimal.from_unscaled_bytes(
+ data,
+ self._precision,
+ self._scale,
+ )
+ if decimal is None:
+ raise ValueError(
+ "MAP<X, BLOB> decimal key exceeds declared precision."
+ )
+ return decimal.to_big_decimal()
+
+
+class DateMapBlobKeySerializer(MapBlobKeySerializer):
+
+ def __init__(self):
+ self.fixed_length = 4
+
+ def serialize(self, key) -> bytes:
+ if isinstance(key, datetime.datetime) or not isinstance(key,
datetime.date):
+ raise ValueError("MAP<X, BLOB> DATE key must be a datetime.date.")
+ try:
+ return struct.pack('<i', (key - _EPOCH_DATE).days)
+ except struct.error as error:
+ raise ValueError(f"MAP<X, BLOB> DATE key is out of range: {key}.")
from error
+
+ def deserialize(self, data: bytes):
+ if len(data) != self.fixed_length:
+ raise ValueError(
+ f"Expected {self.fixed_length} key bytes, but found
{len(data)}."
+ )
+ return _EPOCH_DATE + datetime.timedelta(days=struct.unpack('<i',
data)[0])
+
+
def create_map_blob_key_serializer(data_type: DataType) ->
MapBlobKeySerializer:
if not isinstance(data_type, AtomicType):
raise ValueError(f"Unsupported key type for MAP<X, BLOB>: {data_type}")
@@ -74,6 +170,13 @@ def create_map_blob_key_serializer(data_type: DataType) ->
MapBlobKeySerializer:
return MapBlobKeySerializer(type_name, '<i')
if type_name == 'BIGINT':
return MapBlobKeySerializer(type_name, '<q')
+ if type_name == 'BOOLEAN':
+ return BooleanMapBlobKeySerializer()
+ if type_name.startswith('DECIMAL'):
+ precision, scale = Decimal.extract_decimal_precision_scale(type_name)
+ return DecimalMapBlobKeySerializer(type_name, precision, scale)
+ if type_name == 'DATE':
+ return DateMapBlobKeySerializer()
if type_name == 'STRING' or type_name.startswith('CHAR') or
type_name.startswith('VARCHAR'):
return MapBlobKeySerializer(type_name)
raise ValueError(f"Unsupported key type for MAP<X, BLOB>: {data_type}")
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index 71eb26db9c..e7dfc9d179 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
import io
import os
import shutil
@@ -22,6 +23,7 @@ import struct
import tempfile
import unittest
import zlib
+from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
@@ -3049,10 +3051,31 @@ class BlobEndToEndTest(unittest.TestCase):
(AtomicType("INT"), -2147483648),
(AtomicType("INTEGER"), 0x01020304),
(AtomicType("BIGINT"), -9223372036854775808),
+ (AtomicType("BOOLEAN"), True),
+ (AtomicType("DECIMAL(10, 2)"), Decimal("12.34")),
+ (
+ AtomicType("DECIMAL(20, 2)"),
+ Decimal("123456789012345678.90"),
+ ),
+ (AtomicType("DATE"), datetime.date(1969, 12, 31)),
(AtomicType("STRING"), "string"),
(AtomicType("CHAR(3)"), "abc"),
(AtomicType("VARCHAR(10)"), "varchar"),
]
+ serialized_keys = [
+ b"\x80",
+ b"\x00\x80",
+ b"\x00\x00\x00\x80",
+ b"\x04\x03\x02\x01",
+ b"\x00\x00\x00\x00\x00\x00\x00\x80",
+ b"\x01",
+ b"\xd2\x04\x00\x00\x00\x00\x00\x00",
+ b"\x00\xab\x54\xa9\x8c\xeb\x1f\x0a\xd2",
+ b"\xff\xff\xff\xff",
+ b"string",
+ b"abc",
+ b"varchar",
+ ]
for index, (key_type, key) in enumerate(cases):
with self.subTest(key_type=key_type):
blob_file_path = os.path.join(self.temp_dir,
f"map_key_{index}.blob")
@@ -3079,35 +3102,38 @@ class BlobEndToEndTest(unittest.TestCase):
result = next(iterator).values[0]
self.assertEqual(result[key].to_data(), b"value")
- if key_type.type == "INTEGER":
- with open(blob_file_path, 'rb') as blob_file:
- blob_file.seek(BlobRecordIterator.MAGIC_NUMBER_SIZE
- + BlobRecordIterator.MAP_HEADER_SIZE)
- self.assertEqual(blob_file.read(4),
b"\x04\x03\x02\x01")
- reader = FormatBlobReader(
- file_io=file_io,
- file_path=blob_file_path,
- read_fields=["blob_map"],
- full_fields=fields,
- push_down_predicate=None,
- blob_as_descriptor=False,
+ with open(blob_file_path, 'rb') as blob_file:
+ blob_file.seek(BlobRecordIterator.MAGIC_NUMBER_SIZE
+ + BlobRecordIterator.MAP_HEADER_SIZE)
+ self.assertEqual(
+ blob_file.read(len(serialized_keys[index])),
+ serialized_keys[index],
)
- try:
- value =
dict(reader.read_arrow_batch().column(0)[0].as_py())
- self.assertEqual(value, {key: b"value"})
- finally:
- reader.close()
+
+ reader = FormatBlobReader(
+ file_io=file_io,
+ file_path=blob_file_path,
+ read_fields=["blob_map"],
+ full_fields=fields,
+ push_down_predicate=None,
+ blob_as_descriptor=False,
+ )
+ try:
+ value =
dict(reader.read_arrow_batch().column(0)[0].as_py())
+ self.assertEqual(value, {key: b"value"})
+ finally:
+ reader.close()
output = io.BytesIO()
unsupported_key_writer = BlobFormatWriter(output)
unsupported_key_field = DataField(
0,
"blob_map",
- MapType(True, AtomicType("BOOLEAN"), AtomicType("BLOB")),
+ MapType(True, AtomicType("FLOAT"), AtomicType("BLOB")),
)
with self.assertRaisesRegex(ValueError, "Unsupported key type"):
unsupported_key_writer.add_element(GenericRow(
- [{True: BlobData(b"value")}],
+ [{1.0: BlobData(b"value")}],
[unsupported_key_field],
RowKind.INSERT,
))
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index 33bfe83e19..5db6840a31 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -20,6 +20,7 @@ import json
import os
import sys
import unittest
+from decimal import Decimal
import pandas as pd
import pyarrow as pa
@@ -1552,12 +1553,40 @@ class JavaPyReadWriteTest(unittest.TestCase):
{4: b'java-omega'},
],
)
+ expected_additional_payloads = {
+ 'boolean_payloads': {True: b'java-boolean'},
+ 'compact_decimal_payloads': {
+ Decimal('12.34'): b'java-compact-decimal',
+ },
+ 'high_decimal_payloads': {
+ Decimal('123456789012345678.90'): b'java-high-decimal',
+ },
+ 'date_payloads': {
+ datetime.date(1969, 12, 31): b'java-date',
+ },
+ }
+ for name, expected in expected_additional_payloads.items():
+ self.assertEqual(
+ [None if value is None else dict(value)
+ for value in result.column(name).to_pylist()],
+ [expected, None, None, None],
+ )
def test_write_map_blob_for_java(self):
map_blob_type = pa.map_(pa.int32(), pa.large_binary())
+ boolean_map_blob_type = pa.map_(pa.bool_(), pa.large_binary())
+ compact_decimal_map_blob_type = pa.map_(
+ pa.decimal128(10, 2), pa.large_binary())
+ high_decimal_map_blob_type = pa.map_(
+ pa.decimal128(20, 2), pa.large_binary())
+ date_map_blob_type = pa.map_(pa.date32(), pa.large_binary())
pa_schema = pa.schema([
('id', pa.int32()),
('payloads', map_blob_type),
+ ('boolean_payloads', boolean_map_blob_type),
+ ('compact_decimal_payloads', compact_decimal_map_blob_type),
+ ('high_decimal_payloads', high_decimal_map_blob_type),
+ ('date_payloads', date_map_blob_type),
])
schema = Schema.from_pyarrow_schema(
pa_schema,
@@ -1583,6 +1612,29 @@ class JavaPyReadWriteTest(unittest.TestCase):
],
type=map_blob_type,
),
+ 'boolean_payloads': pa.array(
+ [[(True, b'python-boolean')], None, None, None],
+ type=boolean_map_blob_type,
+ ),
+ 'compact_decimal_payloads': pa.array(
+ [[(Decimal('12.34'), b'python-compact-decimal')],
+ None, None, None],
+ type=compact_decimal_map_blob_type,
+ ),
+ 'high_decimal_payloads': pa.array(
+ [[(
+ Decimal('123456789012345678.90'),
+ b'python-high-decimal',
+ )], None, None, None],
+ type=high_decimal_map_blob_type,
+ ),
+ 'date_payloads': pa.array(
+ [[(
+ datetime.date(1969, 12, 31),
+ b'python-date',
+ )], None, None, None],
+ type=date_map_blob_type,
+ ),
}, schema=pa_schema)
write_builder = table.new_batch_write_builder()
table_write = write_builder.new_write()
@@ -1607,6 +1659,24 @@ class JavaPyReadWriteTest(unittest.TestCase):
{4: b'python-omega'},
],
)
+ expected_additional_payloads = {
+ 'boolean_payloads': {True: b'python-boolean'},
+ 'compact_decimal_payloads': {
+ Decimal('12.34'): b'python-compact-decimal',
+ },
+ 'high_decimal_payloads': {
+ Decimal('123456789012345678.90'): b'python-high-decimal',
+ },
+ 'date_payloads': {
+ datetime.date(1969, 12, 31): b'python-date',
+ },
+ }
+ for name, expected in expected_additional_payloads.items():
+ self.assertEqual(
+ [None if value is None else dict(value)
+ for value in result.column(name).to_pylist()],
+ [expected, None, None, None],
+ )
def test_compact_conflict_shard_update(self):
"""