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 3d1caec797 [python] Fix MAP key resolution and ROW projection 
nullability (#10054)
3d1caec797 is described below

commit 3d1caec7979e7c399b0ff82400a8f9cacbc2874b
Author: zhigang <[email protected]>
AuthorDate: Tue Sep 22 10:45:40 2026 +0800

    [python] Fix MAP key resolution and ROW projection nullability (#10054)
---
 paimon-python/pypaimon/read/read_builder.py        | 95 ++++++++++++----------
 .../read/reader/nested_leaf_batch_reader.py        |  5 +-
 .../tests/map_selected_key_projection_test.py      | 85 +++++++++++++++++++
 .../pypaimon/tests/test_nested_projection_e2e.py   | 33 ++++++++
 .../pypaimon/tests/test_projection_utility.py      | 20 +++++
 paimon-python/pypaimon/utils/projection.py         | 22 +++--
 6 files changed, 207 insertions(+), 53 deletions(-)

diff --git a/paimon-python/pypaimon/read/read_builder.py 
b/paimon-python/pypaimon/read/read_builder.py
index 7a18e1e85f..ccac24842b 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -16,7 +16,7 @@
 # under the License.
 
 import ast
-from typing import List, Optional
+from typing import List, Optional, Sequence, Union
 
 from pypaimon.common.predicate import Predicate
 from pypaimon.common.predicate_builder import PredicateBuilder
@@ -33,6 +33,9 @@ from pypaimon.table.special_fields import SpecialFields
 from pypaimon.utils.projection import MapKey, Projection, is_row_type
 
 
+ProjectionPath = Sequence[Union[int, MapKey]]
+
+
 class _ReadPredicateBuilder(PredicateBuilder):
 
     def __init__(self, fields, unsupported_fields):
@@ -60,7 +63,7 @@ class ReadBuilder:
         # ``_nested_paths`` is also populated and takes precedence
         # in ``read_type()`` and downstream consumers.
         self._projection: Optional[List[str]] = None
-        self._nested_paths: Optional[List[List[int]]] = None
+        self._nested_paths: Optional[List[ProjectionPath]] = None
         self._partition_filter: Optional[Predicate] = None
         self._limit: Optional[int] = None
 
@@ -183,31 +186,14 @@ class ReadBuilder:
     # Helpers
     # ------------------------------------------------------------------
 
-    def _resolve_projection_paths(self, names: List[str]) -> List[List[int]]:
+    def _resolve_projection_paths(self, names: List[str]) -> 
List[ProjectionPath]:
         """Translate ROW paths and MAP-key selectors into internal paths."""
         table_fields = self.table.fields
         if self.table.options.row_tracking_enabled():
             table_fields = 
SpecialFields.row_type_with_row_tracking(table_fields)
         top_index = {f.name: i for i, f in enumerate(table_fields)}
 
-        def resolve_row_path(top, parts):
-            path = [top_index[top]]
-            current_field = table_fields[path[0]]
-            for part in parts:
-                if not is_row_type(current_field.type):
-                    return None
-                child_fields = current_field.type.fields
-                child_idx = next(
-                    (i for i, f in enumerate(child_fields)
-                     if f.name == part),
-                    -1)
-                if child_idx < 0:
-                    return None
-                path.append(child_idx)
-                current_field = child_fields[child_idx]
-            return path
-
-        paths: List[List[int]] = []
+        paths: List[ProjectionPath] = []
         for name in names:
             # Dot can be part of a top-level field name, not only a struct path
             # separator. Top-level match takes precedence over struct walk.
@@ -221,29 +207,31 @@ class ReadBuilder:
                 paths.append([top_index[top], MapKey(key)])
                 continue
 
-            if '.' not in name:
+            if "." not in name:
                 continue
 
             # Preserve the original ROW-path semantics before considering a
             # dotted top-level field name as the path prefix.
-            parts = name.split('.')
+            parts = name.split(".")
             top = parts[0]
             if top in top_index:
-                path = resolve_row_path(top, parts[1:])
+                path = _resolve_row_path(table_fields, top_index[top], 
parts[1:])
                 if path is not None:
                     paths.append(path)
                     continue
 
             candidates = [
-                field_name for field_name in top_index
-                if name.startswith(field_name + '.')
+                field_name
+                for field_name in top_index
+                if name.startswith(field_name + ".")
                 and is_row_type(table_fields[top_index[field_name]].type)
             ]
             if not candidates:
                 continue
             top = max(candidates, key=len)
-            parts = name[len(top) + 1:].split('.')
-            path = resolve_row_path(top, parts)
+            prefix_length = len(top) + 1
+            parts = name[prefix_length:].split(".")
+            path = _resolve_row_path(table_fields, top_index[top], parts)
             if path is not None:
                 paths.append(path)
         return paths
@@ -284,25 +272,44 @@ class ReadBuilder:
         return fields
 
 
+def _resolve_row_path(
+    table_fields: List[DataField], top_index: int, parts: List[str]
+) -> Optional[List[int]]:
+    """Walk ROW children from a top-level field; return None for invalid 
paths."""
+    path = [top_index]
+    current_field = table_fields[top_index]
+    for part in parts:
+        if not is_row_type(current_field.type):
+            return None
+        child_fields = current_field.type.fields
+        child_idx = next((i for i, f in enumerate(child_fields) if f.name == 
part), -1)
+        if child_idx < 0:
+            return None
+        path.append(child_idx)
+        current_field = child_fields[child_idx]
+    return path
+
+
 def _map_key_selector(name, table_fields):
+    if not name.endswith("]"):
+        return None
     candidates = [
-        field for field in table_fields
-        if _is_string_key_map(field.type)
-        and name.startswith(field.name + '[')
+        field
+        for field in table_fields
+        if _is_string_key_map(field.type) and name.startswith(field.name + "[")
     ]
-    if not candidates:
-        return None
-    field = max(candidates, key=lambda candidate: len(candidate.name))
-    selector = name[len(field.name):]
-    if not selector.endswith(']'):
-        return None
-    try:
-        key = ast.literal_eval(selector[1:-1])
-    except (SyntaxError, ValueError):
-        return None
-    if not isinstance(key, str):
-        return None
-    return field.name, key
+    for field in sorted(
+        candidates, key=lambda candidate: len(candidate.name), reverse=True
+    ):
+        prefix_length = len(field.name)
+        selector = name[prefix_length:]
+        try:
+            key = ast.literal_eval(selector[1:-1])
+        except (SyntaxError, ValueError):
+            continue
+        if isinstance(key, str):
+            return field.name, key
+    return None
 
 
 def _is_string_key_map(data_type) -> bool:
diff --git a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py 
b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
index 6bb1d6450d..f85c818081 100644
--- a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
@@ -30,11 +30,12 @@ from pypaimon.schema.data_types import DataField, 
PyarrowFieldParser
 
 
 def _struct_field(column, name):
+    # Resolve literal names before Arrow can interpret them as field paths.
+    field_index = [field.name for field in column.type].index(name)
     struct_field = getattr(pc, "struct_field", None)
     if struct_field is not None:
-        return struct_field(column, name)
+        return struct_field(column, field_index)
 
-    field_index = [field.name for field in column.type].index(name)
     return column.flatten()[field_index]
 
 
diff --git a/paimon-python/pypaimon/tests/map_selected_key_projection_test.py 
b/paimon-python/pypaimon/tests/map_selected_key_projection_test.py
index 63670f25da..aa770564f0 100644
--- a/paimon-python/pypaimon/tests/map_selected_key_projection_test.py
+++ b/paimon-python/pypaimon/tests/map_selected_key_projection_test.py
@@ -61,6 +61,91 @@ class MapSelectedKeyProjectionTest(unittest.TestCase):
             'attributes_missing': [None, None, None],
         }, result.to_pydict())
 
+    def test_dot_prefixed_map_keys_are_literal(self):
+        for file_format in ("parquet", "row"):
+            with self.subTest(file_format=file_format):
+                schema = pa.schema([("attrs", pa.map_(pa.string(), 
pa.int64()))])
+                identifier = "default.literal_dot_" + file_format
+                self.catalog.create_table(
+                    identifier,
+                    Schema.from_pyarrow_schema(
+                        schema,
+                        options={
+                            "bucket": "-1",
+                            "file.format": file_format,
+                        },
+                    ),
+                    False,
+                )
+                table = self.catalog.get_table(identifier)
+                builder = table.new_batch_write_builder()
+                writer = builder.new_write()
+                try:
+                    writer.write_arrow(
+                        pa.Table.from_pylist(
+                            [
+                                {"attrs": [("foo", 100), (".foo", 107), (".", 
108)]},
+                                {"attrs": []},
+                                {"attrs": None},
+                            ],
+                            schema=schema,
+                        )
+                    )
+                    builder.new_commit().commit(writer.prepare_commit())
+                finally:
+                    writer.close()
+
+                result = self._read(table, ["attrs['foo']", "attrs['.foo']"])
+                self.assertEqual(
+                    {
+                        "attrs_foo": [100, None, None],
+                        "attrs__foo": [107, None, None],
+                    },
+                    result.to_pydict(),
+                )
+                for key, expected in ((".foo", 107), (".", 108)):
+                    result = self._read(table, [f"attrs[{key!r}]"])
+                    self.assertEqual(
+                        [expected, None, None], result.column(0).to_pylist()
+                    )
+
+    def test_map_selector_skips_invalid_longer_prefix(self):
+        map_type = pa.map_(pa.string(), pa.int64())
+        schema = pa.schema([("attrs", map_type), ("attrs['x", map_type)])
+        identifier = "default.map_prefix"
+        self.catalog.create_table(
+            identifier,
+            Schema.from_pyarrow_schema(schema, options={"bucket": "-1"}),
+            False,
+        )
+        table = self.catalog.get_table(identifier)
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        try:
+            writer.write_arrow(
+                pa.Table.from_pylist(
+                    [
+                        {"attrs": [("x[0]", 42)], "attrs['x": [("other", 99)]},
+                    ],
+                    schema=schema,
+                )
+            )
+            builder.new_commit().commit(writer.prepare_commit())
+        finally:
+            writer.close()
+
+        for selector in ("attrs['x[0]']", 'attrs["x[0]"]'):
+            with self.subTest(selector=selector):
+                self.assertEqual(
+                    {"attrs_x[0]": [42]}, self._read(table, 
[selector]).to_pydict()
+                )
+        self.assertEqual(
+            {"attrs['x_other": [99]},
+            self._read(
+                table, ["attrs['x['other']", "attrs[123]", "attrs[other]"]
+            ).to_pydict(),
+        )
+
     def test_projects_map_key_from_data_evolution_table(self):
         table = self._write_table('data_evolution', {
             'row-tracking.enabled': 'true',
diff --git a/paimon-python/pypaimon/tests/test_nested_projection_e2e.py 
b/paimon-python/pypaimon/tests/test_nested_projection_e2e.py
index 40e53b672b..934cc4e4ed 100644
--- a/paimon-python/pypaimon/tests/test_nested_projection_e2e.py
+++ b/paimon-python/pypaimon/tests/test_nested_projection_e2e.py
@@ -21,6 +21,7 @@ import tempfile
 import unittest
 
 import pyarrow as pa
+import pyarrow.parquet as pq
 
 from pypaimon import CatalogFactory, Schema
 
@@ -98,6 +99,38 @@ class AppendOnlyNestedParquetTest(_AppendOnlyNestedBase):
 
         self.assertEqual([100, None], got.column(0).to_pylist())
 
+    def test_required_leaf_under_nullable_row_can_be_exported(self):
+        schema = pa.schema(
+            [
+                ("r", pa.struct([pa.field("x", pa.int64(), nullable=False)])),
+            ]
+        )
+        identifier = "default.nullable_row_required_leaf"
+        self.catalog.create_table(
+            identifier,
+            Schema.from_pyarrow_schema(schema, options={"bucket": "-1"}),
+            False,
+        )
+        table = self.catalog.get_table(identifier)
+        wb = table.new_batch_write_builder()
+        writer = wb.new_write()
+        try:
+            writer.write_arrow(
+                pa.Table.from_pylist([{"r": None}, {"r": {"x": 7}}], 
schema=schema)
+            )
+            wb.new_commit().commit(writer.prepare_commit())
+        finally:
+            writer.close()
+
+        rb = table.new_read_builder().with_projection(["r.x"])
+        result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+        self.assertEqual([None, 7], result.column(0).to_pylist())
+        sink = pa.BufferOutputStream()
+        pq.write_table(result, sink)
+        restored = pq.read_table(pa.BufferReader(sink.getvalue()))
+        self.assertTrue(restored.schema.field("r_x").nullable)
+        self.assertEqual(result.to_pydict(), restored.to_pydict())
+
     def test_mixed_nested_and_top_level_preserves_order(self):
         table = self._create_table('ao_mixed_order')
         rb = table.new_read_builder().with_projection(
diff --git a/paimon-python/pypaimon/tests/test_projection_utility.py 
b/paimon-python/pypaimon/tests/test_projection_utility.py
index d3d37d10a3..87b455e0fb 100644
--- a/paimon-python/pypaimon/tests/test_projection_utility.py
+++ b/paimon-python/pypaimon/tests/test_projection_utility.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import unittest
+from itertools import product
 
 from pypaimon.schema.data_types import AtomicType, DataField, RowType
 from pypaimon.utils.projection import (NestedProjection, Projection,
@@ -77,6 +78,25 @@ class TopLevelProjectionTest(unittest.TestCase):
 
 
 class NestedProjectionTest(unittest.TestCase):
+    def test_nullable_is_inherited_from_all_ancestors(self):
+        for outer_nullable, inner_nullable, leaf_nullable in product(
+            (False, True), repeat=3
+        ):
+            with self.subTest(
+                outer=outer_nullable, inner=inner_nullable, leaf=leaf_nullable
+            ):
+                leaf = DataField(3, "x", AtomicType("BIGINT", leaf_nullable))
+                inner = DataField(2, "inner", RowType(inner_nullable, [leaf]))
+                outer = DataField(1, "outer", RowType(outer_nullable, [inner]))
+                projected = Projection.of([[0, 0, 0]]).project([outer])
+                self.assertEqual(
+                    outer_nullable or inner_nullable or leaf_nullable,
+                    projected[0].type.nullable,
+                )
+                # Projection must not alter the source schema's constraints.
+                self.assertEqual(leaf_nullable, leaf.type.nullable)
+                self.assertEqual(inner_nullable, inner.type.nullable)
+                self.assertEqual(outer_nullable, outer.type.nullable)
 
     def test_factory_produces_nested(self):
         p = Projection.of([[1, 0], [1, 1]])
diff --git a/paimon-python/pypaimon/utils/projection.py 
b/paimon-python/pypaimon/utils/projection.py
index 9be66dad10..95111a96e1 100644
--- a/paimon-python/pypaimon/utils/projection.py
+++ b/paimon-python/pypaimon/utils/projection.py
@@ -226,6 +226,7 @@ class NestedProjection(Projection):
         for path in self.paths:
             field = fields[path[0]]
             name_parts = [field.name]
+            nullable = field.type.nullable
             is_map_key = False
             for idx in path[1:]:
                 child_type = field.type
@@ -250,6 +251,7 @@ class NestedProjection(Projection):
                         "for field '%s'" % (child_type, field.name))
                 child_fields = _row_fields(child_type)
                 field = child_fields[idx]
+                nullable = nullable or field.type.nullable
                 name_parts.append(field.name)
             base_name = "_".join(name_parts)
             final_name = base_name
@@ -258,15 +260,21 @@ class NestedProjection(Projection):
                 final_name = "%s__%d" % (base_name, dup_count)
                 dup_count += 1
             seen_names.add(final_name)
+            field_type = field.type
+            if nullable and not field_type.nullable:
+                field_type = copy(field_type)
+                field_type.nullable = True
             # Keep the leaf field's ID so downstream schema-evolution
             # remapping by field ID still works after rename.
-            out.append(DataField(
-                id=field.id,
-                name=final_name,
-                type=field.type,
-                description=getattr(field, 'description', None),
-                default_value=getattr(field, 'default_value', None),
-            ))
+            out.append(
+                DataField(
+                    id=field.id,
+                    name=final_name,
+                    type=field_type,
+                    description=getattr(field, "description", None),
+                    default_value=getattr(field, "default_value", None),
+                )
+            )
         return out
 
 

Reply via email to