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 2b96b75c0c [python] Support schema evolution of nested struct 
sub-fields (#8187)
2b96b75c0c is described below

commit 2b96b75c0cf82537acfff1cecd3594c730da7c2d
Author: chaoyang <[email protected]>
AuthorDate: Mon Jun 15 09:10:55 2026 +0800

    [python] Support schema evolution of nested struct sub-fields (#8187)
    
    Follow-up to #8126, which made read-time schema evolution align
    top-level columns by **field id**. This extends the same id-based
    alignment to **sub-fields inside a `ROW`** (including a `ROW` nested in
    an `ARRAY`/`MAP`).
    
    Before this PR, nested sub-field evolution didn't work: adding a
    sub-field silently created a top-level column, and
    rename/drop/update-type failed, because only the last name in the path
    was matched.
    
    Now a dotted path like `mv.value` is resolved recursively, so for a
    column `mv ROW<version BIGINT, value STRING>`:
    
    - **add** a sub-field → old rows read `NULL` for it;
    - **rename** a sub-field → data follows the field id, not the name;
    - **drop** a sub-field → its old data is not revived;
    - **update type** of a sub-field → cast at read time.
    
    ```python
    # mv ROW<version BIGINT, value STRING>
    catalog.alter_table("db.t", [SchemaChange.rename_column(["mv", "value"], 
"val")])
    # files written under mv.value are read back as mv.val with the same data
    ```
    
    How it works:
    
    - Nested sub-fields get globally-unique field ids at create time;
    `highestFieldId` is computed recursively so nested and top-level ids
    never collide.
    - Schema changes (add / rename / drop / update-type / update-nullability
    / update-comment) recurse along the field-name path, transparently
    through `ARRAY`/`MAP` wrappers.
    - `update column type` is validated against the cast-support rules.
    - The read path aligns nested sub-fields by id — reorder, pad missing
    with `NULL`, follow renames, cast changed types — recursing into struct
    / array / map.
---
 paimon-python/pypaimon/casting/__init__.py         |  16 +
 paimon-python/pypaimon/casting/data_type_casts.py  | 257 ++++++++++
 .../pypaimon/read/reader/data_file_batch_reader.py | 196 ++++++--
 .../read/reader/nested_leaf_batch_reader.py        |  64 +++
 paimon-python/pypaimon/read/split_read.py          |  77 +++
 paimon-python/pypaimon/read/table_read.py          |  19 +-
 paimon-python/pypaimon/schema/data_types.py        | 108 +++-
 paimon-python/pypaimon/schema/schema_manager.py    | 323 +++++++++---
 paimon-python/pypaimon/schema/table_schema.py      |   4 +-
 paimon-python/pypaimon/table/special_fields.py     |   9 +-
 paimon-python/pypaimon/tests/data_types_test.py    |  17 +
 .../pypaimon/tests/filesystem_catalog_test.py      | 109 ++++
 .../tests/schema_evolution_nested_read_test.py     | 554 +++++++++++++++++++--
 .../pypaimon/tests/test_nested_projection_e2e.py   |  64 +++
 14 files changed, 1651 insertions(+), 166 deletions(-)

diff --git a/paimon-python/pypaimon/casting/__init__.py 
b/paimon-python/pypaimon/casting/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/paimon-python/pypaimon/casting/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/paimon-python/pypaimon/casting/data_type_casts.py 
b/paimon-python/pypaimon/casting/data_type_casts.py
new file mode 100644
index 0000000000..819b9c115c
--- /dev/null
+++ b/paimon-python/pypaimon/casting/data_type_casts.py
@@ -0,0 +1,257 @@
+# 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.
+
+"""Type-cast support rules used to validate ``update column type`` schema
+changes.
+
+The rules mirror the engine-wide cast specification so a type change accepted
+here is one the read path can also materialize: an *implicit* cast is a safe
+widening (e.g. INT -> BIGINT, any numeric -> DECIMAL/DOUBLE), while an
+*explicit* cast covers the broader, possibly lossy conversions a user opts into
+(e.g. DOUBLE -> INT truncation, anything -> STRING). Read-time execution then
+applies the conversion leniently.
+"""
+
+import pyarrow as pa
+
+from pypaimon.schema.data_types import (ArrayType, AtomicType, DataTypeParser,
+                                        MapType, MultisetType,
+                                        PyarrowFieldParser, RowType,
+                                        VectorType)
+
+# ---- Type roots 
--------------------------------------------------------------
+
+CHAR = "CHAR"
+VARCHAR = "VARCHAR"
+BOOLEAN = "BOOLEAN"
+BINARY = "BINARY"
+VARBINARY = "VARBINARY"
+DECIMAL = "DECIMAL"
+TINYINT = "TINYINT"
+SMALLINT = "SMALLINT"
+INTEGER = "INTEGER"
+BIGINT = "BIGINT"
+FLOAT = "FLOAT"
+DOUBLE = "DOUBLE"
+DATE = "DATE"
+TIME = "TIME"
+TIMESTAMP = "TIMESTAMP"
+TIMESTAMP_LTZ = "TIMESTAMP_LTZ"
+ARRAY = "ARRAY"
+MAP = "MAP"
+MULTISET = "MULTISET"
+ROW = "ROW"
+VECTOR = "VECTOR"
+VARIANT = "VARIANT"
+BLOB = "BLOB"
+
+# ---- Families 
----------------------------------------------------------------
+
+CHARACTER_STRING = {CHAR, VARCHAR}
+BINARY_STRING = {BINARY, VARBINARY}
+INTEGER_NUMERIC = {TINYINT, SMALLINT, INTEGER, BIGINT}
+NUMERIC = INTEGER_NUMERIC | {FLOAT, DOUBLE, DECIMAL}
+TIMESTAMP_FAMILY = {TIMESTAMP, TIMESTAMP_LTZ}
+TIME_FAMILY = {TIME}
+DATETIME = {DATE, TIME, TIMESTAMP, TIMESTAMP_LTZ}
+PREDEFINED = {
+    CHAR, VARCHAR, BOOLEAN, BINARY, VARBINARY, DECIMAL,
+    TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE,
+    DATE, TIME, TIMESTAMP, TIMESTAMP_LTZ,
+}
+CONSTRUCTED = {ARRAY, MAP, MULTISET, ROW, VECTOR}
+# Constructed types the read path can render as a character string
+# ('{v1, v2}' / '[e1, e2]' / '{k -> v}'). VECTOR and MULTISET have no string
+# rendering, so a type change from them to CHAR/VARCHAR is rejected here
+# rather than failing when an old file is read.
+STRING_RENDERABLE_CONSTRUCTED = {ARRAY, MAP, ROW}
+
+
+def _root(data_type) -> str:
+    if isinstance(data_type, RowType):
+        return ROW
+    if isinstance(data_type, ArrayType):
+        return ARRAY
+    if isinstance(data_type, MapType):
+        return MAP
+    if isinstance(data_type, MultisetType):
+        return MULTISET
+    if isinstance(data_type, VectorType):
+        return VECTOR
+    if isinstance(data_type, AtomicType):
+        t = data_type.type.upper()
+        if t.startswith("DECIMAL") or t.startswith("NUMERIC") or 
t.startswith("DEC"):
+            return DECIMAL
+        if t in ("INT", "INTEGER"):
+            return INTEGER
+        if t in (TINYINT, SMALLINT, BIGINT, FLOAT, DOUBLE, BOOLEAN, DATE):
+            return t
+        if t == "STRING" or t.startswith("VARCHAR"):
+            return VARCHAR
+        if t.startswith("CHAR"):
+            return CHAR
+        if t == "BYTES" or t.startswith("VARBINARY"):
+            return VARBINARY
+        if t.startswith("BINARY"):
+            return BINARY
+        if t == "BLOB":
+            return BLOB
+        if t.startswith("TIMESTAMP_LTZ"):
+            return TIMESTAMP_LTZ
+        if t.startswith("TIMESTAMP"):
+            return TIMESTAMP
+        if t.startswith("TIME"):
+            return TIME
+        if t == "VARIANT":
+            return VARIANT
+    return None
+
+
+def _build_rules():
+    implicit = {}
+    explicit = {}
+    # Identity cast for every root.
+    for root in (PREDEFINED | CONSTRUCTED | {VARIANT, BLOB}):
+        implicit[root] = {root}
+        explicit[root] = set()
+
+    def rule(target, implicit_from=None, explicit_from=None):
+        implicit[target] |= set(implicit_from or set())
+        explicit[target] |= set(explicit_from or set())
+
+    rule(CHAR, {CHAR}, PREDEFINED | STRING_RENDERABLE_CONSTRUCTED)
+    rule(VARCHAR, CHARACTER_STRING, PREDEFINED | STRING_RENDERABLE_CONSTRUCTED)
+    rule(BOOLEAN, {BOOLEAN}, CHARACTER_STRING | INTEGER_NUMERIC)
+    rule(BINARY, {BINARY}, CHARACTER_STRING | {VARBINARY})
+    rule(VARBINARY, BINARY_STRING, CHARACTER_STRING | {BINARY})
+    rule(DECIMAL, NUMERIC, CHARACTER_STRING | {BOOLEAN, TIMESTAMP, 
TIMESTAMP_LTZ})
+    int_explicit = NUMERIC | CHARACTER_STRING | {BOOLEAN, TIMESTAMP, 
TIMESTAMP_LTZ}
+    rule(TINYINT, {TINYINT}, int_explicit)
+    rule(SMALLINT, {TINYINT, SMALLINT}, int_explicit)
+    rule(INTEGER, {TINYINT, SMALLINT, INTEGER}, int_explicit)
+    rule(BIGINT, {TINYINT, SMALLINT, INTEGER, BIGINT}, int_explicit)
+    rule(FLOAT, {TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, DECIMAL}, 
int_explicit)
+    rule(DOUBLE, NUMERIC, CHARACTER_STRING | {BOOLEAN, TIMESTAMP, 
TIMESTAMP_LTZ})
+    rule(DATE, {DATE, TIMESTAMP}, TIMESTAMP_FAMILY | CHARACTER_STRING)
+    rule(TIME, {TIME, TIMESTAMP}, TIME_FAMILY | TIMESTAMP_FAMILY | 
CHARACTER_STRING)
+    rule(TIMESTAMP, {TIMESTAMP, TIMESTAMP_LTZ}, DATETIME | CHARACTER_STRING | 
NUMERIC)
+    rule(TIMESTAMP_LTZ, {TIMESTAMP_LTZ, TIMESTAMP}, DATETIME | 
CHARACTER_STRING | NUMERIC)
+    return implicit, explicit
+
+
+_IMPLICIT_RULES, _EXPLICIT_RULES = _build_rules()
+
+
+def supports_cast(source_type, target_type, allow_explicit: bool = True) -> 
bool:
+    """Whether ``source_type`` can be cast to ``target_type`` for a column type
+    change. ``allow_explicit`` permits the broader (possibly lossy) conversions
+    in addition to the safe widening ones."""
+    source_root = _root(source_type)
+    target_root = _root(target_type)
+    if source_root is None or target_root is None:
+        return False
+    # A NOT NULL target cannot accept a nullable source unless explicitly 
allowed.
+    if source_type.nullable and not target_type.nullable and not 
allow_explicit:
+        return False
+    if source_root == target_root:
+        if source_root in CONSTRUCTED:
+            # A constructed type is only castable to an (ignoring outer
+            # nullability) identical constructed type. Reshaping is done
+            # through sub-field / 'element' / 'value' paths instead: a whole
+            # ROW replacement would carry caller-supplied nested field ids
+            # that corrupt the id model, and there is no runtime conversion
+            # between differently-shaped constructed values.
+            return _equals_ignore_nullable(source_type, target_type)
+        return True
+    if source_root in _IMPLICIT_RULES.get(target_root, set()):
+        return True
+    if allow_explicit and source_root in _EXPLICIT_RULES.get(target_root, 
set()):
+        return True
+    return False
+
+
+def _equals_ignore_nullable(source_type, target_type) -> bool:
+    source_copy = DataTypeParser.parse_data_type(source_type.to_dict())
+    target_copy = DataTypeParser.parse_data_type(target_type.to_dict())
+    source_copy.nullable = True
+    target_copy.nullable = True
+    return source_copy == target_copy
+
+
+# Caches the PyArrow cast-kernel probe per (source, target) pyarrow type so the
+# alter-time check stays cheap. Keyed by the pyarrow type strings.
+_EXECUTABLE_CAST_CACHE = {}
+
+
+def can_execute_cast(source_type, target_type) -> bool:
+    """Whether the Python read path can actually *materialize* a stored
+    ``source_type`` value as ``target_type`` when reading a file written before
+    the column type change.
+
+    ``supports_cast`` only encodes the *logical* cast specification (mirroring
+    Java ``DataTypeCasts``). This is the executable-cast counterpart of Java's
+    ``CastExecutors.resolve(...) != null`` guard: some logically-valid casts
+    (e.g. ``TIMESTAMP -> DECIMAL``, ``BOOLEAN -> DECIMAL``, ``TIME ->
+    TIMESTAMP``) have no PyArrow cast kernel, so without this check the alter
+    succeeds and the read later fails with ``ArrowNotImplementedError``.
+    """
+    source_root = _root(source_type)
+    target_root = _root(target_type)
+    if source_root is None or target_root is None:
+        return False
+    # Same root: identity, or a same-shape constructed type whose value is
+    # rebuilt by the read path's field-id alignment rather than a value cast.
+    if source_root == target_root:
+        return True
+    # Constructed -> character string is rendered by the read path's custom
+    # ``_constructed_to_string_array`` (see DataFileBatchReader), not a cast.
+    if (source_root in STRING_RENDERABLE_CONSTRUCTED
+            and target_root in CHARACTER_STRING):
+        return True
+    # Any other conversion touching a constructed type has no runtime cast.
+    if source_root in CONSTRUCTED or target_root in CONSTRUCTED:
+        return False
+    # Leaf-to-leaf: defer to PyArrow's cast-kernel availability, which is the
+    # read path's actual cast executor (``array.cast(target, safe=False)``).
+    return _pyarrow_cast_supported(source_type, target_type)
+
+
+def _pyarrow_cast_supported(source_type, target_type) -> bool:
+    source_pa = PyarrowFieldParser.from_paimon_type(source_type)
+    target_pa = PyarrowFieldParser.from_paimon_type(target_type)
+    if source_pa == target_pa:
+        return True
+    cache_key = (str(source_pa), str(target_pa))
+    cached = _EXECUTABLE_CAST_CACHE.get(cache_key)
+    if cached is not None:
+        return cached
+    # Probe a one-row (null-valued) array rather than an empty one. An empty
+    # array only resolves the cast kernel; some kernels additionally reject the
+    # target type parameters on any non-empty input -- e.g. INT -> 
DECIMAL(10,2)
+    # has a kernel but needs precision >= 12 to hold an int's range at scale 2,
+    # so an empty probe passes yet the read later fails with ArrowInvalid. A
+    # single null row triggers that static type-parameter validation while
+    # avoiding per-value parse/overflow errors (which ``safe=False`` -- 
matching
+    # the read path -- tolerates anyway).
+    try:
+        pa.nulls(1, type=source_pa).cast(target_pa, safe=False)
+        ok = True
+    except (pa.lib.ArrowNotImplementedError, pa.lib.ArrowInvalid,
+            pa.lib.ArrowTypeError):
+        ok = False
+    _EXECUTABLE_CAST_CACHE[cache_key] = ok
+    return ok
diff --git a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py 
b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
index e3bd511439..0183396fe7 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -18,16 +18,94 @@
 from typing import List, Optional
 
 import pyarrow as pa
+import pyarrow.compute as pc
 from pyarrow import RecordBatch
 
 from pypaimon.common.file_io import FileIO
 from pypaimon.read.partition_info import PartitionInfo
 from pypaimon.read.reader.format_blob_reader import FormatBlobReader
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
-from pypaimon.schema.data_types import DataField, PyarrowFieldParser
+from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
+                                        MapType, PyarrowFieldParser, RowType)
 from pypaimon.table.special_fields import SpecialFields
 
 
+def _is_character_string_type(data_type) -> bool:
+    if not isinstance(data_type, AtomicType):
+        return False
+    t = data_type.type.upper()
+    return t == 'STRING' or t.startswith('VARCHAR') or t.startswith('CHAR')
+
+
+def _unslice(array):
+    """Re-materialize a sliced array so offsets/buffers start at zero.
+
+    The list/map rebuilds below read ``offsets``/raw buffers directly; on a
+    sliced array those still point into the parent storage, which either
+    errors (list rebuild with a null mask) or silently misaligns rows (map
+    rebuild from raw buffers)."""
+    if array.offset == 0:
+        return array
+    return pa.concat_arrays([array])
+
+
+def _to_string_values(array, data_type) -> list:
+    """Render *array* as a list of per-row strings (None for NULL rows)."""
+    if isinstance(data_type, (RowType, ArrayType, MapType)):
+        return _constructed_to_string_array(array, data_type).to_pylist()
+    return array.cast(pa.string(), safe=False).to_pylist()
+
+
+def _constructed_to_string_array(array, file_type):
+    """Render a struct/list/map array in the engine's string form:
+    ROW -> ``{v1, v2}``, ARRAY -> ``[e1, e2]``, MAP -> ``{k1 -> v1, k2 -> 
v2}``.
+    Sub-values are rendered recursively; a NULL sub-value renders as the
+    literal ``null`` while a NULL container row stays NULL."""
+    array = _unslice(array)
+    valid = pc.is_valid(array).to_pylist()
+    out = []
+    if isinstance(file_type, RowType):
+        children = [
+            _to_string_values(array.field(i), sub.type)
+            for i, sub in enumerate(file_type.fields)
+        ]
+        for i in range(len(array)):
+            if not valid[i]:
+                out.append(None)
+                continue
+            vals = [c[i] if c[i] is not None else 'null' for c in children]
+            out.append('{' + ', '.join(vals) + '}')
+    elif isinstance(file_type, ArrayType):
+        values = _to_string_values(array.values, file_type.element)
+        offsets = array.offsets.to_pylist()
+        for i in range(len(array)):
+            if not valid[i]:
+                out.append(None)
+                continue
+            elems = [v if v is not None else 'null'
+                     for v in values[offsets[i]:offsets[i + 1]]]
+            out.append('[' + ', '.join(elems) + ']')
+    elif isinstance(file_type, MapType):
+        keys = _to_string_values(array.keys, file_type.key)
+        items = _to_string_values(array.items, file_type.value)
+        offsets = array.offsets.to_pylist()
+        for i in range(len(array)):
+            if not valid[i]:
+                out.append(None)
+                continue
+            entries = [
+                '{} -> {}'.format(
+                    keys[j] if keys[j] is not None else 'null',
+                    items[j] if items[j] is not None else 'null')
+                for j in range(offsets[i], offsets[i + 1])
+            ]
+            out.append('{' + ', '.join(entries) + '}')
+    else:
+        raise ValueError(
+            'Unsupported constructed type for string rendering: 
{}'.format(file_type))
+    return pa.array(out, type=pa.string())
+
+
 class DataFileBatchReader(RecordBatchReader):
     """
     Reads record batch from files of different formats
@@ -57,55 +135,107 @@ class DataFileBatchReader(RecordBatchReader):
         self.file_io = file_io
         # Per-file field-id normalization: map the physically-read columns
         # (the file's own field order/names) onto the latest read target by
-        # field id, padding missing ids with NULL. ``None`` when there is no
-        # evolution to reconcile (identity) -- the common path stays zero-copy.
-        self._normalize_positions, self._normalize_names = \
-            self._build_normalize_plan(file_data_fields, target_data_fields)
+        # field id, padding missing ids with NULL and recursing into nested
+        # ROW / ARRAY<ROW> / MAP<.,ROW> sub-fields the same way. ``None`` when
+        # there is no evolution to reconcile -- the common path stays 
zero-copy.
+        self._normalize_plan = self._build_normalize_plan(file_data_fields, 
target_data_fields)
 
     @staticmethod
     def _build_normalize_plan(file_data_fields, target_data_fields):
         """Build a per-file field-id alignment plan.
 
-        Returns ``(positions, names)`` where ``positions[i]`` is the column
-        index in the physically-read batch carrying ``target_data_fields[i]``
-        (matched by field id), or -1 if the file does not contain that id (pad
-        NULL). ``names[i]`` is the latest target name. Returns ``(None, None)``
-        when the plan is the identity (no evolution), so the caller skips
-        normalization and stays zero-copy.
+        Returns a list of ``(pos, file_field, target_field)`` -- one per target
+        field, in target order -- where ``pos`` is the column index in the
+        physically-read batch carrying ``target_field`` (matched by field id),
+        or -1 if the file does not contain that id (pad NULL). Returns ``None``
+        when the file already matches the target exactly (no evolution), so the
+        caller stays zero-copy.
         """
         if file_data_fields is None or target_data_fields is None:
-            return None, None
+            return None
+        # Recursive equality covers nested sub-field changes too: any rename /
+        # add / drop / type change at any depth makes the file != target.
+        if file_data_fields == target_data_fields:
+            return None
         file_id_to_pos = {f.id: i for i, f in enumerate(file_data_fields)}
-        positions = []
-        names = []
-        # Identity only when every target maps to the same physical position
-        # AND already carries the same name -- a rename keeps the position but
-        # changes the name, which still requires a relabel pass.
-        identity = len(file_data_fields) == len(target_data_fields)
-        for i, target in enumerate(target_data_fields):
+        plan = []
+        for target in target_data_fields:
             pos = file_id_to_pos.get(target.id, -1)
-            positions.append(pos)
-            names.append(target.name)
-            if pos != i or (pos >= 0 and file_data_fields[pos].name != 
target.name):
-                identity = False
-        if identity:
-            return None, None
-        return positions, names
+            file_field = file_data_fields[pos] if pos >= 0 else None
+            plan.append((pos, file_field, target))
+        return plan
 
     def _normalize_batch(self, record_batch: RecordBatch) -> RecordBatch:
         """Reorder/pad the physically-read batch onto the latest read target by
-        field id, and relabel columns to the latest names. Missing ids become
-        all-NULL columns; types are reconciled later by 
_align_batch_to_read_schema."""
-        if self._normalize_positions is None:
+        field id, relabel columns to the latest names, and align nested ROW
+        sub-fields by id. Missing ids become typed all-NULL columns."""
+        if self._normalize_plan is None:
             return record_batch
         num_rows = record_batch.num_rows
         arrays = []
-        for pos in self._normalize_positions:
+        names = []
+        for pos, file_field, target_field in self._normalize_plan:
+            target_pa_type = 
PyarrowFieldParser.from_paimon_type(target_field.type)
             if pos < 0:
-                arrays.append(pa.nulls(num_rows))
+                arrays.append(pa.nulls(num_rows, type=target_pa_type))
             else:
-                arrays.append(record_batch.column(pos))
-        return pa.RecordBatch.from_arrays(arrays, names=self._normalize_names)
+                arrays.append(self._align_array_by_id(
+                    record_batch.column(pos), file_field.type, 
target_field.type))
+            names.append(target_field.name)
+        return pa.RecordBatch.from_arrays(arrays, names=names)
+
+    def _align_array_by_id(self, array, file_type, target_type):
+        """Return *array* converted to *target_type*, matching ROW sub-fields 
by
+        field id (reorder, pad missing with NULL, follow renames, cast changed
+        types) recursively, transparently through ARRAY/MAP wrappers."""
+        if isinstance(target_type, RowType) and isinstance(file_type, RowType):
+            n = len(array)
+            file_id_to_pos = {f.id: i for i, f in enumerate(file_type.fields)}
+            children = []
+            pa_fields = []
+            for tsub in target_type.fields:
+                p = file_id_to_pos.get(tsub.id, -1)
+                if p < 0:
+                    child = pa.nulls(n, 
type=PyarrowFieldParser.from_paimon_type(tsub.type))
+                else:
+                    child = self._align_array_by_id(
+                        array.field(p), file_type.fields[p].type, tsub.type)
+                children.append(child)
+                pa_fields.append(pa.field(tsub.name, child.type, 
nullable=tsub.type.nullable))
+            # Preserve the struct's own null mask; child values under a null
+            # struct are irrelevant.
+            return pa.StructArray.from_arrays(
+                children, fields=pa_fields, mask=pc.is_null(array))
+        if isinstance(target_type, ArrayType) and isinstance(file_type, 
ArrayType):
+            array = _unslice(array)
+            aligned_values = self._align_array_by_id(
+                array.values, file_type.element, target_type.element)
+            return pa.ListArray.from_arrays(
+                array.offsets, aligned_values, mask=pc.is_null(array))
+        if isinstance(target_type, MapType) and isinstance(file_type, MapType):
+            array = _unslice(array)
+            aligned_items = self._align_array_by_id(
+                array.items, file_type.value, target_type.value)
+            # MapArray.from_arrays cannot carry a null mask (a null map would
+            # collapse to an empty one), so rebuild from buffers, reusing the
+            # original validity/offset buffers and only swapping the value 
child.
+            target_pa = PyarrowFieldParser.from_paimon_type(target_type)
+            entries = pa.StructArray.from_arrays(
+                [array.keys, aligned_items],
+                fields=[target_pa.key_field, target_pa.item_field])
+            return pa.Array.from_buffers(
+                target_pa, len(array), array.buffers()[:2], children=[entries])
+        # A constructed type changed to a character string: pyarrow cannot
+        # cast struct/list/map to utf8 directly, so render the engine's
+        # string form instead.
+        if (isinstance(file_type, (RowType, ArrayType, MapType))
+                and _is_character_string_type(target_type)):
+            return _constructed_to_string_array(array, file_type)
+        # Leaf / non-nested: cast to the target type when it differs.
+        target_pa_type = PyarrowFieldParser.from_paimon_type(target_type)
+        if array.type != target_pa_type:
+            return array.cast(target_pa_type, safe=False)
+        return array
 
     def read_arrow_batch(self, start_idx=None, end_idx=None) -> 
Optional[RecordBatch]:
         if isinstance(self.format_reader, FormatBlobReader):
diff --git a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py 
b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
new file mode 100644
index 0000000000..8f042a9229
--- /dev/null
+++ b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
@@ -0,0 +1,64 @@
+# 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.
+
+from typing import List, Optional
+
+import pyarrow as pa
+import pyarrow.compute as pc
+from pyarrow import RecordBatch
+
+from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
+from pypaimon.schema.data_types import DataField, PyarrowFieldParser
+
+
+class NestedLeafBatchReader(RecordBatchReader):
+    """Extract projected nested leaves from batches of full top-level columns.
+
+    The inner reader yields batches carrying the widened top-level columns,
+    already normalized to the latest schema by field id (renames followed,
+    missing sub-fields padded NULL, types cast). Each requested name path is
+    walked through the struct children (a NULL parent propagates to the
+    leaf), producing the user's flat projected schema.
+    """
+
+    def __init__(self, inner: RecordBatchReader, name_paths: List[List[str]],
+                 output_fields: List[DataField]):
+        if len(name_paths) != len(output_fields):
+            raise ValueError(
+                "name_paths length {} does not match output_fields length 
{}".format(
+                    len(name_paths), len(output_fields)))
+        self._inner = inner
+        self._paths = name_paths
+        self._schema = PyarrowFieldParser.from_paimon_schema(output_fields)
+
+    def read_arrow_batch(self) -> Optional[RecordBatch]:
+        batch = self._inner.read_arrow_batch()
+        if batch is None:
+            return None
+        arrays = []
+        for i, path in enumerate(self._paths):
+            column = batch.column(path[0])
+            for name in path[1:]:
+                column = pc.struct_field(column, name)
+            target_type = self._schema.field(i).type
+            if column.type != target_type:
+                column = column.cast(target_type, safe=False)
+            arrays.append(column)
+        return pa.RecordBatch.from_arrays(arrays, schema=self._schema)
+
+    def close(self) -> None:
+        self._inner.close()
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index 543c858935..103d1e8510 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -627,6 +627,33 @@ class SplitRead(ABC):
 
 
 class RawFileSplitRead(SplitRead):
+    def __init__(
+            self,
+            table,
+            predicate: Optional[Predicate],
+            read_type: List[DataField],
+            split: Split,
+            row_tracking_enabled: bool,
+            outer_extract_name_paths: Optional[List[List[str]]] = None,
+            outer_flat_read_type: Optional[List[DataField]] = None,
+            limit: Optional[int] = None):
+        # Nested-leaf projection is NOT pushed down by name: a leaf path is
+        # only valid against the latest schema, while each data file stores
+        # its own (possibly renamed / retyped) sub-fields. Instead the read
+        # widens to the full top-level columns, which the per-file field-id
+        # normalization aligns to the latest schema, and the requested leaf
+        # paths are extracted afterwards (``outer_extract_name_paths``).
+        super().__init__(
+            table=table,
+            predicate=predicate,
+            read_type=read_type,
+            split=split,
+            row_tracking_enabled=row_tracking_enabled,
+            nested_name_paths=None,
+            limit=limit)
+        self.outer_extract_name_paths = outer_extract_name_paths
+        self.outer_flat_read_type = outer_flat_read_type
+
     def raw_reader_supplier(self, file: DataFileMeta, dv_factory: 
Optional[Callable] = None) -> Optional[RecordReader]:
         read_fields = self._get_final_read_data_fields()
         # Check if this is a SlicedSplit to get shard_file_idx_map
@@ -676,10 +703,43 @@ class RawFileSplitRead(SplitRead):
         # if the table is appendonly table, we don't need extra filter, all 
predicates has pushed down
         if self.table.is_primary_key_table and self.predicate_for_reader:
             reader = FilterRecordReader(concat_reader, 
self.predicate_for_reader)
+            if self.outer_extract_name_paths:
+                # Row-level extraction: the filter evaluates rows in the
+                # widened top-level coordinate space, so extract after it.
+                from pypaimon.read.reader.outer_projection_record_reader 
import \
+                    OuterProjectionRecordReader
+                reader = OuterProjectionRecordReader(
+                    reader, [f.name for f in self.read_fields],
+                    self.outer_extract_name_paths,
+                    file_io=self.table.file_io,
+                    blob_field_indices=_blob_field_indices(self.read_fields),
+                    
vector_field_indices=_vector_field_indices(self.read_fields))
             if self.limit is not None:
                 reader = LimitedRecordReader(reader, self.limit)
         else:
             reader = concat_reader
+            if self.outer_extract_name_paths:
+                from pypaimon.read.reader.nested_leaf_batch_reader import \
+                    NestedLeafBatchReader
+                reader = NestedLeafBatchReader(
+                    reader, self.outer_extract_name_paths,
+                    self.outer_flat_read_type)
+                # A predicate on a projected nested leaf cannot be pushed down:
+                # its leaf path is absent from the widened top-level read
+                # fields, so SplitRead.__init__ dropped it 
(predicate_for_reader
+                # is None). Without re-applying it the filter is silently lost
+                # and every row is returned. Re-evaluate it on the extracted
+                # flat batches, whose column names match the predicate fields;
+                # trim to the projected columns so a filter on a non-projected
+                # column keeps the existing "dropped" semantics rather than
+                # referencing a missing column.
+                if self.predicate is not None and self.predicate_for_reader is 
None:
+                    flat_names = [f.name for f in self.outer_flat_read_type]
+                    trimmed = trim_predicate_by_fields(self.predicate, 
flat_names)
+                    if trimmed is not None:
+                        from pypaimon.read.reader.filter_record_batch_reader \
+                            import FilterRecordBatchReader
+                        reader = FilterRecordBatchReader(reader, trimmed)
             if self.limit is not None:
                 reader = LimitedRecordBatchReader(reader, self.limit)
         return reader
@@ -699,6 +759,7 @@ class MergeFileSplitRead(SplitRead):
             split: Split,
             row_tracking_enabled: bool,
             outer_extract_name_paths: Optional[List[List[str]]] = None,
+            outer_flat_read_type: Optional[List[DataField]] = None,
             limit: Optional[int] = None):
         # Merge functions need full ROW sub-structures, so nested paths
         # are not pushed down here; sub-path extraction happens above
@@ -713,6 +774,7 @@ class MergeFileSplitRead(SplitRead):
             limit=limit,
         )
         self.outer_extract_name_paths = outer_extract_name_paths
+        self.outer_flat_read_type = outer_flat_read_type
         # Built once per split-read (value_fields and options are constant
         # for the object's life), not per section. ``None`` when
         # ``sequence.field`` is unset, in which case the heap falls back to
@@ -811,6 +873,21 @@ class MergeFileSplitRead(SplitRead):
                 file_io=self.table.file_io,
                 blob_field_indices=_blob_field_indices(inner_value_fields),
                 vector_field_indices=_vector_field_indices(inner_value_fields))
+            # A predicate on a projected nested leaf is not pushed down (its 
leaf
+            # path is absent from the widened-to-full-ROW read fields, so it 
was
+            # dropped in __init__). Without re-applying it after extraction the
+            # filter is silently lost. Evaluate it on the extracted flat rows,
+            # whose fields are outer_flat_read_type; trim to the projected
+            # columns and rewrite indices into that flat row.
+            if (self.predicate is not None and self.predicate_for_reader is 
None
+                    and self.outer_flat_read_type is not None):
+                flat_names = [f.name for f in self.outer_flat_read_type]
+                trimmed = trim_predicate_by_fields(self.predicate, flat_names)
+                if trimmed is not None:
+                    reader = FilterRecordReader(
+                        reader,
+                        rewrite_predicate_indices(
+                            trimmed, self.outer_flat_read_type))
         if self.limit is not None:
             reader = LimitedRecordReader(reader, self.limit)
         return reader
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 57e704b2b5..1c29025fdb 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -585,6 +585,8 @@ class TableRead:
                 split=split,
                 row_tracking_enabled=False,
                 outer_extract_name_paths=outer_extract_name_paths,
+                outer_flat_read_type=(
+                    self.read_type if outer_extract_name_paths else None),
                 limit=self.limit,
             )
         elif self.table.options.data_evolution_enabled():
@@ -603,13 +605,26 @@ class TableRead:
                 limit=self.limit,
             )
         else:
+            inner_read_type = self.read_type
+            outer_extract_name_paths: Optional[List[List[str]]] = None
+            if self.nested_name_paths and any(
+                    len(p) > 1 for p in self.nested_name_paths):
+                # Mirror the merge path: read the full top-level columns so
+                # the per-file field-id normalization applies (a leaf path is
+                # only valid against the latest schema, not each file's own
+                # names/types), then extract the requested sub-paths back to
+                # the user's flat schema.
+                inner_read_type = self._widen_to_top_level_for_merge()
+                outer_extract_name_paths = self.nested_name_paths
             return RawFileSplitRead(
                 table=self.table,
                 predicate=self.predicate,
-                read_type=self.read_type,
+                read_type=inner_read_type,
                 split=split,
                 row_tracking_enabled=self.table.options.row_tracking_enabled(),
-                nested_name_paths=self.nested_name_paths,
+                outer_extract_name_paths=outer_extract_name_paths,
+                outer_flat_read_type=(
+                    self.read_type if outer_extract_name_paths else None),
                 limit=self.limit,
             )
 
diff --git a/paimon-python/pypaimon/schema/data_types.py 
b/paimon-python/pypaimon/schema/data_types.py
index 9530996a9d..3ada7a62aa 100755
--- a/paimon-python/pypaimon/schema/data_types.py
+++ b/paimon-python/pypaimon/schema/data_types.py
@@ -25,6 +25,15 @@ from typing import Any, Dict, List, Optional, Union
 import pyarrow
 from pyarrow import types
 
+# Field ids at or above this value are reserved for system fields (sequence
+# number, value kind, row id, ...). User field ids stay strictly below it, so
+# the highest-user-field-id computation can ignore anything from here up.
+SYSTEM_FIELD_ID_START = 2147483647 // 2
+
+
+def is_system_field_id(field_id: int) -> bool:
+    return field_id >= SYSTEM_FIELD_ID_START
+
 
 class AtomicInteger:
 
@@ -370,6 +379,73 @@ class RowType(DataType):
         return "ROW<{}>{}".format(', '.join(field_strs), null_suffix)
 
 
+def reassign_field_id(data_type: DataType, field_id: "AtomicInteger") -> 
DataType:
+    """Return a copy of *data_type* with every nested field id reassigned from
+    *field_id*, depth-first with children allocated before their parent field.
+
+    Mirrors the canonical id-reassignment used when a column (possibly carrying
+    a nested ROW/ARRAY/MAP) is added, so nested subfields get globally-unique
+    ids drawn from the schema's running counter rather than struct-local ones.
+    """
+    if isinstance(data_type, RowType):
+        new_fields = []
+        for field in data_type.fields:
+            # Visit the nested type first, then allocate this field's id, so 
the
+            # ordering matches the rest of the engine ecosystem.
+            new_type = reassign_field_id(field.type, field_id)
+            new_id = field_id.increment_and_get()
+            new_fields.append(DataField(
+                new_id, field.name, new_type, field.description, 
field.default_value))
+        return RowType(data_type.nullable, new_fields)
+    if isinstance(data_type, ArrayType):
+        return ArrayType(data_type.nullable, 
reassign_field_id(data_type.element, field_id))
+    if isinstance(data_type, VectorType):
+        return VectorType(
+            data_type.nullable, reassign_field_id(data_type.element, 
field_id), data_type.length)
+    if isinstance(data_type, MultisetType):
+        return MultisetType(data_type.nullable, 
reassign_field_id(data_type.element, field_id))
+    if isinstance(data_type, MapType):
+        new_key = reassign_field_id(data_type.key, field_id)
+        new_value = reassign_field_id(data_type.value, field_id)
+        return MapType(data_type.nullable, new_key, new_value)
+    return data_type
+
+
+def collect_field_ids(data_type: DataType, field_ids: set):
+    """Collect all (nested) field ids reachable from *data_type* into 
*field_ids*,
+    raising on a duplicate id (a broken schema)."""
+    if isinstance(data_type, RowType):
+        for field in data_type.fields:
+            if field.id in field_ids:
+                raise ValueError(
+                    "Broken schema, field id {} is 
duplicated.".format(field.id))
+            field_ids.add(field.id)
+            collect_field_ids(field.type, field_ids)
+    elif isinstance(data_type, (ArrayType, VectorType, MultisetType)):
+        collect_field_ids(data_type.element, field_ids)
+    elif isinstance(data_type, MapType):
+        collect_field_ids(data_type.key, field_ids)
+        collect_field_ids(data_type.value, field_ids)
+
+
+def current_highest_field_id(fields: List[DataField]) -> int:
+    """Highest user field id across *fields*, recursing into nested 
ROW/ARRAY/MAP.
+
+    System field ids are excluded. Returns -1 for an empty/system-only schema.
+    The result is persisted as ``highestFieldId``; later schema changes seed
+    their id counter from the stored value (not from the live fields, since a
+    dropped field may have carried a higher id than any survivor).
+    """
+    field_ids = set()
+    for field in fields:
+        if field.id in field_ids:
+            raise ValueError("Broken schema, field id {} is 
duplicated.".format(field.id))
+        field_ids.add(field.id)
+        collect_field_ids(field.type, field_ids)
+    user_ids = [fid for fid in field_ids if not is_system_field_id(fid)]
+    return max(user_ids) if user_ids else -1
+
+
 class Keyword(Enum):
     CHAR = "CHAR"
     VARCHAR = "VARCHAR"
@@ -408,7 +484,18 @@ class DataTypeParser:
 
     @staticmethod
     def parse_atomic_type_sql_string(type_string: str) -> DataType:
+        nullable = DataTypeParser.parse_nullability(type_string)
         type_upper = type_string.upper().strip()
+        # Strip the trailing nullability suffix so it is stored only in
+        # ``nullable``, not baked into the atomic type string. The space-split
+        # branch below drops it for plain types ("BIGINT NOT NULL"), but a
+        # parameterized type ("DECIMAL(12, 2) NOT NULL", "VARCHAR(10) NOT 
NULL")
+        # takes the paren branch and would otherwise keep the suffix in
+        # ``AtomicType.type`` -- doubling it on the next ``to_dict()``.
+        for suffix in (" NOT NULL", " NULL"):
+            if type_upper.endswith(suffix):
+                type_upper = type_upper[: -len(suffix)].rstrip()
+                break
 
         if "(" in type_upper:
             base_type = type_upper.split("(")[0]
@@ -420,9 +507,7 @@ class DataTypeParser:
 
         try:
             Keyword(base_type)
-            return AtomicType(
-                type_upper, DataTypeParser.parse_nullability(type_string)
-            )
+            return AtomicType(type_upper, nullable)
         except ValueError:
             raise Exception("Unknown type: {}".format(base_type))
 
@@ -717,12 +802,21 @@ class PyarrowFieldParser:
 
     @staticmethod
     def to_paimon_schema(pa_schema: pyarrow.Schema) -> List[DataField]:
-        # Convert PyArrow schema to Paimon fields
+        # Convert PyArrow schema to Paimon fields, assigning globally-unique 
ids:
+        # each top-level field takes the next id, then its (possibly nested) 
type
+        # has its subfield ids reassigned from the same running counter. A flat
+        # schema keeps the plain 0,1,2,... ids; nested subfields get ids that 
do
+        # not collide with top-level ones.
+        field_id = AtomicInteger(-1)
         fields = []
-        for i, pa_field in enumerate(pa_schema):
+        for pa_field in pa_schema:
             pa_field: pyarrow.Field
-            data_field = PyarrowFieldParser.to_paimon_field(i, pa_field)
-            fields.append(data_field)
+            top_id = field_id.increment_and_get()
+            data_type = PyarrowFieldParser.to_paimon_type(pa_field.type, 
pa_field.nullable)
+            data_type = reassign_field_id(data_type, field_id)
+            description = pa_field.metadata.get(b'description', 
b'').decode('utf-8') \
+                if pa_field.metadata and b'description' in pa_field.metadata 
else None
+            fields.append(DataField(top_id, pa_field.name, data_type, 
description))
         return fields
 
     @staticmethod
diff --git a/paimon-python/pypaimon/schema/schema_manager.py 
b/paimon-python/pypaimon/schema/schema_manager.py
index d01549c71b..d82a4f8a20 100644
--- a/paimon-python/pypaimon/schema/schema_manager.py
+++ b/paimon-python/pypaimon/schema/schema_manager.py
@@ -26,7 +26,9 @@ from pypaimon.common.options import CoreOptions, Options
 from pypaimon.schema.column_directive_utils import (
     apply_add_column_directive, apply_directives,
     remove_dropped_directive_options)
-from pypaimon.schema.data_types import AtomicInteger, DataField
+from pypaimon.casting.data_type_casts import can_execute_cast, supports_cast
+from pypaimon.schema.data_types import (ArrayType, AtomicInteger, DataField,
+                                        MapType, RowType, reassign_field_id)
 from pypaimon.schema.schema import Schema
 from pypaimon.schema.schema_change import (AddColumn, DropColumn, RemoveOption,
                                            RenameColumn, SchemaChange,
@@ -44,6 +46,123 @@ def _find_field_index(fields: List[DataField], field_name: 
str) -> Optional[int]
     return None
 
 
+def _extract_row_data_fields(data_type, out_fields: List[DataField],
+                             field_names: List[str], token_pos: int) -> int:
+    """Collect the immediate sub-fields reachable from *data_type* into
+    *out_fields* and return the path depth consumed. A ROW contributes its
+    fields (depth 1); an ARRAY/MAP is transparent and descends into its
+    element/value, consuming the ``element``/``value`` path token -- the
+    consumed token is validated so an unknown step cannot silently mutate
+    the schema; anything else contributes nothing (depth 1)."""
+    if isinstance(data_type, RowType):
+        out_fields.extend(data_type.fields)
+        return 1
+    if isinstance(data_type, ArrayType):
+        _assert_wrapper_token(field_names, token_pos, 'element')
+        return _extract_row_data_fields(
+            data_type.element, out_fields, field_names, token_pos + 1) + 1
+    if isinstance(data_type, MapType):
+        _assert_wrapper_token(field_names, token_pos, 'value')
+        return _extract_row_data_fields(
+            data_type.value, out_fields, field_names, token_pos + 1) + 1
+    return 1
+
+
+def _assert_wrapper_token(field_names: List[str], token_pos: int, expected: 
str):
+    # A path that ends inside the wrappers (token_pos out of range) is the
+    # update-the-wrapped-type-itself case, handled by the caller's overflow
+    # branch; only a present-but-wrong token is rejected.
+    if token_pos < len(field_names) and field_names[token_pos] != expected:
+        raise ColumnNotExistException('.'.join(field_names))
+
+
+def _wrap_new_row_type(data_type, nested_fields: List[DataField]):
+    """Rebuild *data_type* substituting *nested_fields* at its innermost ROW,
+    preserving any ARRAY/MAP wrappers."""
+    if isinstance(data_type, RowType):
+        return RowType(data_type.nullable, nested_fields)
+    if isinstance(data_type, ArrayType):
+        return ArrayType(data_type.nullable, 
_wrap_new_row_type(data_type.element, nested_fields))
+    if isinstance(data_type, MapType):
+        return MapType(
+            data_type.nullable, data_type.key,
+            _wrap_new_row_type(data_type.value, nested_fields))
+    return data_type
+
+
+def _get_root_type(data_type, curr_depth: int, max_depth: int):
+    """Return the type sitting at ``max_depth`` when walking ARRAY/MAP wrappers
+    from *data_type* (e.g. the INT in ARRAY<MAP<STRING, ARRAY<INT>>>)."""
+    if curr_depth == max_depth - 1:
+        return data_type
+    if isinstance(data_type, ArrayType):
+        return _get_root_type(data_type.element, curr_depth + 1, max_depth)
+    if isinstance(data_type, MapType):
+        return _get_root_type(data_type.value, curr_depth + 1, max_depth)
+    return data_type
+
+
+def _get_array_map_type_with_target_type_root(source, target, curr_depth: int, 
max_depth: int):
+    """Rebuild *source* with *target* substituted at ``max_depth``, keeping the
+    ARRAY/MAP wrappers around it intact."""
+    if curr_depth == max_depth - 1:
+        return target
+    if isinstance(source, ArrayType):
+        return ArrayType(
+            source.nullable,
+            _get_array_map_type_with_target_type_root(
+                source.element, target, curr_depth + 1, max_depth))
+    if isinstance(source, MapType):
+        return MapType(
+            source.nullable, source.key,
+            _get_array_map_type_with_target_type_root(
+                source.value, target, curr_depth + 1, max_depth))
+    return target
+
+
+def _update_intermediate_column(new_fields, previous_fields, depth, prev_depth,
+                                field_names, update_last_fn):
+    """Walk *field_names* into nested ROW (transparently through ARRAY/MAP),
+    then run *update_last_fn(depth, fields, name)* on the field list that
+    directly contains the final path element, rebuilding parent types 
upward."""
+    if depth == len(field_names) - 1:
+        update_last_fn(depth, new_fields, field_names[depth])
+        return
+    if depth >= len(field_names):
+        # Path descended through ARRAY/MAP past the last ROW; operate on the
+        # field that owns the wrapper at the previous depth.
+        update_last_fn(prev_depth, previous_fields, field_names[prev_depth])
+        return
+    for i, field in enumerate(new_fields):
+        if field.name != field_names[depth]:
+            continue
+        nested_fields: List[DataField] = []
+        new_depth = depth + _extract_row_data_fields(
+            field.type, nested_fields, field_names, depth + 1)
+        _update_intermediate_column(
+            nested_fields, new_fields, new_depth, depth, field_names, 
update_last_fn)
+        field = new_fields[i]
+        new_fields[i] = DataField(
+            field.id, field.name,
+            _wrap_new_row_type(field.type, nested_fields),
+            field.description, field.default_value)
+        return
+    raise ColumnNotExistException('.'.join(field_names[:depth + 1]))
+
+
+def _modify_nested_column(new_fields, field_names, update_last_fn):
+    _update_intermediate_column(new_fields, new_fields, 0, 0, field_names, 
update_last_fn)
+
+
+def _update_nested_column(new_fields, field_names, update_func):
+    def update_last(depth, fields, field_name):
+        idx = _find_field_index(fields, field_name)
+        if idx is None:
+            raise ColumnNotExistException('.'.join(field_names))
+        fields[idx] = update_func(fields[idx], depth)
+    _modify_nested_column(new_fields, field_names, update_last)
+
+
 def _get_rename_mappings(changes: List[SchemaChange]) -> dict:
     rename_mappings = {}
     for change in changes:
@@ -55,49 +174,87 @@ def _get_rename_mappings(changes: List[SchemaChange]) -> 
dict:
 def _handle_update_column_comment(
     change: UpdateColumnComment, new_fields: List[DataField]
 ):
-    field_name = change.field_names[-1]
-    field_index = _find_field_index(new_fields, field_name)
-    if field_index is None:
-        raise ColumnNotExistException(field_name)
-    field = new_fields[field_index]
-    new_fields[field_index] = DataField(
-        field.id, field.name, field.type, change.new_comment, 
field.default_value
-    )
+    def update_func(field: DataField, depth: int) -> DataField:
+        return DataField(
+            field.id, field.name, field.type, change.new_comment, 
field.default_value
+        )
+    _update_nested_column(new_fields, change.field_names, update_func)
+
+
+def _assert_nullability_change(old_nullability: bool, new_nullability: bool,
+                               field_name: str, disable_null_to_not_null: 
bool):
+    if disable_null_to_not_null and old_nullability and not new_nullability:
+        raise ValueError(
+            "Cannot update column type from nullable to non nullable for {}. "
+            "You can set table configuration option "
+            "'alter-column-null-to-not-null.disabled' = 'false' "
+            "to allow converting null columns to not null".format(field_name)
+        )
 
 
 def _handle_update_column_nullability(
-    change: UpdateColumnNullability, new_fields: List[DataField]
+    change: UpdateColumnNullability, new_fields: List[DataField],
+    disable_null_to_not_null: bool
 ):
-    field_name = change.field_names[-1]
-    field_index = _find_field_index(new_fields, field_name)
-    if field_index is None:
-        raise ColumnNotExistException(field_name)
-    field = new_fields[field_index]
     from pypaimon.schema.data_types import DataTypeParser
-    field_type_dict = field.type.to_dict()
-    new_type = DataTypeParser.parse_data_type(field_type_dict)
-    new_type.nullable = change.new_nullability
-    new_fields[field_index] = DataField(
-        field.id, field.name, new_type, field.description, field.default_value
-    )
+    field_names = change.field_names
+    max_depth = len(field_names)
+
+    def update_func(field: DataField, depth: int) -> DataField:
+        source_root = _get_root_type(field.type, depth, max_depth)
+        _assert_nullability_change(
+            source_root.nullable, change.new_nullability,
+            '.'.join(field_names), disable_null_to_not_null)
+        new_root = DataTypeParser.parse_data_type(source_root.to_dict())
+        new_root.nullable = change.new_nullability
+        new_type = _get_array_map_type_with_target_type_root(
+            field.type, new_root, depth, max_depth)
+        return DataField(
+            field.id, field.name, new_type, field.description, 
field.default_value
+        )
+    _update_nested_column(new_fields, field_names, update_func)
 
 
 def _handle_update_column_type(
-    change: UpdateColumnType, new_fields: List[DataField]
+    change: UpdateColumnType, new_fields: List[DataField],
+    disable_null_to_not_null: bool
 ):
-    field_name = change.field_names[-1]
-    field_index = _find_field_index(new_fields, field_name)
-    if field_index is None:
-        raise ColumnNotExistException(field_name)
-    field = new_fields[field_index]
     from pypaimon.schema.data_types import DataTypeParser
-    new_type_dict = change.new_data_type.to_dict()
-    new_type = DataTypeParser.parse_data_type(new_type_dict)
-    if change.keep_nullability:
-        new_type.nullable = field.type.nullable
-    new_fields[field_index] = DataField(
-        field.id, field.name, new_type, field.description, field.default_value
-    )
+    field_names = change.field_names
+    max_depth = len(field_names)
+
+    def update_func(field: DataField, depth: int) -> DataField:
+        source_root = _get_root_type(field.type, depth, max_depth)
+        target_root = 
DataTypeParser.parse_data_type(change.new_data_type.to_dict())
+        if change.keep_nullability:
+            target_root.nullable = source_root.nullable
+        else:
+            # A type change carries its own nullability; guard nullable ->
+            # not null just like UpdateColumnNullability (mirrors Java
+            # SchemaManager#updateColumnType).
+            _assert_nullability_change(
+                source_root.nullable, target_root.nullable,
+                '.'.join(field_names), disable_null_to_not_null)
+        if not supports_cast(source_root, target_root):
+            raise ValueError(
+                "Column type {}[{}] cannot be converted to {} without losing 
information."
+                .format(field.name, source_root, target_root)
+            )
+        # Logical cast support is not enough: the read path materializes the
+        # change via PyArrow when reading old files, so reject casts it cannot
+        # execute (mirrors Java's CastExecutors.resolve(...) != null check).
+        if not can_execute_cast(source_root, target_root):
+            raise ValueError(
+                "Column type {}[{}] cannot be converted to {}: the read path "
+                "has no executable cast for this conversion."
+                .format(field.name, source_root, target_root)
+            )
+        new_type = _get_array_map_type_with_target_type_root(
+            field.type, target_root, depth, max_depth)
+        return DataField(
+            field.id, field.name, new_type, field.description, 
field.default_value
+        )
+    _update_nested_column(new_fields, field_names, update_func)
 
 
 def _drop_column_validation(schema: 'TableSchema', change: DropColumn):
@@ -112,17 +269,20 @@ def _drop_column_validation(schema: 'TableSchema', 
change: DropColumn):
 
 def _handle_drop_column(change: DropColumn, new_fields: List[DataField],
                         new_options: dict):
-    field_name = change.field_names[-1]
-    field_index = _find_field_index(new_fields, field_name)
-    if field_index is None:
-        raise ColumnNotExistException(field_name)
-    if len(change.field_names) == 1:
-        field = new_fields[field_index]
-        type_root = _get_type_root(field.type)
-        remove_dropped_directive_options(field_name, type_root, new_options)
-    new_fields.pop(field_index)
-    if not new_fields:
-        raise ValueError("Cannot drop all fields in table")
+    field_names = change.field_names
+
+    def update_last(depth, fields, field_name):
+        field_index = _find_field_index(fields, field_name)
+        if field_index is None:
+            raise ColumnNotExistException(field_name)
+        if len(field_names) == 1:
+            field = fields[field_index]
+            type_root = _get_type_root(field.type)
+            remove_dropped_directive_options(field_name, type_root, 
new_options)
+        fields.pop(field_index)
+        if not fields:
+            raise ValueError("Cannot drop all fields in table")
+    _modify_nested_column(new_fields, field_names, update_last)
 
 
 def _get_type_root(data_type) -> str:
@@ -274,17 +434,19 @@ def _validate_blob_external_storage_fields(fields: 
List[DataField], options: dic
 
 
 def _handle_rename_column(change: RenameColumn, new_fields: List[DataField]):
-    field_name = change.field_names[-1]
     new_name = change.new_name
-    field_index = _find_field_index(new_fields, field_name)
-    if field_index is None:
-        raise ColumnNotExistException(field_name)
-    if _find_field_index(new_fields, new_name) is not None:
-        raise ColumnAlreadyExistException(new_name)
-    field = new_fields[field_index]
-    new_fields[field_index] = DataField(
-        field.id, new_name, field.type, field.description, field.default_value
-    )
+
+    def update_last(depth, fields, field_name):
+        field_index = _find_field_index(fields, field_name)
+        if field_index is None:
+            raise ColumnNotExistException(field_name)
+        if _find_field_index(fields, new_name) is not None:
+            raise ColumnAlreadyExistException(new_name)
+        field = fields[field_index]
+        fields[field_index] = DataField(
+            field.id, new_name, field.type, field.description, 
field.default_value
+        )
+    _modify_nested_column(new_fields, change.field_names, update_last)
 
 
 def _apply_move(fields: List[DataField], new_field: Optional[DataField], move):
@@ -332,11 +494,11 @@ def _handle_add_column(
             f"Column {'.'.join(change.field_names)} cannot specify NOT NULL in 
the table."
         )
     field_id = highest_field_id.increment_and_get()
+    # Reassign ids of any nested sub-fields the new column carries (a 
ROW/ARRAY/
+    # MAP type) so they draw globally-unique ids from the running counter.
+    data_type = reassign_field_id(change.data_type, highest_field_id)
     field_name = change.field_names[-1]
-    if _find_field_index(new_fields, field_name) is not None:
-        raise ColumnAlreadyExistException(field_name)
 
-    data_type = change.data_type
     comment = change.comment
     converted = apply_add_column_directive(comment, field_name, data_type, 
new_options)
     if converted is not None:
@@ -349,21 +511,26 @@ def _handle_add_column(
         comment = converted.comment
 
     new_field = DataField(field_id, field_name, data_type, comment)
-    if change.move:
-        _apply_move(new_fields, new_field, change.move)
-    elif (
-        add_column_before_partition
-        and partition_keys
-        and len(change.field_names) == 1
-    ):
-        insert_index = len(new_fields)
-        for i, field in enumerate(new_fields):
-            if field.name in partition_keys:
-                insert_index = i
-                break
-        new_fields.insert(insert_index, new_field)
-    else:
-        new_fields.append(new_field)
+
+    def update_last(depth, fields, fname):
+        if _find_field_index(fields, fname) is not None:
+            raise ColumnAlreadyExistException(fname)
+        if change.move:
+            _apply_move(fields, new_field, change.move)
+        elif (
+            add_column_before_partition
+            and partition_keys
+            and len(change.field_names) == 1
+        ):
+            insert_index = len(fields)
+            for i, field in enumerate(fields):
+                if field.name in partition_keys:
+                    insert_index = i
+                    break
+            fields.insert(insert_index, new_field)
+        else:
+            fields.append(new_field)
+    _modify_nested_column(new_fields, change.field_names, update_last)
 
 
 class SchemaManager:
@@ -517,6 +684,10 @@ class SchemaManager:
         # Get add_column_before_partition option
         add_column_before_partition = 
CoreOptions(Options(old_table_schema.options)).add_column_before_partition()
         partition_keys = old_table_schema.partition_keys
+        # Converting a nullable column to NOT NULL is unsafe for existing
+        # data and is disabled by default; the table option below opts in.
+        disable_null_to_not_null = str(old_table_schema.options.get(
+            'alter-column-null-to-not-null.disabled', 'true')).lower() != 
'false'
 
         for change in changes:
             if isinstance(change, SetOption):
@@ -547,13 +718,15 @@ class SchemaManager:
                 _assert_not_updating_primary_keys(
                     old_table_schema, change.field_names, "update"
                 )
-                _handle_update_column_type(change, new_fields)
+                _handle_update_column_type(
+                    change, new_fields, disable_null_to_not_null)
             elif isinstance(change, UpdateColumnNullability):
                 if change.new_nullability:
                     _assert_not_updating_primary_keys(
                         old_table_schema, change.field_names, "change 
nullability of"
                     )
-                _handle_update_column_nullability(change, new_fields)
+                _handle_update_column_nullability(
+                    change, new_fields, disable_null_to_not_null)
             elif isinstance(change, UpdateColumnComment):
                 _handle_update_column_comment(change, new_fields)
             elif isinstance(change, UpdateColumnPosition):
diff --git a/paimon-python/pypaimon/schema/table_schema.py 
b/paimon-python/pypaimon/schema/table_schema.py
index 52324ae7f6..d8586b28d1 100644
--- a/paimon-python/pypaimon/schema/table_schema.py
+++ b/paimon-python/pypaimon/schema/table_schema.py
@@ -23,7 +23,7 @@ from typing import Dict, List, Optional
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.file_io import FileIO
 from pypaimon.common.json_util import json_field
-from pypaimon.schema.data_types import DataField
+from pypaimon.schema.data_types import DataField, current_highest_field_id
 from pypaimon.schema.schema import Schema
 
 
@@ -115,7 +115,7 @@ class TableSchema:
         partition_keys: List[str] = schema.partition_keys
         primary_keys: List[str] = schema.primary_keys
         options: Dict[str, str] = schema.options
-        highest_field_id: int = max((field.id for field in fields), default=0)
+        highest_field_id: int = current_highest_field_id(fields)
 
         return TableSchema(
             TableSchema.CURRENT_VERSION,
diff --git a/paimon-python/pypaimon/table/special_fields.py 
b/paimon-python/pypaimon/table/special_fields.py
index 64d2429bef..94dbfacc9a 100644
--- a/paimon-python/pypaimon/table/special_fields.py
+++ b/paimon-python/pypaimon/table/special_fields.py
@@ -17,7 +17,7 @@
 
 from typing import List
 
-from ..schema.data_types import AtomicType, DataField
+from ..schema.data_types import AtomicType, DataField, SYSTEM_FIELD_ID_START
 
 
 class SpecialFields:
@@ -35,11 +35,18 @@ class SpecialFields:
         '_ROW_ID'
     }
 
+    SYSTEM_FIELD_ID_START = SYSTEM_FIELD_ID_START
+
     @staticmethod
     def is_system_field(field_name: str) -> bool:
         """Check if a field is a system field."""
         return field_name in SpecialFields.SYSTEM_FIELD_NAMES
 
+    @staticmethod
+    def is_system_field_id(field_id: int) -> bool:
+        """Check if a field id is reserved for system fields."""
+        return field_id >= SYSTEM_FIELD_ID_START
+
     @staticmethod
     def find_system_fields(read_fields: List[DataField]) -> dict:
         """Find system fields in read fields and return a mapping of field 
name to index."""
diff --git a/paimon-python/pypaimon/tests/data_types_test.py 
b/paimon-python/pypaimon/tests/data_types_test.py
index 5c8d86e55b..fb817ba942 100755
--- a/paimon-python/pypaimon/tests/data_types_test.py
+++ b/paimon-python/pypaimon/tests/data_types_test.py
@@ -42,6 +42,23 @@ class DataTypesTest(unittest.TestCase):
         self.assertEqual(str(AtomicType("INT")),
                          
str(AtomicType.from_dict(AtomicType("INT").to_dict())))
 
+    def test_parameterized_atomic_type_not_null_roundtrip(self):
+        # ``to_dict`` appends " NOT NULL" to the type string; the parser must
+        # strip it back into ``nullable`` instead of keeping it inside
+        # ``AtomicType.type``. Parameterized types take the paren branch where
+        # this used to be missed, so a re-serialize doubled the suffix and
+        # ``from_paimon_type`` blew up with "... NOT NULL NOT NULL".
+        for type_str in ("DECIMAL(12, 2)", "VARCHAR(10)", "CHAR(5)",
+                         "TIMESTAMP(3)", "TIME(0)", "BINARY(12)"):
+            original = AtomicType(type_str, nullable=False)
+            parsed = AtomicType.from_dict(original.to_dict())
+            self.assertEqual(parsed.type, type_str, type_str)
+            self.assertFalse(parsed.nullable, type_str)
+            self.assertEqual(parsed, original, type_str)
+            # Round-trips stably and stays materializable as a PyArrow type.
+            self.assertEqual(parsed.to_dict(), original.to_dict(), type_str)
+            PyarrowFieldParser.from_paimon_type(parsed)
+
     @parameterized.expand([
         (ArrayType, AtomicType("TIMESTAMP(6)"), "ARRAY<TIMESTAMP(6)>", 
"ARRAY<ARRAY<TIMESTAMP(6)>>"),
         (MultisetType, AtomicType("TIMESTAMP(6)"), "MULTISET<TIMESTAMP(6)>", 
"MULTISET<MULTISET<TIMESTAMP(6)>>")
diff --git a/paimon-python/pypaimon/tests/filesystem_catalog_test.py 
b/paimon-python/pypaimon/tests/filesystem_catalog_test.py
index 5f5f216794..0251e7fd88 100644
--- a/paimon-python/pypaimon/tests/filesystem_catalog_test.py
+++ b/paimon-python/pypaimon/tests/filesystem_catalog_test.py
@@ -219,6 +219,115 @@ class FileSystemCatalogTest(unittest.TestCase):
         table = catalog.get_table(identifier)
         self.assertEqual(len(table.fields), 2)
 
+    def test_update_column_type_guards_null_to_not_null(self):
+        catalog = CatalogFactory.create({"warehouse": self.warehouse})
+        catalog.create_database("test_db_guard", False)
+
+        def _make_table(name, options):
+            identifier = "test_db_guard.{}".format(name)
+            schema = Schema(
+                fields=[
+                    DataField.from_dict({"id": 0, "name": "k", "type": "INT"}),
+                    DataField.from_dict({"id": 1, "name": "v", "type": 
"BIGINT"}),
+                ],
+                partition_keys=[], primary_keys=[], options=options, 
comment="",
+            )
+            catalog.create_table(identifier, schema, False)
+            return identifier
+
+        # Default option (disabled=true) rejects nullable -> not null, 
mirroring
+        # Java SchemaManager#updateColumnType.
+        default_id = _make_table("default_opt", {})
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                default_id,
+                [SchemaChange.update_column_type(
+                    "v", AtomicType("BIGINT", nullable=False))],
+                False)
+        self.assertIn("nullable to non nullable", str(ctx.exception))
+
+        # Opting out via the table option allows the transition.
+        allowed_id = _make_table(
+            "allow_opt", {"alter-column-null-to-not-null.disabled": "false"})
+        catalog.alter_table(
+            allowed_id,
+            [SchemaChange.update_column_type(
+                "v", AtomicType("BIGINT", nullable=False))],
+            False)
+        table = catalog.get_table(allowed_id)
+        self.assertFalse(table.fields[1].type.nullable)
+
+    def test_update_column_type_rejects_non_executable_cast(self):
+        catalog = CatalogFactory.create({"warehouse": self.warehouse})
+        catalog.create_database("test_db_cast", False)
+
+        identifier = "test_db_cast.ts_table"
+        schema = Schema(
+            fields=[
+                DataField.from_dict({"id": 0, "name": "k", "type": "INT"}),
+                DataField.from_dict({"id": 1, "name": "ts", "type": 
"TIMESTAMP(3)"}),
+            ],
+            partition_keys=[], primary_keys=[], options={}, comment="",
+        )
+        catalog.create_table(identifier, schema, False)
+
+        # TIMESTAMP -> DECIMAL is logically allowed but has no PyArrow cast
+        # kernel, so the read path could not materialize it. Reject at alter
+        # time (mirrors Java's CastExecutors.resolve(...) != null check) 
instead
+        # of failing later at read with ArrowNotImplementedError.
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                identifier,
+                [SchemaChange.update_column_type(
+                    "ts", AtomicType("DECIMAL(10, 0)"))],
+                False)
+        self.assertIn("no executable cast", str(ctx.exception))
+
+        # INT -> DECIMAL(10, 2) has a PyArrow kernel but the target precision 
is
+        # too small to hold an int's range at scale 2 (needs >= 12); the read
+        # path would fail with ArrowInvalid, so reject it at alter time too.
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                identifier,
+                [SchemaChange.update_column_type(
+                    "k", AtomicType("DECIMAL(10, 2)"))],
+                False)
+        self.assertIn("no executable cast", str(ctx.exception))
+
+        # A wide-enough DECIMAL is executable and succeeds.
+        catalog.alter_table(
+            identifier,
+            [SchemaChange.update_column_type("k", AtomicType("DECIMAL(12, 
2)"))],
+            False)
+        table = catalog.get_table(identifier)
+        self.assertEqual(table.fields[0].type.type, "DECIMAL(12, 2)")
+
+    def test_update_column_type_parameterized_not_null_target(self):
+        catalog = CatalogFactory.create({"warehouse": self.warehouse})
+        catalog.create_database("test_db_nn_param", False)
+        identifier = "test_db_nn_param.t"
+        schema = Schema(
+            fields=[
+                DataField.from_dict({"id": 0, "name": "v", "type": "INT NOT 
NULL"}),
+                DataField.from_dict({"id": 1, "name": "s", "type": "STRING"}),
+            ],
+            partition_keys=[], primary_keys=[], options={}, comment="",
+        )
+        catalog.create_table(identifier, schema, False)
+
+        # Widening a non-null INT to a non-null DECIMAL(12, 2) is valid. The
+        # target's to_dict() is "DECIMAL(12, 2) NOT NULL"; the atomic parser 
must
+        # keep the nullability in `nullable` so the executable-cast check does
+        # not choke on a doubled "NOT NULL NOT NULL" type string.
+        catalog.alter_table(
+            identifier,
+            [SchemaChange.update_column_type(
+                "v", AtomicType("DECIMAL(12, 2)", nullable=False))],
+            False)
+        table = catalog.get_table(identifier)
+        self.assertEqual(table.fields[0].type.type, "DECIMAL(12, 2)")
+        self.assertFalse(table.fields[0].type.nullable)
+
     def test_add_column_before_partition(self):
         catalog = CatalogFactory.create({
             "warehouse": self.warehouse
diff --git a/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py 
b/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
index c9147b823f..7a9971c449 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
@@ -22,11 +22,10 @@ Two layers are covered:
 * Whole-column evolution of a top-level struct/array/map column
   (add / drop / rename / projection) -- aligned by the column's field id.
 * Sub-field evolution INSIDE a struct (add/rename/update-type/drop a nested
-  field via a dotted ``field_names`` path) -- this is NOT implemented:
-  ``schema_manager`` only operates on the top-level ``field_names[-1]``.
-  ``SchemaEvolutionNestedGapTest`` locks in the current behaviour with
-  explicit assertions so the gap is documented and any future fix is
-  noticed.
+  field via a dotted ``field_names`` path), including sub-fields of a ROW
+  nested in an ARRAY/MAP. Sub-fields are aligned by field id, so a rename
+  follows the data, an added sub-field reads NULL for old rows, a dropped one
+  is not revived, and a type change is cast at read time.
 """
 
 import os
@@ -37,7 +36,13 @@ import unittest
 import pyarrow as pa
 
 from pypaimon import CatalogFactory, Schema
-from pypaimon.schema.data_types import AtomicType, PyarrowFieldParser
+from pypaimon.casting.data_type_casts import can_execute_cast, supports_cast
+from pypaimon.schema.data_types import (ArrayType, AtomicInteger, AtomicType,
+                                        DataField, MapType, MultisetType,
+                                        PyarrowFieldParser, RowType,
+                                        VectorType, collect_field_ids,
+                                        current_highest_field_id,
+                                        reassign_field_id)
 from pypaimon.schema.schema_change import SchemaChange
 
 
@@ -240,18 +245,12 @@ class SchemaEvolutionNestedReadTest(_NestedBase):
             {'id': 2, 'mv2': {'latest_version': 200, 'latest_value': 'b'}, 
'val': 'y'}])
 
 
-class SchemaEvolutionNestedGapTest(_NestedBase):
-    """Sub-field-level evolution inside a struct is NOT implemented.
+class SchemaEvolutionNestedSubfieldTest(_NestedBase):
+    """Sub-field evolution inside a struct, aligned by field id."""
 
-    schema_manager handles only the top-level ``field_names[-1]``; a dotted
-    path like ``['mv', 'latest_value']`` never recurses into the RowType.
-    These tests assert the current behaviour (silent top-level mutation, or
-    ColumnNotExistException) so the gap is documented.
-    """
-
-    def _create_struct_table(self, name):
+    def _create_struct_table(self, name, primary_keys=None, bucket='-1'):
         s0 = pa.schema([('id', pa.int64()), ('mv', _MV_PA), ('val', 
pa.string())])
-        table = self._create(name, s0)
+        table = self._create(name, s0, primary_keys=primary_keys, 
bucket=bucket)
         self._write(table, pa.Table.from_pylist([
             {'id': 1, 'mv': {'latest_version': 100, 'latest_value': 'a'}, 
'val': 'x'},
         ], schema=s0))
@@ -268,49 +267,512 @@ class SchemaEvolutionNestedGapTest(_NestedBase):
             'default.{}'.format(table_name)).table_schema
         return [f.name for f in schema.fields]
 
-    # -- C7: add nested sub-field -> silently adds a TOP-LEVEL column ----
+    def test_add_subfield_goes_inside_struct_and_pads_null(self):
+        table = self._create_struct_table('nsub_add')
+        self.catalog.alter_table(
+            'default.nsub_add',
+            [SchemaChange.add_column(['mv', 'score'], AtomicType('INT'))], 
False)
+        # The sub-field lands inside mv, not as a stray top-level column.
+        self.assertEqual(self._mv_subfield_names('nsub_add'),
+                         ['latest_version', 'latest_value', 'score'])
+        self.assertNotIn('score', self._top_level_names('nsub_add'))
+
+        table = self.catalog.get_table('default.nsub_add')
+        s1 = pa.schema([
+            ('id', pa.int64()),
+            ('mv', pa.struct([('latest_version', pa.int64()),
+                              ('latest_value', pa.string()),
+                              ('score', pa.int32())])),
+            ('val', pa.string())])
+        self._write(table, pa.Table.from_pylist([
+            {'id': 2, 'mv': {'latest_version': 200, 'latest_value': 'b', 
'score': 7},
+             'val': 'y'}], schema=s1))
+        rows = self._read_sorted(table)
+        # Old row reads NULL for the added sub-field; new row carries it.
+        self.assertEqual(rows[0]['mv'],
+                         {'latest_version': 100, 'latest_value': 'a', 'score': 
None})
+        self.assertEqual(rows[1]['mv'],
+                         {'latest_version': 200, 'latest_value': 'b', 'score': 
7})
+
+    def test_rename_subfield_follows_field_id(self):
+        table = self._create_struct_table('nsub_rename')
+        self.catalog.alter_table(
+            'default.nsub_rename',
+            [SchemaChange.rename_column(['mv', 'latest_value'], 'lv')], False)
+        self.assertEqual(self._mv_subfield_names('nsub_rename'),
+                         ['latest_version', 'lv'])
+        table = self.catalog.get_table('default.nsub_rename')
+        rows = self._read_sorted(table)
+        # Old data follows the renamed sub-field by id, not by name.
+        self.assertEqual(rows[0]['mv'], {'latest_version': 100, 'lv': 'a'})
 
-    def test_nested_add_subfield_mutates_top_level(self):
-        # GAP: add_column(['mv','new_inner']) does NOT add new_inner inside
-        # mv; it silently appends a top-level column 'new_inner' instead.
-        self._create_struct_table('gap_add')
+    def test_update_subfield_type_casts(self):
+        s0 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('v', pa.int32()), ('s', 
pa.string())]))])
+        table = self._create('nsub_type', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'v': 10, 's': 'a'}}], schema=s0))
         self.catalog.alter_table(
-            'default.gap_add',
-            [SchemaChange.add_column(['mv', 'new_inner'], AtomicType('INT'))],
-            False)
-        # mv's sub-fields are unchanged; a stray top-level column appeared.
-        self.assertEqual(self._mv_subfield_names('gap_add'),
-                         ['latest_version', 'latest_value'])
-        self.assertIn('new_inner', self._top_level_names('gap_add'))
+            'default.nsub_type',
+            [SchemaChange.update_column_type(['mv', 'v'], 
AtomicType('BIGINT'))], False)
+        table = self.catalog.get_table('default.nsub_type')
+        rb = table.new_read_builder()
+        splits = rb.new_scan().plan().splits()
+        arrow = rb.new_read().to_arrow(splits)
+        self.assertEqual(arrow.schema.field('mv').type.field('v').type, 
pa.int64())
+        self.assertEqual(arrow.to_pylist()[0]['mv'], {'v': 10, 's': 'a'})
 
-    # -- C8/C9/C10: rename / update-type / drop nested sub-field ---------
+    def test_drop_subfield_not_revived(self):
+        table = self._create_struct_table('nsub_drop')
+        self.catalog.alter_table(
+            'default.nsub_drop',
+            [SchemaChange.drop_column(['mv', 'latest_value'])], False)
+        self.assertEqual(self._mv_subfield_names('nsub_drop'), 
['latest_version'])
+        table = self.catalog.get_table('default.nsub_drop')
+        rows = self._read_sorted(table)
+        # The dropped sub-field's old data is gone, not revived under its id.
+        self.assertEqual(rows[0]['mv'], {'latest_version': 100})
 
-    def test_nested_rename_subfield_raises(self):
-        # GAP: field_names[-1]='latest_value' is looked up at the TOP level,
-        # where it does not exist -> ColumnNotExistException (wrapped).
-        self._create_struct_table('gap_rename')
+    def test_drop_all_subfields_rejected(self):
+        self._create_struct_table('nsub_dropall')
+        self.catalog.alter_table(
+            'default.nsub_dropall',
+            [SchemaChange.drop_column(['mv', 'latest_value'])], False)
+        with self.assertRaises(RuntimeError):
+            self.catalog.alter_table(
+                'default.nsub_dropall',
+                [SchemaChange.drop_column(['mv', 'latest_version'])], False)
+
+    def test_null_to_not_null_disabled_by_default(self):
+        # Converting nullable -> NOT NULL is unsafe for existing data and is
+        # rejected unless the table opts in via
+        # 'alter-column-null-to-not-null.disabled' = 'false'.
+        self._create_struct_table('nsub_nullability')
         with self.assertRaises(RuntimeError) as cm:
             self.catalog.alter_table(
-                'default.gap_rename',
-                [SchemaChange.rename_column(['mv', 'latest_value'], 'lv')], 
False)
-        self.assertIn('latest_value', str(cm.exception))
+                'default.nsub_nullability',
+                [SchemaChange.update_column_nullability(
+                    ['mv', 'latest_value'], False)], False)
+        self.assertIn('nullable to non nullable', str(cm.exception))
+        # Opt-in makes the same change succeed.
+        self.catalog.alter_table(
+            'default.nsub_nullability',
+            [SchemaChange.set_option(
+                'alter-column-null-to-not-null.disabled', 'false')], False)
+        self.catalog.alter_table(
+            'default.nsub_nullability',
+            [SchemaChange.update_column_nullability(
+                ['mv', 'latest_value'], False)], False)
+        schema = 
self.catalog.get_table('default.nsub_nullability').table_schema
+        mv = next(f for f in schema.fields if f.name == 'mv')
+        lv = next(sf for sf in mv.type.fields if sf.name == 'latest_value')
+        self.assertFalse(lv.type.nullable)
 
-    def test_nested_update_subfield_type_raises(self):
-        self._create_struct_table('gap_update')
+    def test_unsupported_subfield_cast_rejected(self):
+        self._create_struct_table('nsub_badcast')
         with self.assertRaises(RuntimeError) as cm:
             self.catalog.alter_table(
-                'default.gap_update',
+                'default.nsub_badcast',
                 [SchemaChange.update_column_type(
-                    ['mv', 'latest_version'], AtomicType('BIGINT'))], False)
-        self.assertIn('latest_version', str(cm.exception))
+                    ['mv', 'latest_version'], AtomicType('DATE'))], False)
+        self.assertIn('cannot be converted', str(cm.exception))
+
+    def test_nested_projection_after_rename_subfield(self):
+        # Projecting a renamed leaf must follow the field id into old files,
+        # not look the new name up in the file's physical schema.
+        s0 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('v', pa.int32()), ('s', 
pa.string())]))])
+        table = self._create('nsub_proj_rename', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'v': 10, 's': 'a'}}], schema=s0))
+        self.catalog.alter_table(
+            'default.nsub_proj_rename',
+            [SchemaChange.rename_column(['mv', 's'], 'ss')], False)
+        table = self.catalog.get_table('default.nsub_proj_rename')
+        s1 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('v', pa.int32()), ('ss', 
pa.string())]))])
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 2, 'mv': {'v': 20, 'ss': 'b'}}], schema=s1))
+
+        rows = self._read_sorted(table, projection=['id', 'mv.ss'])
+        self.assertEqual(rows, [
+            {'id': 1, 'mv_ss': 'a'},
+            {'id': 2, 'mv_ss': 'b'},
+        ])
+
+    def test_nested_projection_after_update_subfield_type(self):
+        # Projecting a type-changed leaf must cast old batches to the latest
+        # type instead of emitting mixed-type batches.
+        s0 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('v', pa.int32()), ('s', 
pa.string())]))])
+        table = self._create('nsub_proj_type', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'v': 10, 's': 'a'}}], schema=s0))
+        self.catalog.alter_table(
+            'default.nsub_proj_type',
+            [SchemaChange.update_column_type(['mv', 'v'], 
AtomicType('BIGINT'))], False)
+        table = self.catalog.get_table('default.nsub_proj_type')
+        s1 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('v', pa.int64()), ('s', 
pa.string())]))])
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 2, 'mv': {'v': 20, 's': 'b'}}], schema=s1))
+
+        rb = table.new_read_builder().with_projection(['id', 'mv.v'])
+        splits = rb.new_scan().plan().splits()
+        arrow = rb.new_read().to_arrow(splits)
+        self.assertEqual(arrow.schema.field('mv_v').type, pa.int64())
+        rows = sorted(arrow.to_pylist(), key=lambda r: r['id'])
+        self.assertEqual(rows, [
+            {'id': 1, 'mv_v': 10},
+            {'id': 2, 'mv_v': 20},
+        ])
+
+    def test_pk_nested_subfield_evolution_merge(self):
+        s0 = pa.schema([('id', pa.int64()), ('mv', _MV_PA)])
+        table = self._create('nsub_pk', s0, primary_keys=['id'], bucket='1')
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'latest_version': 1, 'latest_value': 'a'}}], 
schema=s0))
+        self.catalog.alter_table(
+            'default.nsub_pk',
+            [SchemaChange.add_column(['mv', 'score'], AtomicType('INT'))], 
False)
+        table = self.catalog.get_table('default.nsub_pk')
+        s1 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('latest_version', pa.int64()),
+                                          ('latest_value', pa.string()),
+                                          ('score', pa.int32())]))])
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'latest_version': 2, 'latest_value': 'b', 
'score': 9}}],
+            schema=s1))
+        rows = self._read_sorted(table)
+        self.assertEqual(len(rows), 1)
+        self.assertEqual(rows[0]['mv'],
+                         {'latest_version': 2, 'latest_value': 'b', 'score': 
9})
+
+
+class SchemaEvolutionNestedContainerTest(_NestedBase):
+    """Sub-field evolution of a ROW nested inside an ARRAY / MAP."""
+
+    def test_array_of_row_add_and_rename_subfield(self):
+        elem = pa.struct([('a', pa.int64()), ('b', pa.string())])
+        s0 = pa.schema([('id', pa.int64()), ('arr', pa.list_(elem))])
+        table = self._create('narr', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'arr': [{'a': 1, 'b': 'x'}, {'a': 2, 'b': 'y'}]}], 
schema=s0))
+        # Descend through the array element into the ROW.
+        self.catalog.alter_table('default.narr', [
+            SchemaChange.add_column(['arr', 'element', 'c'], 
AtomicType('INT')),
+            SchemaChange.rename_column(['arr', 'element', 'b'], 'bb'),
+        ], False)
+        table = self.catalog.get_table('default.narr')
+        rows = self._read_sorted(table)
+        self.assertEqual(rows[0]['arr'],
+                         [{'a': 1, 'bb': 'x', 'c': None},
+                          {'a': 2, 'bb': 'y', 'c': None}])
+
+    def test_map_of_row_add_subfield(self):
+        val = pa.struct([('a', pa.int64()), ('b', pa.string())])
+        s0 = pa.schema([('id', pa.int64()), ('m', pa.map_(pa.string(), val))])
+        table = self._create('nmap', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'm': [('k', {'a': 1, 'b': 'x'})]}], schema=s0))
+        # Descend through the map value into the ROW.
+        self.catalog.alter_table(
+            'default.nmap',
+            [SchemaChange.add_column(['m', 'value', 'c'], AtomicType('INT'))], 
False)
+        table = self.catalog.get_table('default.nmap')
+        rows = self._read_sorted(table)
+        self.assertEqual(rows[0]['m'], [('k', {'a': 1, 'b': 'x', 'c': None})])
+
+    def test_array_wrapper_token_validated(self):
+        # The token consumed when descending through an ARRAY must be
+        # 'element'; an unknown step must not silently mutate the schema.
+        elem = pa.struct([('a', pa.int64())])
+        s0 = pa.schema([('id', pa.int64()), ('arr', pa.list_(elem))])
+        self._create('ntok_arr', s0)
+        with self.assertRaises(RuntimeError) as cm:
+            self.catalog.alter_table(
+                'default.ntok_arr',
+                [SchemaChange.add_column(['arr', 'wrong', 'c'], 
AtomicType('INT'))],
+                False)
+        self.assertIn('arr.wrong.c', str(cm.exception))
+        # The canonical token still works.
+        self.catalog.alter_table(
+            'default.ntok_arr',
+            [SchemaChange.add_column(['arr', 'element', 'c'], 
AtomicType('INT'))],
+            False)
 
-    def test_nested_drop_subfield_raises(self):
-        self._create_struct_table('gap_drop')
+    def test_array_element_type_update(self):
+        # The canonical path for promoting an array's element type descends
+        # through the 'element' token; old files are cast at read time.
+        s0 = pa.schema([('id', pa.int64()), ('a2', pa.list_(pa.int32()))])
+        table = self._create('nelem_type', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'a2': [1, 2]}], schema=s0))
+        self.catalog.alter_table(
+            'default.nelem_type',
+            [SchemaChange.update_column_type(['a2', 'element'], 
AtomicType('BIGINT'))],
+            False)
+        table = self.catalog.get_table('default.nelem_type')
+        s1 = pa.schema([('id', pa.int64()), ('a2', pa.list_(pa.int64()))])
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 2, 'a2': [3]}], schema=s1))
+        rb = table.new_read_builder()
+        splits = rb.new_scan().plan().splits()
+        arrow = rb.new_read().to_arrow(splits)
+        self.assertEqual(arrow.schema.field('a2').type, pa.list_(pa.int64()))
+        rows = sorted(arrow.to_pylist(), key=lambda r: r['id'])
+        self.assertEqual(rows, [{'id': 1, 'a2': [1, 2]}, {'id': 2, 'a2': [3]}])
+
+    def test_whole_struct_type_replacement_rejected(self):
+        # Replacing a whole ROW type would carry caller-supplied nested ids
+        # that corrupt the id model; it must be rejected at alter time.
+        elem = pa.struct([('a', pa.int32()), ('b', pa.string())])
+        s0 = pa.schema([('id', pa.int64()), ('mv', elem)])
+        self._create('nrow_replace', s0)
+        new_row = _paimon_type(pa.struct([('a', pa.int64()), ('c', 
pa.string())]))
         with self.assertRaises(RuntimeError) as cm:
             self.catalog.alter_table(
-                'default.gap_drop',
-                [SchemaChange.drop_column(['mv', 'latest_value'])], False)
-        self.assertIn('latest_value', str(cm.exception))
+                'default.nrow_replace',
+                [SchemaChange.update_column_type('mv', new_row)], False)
+        self.assertIn('cannot be converted', str(cm.exception))
+
+    def test_align_handles_sliced_arrays(self):
+        # The list/map rebuilds read offsets/raw buffers; a sliced input
+        # must be re-materialized, not read through stale parent offsets.
+        from pypaimon.read.reader.data_file_batch_reader import \
+            DataFileBatchReader
+        reader = DataFileBatchReader.__new__(DataFileBatchReader)
+        sliced_list = pa.array(
+            [[1, 2], [3], [4, 5, 6], None], 
type=pa.list_(pa.int32())).slice(1, 3)
+        out = reader._align_array_by_id(
+            sliced_list,
+            ArrayType(True, AtomicType('INT')),
+            ArrayType(True, AtomicType('BIGINT')))
+        self.assertEqual(out.to_pylist(), [[3], [4, 5, 6], None])
+        self.assertEqual(out.type, pa.list_(pa.int64()))
+
+        sliced_map = pa.array(
+            [[('a', 1)], [('b', 2)], None],
+            type=pa.map_(pa.string(), pa.int32())).slice(1, 2)
+        out = reader._align_array_by_id(
+            sliced_map,
+            MapType(True, AtomicType('STRING'), AtomicType('INT')),
+            MapType(True, AtomicType('STRING'), AtomicType('BIGINT')))
+        self.assertEqual(out.to_pylist(), [[('b', 2)], None])
+
+    def test_map_wrapper_token_validated(self):
+        # The token consumed when descending through a MAP must be 'value'.
+        val = pa.struct([('a', pa.int64())])
+        s0 = pa.schema([('id', pa.int64()), ('m', pa.map_(pa.string(), val))])
+        self._create('ntok_map', s0)
+        with self.assertRaises(RuntimeError) as cm:
+            self.catalog.alter_table(
+                'default.ntok_map',
+                [SchemaChange.add_column(['m', 'wrong', 'c'], 
AtomicType('INT'))],
+                False)
+        self.assertIn('m.wrong.c', str(cm.exception))
+        self.catalog.alter_table(
+            'default.ntok_map',
+            [SchemaChange.add_column(['m', 'value', 'c'], AtomicType('INT'))],
+            False)
+
+
+class SchemaEvolutionConstructedToStringTest(_NestedBase):
+    """update column type from ROW/ARRAY/MAP to STRING: old files must be
+    materialized as the engine's string rendering at read time."""
+
+    def test_row_to_string(self):
+        s0 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('a', pa.int32()), ('b', 
pa.string())]))])
+        table = self._create('c2s_row', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'a': 1, 'b': 'x'}}], schema=s0))
+        self.catalog.alter_table(
+            'default.c2s_row',
+            [SchemaChange.update_column_type('mv', AtomicType('STRING'))], 
False)
+        table = self.catalog.get_table('default.c2s_row')
+        s1 = pa.schema([('id', pa.int64()), ('mv', pa.string())])
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 2, 'mv': 's2'}], schema=s1))
+
+        rows = self._read_sorted(table)
+        self.assertEqual(rows, [
+            {'id': 1, 'mv': '{1, x}'},
+            {'id': 2, 'mv': 's2'},
+        ])
+
+    def test_array_to_string(self):
+        s0 = pa.schema([('id', pa.int64()), ('arr', pa.list_(pa.int32()))])
+        table = self._create('c2s_arr', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'arr': [1, 2, 3]}], schema=s0))
+        self.catalog.alter_table(
+            'default.c2s_arr',
+            [SchemaChange.update_column_type('arr', AtomicType('STRING'))], 
False)
+        table = self.catalog.get_table('default.c2s_arr')
+        rows = self._read_sorted(table)
+        self.assertEqual(rows[0]['arr'], '[1, 2, 3]')
+
+    def test_map_to_string(self):
+        s0 = pa.schema([('id', pa.int64()),
+                        ('m', pa.map_(pa.string(), pa.int32()))])
+        table = self._create('c2s_map', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'm': [('k', 7)]}], schema=s0))
+        self.catalog.alter_table(
+            'default.c2s_map',
+            [SchemaChange.update_column_type('m', AtomicType('STRING'))], 
False)
+        table = self.catalog.get_table('default.c2s_map')
+        rows = self._read_sorted(table)
+        self.assertEqual(rows[0]['m'], '{k -> 7}')
+
+    def test_row_to_string_null_semantics(self):
+        s0 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('a', pa.int32()), ('b', 
pa.string())]))])
+        table = self._create('c2s_null', s0)
+        self._write(table, pa.Table.from_pylist([
+            {'id': 1, 'mv': None},
+            {'id': 2, 'mv': {'a': None, 'b': 'x'}},
+        ], schema=s0))
+        self.catalog.alter_table(
+            'default.c2s_null',
+            [SchemaChange.update_column_type('mv', AtomicType('STRING'))], 
False)
+        table = self.catalog.get_table('default.c2s_null')
+        rows = self._read_sorted(table)
+        # A NULL container stays NULL; a NULL sub-value renders as 'null'.
+        self.assertIsNone(rows[0]['mv'])
+        self.assertEqual(rows[1]['mv'], '{null, x}')
+
+    def test_vector_to_string_rejected(self):
+        # There is no read-time string rendering for vectors, so the type
+        # change must be rejected at alter time instead of failing on read.
+        s0 = pa.schema([('id', pa.int64()),
+                        ('embed', pa.list_(pa.float32(), 3))])
+        table = self._create('c2s_vec', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'embed': [1.0, 2.0, 3.0]}], schema=s0))
+        with self.assertRaises(RuntimeError) as cm:
+            self.catalog.alter_table(
+                'default.c2s_vec',
+                [SchemaChange.update_column_type('embed', 
AtomicType('STRING'))],
+                False)
+        self.assertIn('cannot be converted', str(cm.exception))
+        # The vector column itself still reads fine.
+        rows = self._read_sorted(table)
+        self.assertEqual(rows[0]['embed'], [1.0, 2.0, 3.0])
+
+    def test_nested_subfield_row_to_string(self):
+        inner = pa.struct([('a', pa.int32())])
+        s0 = pa.schema([('id', pa.int64()),
+                        ('mv', pa.struct([('inner', inner)]))])
+        table = self._create('c2s_nested', s0)
+        self._write(table, pa.Table.from_pylist(
+            [{'id': 1, 'mv': {'inner': {'a': 1}}}], schema=s0))
+        self.catalog.alter_table(
+            'default.c2s_nested',
+            [SchemaChange.update_column_type(['mv', 'inner'], 
AtomicType('STRING'))],
+            False)
+        table = self.catalog.get_table('default.c2s_nested')
+        rows = self._read_sorted(table)
+        self.assertEqual(rows[0]['mv'], {'inner': '{1}'})
+
+
+class NestedFieldIdModelTest(unittest.TestCase):
+    """Globally-unique nested field ids, mirrored from the engine id model."""
+
+    def test_nested_ids_are_globally_unique(self):
+        s = pa.schema([('id', pa.int64()), ('mv', _MV_PA), ('x', pa.string())])
+        fields = PyarrowFieldParser.to_paimon_schema(s)
+        ids = set()
+        for f in fields:
+            ids.add(f.id)
+            collect_field_ids(f.type, ids)
+        # id(0), mv(1), latest_version(2), latest_value(3), x(4)
+        self.assertEqual(ids, {0, 1, 2, 3, 4})
+        self.assertEqual(current_highest_field_id(fields), 4)
+
+    def test_flat_schema_ids_unchanged(self):
+        fields = PyarrowFieldParser.to_paimon_schema(
+            pa.schema([('a', pa.int64()), ('b', pa.string()), ('c', 
pa.int32())]))
+        self.assertEqual([f.id for f in fields], [0, 1, 2])
+
+    def test_reassign_field_id_depth_first_order(self):
+        inner = RowType(True, [DataField(0, 'c', AtomicType('INT'))])
+        mid = RowType(True, [DataField(0, 'b', inner)])
+        outer = RowType(True, [DataField(0, 'a', mid),
+                               DataField(0, 'd', AtomicType('INT'))])
+        result = reassign_field_id(outer, AtomicInteger(2))
+        ids = set()
+        collect_field_ids(result, ids)
+        self.assertEqual(ids, {3, 4, 5, 6})
+
+    def test_duplicate_field_id_raises(self):
+        bad = [
+            DataField(0, 'a', AtomicType('INT')),
+            DataField(1, 'b', RowType(True, [DataField(0, 'c', 
AtomicType('INT'))])),
+        ]
+        with self.assertRaises(ValueError):
+            current_highest_field_id(bad)
+
+
+class SupportsCastTest(unittest.TestCase):
+
+    def test_supported_casts(self):
+        for src, dst in [('INT', 'BIGINT'), ('FLOAT', 'DOUBLE'), ('INT', 
'STRING'),
+                         ('DOUBLE', 'INT'), ('DECIMAL(10, 4)', 'DECIMAL(10, 
2)')]:
+            self.assertTrue(supports_cast(AtomicType(src), AtomicType(dst)),
+                            '{} -> {}'.format(src, dst))
+
+    def test_unsupported_casts(self):
+        for src, dst in [('BIGINT', 'DATE'), ('BOOLEAN', 'DATE')]:
+            self.assertFalse(supports_cast(AtomicType(src), AtomicType(dst)),
+                             '{} -> {}'.format(src, dst))
+
+    def test_can_execute_cast_decimal_precision(self):
+        # A numeric -> decimal cast has a PyArrow kernel but is only executable
+        # when the target precision can hold the source's range at the target
+        # scale (INT needs >= 12 at scale 2, BIGINT >= 21). An empty-array 
probe
+        # misses this; can_execute_cast must reject the too-small targets so 
the
+        # read path does not later fail with ArrowInvalid.
+        for src, dst in [('INT', 'DECIMAL(10, 2)'), ('BIGINT', 'DECIMAL(10, 
2)'),
+                         ('BIGINT', 'DECIMAL(20, 2)')]:
+            self.assertFalse(can_execute_cast(AtomicType(src), 
AtomicType(dst)),
+                             '{} -> {}'.format(src, dst))
+        for src, dst in [('INT', 'DECIMAL(12, 2)'), ('BIGINT', 'DECIMAL(21, 
2)'),
+                         ('INT', 'BIGINT'), ('DOUBLE', 'INT'), ('INT', 
'STRING')]:
+            self.assertTrue(can_execute_cast(AtomicType(src), AtomicType(dst)),
+                            '{} -> {}'.format(src, dst))
+
+    def test_constructed_to_string(self):
+        # ROW/ARRAY/MAP have a read-time string rendering; vector and
+        # multiset do not, so their type change must be rejected.
+        row = RowType(True, [DataField(0, 'a', AtomicType('INT'))])
+        arr = ArrayType(True, AtomicType('INT'))
+        m = MapType(True, AtomicType('STRING'), AtomicType('INT'))
+        for src in (row, arr, m):
+            self.assertTrue(supports_cast(src, AtomicType('STRING')), str(src))
+        vec = VectorType(True, AtomicType('FLOAT'), 3)
+        ms = MultisetType(True, AtomicType('INT'))
+        for src in (vec, ms):
+            self.assertFalse(supports_cast(src, AtomicType('STRING')), 
str(src))
+
+    def test_constructed_to_differently_shaped_constructed_rejected(self):
+        # Reshaping a constructed type must go through sub-field /
+        # 'element' / 'value' paths; a whole-type replacement would carry
+        # caller-supplied nested ids that corrupt the id model.
+        self.assertFalse(supports_cast(
+            RowType(True, [DataField(0, 'a', AtomicType('INT'))]),
+            RowType(True, [DataField(0, 'a', AtomicType('BIGINT'))])))
+        self.assertFalse(supports_cast(
+            ArrayType(True, AtomicType('INT')),
+            ArrayType(True, AtomicType('BIGINT'))))
+        self.assertFalse(supports_cast(
+            VectorType(True, AtomicType('FLOAT'), 3),
+            VectorType(True, AtomicType('FLOAT'), 5)))
+        # Only the outer nullability differing is still an identity cast.
+        self.assertTrue(supports_cast(
+            RowType(True, [DataField(2, 'a', AtomicType('INT'))]),
+            RowType(False, [DataField(2, 'a', AtomicType('INT'))])))
 
 
 if __name__ == '__main__':
diff --git a/paimon-python/pypaimon/tests/test_nested_projection_e2e.py 
b/paimon-python/pypaimon/tests/test_nested_projection_e2e.py
index 50a239a1d8..a775a6dffd 100644
--- a/paimon-python/pypaimon/tests/test_nested_projection_e2e.py
+++ b/paimon-python/pypaimon/tests/test_nested_projection_e2e.py
@@ -193,6 +193,23 @@ class AppendOnlyNestedParquetTest(_AppendOnlyNestedBase):
             [{'part': 'A', 'mv_latest_version': 100, 'val': 'x'},
              {'part': 'B', 'mv_latest_version': 200, 'val': 'y'}])
 
+    def test_filter_on_projected_nested_leaf(self):
+        """A predicate on a projected nested leaf must actually filter rows.
+        The read widens the projection to the top-level struct, which drops
+        the leaf predicate from push-down (its path is absent from the read
+        fields); without re-applying it after the leaves are extracted, every
+        row leaks through."""
+        table = self._create_table('ao_nested_leaf_filter')
+        rb = table.new_read_builder().with_projection(['id', 
'mv.latest_version'])
+        pred = rb.new_predicate_builder().greater_than('mv_latest_version', 
150)
+        rb = rb.with_filter(pred)
+        got = rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist()
+        got = sorted(got, key=lambda r: r['id'])
+        self.assertEqual(
+            got,
+            [{'id': 2, 'mv_latest_version': 200},
+             {'id': 3, 'mv_latest_version': 300}])
+
     def test_avro_nested_projection_python_fallback(self):
         """Avro has no native nested column pruning; the reader walks
         each fastavro record dict by path and assembles the column
@@ -245,11 +262,43 @@ class PrimaryKeyNestedTest(_AppendOnlyNestedBase):
             w.close()
         return table
 
+    def _create_pk_raw_table(self, name: str, file_format: str = 'parquet'):
+        """Single commit keeps the split raw-convertible, so the read stays on
+        the RawFileSplitRead fast path rather than the merge reader."""
+        identifier = 'default.{}'.format(name)
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            primary_keys=['id'],
+            options={'bucket': '1', 'file.format': file_format},
+        )
+        self.catalog.create_table(identifier, schema, False)
+        table = self.catalog.get_table(identifier)
+        wb = table.new_batch_write_builder()
+        w = wb.new_write()
+        w.write_arrow(pa.Table.from_pylist(self.rows, schema=self.pa_schema))
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+        return table
+
     def _read_arrow(self, table, projection):
         rb = table.new_read_builder().with_projection(projection)
         splits = rb.new_scan().plan().splits()
         return rb.new_read().to_arrow(splits)
 
+    def test_raw_convertible_filter_on_projected_nested_leaf(self):
+        """PK raw-convertible split also widens nested projection and so drops
+        the leaf predicate from push-down. The filter must be re-applied on the
+        extracted leaves; otherwise all rows are returned (reviewer repro)."""
+        table = self._create_pk_raw_table('pk_raw_nested_leaf_filter')
+        rb = table.new_read_builder().with_projection(['id', 
'mv.latest_version'])
+        pred = rb.new_predicate_builder().greater_than('mv_latest_version', 
150)
+        rb = rb.with_filter(pred)
+        arrow = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+        rows = sorted(zip(
+            arrow.column('id').to_pylist(),
+            arrow.column('mv_latest_version').to_pylist()))
+        self.assertEqual(rows, [(2, 200), (3, 300)])
+
     def test_extracts_single_nested_leaf(self):
         table = self._create_pk_table('pk_nested_single')
         arrow = self._read_arrow(table, ['mv.latest_version'])
@@ -301,6 +350,21 @@ class PrimaryKeyNestedTest(_AppendOnlyNestedBase):
         got = rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist()
         self.assertEqual(got, [{'id': 1, 'media.left': 'hello'}])
 
+    def test_merge_filter_on_projected_nested_leaf(self):
+        """Non-raw-convertible PK splits go through the merge reader, which
+        widens the nested projection to the full ROW and so also drops the leaf
+        predicate from push-down. The filter must be re-applied on the 
extracted
+        leaves above the merge; otherwise all rows are returned."""
+        table = self._create_pk_table('pk_merge_nested_leaf_filter')
+        rb = table.new_read_builder().with_projection(['id', 
'mv.latest_version'])
+        pred = rb.new_predicate_builder().greater_than('mv_latest_version', 
150)
+        rb = rb.with_filter(pred)
+        arrow = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+        rows = sorted(zip(
+            arrow.column('id').to_pylist(),
+            arrow.column('mv_latest_version').to_pylist()))
+        self.assertEqual(rows, [(2, 200), (3, 300)])
+
     def test_avro_extracts_single_nested_leaf(self):
         # Avro PK reads resolve DataFields through ``full_fields_map`` which
         # historically only covered merge-internal aliases; without the

Reply via email to