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 464c28278d [format][python] Support TIME keys for MAP BLOB (#9009)
464c28278d is described below

commit 464c28278dbe39094ba83fe4459197e7d16389d4
Author: QuakeWang <[email protected]>
AuthorDate: Wed Aug 5 13:16:42 2026 +0800

    [format][python] Support TIME keys for MAP BLOB (#9009)
---
 docs/docs/concepts/spec/fileformat.md              |  4 ++-
 docs/docs/multimodal-table/blob.mdx                |  2 +-
 docs/docs/primary-key-table/blob-storage.md        |  2 +-
 .../test/java/org/apache/paimon/JavaPyE2ETest.java | 22 +++++++++++---
 .../format/blob/MapBlobElementSerializer.java      |  1 +
 .../paimon/format/blob/BlobFileFormatTest.java     |  3 ++
 .../pypaimon/common/map_blob_key_serializer.py     | 34 ++++++++++++++++++++++
 paimon-python/pypaimon/tests/blob_test.py          | 24 +++++++++++++++
 .../pypaimon/tests/e2e/java_py_read_write_test.py  | 15 ++++++++++
 9 files changed, 100 insertions(+), 7 deletions(-)

diff --git a/docs/docs/concepts/spec/fileformat.md 
b/docs/docs/concepts/spec/fileformat.md
index 7701184ac9..541498df57 100644
--- a/docs/docs/concepts/spec/fileformat.md
+++ b/docs/docs/concepts/spec/fileformat.md
@@ -895,10 +895,12 @@ their encodings are:
 | `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 |
+| `TIME(p)` | Four-byte little-endian signed count of milliseconds since 
midnight |
 | `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.
+map has an entry count of zero and is distinct from a null map. The `TIME(p)` 
encoding
+uses Paimon's millisecond internal representation and does not add nanosecond 
precision.
 
 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 400b83766a..3ee89a0910 100644
--- a/docs/docs/multimodal-table/blob.mdx
+++ b/docs/docs/multimodal-table/blob.mdx
@@ -87,7 +87,7 @@ 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, `BOOLEAN`, `DECIMAL`, `DATE`, `CHAR`, and
+Map keys support the integer family, `BOOLEAN`, `DECIMAL`, `DATE`, `TIME`, 
`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 7469d88044..3eff81bb83 100644
--- a/docs/docs/primary-key-table/blob-storage.md
+++ b/docs/docs/primary-key-table/blob-storage.md
@@ -94,7 +94,7 @@ 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, `BOOLEAN`, `DECIMAL`, `DATE`, `CHAR`, and `VARCHAR`; 
`blob-descriptor-field` and
+the integer family, `BOOLEAN`, `DECIMAL`, `DATE`, `TIME`, `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
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 403ec3c2dc..a45990f158 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -1522,6 +1522,7 @@ public class JavaPyE2ETest {
                                 "high_decimal_payloads",
                                 DataTypes.MAP(DataTypes.DECIMAL(20, 2), 
DataTypes.BLOB()))
                         .column("date_payloads", 
DataTypes.MAP(DataTypes.DATE(), DataTypes.BLOB()))
+                        .column("time_payloads", 
DataTypes.MAP(DataTypes.TIME(3), DataTypes.BLOB()))
                         .option(ROW_TRACKING_ENABLED.key(), "true")
                         .option(DATA_EVOLUTION_ENABLED.key(), "true")
                         .option(BUCKET.key(), "-1")
@@ -1546,6 +1547,8 @@ public class JavaPyE2ETest {
                 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)));
+        Map<Object, Object> timePayloads = new LinkedHashMap<>();
+        timePayloads.put(45_296_789, new 
BlobData("java-time".getBytes(StandardCharsets.UTF_8)));
 
         FileStoreTable table = (FileStoreTable) catalog.getTable(identifier);
         BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
@@ -1558,12 +1561,19 @@ public class JavaPyE2ETest {
                             new GenericMap(booleanPayloads),
                             new GenericMap(compactDecimalPayloads),
                             new GenericMap(highDecimalPayloads),
-                            new GenericMap(datePayloads)));
+                            new GenericMap(datePayloads),
+                            new GenericMap(timePayloads)));
             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));
+                            2,
+                            new GenericMap(Collections.emptyMap()),
+                            null,
+                            null,
+                            null,
+                            null,
+                            null));
+            write.write(GenericRow.of(3, null, null, null, null, null, null));
+            write.write(GenericRow.of(4, new GenericMap(last), null, null, 
null, null, null));
             commit.commit(write.prepareCommit());
         }
 
@@ -1653,6 +1663,10 @@ public class JavaPyE2ETest {
                         InternalMap dateMap = row.getMap(5);
                         assertThat(dateMap.keyArray().getInt(0)).isEqualTo(-1);
                         assertSingleBlobValue(dateMap, valuePrefix + "-date");
+
+                        InternalMap timeMap = row.getMap(6);
+                        
assertThat(timeMap.keyArray().getInt(0)).isEqualTo(45_296_789);
+                        assertSingleBlobValue(timeMap, valuePrefix + "-time");
                     });
         }
         assertThat(found[0]).isTrue();
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 3c67077e3d..6d73bfc5d6 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
@@ -468,6 +468,7 @@ final class MapBlobElementSerializer implements 
BlobElementSerializer {
                 DecimalType decimalType = (DecimalType) keyType;
                 return new DecimalKeySerializer(decimalType.getPrecision(), 
decimalType.getScale());
             case DATE:
+            case TIME_WITHOUT_TIME_ZONE:
                 return new IntKeySerializer();
             case CHAR:
             case VARCHAR:
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 0c22894ee5..a155100b24 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
@@ -499,6 +499,7 @@ public class BlobFileFormatTest {
                     DataTypes.DECIMAL(10, 2),
                     DataTypes.DECIMAL(20, 2),
                     DataTypes.DATE(),
+                    DataTypes.TIME(3),
                     DataTypes.CHAR(10),
                     DataTypes.VARCHAR(10)
                 };
@@ -512,6 +513,7 @@ public class BlobFileFormatTest {
                     Decimal.fromBigDecimal(new BigDecimal("12.34"), 10, 2),
                     Decimal.fromBigDecimal(new 
BigDecimal("123456789012345678.90"), 20, 2),
                     -1,
+                    45_296_789,
                     BinaryString.fromString("char"),
                     BinaryString.fromString("varchar")
                 };
@@ -535,6 +537,7 @@ public class BlobFileFormatTest {
                         (byte) 0xd2
                     },
                     {(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff},
+                    {(byte) 0x95, 0x2c, (byte) 0xb3, 0x02},
                     "char".getBytes(),
                     "varchar".getBytes()
                 };
diff --git a/paimon-python/pypaimon/common/map_blob_key_serializer.py 
b/paimon-python/pypaimon/common/map_blob_key_serializer.py
index 51bc3123c6..58eaa02dd4 100644
--- a/paimon-python/pypaimon/common/map_blob_key_serializer.py
+++ b/paimon-python/pypaimon/common/map_blob_key_serializer.py
@@ -157,6 +157,38 @@ class DateMapBlobKeySerializer(MapBlobKeySerializer):
         return _EPOCH_DATE + datetime.timedelta(days=struct.unpack('<i', 
data)[0])
 
 
+class TimeMapBlobKeySerializer(MapBlobKeySerializer):
+
+    def __init__(self, type_name: str):
+        self._type_name = type_name
+        self.fixed_length = 4
+
+    def serialize(self, key) -> bytes:
+        if not isinstance(key, datetime.time):
+            raise ValueError(
+                f"MAP<X, BLOB> {self._type_name} key must be a datetime.time."
+            )
+        millis = (
+            (key.hour * 3600 + key.minute * 60 + key.second) * 1000
+            + key.microsecond // 1000
+        )
+        return struct.pack('<i', millis)
+
+    def deserialize(self, data: bytes):
+        if len(data) != self.fixed_length:
+            raise ValueError(
+                f"Expected {self.fixed_length} key bytes, but found 
{len(data)}."
+            )
+        millis = struct.unpack('<i', data)[0]
+        seconds, millis = divmod(millis, 1000)
+        minutes, second = divmod(seconds, 60)
+        hour, minute = divmod(minutes, 60)
+        try:
+            return datetime.time(hour, minute, second, millis * 1000)
+        except ValueError as error:
+            raise ValueError("Invalid MAP<X, BLOB> TIME key.") from error
+
+
 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}")
@@ -177,6 +209,8 @@ def create_map_blob_key_serializer(data_type: DataType) -> 
MapBlobKeySerializer:
         return DecimalMapBlobKeySerializer(type_name, precision, scale)
     if type_name == 'DATE':
         return DateMapBlobKeySerializer()
+    if type_name == 'TIME' or type_name.startswith('TIME('):
+        return TimeMapBlobKeySerializer(type_name)
     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 e7dfc9d179..70100d2d1a 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -3042,6 +3042,7 @@ class BlobEndToEndTest(unittest.TestCase):
             self.assertEqual(result["tail"].to_data(), b"third")
 
     def test_map_blob_key_types_and_rejections(self):
+        from pypaimon.common.map_blob_key_serializer import 
create_map_blob_key_serializer
         from pypaimon.write.blob_format_writer import BlobFormatWriter
 
         file_io = LocalFileIO(self.temp_dir, Options({}))
@@ -3058,6 +3059,7 @@ class BlobEndToEndTest(unittest.TestCase):
                 Decimal("123456789012345678.90"),
             ),
             (AtomicType("DATE"), datetime.date(1969, 12, 31)),
+            (AtomicType("TIME(3)"), datetime.time(12, 34, 56, 789000)),
             (AtomicType("STRING"), "string"),
             (AtomicType("CHAR(3)"), "abc"),
             (AtomicType("VARCHAR(10)"), "varchar"),
@@ -3072,6 +3074,7 @@ class BlobEndToEndTest(unittest.TestCase):
             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"\x95\x2c\xb3\x02",
             b"string",
             b"abc",
             b"varchar",
@@ -3124,6 +3127,14 @@ class BlobEndToEndTest(unittest.TestCase):
                 finally:
                     reader.close()
 
+        time_serializer = create_map_blob_key_serializer(AtomicType("TIME(9)"))
+        serialized_time = time_serializer.serialize(datetime.time(12, 34, 56, 
789999))
+        self.assertEqual(serialized_time, b"\x95\x2c\xb3\x02")
+        self.assertEqual(
+            time_serializer.deserialize(serialized_time),
+            datetime.time(12, 34, 56, 789000),
+        )
+
         output = io.BytesIO()
         unsupported_key_writer = BlobFormatWriter(output)
         unsupported_key_field = DataField(
@@ -3164,6 +3175,19 @@ class BlobEndToEndTest(unittest.TestCase):
                 RowKind.INSERT,
             ))
 
+        invalid_time_key_writer = BlobFormatWriter(io.BytesIO())
+        time_key_field = DataField(
+            0,
+            "blob_map",
+            MapType(True, AtomicType("TIME(3)"), AtomicType("BLOB")),
+        )
+        with self.assertRaisesRegex(ValueError, "key must be a datetime.time"):
+            invalid_time_key_writer.add_element(GenericRow(
+                [{"not-a-time": BlobData(b"value")}],
+                [time_key_field],
+                RowKind.INSERT,
+            ))
+
     def test_reject_malformed_map_blob_payloads(self):
         string_field = DataField(
             0,
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 5db6840a31..ce7b9d6038 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
@@ -1564,6 +1564,9 @@ class JavaPyReadWriteTest(unittest.TestCase):
             'date_payloads': {
                 datetime.date(1969, 12, 31): b'java-date',
             },
+            'time_payloads': {
+                datetime.time(12, 34, 56, 789000): b'java-time',
+            },
         }
         for name, expected in expected_additional_payloads.items():
             self.assertEqual(
@@ -1580,6 +1583,7 @@ class JavaPyReadWriteTest(unittest.TestCase):
         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())
+        time_map_blob_type = pa.map_(pa.time32('ms'), pa.large_binary())
         pa_schema = pa.schema([
             ('id', pa.int32()),
             ('payloads', map_blob_type),
@@ -1587,6 +1591,7 @@ class JavaPyReadWriteTest(unittest.TestCase):
             ('compact_decimal_payloads', compact_decimal_map_blob_type),
             ('high_decimal_payloads', high_decimal_map_blob_type),
             ('date_payloads', date_map_blob_type),
+            ('time_payloads', time_map_blob_type),
         ])
         schema = Schema.from_pyarrow_schema(
             pa_schema,
@@ -1635,6 +1640,13 @@ class JavaPyReadWriteTest(unittest.TestCase):
                 )], None, None, None],
                 type=date_map_blob_type,
             ),
+            'time_payloads': pa.array(
+                [[(
+                    datetime.time(12, 34, 56, 789000),
+                    b'python-time',
+                )], None, None, None],
+                type=time_map_blob_type,
+            ),
         }, schema=pa_schema)
         write_builder = table.new_batch_write_builder()
         table_write = write_builder.new_write()
@@ -1670,6 +1682,9 @@ class JavaPyReadWriteTest(unittest.TestCase):
             'date_payloads': {
                 datetime.date(1969, 12, 31): b'python-date',
             },
+            'time_payloads': {
+                datetime.time(12, 34, 56, 789000): b'python-time',
+            },
         }
         for name, expected in expected_additional_payloads.items():
             self.assertEqual(

Reply via email to