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

lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 3e9304a6 feat(blob): support writing MAP<K, BLOB> fields (#416)
3e9304a6 is described below

commit 3e9304a6236ff80738a8a930d88a3f563d474f95
Author: Nicholas Jiang <[email protected]>
AuthorDate: Thu Oct 8 14:09:11 2026 +0700

    feat(blob): support writing MAP<K, BLOB> fields (#416)
---
 docs/source/user_guide/data_types.rst              |  27 +-
 docs/source/user_guide/write.rst                   |  68 ++-
 include/paimon/defs.h                              |  16 +-
 src/paimon/common/data/blob_defs.h                 |   9 +
 src/paimon/common/data/blob_utils.cpp              |  21 +-
 src/paimon/common/data/blob_utils.h                |   6 +-
 src/paimon/common/data/blob_utils_test.cpp         |  22 +-
 .../append/append_compact_coordinator_test.cpp     |   6 +-
 src/paimon/core/operation/file_store_write.cpp     |   2 -
 .../core/operation/file_store_write_test.cpp       |  28 --
 src/paimon/core/schema/schema_validation.cpp       |  14 +
 src/paimon/core/schema/schema_validation_test.cpp  |  35 ++
 src/paimon/core/schema/table_schema.cpp            |  10 +-
 src/paimon/core/schema/table_schema_test.cpp       |  32 +-
 src/paimon/format/blob/blob_file_batch_reader.cpp  |   6 +-
 src/paimon/format/blob/blob_format_writer.cpp      | 223 +++++++++-
 src/paimon/format/blob/blob_format_writer.h        |  53 ++-
 src/paimon/format/blob/blob_format_writer_test.cpp | 480 ++++++++++++++++++++-
 test/inte/blob_table_inte_test.cpp                 | 127 ++++++
 19 files changed, 1042 insertions(+), 143 deletions(-)

diff --git a/docs/source/user_guide/data_types.rst 
b/docs/source/user_guide/data_types.rst
index e893d498..f864596d 100644
--- a/docs/source/user_guide/data_types.rst
+++ b/docs/source/user_guide/data_types.rst
@@ -294,9 +294,13 @@ and `Arrow DataTypes 
<https://arrow.apache.org/docs/format/Columnar.html#data-ty
    * - ``BLOB``
 
        ``ARRAY<BLOB>``
+
+       ``MAP<kt, BLOB>``
      - LargeBinary
 
        List<LargeBinary>
+
+       Map<kt, LargeBinary>
      - Data type of a binary large object, such as an image or a video, stored
        in dedicated ``.blob`` files instead of the normal data files. A BLOB
        column listed in ``blob-descriptor-field`` or ``blob-view-field`` 
instead
@@ -308,12 +312,17 @@ and `Arrow DataTypes 
<https://arrow.apache.org/docs/format/Columnar.html#data-ty
        field. An ``ARRAY<BLOB>`` field is a top-level ``List`` field whose
        element field is a BLOB field, which stores an ordered collection of
        objects (e.g. the frames of one sample) in a single blob entry. Both the
-       array and its elements may be null.
-
-       Tables with BLOB or ``ARRAY<BLOB>`` columns must enable
-       ``row-tracking.enabled`` and ``data-evolution.enabled``, and must have
-       at least one non-BLOB column. These columns cannot be partition keys.
-       ``ARRAY<BLOB>`` cannot be nested inside other types or listed in
-       ``blob-descriptor-field`` or ``blob-view-field``. Paimon C++ does not
-       support compacting these tables yet, and can read but not write
-       ``MAP<kt, BLOB>`` columns. See :doc:`write` for writing BLOB columns.
+       array and its elements may be null. A ``MAP<kt, BLOB>`` field is a
+       top-level ``Map`` field whose values are ``LargeBinary``, which stores
+       keyed objects (e.g. the views of one sample) in a single blob entry. 
Both
+       the map and its values may be null, while its keys must be unique and
+       non-null. ``kt`` must be BOOLEAN, TINYINT, SMALLINT, INT, BIGINT, DATE,
+       DECIMAL, CHAR, VARCHAR, BINARY or VARBINARY.
+
+       Tables with BLOB, ``ARRAY<BLOB>`` or ``MAP<kt, BLOB>`` columns must
+       enable ``row-tracking.enabled`` and ``data-evolution.enabled``, and must
+       have at least one non-BLOB column. These columns cannot be partition
+       keys. ``ARRAY<BLOB>`` and ``MAP<kt, BLOB>`` cannot be nested inside 
other
+       types or listed in ``blob-descriptor-field`` or ``blob-view-field``.
+       Paimon C++ does not support compacting these tables yet. See 
:doc:`write`
+       for writing BLOB columns.
diff --git a/docs/source/user_guide/write.rst b/docs/source/user_guide/write.rst
index 89f467db..4a3d27db 100644
--- a/docs/source/user_guide/write.rst
+++ b/docs/source/user_guide/write.rst
@@ -75,10 +75,11 @@ Writing BLOB Columns
 ~~~~~~~~~~~~~~~~~~~~
 
 A ``BLOB`` column is a ``LargeBinary`` field carrying Paimon's BLOB field
-metadata, and an ``ARRAY<BLOB>`` column is a top-level ``List`` field whose
-element field carries it. Build the BLOB field with 
``paimon::Blob::ArrowField``
-and import it into Arrow; the element field of an ``ARRAY<BLOB>`` column must
-keep that metadata:
+metadata, an ``ARRAY<BLOB>`` column is a top-level ``List`` field whose element
+field carries it, and a ``MAP<kt, BLOB>`` column is a top-level ``Map`` field
+whose values are ``LargeBinary``. Build the BLOB field with
+``paimon::Blob::ArrowField`` and import it into Arrow; the element field of an
+``ARRAY<BLOB>`` column must keep that metadata:
 
 .. code-block:: cpp
 
@@ -87,14 +88,16 @@ keep that metadata:
    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Field> element,
                                      arrow::ImportField(c_element.get()));
    std::shared_ptr<arrow::Schema> schema = arrow::schema(
-       {arrow::field("id", arrow::int32()), arrow::field("frames", 
arrow::list(element))});
+       {arrow::field("id", arrow::int32()), arrow::field("frames", 
arrow::list(element)),
+        arrow::field("views", arrow::map(arrow::utf8(), element))});
 
-For a column stored in blob files, which includes every ``ARRAY<BLOB>`` column,
-each value or element holds either the raw bytes or a serialized
-``paimon::BlobDescriptor`` produced by ``paimon::Blob::ToDescriptor``; the 
writer
-copies the referenced data into the blob file. A BLOB column listed in
-``blob-descriptor-field`` or ``blob-view-field`` keeps the reference in the 
data
-file instead, so the referenced data must remain available.
+For a column stored in blob files, which includes every ``ARRAY<BLOB>`` and
+``MAP<kt, BLOB>`` column, each value, element or map value holds either the raw
+bytes or a serialized ``paimon::BlobDescriptor`` produced by
+``paimon::Blob::ToDescriptor``; the writer copies the referenced data into the
+blob file. A BLOB column listed in ``blob-descriptor-field`` or
+``blob-view-field`` keeps the reference in the data file instead, so the
+referenced data must remain available.
 
 If the referenced data cannot be reached, the write fails unless a write-null
 option covers the failure: ``blob-write-null-on-missing-file`` covers a
@@ -102,11 +105,14 @@ referenced file that does not exist, and 
``blob-write-null-on-fetch-failure``
 covers any other failure to resolve the descriptor or open the data, including
 a missing file when the former is disabled and an offset past the end of the
 file for a descriptor with a dynamic length (``-1``). A covered value is 
written
-as NULL; in an ``ARRAY<BLOB>`` only that element becomes NULL, not the array.
+as NULL; in an ``ARRAY<BLOB>`` or a ``MAP<kt, BLOB>`` only that element or map
+value becomes NULL, not the array or map.
 Any other failure fails the write, such as a failure to write the blob file or
 to close a referenced file. As in Paimon Java, this includes a file too short
 for the range of a descriptor with a known length, which is only detected while
 the data is copied.
+Except for the placeholder marker defined below, a ``MAP<kt, BLOB>`` value with
+a repeated or null key also fails the write, before any of its data is written.
 See :doc:`data_types` for the table requirements and restrictions.
 
 A data-evolution write can update columns of existing rows. The write does not
@@ -125,20 +131,40 @@ payload was serialized with.
 In such an update, a row whose BLOB or ``ARRAY<BLOB>`` value stays unchanged is
 marked with the reserved bytes ``_PAIMON_BLOB_PLACEHOLDER``: as the value 
itself
 for a BLOB column, or as the only element of the array for an ``ARRAY<BLOB>``
-column. As in Paimon Java, the marker works whatever other columns the update
-carries. Such a row keeps its value from the older files when read. Every write
-stores a value equal to the reserved bytes as such a marker, so that value is
-not supported.
+column. Every write stores a value equal to the reserved bytes as such a 
marker,
+so that value is not supported. A row whose ``MAP<kt, BLOB>`` value stays
+unchanged is marked with a map of exactly two entries with equal keys and null
+values, which no other map can hold since its keys must be unique. As in Paimon
+Java, a marker works whatever other columns the update carries, and its row
+keeps its value from the older files when read.
+
+Append the two entries of a ``MAP<kt, BLOB>`` marker one by one, for example
+with ``arrow::MapBuilder``, since a JSON object or a dictionary merges equal
+keys into one entry:
+
+.. code-block:: cpp
+
+   // map_builder builds the MAP<STRING, BLOB> column of the update.
+   auto* keys = static_cast<arrow::StringBuilder*>(map_builder->key_builder());
+   auto* values = 
static_cast<arrow::LargeBinaryBuilder*>(map_builder->item_builder());
+   ARROW_RETURN_NOT_OK(map_builder->Append());
+   for (int32_t i = 0; i < 2; ++i) {
+       ARROW_RETURN_NOT_OK(keys->Append("k"));
+       ARROW_RETURN_NOT_OK(values->AppendNull());
+   }
 
 .. note::
    The C++ writer differs from Paimon Java in these respects:
 
-   - A placeholder is identified by the reserved bytes; Java uses a dedicated
-     placeholder object, which cannot collide with a user value.
+   - A placeholder is identified by the reserved bytes, which can collide with 
a
+     user value, or by the two-entry map marker above; Java uses a dedicated
+     placeholder object.
+   - A ``MAP<kt, BLOB>`` key cannot be null, as Arrow map keys are not
+     nullable, and cannot be TIME; Java allows one null key and TIME keys.
    - A missing file is detected with ``FileSystem::Exists``. Java detects a
-     missing file for an ``ARRAY<BLOB>`` element only from an HTTP 404, and
-     does not write NULL for a 404 under ``blob-write-null-on-fetch-failure``
-     alone.
+     missing file for an ``ARRAY<BLOB>`` element or a ``MAP<kt, BLOB>`` value
+     only from an HTTP 404, and does not write NULL for a 404 under
+     ``blob-write-null-on-fetch-failure`` alone.
    - A descriptor with a dynamic length is copied up to the file length read
      when it is opened, so data appended to the file during the copy is left
      out, and a file truncated during the copy fails the write. Java reads it
diff --git a/include/paimon/defs.h b/include/paimon/defs.h
index bea13f61..d6abd047 100644
--- a/include/paimon/defs.h
+++ b/include/paimon/defs.h
@@ -574,17 +574,19 @@ struct PAIMON_EXPORT Options {
     /// path and requires manual configuration by the user. No default value.
     static const char BLOB_VIEW_UPSTREAM_WAREHOUSE[];
     /// "blob-write-null-on-missing-file" - Whether to write NULL for a 
descriptor BLOB value,
-    /// including an ARRAY<BLOB> element, when the referenced file does not 
exist at write time.
+    /// including an ARRAY<BLOB> element or a MAP<..., BLOB> value, when the 
referenced file does
+    /// not exist at write time.
     /// When false, a missing file is treated like any other fetch failure, 
following
     /// "blob-write-null-on-fetch-failure". Default value is "false".
     static const char BLOB_WRITE_NULL_ON_MISSING_FILE[];
     /// "blob-write-null-on-fetch-failure" - Whether to write NULL for a 
descriptor BLOB value,
-    /// including an ARRAY<BLOB> element, when the referenced data cannot be 
reached at write time,
-    /// i.e. the descriptor cannot be deserialized or the data cannot be 
opened (e.g. the offset of
-    /// a descriptor with dynamic length -1 is past the end of the file). A 
missing file is handled
-    /// by "blob-write-null-on-missing-file" when that option is enabled. When 
false, such a
-    /// failure fails the write. A failure while copying the data, such as a 
read error or a file
-    /// too short for the range of a descriptor with a known length, always 
fails the write.
+    /// including an ARRAY<BLOB> element or a MAP<..., BLOB> value, when the 
referenced data cannot
+    /// be reached at write time, i.e. the descriptor cannot be deserialized 
or the data cannot be
+    /// opened (e.g. the offset of a descriptor with dynamic length -1 is past 
the end of the
+    /// file). A missing file is handled by "blob-write-null-on-missing-file" 
when that option is
+    /// enabled. When false, such a failure fails the write. A failure while 
copying the data, such
+    /// as a read error or a file too short for the range of a descriptor with 
a known length,
+    /// always fails the write.
     /// Default value is "false".
     static const char BLOB_WRITE_NULL_ON_FETCH_FAILURE[];
     /// "global-index.enabled" - Whether to enable global index for scan. 
Default value is "true".
diff --git a/src/paimon/common/data/blob_defs.h 
b/src/paimon/common/data/blob_defs.h
index 68d396da..ce5932b4 100644
--- a/src/paimon/common/data/blob_defs.h
+++ b/src/paimon/common/data/blob_defs.h
@@ -76,6 +76,11 @@ class BlobDefs {
     /// merge resolves from an older layer, or degrades to a null blob when 
every layer holds a
     /// placeholder for the row. The marker is distinctive enough that these 
collisions are
     /// accepted as negligibly improbable.
+    ///
+    /// A MAP<..., BLOB> placeholder does not use these bytes: both channels 
mark it as a map of
+    /// exactly two entries with equal keys and null values (see 
BlobUtils::IsMapBlobPlaceholder).
+    /// The writer rejects duplicate keys in any other map, so that marker 
never collides with a
+    /// user value.
     static constexpr char kPlaceholderSentinel[] = "_PAIMON_BLOB_PLACEHOLDER";
     /// Byte length of kPlaceholderSentinel, excluding the literal's 
terminating NUL.
     static constexpr int32_t kPlaceholderSentinelLength = 
sizeof(kPlaceholderSentinel) - 1;
@@ -119,6 +124,10 @@ class BlobDefs {
     static constexpr int32_t kArrayBlobMagicNumber = 1094861634;
     /// ARRAY<BLOB> nested payload version.
     static constexpr int8_t kArrayBlobVersion = 1;
+    /// Magic number identifying the nested payload of a MAP<..., BLOB> entry.
+    static constexpr int32_t kMapBlobMagicNumber = 0x4D424342;
+    /// MAP<..., BLOB> nested payload version.
+    static constexpr int8_t kMapBlobVersion = 1;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/common/data/blob_utils.cpp 
b/src/paimon/common/data/blob_utils.cpp
index a601ee4b..fd12cbcd 100644
--- a/src/paimon/common/data/blob_utils.cpp
+++ b/src/paimon/common/data/blob_utils.cpp
@@ -166,14 +166,21 @@ bool BlobUtils::IsMapBlobPlaceholder(const 
arrow::MapArray& array, int64_t row)
     return keys->RangeEquals(entry_index, entry_index + 1, entry_index + 1, 
*keys);
 }
 
-Status BlobUtils::ValidateContainerBlobWriteSchema(const 
std::shared_ptr<arrow::Schema>& schema) {
-    for (const auto& field : schema->fields()) {
-        if (IsMapBlobField(field)) {
-            return Status::NotImplemented(
-                "Writing a table with MAP<..., BLOB> is not supported by the 
C++ writer.");
-        }
+bool BlobUtils::IsSupportedMapBlobKeyType(const arrow::DataType& key_type) {
+    switch (key_type.id()) {
+        case arrow::Type::BOOL:
+        case arrow::Type::INT8:
+        case arrow::Type::INT16:
+        case arrow::Type::INT32:
+        case arrow::Type::INT64:
+        case arrow::Type::DATE32:
+        case arrow::Type::DECIMAL128:
+        case arrow::Type::STRING:
+        case arrow::Type::BINARY:
+            return true;
+        default:
+            return false;
     }
-    return Status::OK();
 }
 
 bool BlobUtils::IsBlobMetadata(const std::shared_ptr<const 
arrow::KeyValueMetadata>& metadata) {
diff --git a/src/paimon/common/data/blob_utils.h 
b/src/paimon/common/data/blob_utils.h
index b7c6c6c2..8292c108 100644
--- a/src/paimon/common/data/blob_utils.h
+++ b/src/paimon/common/data/blob_utils.h
@@ -30,6 +30,7 @@
 #include "paimon/visibility.h"
 
 namespace arrow {
+class DataType;
 class Field;
 class KeyValueMetadata;
 class ListArray;
@@ -88,8 +89,9 @@ class PAIMON_EXPORT BlobUtils {
     static bool IsArrayBlobPlaceholder(const arrow::ListArray& array, int64_t 
row);
     /// Returns whether a MAP<..., BLOB> row is the internal fallback sentinel.
     static bool IsMapBlobPlaceholder(const arrow::MapArray& array, int64_t 
row);
-    /// Rejects MAP<..., BLOB>, which is currently supported by the C++ reader 
only.
-    static Status ValidateContainerBlobWriteSchema(const 
std::shared_ptr<arrow::Schema>& schema);
+    /// Returns whether Paimon C++ supports `key_type` as the key type of 
MAP<..., BLOB>:
+    /// BOOLEAN, TINYINT, SMALLINT, INT, BIGINT, DATE, DECIMAL, CHAR/VARCHAR 
and BINARY/VARBINARY.
+    static bool IsSupportedMapBlobKeyType(const arrow::DataType& key_type);
     static bool IsBlobMetadata(const std::shared_ptr<const 
arrow::KeyValueMetadata>& metadata);
     static bool IsBlobFile(const std::string& file_name);
 
diff --git a/src/paimon/common/data/blob_utils_test.cpp 
b/src/paimon/common/data/blob_utils_test.cpp
index 9e989c86..b4d9a21b 100644
--- a/src/paimon/common/data/blob_utils_test.cpp
+++ b/src/paimon/common/data/blob_utils_test.cpp
@@ -92,17 +92,17 @@ TEST_F(BlobUtilsTest, IsArrayBlobField) {
         arrow::field("nested", 
arrow::list(arrow::list(BlobUtils::ToArrowField("item", true))))));
 }
 
-TEST_F(BlobUtilsTest, ValidateContainerBlobWriteSchema) {
-    auto array_blob_field =
-        arrow::field("array_blob", arrow::list(BlobUtils::ToArrowField("item", 
true)));
-    ASSERT_OK(BlobUtils::ValidateContainerBlobWriteSchema(
-        arrow::schema({arrow::field("id", arrow::int32()), 
array_blob_field})));
-
-    auto map_blob_field =
-        arrow::field("map_blob", arrow::map(arrow::utf8(), 
BlobUtils::ToArrowField("value", true)));
-    ASSERT_NOK_WITH_MSG(BlobUtils::ValidateContainerBlobWriteSchema(
-                            arrow::schema({array_blob_field, map_blob_field})),
-                        "Writing a table with MAP<..., BLOB> is not supported 
by the C++ writer");
+TEST_F(BlobUtilsTest, IsSupportedMapBlobKeyType) {
+    for (const auto& key_type : {arrow::boolean(), arrow::int8(), 
arrow::int16(), arrow::int32(),
+                                 arrow::int64(), arrow::date32(), 
arrow::decimal128(10, 2),
+                                 arrow::decimal128(38, 2), arrow::utf8(), 
arrow::binary()}) {
+        ASSERT_TRUE(BlobUtils::IsSupportedMapBlobKeyType(*key_type)) << 
key_type->ToString();
+    }
+    for (const auto& key_type :
+         {arrow::float32(), arrow::float64(), 
arrow::time32(arrow::TimeUnit::MILLI),
+          arrow::timestamp(arrow::TimeUnit::MICRO), arrow::large_binary(), 
arrow::large_utf8()}) {
+        ASSERT_FALSE(BlobUtils::IsSupportedMapBlobKeyType(*key_type)) << 
key_type->ToString();
+    }
 }
 
 TEST_F(BlobUtilsTest, IsArrayBlobPlaceholder) {
diff --git a/src/paimon/core/append/append_compact_coordinator_test.cpp 
b/src/paimon/core/append/append_compact_coordinator_test.cpp
index 57758ace..acdf59cb 100644
--- a/src/paimon/core/append/append_compact_coordinator_test.cpp
+++ b/src/paimon/core/append/append_compact_coordinator_test.cpp
@@ -528,7 +528,7 @@ TEST_F(AppendCompactCoordinatorTest, 
TestValidateFailsOnDataEvolutionTable) {
                         "not support for data evolution in UNAWARE_BUCKET 
mode");
 }
 
-TEST_F(AppendCompactCoordinatorTest, TestValidateFailsOnArrayBlobTable) {
+TEST_F(AppendCompactCoordinatorTest, TestValidateFailsOnContainerBlobTable) {
     std::map<std::string, std::string> options = {{Options::FILE_FORMAT, 
"parquet"},
                                                   {Options::BUCKET, "-1"},
                                                   {Options::FILE_SYSTEM, 
"local"},
@@ -537,7 +537,9 @@ TEST_F(AppendCompactCoordinatorTest, 
TestValidateFailsOnArrayBlobTable) {
 
     arrow::FieldVector fields = {
         arrow::field("f0", arrow::int32()),
-        arrow::field("a0", arrow::list(BlobUtils::ToArrowField("item", 
/*nullable=*/true)))};
+        arrow::field("a0", arrow::list(BlobUtils::ToArrowField("item", 
/*nullable=*/true))),
+        arrow::field(
+            "m0", arrow::map(arrow::utf8(), BlobUtils::ToArrowField("value", 
/*nullable=*/true)))};
     CreateTable(fields, /*partition_keys=*/{}, options);
 
     ASSERT_NOK_WITH_MSG(AppendCompactCoordinator::Run(TablePath(), options, 
/*partitions=*/{},
diff --git a/src/paimon/core/operation/file_store_write.cpp 
b/src/paimon/core/operation/file_store_write.cpp
index 6056ba1e..2ae664f8 100644
--- a/src/paimon/core/operation/file_store_write.cpp
+++ b/src/paimon/core/operation/file_store_write.cpp
@@ -26,7 +26,6 @@
 
 #include "fmt/format.h"
 #include "paimon/catalog/catalog.h"
-#include "paimon/common/data/blob_utils.h"
 #include "paimon/common/global_index/btree/btree_defs.h"
 #include "paimon/common/types/data_field.h"
 #include "paimon/common/utils/fields_comparator.h"
@@ -239,7 +238,6 @@ Result<std::unique_ptr<FileStoreWrite>> 
FileStoreWrite::Create(std::unique_ptr<W
     }
     const std::shared_ptr<TableSchema>& schema = latest_schema;
     auto arrow_schema = 
DataField::ConvertDataFieldsToArrowSchema(schema->Fields());
-    
PAIMON_RETURN_NOT_OK(BlobUtils::ValidateContainerBlobWriteSchema(arrow_schema));
     auto opts = schema->Options();
     for (const auto& [key, value] : ctx->GetOptions()) {
         opts[key] = value;
diff --git a/src/paimon/core/operation/file_store_write_test.cpp 
b/src/paimon/core/operation/file_store_write_test.cpp
index 3893d857..520e850e 100644
--- a/src/paimon/core/operation/file_store_write_test.cpp
+++ b/src/paimon/core/operation/file_store_write_test.cpp
@@ -253,34 +253,6 @@ TEST(FileStoreWriteTest, 
TestCatalogWriteUsesPerTableFileSystem) {
     ASSERT_EQ(requests.back().GetFullName(), identifier.GetFullName());
 }
 
-TEST(FileStoreWriteTest, TestCreateWriterForLoadedMapBlobTable) {
-    auto dir = UniqueTestDirectory::Create();
-    std::string table_path = PathUtil::JoinPath(dir->Str(), "foo.db/bar");
-    auto fs = std::make_shared<LocalFileSystem>();
-    SchemaManager schema_manager(fs, table_path);
-    std::string schema_json = R"json({
-        "version" : 3,
-        "id" : 0,
-        "fields" : [ {
-            "id" : 0,
-            "name" : "blob_map",
-            "type" : {"type":"MAP", "key":"STRING", "value":"BLOB"}
-        } ],
-        "highestFieldId" : 0,
-        "partitionKeys" : [],
-        "primaryKeys" : [],
-        "options" : {},
-        "timeMillis" : 1721614341162
-    })json";
-    
ASSERT_OK(fs->AtomicStore(PathUtil::JoinPath(schema_manager.SchemaDirectory(), 
"schema-0"),
-                              schema_json));
-
-    WriteContextBuilder context_builder(table_path, "commit_user_1");
-    ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriteContext> write_context, 
context_builder.Finish());
-    ASSERT_NOK_WITH_MSG(FileStoreWrite::Create(std::move(write_context)),
-                        "Writing a table with MAP<..., BLOB> is not supported 
by the C++ writer");
-}
-
 TEST(FileStoreWriteTest, TestCreateAppendTableWithInvalidBucket) {
     auto dir = UniqueTestDirectory::Create();
     arrow::FieldVector fields = {
diff --git a/src/paimon/core/schema/schema_validation.cpp 
b/src/paimon/core/schema/schema_validation.cpp
index 9a5b2983..a5992455 100644
--- a/src/paimon/core/schema/schema_validation.cpp
+++ b/src/paimon/core/schema/schema_validation.cpp
@@ -801,6 +801,20 @@ Status SchemaValidation::ValidateRowTracking(const 
TableSchema& table_schema,
 }
 
 Status SchemaValidation::ValidateBlobFields(const TableSchema& schema, const 
CoreOptions& options) {
+    // A MAP<..., BLOB> field goes to blob files by its type alone, so its key 
type is checked
+    // whether or not a blob option names it.
+    for (const auto& field : schema.Fields()) {
+        if (!BlobUtils::IsMapBlobField(field.ArrowField())) {
+            continue;
+        }
+        const auto& map_type = checked_cast<const 
arrow::MapType&>(*field.Type());
+        if (!BlobUtils::IsSupportedMapBlobKeyType(*map_type.key_type())) {
+            return Status::NotImplemented(
+                fmt::format("Field '{}' has unsupported MAP<..., BLOB> key 
type: {}.", field.Name(),
+                            map_type.key_type()->ToString()));
+        }
+    }
+
     const auto& configured_blob_names = options.GetBlobFields();
     const auto& blob_descriptor_names = options.GetBlobDescriptorFields();
     const auto& blob_view_names = options.GetBlobViewFields();
diff --git a/src/paimon/core/schema/schema_validation_test.cpp 
b/src/paimon/core/schema/schema_validation_test.cpp
index bccbad8e..f0823157 100644
--- a/src/paimon/core/schema/schema_validation_test.cpp
+++ b/src/paimon/core/schema/schema_validation_test.cpp
@@ -459,6 +459,41 @@ TEST(SchemaValidationTest, TestContainerBlobRowTracking) {
     }
 }
 
+TEST(SchemaValidationTest, TestMapBlobKeyType) {
+    const std::string options_json = R"({
+        "bucket": "-1",
+        "row-tracking.enabled": "true",
+        "data-evolution.enabled": "true"
+    })";
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<TableSchema> table_schema,
+        
MakeContainerBlobSchema(R"json({"type":"MAP","key":"STRING","value":"BLOB"})json",
 "[]",
+                                options_json));
+    ASSERT_OK(SchemaValidation::ValidateTableSchema(*table_schema));
+
+    // Checked without any blob option naming the field.
+    ASSERT_OK_AND_ASSIGN(
+        table_schema,
+        
MakeContainerBlobSchema(R"json({"type":"MAP","key":"TIME(3)","value":"BLOB"})json",
 "[]",
+                                options_json));
+    ASSERT_NOK_WITH_MSG(SchemaValidation::ValidateTableSchema(*table_schema),
+                        "Field 'blob' has unsupported MAP<..., BLOB> key type: 
time32[ms].");
+
+    std::map<std::string, std::string> options = {{Options::BUCKET, "-1"},
+                                                  
{Options::ROW_TRACKING_ENABLED, "true"},
+                                                  
{Options::DATA_EVOLUTION_ENABLED, "true"},
+                                                  {Options::BLOB_FIELD, 
"blob_map"}};
+    auto schema = arrow::schema(
+        {arrow::field("id", arrow::int32()),
+         arrow::field("blob_map", arrow::map(arrow::float32(), 
BlobUtils::ToArrowField(
+                                                                   "value", 
/*nullable=*/true)))});
+    ASSERT_OK_AND_ASSIGN(table_schema,
+                         TableSchema::Create(/*schema_id=*/0, schema, 
/*partition_keys=*/{},
+                                             /*primary_keys=*/{}, options));
+    ASSERT_NOK_WITH_MSG(SchemaValidation::ValidateTableSchema(*table_schema),
+                        "Field 'blob_map' has unsupported MAP<..., BLOB> key 
type: float.");
+}
+
 TEST(SchemaValidationTest, TestWithBlobField) {
     auto f0 = arrow::field("f0", arrow::utf8());
     auto f1 = arrow::field("f1", arrow::int32());
diff --git a/src/paimon/core/schema/table_schema.cpp 
b/src/paimon/core/schema/table_schema.cpp
index 5e70ff5a..1d69c033 100644
--- a/src/paimon/core/schema/table_schema.cpp
+++ b/src/paimon/core/schema/table_schema.cpp
@@ -27,6 +27,7 @@
 #include "arrow/api.h"
 #include "arrow/c/bridge.h"
 #include "fmt/format.h"
+#include "paimon/common/data/blob_defs.h"
 #include "paimon/common/data/blob_utils.h"
 #include "paimon/common/data/variant/variant_type_utils.h"
 #include "paimon/common/utils/arrow/status_utils.h"
@@ -57,7 +58,6 @@ Result<std::unique_ptr<TableSchema>> TableSchema::Create(
     for (const auto& primary_key : primary_keys) {
         primary_key_set.insert(primary_key);
     }
-    PAIMON_RETURN_NOT_OK(BlobUtils::ValidateContainerBlobWriteSchema(schema));
     for (const auto& field : schema->fields()) {
         PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Field> field_with_id,
                                AssignFieldIdsRecursively(field, 
/*set_field_id=*/true, &field_id));
@@ -132,6 +132,14 @@ Result<std::shared_ptr<arrow::Field>> 
TableSchema::AssignFieldIdsRecursively(
         auto map_type = checked_pointer_cast<arrow::MapType>(field->type());
         std::shared_ptr<arrow::Field> key_field = map_type->key_field();
         std::shared_ptr<arrow::Field> value_field = map_type->item_field();
+        // Arrow's C schema bridge drops the metadata of a map value field, 
which marks a BLOB.
+        // Paimon BYTES is BINARY, so a LARGE_BINARY map value can only be a 
BLOB (see
+        // BlobUtils::IsMapBlobField); restore the marker.
+        if (value_field->type()->id() == arrow::Type::LARGE_BINARY &&
+            !BlobUtils::IsBlobMetadata(value_field->metadata())) {
+            value_field = 
value_field->WithMergedMetadata(arrow::KeyValueMetadata::Make(
+                {BlobDefs::kExtensionTypeKey}, 
{BlobDefs::kExtensionTypeValue}));
+        }
         PAIMON_ASSIGN_OR_RAISE(
             key_field, AssignFieldIdsRecursively(key_field, 
/*set_field_id=*/false, field_id));
         PAIMON_ASSIGN_OR_RAISE(
diff --git a/src/paimon/core/schema/table_schema_test.cpp 
b/src/paimon/core/schema/table_schema_test.cpp
index e0f75ca4..73909d41 100644
--- a/src/paimon/core/schema/table_schema_test.cpp
+++ b/src/paimon/core/schema/table_schema_test.cpp
@@ -22,6 +22,7 @@
 #include <utility>
 
 #include "arrow/api.h"
+#include "arrow/c/bridge.h"
 #include "gtest/gtest.h"
 #include "paimon/common/data/blob_utils.h"
 #include "paimon/common/data/variant/variant_type_utils.h"
@@ -1358,12 +1359,33 @@ TEST_F(TableSchemaTest, MapBlobSchemaLoadsFromJson) {
     ASSERT_EQ(restored_json, serialized);
 }
 
-TEST_F(TableSchemaTest, CreatingMapBlobSchemaIsRejected) {
+TEST_F(TableSchemaTest, CreatingMapBlobSchemaIsAllowed) {
     auto map_type = arrow::map(arrow::utf8(), BlobUtils::ToArrowField("value", 
/*nullable=*/true));
-    ASSERT_NOK_WITH_MSG(
-        TableSchema::Create(/*schema_id=*/0, 
arrow::schema({arrow::field("blob_map", map_type)}),
-                            /*partition_keys=*/{}, /*primary_keys=*/{}, 
/*options=*/{}),
-        "not supported by the C++ writer");
+    auto schema =
+        arrow::schema({arrow::field("id", arrow::int32()), 
arrow::field("blob_map", map_type)});
+    ::ArrowSchema c_schema;
+    ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+    std::shared_ptr<arrow::Schema> imported = 
arrow::ImportSchema(&c_schema).ValueOrDie();
+    auto missing_metadata_value = arrow::field("value", arrow::large_binary(), 
/*nullable=*/true);
+    ASSERT_FALSE(BlobUtils::IsBlobField(missing_metadata_value));
+    auto missing_metadata = arrow::schema(
+        {arrow::field("id", arrow::int32()),
+         arrow::field("blob_map", arrow::map(arrow::utf8(), 
missing_metadata_value))});
+
+    for (const auto& input : {schema, imported, missing_metadata}) {
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableSchema> table_schema,
+                             TableSchema::Create(/*schema_id=*/0, input, 
/*partition_keys=*/{},
+                                                 /*primary_keys=*/{}, 
/*options=*/{}));
+        ASSERT_OK_AND_ASSIGN(std::string serialized, 
table_schema->ToJsonString());
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableSchema> restored,
+                             TableSchema::CreateFromJson(serialized));
+        for (const auto& fields : {table_schema->Fields(), 
restored->Fields()}) {
+            auto field = DataField::ConvertDataFieldToArrowField(fields[1]);
+            ASSERT_TRUE(BlobUtils::IsMapBlobField(field));
+            ASSERT_TRUE(BlobUtils::IsBlobField(
+                checked_cast<const 
arrow::MapType&>(*field->type()).item_field()));
+        }
+    }
 }
 
 TEST_F(TableSchemaTest, CreatingArrayBlobSchemaIsAllowed) {
diff --git a/src/paimon/format/blob/blob_file_batch_reader.cpp 
b/src/paimon/format/blob/blob_file_batch_reader.cpp
index 86ff1f1c..5856e88d 100644
--- a/src/paimon/format/blob/blob_file_batch_reader.cpp
+++ b/src/paimon/format/blob/blob_file_batch_reader.cpp
@@ -53,8 +53,6 @@ constexpr int32_t kArrayBlobHeaderLength = 9;
 constexpr int32_t kArrayBlobIndexLengthSize = 4;
 constexpr int32_t kArrayBlobMinPayloadLength = kArrayBlobHeaderLength + 
kArrayBlobIndexLengthSize;
 
-constexpr int32_t kMapBlobMagicNumber = 0x4D424342;
-constexpr int8_t kMapBlobVersion = 1;
 constexpr int32_t kMapBlobHeaderLength = 9;
 constexpr int32_t kMapBlobIndexLengthsSize = 8;
 constexpr int32_t kMapBlobMinPayloadLength = kMapBlobHeaderLength + 
kMapBlobIndexLengthsSize;
@@ -788,12 +786,12 @@ Result<BlobFileBatchReader::MapBlobPayload> 
BlobFileBatchReader::ReadMapBlobPayl
     std::array<uint8_t, kMapBlobHeaderLength> header;
     PAIMON_RETURN_NOT_OK(ReadBlobContentAt(payload_offset, header.size(), 
header.data()));
     const auto magic_number = ReadLittleEndian<int32_t>(header.data());
-    if (magic_number != kMapBlobMagicNumber) {
+    if (magic_number != BlobDefs::kMapBlobMagicNumber) {
         return Status::Invalid(
             fmt::format("invalid MAP<..., BLOB> payload magic number: {}", 
magic_number));
     }
     const auto version = static_cast<int8_t>(header[4]);
-    if (version != kMapBlobVersion) {
+    if (version != BlobDefs::kMapBlobVersion) {
         return Status::NotImplemented(
             fmt::format("unsupported MAP<..., BLOB> payload version: {}", 
version));
     }
diff --git a/src/paimon/format/blob/blob_format_writer.cpp 
b/src/paimon/format/blob/blob_format_writer.cpp
index b7673f06..7905be9d 100644
--- a/src/paimon/format/blob/blob_format_writer.cpp
+++ b/src/paimon/format/blob/blob_format_writer.cpp
@@ -23,10 +23,13 @@
 #include <map>
 #include <optional>
 #include <string>
+#include <string_view>
+#include <unordered_set>
 #include <vector>
 
 #include "arrow/api.h"
 #include "arrow/c/bridge.h"
+#include "arrow/util/utf8.h"
 #include "paimon/common/data/blob_defs.h"
 #include "paimon/common/data/blob_utils.h"
 #include "paimon/common/io/offset_input_stream.h"
@@ -35,12 +38,92 @@
 #include "paimon/common/utils/arrow/status_utils.h"
 #include "paimon/common/utils/checked_cast.h"
 #include "paimon/common/utils/delta_varint_compressor.h"
+#include "paimon/common/utils/math.h"
 #include "paimon/data/blob_descriptor.h"
+#include "paimon/data/decimal.h"
 #include "paimon/fs/file_system.h"
 #include "paimon/io/byte_array_input_stream.h"
 #include "paimon/logging.h"
 
 namespace paimon::blob {
+namespace {
+
+template <typename T>
+void AppendLittleEndian(T value, std::string* out) {
+    const T little_endian = ToLittleEndian(value);
+    out->append(reinterpret_cast<const char*>(&little_endian), sizeof(T));
+}
+
+/// Serialize the key at `index` of `keys` and append its bytes to `out`, as 
Java's
+/// MapBlobElementSerializer serializes a MAP<..., BLOB> key: fixed-width 
little-endian integers,
+/// one byte 0 or 1 for a boolean, the unscaled long of a compact decimal or 
the big-endian
+/// two's-complement unscaled bytes of any other decimal, and the raw bytes of 
a string or binary.
+Status SerializeMapBlobKey(const arrow::Array& keys, int64_t index, 
std::string* out) {
+    switch (keys.type_id()) {
+        case arrow::Type::BOOL:
+            out->push_back(checked_cast<const 
arrow::BooleanArray&>(keys).Value(index) ? 1 : 0);
+            return Status::OK();
+        case arrow::Type::INT8:
+            out->push_back(
+                static_cast<char>(checked_cast<const 
arrow::Int8Array&>(keys).Value(index)));
+            return Status::OK();
+        case arrow::Type::INT16:
+            AppendLittleEndian(checked_cast<const 
arrow::Int16Array&>(keys).Value(index), out);
+            return Status::OK();
+        case arrow::Type::INT32:
+            AppendLittleEndian(checked_cast<const 
arrow::Int32Array&>(keys).Value(index), out);
+            return Status::OK();
+        case arrow::Type::INT64:
+            AppendLittleEndian(checked_cast<const 
arrow::Int64Array&>(keys).Value(index), out);
+            return Status::OK();
+        case arrow::Type::DATE32:
+            AppendLittleEndian(checked_cast<const 
arrow::Date32Array&>(keys).Value(index), out);
+            return Status::OK();
+        case arrow::Type::DECIMAL128: {
+            const auto& decimal_type = checked_cast<const 
arrow::Decimal128Type&>(*keys.type());
+            const arrow::Decimal128 value(
+                checked_cast<const 
arrow::Decimal128Array&>(keys).GetValue(index));
+            // Keep the key within its declared precision so the C++ reader 
can read it back.
+            if (!value.FitsInPrecision(decimal_type.precision())) {
+                return Status::Invalid("MAP<..., BLOB> decimal key exceeds 
declared precision");
+            }
+            const Decimal decimal(
+                decimal_type.precision(), decimal_type.scale(),
+                static_cast<Decimal::int128_t>(
+                    
static_cast<Decimal::uint128_t>(static_cast<uint64_t>(value.high_bits()))
+                        << 64 |
+                    value.low_bits()));
+            if (decimal.IsCompact()) {
+                AppendLittleEndian(decimal.ToUnscaledLong(), out);
+                return Status::OK();
+            }
+            const std::vector<char> bytes = decimal.ToUnscaledBytes();
+            out->append(bytes.data(), bytes.size());
+            return Status::OK();
+        }
+        case arrow::Type::STRING: {
+            const std::string_view key =
+                checked_cast<const arrow::StringArray&>(keys).GetView(index);
+            // Write only valid UTF-8 so the C++ reader can read the string 
key back.
+            if (!arrow::util::ValidateUTF8(key)) {
+                return Status::Invalid("invalid UTF-8 in MAP<STRING, BLOB> 
key");
+            }
+            out->append(key.data(), key.size());
+            return Status::OK();
+        }
+        case arrow::Type::BINARY: {
+            const std::string_view key =
+                checked_cast<const arrow::BinaryArray&>(keys).GetView(index);
+            out->append(key.data(), key.size());
+            return Status::OK();
+        }
+        default:
+            return Status::Invalid(
+                fmt::format("unsupported MAP<..., BLOB> key type: {}", 
keys.type()->ToString()));
+    }
+}
+
+}  // namespace
 
 BlobFormatWriter::BlobFormatWriter(const std::shared_ptr<OutputStream>& out, 
const std::string& uri,
                                    const std::shared_ptr<arrow::DataType>& 
data_type,
@@ -55,13 +138,16 @@ BlobFormatWriter::BlobFormatWriter(const 
std::shared_ptr<OutputStream>& out, con
       pool_(pool),
       write_null_on_missing_file_(write_null_on_missing_file),
       write_null_on_fetch_failure_(write_null_on_fetch_failure) {
-    // Create() has already checked that data_type has exactly one BLOB or 
ARRAY<BLOB> field.
+    // Create() has already checked that data_type has exactly one BLOB, 
ARRAY<BLOB> or
+    // MAP<..., BLOB> field.
     blob_field_name_ = data_type_->field(0)->name();
+    is_map_blob_field_ = BlobUtils::IsMapBlobField(data_type_->field(0));
     metrics_ = std::make_shared<MetricsImpl>();
     tmp_buffer_ = Bytes::AllocateBytes(kTmpBufferSize, pool_.get());
     magic_number_bytes_ = 
IntegerToLittleEndian<int32_t>(BlobDefs::kMagicNumber, pool_);
     array_magic_number_bytes_ =
         IntegerToLittleEndian<int32_t>(BlobDefs::kArrayBlobMagicNumber, pool_);
+    map_magic_number_bytes_ = 
IntegerToLittleEndian<int32_t>(BlobDefs::kMapBlobMagicNumber, pool_);
     logger_ = Logger::GetLogger("BlobFormatWriter");
 }
 
@@ -85,10 +171,20 @@ Result<std::unique_ptr<BlobFormatWriter>> 
BlobFormatWriter::Create(
         return Status::Invalid(
             fmt::format("blob data type field number {} is not 1", 
data_type->num_fields()));
     }
-    if (!BlobUtils::IsBlobField(data_type->field(0)) &&
-        !BlobUtils::IsArrayBlobField(data_type->field(0))) {
+    const std::shared_ptr<arrow::Field>& field = data_type->field(0);
+    if (!BlobUtils::IsAnyBlobField(field)) {
         return Status::Invalid(
-            fmt::format("field {} is not BLOB or ARRAY<BLOB>", 
data_type->field(0)->ToString()));
+            fmt::format("field {} is not BLOB, ARRAY<BLOB> or MAP<..., BLOB>", 
field->ToString()));
+    }
+    if (BlobUtils::IsMapBlobField(field)) {
+        const auto& map_type = checked_cast<const 
arrow::MapType&>(*field->type());
+        if (!BlobUtils::IsSupportedMapBlobKeyType(*map_type.key_type())) {
+            return Status::Invalid(fmt::format("unsupported MAP<..., BLOB> key 
type: {}",
+                                               
map_type.key_type()->ToString()));
+        }
+        if (map_type.key_type()->id() == arrow::Type::STRING) {
+            arrow::util::InitializeUTF8();
+        }
     }
     PAIMON_ASSIGN_OR_RAISE(std::string uri, out->GetUri());
     return std::unique_ptr<BlobFormatWriter>(new BlobFormatWriter(
@@ -130,6 +226,16 @@ Status BlobFormatWriter::AddBatch(ArrowArray* batch) {
         UpdateMetrics();
         return Status::OK();
     }
+    if (child_array->type_id() == arrow::Type::type::MAP) {
+        const auto& map_array = checked_cast<const 
arrow::MapArray&>(*child_array);
+        if (BlobUtils::IsMapBlobPlaceholder(map_array, 0)) {
+            bin_lengths_.push_back(BlobDefs::kPlaceholderBinLength);
+            return Status::OK();
+        }
+        PAIMON_RETURN_NOT_OK(WriteMapBlob(map_array));
+        UpdateMetrics();
+        return Status::OK();
+    }
     if (child_array->type_id() != arrow::Type::type::LARGE_BINARY) {
         return Status::Invalid("BlobFormatWriter only support large binary 
type.");
     }
@@ -217,22 +323,8 @@ Status BlobFormatWriter::WriteArrayBlob(const 
arrow::ListArray& list_array) {
         IntegerToLittleEndian<int32_t>(element_count, pool_);
     PAIMON_RETURN_NOT_OK(WriteWithCrc32(element_count_bytes->data(), 
element_count_bytes->size()));
 
-    // Each element is opened before any of its bytes are written, so an 
element converted to NULL
-    // leaves no partial data. As in Java, any other failure leaves a partial 
entry and fails the
-    // write.
-    std::vector<int64_t> element_lengths(element_count, 
BlobDefs::kNullBinLength);
-    for (int32_t i = 0; i < element_count; ++i) {
-        const int64_t value_index = element_offset + i;
-        if (blob_values.IsNull(value_index)) {
-            continue;
-        }
-        PAIMON_ASSIGN_OR_RAISE(BlobCopySource source,
-                               
OpenBlobInputStream(blob_values.GetView(value_index), i));
-        if (source.stream == nullptr) {
-            continue;
-        }
-        PAIMON_ASSIGN_OR_RAISE(element_lengths[i], WriteBlobData(source, i));
-    }
+    PAIMON_ASSIGN_OR_RAISE(std::vector<int64_t> element_lengths,
+                           WriteBlobElements(blob_values, element_offset, 
element_count));
 
     const std::vector<char> index_bytes = 
DeltaVarintCompressor::Compress(element_lengths);
     PAIMON_ASSIGN_OR_RAISE(int32_t index_length, 
ToIndexLength(index_bytes.size()));
@@ -243,6 +335,88 @@ Status BlobFormatWriter::WriteArrayBlob(const 
arrow::ListArray& list_array) {
     return FinishEntry(entry_pos);
 }
 
+Status BlobFormatWriter::WriteMapBlob(const arrow::MapArray& map_array) {
+    const std::shared_ptr<arrow::Array>& items = map_array.items();
+    if (items->type_id() != arrow::Type::type::LARGE_BINARY) {
+        return Status::Invalid("BlobFormatWriter only support large binary 
MAP<..., BLOB> values.");
+    }
+    const auto& blob_values = checked_cast<const 
arrow::LargeBinaryArray&>(*items);
+    const arrow::Array& keys = *map_array.keys();
+    const int64_t entry_offset = map_array.value_offset(0);
+    const int32_t entry_count = map_array.value_length(0);
+
+    // As in Java, the keys are serialized and validated before the entry is 
begun, so an invalid
+    // key fails the write without leaving partial data.
+    std::string key_data;
+    std::vector<int64_t> key_lengths(entry_count);
+    std::unordered_set<std::string> serialized_keys;
+    serialized_keys.reserve(entry_count);
+    for (int32_t i = 0; i < entry_count; ++i) {
+        const int64_t key_index = entry_offset + i;
+        if (keys.IsNull(key_index)) {
+            return Status::Invalid(
+                fmt::format("invalid {}: MAP<..., BLOB> keys cannot be null", 
DescribeMapKey(i)));
+        }
+        std::string key;
+        Status status = SerializeMapBlobKey(keys, key_index, &key);
+        if (!status.ok()) {
+            return status.WithMessage("invalid ", DescribeMapKey(i), ": ", 
status.message());
+        }
+        key_data.append(key);
+        key_lengths[i] = static_cast<int64_t>(key.size());
+        if (!serialized_keys.insert(std::move(key)).second) {
+            return Status::Invalid(
+                fmt::format("invalid {}: MAP<..., BLOB> keys must be unique", 
DescribeMapKey(i)));
+        }
+    }
+
+    PAIMON_ASSIGN_OR_RAISE(int64_t entry_pos, BeginEntry());
+    PAIMON_RETURN_NOT_OK(
+        WriteWithCrc32(map_magic_number_bytes_->data(), 
map_magic_number_bytes_->size()));
+    PAIMON_RETURN_NOT_OK(WriteWithCrc32(reinterpret_cast<const 
char*>(&BlobDefs::kMapBlobVersion),
+                                        sizeof(BlobDefs::kMapBlobVersion)));
+    PAIMON_UNIQUE_PTR<Bytes> entry_count_bytes = 
IntegerToLittleEndian<int32_t>(entry_count, pool_);
+    PAIMON_RETURN_NOT_OK(WriteWithCrc32(entry_count_bytes->data(), 
entry_count_bytes->size()));
+    PAIMON_RETURN_NOT_OK(WriteWithCrc32(key_data.data(), key_data.size()));
+
+    PAIMON_ASSIGN_OR_RAISE(std::vector<int64_t> value_lengths,
+                           WriteBlobElements(blob_values, entry_offset, 
entry_count));
+
+    const std::vector<char> key_index_bytes = 
DeltaVarintCompressor::Compress(key_lengths);
+    const std::vector<char> value_index_bytes = 
DeltaVarintCompressor::Compress(value_lengths);
+    PAIMON_ASSIGN_OR_RAISE(int32_t key_index_length, 
ToIndexLength(key_index_bytes.size()));
+    PAIMON_ASSIGN_OR_RAISE(int32_t value_index_length, 
ToIndexLength(value_index_bytes.size()));
+    PAIMON_RETURN_NOT_OK(WriteWithCrc32(key_index_bytes.data(), 
key_index_bytes.size()));
+    PAIMON_RETURN_NOT_OK(WriteWithCrc32(value_index_bytes.data(), 
value_index_bytes.size()));
+    PAIMON_UNIQUE_PTR<Bytes> key_index_length_bytes =
+        IntegerToLittleEndian<int32_t>(key_index_length, pool_);
+    PAIMON_RETURN_NOT_OK(
+        WriteWithCrc32(key_index_length_bytes->data(), 
key_index_length_bytes->size()));
+    PAIMON_UNIQUE_PTR<Bytes> value_index_length_bytes =
+        IntegerToLittleEndian<int32_t>(value_index_length, pool_);
+    PAIMON_RETURN_NOT_OK(
+        WriteWithCrc32(value_index_length_bytes->data(), 
value_index_length_bytes->size()));
+    return FinishEntry(entry_pos);
+}
+
+Result<std::vector<int64_t>> BlobFormatWriter::WriteBlobElements(
+    const arrow::LargeBinaryArray& values, int64_t offset, int32_t count) {
+    std::vector<int64_t> lengths(count, BlobDefs::kNullBinLength);
+    for (int32_t i = 0; i < count; ++i) {
+        const int64_t value_index = offset + i;
+        if (values.IsNull(value_index)) {
+            continue;
+        }
+        PAIMON_ASSIGN_OR_RAISE(BlobCopySource source,
+                               
OpenBlobInputStream(values.GetView(value_index), i));
+        if (source.stream == nullptr) {
+            continue;
+        }
+        PAIMON_ASSIGN_OR_RAISE(lengths[i], WriteBlobData(source, i));
+    }
+    return lengths;
+}
+
 Result<BlobFormatWriter::BlobCopySource> BlobFormatWriter::OpenBlobInputStream(
     std::string_view blob_data, std::optional<int32_t> element_index) {
     // Whether blob_data is a serialized BlobDescriptor is detected by its 
magic header rather
@@ -474,10 +648,19 @@ std::string 
BlobFormatWriter::DescribeValue(std::optional<int32_t> element_index
     if (!element_index) {
         return fmt::format("BLOB field {} in row {} of blob file {}", 
blob_field_name_, row, uri_);
     }
+    if (is_map_blob_field_) {
+        return fmt::format("value of entry {} of MAP<..., BLOB> field {} in 
row {} of blob file {}",
+                           *element_index, blob_field_name_, row, uri_);
+    }
     return fmt::format("element {} of ARRAY<BLOB> field {} in row {} of blob 
file {}",
                        *element_index, blob_field_name_, row, uri_);
 }
 
+std::string BlobFormatWriter::DescribeMapKey(int32_t entry_index) const {
+    return fmt::format("key of entry {} of MAP<..., BLOB> field {} in row {} 
of blob file {}",
+                       entry_index, blob_field_name_, bin_lengths_.size(), 
uri_);
+}
+
 Result<int32_t> BlobFormatWriter::ToIndexLength(size_t index_size) const {
     if (index_size > static_cast<size_t>(std::numeric_limits<int32_t>::max())) 
{
         return Status::Invalid(
diff --git a/src/paimon/format/blob/blob_format_writer.h 
b/src/paimon/format/blob/blob_format_writer.h
index 1e26c7cd..7293bc1f 100644
--- a/src/paimon/format/blob/blob_format_writer.h
+++ b/src/paimon/format/blob/blob_format_writer.h
@@ -37,7 +37,9 @@
 
 namespace arrow {
 class DataType;
+class LargeBinaryArray;
 class ListArray;
+class MapArray;
 }  // namespace arrow
 struct ArrowArray;
 
@@ -53,12 +55,12 @@ namespace paimon::blob {
 
 class BlobMetrics {
  public:
-    /// Number of BLOB values (rows, or ARRAY<BLOB> elements) written as NULL 
because their
-    /// referenced file did not exist.
+    /// Number of BLOB values (rows, ARRAY<BLOB> elements or MAP<..., BLOB> 
values) written as NULL
+    /// because their referenced file did not exist.
     static inline const char WRITE_NULL_ON_MISSING_FILE_COUNT[] =
         "blob.write.null-on-missing-file.count";
-    /// Number of BLOB values (rows, or ARRAY<BLOB> elements) written as NULL 
because their
-    /// referenced data could not be reached.
+    /// Number of BLOB values (rows, ARRAY<BLOB> elements or MAP<..., BLOB> 
values) written as NULL
+    /// because their referenced data could not be reached.
     static inline const char WRITE_NULL_ON_FETCH_FAILURE_COUNT[] =
         "blob.write.null-on-fetch-failure.count";
 };
@@ -66,8 +68,9 @@ class BlobMetrics {
 // Blob format:
 // 
https://cwiki.apache.org/confluence/display/PAIMON/PIP-35%3A+Introduce+Blob+to+store+multimodal+data
 //
-// The single field of a blob file is BLOB or ARRAY<BLOB>. An ARRAY<BLOB> 
entry stores the nested
-// payload of the Paimon BLOB file spec, where a null element has length -1.
+// The single field of a blob file is BLOB, ARRAY<BLOB> or MAP<..., BLOB>. An 
ARRAY<BLOB> or
+// MAP<..., BLOB> entry stores the nested payload of the Paimon BLOB file 
spec, where a null element
+// or value has length -1.
 class BlobFormatWriter : public FormatWriter {
  public:
     /// `write_null_on_missing_file` converts a descriptor whose referenced 
file does not exist
@@ -78,10 +81,17 @@ class BlobFormatWriter : public FormatWriter {
     /// `write_null_on_fetch_failure` like any other failed open.
     /// See Options::BLOB_WRITE_NULL_ON_MISSING_FILE / 
BLOB_WRITE_NULL_ON_FETCH_FAILURE.
     ///
-    /// A value exactly equal to BlobDefs::kPlaceholderSentinel, or an 
ARRAY<BLOB> whose only
-    /// element is that sentinel, is persisted as a placeholder entry 
(bin_length -2, no data bytes)
-    /// in every write, as Java persists its placeholder objects; any other 
value is written as
-    /// usual.
+    /// A value exactly equal to BlobDefs::kPlaceholderSentinel, an 
ARRAY<BLOB> whose only element
+    /// is that sentinel, or a MAP<..., BLOB> of exactly two entries with 
equal keys and null values
+    /// (see BlobUtils::IsMapBlobPlaceholder), is persisted as a placeholder 
entry (bin_length -2,
+    /// no data bytes) in every write, as Java persists its placeholder 
objects; any other value is
+    /// written as usual.
+    ///
+    /// Any other MAP<..., BLOB> row fails the write before any of its bytes 
are written when its
+    /// keys are not unique by their serialized bytes, as in Java's 
MapBlobElementSerializer, when
+    /// it has a null key, which Java allows once but Arrow map keys cannot 
hold, or when it has a
+    /// key the C++ reader rejects: a decimal key exceeding its precision or a 
string key that is
+    /// not valid UTF-8. The key type must satisfy 
BlobUtils::IsSupportedMapBlobKeyType.
     static Result<std::unique_ptr<BlobFormatWriter>> Create(
         const std::shared_ptr<OutputStream>& out, const 
std::shared_ptr<arrow::DataType>& data_type,
         bool write_null_on_missing_file, bool write_null_on_fetch_failure,
@@ -114,6 +124,15 @@ class BlobFormatWriter : public FormatWriter {
 
     Status WriteArrayBlob(const arrow::ListArray& list_array);
 
+    Status WriteMapBlob(const arrow::MapArray& map_array);
+
+    /// Copy `count` blob values of `values` from `offset` into the current 
entry, returning the
+    /// length of each one, or -1 for a null value. Each value is opened 
before any of its bytes are
+    /// written, so a value converted to NULL leaves no partial data. As in 
Java, any other failure
+    /// leaves a partial entry and fails the write.
+    Result<std::vector<int64_t>> WriteBlobElements(const 
arrow::LargeBinaryArray& values,
+                                                   int64_t offset, int32_t 
count);
+
     /// The input stream of a blob value, as Java's BlobCopySource. A `reused` 
stream is a view on
     /// the kept source stream and must not be closed; any other stream is the 
value's own.
     struct BlobCopySource {
@@ -124,8 +143,8 @@ class BlobFormatWriter : public FormatWriter {
     /// Open an input stream on a blob value, which is either a serialized 
BlobDescriptor or the
     /// raw blob bytes. Returns a null stream when a failure to reach the 
referenced data is
     /// converted to a NULL value by `write_null_on_missing_file_` or
-    /// `write_null_on_fetch_failure_`. `element_index` identifies an 
ARRAY<BLOB> element in logs
-    /// and errors.
+    /// `write_null_on_fetch_failure_`. `element_index` identifies an 
ARRAY<BLOB> element or a
+    /// MAP<..., BLOB> value in logs and errors.
     Result<BlobCopySource> OpenBlobInputStream(std::string_view blob_data,
                                                std::optional<int32_t> 
element_index);
 
@@ -188,10 +207,14 @@ class BlobFormatWriter : public FormatWriter {
     Status AddFailureContext(const Status& status, const std::string& action,
                              std::optional<int32_t> element_index) const;
 
-    /// Describe the value being written for logs and errors: the BLOB field 
or one element of the
-    /// ARRAY<BLOB> field, with its row in the blob file.
+    /// Describe the value being written for logs and errors: the BLOB field, 
one element of the
+    /// ARRAY<BLOB> field or the value of one entry of the MAP<..., BLOB> 
field, with its row in the
+    /// blob file.
     std::string DescribeValue(std::optional<int32_t> element_index) const;
 
+    /// Describe the key of one entry of the MAP<..., BLOB> field being 
written for errors.
+    std::string DescribeMapKey(int32_t entry_index) const;
+
     /// Convert the size of a compressed index to the signed 32-bit index 
length stored after it,
     /// failing when the index is too large for that field.
     Result<int32_t> ToIndexLength(size_t index_size) const;
@@ -214,8 +237,10 @@ class BlobFormatWriter : public FormatWriter {
     PAIMON_UNIQUE_PTR<Bytes> tmp_buffer_;
     PAIMON_UNIQUE_PTR<Bytes> magic_number_bytes_;
     PAIMON_UNIQUE_PTR<Bytes> array_magic_number_bytes_;
+    PAIMON_UNIQUE_PTR<Bytes> map_magic_number_bytes_;
     std::shared_ptr<arrow::DataType> data_type_;
     std::string blob_field_name_;
+    bool is_map_blob_field_ = false;
     std::shared_ptr<FileSystem> fs_;
     std::shared_ptr<MemoryPool> pool_;
     std::shared_ptr<Metrics> metrics_;
diff --git a/src/paimon/format/blob/blob_format_writer_test.cpp 
b/src/paimon/format/blob/blob_format_writer_test.cpp
index 10de8a60..49fed766 100644
--- a/src/paimon/format/blob/blob_format_writer_test.cpp
+++ b/src/paimon/format/blob/blob_format_writer_test.cpp
@@ -27,6 +27,7 @@
 #include <vector>
 
 #include "arrow/c/bridge.h"
+#include "arrow/ipc/json_simple.h"
 #include "fmt/format.h"
 #include "gtest/gtest.h"
 #include "paimon/common/data/blob_defs.h"
@@ -319,6 +320,13 @@ class BlobFormatWriterTestBase : public ::testing::Test {
         return path;
     }
 
+    Result<std::string> DescriptorOf(const std::string& path, int64_t offset = 
0,
+                                     int64_t length = -1) const {
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<Blob> blob, 
Blob::FromPath(path, offset, length));
+        PAIMON_UNIQUE_PTR<Bytes> descriptor = blob->ToDescriptor(pool_);
+        return std::string(descriptor->data(), descriptor->size());
+    }
+
  protected:
     std::shared_ptr<MemoryPool> pool_;
     std::unique_ptr<paimon::test::UniqueTestDirectory> dir_;
@@ -465,7 +473,7 @@ TEST_P(BlobFormatWriterTest, 
TestCreateWithInvalidParameters) {
         BlobFormatWriter::Create(output_stream_, non_blob_type,
                                  /*write_null_on_missing_file=*/false,
                                  /*write_null_on_fetch_failure=*/false, 
file_system_, pool_),
-        "field regular_col: binary is not BLOB or ARRAY<BLOB>");
+        "field regular_col: binary is not BLOB, ARRAY<BLOB> or MAP<..., 
BLOB>");
 
     auto plain_binary_array_type =
         arrow::struct_({arrow::field("array_col", 
arrow::list(arrow::large_binary()))});
@@ -473,14 +481,32 @@ TEST_P(BlobFormatWriterTest, 
TestCreateWithInvalidParameters) {
         BlobFormatWriter::Create(output_stream_, plain_binary_array_type,
                                  /*write_null_on_missing_file=*/false,
                                  /*write_null_on_fetch_failure=*/false, 
file_system_, pool_),
-        "is not BLOB or ARRAY<BLOB>");
+        "is not BLOB, ARRAY<BLOB> or MAP<..., BLOB>");
     auto nested_array_type = arrow::struct_({arrow::field(
         "nested_col", arrow::list(arrow::list(BlobUtils::ToArrowField("item", 
true))))});
     ASSERT_NOK_WITH_MSG(
         BlobFormatWriter::Create(output_stream_, nested_array_type,
                                  /*write_null_on_missing_file=*/false,
                                  /*write_null_on_fetch_failure=*/false, 
file_system_, pool_),
-        "is not BLOB or ARRAY<BLOB>");
+        "is not BLOB, ARRAY<BLOB> or MAP<..., BLOB>");
+    auto plain_binary_map_type =
+        arrow::struct_({arrow::field("map_col", arrow::map(arrow::utf8(), 
arrow::binary()))});
+    ASSERT_NOK_WITH_MSG(
+        BlobFormatWriter::Create(output_stream_, plain_binary_map_type,
+                                 /*write_null_on_missing_file=*/false,
+                                 /*write_null_on_fetch_failure=*/false, 
file_system_, pool_),
+        "is not BLOB, ARRAY<BLOB> or MAP<..., BLOB>");
+
+    for (const auto& key_type : {arrow::float32(), 
arrow::time32(arrow::TimeUnit::MILLI),
+                                 arrow::timestamp(arrow::TimeUnit::MILLI)}) {
+        auto map_blob_type = arrow::struct_({arrow::field(
+            "map_col", arrow::map(key_type, BlobUtils::ToArrowField("value", 
true)))});
+        ASSERT_NOK_WITH_MSG(
+            BlobFormatWriter::Create(output_stream_, map_blob_type,
+                                     /*write_null_on_missing_file=*/false,
+                                     /*write_null_on_fetch_failure=*/false, 
file_system_, pool_),
+            "unsupported MAP<..., BLOB> key type: " + key_type->ToString());
+    }
 }
 
 TEST_P(BlobFormatWriterTest, TestInvalidCase) {
@@ -1363,13 +1389,6 @@ class BlobFormatWriterArrayBlobTest : public 
BlobFormatWriterTestBase {
         return AddBatchOnce(writer, array);
     }
 
-    Result<std::string> DescriptorOf(const std::string& path, int64_t offset = 
0,
-                                     int64_t length = -1) const {
-        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<Blob> blob, 
Blob::FromPath(path, offset, length));
-        PAIMON_UNIQUE_PTR<Bytes> descriptor = blob->ToDescriptor(pool_);
-        return std::string(descriptor->data(), descriptor->size());
-    }
-
     Result<std::shared_ptr<arrow::ListArray>> ReadBackArrays(
         bool blob_as_descriptor, bool emit_placeholder_sentinel,
         const std::string& file_name = "file.blob") const {
@@ -1764,4 +1783,445 @@ TEST_F(BlobFormatWriterArrayBlobTest, 
TestArrayBlobOutputFailureFailsWrite) {
     }
 }
 
+class BlobFormatWriterMapBlobTest : public BlobFormatWriterTestBase {
+ public:
+    using MapEntries = std::vector<std::pair<std::string, 
std::optional<std::string>>>;
+
+    void SetUp() override {
+        BlobFormatWriterTestBase::SetUp();
+        struct_type_ = MapBlobStructType(arrow::utf8());
+    }
+
+    static std::shared_ptr<arrow::DataType> MapBlobStructType(
+        const std::shared_ptr<arrow::DataType>& key_type) {
+        return arrow::struct_({arrow::field(
+            "map_blob_col", arrow::map(key_type, 
BlobUtils::ToArrowField("value", true)), true)});
+    }
+
+    static Result<std::shared_ptr<arrow::Array>> MakeMapBlobRow(
+        const std::shared_ptr<arrow::DataType>& struct_type,
+        const std::shared_ptr<arrow::Array>& keys,
+        const std::vector<std::optional<std::string>>& values) {
+        arrow::LargeBinaryBuilder value_builder;
+        for (const std::optional<std::string>& value : values) {
+            if (value) {
+                PAIMON_RETURN_NOT_OK_FROM_ARROW(value_builder.Append(*value));
+            } else {
+                PAIMON_RETURN_NOT_OK_FROM_ARROW(value_builder.AppendNull());
+            }
+        }
+        std::shared_ptr<arrow::Array> items;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(value_builder.Finish(&items));
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+            std::shared_ptr<arrow::Array> offsets,
+            arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(),
+                                                      fmt::format("[0, {}]", 
keys->length())));
+        const std::shared_ptr<arrow::Field>& map_field = struct_type->field(0);
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+            std::shared_ptr<arrow::Array> map_array,
+            arrow::MapArray::FromArrays(map_field->type(), offsets, keys, 
items));
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> 
struct_array,
+                                          
arrow::StructArray::Make({map_array}, {map_field}));
+        return struct_array;
+    }
+
+    static Result<std::vector<std::shared_ptr<arrow::Array>>> RowsFromJson(
+        const std::shared_ptr<arrow::DataType>& struct_type, const 
std::string& json) {
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+            std::shared_ptr<arrow::Array> array,
+            arrow::ipc::internal::json::ArrayFromJSON(struct_type, json));
+        std::vector<std::shared_ptr<arrow::Array>> rows;
+        for (int64_t i = 0; i < array->length(); ++i) {
+            rows.push_back(array->Slice(i, 1));
+        }
+        return rows;
+    }
+
+    Status AddMapBlobRow(const std::shared_ptr<BlobFormatWriter>& writer,
+                         const std::optional<MapEntries>& entries) const {
+        if (!entries) {
+            PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<arrow::Array>> 
rows,
+                                   RowsFromJson(struct_type_, "[[null]]"));
+            return AddBatchOnce(writer, rows[0]);
+        }
+        arrow::StringBuilder key_builder;
+        std::vector<std::optional<std::string>> values;
+        for (const auto& [key, value] : *entries) {
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(key_builder.Append(key));
+            values.push_back(value);
+        }
+        std::shared_ptr<arrow::Array> keys;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(key_builder.Finish(&keys));
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> row,
+                               MakeMapBlobRow(struct_type_, keys, values));
+        return AddBatchOnce(writer, row);
+    }
+
+    Result<std::shared_ptr<arrow::MapArray>> ReadBackMapArray(
+        bool blob_as_descriptor, bool emit_placeholder_sentinel,
+        const std::string& file_name = "file.blob") const {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::StructArray> 
struct_array,
+                               ReadBack(blob_as_descriptor, 
emit_placeholder_sentinel, file_name));
+        return checked_pointer_cast<arrow::MapArray>(struct_array->field(0));
+    }
+
+    Result<std::vector<std::optional<MapEntries>>> ReadBackMaps(
+        bool blob_as_descriptor, const std::string& file_name = "file.blob") 
const {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::MapArray> map_array,
+                               ReadBackMapArray(blob_as_descriptor,
+                                                
/*emit_placeholder_sentinel=*/false, file_name));
+        const auto& keys = checked_cast<const 
arrow::StringArray&>(*map_array->keys());
+        const auto& values = checked_cast<const 
arrow::LargeBinaryArray&>(*map_array->items());
+        std::vector<std::optional<MapEntries>> rows;
+        for (int64_t row = 0; row < map_array->length(); ++row) {
+            if (map_array->IsNull(row)) {
+                rows.emplace_back(std::nullopt);
+                continue;
+            }
+            MapEntries entries;
+            for (int64_t i = map_array->value_offset(row); i < 
map_array->value_offset(row + 1);
+                 ++i) {
+                if (values.IsNull(i)) {
+                    entries.emplace_back(keys.GetString(i), std::nullopt);
+                    continue;
+                }
+                std::string_view stored = values.GetView(i);
+                if (!blob_as_descriptor) {
+                    entries.emplace_back(keys.GetString(i), 
std::string(stored));
+                    continue;
+                }
+                PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<Blob> blob,
+                                       Blob::FromDescriptor(stored.data(), 
stored.size()));
+                PAIMON_ASSIGN_OR_RAISE(PAIMON_UNIQUE_PTR<Bytes> data,
+                                       blob->ToData(file_system_, pool_));
+                entries.emplace_back(keys.GetString(i), 
std::string(data->data(), data->size()));
+            }
+            rows.emplace_back(std::move(entries));
+        }
+        return rows;
+    }
+
+    static MapEntries PlaceholderEntries() {
+        return {{"key", std::nullopt}, {"key", std::nullopt}};
+    }
+};
+
+TEST_F(BlobFormatWriterMapBlobTest, TestMapBlobGoldenBytes) {
+    ASSERT_OK_AND_ASSIGN(std::string descriptor,
+                         DescriptorOf(WriteSourceFile("source.bin", 
"descriptor")));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, 
CreateDefaultWriter());
+
+    ASSERT_OK(AddMapBlobRow(writer, MapEntries{}));
+    ASSERT_OK(AddMapBlobRow(
+        writer,
+        MapEntries{
+            {"", ""}, {"inline", "data"}, {"descriptor", descriptor}, {"null", 
std::nullopt}}));
+    ASSERT_OK(AddMapBlobRow(writer, std::nullopt));
+    ASSERT_OK(AddMapBlobRow(writer, PlaceholderEntries()));
+    ASSERT_OK(writer->Finish());
+
+    std::vector<uint8_t> expected = {
+        0xcf, 0x11, 0x4e, 0x58, 0x42, 0x43, 0x42, 0x4d, 0x01, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x83,
+        0x60, 0x59, 0x1e, 0xcf, 0x11, 0x4e, 0x58, 0x42, 0x43, 0x42, 0x4d, 
0x01, 0x04, 0x00, 0x00,
+        0x00, 0x69, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x64, 0x65, 0x73, 0x63, 
0x72, 0x69, 0x70, 0x74,
+        0x6f, 0x72, 0x6e, 0x75, 0x6c, 0x6c, 0x64, 0x61, 0x74, 0x61, 0x64, 
0x65, 0x73, 0x63, 0x72,
+        0x69, 0x70, 0x74, 0x6f, 0x72, 0x00, 0x0c, 0x08, 0x0b, 0x00, 0x08, 
0x0c, 0x15, 0x04, 0x00,
+        0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x4b, 0x00, 0x00, 0x00, 0x00, 
0x00, 0x00, 0x00, 0x3a,
+        0xbb, 0x6e, 0xee, 0x42, 0x54, 0x97, 0x01, 0x01, 0x05, 0x00, 0x00, 
0x00, 0x01};
+    std::string content;
+    ASSERT_OK(file_system_->ReadFile(dir_->Str() + "/file.blob", &content));
+    ASSERT_EQ(std::vector<uint8_t>(content.begin(), content.end()), expected);
+}
+
+TEST_F(BlobFormatWriterMapBlobTest, TestMatchesJavaWrittenFiles) {
+    const std::string table_dir =
+        paimon::test::GetDataDir() + 
"/parquet/map_blob_java.db/map_blob_java/bucket-0/";
+    const std::string java_file_prefix = 
"data-1be78d56-71bd-453f-ab1f-4312f3bf480e-";
+    const std::string null_rows = R"(, [null], [null], [null]])";
+    struct Case {
+        std::string java_file;
+        std::shared_ptr<arrow::DataType> key_type;
+        std::string json;
+    };
+    const std::vector<Case> cases = {
+        {java_file_prefix + "1.blob", arrow::utf8(),
+         R"([[[["", "string-empty"], ["alpha", "string-alpha"]]], [[]], [null],
+             [[["omega", "string-omega"]]]])"},
+        {java_file_prefix + "2.blob", arrow::boolean(),
+         R"([[[[false, "bool-false"], [true, "bool-true"]]])" + null_rows},
+        {java_file_prefix + "3.blob", arrow::int8(),
+         R"([[[[-128, "tiny-min"], [-1, "tiny-negative"], [127, 
"tiny-max"]]])" + null_rows},
+        {java_file_prefix + "4.blob", arrow::int16(),
+         R"([[[[-32768, "small-min"], [-1, "small-negative"], [32767, 
"small-max"]]])" + null_rows},
+        {java_file_prefix + "5.blob", arrow::int32(),
+         R"([[[[-2147483648, "int-min"], [-1, "int-negative"], [2147483647, 
"int-max"]]])" +
+             null_rows},
+        {java_file_prefix + "6.blob", arrow::int64(),
+         R"([[[[-9223372036854775808, "big-min"], [-1, "big-negative"],
+               [9223372036854775807, "big-max"]]])" +
+             null_rows},
+        {java_file_prefix + "7.blob", arrow::date32(),
+         R"([[[[-1, "date-negative"], [0, "date-epoch"]]])" + null_rows},
+        {java_file_prefix + "8.blob", arrow::binary(), ""},
+        {java_file_prefix + "9.blob", arrow::decimal128(10, 2),
+         R"([[[["-99999999.99", "compact-negative"], ["99999999.99", 
"compact-positive"]]])" +
+             null_rows},
+        {java_file_prefix + "10.blob", arrow::decimal128(20, 2),
+         R"([[[["-999999999999999999.99", "large-negative"],
+               ["999999999999999999.99", "large-positive"]]])" +
+             null_rows},
+        {"data-d5380318-987b-4af0-9073-9c8e8162b568-0.blob", arrow::utf8(),
+         R"([[[["key", null], ["key", null]]], [[["key", null], ["key", 
null]]],
+             [[["key", null], ["key", null]]], [[["key", null], ["key", 
null]]]])"}};
+    for (size_t i = 0; i < cases.size(); ++i) {
+        const Case& c = cases[i];
+        SCOPED_TRACE(fmt::format("case {}: {}", i, c.java_file));
+        const auto struct_type = MapBlobStructType(c.key_type);
+        std::vector<std::shared_ptr<arrow::Array>> rows;
+        if (c.key_type->id() == arrow::Type::BINARY) {
+            arrow::BinaryBuilder key_builder;
+            ASSERT_TRUE(key_builder.Append("").ok());
+            ASSERT_TRUE(key_builder.Append(std::string("\0\xff\1\2", 4)).ok());
+            std::shared_ptr<arrow::Array> keys;
+            ASSERT_TRUE(key_builder.Finish(&keys).ok());
+            ASSERT_OK_AND_ASSIGN(
+                std::shared_ptr<arrow::Array> first_row,
+                MakeMapBlobRow(struct_type, keys, {"binary-empty", 
"binary-bytes"}));
+            ASSERT_OK_AND_ASSIGN(rows, RowsFromJson(struct_type, "[[null], 
[null], [null]]"));
+            rows.insert(rows.begin(), first_row);
+        } else {
+            ASSERT_OK_AND_ASSIGN(rows, RowsFromJson(struct_type, c.json));
+        }
+
+        const std::string file_path = dir_->Str() + 
fmt::format("/java_{}.blob", i);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> out,
+                             file_system_->Create(file_path, 
/*overwrite=*/true));
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<BlobFormatWriter> writer,
+            BlobFormatWriter::Create(out, struct_type, 
/*write_null_on_missing_file=*/false,
+                                     /*write_null_on_fetch_failure=*/false, 
file_system_, pool_));
+        for (const std::shared_ptr<arrow::Array>& row : rows) {
+            ASSERT_OK(AddBatchOnce(writer, row));
+        }
+        ASSERT_OK(writer->Finish());
+        ASSERT_OK(out->Close());
+
+        std::string actual;
+        ASSERT_OK(file_system_->ReadFile(file_path, &actual));
+        std::string expected;
+        ASSERT_OK(file_system_->ReadFile(table_dir + c.java_file, &expected));
+        ASSERT_EQ(std::vector<uint8_t>(actual.begin(), actual.end()),
+                  std::vector<uint8_t>(expected.begin(), expected.end()));
+    }
+}
+
+TEST_F(BlobFormatWriterMapBlobTest, TestMapBlobRoundTrip) {
+    std::string xxhash_file = paimon::test::GetDataDir() + "/xxhash.data";
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob_slice,
+                         Blob::FromPath(xxhash_file, /*offset=*/92, 
/*length=*/85));
+    PAIMON_UNIQUE_PTR<Bytes> slice_descriptor = 
blob_slice->ToDescriptor(pool_);
+    ASSERT_OK_AND_ASSIGN(PAIMON_UNIQUE_PTR<Bytes> slice_data,
+                         blob_slice->ToData(file_system_, pool_));
+    const std::string slice_bytes(slice_data->data(), slice_data->size());
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, 
CreateDefaultWriter());
+    const std::vector<std::optional<MapEntries>> rows = {
+        MapEntries{{"first", "1"}, {"empty", ""}, {"", "empty key"}, {"null", 
std::nullopt}},
+        std::nullopt,
+        MapEntries{},
+        MapEntries{{"slice", std::string(slice_descriptor->data(), 
slice_descriptor->size())},
+                   {"tail", "tail"}},
+        MapEntries{{"large", std::string((1 << 20) + 7, 'x')}, {"small", 
"small"}},
+    };
+    for (const std::optional<MapEntries>& row : rows) {
+        ASSERT_OK(AddMapBlobRow(writer, row));
+    }
+    ASSERT_OK(writer->Finish());
+
+    std::vector<std::optional<MapEntries>> expected = rows;
+    expected[3] = MapEntries{{"slice", slice_bytes}, {"tail", "tail"}};
+    for (bool blob_as_descriptor : {false, true}) {
+        ASSERT_OK_AND_ASSIGN(std::vector<std::optional<MapEntries>> actual,
+                             ReadBackMaps(blob_as_descriptor));
+        ASSERT_EQ(actual, expected) << "blob_as_descriptor: " << 
blob_as_descriptor;
+    }
+}
+
+TEST_F(BlobFormatWriterMapBlobTest, TestMapBlobPlaceholder) {
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, 
CreateDefaultWriter());
+    ASSERT_OK(AddMapBlobRow(writer, MapEntries{{"key", "value"}}));
+    ASSERT_OK(AddMapBlobRow(writer, PlaceholderEntries()));
+    ASSERT_OK(AddMapBlobRow(writer, MapEntries{{"key", std::nullopt}}));
+    ASSERT_OK(AddMapBlobRow(writer, MapEntries{{"left", std::nullopt}, 
{"right", std::nullopt}}));
+    ASSERT_OK(writer->Finish());
+
+    ASSERT_NOK_WITH_MSG(ReadBackMapArray(/*blob_as_descriptor=*/false,
+                                         /*emit_placeholder_sentinel=*/false),
+                        "placeholder");
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::MapArray> map_array,
+                         ReadBackMapArray(/*blob_as_descriptor=*/false,
+                                          /*emit_placeholder_sentinel=*/true));
+    ASSERT_EQ(map_array->length(), 4);
+    for (int64_t row = 0; row < map_array->length(); ++row) {
+        ASSERT_EQ(BlobUtils::IsMapBlobPlaceholder(*map_array, row), row == 1) 
<< "row " << row;
+    }
+    const auto& keys = checked_cast<const 
arrow::StringArray&>(*map_array->keys());
+    const auto& values = checked_cast<const 
arrow::LargeBinaryArray&>(*map_array->items());
+    ASSERT_EQ(map_array->value_length(0), 1);
+    ASSERT_EQ(keys.GetString(map_array->value_offset(0)), "key");
+    ASSERT_EQ(values.GetString(map_array->value_offset(0)), "value");
+    ASSERT_EQ(map_array->value_length(2), 1);
+    ASSERT_TRUE(values.IsNull(map_array->value_offset(2)));
+    ASSERT_EQ(map_array->value_length(3), 2);
+}
+
+TEST_F(BlobFormatWriterMapBlobTest, 
TestInvalidKeysFailWriteWithoutPartialData) {
+    const std::string key_context =
+        "invalid key of entry 1 of MAP<..., BLOB> field map_blob_col in row 0 
of blob file";
+    std::shared_ptr<arrow::Array> null_keys =
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::utf8(), R"(["key", 
null])").ValueOrDie();
+    std::shared_ptr<arrow::ArrayData> null_key_data = 
null_keys->data()->Copy();
+    null_key_data->SetNullCount(0);
+    std::shared_ptr<arrow::Array> keys = arrow::MakeArray(null_key_data);
+    ASSERT_EQ(keys->null_count(), 0);
+    ASSERT_TRUE(keys->IsNull(1));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> null_key_row,
+                         MakeMapBlobRow(struct_type_, keys, {"a", "b"}));
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, 
CreateDefaultWriter());
+    const std::vector<std::pair<Status, std::string>> failures = {
+        {AddMapBlobRow(writer, MapEntries{{"key", "first"}, {"key", 
"second"}}),
+         "MAP<..., BLOB> keys must be unique"},
+        {AddMapBlobRow(writer,
+                       MapEntries{{"key", std::nullopt}, {"key", 
std::nullopt}, {"x", "x"}}),
+         "MAP<..., BLOB> keys must be unique"},
+        {AddMapBlobRow(writer, MapEntries{{"key", std::nullopt}, {"key", 
"value"}}),
+         "MAP<..., BLOB> keys must be unique"},
+        {AddMapBlobRow(writer, MapEntries{{"key", "first"}, {"\xff", 
"second"}}),
+         "invalid UTF-8 in MAP<STRING, BLOB> key"},
+        {AddBatchOnce(writer, null_key_row), "MAP<..., BLOB> keys cannot be 
null"}};
+    for (size_t i = 0; i < failures.size(); ++i) {
+        SCOPED_TRACE(fmt::format("case {}", i));
+        ASSERT_NOK_WITH_MSG(failures[i].first, key_context);
+        ASSERT_NOK_WITH_MSG(failures[i].first, failures[i].second);
+    }
+    ASSERT_OK(AddMapBlobRow(writer, MapEntries{{"key", "value"}}));
+    ASSERT_OK(writer->Finish());
+    ASSERT_OK_AND_ASSIGN(std::vector<std::optional<MapEntries>> actual,
+                         ReadBackMaps(/*blob_as_descriptor=*/false));
+    const std::vector<std::optional<MapEntries>> expected = 
{MapEntries{{"key", "value"}}};
+    ASSERT_EQ(actual, expected);
+
+    const auto decimal_struct_type = MapBlobStructType(arrow::decimal128(10, 
2));
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> decimal_out,
+                         file_system_->Create(dir_->Str() + "/decimal.blob", 
/*overwrite=*/true));
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<BlobFormatWriter> decimal_writer,
+        BlobFormatWriter::Create(decimal_out, decimal_struct_type,
+                                 /*write_null_on_missing_file=*/false,
+                                 /*write_null_on_fetch_failure=*/false, 
file_system_, pool_));
+    arrow::Decimal128Builder key_builder(arrow::decimal128(10, 2));
+    ASSERT_TRUE(key_builder.Append(arrow::Decimal128(100)).ok());
+    ASSERT_TRUE(key_builder.Append(arrow::Decimal128(12345678901LL)).ok());
+    std::shared_ptr<arrow::Array> decimal_keys;
+    ASSERT_TRUE(key_builder.Finish(&decimal_keys).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> decimal_row,
+                         MakeMapBlobRow(decimal_struct_type, decimal_keys, 
{"a", "b"}));
+    Status status = AddBatchOnce(decimal_writer, decimal_row);
+    ASSERT_NOK_WITH_MSG(status, key_context);
+    ASSERT_NOK_WITH_MSG(status, "MAP<..., BLOB> decimal key exceeds declared 
precision");
+    ASSERT_OK_AND_ASSIGN(int64_t decimal_pos, decimal_out->GetPos());
+    ASSERT_EQ(decimal_pos, 0);
+    ASSERT_OK(decimal_out->Close());
+}
+
+TEST_F(BlobFormatWriterMapBlobTest, TestWriteNullOnUnreachableValues) {
+    ASSERT_OK_AND_ASSIGN(const std::string missing_descriptor,
+                         DescriptorOf(dir_->Str() + "/not_exist_file"));
+    const MapEntries entries = {{"first", "1"}, {"missing", 
missing_descriptor}, {"last", "2"}};
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> failing_writer, 
CreateDefaultWriter());
+    Status status = AddMapBlobRow(failing_writer, entries);
+    ASSERT_NOK_WITH_MSG(
+        status, "value of entry 1 of MAP<..., BLOB> field map_blob_col in row 
0 of blob file");
+    ASSERT_NOK_WITH_MSG(status, "not exists");
+
+    const std::string file_name = "write_null.blob";
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> out,
+                         file_system_->Create(dir_->Str() + "/" + file_name, 
/*overwrite=*/true));
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<BlobFormatWriter> writer,
+        BlobFormatWriter::Create(out, struct_type_, 
/*write_null_on_missing_file=*/true,
+                                 /*write_null_on_fetch_failure=*/false, 
file_system_, pool_));
+    ASSERT_OK(AddMapBlobRow(writer, entries));
+    ASSERT_OK_AND_ASSIGN(
+        uint64_t missing_nulls,
+        
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_MISSING_FILE_COUNT));
+    ASSERT_EQ(missing_nulls, 1);
+    ASSERT_OK(writer->Finish());
+    ASSERT_OK(out->Close());
+
+    ASSERT_OK_AND_ASSIGN(std::vector<std::optional<MapEntries>> actual,
+                         ReadBackMaps(/*blob_as_descriptor=*/false, 
file_name));
+    const std::vector<std::optional<MapEntries>> expected = {
+        MapEntries{{"first", "1"}, {"missing", std::nullopt}, {"last", "2"}}};
+    ASSERT_EQ(actual, expected);
+}
+
+TEST_F(BlobFormatWriterMapBlobTest, TestMapBlobOutputFailureFailsWrite) {
+    struct Case {
+        int64_t write_limit;
+        bool short_write;
+        bool fail_flush;
+        bool fails_on_finish;
+        std::string expected_error;
+    };
+    const int64_t key_data_begin = 13;
+    const int64_t value_data_begin = 16;
+    const int64_t key_index_begin = 21;
+    const int64_t value_index_begin = 22;
+    const int64_t key_index_length_begin = 23;
+    const int64_t value_index_length_begin = 27;
+    const int64_t entry_end = 43;
+    const std::string write_error = "failed to write blob file mock.blob: mock 
write error";
+    const std::vector<Case> cases = {
+        {key_data_begin, false, false, false, write_error},
+        {key_data_begin + 1, true, false, false,
+         "failed to write blob file mock.blob: unexpected actual length 1 not 
match with expect 3"},
+        {value_data_begin, false, false, false,
+         "failed to copy value of entry 0 of MAP<..., BLOB> field map_blob_col 
in row 0 of blob "
+         "file mock.blob: " +
+             write_error},
+        {key_index_begin, false, false, false, write_error},
+        {value_index_begin, false, false, false, write_error},
+        {key_index_length_begin, false, false, false, write_error},
+        {value_index_length_begin, false, false, false, write_error},
+        {entry_end, false, false, true, write_error},
+        {std::numeric_limits<int64_t>::max(), false, true, true,
+         "failed to flush blob file mock.blob: mock flush error"}};
+    for (size_t i = 0; i < cases.size(); ++i) {
+        const Case& c = cases[i];
+        SCOPED_TRACE(fmt::format("case {}: write_limit={}, short_write={}, 
fail_flush={}", i,
+                                 c.write_limit, c.short_write, c.fail_flush));
+        auto out =
+            std::make_shared<FailingOutputStream>(c.write_limit, 
c.short_write, c.fail_flush);
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<BlobFormatWriter> writer,
+            BlobFormatWriter::Create(out, struct_type_, 
/*write_null_on_missing_file=*/false,
+                                     /*write_null_on_fetch_failure=*/false, 
file_system_, pool_));
+        Status status = AddMapBlobRow(writer, MapEntries{{"key", "value"}});
+        if (c.fails_on_finish) {
+            ASSERT_OK(status);
+            status = writer->Finish();
+        }
+        ASSERT_NOK_WITH_MSG(status, c.expected_error);
+        if (!c.fail_flush) {
+            ASSERT_OK_AND_ASSIGN(int64_t pos, out->GetPos());
+            ASSERT_EQ(pos, c.write_limit);
+        }
+    }
+}
+
 }  // namespace paimon::blob::test
diff --git a/test/inte/blob_table_inte_test.cpp 
b/test/inte/blob_table_inte_test.cpp
index b4b05901..0d566595 100644
--- a/test/inte/blob_table_inte_test.cpp
+++ b/test/inte/blob_table_inte_test.cpp
@@ -611,6 +611,35 @@ class BlobTableInteTest : public testing::Test, public 
::testing::WithParamInter
         return array;
     }
 
+    using MapBlobEntries = std::vector<std::pair<std::string, 
std::optional<std::string>>>;
+
+    static Result<std::shared_ptr<arrow::Array>> MakeMapBlobArray(
+        const std::shared_ptr<arrow::DataType>& map_type,
+        const std::vector<std::optional<MapBlobEntries>>& rows) {
+        auto key_builder = std::make_shared<arrow::StringBuilder>();
+        auto item_builder = std::make_shared<arrow::LargeBinaryBuilder>();
+        arrow::MapBuilder map_builder(arrow::default_memory_pool(), 
key_builder, item_builder,
+                                      map_type);
+        for (const std::optional<MapBlobEntries>& row : rows) {
+            if (!row) {
+                PAIMON_RETURN_NOT_OK_FROM_ARROW(map_builder.AppendNull());
+                continue;
+            }
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(map_builder.Append());
+            for (const auto& [key, value] : *row) {
+                PAIMON_RETURN_NOT_OK_FROM_ARROW(key_builder->Append(key));
+                if (value) {
+                    
PAIMON_RETURN_NOT_OK_FROM_ARROW(item_builder->Append(*value));
+                } else {
+                    
PAIMON_RETURN_NOT_OK_FROM_ARROW(item_builder->AppendNull());
+                }
+            }
+        }
+        std::shared_ptr<arrow::Array> array;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(map_builder.Finish(&array));
+        return array;
+    }
+
     void CheckArrayBlobColumn(const std::shared_ptr<arrow::StructArray>& rows,
                               const std::string& field_name, const 
std::string& expected_json,
                               bool blob_as_descriptor) const {
@@ -1706,6 +1735,104 @@ TEST_P(BlobTableInteTest, 
TestArrayBlobWithBlobFieldAcrossMultipleBlobFiles) {
     ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
 }
 
+TEST_P(BlobTableInteTest, TestMapBlobWriteAndPartialUpdate) {
+    arrow::FieldVector fields = {
+        arrow::field("f0", arrow::int32()),
+        arrow::field(
+            "m0", arrow::map(arrow::utf8(), BlobUtils::ToArrowField("value", 
/*nullable=*/true)))};
+    std::map<std::string, std::string> options = {
+        {Options::FILE_FORMAT, GetParam()},
+        {Options::FILE_SYSTEM, "local"},
+        {Options::ROW_TRACKING_ENABLED, "true"},
+        {Options::DATA_EVOLUTION_ENABLED, "true"},
+        {Options::BLOB_WRITE_NULL_ON_FETCH_FAILURE, "true"}};
+    CreateTable(fields, /*partition_keys=*/{}, options);
+    std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+    auto schema = arrow::schema(fields);
+
+    auto src_array = std::dynamic_pointer_cast<arrow::StructArray>(
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
+        [1, [["a", "va"], ["", "empty key"], ["null", null]]],
+        [2, null],
+        [3, []],
+        [4, [["b", "vb"]]]
+    ])")
+            .ValueOrDie());
+    ASSERT_OK_AND_ASSIGN(auto commit_msgs0,
+                         WriteArray(table_path, {}, schema->field_names(), 
{src_array}));
+    ASSERT_OK(Commit(table_path, commit_msgs0));
+    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), src_array));
+
+    auto update_array = std::dynamic_pointer_cast<arrow::StructArray>(
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_({fields[1]}), 
R"([
+        [[["k", null], ["k", null]]],
+        [[["updated", "vu"], ["null", null]]],
+        [null],
+        [[["k", null], ["k", null]]]
+    ])")
+            .ValueOrDie());
+    ASSERT_OK_AND_ASSIGN(auto commit_msgs1, WriteArray(table_path, {}, {"m0"}, 
{update_array}));
+    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs1);
+    ASSERT_OK(Commit(table_path, commit_msgs1));
+
+    auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
+        [1, [["a", "va"], ["", "empty key"], ["null", null]]],
+        [2, [["updated", "vu"], ["null", null]]],
+        [3, null],
+        [4, [["b", "vb"]]]
+    ])")
+            .ValueOrDie());
+    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
+
+    const std::string alpha_path = blob_dir_->Str() + "/alpha.bin";
+    ASSERT_OK(std::make_shared<LocalFileSystem>()->WriteFile(alpha_path, 
"alpha",
+                                                             
/*overwrite=*/true));
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<Blob> alpha_blob, 
Blob::FromPath(alpha_path));
+    PAIMON_UNIQUE_PTR<Bytes> alpha_descriptor = 
alpha_blob->ToDescriptor(pool_);
+    ASSERT_OK_AND_ASSIGN(std::unique_ptr<Blob> missing_blob,
+                         Blob::FromPath(blob_dir_->Str() + "/missing.bin"));
+    PAIMON_UNIQUE_PTR<Bytes> missing_descriptor = 
missing_blob->ToDescriptor(pool_);
+    const MapBlobEntries placeholder = {{"k", std::nullopt}, {"k", 
std::nullopt}};
+    ASSERT_OK_AND_ASSIGN(
+        std::shared_ptr<arrow::Array> update_map,
+        MakeMapBlobArray(
+            fields[1]->type(),
+            {MapBlobEntries{
+                 {"missing", std::string(missing_descriptor->data(), 
missing_descriptor->size())},
+                 {"desc", std::string(alpha_descriptor->data(), 
alpha_descriptor->size())}},
+             placeholder, placeholder, placeholder}));
+    auto second_update_array = arrow::StructArray::Make({update_map}, 
{fields[1]}).ValueOrDie();
+    ASSERT_OK_AND_ASSIGN(auto commit_msgs2,
+                         WriteArray(table_path, {}, {"m0"}, 
{second_update_array}));
+    SetFirstRowId(/*reset_first_row_id=*/0, commit_msgs2);
+    ASSERT_OK(Commit(table_path, commit_msgs2));
+    blob_dir_.reset();
+
+    expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
+        arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
+        [1, [["missing", null], ["desc", "alpha"]]],
+        [2, [["updated", "vu"], ["null", null]]],
+        [3, null],
+        [4, [["b", "vb"]]]
+    ])")
+            .ValueOrDie());
+    ASSERT_OK(ScanAndRead(table_path, schema->field_names(), expected_array));
+
+    ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
+    std::map<std::string, std::string> read_options = 
{{Options::BLOB_AS_DESCRIPTOR, "true"}};
+    ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, 
schema->field_names(), plan,
+                                                /*predicate=*/nullptr, 
read_options));
+    ASSERT_TRUE(result);
+    auto rows = std::dynamic_pointer_cast<arrow::StructArray>(
+        arrow::Concatenate(result->chunks()).ValueOrDie());
+    ASSERT_TRUE(rows);
+    CheckMapBlobColumn(rows, "m0",
+                       R"([[["missing", null], ["desc", "alpha"]],
+                           [["updated", "vu"], ["null", null]], null, [["b", 
"vb"]]])",
+                       /*blob_as_descriptor=*/true);
+}
+
 TEST_P(BlobTableInteTest, TestDataEvolutionBlobPartialUpdateMultipleLayers) {
     arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
                                  arrow::field("f1", arrow::utf8()), 
BlobUtils::ToArrowField("b0")};

Reply via email to