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

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


The following commit(s) were added to refs/heads/main by this push:
     new 6d1f8eaf feat(blob): support MAP<K, BLOB> reads in Go (#784)
6d1f8eaf is described below

commit 6d1f8eaf23cccd4071d20f1c70520c2064e81aa9
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Sep 3 13:35:51 2026 +0800

    feat(blob): support MAP<K, BLOB> reads in Go (#784)
---
 .licenserc.yaml                                    |   1 +
 bindings/go/blob_reader.go                         |  37 +
 bindings/go/table.go                               |  63 +-
 bindings/go/tests/blob_reader_test.go              |  90 ++
 ...-5d9ffa8c-bb8e-4beb-bec0-20417639ec99-0.parquet | Bin 0 -> 463 bytes
 ...ata-feedc1e6-e063-4f9b-8fb7-e70912f4e374-0.blob | Bin 0 -> 109 bytes
 ...manifest-aa99cf7f-6ec4-420a-a2fb-091e7a31c5a2-0 | Bin 0 -> 2293 bytes
 ...est-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-0 | Bin 0 -> 799 bytes
 ...est-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-1 | Bin 0 -> 899 bytes
 .../tests/testdata/map_blob_table/schema/schema-0  |  30 +
 .../tests/testdata/map_blob_table/snapshot/LATEST  |   1 +
 .../testdata/map_blob_table/snapshot/snapshot-1    |  15 +
 crates/paimon/src/arrow/format/blob.rs             | 956 ++++++++++++++++++++-
 crates/paimon/src/spec/schema.rs                   |   2 +-
 crates/paimon/src/spec/types.rs                    |   3 +
 .../table/data_evolution_reader/blob_fallback.rs   |  51 +-
 docs/src/go-binding.md                             |  77 ++
 17 files changed, 1268 insertions(+), 58 deletions(-)

diff --git a/.licenserc.yaml b/.licenserc.yaml
index e147040a..9ee03354 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -28,6 +28,7 @@ header:
     - ".github/PULL_REQUEST_TEMPLATE.md"
     - "crates/paimon/tests/**/*.json"
     - "crates/paimon/testdata/**"
+    - "bindings/go/tests/testdata/**"
     - "third-party-licenses/jieba-rs-0.10.3.LICENSE"
     - "third-party-licenses/openssl-1.1.1.LICENSE"
     - "**/go.sum"
diff --git a/bindings/go/blob_reader.go b/bindings/go/blob_reader.go
index 7f0071e5..529ae9b0 100644
--- a/bindings/go/blob_reader.go
+++ b/bindings/go/blob_reader.go
@@ -23,9 +23,12 @@ import (
        "context"
        "fmt"
        "runtime"
+       "strings"
        "sync"
        "unsafe"
 
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
        "github.com/jupiterrider/ffi"
 )
 
@@ -83,6 +86,40 @@ func (r *BlobReader) ReadBlobs(descriptors [][]byte) 
([][]byte, error) {
        return ffiBlobReaderReadBlobs.symbol(r.ctx)(r.inner, descriptors)
 }
 
+// StringBlobMapDescriptors returns one MAP<STRING, BLOB> row.
+func StringBlobMapDescriptors(column arrow.Array, row int) (map[string][]byte, 
error) {
+       m, ok := column.(*array.Map)
+       if !ok {
+               return nil, fmt.Errorf("paimon: BLOB map column is %T, want 
*array.Map", column)
+       }
+       if row < 0 || row >= m.Len() {
+               return nil, fmt.Errorf("paimon: BLOB map row %d is out of 
range", row)
+       }
+       if m.IsNull(row) {
+               return nil, nil
+       }
+       keys, ok := m.Keys().(*array.String)
+       if !ok {
+               return nil, fmt.Errorf("paimon: BLOB map keys are %T, want 
*array.String", m.Keys())
+       }
+       descriptors, ok := m.Items().(*array.Binary)
+       if !ok {
+               return nil, fmt.Errorf("paimon: BLOB map values are %T, want 
*array.Binary", m.Items())
+       }
+       start, end := m.ValueOffsets(row)
+       result := make(map[string][]byte, end-start)
+       for index := start; index < end; index++ {
+               i := int(index)
+               key := strings.Clone(keys.Value(i))
+               if descriptors.IsNull(i) {
+                       result[key] = nil
+                       continue
+               }
+               result[key] = append([]byte(nil), descriptors.Value(i)...)
+       }
+       return result, nil
+}
+
 // Close releases the reader and is idempotent.
 func (r *BlobReader) Close() {
        r.mu.Lock()
diff --git a/bindings/go/table.go b/bindings/go/table.go
index e6008a22..d41a84bd 100644
--- a/bindings/go/table.go
+++ b/bindings/go/table.go
@@ -21,6 +21,7 @@ package paimon
 
 import (
        "context"
+       "runtime"
        "sync"
        "unsafe"
 
@@ -54,8 +55,20 @@ func (t *Table) NewReadBuilder() (*ReadBuilder, error) {
        if t.inner == nil {
                return nil, ErrClosed
        }
-       createFn := ffiTableNewReadBuilder.symbol(t.ctx)
-       inner, err := createFn(t.inner)
+       inner, err := ffiTableNewReadBuilder.symbol(t.ctx)(t.inner)
+       if err != nil {
+               return nil, err
+       }
+       t.lib.acquire()
+       return &ReadBuilder{ctx: t.ctx, lib: t.lib, inner: inner}, nil
+}
+
+// NewReadBuilderWithOptions creates a ReadBuilder with per-read options.
+func (t *Table) NewReadBuilderWithOptions(options map[string]string) 
(*ReadBuilder, error) {
+       if t.inner == nil {
+               return nil, ErrClosed
+       }
+       inner, err := ffiTableNewReadBuilderWithOptions.symbol(t.ctx)(t.inner, 
options)
        if err != nil {
                return nil, err
        }
@@ -93,3 +106,49 @@ var ffiTableNewReadBuilder = newFFI(ffiOpts{
                return result.readBuilder, nil
        }
 })
+
+var ffiTableNewReadBuilderWithOptions = newFFI(ffiOpts{
+       sym:   "paimon_table_new_read_builder_with_options",
+       rType: &typeResultReadBuilder,
+       aTypes: []*ffi.Type{
+               &ffi.TypePointer,
+               &ffi.TypePointer,
+               &ffi.TypePointer,
+       },
+}, func(ctx context.Context, ffiCall ffiCall) func(*paimonTable, 
map[string]string) (*paimonReadBuilder, error) {
+       return func(table *paimonTable, options map[string]string) 
(*paimonReadBuilder, error) {
+               type paimonOption struct {
+                       key   *byte
+                       value *byte
+               }
+               opts := make([]paimonOption, 0, len(options))
+               for key, value := range options {
+                       keyPtr, err := bytePtrFromString(key)
+                       if err != nil {
+                               return nil, err
+                       }
+                       valuePtr, err := bytePtrFromString(value)
+                       if err != nil {
+                               return nil, err
+                       }
+                       opts = append(opts, paimonOption{key: keyPtr, value: 
valuePtr})
+               }
+               var optsPtr unsafe.Pointer
+               if len(opts) > 0 {
+                       optsPtr = unsafe.Pointer(&opts[0])
+               }
+               optsLen := uintptr(len(opts))
+               var result resultReadBuilder
+               ffiCall(
+                       unsafe.Pointer(&result),
+                       unsafe.Pointer(&table),
+                       unsafe.Pointer(&optsPtr),
+                       unsafe.Pointer(&optsLen),
+               )
+               runtime.KeepAlive(opts)
+               if result.error != nil {
+                       return nil, parseError(ctx, result.error)
+               }
+               return result.readBuilder, nil
+       }
+})
diff --git a/bindings/go/tests/blob_reader_test.go 
b/bindings/go/tests/blob_reader_test.go
index 14e3ea39..191d854c 100644
--- a/bindings/go/tests/blob_reader_test.go
+++ b/bindings/go/tests/blob_reader_test.go
@@ -30,6 +30,7 @@ import (
        "strings"
        "testing"
 
+       "github.com/apache/arrow-go/v18/arrow/array"
        paimon "github.com/apache/paimon-rust/bindings/go"
 )
 
@@ -101,6 +102,95 @@ func TestBlobReaderReadBlobAndBatch(t *testing.T) {
        }
 }
 
+func TestStringBlobMapDescriptors(t *testing.T) {
+       source := filepath.Join("testdata", "map_blob_table")
+       warehouse := t.TempDir()
+       if err := copyDirectory(source, filepath.Join(warehouse, "default.db", 
"map_blob_table")); err != nil {
+               t.Fatal(err)
+       }
+       table := openTableAt(t, warehouse, "map_blob_table")
+       builder, err := table.NewReadBuilderWithOptions(map[string]string{
+               "blob-as-descriptor": "true",
+       })
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer builder.Close()
+       if err := builder.WithProjection([]string{"id", "assets"}); err != nil {
+               t.Fatal(err)
+       }
+       scan, err := builder.NewScan()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer scan.Close()
+       plan, err := scan.Plan()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer plan.Close()
+       read, err := builder.NewRead()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer read.Close()
+       batches, err := read.NewRecordBatchReader(plan.Splits())
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer batches.Close()
+
+       rows := make(map[int32]map[string][]byte)
+       for {
+               record, err := batches.NextRecord()
+               if errors.Is(err, io.EOF) {
+                       break
+               }
+               if err != nil {
+                       t.Fatal(err)
+               }
+               ids := record.Column(0).(*array.Int32)
+               for row := 0; row < int(record.NumRows()); row++ {
+                       rows[ids.Value(row)], err = 
paimon.StringBlobMapDescriptors(record.Column(1), row)
+                       if err != nil {
+                               record.Release()
+                               t.Fatal(err)
+                       }
+               }
+               record.Release()
+       }
+
+       descriptors := rows[1]
+       if len(rows) != 3 {
+               t.Fatalf("read %d rows, want 3", len(rows))
+       }
+       if len(descriptors["first"]) == 0 || len(descriptors["tail"]) == 0 {
+               t.Fatalf("descriptor map is invalid after Arrow release: %#v", 
descriptors)
+       }
+       if rows[2] != nil || len(rows[3]) != 0 {
+               t.Fatalf("unexpected null or empty maps: %#v", rows)
+       }
+       reader, err := table.NewBlobReader()
+       if err != nil {
+               t.Fatal(err)
+       }
+       defer reader.Close()
+       resolved, err := reader.ReadBlobs([][]byte{
+               descriptors["tail"],
+               descriptors["first"],
+               descriptors["empty"],
+       })
+       if err != nil {
+               t.Fatal(err)
+       }
+       if string(resolved[0]) != "ghij" || string(resolved[1]) != "abc" || 
len(resolved[2]) != 0 {
+               t.Fatalf("unexpected values: %q", resolved)
+       }
+       if descriptors["null"] != nil {
+               t.Fatalf("null BLOB returned %#v", descriptors["null"])
+       }
+}
+
 func TestBlobReaderFromTableOutlivesTable(t *testing.T) {
        file := writeBlobFile(t, "table", "abcdefghij")
 
diff --git 
a/bindings/go/tests/testdata/map_blob_table/bucket-0/data-5d9ffa8c-bb8e-4beb-bec0-20417639ec99-0.parquet
 
b/bindings/go/tests/testdata/map_blob_table/bucket-0/data-5d9ffa8c-bb8e-4beb-bec0-20417639ec99-0.parquet
new file mode 100644
index 00000000..135180d7
Binary files /dev/null and 
b/bindings/go/tests/testdata/map_blob_table/bucket-0/data-5d9ffa8c-bb8e-4beb-bec0-20417639ec99-0.parquet
 differ
diff --git 
a/bindings/go/tests/testdata/map_blob_table/bucket-0/data-feedc1e6-e063-4f9b-8fb7-e70912f4e374-0.blob
 
b/bindings/go/tests/testdata/map_blob_table/bucket-0/data-feedc1e6-e063-4f9b-8fb7-e70912f4e374-0.blob
new file mode 100644
index 00000000..93a623ba
Binary files /dev/null and 
b/bindings/go/tests/testdata/map_blob_table/bucket-0/data-feedc1e6-e063-4f9b-8fb7-e70912f4e374-0.blob
 differ
diff --git 
a/bindings/go/tests/testdata/map_blob_table/manifest/manifest-aa99cf7f-6ec4-420a-a2fb-091e7a31c5a2-0
 
b/bindings/go/tests/testdata/map_blob_table/manifest/manifest-aa99cf7f-6ec4-420a-a2fb-091e7a31c5a2-0
new file mode 100644
index 00000000..09535568
Binary files /dev/null and 
b/bindings/go/tests/testdata/map_blob_table/manifest/manifest-aa99cf7f-6ec4-420a-a2fb-091e7a31c5a2-0
 differ
diff --git 
a/bindings/go/tests/testdata/map_blob_table/manifest/manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-0
 
b/bindings/go/tests/testdata/map_blob_table/manifest/manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-0
new file mode 100644
index 00000000..78521e82
Binary files /dev/null and 
b/bindings/go/tests/testdata/map_blob_table/manifest/manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-0
 differ
diff --git 
a/bindings/go/tests/testdata/map_blob_table/manifest/manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-1
 
b/bindings/go/tests/testdata/map_blob_table/manifest/manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-1
new file mode 100644
index 00000000..2bdd4a87
Binary files /dev/null and 
b/bindings/go/tests/testdata/map_blob_table/manifest/manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-1
 differ
diff --git a/bindings/go/tests/testdata/map_blob_table/schema/schema-0 
b/bindings/go/tests/testdata/map_blob_table/schema/schema-0
new file mode 100644
index 00000000..acdb0d85
--- /dev/null
+++ b/bindings/go/tests/testdata/map_blob_table/schema/schema-0
@@ -0,0 +1,30 @@
+{
+  "version": 3,
+  "id": 0,
+  "fields": [
+    {
+      "id": 0,
+      "name": "id",
+      "type": "INT"
+    },
+    {
+      "id": 1,
+      "name": "assets",
+      "type": {
+        "type": "MAP",
+        "key": "STRING NOT NULL",
+        "value": "BLOB",
+        "nullable": true
+      }
+    }
+  ],
+  "highestFieldId": 1,
+  "partitionKeys": [],
+  "primaryKeys": [],
+  "options": {
+    "row-tracking.enabled": "true",
+    "data-evolution.enabled": "true"
+  },
+  "comment": null,
+  "timeMillis": 1788359448372
+}
diff --git a/bindings/go/tests/testdata/map_blob_table/snapshot/LATEST 
b/bindings/go/tests/testdata/map_blob_table/snapshot/LATEST
new file mode 100644
index 00000000..56a6051c
--- /dev/null
+++ b/bindings/go/tests/testdata/map_blob_table/snapshot/LATEST
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/bindings/go/tests/testdata/map_blob_table/snapshot/snapshot-1 
b/bindings/go/tests/testdata/map_blob_table/snapshot/snapshot-1
new file mode 100644
index 00000000..0e069762
--- /dev/null
+++ b/bindings/go/tests/testdata/map_blob_table/snapshot/snapshot-1
@@ -0,0 +1,15 @@
+{
+  "version": 3,
+  "id": 1,
+  "schemaId": 0,
+  "baseManifestList": "manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-0",
+  "deltaManifestList": "manifest-list-d854a2ec-c453-4ece-bcbe-5ad5411a8273-1",
+  "totalRecordCount": 6,
+  "deltaRecordCount": 6,
+  "commitUser": "ab9bd0e7-c0bc-4695-84cf-44f36df42a6a",
+  "commitIdentifier": 9223372036854775807,
+  "commitKind": "APPEND",
+  "timeMillis": 1788359448379,
+  "nextRowId": 3,
+  "uuid": "19a3dfb1-eae3-4d7a-8329-26b85143edcc"
+}
\ No newline at end of file
diff --git a/crates/paimon/src/arrow/format/blob.rs 
b/crates/paimon/src/arrow/format/blob.rs
index a7c7d7ed..6002d465 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -22,7 +22,13 @@ use crate::spec::{BlobDescriptor, DataField, DataType};
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
 use arrow_array::builder::{BinaryBuilder, ListBuilder};
-use arrow_array::{Array, ArrayRef, RecordBatch, RecordBatchOptions};
+use arrow_array::{
+    Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array, 
Int16Array,
+    Int32Array, Int64Array, Int8Array, MapArray, RecordBatch, 
RecordBatchOptions, StringArray,
+    StructArray, Time32MillisecondArray,
+};
+use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer};
+use arrow_schema::DataType as ArrowDataType;
 use async_stream::try_stream;
 use async_trait::async_trait;
 use bytes::Bytes;
@@ -96,12 +102,29 @@ impl IndexedBlobReader {
         )
         .await
     }
+
+    pub(crate) async fn read_map_positions(
+        &self,
+        positions: &[usize],
+        key_type: &DataType,
+    ) -> crate::Result<Vec<BlobReadValue>> {
+        let planned_reads = plan_blob_array_reads(&self.index, positions)?;
+        fetch_blob_map_values(
+            self.reader.as_ref(),
+            planned_reads,
+            &self.file_path,
+            self.descriptor_mode,
+            key_type,
+        )
+        .await
+    }
 }
 
 #[derive(Debug)]
 pub(crate) enum BlobReadValue {
     Value(Bytes),
     Array(Vec<Option<Bytes>>),
+    Map(Vec<(Bytes, Option<Bytes>)>),
     Null,
     Placeholder,
 }
@@ -121,11 +144,18 @@ const BLOB_ARRAY_HEADER_SIZE: u64 = 9;
 const BLOB_ARRAY_INDEX_LENGTH_SIZE: u64 = 4;
 const BLOB_ARRAY_MIN_PAYLOAD_SIZE: u64 = BLOB_ARRAY_HEADER_SIZE + 
BLOB_ARRAY_INDEX_LENGTH_SIZE;
 const BLOB_ARRAY_NULL_ELEMENT_LENGTH: i64 = -1;
+const BLOB_MAP_MAGIC_NUMBER: i32 = 0x4D424342;
+const BLOB_MAP_VERSION: u8 = 1;
+const BLOB_MAP_HEADER_SIZE: u64 = 9;
+const BLOB_MAP_INDEX_LENGTHS_SIZE: u64 = 8;
+const BLOB_MAP_MIN_PAYLOAD_SIZE: u64 = BLOB_MAP_HEADER_SIZE + 
BLOB_MAP_INDEX_LENGTHS_SIZE;
+const BLOB_MAP_NULL_LENGTH: i64 = -1;
 
-#[derive(Debug, Clone, Copy)]
-enum BlobFieldKind {
+#[derive(Debug, Clone)]
+pub(crate) enum BlobFieldKind {
     Scalar,
     Array,
+    Map(DataType),
 }
 
 #[async_trait]
@@ -159,7 +189,7 @@ impl FormatFileReader for BlobFormatReader {
 
         Ok(try_stream! {
             while let Some(positions) = selection.next_batch(batch_size) {
-                let batch = match field_kind {
+                let batch = match &field_kind {
                     Some(BlobFieldKind::Scalar) => {
                         let values = 
blob_reader.read_positions(&positions).await?;
                         build_blob_batch(&target_schema, values)?
@@ -168,6 +198,10 @@ impl FormatFileReader for BlobFormatReader {
                         let values = 
blob_reader.read_array_positions(&positions).await?;
                         build_blob_array_batch(&target_schema, values)?
                     }
+                    Some(BlobFieldKind::Map(key_type)) => {
+                        let values = 
blob_reader.read_map_positions(&positions, key_type).await?;
+                        build_blob_map_batch(&target_schema, values, key_type)?
+                    }
                     None => RecordBatch::try_new_with_options(
                         target_schema.clone(),
                         Vec::new(),
@@ -203,9 +237,12 @@ fn validate_read_fields(read_fields: &[DataField]) -> 
crate::Result<Option<BlobF
             DataType::Array(array) if matches!(array.element_type(), 
DataType::Blob(_)) => {
                 Ok(BlobFieldKind::Array)
             }
+            DataType::Map(map) if matches!(map.value_type(), 
DataType::Blob(_)) => {
+                Ok(BlobFieldKind::Map(map.key_type().clone()))
+            }
             other => Err(Error::DataInvalid {
                 message: format!(
-                    ".blob format requires a Blob or Array<Blob> field, got 
{:?} for column '{}'",
+                    ".blob format requires a Blob, Array<Blob>, or Map<X, 
Blob> field, got {:?} for column '{}'",
                     other,
                     field.name()
                 ),
@@ -258,7 +295,7 @@ pub(crate) fn build_blob_batch(
         match value {
             BlobReadValue::Value(bytes) => 
builder.append_value(bytes.as_ref()),
             BlobReadValue::Null | BlobReadValue::Placeholder => 
builder.append_null(),
-            BlobReadValue::Array(_) => {
+            BlobReadValue::Array(_) | BlobReadValue::Map(_) => {
                 return Err(Error::UnexpectedError {
                     message: "Scalar BLOB reader produced an ARRAY<BLOB> 
value".to_string(),
                     source: None,
@@ -302,7 +339,7 @@ pub(crate) fn build_blob_array_batch(
                 builder.append(true);
             }
             BlobReadValue::Null | BlobReadValue::Placeholder => 
builder.append(false),
-            BlobReadValue::Value(_) => {
+            BlobReadValue::Value(_) | BlobReadValue::Map(_) => {
                 return Err(Error::UnexpectedError {
                     message: "ARRAY<BLOB> reader produced a scalar BLOB 
value".to_string(),
                     source: None,
@@ -318,6 +355,275 @@ pub(crate) fn build_blob_array_batch(
     })
 }
 
+pub(crate) fn build_blob_map_batch(
+    target_schema: &Arc<arrow_schema::Schema>,
+    values: Vec<BlobReadValue>,
+    key_type: &DataType,
+) -> crate::Result<RecordBatch> {
+    let ArrowDataType::Map(entries_field, ordered) = 
target_schema.field(0).data_type() else {
+        return Err(Error::UnexpectedError {
+            message: "Expected MAP<X, BLOB> to map to Arrow Map".to_string(),
+            source: None,
+        });
+    };
+    let ArrowDataType::Struct(entry_fields) = entries_field.data_type() else {
+        return Err(Error::UnexpectedError {
+            message: "Expected MAP<X, BLOB> entries to be an Arrow 
Struct".to_string(),
+            source: None,
+        });
+    };
+
+    let mut keys = Vec::new();
+    let mut blobs = Vec::new();
+    let mut blob_data_length = 0u64;
+    let mut offsets = vec![0i32];
+    let mut validity = Vec::with_capacity(values.len());
+    for value in values {
+        match value {
+            BlobReadValue::Map(entries) => {
+                validity.push(true);
+                let next = offsets
+                    .last()
+                    .copied()
+                    .unwrap()
+                    .checked_add(
+                        i32::try_from(entries.len()).map_err(|e| 
Error::DataInvalid {
+                            message: "MAP<X, BLOB> entry count exceeds Arrow 
i32 offsets"
+                                .to_string(),
+                            source: Some(Box::new(e)),
+                        })?,
+                    )
+                    .ok_or_else(|| Error::DataInvalid {
+                        message: "MAP<X, BLOB> batch exceeds Arrow i32 
offsets".to_string(),
+                        source: None,
+                    })?;
+                for (key, blob) in entries {
+                    if let Some(blob) = &blob {
+                        blob_data_length = checked_arrow_binary_data_length(
+                            blob_data_length,
+                            blob.len() as u64,
+                            "MAP<X, BLOB> batch value data",
+                        )?;
+                    }
+                    keys.push(key);
+                    blobs.push(blob);
+                }
+                offsets.push(next);
+            }
+            BlobReadValue::Null | BlobReadValue::Placeholder => {
+                validity.push(false);
+                offsets.push(*offsets.last().unwrap());
+            }
+            BlobReadValue::Value(_) | BlobReadValue::Array(_) => {
+                return Err(Error::UnexpectedError {
+                    message: "MAP<X, BLOB> reader produced a non-map 
value".to_string(),
+                    source: None,
+                });
+            }
+        }
+    }
+
+    let key_array = decode_blob_map_keys(&keys, key_type)?;
+    let value_array = Arc::new(BinaryArray::from_iter(
+        blobs.iter().map(|value| value.as_deref()),
+    )) as ArrayRef;
+    let entries = StructArray::try_new(entry_fields.clone(), vec![key_array, 
value_array], None)
+        .map_err(|e| Error::UnexpectedError {
+            message: format!("Failed to build MAP<X, BLOB> entries: {e}"),
+            source: Some(Box::new(e)),
+        })?;
+    let map = MapArray::try_new(
+        entries_field.clone(),
+        OffsetBuffer::new(ScalarBuffer::from(offsets)),
+        entries,
+        Some(NullBuffer::new(BooleanBuffer::from(validity))),
+        *ordered,
+    )
+    .map_err(|e| Error::UnexpectedError {
+        message: format!("Failed to build MAP<X, BLOB> array: {e}"),
+        source: Some(Box::new(e)),
+    })?;
+    RecordBatch::try_new(target_schema.clone(), 
vec![Arc::new(map)]).map_err(|e| {
+        Error::UnexpectedError {
+            message: format!("Failed to build MAP<X, BLOB> RecordBatch: {e}"),
+            source: Some(Box::new(e)),
+        }
+    })
+}
+
+fn decode_blob_map_keys(keys: &[Bytes], key_type: &DataType) -> 
crate::Result<ArrayRef> {
+    macro_rules! fixed_keys {
+        ($array:ty, $type:ty, $size:expr) => {{
+            let values = keys
+                .iter()
+                .map(|key| {
+                    let bytes: [u8; $size] =
+                        key.as_ref().try_into().map_err(|_| Error::DataInvalid 
{
+                            message: format!(
+                                "Invalid MAP<X, BLOB> fixed-width key length: 
{}",
+                                key.len()
+                            ),
+                            source: None,
+                        })?;
+                    Ok(<$type>::from_le_bytes(bytes))
+                })
+                .collect::<crate::Result<Vec<_>>>()?;
+            Ok(Arc::new(<$array>::from(values)) as ArrayRef)
+        }};
+    }
+
+    for key in keys {
+        validate_blob_map_key_length(key_type, key.len() as u64)?;
+    }
+    if blob_map_key_uses_binary_offsets(key_type) {
+        keys.iter().try_fold(0u64, |total, key| {
+            checked_arrow_binary_data_length(total, key.len() as u64, "MAP<X, 
BLOB> batch key data")
+        })?;
+    }
+
+    match key_type {
+        DataType::TinyInt(_) => fixed_keys!(Int8Array, i8, 1),
+        DataType::SmallInt(_) => fixed_keys!(Int16Array, i16, 2),
+        DataType::Int(_) => fixed_keys!(Int32Array, i32, 4),
+        DataType::BigInt(_) => fixed_keys!(Int64Array, i64, 8),
+        DataType::Date(_) => fixed_keys!(Date32Array, i32, 4),
+        DataType::Time(_) => fixed_keys!(Time32MillisecondArray, i32, 4),
+        DataType::Boolean(_) => {
+            let values = keys
+                .iter()
+                .map(|key| match key.as_ref() {
+                    [0] => Ok(false),
+                    [1] => Ok(true),
+                    _ => Err(Error::DataInvalid {
+                        message: "Invalid MAP<X, BLOB> boolean 
key".to_string(),
+                        source: None,
+                    }),
+                })
+                .collect::<crate::Result<Vec<_>>>()?;
+            Ok(Arc::new(BooleanArray::from(values)))
+        }
+        DataType::Char(_) | DataType::VarChar(_) => {
+            let values = keys
+                .iter()
+                .map(|key| {
+                    std::str::from_utf8(key).map_err(|e| Error::DataInvalid {
+                        message: "Invalid MAP<X, BLOB> string key".to_string(),
+                        source: Some(Box::new(e)),
+                    })
+                })
+                .collect::<crate::Result<Vec<_>>>()?;
+            Ok(Arc::new(StringArray::from(values)))
+        }
+        DataType::Binary(_) | DataType::VarBinary(_) => Ok(Arc::new(
+            BinaryArray::from_iter_values(keys.iter().map(|key| key.as_ref())),
+        )),
+        DataType::Decimal(decimal) => {
+            let values = keys
+                .iter()
+                .map(|key| decode_blob_map_decimal(key, decimal.precision()))
+                .collect::<crate::Result<Vec<_>>>()?;
+            let array = Decimal128Array::from(values)
+                .with_precision_and_scale(decimal.precision() as u8, 
decimal.scale() as i8)
+                .map_err(|e| Error::DataInvalid {
+                    message: format!("Invalid MAP<X, BLOB> decimal key: {e}"),
+                    source: Some(Box::new(e)),
+                })?;
+            Ok(Arc::new(array))
+        }
+        other => Err(Error::Unsupported {
+            message: format!("Unsupported key type for MAP<X, BLOB>: 
{other:?}"),
+        }),
+    }
+}
+
+fn blob_map_key_uses_binary_offsets(key_type: &DataType) -> bool {
+    matches!(
+        key_type,
+        DataType::Char(_) | DataType::VarChar(_) | DataType::Binary(_) | 
DataType::VarBinary(_)
+    )
+}
+
+fn validate_blob_map_key_length(key_type: &DataType, length: u64) -> 
crate::Result<()> {
+    let fixed_length = match key_type {
+        DataType::TinyInt(_) | DataType::Boolean(_) => Some(1),
+        DataType::SmallInt(_) => Some(2),
+        DataType::Int(_) | DataType::Date(_) | DataType::Time(_) => Some(4),
+        DataType::BigInt(_) => Some(8),
+        DataType::Decimal(decimal) if decimal.precision() <= 18 => Some(8),
+        DataType::Decimal(_) => {
+            if !(1..=16).contains(&length) {
+                return Err(Error::DataInvalid {
+                    message: "Invalid MAP<X, BLOB> decimal key".to_string(),
+                    source: None,
+                });
+            }
+            return Ok(());
+        }
+        DataType::Char(_) | DataType::VarChar(_) | DataType::Binary(_) | 
DataType::VarBinary(_) => {
+            return Ok(())
+        }
+        other => {
+            return Err(Error::Unsupported {
+                message: format!("Unsupported key type for MAP<X, BLOB>: 
{other:?}"),
+            });
+        }
+    };
+    if fixed_length != Some(length) {
+        return Err(Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> fixed-width key length: 
{length}"),
+            source: None,
+        });
+    }
+    Ok(())
+}
+
+fn checked_arrow_binary_data_length(
+    current: u64,
+    additional: u64,
+    context: &str,
+) -> crate::Result<u64> {
+    let total = current
+        .checked_add(additional)
+        .filter(|total| *total <= i32::MAX as u64)
+        .ok_or_else(|| Error::DataInvalid {
+            message: format!("{context} is too large for Arrow Binary"),
+            source: None,
+        })?;
+    Ok(total)
+}
+
+fn decode_blob_map_decimal(bytes: &[u8], precision: u32) -> 
crate::Result<i128> {
+    let value = if precision <= 18 {
+        let bytes: [u8; 8] = bytes.try_into().map_err(|_| Error::DataInvalid {
+            message: format!(
+                "Invalid MAP<X, BLOB> fixed-width key length: {}",
+                bytes.len()
+            ),
+            source: None,
+        })?;
+        i64::from_le_bytes(bytes) as i128
+    } else {
+        if bytes.is_empty() || bytes.len() > 16 {
+            return Err(Error::DataInvalid {
+                message: "Invalid MAP<X, BLOB> decimal key".to_string(),
+                source: None,
+            });
+        }
+        let fill = if bytes[0] & 0x80 == 0 { 0 } else { 0xff };
+        let mut extended = [fill; 16];
+        extended[16 - bytes.len()..].copy_from_slice(bytes);
+        i128::from_be_bytes(extended)
+    };
+    let digits = value.unsigned_abs().to_string().len() as u32;
+    if digits > precision {
+        return Err(Error::DataInvalid {
+            message: "MAP<X, BLOB> decimal key exceeds declared 
precision".to_string(),
+            source: None,
+        });
+    }
+    Ok(value)
+}
+
 fn plan_blob_reads(
     blob_index: &BlobFileIndex,
     positions: &[usize],
@@ -764,6 +1070,287 @@ fn build_blob_array_descriptors(
     Ok(BlobReadValue::Array(elements))
 }
 
+async fn fetch_blob_map_values(
+    reader: &dyn FileRead,
+    planned_reads: Vec<PlannedBlobArrayRead>,
+    file_path: &str,
+    descriptor_mode: bool,
+    key_type: &DataType,
+) -> crate::Result<Vec<BlobReadValue>> {
+    futures::stream::iter(planned_reads.into_iter().map(|planned_read| async 
move {
+        match planned_read {
+            PlannedBlobArrayRead::Null => Ok(BlobReadValue::Null),
+            PlannedBlobArrayRead::Placeholder => 
Ok(BlobReadValue::Placeholder),
+            PlannedBlobArrayRead::Read(payload_range) => {
+                read_blob_map_entry(reader, payload_range, file_path, 
descriptor_mode, key_type)
+                    .await
+            }
+        }
+    }))
+    .buffered(BLOB_READ_CONCURRENCY)
+    .try_collect()
+    .await
+}
+
+async fn read_blob_map_entry(
+    reader: &dyn FileRead,
+    payload_range: Range<u64>,
+    file_path: &str,
+    descriptor_mode: bool,
+    key_type: &DataType,
+) -> crate::Result<BlobReadValue> {
+    let payload_length = payload_range
+        .end
+        .checked_sub(payload_range.start)
+        .ok_or_else(|| Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> payload range: 
{payload_range:?}"),
+            source: None,
+        })?;
+    if payload_length < BLOB_MAP_MIN_PAYLOAD_SIZE {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "MAP<X, BLOB> payload is too small: expected at least 
{BLOB_MAP_MIN_PAYLOAD_SIZE} bytes, got {payload_length}"
+            ),
+            source: None,
+        });
+    }
+
+    let header = read_blob_map_range(
+        reader,
+        payload_range.start..payload_range.start + BLOB_MAP_HEADER_SIZE,
+        "header",
+    )
+    .await?;
+    let magic = i32::from_le_bytes(header[..4].try_into().unwrap());
+    if magic != BLOB_MAP_MAGIC_NUMBER {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "Invalid MAP<X, BLOB> payload magic number: expected 
{BLOB_MAP_MAGIC_NUMBER}, got {magic}"
+            ),
+            source: None,
+        });
+    }
+    if header[4] != BLOB_MAP_VERSION {
+        return Err(Error::Unsupported {
+            message: format!(
+                "Unsupported MAP<X, BLOB> payload version: expected 
{BLOB_MAP_VERSION}, got {}",
+                header[4]
+            ),
+        });
+    }
+    let entry_count = i32::from_le_bytes(header[5..9].try_into().unwrap());
+    if entry_count < 0 {
+        return Err(Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> entry count: 
{entry_count}"),
+            source: None,
+        });
+    }
+    let entry_count = entry_count as usize;
+
+    let index_lengths_start = payload_range.end - BLOB_MAP_INDEX_LENGTHS_SIZE;
+    let index_lengths = read_blob_map_range(
+        reader,
+        index_lengths_start..payload_range.end,
+        "index lengths",
+    )
+    .await?;
+    let key_index_length = 
i32::from_le_bytes(index_lengths[..4].try_into().unwrap());
+    let value_index_length = 
i32::from_le_bytes(index_lengths[4..8].try_into().unwrap());
+    let max_indexes = payload_length - BLOB_MAP_MIN_PAYLOAD_SIZE;
+    if key_index_length < 0 || key_index_length as u64 > max_indexes {
+        return Err(Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> key index length: 
{key_index_length}"),
+            source: None,
+        });
+    }
+    if value_index_length < 0 || value_index_length as u64 > max_indexes {
+        return Err(Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> value index length: 
{value_index_length}"),
+            source: None,
+        });
+    }
+    let key_index_length = key_index_length as u64;
+    let value_index_length = value_index_length as u64;
+    if key_index_length + value_index_length > max_indexes
+        || entry_count as u64 > key_index_length
+        || entry_count as u64 > value_index_length
+    {
+        return Err(Error::DataInvalid {
+            message: "MAP<X, BLOB> indexes do not match the 
payload".to_string(),
+            source: None,
+        });
+    }
+
+    let value_index_start = index_lengths_start - value_index_length;
+    let key_index_start = value_index_start - key_index_length;
+    let key_index =
+        read_blob_map_range(reader, key_index_start..value_index_start, "key 
index").await?;
+    let value_index = read_blob_map_range(
+        reader,
+        value_index_start..index_lengths_start,
+        "value index",
+    )
+    .await?;
+    let key_lengths = decode_delta_varints(&key_index).map_err(|e| 
Error::DataInvalid {
+        message: format!("Invalid MAP<X, BLOB> key index: {e}"),
+        source: Some(Box::new(e)),
+    })?;
+    let value_lengths = decode_delta_varints(&value_index).map_err(|e| 
Error::DataInvalid {
+        message: format!("Invalid MAP<X, BLOB> value index: {e}"),
+        source: Some(Box::new(e)),
+    })?;
+    if key_lengths.len() != entry_count || value_lengths.len() != entry_count {
+        return Err(Error::DataInvalid {
+            message: "MAP<X, BLOB> entry count does not match index 
lengths".to_string(),
+            source: None,
+        });
+    }
+
+    let data_start = payload_range.start + BLOB_MAP_HEADER_SIZE;
+    let data_length = key_index_start - data_start;
+    let mut key_data_length = 0u64;
+    for &length in &key_lengths {
+        if length == BLOB_MAP_NULL_LENGTH {
+            return Err(Error::DataInvalid {
+                message: "MAP<X, BLOB> null keys cannot be represented by 
Arrow".to_string(),
+                source: None,
+            });
+        }
+        let length = u64::try_from(length).map_err(|e| Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> key length: {length}"),
+            source: Some(Box::new(e)),
+        })?;
+        validate_blob_map_key_length(key_type, length)?;
+        key_data_length = key_data_length
+            .checked_add(length)
+            .filter(|total| *total <= data_length)
+            .ok_or_else(|| Error::DataInvalid {
+                message: "MAP<X, BLOB> key lengths exceed the payload data 
length".to_string(),
+                source: None,
+            })?;
+    }
+    let value_data_length = data_length - key_data_length;
+    let mut total_value_length = 0u64;
+    for &length in &value_lengths {
+        if length == BLOB_MAP_NULL_LENGTH {
+            continue;
+        }
+        let length = u64::try_from(length).map_err(|e| Error::DataInvalid {
+            message: format!("Invalid MAP<X, BLOB> value length: {length}"),
+            source: Some(Box::new(e)),
+        })?;
+        total_value_length = total_value_length
+            .checked_add(length)
+            .filter(|total| *total <= value_data_length)
+            .ok_or_else(|| Error::DataInvalid {
+                message: "MAP<X, BLOB> value lengths exceed the payload data 
length".to_string(),
+                source: None,
+            })?;
+    }
+    if total_value_length != value_data_length {
+        return Err(Error::DataInvalid {
+            message: "MAP<X, BLOB> key/value lengths do not match the payload 
data length"
+                .to_string(),
+            source: None,
+        });
+    }
+    if !descriptor_mode {
+        checked_arrow_binary_data_length(0, total_value_length, "MAP<X, BLOB> 
inline value data")?;
+    }
+    if blob_map_key_uses_binary_offsets(key_type) {
+        checked_arrow_binary_data_length(0, key_data_length, "MAP<X, BLOB> key 
data")?;
+    }
+
+    let key_data =
+        read_blob_map_range(reader, data_start..data_start + key_data_length, 
"key data").await?;
+    let mut keys = Vec::with_capacity(entry_count);
+    let mut cursor = 0usize;
+    let mut unique = std::collections::HashSet::with_capacity(entry_count);
+    for length in key_lengths {
+        let length = length as usize;
+        let end = cursor + length;
+        let key = key_data.slice(cursor..end);
+        if !unique.insert(key.clone()) {
+            return Err(Error::DataInvalid {
+                message: "Invalid MAP<X, BLOB> payload: duplicate 
key".to_string(),
+                source: None,
+            });
+        }
+        keys.push(key);
+        cursor = end;
+    }
+
+    let mut value_offset = data_start + key_data_length;
+    let mut reads = Vec::with_capacity(entry_count);
+    for length in value_lengths {
+        if length == BLOB_MAP_NULL_LENGTH {
+            reads.push(None);
+        } else {
+            let length = length as u64;
+            reads.push(Some(value_offset..value_offset + length));
+            value_offset += length;
+        }
+    }
+    let values = if descriptor_mode {
+        reads
+            .into_iter()
+            .map(|range| {
+                range
+                    .map(|range| {
+                        let offset =
+                            i64::try_from(range.start).map_err(|e| 
Error::DataInvalid {
+                                message: "MAP<X, BLOB> descriptor offset 
exceeds i64".to_string(),
+                                source: Some(Box::new(e)),
+                            })?;
+                        let length = i64::try_from(range.end - 
range.start).map_err(|e| {
+                            Error::DataInvalid {
+                                message: "MAP<X, BLOB> descriptor length 
exceeds i64".to_string(),
+                                source: Some(Box::new(e)),
+                            }
+                        })?;
+                        Ok(Bytes::from(
+                            BlobDescriptor::new(file_path.to_string(), offset, 
length).serialize(),
+                        ))
+                    })
+                    .transpose()
+            })
+            .collect::<crate::Result<Vec<_>>>()?
+    } else {
+        let payload = read_blob_entry(reader, 
blob_entry_range(&payload_range)).await?;
+        reads
+            .into_iter()
+            .map(|range| {
+                range.map(|range| {
+                    payload.slice(
+                        (range.start - payload_range.start) as usize
+                            ..(range.end - payload_range.start) as usize,
+                    )
+                })
+            })
+            .collect()
+    };
+    Ok(BlobReadValue::Map(keys.into_iter().zip(values).collect()))
+}
+
+async fn read_blob_map_range(
+    reader: &dyn FileRead,
+    range: Range<u64>,
+    part: &str,
+) -> crate::Result<Bytes> {
+    let expected = range.end - range.start;
+    let bytes = reader.read(range.clone()).await?;
+    if bytes.len() as u64 != expected {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "Short read for MAP<X, BLOB> {part} range {range:?}: expected 
{expected} bytes, got {}",
+                bytes.len()
+            ),
+            source: None,
+        });
+    }
+    Ok(bytes)
+}
+
 #[derive(Debug, Clone)]
 enum PlannedBlobRead {
     Null,
@@ -1368,7 +1955,7 @@ fn encode_varint(value: i64, out: &mut Vec<u8>) {
 mod tests {
     use super::*;
     use crate::btree::test_util::BytesFileRead;
-    use crate::spec::{ArrayType, BlobType};
+    use crate::spec::{ArrayType, BlobType, MapType, VarCharType};
     use arrow_array::Array;
     use bytes::Bytes;
     use futures::TryStreamExt;
@@ -1504,6 +2091,190 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_blob_map_reader_returns_inline_values_and_descriptors() {
+        let payload = build_blob_map_payload(&[
+            ("video", Some(b"alpha")),
+            ("thumbnail", None),
+            ("empty", Some(b"")),
+        ]);
+        let file_bytes = 
blob_test_utils::build_blob_file_bytes(&[Some(payload.as_slice()), None]);
+        let fields = blob_map_read_fields();
+
+        let inline = BlobFormatReader::new("file:///tmp/map.blob".to_string(), 
false)
+            .read_batch_stream(
+                Box::new(BytesFileRead(Bytes::from(file_bytes.clone()))),
+                file_bytes.len() as u64,
+                &fields,
+                None,
+                None,
+                None,
+            )
+            .await
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert_eq!(
+            collect_blob_map_values(&inline[0]),
+            vec![
+                Some(vec![
+                    ("video".to_string(), Some(b"alpha".to_vec())),
+                    ("thumbnail".to_string(), None),
+                    ("empty".to_string(), Some(Vec::new())),
+                ]),
+                None,
+            ]
+        );
+
+        let descriptors = 
BlobFormatReader::new("file:///tmp/map.blob".to_string(), true)
+            .read_batch_stream(
+                Box::new(BytesFileRead(Bytes::from(file_bytes.clone()))),
+                file_bytes.len() as u64,
+                &fields,
+                None,
+                None,
+                None,
+            )
+            .await
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        let rows = collect_blob_map_values(&descriptors[0]);
+        let entries = rows[0].as_ref().unwrap();
+        let video = 
BlobDescriptor::deserialize(entries[0].1.as_ref().unwrap()).unwrap();
+        assert_eq!(video.uri(), "file:///tmp/map.blob");
+        assert_eq!(video.length(), 5);
+        assert!(entries[1].1.is_none());
+        let empty = 
BlobDescriptor::deserialize(entries[2].1.as_ref().unwrap()).unwrap();
+        assert_eq!(empty.length(), 0);
+    }
+
+    #[tokio::test]
+    async fn test_blob_map_descriptor_read_skips_values() {
+        let file_path = "file:///tmp/map.blob";
+        let payload =
+            build_blob_map_payload(&[("first", Some(b"alpha")), ("second", 
Some(b"beta"))]);
+        let file_bytes = 
blob_test_utils::build_blob_file_bytes(&[Some(payload.as_slice())]);
+        let reader = TrackingFileRead::new(Bytes::from(file_bytes.clone()));
+        let batches = BlobFormatReader::new(file_path.to_string(), true)
+            .read_batch_stream(
+                Box::new(reader.clone()),
+                file_bytes.len() as u64,
+                &blob_map_read_fields(),
+                None,
+                None,
+                None,
+            )
+            .await
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+
+        for (_, descriptor) in 
collect_blob_map_values(&batches[0])[0].as_ref().unwrap() {
+            let descriptor = 
BlobDescriptor::deserialize(descriptor.as_ref().unwrap()).unwrap();
+            let value_range =
+                descriptor.offset() as u64..(descriptor.offset() + 
descriptor.length()) as u64;
+            assert!(reader
+                .ranges()
+                .iter()
+                .all(|range| range.end <= value_range.start || range.start >= 
value_range.end));
+        }
+    }
+
+    #[tokio::test]
+    async fn test_inline_blob_map_reader_rejects_crc_mismatch() {
+        let payload = build_blob_map_payload(&[("key", Some(b"value"))]);
+        let mut file_bytes = 
blob_test_utils::build_blob_file_bytes(&[Some(payload.as_slice())]);
+        let value_offset =
+            (BLOB_INLINE_HEADER_SIZE + BLOB_MAP_HEADER_SIZE + "key".len() as 
u64) as usize;
+        file_bytes[value_offset] ^= 0xff;
+
+        let stream = BlobFormatReader::new(String::new(), false)
+            .read_batch_stream(
+                Box::new(BytesFileRead(Bytes::from(file_bytes.clone()))),
+                file_bytes.len() as u64,
+                &blob_map_read_fields(),
+                None,
+                None,
+                None,
+            )
+            .await
+            .unwrap();
+        let error = stream.try_collect::<Vec<_>>().await.unwrap_err();
+        assert_data_invalid(error, "CRC32 mismatch");
+    }
+
+    #[tokio::test]
+    async fn 
test_inline_blob_map_reader_rejects_oversized_data_before_entry_read() {
+        let value_length = i32::MAX as u64 + 1;
+        let (reader, payload_range) = sparse_blob_map_entry(&[1], 
&[value_length as i64]);
+        let key_type = 
DataType::VarChar(VarCharType::new(VarCharType::MAX_LENGTH).unwrap());
+
+        let error = read_blob_map_entry(&reader, payload_range.clone(), "", 
false, &key_type)
+            .await
+            .unwrap_err();
+
+        assert!(
+            !reader.ranges().contains(&blob_entry_range(&payload_range)),
+            "oversized inline MAP<X, BLOB> must be rejected before reading the 
complete entry"
+        );
+        assert_data_invalid(error, "too large");
+    }
+
+    #[tokio::test]
+    async fn test_blob_map_reader_rejects_oversized_key_before_data_read() {
+        let key_length = i32::MAX as u64 + 1;
+        let (reader, payload_range) = sparse_blob_map_entry(&[key_length as 
i64], &[0]);
+        let key_type = 
DataType::VarChar(VarCharType::new(VarCharType::MAX_LENGTH).unwrap());
+
+        let error = read_blob_map_entry(&reader, payload_range.clone(), "", 
false, &key_type)
+            .await
+            .unwrap_err();
+
+        assert_eq!(reader.ranges().len(), 4);
+        assert!(!reader.ranges().contains(&blob_entry_range(&payload_range)));
+        assert_data_invalid(error, "too large");
+    }
+
+    #[tokio::test]
+    async fn test_blob_map_reader_rejects_invalid_fixed_key_before_data_read() 
{
+        let key_length = i32::MAX as u64 + 1;
+        let (reader, payload_range) = sparse_blob_map_entry(&[key_length as 
i64], &[0]);
+        let key_type = DataType::Int(crate::spec::IntType::new());
+
+        let error = read_blob_map_entry(&reader, payload_range.clone(), "", 
false, &key_type)
+            .await
+            .unwrap_err();
+
+        assert_eq!(reader.ranges().len(), 4);
+        assert!(!reader.ranges().contains(&blob_entry_range(&payload_range)));
+        assert_data_invalid(error, "fixed-width key length");
+    }
+
+    #[tokio::test]
+    async fn test_blob_map_reader_rejects_null_key_for_arrow() {
+        let (reader, payload_range) = sparse_blob_map_entry(&[-1], &[0]);
+        let key_type = 
DataType::VarChar(VarCharType::new(VarCharType::MAX_LENGTH).unwrap());
+
+        let error = read_blob_map_entry(&reader, payload_range, "", false, 
&key_type)
+            .await
+            .unwrap_err();
+
+        assert_data_invalid(error, "null keys cannot be represented by Arrow");
+    }
+
+    #[test]
+    fn test_blob_map_batch_rejects_oversized_binary_data() {
+        let error =
+            checked_arrow_binary_data_length(i32::MAX as u64, 1, "MAP<X, BLOB> 
batch value data")
+                .unwrap_err();
+
+        assert_data_invalid(error, "too large");
+    }
+
     #[tokio::test]
     async fn test_inline_blob_array_reader_rejects_payload_crc_mismatch() {
         let payload = build_blob_array_payload(b"helloworld", &[5, -1, 5]);
@@ -1534,8 +2305,20 @@ mod tests {
         let payload_length =
             BLOB_ARRAY_MIN_PAYLOAD_SIZE + element_data_length + 
element_index.len() as u64;
         let payload_range = BLOB_INLINE_HEADER_SIZE..BLOB_INLINE_HEADER_SIZE + 
payload_length;
-        let reader =
-            BlobArrayPreflightFileRead::new(payload_range.clone(), 1, 
element_index.len() as i32);
+        let mut header = Vec::with_capacity(BLOB_ARRAY_HEADER_SIZE as usize);
+        header.extend_from_slice(&BLOB_ARRAY_MAGIC_NUMBER.to_le_bytes());
+        header.push(BLOB_ARRAY_VERSION);
+        header.extend_from_slice(&1i32.to_le_bytes());
+        let reader = SparseFileRead::new(vec![
+            (
+                payload_range.start..payload_range.start + 
BLOB_ARRAY_HEADER_SIZE,
+                Bytes::from(header),
+            ),
+            (
+                payload_range.end - 
BLOB_ARRAY_INDEX_LENGTH_SIZE..payload_range.end,
+                Bytes::copy_from_slice(&(element_index.len() as 
i32).to_le_bytes()),
+            ),
+        ]);
 
         let error = read_inline_blob_array_entry(&reader, 
payload_range.clone())
             .await
@@ -1848,7 +2631,7 @@ mod tests {
             .await;
 
         assert!(
-            matches!(result, Err(Error::DataInvalid { message, .. }) if 
message.contains("Blob or Array<Blob> field"))
+            matches!(result, Err(Error::DataInvalid { message, .. }) if 
message.contains("Blob, Array<Blob>, or Map<X, Blob> field"))
         );
     }
 
@@ -1875,7 +2658,7 @@ mod tests {
             .await;
 
         assert!(
-            matches!(result, Err(Error::DataInvalid { message, .. }) if 
message.contains("Blob or Array<Blob>"))
+            matches!(result, Err(Error::DataInvalid { message, .. }) if 
message.contains("Blob, Array<Blob>, or Map<X, Blob>"))
         );
     }
 
@@ -2150,6 +2933,17 @@ mod tests {
         )]
     }
 
+    fn blob_map_read_fields() -> Vec<DataField> {
+        vec![DataField::new(
+            0,
+            "payloads".to_string(),
+            DataType::Map(MapType::new(
+                
DataType::VarChar(VarCharType::new(VarCharType::MAX_LENGTH).unwrap()),
+                DataType::Blob(BlobType::new()),
+            )),
+        )]
+    }
+
     fn build_blob_array_payload(element_data: &[u8], element_lengths: &[i64]) 
-> Vec<u8> {
         let index = encode_delta_varints_write(element_lengths);
         let mut payload = Vec::with_capacity(
@@ -2164,6 +2958,36 @@ mod tests {
         payload
     }
 
+    fn build_blob_map_payload(entries: &[(&str, Option<&[u8]>)]) -> Vec<u8> {
+        let key_lengths = entries
+            .iter()
+            .map(|(key, _)| key.len() as i64)
+            .collect::<Vec<_>>();
+        let value_lengths = entries
+            .iter()
+            .map(|(_, value)| value.map_or(-1, |value| value.len() as i64))
+            .collect::<Vec<_>>();
+        let key_index = encode_delta_varints_write(&key_lengths);
+        let value_index = encode_delta_varints_write(&value_lengths);
+        let mut payload = Vec::new();
+        payload.extend_from_slice(&BLOB_MAP_MAGIC_NUMBER.to_le_bytes());
+        payload.push(BLOB_MAP_VERSION);
+        payload.extend_from_slice(&(entries.len() as i32).to_le_bytes());
+        for (key, _) in entries {
+            payload.extend_from_slice(key.as_bytes());
+        }
+        for (_, value) in entries {
+            if let Some(value) = value {
+                payload.extend_from_slice(value);
+            }
+        }
+        payload.extend_from_slice(&key_index);
+        payload.extend_from_slice(&value_index);
+        payload.extend_from_slice(&(key_index.len() as i32).to_le_bytes());
+        payload.extend_from_slice(&(value_index.len() as i32).to_le_bytes());
+        payload
+    }
+
     fn set_blob_array_index_length(payload: &mut [u8], index_length: i32) {
         let index_length_position = payload.len() - 
BLOB_ARRAY_INDEX_LENGTH_SIZE as usize;
         
payload[index_length_position..].copy_from_slice(&index_length.to_le_bytes());
@@ -2259,6 +3083,38 @@ mod tests {
             .collect()
     }
 
+    type BlobMapRows = Vec<Option<Vec<(String, Option<Vec<u8>>)>>>;
+
+    fn collect_blob_map_values(batch: &RecordBatch) -> BlobMapRows {
+        let array = 
batch.column(0).as_any().downcast_ref::<MapArray>().unwrap();
+        let keys = 
array.keys().as_any().downcast_ref::<StringArray>().unwrap();
+        let values = array
+            .values()
+            .as_any()
+            .downcast_ref::<BinaryArray>()
+            .unwrap();
+        (0..array.len())
+            .map(|row| {
+                if array.is_null(row) {
+                    return None;
+                }
+                let start = array.value_offsets()[row];
+                let end = array.value_offsets()[row + 1];
+                Some(
+                    (start..end)
+                        .map(|index| {
+                            let index = index as usize;
+                            (
+                                keys.value(index).to_string(),
+                                (!values.is_null(index)).then(|| 
values.value(index).to_vec()),
+                            )
+                        })
+                        .collect(),
+                )
+            })
+            .collect()
+    }
+
     fn load_blob_fixture(name: &str) -> Vec<u8> {
         let path = format!("{}/testdata/blob/{name}", 
env!("CARGO_MANIFEST_DIR"));
         std::fs::read(&path).unwrap_or_else(|e| panic!("Failed to read {path}: 
{e}"))
@@ -2303,19 +3159,15 @@ mod tests {
         }
     }
 
-    struct BlobArrayPreflightFileRead {
-        payload_range: Range<u64>,
-        element_count: i32,
-        index_length: i32,
+    struct SparseFileRead {
+        responses: Vec<(Range<u64>, Bytes)>,
         ranges: Mutex<Vec<Range<u64>>>,
     }
 
-    impl BlobArrayPreflightFileRead {
-        fn new(payload_range: Range<u64>, element_count: i32, index_length: 
i32) -> Self {
+    impl SparseFileRead {
+        fn new(responses: Vec<(Range<u64>, Bytes)>) -> Self {
             Self {
-                payload_range,
-                element_count,
-                index_length,
+                responses,
                 ranges: Mutex::new(Vec::new()),
             }
         }
@@ -2325,25 +3177,55 @@ mod tests {
         }
     }
 
+    fn sparse_blob_map_entry(
+        key_lengths: &[i64],
+        value_lengths: &[i64],
+    ) -> (SparseFileRead, Range<u64>) {
+        let key_index = encode_delta_varints_write(key_lengths);
+        let value_index = encode_delta_varints_write(value_lengths);
+        let data_length = key_lengths
+            .iter()
+            .chain(value_lengths)
+            .filter(|length| **length >= 0)
+            .map(|length| *length as u64)
+            .sum::<u64>();
+        let payload_length = BLOB_MAP_MIN_PAYLOAD_SIZE
+            + data_length
+            + key_index.len() as u64
+            + value_index.len() as u64;
+        let payload_range = BLOB_INLINE_HEADER_SIZE..BLOB_INLINE_HEADER_SIZE + 
payload_length;
+        let mut header = Vec::with_capacity(BLOB_MAP_HEADER_SIZE as usize);
+        header.extend_from_slice(&BLOB_MAP_MAGIC_NUMBER.to_le_bytes());
+        header.push(BLOB_MAP_VERSION);
+        header.extend_from_slice(&(key_lengths.len() as i32).to_le_bytes());
+        let lengths_start = payload_range.end - BLOB_MAP_INDEX_LENGTHS_SIZE;
+        let value_index_start = lengths_start - value_index.len() as u64;
+        let key_index_start = value_index_start - key_index.len() as u64;
+        let mut index_lengths = Vec::with_capacity(BLOB_MAP_INDEX_LENGTHS_SIZE 
as usize);
+        index_lengths.extend_from_slice(&(key_index.len() as 
i32).to_le_bytes());
+        index_lengths.extend_from_slice(&(value_index.len() as 
i32).to_le_bytes());
+        let reader = SparseFileRead::new(vec![
+            (
+                payload_range.start..payload_range.start + 
BLOB_MAP_HEADER_SIZE,
+                Bytes::from(header),
+            ),
+            (lengths_start..payload_range.end, Bytes::from(index_lengths)),
+            (key_index_start..value_index_start, Bytes::from(key_index)),
+            (value_index_start..lengths_start, Bytes::from(value_index)),
+        ]);
+        (reader, payload_range)
+    }
+
     #[async_trait::async_trait]
-    impl FileRead for BlobArrayPreflightFileRead {
+    impl FileRead for SparseFileRead {
         async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
             self.ranges.lock().unwrap().push(range.clone());
-
-            let header_range =
-                self.payload_range.start..self.payload_range.start + 
BLOB_ARRAY_HEADER_SIZE;
-            if range == header_range {
-                let mut header = Vec::with_capacity(BLOB_ARRAY_HEADER_SIZE as 
usize);
-                
header.extend_from_slice(&BLOB_ARRAY_MAGIC_NUMBER.to_le_bytes());
-                header.push(BLOB_ARRAY_VERSION);
-                header.extend_from_slice(&self.element_count.to_le_bytes());
-                return Ok(Bytes::from(header));
-            }
-
-            let index_length_range =
-                self.payload_range.end - 
BLOB_ARRAY_INDEX_LENGTH_SIZE..self.payload_range.end;
-            if range == index_length_range {
-                return 
Ok(Bytes::copy_from_slice(&self.index_length.to_le_bytes()));
+            if let Some((_, bytes)) = self
+                .responses
+                .iter()
+                .find(|(expected, _)| expected == &range)
+            {
+                return Ok(bytes.clone());
             }
 
             Err(Error::UnexpectedError {
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index ece7992c..1e020eff 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -938,7 +938,7 @@ fn append_csv_field(existing: Option<&str>, field_name: 
&str) -> String {
 fn normalize_blob_field_type(field_name: &str, data_type: DataType) -> 
crate::Result<DataType> {
     let nullable = data_type.is_nullable();
     match data_type {
-        DataType::Blob(_) => Ok(data_type),
+        ref value if value.is_blob_file_field() => Ok(data_type),
         DataType::Binary(_) | DataType::VarBinary(_) => {
             Ok(DataType::Blob(BlobType::with_nullable(nullable)))
         }
diff --git a/crates/paimon/src/spec/types.rs b/crates/paimon/src/spec/types.rs
index 46d664db..4835ce1d 100644
--- a/crates/paimon/src/spec/types.rs
+++ b/crates/paimon/src/spec/types.rs
@@ -133,6 +133,7 @@ impl DataType {
         match self {
             DataType::Blob(_) => true,
             DataType::Array(array) => array.element_type().is_blob_type(),
+            DataType::Map(map) => map.value_type().is_blob_type(),
             _ => false,
         }
     }
@@ -1976,10 +1977,12 @@ mod tests {
     fn test_blob_file_field_classification() {
         let blob = DataType::Blob(BlobType::new());
         let array_blob = DataType::Array(ArrayType::new(blob.clone()));
+        let map_blob = 
DataType::Map(MapType::new(DataType::Int(IntType::new()), blob.clone()));
         let nested_array_blob = 
DataType::Array(ArrayType::new(array_blob.clone()));
 
         assert!(blob.is_blob_file_field());
         assert!(array_blob.is_blob_file_field());
+        assert!(map_blob.is_blob_file_field());
         assert!(!nested_array_blob.is_blob_file_field());
         assert!(!DataType::Int(IntType::new()).is_blob_file_field());
     }
diff --git a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs 
b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
index bc538df1..dbd6a0ae 100644
--- a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
+++ b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
@@ -21,7 +21,8 @@ use super::{
 };
 use crate::arrow::build_target_arrow_schema;
 use crate::arrow::format::blob::{
-    build_blob_array_batch, build_blob_batch, BlobReadValue, IndexedBlobReader,
+    build_blob_array_batch, build_blob_batch, build_blob_map_batch, 
BlobFieldKind, BlobReadValue,
+    IndexedBlobReader,
 };
 use crate::io::FileIO;
 use crate::spec::{DataField, DataType};
@@ -50,7 +51,7 @@ impl LazyBlobFile {
         positions: &[usize],
         file_io: &FileIO,
         blob_as_descriptor: bool,
-        array_field: bool,
+        field_kind: &BlobFieldKind,
     ) -> crate::Result<Vec<BlobReadValue>> {
         if self.reader.is_none() {
             let file_size = u64::try_from(self.file_size).map_err(|e| 
Error::DataInvalid {
@@ -94,10 +95,10 @@ impl LazyBlobFile {
             .reader
             .as_ref()
             .expect("blob reader is initialized above");
-        if array_field {
-            reader.read_array_positions(positions).await
-        } else {
-            reader.read_positions(positions).await
+        match field_kind {
+            BlobFieldKind::Scalar => reader.read_positions(positions).await,
+            BlobFieldKind::Array => 
reader.read_array_positions(positions).await,
+            BlobFieldKind::Map(key_type) => 
reader.read_map_positions(positions, key_type).await,
         }
     }
 
@@ -119,13 +120,20 @@ pub(super) fn read(
 ) -> crate::Result<ArrowRecordBatchStream> {
     if read_fields.len() != 1 || 
!read_fields[0].data_type().is_blob_file_field() {
         return Err(Error::DataInvalid {
-            message: "Blob bunch should provide exactly one BLOB or 
ARRAY<BLOB> field".to_string(),
+            message:
+                "Blob bunch should provide exactly one BLOB, ARRAY<BLOB>, or 
MAP<X, BLOB> field"
+                    .to_string(),
             source: None,
         });
     }
 
     let target_schema = build_target_arrow_schema(&read_fields)?;
-    let array_field = matches!(read_fields[0].data_type(), DataType::Array(_));
+    let field_kind = match read_fields[0].data_type() {
+        DataType::Blob(_) => BlobFieldKind::Scalar,
+        DataType::Array(_) => BlobFieldKind::Array,
+        DataType::Map(map) => BlobFieldKind::Map(map.key_type().clone()),
+        _ => unreachable!("validated as a blob file field"),
+    };
     let batch_size = batch_size.unwrap_or(BATCH_SIZE).max(1);
     let split = split.clone();
 
@@ -172,7 +180,7 @@ pub(super) fn read(
                 target_schema.clone(),
                 &file_io,
                 blob_as_descriptor,
-                array_field,
+                &field_kind,
             ).await?;
         }
     }
@@ -185,7 +193,7 @@ async fn resolve_batch(
     target_schema: Arc<arrow_schema::Schema>,
     file_io: &FileIO,
     blob_as_descriptor: bool,
-    array_field: bool,
+    field_kind: &BlobFieldKind,
 ) -> crate::Result<RecordBatch> {
     let mut resolved = (0..row_ids.len())
         .map(|_| BlobReadValue::Placeholder)
@@ -233,7 +241,7 @@ async fn resolve_batch(
 
             if !file_positions.is_empty() {
                 let values = file
-                    .read_positions(&file_positions, file_io, 
blob_as_descriptor, array_field)
+                    .read_positions(&file_positions, file_io, 
blob_as_descriptor, field_kind)
                     .await?;
                 for (output_position, value) in 
output_positions.into_iter().zip(values) {
                     if !matches!(&value, BlobReadValue::Placeholder) {
@@ -262,10 +270,10 @@ async fn resolve_batch(
         }
     }
 
-    if array_field {
-        build_blob_array_batch(&target_schema, resolved)
-    } else {
-        build_blob_batch(&target_schema, resolved)
+    match field_kind {
+        BlobFieldKind::Scalar => build_blob_batch(&target_schema, resolved),
+        BlobFieldKind::Array => build_blob_array_batch(&target_schema, 
resolved),
+        BlobFieldKind::Map(key_type) => build_blob_map_batch(&target_schema, 
resolved, key_type),
     }
 }
 
@@ -424,9 +432,16 @@ mod tests {
             VecDeque::from([oldest]),
         ];
         let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-        let batch = resolve_batch(&mut groups, &[0, 1, 2, 3], schema, 
&file_io, false, false)
-            .await
-            .unwrap();
+        let batch = resolve_batch(
+            &mut groups,
+            &[0, 1, 2, 3],
+            schema,
+            &file_io,
+            false,
+            &BlobFieldKind::Scalar,
+        )
+        .await
+        .unwrap();
         let values = batch
             .column(0)
             .as_any()
diff --git a/docs/src/go-binding.md b/docs/src/go-binding.md
index 5a82c4c6..40acda2d 100644
--- a/docs/src/go-binding.md
+++ b/docs/src/go-binding.md
@@ -172,6 +172,83 @@ before expiry. Set `dlf.oss-endpoint` when the 
server-provided endpoint is not
 reachable from the application. Static options passed to
 `paimon.NewBlobReader` are not refreshed.
 
+Read a `MAP<STRING, BLOB>` column as descriptors, then stream one value:
+
+Rows with null map keys cannot be represented as Arrow maps and return an 
error.
+
+```go
+readBuilder, err := table.NewReadBuilderWithOptions(map[string]string{
+    "blob-as-descriptor": "true",
+})
+if err != nil {
+    log.Fatal(err)
+}
+defer readBuilder.Close()
+if err := readBuilder.WithProjection([]string{"assets"}); err != nil {
+    log.Fatal(err)
+}
+
+scan, err := readBuilder.NewScan()
+if err != nil {
+    log.Fatal(err)
+}
+defer scan.Close()
+plan, err := scan.Plan()
+if err != nil {
+    log.Fatal(err)
+}
+defer plan.Close()
+read, err := readBuilder.NewRead()
+if err != nil {
+    log.Fatal(err)
+}
+defer read.Close()
+batches, err := read.NewRecordBatchReader(plan.Splits())
+if err != nil {
+    log.Fatal(err)
+}
+defer batches.Close()
+
+record, err := batches.NextRecord()
+if err != nil {
+    log.Fatal(err)
+}
+descriptors, err := paimon.StringBlobMapDescriptors(record.Column(0), 0)
+if err != nil {
+    log.Fatal(err)
+}
+record.Release() // the map owns its keys and descriptors
+for key, descriptor := range descriptors {
+       if descriptor == nil { // null BLOB
+               continue
+       }
+    stream, err := reader.OpenBlob(descriptor)
+    if err != nil {
+        log.Fatal(err)
+    }
+    if _, err := io.Copy(destinationFor(key), stream); err != nil {
+        stream.Close()
+        log.Fatal(err)
+    }
+    stream.Close()
+}
+```
+
+`StringBlobMapDescriptors` returns an ordinary Go map and remains valid after
+releasing the Arrow record. To materialize small values in one merged batch:
+
+```go
+batch := make([][]byte, 0, len(descriptors))
+for _, descriptor := range descriptors {
+    if descriptor != nil {
+        batch = append(batch, descriptor)
+    }
+}
+values, err := reader.ReadBlobs(batch)
+```
+
+Use `OpenBlob` for large values.
+
 Reads are grouped by URI and nearby ranges are merged. The fixed limits are a
 64 KiB merge gap, 8 MiB merged span, 8 concurrent requests, and a 64 MiB
 per-reader admission budget. One larger range runs alone but may exceed that

Reply via email to