This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new bfd6abc3a0 [python] Support reading shared-shredding maps (#9763)
bfd6abc3a0 is described below
commit bfd6abc3a04ca06c4aa50462c7c27fb619dbc5eb
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Sep 13 16:30:06 2026 +0800
[python] Support reading shared-shredding maps (#9763)
---
.../test/java/org/apache/paimon/JavaPyE2ETest.java | 53 +++
paimon-python/dev/run_mixed_tests.sh | 38 +-
.../pypaimon/data/map_shared_shredding.py | 347 ++++++++++++++++++
.../pypaimon/read/reader/format_pyarrow_reader.py | 111 +++++-
.../pypaimon/tests/e2e/java_py_read_write_test.py | 28 ++
.../format_pyarrow_shared_shredding_map_test.py | 402 +++++++++++++++++++++
6 files changed, 973 insertions(+), 6 deletions(-)
diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index 8ff98960ea..524344ed54 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -1567,6 +1567,59 @@ public class JavaPyE2ETest {
assertAdditionalMapBlobKeyTypes(table, "python");
}
+ /** Java writes shared-shredding MAP columns for Python to read. */
+ @Test
+ @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
+ public void testJavaWriteSharedShreddingMapTable() throws Exception {
+ for (String format : Arrays.asList("parquet", "orc")) {
+ Identifier identifier =
identifier("shared_shredding_map_java_test_" + format);
+ catalog.dropTable(identifier, true);
+ Schema schema =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column(
+ "metrics",
+
DataTypes.MAP(DataTypes.STRING().notNull(), DataTypes.BIGINT()))
+ .option(BUCKET.key(), "-1")
+ .option(CoreOptions.FILE_FORMAT.key(), format)
+ .option(CoreOptions.WRITE_ONLY.key(), "true")
+ .option("fields.metrics.map.storage-layout",
"shared-shredding")
+
.option("fields.metrics.map.shared-shredding.max-columns", "2")
+ .build();
+ catalog.createTable(identifier, schema, false);
+
+ Map<Object, Object> first = new LinkedHashMap<>();
+ first.put(BinaryString.fromString("hot"), 10L);
+ first.put(BinaryString.fromString("warm"), 20L);
+ first.put(BinaryString.fromString("overflow"), 30L);
+ Map<Object, Object> second = new LinkedHashMap<>();
+ second.put(BinaryString.fromString("hot"), null);
+ second.put(BinaryString.fromString("new"), 40L);
+
+ FileStoreTable table = (FileStoreTable)
catalog.getTable(identifier);
+ BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = writeBuilder.newWrite();
+ BatchTableCommit commit = writeBuilder.newCommit()) {
+ write.write(GenericRow.of(1, new GenericMap(first)));
+ write.write(GenericRow.of(2, new GenericMap(second)));
+ write.write(GenericRow.of(3, new
GenericMap(Collections.emptyMap())));
+ write.write(GenericRow.of(4, null));
+ commit.commit(write.prepareCommit());
+ }
+
+ Map<Object, Object> later = new LinkedHashMap<>();
+ later.put(BinaryString.fromString("late"), 50L);
+ later.put(BinaryString.fromString("hot"), 60L);
+ table = (FileStoreTable) catalog.getTable(identifier);
+ writeBuilder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = writeBuilder.newWrite();
+ BatchTableCommit commit = writeBuilder.newCommit()) {
+ write.write(GenericRow.of(5, new GenericMap(later)));
+ commit.commit(write.prepareCommit());
+ }
+ }
+ }
+
private Map<Integer, Map<Integer, byte[]>> readMapBlobRows(FileStoreTable
table)
throws Exception {
Map<Integer, Map<Integer, byte[]>> rows = new HashMap<>();
diff --git a/paimon-python/dev/run_mixed_tests.sh
b/paimon-python/dev/run_mixed_tests.sh
index b929c6a9cd..66ff2b3e5f 100755
--- a/paimon-python/dev/run_mixed_tests.sh
+++ b/paimon-python/dev/run_mixed_tests.sh
@@ -111,6 +111,7 @@ run_batched_java_write_tests() {
core_tests="${core_tests}+testBlobWriteAlterCompact"
core_tests="${core_tests}+testJavaWriteArrayBlobTable"
core_tests="${core_tests}+testJavaWriteMapBlobTable"
+ core_tests="${core_tests}+testJavaWriteSharedShreddingMapTable"
core_tests="${core_tests}+testDataEvolutionWrite"
core_tests="${core_tests}+testJavaWriteRowAppendTable"
if [[ "$PYTHON_MINOR" -ge 7 ]]; then
@@ -1011,6 +1012,28 @@ run_map_blob_interop_test() {
echo -e "${GREEN}✓ Java MAP<K, BLOB> read test completed successfully${NC}"
}
+run_shared_shredding_map_test() {
+ echo -e "${YELLOW}=== Running shared-shredding MAP Test (Java Write →
Python Read) ===${NC}"
+
+ if ! skip_batched_java_write; then
+ cd "$PROJECT_ROOT"
+ echo "Running Maven test for
JavaPyE2ETest.testJavaWriteSharedShreddingMapTable..."
+ if ! mvn test
-Dtest=org.apache.paimon.JavaPyE2ETest#testJavaWriteSharedShreddingMapTable -pl
paimon-core -q -Drun.e2e.tests=true; then
+ echo -e "${RED}✗ Java shared-shredding MAP write test failed${NC}"
+ return 1
+ fi
+ echo -e "${GREEN}✓ Java shared-shredding MAP write test completed
successfully${NC}"
+ fi
+
+ cd "$PAIMON_PYTHON_DIR"
+ echo "Running Python shared-shredding MAP read test..."
+ if ! python -m pytest
java_py_read_write_test.py::JavaPyReadWriteTest::test_read_shared_shredding_map_written_by_java
-v; then
+ echo -e "${RED}✗ Python shared-shredding MAP read test failed${NC}"
+ return 1
+ fi
+ echo -e "${GREEN}✓ Python shared-shredding MAP read test completed
successfully${NC}"
+}
+
# Function to run VARIANT test (Java write, Python read)
run_java_variant_write_py_read_test() {
echo -e "${YELLOW}=== Running VARIANT Test (Java Write, Python Read)
===${NC}"
@@ -1129,6 +1152,7 @@ main() {
local blob_alter_compact_result=0
local array_blob_interop_result=0
local map_blob_interop_result=0
+ local shared_shredding_map_result=0
local data_evolution_result=0
local data_evolution_deletion_vector_result=0
local data_evolution_py_write_result=0
@@ -1373,6 +1397,12 @@ main() {
echo ""
+ if ! run_shared_shredding_map_test; then
+ shared_shredding_map_result=1
+ fi
+
+ echo ""
+
# Run data evolution test (Java write, Python read). Lance variant skips
# itself on <3.8 (get_file_format_params + gated Java lance read).
if ! run_data_evolution_test; then
@@ -1573,6 +1603,12 @@ main() {
echo -e "${RED}✗ MAP<K, BLOB> Interoperability Test (Java ↔ Python):
FAILED${NC}"
fi
+ if [[ $shared_shredding_map_result -eq 0 ]]; then
+ echo -e "${GREEN}✓ Shared-shredding MAP Test (Java Write → Python
Read): PASSED${NC}"
+ else
+ echo -e "${RED}✗ Shared-shredding MAP Test (Java Write → Python Read):
FAILED${NC}"
+ fi
+
if [[ $data_evolution_result -eq 0 ]]; then
echo -e "${GREEN}✓ Data Evolution Test (Java Write, Python Read):
PASSED${NC}"
else
@@ -1614,7 +1650,7 @@ main() {
# Clean up warehouse directory after all tests
cleanup_warehouse
- if [[ $java_write_result -eq 0 && $python_read_result -eq 0 &&
$python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 &&
$btree_index_result -eq 0 && $btree_raw_fallback_result -eq 0 &&
$bitmap_index_result -eq 0 && $compressed_global_index_result -eq 0 &&
$compressed_text_result -eq 0 && $native_fulltext_result -eq 0 &&
$lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 &&
$vindex_vector_result -eq 0 && $vindex_vector_raw_fallback_result -eq 0 &&
[...]
+ if [[ $java_write_result -eq 0 && $python_read_result -eq 0 &&
$python_write_result -eq 0 && $java_read_result -eq 0 && $pk_dv_result -eq 0 &&
$btree_index_result -eq 0 && $btree_raw_fallback_result -eq 0 &&
$bitmap_index_result -eq 0 && $compressed_global_index_result -eq 0 &&
$compressed_text_result -eq 0 && $native_fulltext_result -eq 0 &&
$lumina_vector_result -eq 0 && $lumina_vector_btree_result -eq 0 &&
$vindex_vector_result -eq 0 && $vindex_vector_raw_fallback_result -eq 0 &&
[...]
echo -e "${GREEN}🎉 All tests passed! Java-Python interoperability
verified.${NC}"
return 0
else
diff --git a/paimon-python/pypaimon/data/map_shared_shredding.py
b/paimon-python/pypaimon/data/map_shared_shredding.py
new file mode 100644
index 0000000000..ecadf807a5
--- /dev/null
+++ b/paimon-python/pypaimon/data/map_shared_shredding.py
@@ -0,0 +1,347 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Read support for Paimon's shared-shredding MAP storage layout."""
+
+import json
+import struct
+from typing import Dict
+
+import pyarrow as pa
+import pyarrow.compute as pc
+
+
+_STORAGE_LAYOUT = b"paimon.map.storage-layout"
+_VERSION = b"paimon.map.shared-shredding.version"
+_FIELD_DICT = b"paimon.map.shared-shredding.field-dict"
+_FIELD_DICT_COMPRESSION = b"paimon.map.shared-shredding.field-dict-compression"
+_FIELD_DICT_ORIGINAL_SIZE =
b"paimon.map.shared-shredding.field-dict-original-size"
+_NUM_COLUMNS = b"paimon.map.shared-shredding.num-columns"
+_FIELD_MAPPING = "__field_mapping"
+_OVERFLOW = "__overflow"
+_PHYSICAL_COLUMN_PREFIX = "__col_"
+
+
+def is_shared_shredding(field: pa.Field) -> bool:
+ metadata = field.metadata
+ return metadata is not None and metadata.get(_STORAGE_LAYOUT) ==
b"shared-shredding"
+
+
+def parse_shared_shredding_metadata(field: pa.Field):
+ metadata = field.metadata or {}
+ version = _required_int(metadata, _VERSION)
+ if version != 1:
+ raise ValueError(
+ "Unsupported shared-shredding metadata version:
{}".format(version))
+
+ original_size = _required_int(metadata, _FIELD_DICT_ORIGINAL_SIZE)
+ compression = metadata.get(_FIELD_DICT_COMPRESSION,
b"zstd").decode("utf-8").lower()
+ encoded_dict = _required(metadata,
_FIELD_DICT).decode("utf-8").encode("latin-1")
+ field_dict = json.loads(
+ _decompress(encoded_dict, original_size, compression).decode("utf-8"))
+ if not isinstance(field_dict, dict):
+ raise ValueError("Shared-shredding field dictionary must be an object")
+ if not all(
+ isinstance(name, str) and isinstance(field_id, int)
+ for name, field_id in field_dict.items()):
+ raise ValueError("Shared-shredding field dictionary is malformed")
+ name_by_id = {field_id: name for name, field_id in field_dict.items()}
+ num_columns = _required_int(metadata, _NUM_COLUMNS)
+ if num_columns < 0:
+ raise ValueError("Shared-shredding column count must not be negative")
+ return name_by_id, num_columns
+
+
+def assemble_shared_shredding_map(
+ column: pa.StructArray,
+ map_type: pa.MapType,
+ name_by_id: Dict[int, str],
+ num_columns: int) -> pa.MapArray:
+ """Restore one physical shared-shredding struct as a logical MAP."""
+ if not pa.types.is_struct(column.type):
+ raise TypeError("Shared-shredding MAP must be stored as a struct")
+
+ field_names = [field.name for field in column.type]
+ if not field_names or field_names[0] != _FIELD_MAPPING:
+ raise ValueError(
+ "Shared-shredding physical struct must start with {}".format(
+ _FIELD_MAPPING))
+
+ physical_columns = [None] * num_columns
+ overflow = None
+ for position, field_name in enumerate(field_names[1:], 1):
+ if field_name == _OVERFLOW:
+ if position != len(field_names) - 1:
+ raise ValueError("Shared-shredding overflow must be the last
field")
+ overflow = column.field(position)
+ continue
+ if not field_name.startswith(_PHYSICAL_COLUMN_PREFIX):
+ raise ValueError(
+ "Unexpected shared-shredding physical field:
{}".format(field_name))
+ try:
+ physical_index = int(field_name[len(_PHYSICAL_COLUMN_PREFIX):])
+ except ValueError:
+ raise ValueError(
+ "Unexpected shared-shredding physical field:
{}".format(field_name))
+ if physical_index < 0 or physical_index >= num_columns:
+ raise ValueError(
+ "Shared-shredding physical column {} exceeds metadata column
count {}".format(
+ physical_index, num_columns))
+ if physical_columns[physical_index] is not None:
+ raise ValueError(
+ "Duplicate shared-shredding physical column {}".format(
+ physical_index))
+ physical_columns[physical_index] = column.field(position)
+
+ mapping_column = column.field(0)
+ if not (
+ pa.types.is_list(mapping_column.type)
+ or pa.types.is_large_list(mapping_column.type)):
+ raise TypeError("Shared-shredding field mapping must be an array")
+ mapping = mapping_column.to_pylist()
+ null_rows = column.is_null().to_pylist()
+ overflow_offsets = None
+ overflow_keys = None
+ overflow_values = None
+ if overflow is not None:
+ if not pa.types.is_map(overflow.type):
+ raise TypeError("Shared-shredding overflow field must be a map")
+ overflow_offsets, overflow_start, overflow_end =
_normalized_offsets(overflow)
+ overflow_nulls = overflow.is_null().to_pylist()
+ overflow_keys = overflow.keys.slice(
+ overflow_start, overflow_end - overflow_start).to_pylist()
+ overflow_values = overflow.items.slice(
+ overflow_start, overflow_end - overflow_start)
+
+ sources = list(physical_columns)
+ if overflow_values is not None:
+ sources.append(overflow_values)
+ selected_indices = [[] for _ in sources]
+ entry_sources = []
+ entry_positions = []
+ keys = []
+ offsets = [0]
+
+ for row in range(len(column)):
+ if null_rows[row]:
+ offsets[-1] = None
+ offsets.append(len(keys))
+ continue
+
+ row_mapping = mapping[row]
+ if row_mapping is None or len(row_mapping) != num_columns:
+ raise ValueError(
+ "Shared-shredding field mapping length must equal {}".format(
+ num_columns))
+ for physical_index, field_id in enumerate(row_mapping):
+ if field_id is None:
+ raise ValueError(
+ "Shared-shredding field mapping must not contain null")
+ name = name_by_id.get(field_id)
+ if field_id < 0 or name is None:
+ continue
+ if physical_columns[physical_index] is None:
+ raise ValueError(
+ "Missing shared-shredding physical column {}".format(
+ physical_index))
+ _append_entry(
+ keys, entry_sources, entry_positions, selected_indices,
+ name, physical_index, row)
+
+ if overflow_offsets is not None and not overflow_nulls[row]:
+ overflow_source = len(sources) - 1
+ for item_index in range(
+ overflow_offsets[row], overflow_offsets[row + 1]):
+ name = name_by_id.get(overflow_keys[item_index])
+ if name is not None:
+ _append_entry(
+ keys, entry_sources, entry_positions, selected_indices,
+ name, overflow_source, item_index)
+ offsets.append(len(keys))
+
+ selected_values = []
+ source_bases = []
+ for source, indices in zip(sources, selected_indices):
+ source_bases.append(sum(len(values) for values in selected_values))
+ if indices:
+ selected = pc.take(source, pa.array(indices, type=pa.int64()))
+ selected_values.append(
+ _restore_orc_temporal_values(selected, map_type.item_type))
+ else:
+ selected_values.append(pa.array([], type=map_type.item_type))
+
+ if selected_values:
+ value_pool = pa.concat_arrays(selected_values)
+ value_indices = [
+ source_bases[source] + position
+ for source, position in zip(entry_sources, entry_positions)
+ ]
+ values = pc.take(value_pool, pa.array(value_indices, type=pa.int64()))
+ else:
+ values = pa.array([], type=map_type.item_type)
+
+ result = pa.MapArray.from_arrays(
+ pa.array(offsets, type=pa.int32()),
+ pa.array(keys, type=map_type.key_type),
+ values,
+ )
+ entries = pa.StructArray.from_arrays(
+ [result.keys, result.items],
+ fields=[map_type.key_field, map_type.item_field],
+ )
+ return pa.Array.from_buffers(
+ map_type,
+ len(result),
+ result.buffers()[:2],
+ null_count=result.null_count,
+ children=[entries],
+ )
+
+
+def _restore_orc_temporal_values(column, logical_type):
+ """Restore logical temporal types from their ORC representations."""
+ if column.type == logical_type:
+ return column
+ if pa.types.is_time(logical_type) and pa.types.is_int32(column.type):
+ return column.cast(logical_type)
+ if (pa.types.is_timestamp(logical_type)
+ and pa.types.is_timestamp(column.type)):
+ return column.cast(logical_type)
+ if pa.types.is_struct(logical_type) and pa.types.is_struct(column.type):
+ if len(column.type) != len(logical_type):
+ return column
+ fields = list(logical_type)
+ children = [
+ _restore_orc_temporal_values(column.field(i), field.type)
+ for i, field in enumerate(fields)
+ ]
+ mask = column.is_null() if column.null_count else None
+ return pa.StructArray.from_arrays(children, fields=fields, mask=mask)
+ if ((pa.types.is_list(logical_type) and pa.types.is_list(column.type))
+ or (pa.types.is_large_list(logical_type)
+ and pa.types.is_large_list(column.type))):
+ offsets, start, end = _normalized_offsets(column)
+ offsets = _nullable_offsets(column, offsets, logical_type)
+ values = _restore_orc_temporal_values(
+ column.values.slice(start, end - start), logical_type.value_type)
+ result = (pa.LargeListArray.from_arrays(offsets, values)
+ if pa.types.is_large_list(logical_type)
+ else pa.ListArray.from_arrays(offsets, values))
+ return pa.Array.from_buffers(
+ logical_type,
+ len(result),
+ result.buffers()[:2],
+ null_count=result.null_count,
+ children=[values],
+ )
+ if pa.types.is_map(logical_type) and pa.types.is_map(column.type):
+ offsets, start, end = _normalized_offsets(column)
+ offsets = _nullable_offsets(column, offsets, logical_type)
+ keys = _restore_orc_temporal_values(
+ column.keys.slice(start, end - start), logical_type.key_type)
+ items = _restore_orc_temporal_values(
+ column.items.slice(start, end - start), logical_type.item_type)
+ result = pa.MapArray.from_arrays(offsets, keys, items)
+ entries = pa.StructArray.from_arrays(
+ [keys, items],
+ fields=[logical_type.key_field, logical_type.item_field],
+ )
+ return pa.Array.from_buffers(
+ logical_type,
+ len(result),
+ result.buffers()[:2],
+ null_count=result.null_count,
+ children=[entries],
+ )
+ return column
+
+
+def _nullable_offsets(column, offsets, logical_type):
+ for index, is_null in enumerate(column.is_null().to_pylist()):
+ if is_null:
+ offsets[index] = None
+ offset_type = (pa.int64() if pa.types.is_large_list(logical_type)
+ else pa.int32())
+ return pa.array(offsets, type=offset_type)
+
+
+def _append_entry(keys, entry_sources, entry_positions, selected_indices,
+ name, source, source_index):
+ keys.append(name)
+ entry_sources.append(source)
+ entry_positions.append(len(selected_indices[source]))
+ selected_indices[source].append(source_index)
+
+
+def _normalized_offsets(column):
+ offsets_array = getattr(column, "offsets", None)
+ if offsets_array is None:
+ offsets_array = pa.Array.from_buffers(
+ pa.int32(),
+ len(column) + 1,
+ [None, column.buffers()[1]],
+ offset=column.offset,
+ )
+ offsets = offsets_array.to_pylist()
+ start = offsets[0]
+ normalized = [value - start for value in offsets]
+ return normalized, start, offsets[-1]
+
+
+def _decompress(data: bytes, original_size: int, compression: str) -> bytes:
+ if original_size < 0:
+ raise ValueError("Shared-shredding field dictionary size must not be
negative")
+ if compression == "none":
+ result = data
+ elif compression == "zstd":
+ import zstandard as zstd
+ result = zstd.ZstdDecompressor().decompress(
+ data, max_output_size=original_size)
+ elif compression == "lz4":
+ if len(data) < 8:
+ raise ValueError("Shared-shredding LZ4 dictionary is truncated")
+ compressed_size, stored_size = struct.unpack_from("<ii", data)
+ if compressed_size < 0 or stored_size != original_size:
+ raise ValueError("Shared-shredding LZ4 dictionary header is
invalid")
+ payload = data[8:]
+ if len(payload) != compressed_size:
+ raise ValueError("Shared-shredding LZ4 dictionary is truncated")
+ result = bytes(pa.Codec("lz4_raw").decompress(payload, original_size))
+ else:
+ raise ValueError(
+ "Unsupported shared-shredding dictionary compression: {}".format(
+ compression))
+ if len(result) != original_size:
+ raise ValueError("Shared-shredding field dictionary size is invalid")
+ return result
+
+
+def _required(metadata, key):
+ try:
+ return metadata[key]
+ except KeyError:
+ raise ValueError(
+ "Missing shared-shredding metadata key: {}".format(
+ key.decode("utf-8")))
+
+
+def _required_int(metadata, key):
+ try:
+ return int(_required(metadata, key))
+ except ValueError:
+ raise ValueError(
+ "Malformed shared-shredding metadata value for: {}".format(
+ key.decode("utf-8")))
diff --git a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
index 0d067560b4..672fdcfa8a 100644
--- a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+import base64
+import binascii
import os
import sys
import threading
@@ -29,6 +31,11 @@ from pyarrow import RecordBatch
from pypaimon.common.file_io import FileIO
from pypaimon.common.options.config import CatalogOptions
from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.data.map_shared_shredding import (
+ assemble_shared_shredding_map,
+ is_shared_shredding,
+ parse_shared_shredding_metadata,
+)
from pypaimon.data.variant_shredding import (
VariantSchema,
assemble_shredded_column,
@@ -280,6 +287,33 @@ def _file_format_dataset(file_io: FileIO, file_format:
str, file_path: str,
key, dataset, file_format))
+def _orc_schema_with_field_metadata(file_io: FileIO, file_path: str,
+ fallback: pa.Schema) -> pa.Schema:
+ """Read Paimon's Arrow schema from ORC user metadata when necessary."""
+ if any(is_shared_shredding(field) for field in fallback):
+ return fallback
+
+ import pyarrow.orc as orc
+ source = file_io.filesystem.open_input_file(
+ file_io.to_filesystem_path(file_path))
+ try:
+ metadata = orc.ORCFile(source).metadata
+ arrow_schema = metadata.get(b"ARROW:schema")
+ if arrow_schema is None:
+ arrow_schema = metadata.get("ARROW:schema")
+ if arrow_schema is None:
+ return fallback
+ if isinstance(arrow_schema, str):
+ arrow_schema = arrow_schema.encode("latin-1")
+ try:
+ arrow_schema = base64.b64decode(arrow_schema, validate=True)
+ except binascii.Error:
+ pass
+ return pa.ipc.read_schema(pa.BufferReader(arrow_schema))
+ finally:
+ source.close()
+
+
class FormatPyArrowReader(RecordBatchReader):
"""
A Format Reader that reads record batch from a Parquet or ORC file using
PyArrow,
@@ -288,6 +322,7 @@ class FormatPyArrowReader(RecordBatchReader):
When a VARIANT column is stored in the shredded Parquet format (a struct
with
``metadata``, ``value``, and ``typed_value`` fields), this reader
transparently
reconstructs the standard ``struct<value: binary, metadata: binary>``
representation.
+ It also restores shared-shredding MAP columns from their physical struct
layout.
"""
def __init__(self, file_io: FileIO, file_format: str, file_path: str,
@@ -371,6 +406,10 @@ class FormatPyArrowReader(RecordBatchReader):
self._has_nested_path = has_nested_path
file_schema = self.dataset.schema
+ has_logical_map = any(isinstance(field.type, MapType) for field in
read_fields)
+ metadata_schema = (
+ _orc_schema_with_field_metadata(file_io, file_path, file_schema)
+ if file_format == 'orc' and has_logical_map else file_schema)
if has_nested_path:
self.existing_fields = []
self.missing_fields = []
@@ -387,16 +426,51 @@ class FormatPyArrowReader(RecordBatchReader):
self._variant_shredding_enabled = (
options is None or options.variant_shredding_enabled())
self._variant_schema_cache: Dict[pa.DataType, VariantSchema] = {}
+ self._shared_shredding_maps = {}
+ logical_maps_by_source = {}
+ if nested_name_paths is None:
+ source_names = [field.name for field in read_fields]
+ else:
+ source_names = [
+ path[0] if len(path) == 1 else None
+ for path in nested_name_paths
+ ]
+ for logical_field, source_name in zip(read_fields, source_names):
+ if (source_name is not None
+ and isinstance(logical_field.type, MapType)):
+ logical_maps_by_source.setdefault(source_name, []).append(
+ logical_field)
+ for field in metadata_schema:
+ logical_fields = logical_maps_by_source.get(field.name, [])
+ if logical_fields and is_shared_shredding(field):
+ metadata = parse_shared_shredding_metadata(field)
+ for logical_field in logical_fields:
+ logical_arrow_type = PyarrowFieldParser.from_paimon_type(
+ logical_field.type)
+ self._shared_shredding_maps[logical_field.name] = (
+ logical_arrow_type, metadata)
self._bounded_variant_read = (
self._file_format == 'parquet' and self._has_projected_variant())
+ self._select_nested_after_scan = False
if has_nested_path and not self._bounded_variant_read:
existing_set = set(self.existing_fields)
columns_dict = {}
- for f, path in zip(read_fields, nested_name_paths):
- if f.name in existing_set:
- columns_dict[f.name] = ds.field(*path)
- self._scan_columns = columns_dict
+ try:
+ for f, path in zip(read_fields, nested_name_paths):
+ if f.name in existing_set:
+ columns_dict[f.name] = ds.field(*path)
+ self._scan_columns = columns_dict
+ except TypeError:
+ # PyArrow 6 only accepts one field name and cannot build a
+ # nested FieldRef. Read the required top-level columns and
+ # extract their children after scanning instead.
+ self._scan_columns = []
+ for f, path in zip(read_fields, nested_name_paths):
+ if (f.name in existing_set
+ and path[0] not in self._scan_columns):
+ self._scan_columns.append(path[0])
+ self._select_nested_after_scan = True
elif has_nested_path:
self._scan_columns = None
else:
@@ -427,7 +501,12 @@ class FormatPyArrowReader(RecordBatchReader):
filter=self._scan_filter,
batch_size=self._scan_batch_size,
).to_reader()
- self._raw_batches = self._iter_reader_batches(reader)
+ raw_batches = self._iter_reader_batches(reader)
+ if self._select_nested_after_scan:
+ raw_batches = (
+ self._select_nested_fields(batch)
+ for batch in raw_batches)
+ self._raw_batches = raw_batches
def _has_projected_variant(self) -> bool:
return any(
@@ -574,6 +653,9 @@ class FormatPyArrowReader(RecordBatchReader):
if self._file_format == 'orc' and self._output_schema is not None:
batch = self._cast_orc_time_columns(batch)
+ if self._shared_shredding_maps:
+ batch = self._assemble_shared_shredding_maps(batch)
+
if self._variant_shredding_enabled:
batch = self._assemble_shredded_variants(batch)
@@ -609,6 +691,25 @@ class FormatPyArrowReader(RecordBatchReader):
return pa.RecordBatch.from_arrays(
all_columns, schema=pa.schema(out_fields))
+ def _assemble_shared_shredding_maps(
+ self, batch: pa.RecordBatch) -> pa.RecordBatch:
+ columns = list(batch.columns)
+ fields = list(batch.schema)
+ changed = False
+ for index, field in enumerate(fields):
+ shared = self._shared_shredding_maps.get(field.name)
+ if shared is None:
+ continue
+ map_type, (name_by_id, num_columns) = shared
+ columns[index] = assemble_shared_shredding_map(
+ columns[index], map_type, name_by_id, num_columns)
+ fields[index] = pa.field(
+ field.name, map_type, nullable=field.nullable)
+ changed = True
+ if not changed:
+ return batch
+ return pa.RecordBatch.from_arrays(columns, schema=pa.schema(fields))
+
def _assemble_shredded_variants(self, batch: pa.RecordBatch) ->
pa.RecordBatch:
changed = False
columns = list(batch.columns)
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index d011162b49..618920bd5e 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -1582,6 +1582,34 @@ class JavaPyReadWriteTest(unittest.TestCase):
[expected, None, None, None],
)
+ def test_read_shared_shredding_map_written_by_java(self):
+ expected = [
+ {'hot': 10, 'warm': 20, 'overflow': 30},
+ {'hot': None, 'new': 40},
+ {},
+ None,
+ {'late': 50, 'hot': 60},
+ ]
+ for file_format in ('parquet', 'orc'):
+ with self.subTest(file_format=file_format):
+ table = self.catalog.get_table(
+ 'default.shared_shredding_map_java_test_{}'.format(
+ file_format))
+ read_builder = table.new_read_builder()
+ result = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits())
+ result = table_sort_by(result, 'id')
+
+ self.assertTrue(
+ pa.types.is_map(result.schema.field('metrics').type))
+ self.assertEqual([1, 2, 3, 4, 5],
+ result.column('id').to_pylist())
+ self.assertEqual(
+ expected,
+ [None if value is None else dict(value)
+ for value in result.column('metrics').to_pylist()],
+ )
+
def test_write_map_blob_for_java(self):
map_blob_type = pa.map_(pa.int32(), pa.large_binary())
boolean_map_blob_type = pa.map_(pa.bool_(), pa.large_binary())
diff --git
a/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py
b/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py
new file mode 100644
index 0000000000..11b57420c0
--- /dev/null
+++ b/paimon-python/pypaimon/tests/format_pyarrow_shared_shredding_map_test.py
@@ -0,0 +1,402 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import base64
+from datetime import datetime, time, timezone
+import json
+import os
+import shutil
+import struct
+import tempfile
+import unittest
+from unittest import mock
+
+import pyarrow as pa
+import pyarrow.fs as pafs
+import pyarrow.orc as orc
+import pyarrow.parquet as pq
+
+from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader
+from pypaimon.schema.data_types import (
+ ArrayType,
+ AtomicType,
+ DataField,
+ MapType,
+ RowType,
+)
+
+
+class _LocalFileIO:
+ filesystem = pafs.LocalFileSystem()
+
+ def to_filesystem_path(self, path):
+ return path
+
+
+def _metadata(compression):
+ field_dict = json.dumps(
+ {"camera": 0, "state": 1, "action": 2},
+ separators=(",", ":"), sort_keys=True).encode("utf-8")
+ if compression == "none":
+ compressed = field_dict
+ elif compression == "zstd":
+ compressed = bytes(pa.Codec("zstd").compress(field_dict))
+ else:
+ payload = bytes(pa.Codec("lz4_raw").compress(field_dict))
+ compressed = struct.pack("<ii", len(payload), len(field_dict)) +
payload
+
+ return {
+ "paimon.map.storage-layout": "shared-shredding",
+ "paimon.map.shared-shredding.version": "1",
+ "paimon.map.shared-shredding.field-dict": compressed.decode("latin-1"),
+ "paimon.map.shared-shredding.field-dict-compression": compression,
+ "paimon.map.shared-shredding.field-dict-original-size":
str(len(field_dict)),
+ "paimon.map.shared-shredding.num-columns": "2",
+ }
+
+
+class SharedShreddingMapReaderTest(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.value_arrow_type = pa.struct([
+ pa.field("record_index", pa.int64()),
+ pa.field("timestamp_ns", pa.int64()),
+ ])
+ self.value_type = RowType(True, [
+ DataField(1, "record_index", AtomicType("BIGINT")),
+ DataField(2, "timestamp_ns", AtomicType("BIGINT")),
+ ])
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def _write(self, compression, file_format):
+ values0 = pa.array([
+ {"record_index": 10, "timestamp_ns": 100},
+ {"record_index": 40, "timestamp_ns": 400},
+ None,
+ None,
+ ], type=self.value_arrow_type)
+ values1 = pa.array([
+ {"record_index": 20, "timestamp_ns": 200},
+ None,
+ None,
+ None,
+ ], type=self.value_arrow_type)
+ overflow = pa.array([
+ [(2, {"record_index": 30, "timestamp_ns": 300})],
+ [(99, {"record_index": 50, "timestamp_ns": 500})],
+ None,
+ [],
+ ], type=pa.map_(pa.int32(), self.value_arrow_type))
+ physical = pa.StructArray.from_arrays(
+ [
+ pa.array([[0, 1], [1, -1], [0, -1], [-1, -1]],
+ type=pa.list_(pa.int32())),
+ values0,
+ values1,
+ overflow,
+ ],
+ names=["__field_mapping", "__col_0", "__col_1", "__overflow"],
+ mask=pa.array([False, False, True, False]),
+ )
+ field = pa.field(
+ "content_refs", physical.type, metadata=_metadata(compression))
+ table = pa.Table.from_arrays(
+ [pa.array([0, 1, 2, 3]), physical],
+ schema=pa.schema([pa.field("id", pa.int64()), field]))
+ path = os.path.join(
+ self.tmp, "{}.{}".format(compression, file_format))
+ if file_format == "parquet":
+ pq.write_table(table, path, row_group_size=3)
+ else:
+ orc.write_table(table, path)
+ return path
+
+ def test_reads_complete_map_for_all_metadata_compressions(self):
+ expected = [
+ [
+ ("camera", {"record_index": 10, "timestamp_ns": 100}),
+ ("state", {"record_index": 20, "timestamp_ns": 200}),
+ ("action", {"record_index": 30, "timestamp_ns": 300}),
+ ],
+ [("state", {"record_index": 40, "timestamp_ns": 400})],
+ None,
+ [],
+ ]
+ for compression in ("none", "lz4", "zstd"):
+ with self.subTest(compression=compression):
+ self._assert_complete_map("parquet", compression, expected)
+
+ def _assert_complete_map(
+ self, file_format, compression, expected, path=None):
+ reader = FormatPyArrowReader(
+ _LocalFileIO(), file_format,
+ path or self._write(compression, file_format),
+ [DataField(
+ 0,
+ "content_refs",
+ MapType(
+ True, AtomicType("STRING", False), self.value_type),
+ )],
+ None,
+ batch_size=2,
+ )
+ actual = []
+ while True:
+ batch = reader.read_arrow_batch()
+ if batch is None:
+ break
+ self.assertTrue(pa.types.is_map(batch.column(0).type))
+ actual.extend(batch.column(0).to_pylist())
+ self.assertEqual(expected, actual)
+
+ def test_reads_arrow_schema_metadata_from_orc(self):
+ path = self._write("none", "orc")
+ physical_schema = orc.ORCFile(path).schema
+ fields = list(physical_schema)
+ index = physical_schema.get_field_index("content_refs")
+ fields[index] = pa.field(
+ "content_refs", fields[index].type, metadata=_metadata("none"))
+ arrow_schema = base64.b64encode(
+ pa.schema(fields).serialize().to_pybytes())
+
+ expected = [
+ [
+ ("camera", {"record_index": 10, "timestamp_ns": 100}),
+ ("state", {"record_index": 20, "timestamp_ns": 200}),
+ ("action", {"record_index": 30, "timestamp_ns": 300}),
+ ],
+ [("state", {"record_index": 40, "timestamp_ns": 400})],
+ None,
+ [],
+ ]
+ metadata = mock.Mock()
+ metadata.get.side_effect = lambda key: (
+ arrow_schema if key in (b"ARROW:schema", "ARROW:schema") else None)
+ orc_file = mock.Mock(metadata=metadata)
+ with mock.patch("pyarrow.orc.ORCFile", return_value=orc_file):
+ self._assert_complete_map(
+ "orc", "none", expected, path=path)
+
+ def test_restores_time_values_from_orc(self):
+ overflow = pa.array(
+ [[(2, 5678)]], type=pa.map_(pa.int32(), pa.int32()))
+ physical = pa.StructArray.from_arrays(
+ [
+ pa.array([[0, -1]], type=pa.list_(pa.int32())),
+ pa.array([1234], type=pa.int32()),
+ pa.array([None], type=pa.int32()),
+ overflow,
+ ],
+ names=["__field_mapping", "__col_0", "__col_1", "__overflow"],
+ )
+ path = os.path.join(self.tmp, "time.orc")
+ orc.write_table(pa.table({"content_refs": physical}), path)
+
+ result = self._read_orc_shared_map(path, AtomicType("TIME(3)"))
+
+ self.assertEqual(pa.map_(pa.string(), pa.time32("ms")), result.type)
+ self.assertEqual(
+ [[("camera", time(0, 0, 1, 234000)),
+ ("action", time(0, 0, 5, 678000))]],
+ result.to_pylist(),
+ )
+
+ def test_restores_timestamp_precision_from_orc(self):
+ camera_timestamp = datetime(2024, 1, 2, 3, 4, 5, 123000)
+ action_timestamp = datetime(2024, 1, 2, 3, 4, 5, 678000)
+ physical = pa.StructArray.from_arrays(
+ [
+ pa.array([[0, -1]], type=pa.list_(pa.int32())),
+ pa.array([camera_timestamp], type=pa.timestamp("ns")),
+ pa.array([None], type=pa.timestamp("ns")),
+ pa.array(
+ [[(2, action_timestamp)]],
+ type=pa.map_(pa.int32(), pa.timestamp("ns")),
+ ),
+ ],
+ names=["__field_mapping", "__col_0", "__col_1", "__overflow"],
+ )
+ path = os.path.join(self.tmp, "timestamp.orc")
+ orc.write_table(pa.table({"content_refs": physical}), path)
+
+ result = self._read_orc_shared_map(
+ path, AtomicType("TIMESTAMP(3)"))
+
+ self.assertEqual(
+ pa.map_(pa.string(), pa.timestamp("ms")), result.type)
+ self.assertEqual(
+ [[("camera", camera_timestamp), ("action", action_timestamp)]],
+ result.to_pylist(),
+ )
+
+ def test_restores_nested_timestamp_values_from_orc(self):
+ camera_timestamp = datetime(2024, 1, 2, 3, 4, 5, 123000)
+ history_timestamp = datetime(
+ 2024, 1, 2, 3, 4, 5, 123456, tzinfo=timezone.utc)
+ physical_value_type = pa.struct([
+ pa.field("captured_at", pa.timestamp("ns")),
+ pa.field("history", pa.list_(pa.timestamp("ns", tz="UTC"))),
+ ])
+ physical = pa.StructArray.from_arrays(
+ [
+ pa.array([[0, -1]], type=pa.list_(pa.int32())),
+ pa.array(
+ [{
+ "captured_at": camera_timestamp,
+ "history": [history_timestamp],
+ }],
+ type=physical_value_type,
+ ),
+ pa.array([None], type=physical_value_type),
+ pa.array(
+ [[]], type=pa.map_(pa.int32(), physical_value_type)),
+ ],
+ names=["__field_mapping", "__col_0", "__col_1", "__overflow"],
+ )
+ path = os.path.join(self.tmp, "nested-timestamp.orc")
+ orc.write_table(pa.table({"content_refs": physical}), path)
+ logical_value_type = RowType(True, [
+ DataField(1, "captured_at", AtomicType("TIMESTAMP(3)")),
+ DataField(
+ 2,
+ "history",
+ ArrayType(True, AtomicType("TIMESTAMP_LTZ(6)")),
+ ),
+ ])
+
+ result = self._read_orc_shared_map(path, logical_value_type)
+
+ self.assertEqual(
+ pa.struct([
+ pa.field("captured_at", pa.timestamp("ms")),
+ pa.field(
+ "history", pa.list_(pa.timestamp("us", tz="UTC"))),
+ ]),
+ result.type.item_type,
+ )
+ self.assertEqual(
+ [[("camera", {
+ "captured_at": camera_timestamp,
+ "history": [history_timestamp],
+ })]],
+ result.to_pylist(),
+ )
+
+ def _read_orc_shared_map(self, path, value_type):
+ physical_field = orc.ORCFile(path).schema.field("content_refs")
+ metadata_field = pa.field(
+ "content_refs", physical_field.type, metadata=_metadata("none"))
+ arrow_schema = base64.b64encode(
+ pa.schema([metadata_field]).serialize().to_pybytes())
+ metadata = mock.Mock()
+ metadata.get.side_effect = lambda key: (
+ arrow_schema if key in (b"ARROW:schema", "ARROW:schema") else None)
+
+ with mock.patch(
+ "pyarrow.orc.ORCFile",
+ return_value=mock.Mock(metadata=metadata)):
+ reader = FormatPyArrowReader(
+ _LocalFileIO(), "orc", path,
+ [DataField(
+ 0,
+ "content_refs",
+ MapType(
+ True,
+ AtomicType("STRING", False),
+ value_type,
+ ),
+ )],
+ None,
+ )
+ return reader.read_arrow_batch().column(0)
+
+ def test_restores_map_with_nested_projection_alias(self):
+ physical = pa.StructArray.from_arrays(
+ [
+ pa.array([[0, -1]], type=pa.list_(pa.int32())),
+ pa.array([10], type=pa.int64()),
+ pa.array([None], type=pa.int64()),
+ pa.array([[]], type=pa.map_(pa.int32(), pa.int64())),
+ ],
+ names=["__field_mapping", "__col_0", "__col_1", "__overflow"],
+ )
+ nested = pa.StructArray.from_arrays(
+ [pa.array([7], type=pa.int64())], names=["b"])
+ path = os.path.join(self.tmp, "nested-alias.parquet")
+ pq.write_table(
+ pa.Table.from_arrays(
+ [nested, physical],
+ schema=pa.schema([
+ pa.field("a", nested.type),
+ pa.field(
+ "a_b", physical.type, metadata=_metadata("none")),
+ ]),
+ ),
+ path,
+ )
+
+ reader = FormatPyArrowReader(
+ _LocalFileIO(),
+ "parquet",
+ path,
+ [
+ DataField(1, "a_b", AtomicType("BIGINT")),
+ DataField(
+ 2,
+ "a_b__0",
+ MapType(
+ True,
+ AtomicType("STRING", False),
+ AtomicType("BIGINT"),
+ ),
+ ),
+ ],
+ None,
+ nested_name_paths=[["a", "b"], ["a_b"]],
+ )
+ batch = reader.read_arrow_batch()
+
+ self.assertEqual(["a_b", "a_b__0"], batch.schema.names)
+ self.assertEqual([7], batch.column(0).to_pylist())
+ self.assertEqual(
+ [[("camera", 10)]], batch.column(1).to_pylist())
+
+ def test_leaves_normal_map_unchanged(self):
+ path = os.path.join(self.tmp, "normal.parquet")
+ pq.write_table(
+ pa.table({"content_refs": pa.array(
+ [[("camera", 1)]], type=pa.map_(pa.string(), pa.int64()))}),
+ path,
+ )
+ reader = FormatPyArrowReader(
+ _LocalFileIO(), "parquet", path,
+ [DataField(
+ 0, "content_refs",
+ MapType(
+ True,
+ AtomicType("STRING", False),
+ AtomicType("BIGINT")))],
+ None,
+ )
+ self.assertEqual(
+ [[("camera", 1)]], reader.read_arrow_batch().column(0).to_pylist())
+
+
+if __name__ == "__main__":
+ unittest.main()