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 c60b7a93e2 [Python] Enable native Blob and VARIANT updates with Java 
layouts (#10390)
c60b7a93e2 is described below

commit c60b7a93e25fa6c4dedcb46f8bed5452fd51ed09
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 5 19:03:44 2026 +0800

    [Python] Enable native Blob and VARIANT updates with Java layouts (#10390)
---
 .../pypaimon/common/options/core_options.py        |  29 +-
 .../pypaimon/read/reader/format_pyarrow_reader.py  |   5 +-
 paimon-python/pypaimon/table/file_store_table.py   |  24 +-
 paimon-python/pypaimon/tests/blob_table_test.py    |  20 +-
 paimon-python/pypaimon/tests/blob_test.py          |   8 +-
 .../pypaimon/tests/native_blob_update_test.py      | 330 +++++++++++++++++++++
 .../pypaimon/tests/native_variant_update_test.py   | 195 ++++++++++++
 .../table/time_travel_schema_evolution_test.py     |  28 +-
 .../tests/table_update_by_row_id_chunked_test.py   |   4 +-
 paimon-python/pypaimon/write/native_update.py      |  38 ++-
 paimon-python/pypaimon/write/row_utils.py          |  17 +-
 paimon-python/pypaimon/write/table_update.py       |   2 +-
 .../pypaimon/write/table_update_by_row_id.py       |  17 +-
 paimon-python/pypaimon/write/writer/data_writer.py |   3 +-
 .../write/writer/dedicated_format_writer.py        |  23 +-
 15 files changed, 646 insertions(+), 97 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index e419a70415..525b16f708 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -1199,18 +1199,6 @@ class CoreOptions:
         )
     )
 
-    VARIANT_SHREDDING_ENABLED: ConfigOption[bool] = (
-        ConfigOptions.key("variant.shredding.enabled")
-        .boolean_type()
-        .default_value(True)
-        .with_description(
-            "Whether to enable VARIANT shredding. When True (default), writes 
apply the "
-            "shredding schema configured via 'variant.shreddingSchema', and 
reads "
-            "automatically reassemble shredded columns back to the standard "
-            "struct<value, metadata> form. Set to False to bypass both 
behaviours."
-        )
-    )
-
     VARIANT_SHREDDING_SCHEMA: ConfigOption[str] = (
         ConfigOptions.key("variant.shreddingSchema")
         .string_type()
@@ -1398,9 +1386,6 @@ class CoreOptions:
     def blob_as_descriptor(self, default=None):
         return self.options.get(CoreOptions.BLOB_AS_DESCRIPTOR, default)
 
-    def variant_shredding_enabled(self) -> bool:
-        return self.options.get(CoreOptions.VARIANT_SHREDDING_ENABLED, True)
-
     def variant_shredding_schema(self) -> Optional[str]:
         val = self.options.get(CoreOptions.VARIANT_SHREDDING_SCHEMA)
         if val is None:
@@ -1409,15 +1394,11 @@ class CoreOptions:
         return val
 
     def blob_descriptor_fields(self, default=None):
-        # Do not treat blob.stored-descriptor-fields as a layout switch.
-        # Python master ignored that key and wrote dedicated .blob payloads;
-        # a global fallback would mis-parse those files during a rolling
-        # upgrade. The cost is that Java tables which only set the fallback
-        # key store inline descriptors, and Python returns those bytes
-        # instead of fetching payload. Migrate explicitly to
-        # blob-descriptor-field (column directives already copy the legacy
-        # key onto the canonical option).
-        value = self.options.get(CoreOptions.BLOB_DESCRIPTOR_FIELD, default)
+        # Java's fallback key applies only when the canonical key is absent.
+        if self.options.contains_key(CoreOptions.BLOB_DESCRIPTOR_FIELD.key()):
+            value = self.options.get(CoreOptions.BLOB_DESCRIPTOR_FIELD, 
default)
+        else:
+            value = self.options.to_map().get('blob.stored-descriptor-fields', 
default)
         return CoreOptions._parse_field_set(value)
 
     def blob_view_fields(self, default=None):
diff --git a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py 
b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
index ecb7db7e74..58d481ccf6 100644
--- a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
@@ -468,8 +468,6 @@ class FormatPyArrowReader(RecordBatchReader):
             self.existing_fields = [f.name for f in read_fields if f.name in 
file_schema_names]
             self.missing_fields = [f.name for f in read_fields if f.name not 
in file_schema_names]
 
-        self._variant_shredding_enabled = (
-            options is None or options.variant_shredding_enabled())
         self._variant_schema_cache: Dict[pa.DataType, VariantSchema] = {}
         self._shared_shredding_maps = {}
         self._selected_key_maps = {}
@@ -817,8 +815,7 @@ class FormatPyArrowReader(RecordBatchReader):
         if self._selected_key_maps:
             batch = self._assemble_selected_key_maps(batch)
 
-        if self._variant_shredding_enabled:
-            batch = self._assemble_shredded_variants(batch)
+        batch = self._assemble_shredded_variants(batch)
 
         if not self.missing_fields:
             return batch
diff --git a/paimon-python/pypaimon/table/file_store_table.py 
b/paimon-python/pypaimon/table/file_store_table.py
index 85c9c5775b..26bad0deda 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -613,21 +613,19 @@ class FileStoreTable(Table):
             historical_schema: TableSchema, options: Options, 
dynamic_option_keys: Set[str]) -> dict:
         # Keep scan and runtime options, but restore column declarations to 
match historical fields.
         historical_options = dict(options.to_map())
-        for key in (
-                CoreOptions.VECTOR_FIELD.key(),
-                CoreOptions.BLOB_FIELD.key(),
-                CoreOptions.BLOB_DESCRIPTOR_FIELD.key(),
-                CoreOptions.BLOB_VIEW_FIELD.key(),
-                # Restore the legacy key verbatim, not as a canonical 
descriptor option:
-                # Python intentionally ignores it when choosing the read 
layout.
-                'blob.stored-descriptor-fields'):
-            if key in dynamic_option_keys:
+        for keys in (
+                (CoreOptions.VECTOR_FIELD.key(),),
+                (CoreOptions.BLOB_FIELD.key(),),
+                (CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), 
'blob.stored-descriptor-fields'),
+                (CoreOptions.BLOB_VIEW_FIELD.key(),)):
+            if any(key in dynamic_option_keys for key in keys):
                 # Preserve explicit overrides and removals, including those 
from earlier copies.
                 continue
-            if key in historical_schema.options:
-                historical_options[key] = historical_schema.options[key]
-            else:
-                historical_options.pop(key, None)
+            for key in keys:
+                if key in historical_schema.options:
+                    historical_options[key] = historical_schema.options[key]
+                else:
+                    historical_options.pop(key, None)
         return historical_options
 
     def _create_external_paths(self) -> List[str]:
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py 
b/paimon-python/pypaimon/tests/blob_table_test.py
index 75b6aa405b..41b86fd57b 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -5354,14 +5354,10 @@ class DedicatedFormatWriterTest(unittest.TestCase):
         self.assertEqual(result['id'], list(range(2000)))
         self.assertEqual(result['name'], ['updated'] * 2000)
 
-    def test_legacy_stored_descriptor_fields_keeps_dedicated_blob_layout(self):
-        """blob.stored-descriptor-fields must not switch Python to inline 
descriptors.
-
-        Master ignored that key and wrote dedicated .blob payloads. Head write
-        with the same option must keep that layout so old readers still see
-        payloads, and head read must not fail-fast on those bytes.
-        """
+    def test_stored_descriptor_fallback_uses_java_inline_layout(self):
+        """The fallback option stores references in Parquet, as Java does."""
         from pypaimon import Schema
+        from pypaimon.table.row.blob import BlobDescriptor
 
         pa_schema = pa.schema([
             ('id', pa.int32()),
@@ -5380,11 +5376,15 @@ class DedicatedFormatWriterTest(unittest.TestCase):
         table = 
self.catalog.get_table('test_db.legacy_stored_descriptor_fields')
 
         payload = b'legacy-dedicated-blob-payload'
+        source = os.path.join(self.temp_dir, 'fallback-descriptor-source')
+        with open(source, 'wb') as stream:
+            stream.write(payload)
+        descriptor = BlobDescriptor(source, 0, len(payload)).serialize()
         write_builder = table.new_batch_write_builder()
         writer = write_builder.new_write()
         writer.write_arrow(pa.Table.from_pydict({
             'id': [1],
-            'picture': [payload],
+            'picture': [descriptor],
         }, schema=pa_schema))
         commit_messages = writer.prepare_commit()
         write_builder.new_commit().commit(commit_messages)
@@ -5392,8 +5392,8 @@ class DedicatedFormatWriterTest(unittest.TestCase):
 
         all_files = [f for msg in commit_messages for f in msg.new_files]
         blob_files = [f for f in all_files if f.file_name.endswith('.blob')]
-        self.assertGreaterEqual(len(blob_files), 1)
-        self.assertTrue(all(f.write_cols == ['picture'] for f in blob_files))
+        self.assertEqual(blob_files, [])
+        self.assertTrue(all(f.file_name.endswith('.parquet') for f in 
all_files))
 
         result = table.new_read_builder().new_read().to_arrow(
             table.new_read_builder().new_scan().plan().splits())
diff --git a/paimon-python/pypaimon/tests/blob_test.py 
b/paimon-python/pypaimon/tests/blob_test.py
index f08eab83df..a15f7c5ac6 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -1597,14 +1597,12 @@ class BlobTest(unittest.TestCase):
             BlobDescriptor("file:///tmp/blob.bin", 0, 10),
         )
 
-    def test_blob_descriptor_fields_ignores_legacy_stored_key(self):
+    def test_blob_descriptor_fields_uses_java_fallback_key(self):
         from pypaimon.common.options.core_options import CoreOptions
 
-        # Python master ignored this key and wrote dedicated .blob files.
-        # A global fallback would break rolling upgrades.
         legacy_only = CoreOptions(
             Options({"blob.stored-descriptor-fields": "legacy_col"}))
-        self.assertEqual(set(), legacy_only.blob_descriptor_fields())
+        self.assertEqual({'legacy_col'}, legacy_only.blob_descriptor_fields())
 
         canonical_wins = CoreOptions(Options({
             "blob-descriptor-field": "canon",
@@ -2428,7 +2426,7 @@ class BlobTest(unittest.TestCase):
         self.assertFalse(needs_blob_inline_convert(_Table({
             "blob-as-descriptor": "true",
         })))
-        self.assertFalse(needs_blob_inline_convert(_Table({
+        self.assertTrue(needs_blob_inline_convert(_Table({
             "blob.stored-descriptor-fields": "picture",
         })))
 
diff --git a/paimon-python/pypaimon/tests/native_blob_update_test.py 
b/paimon-python/pypaimon/tests/native_blob_update_test.py
new file mode 100644
index 0000000000..d7e9ffdc7b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_blob_update_test.py
@@ -0,0 +1,330 @@
+# 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.
+
+"""Native updates of Parquet columns preserve dedicated Blob files."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+from urllib.parse import urlparse
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.schema.data_types import AtomicType, DataField, 
PyarrowFieldParser
+from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobViewStruct
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.table_delete import TableDeleteByRowId
+from pypaimon.write.native_update import NativeTableUpdateByRowId
+from pypaimon.write.native_write import NativeTableWrite
+from pypaimon.write.table_update import TableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(tmp_path, extra=None, catalog=None, payloads=None):
+    catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path / 
'warehouse')})
+    catalog.create_database('db', True)
+    fields = [DataField(0, 'id', AtomicType('INT')),
+              DataField(1, 'pt', AtomicType('STRING')),
+              DataField(2, 'value', AtomicType('INT')),
+              DataField(3, 'payload', AtomicType('BLOB'))]
+    options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true',
+               'write.native.enabled': 'true', 'read.native.enabled': 'true',
+               'scan.native-plan.enabled': 'true', 'deletion-vectors.enabled': 
'true',
+               'blob.target-file-size': '16 B', 'target-file-row-num': '2'}
+    options.update(extra or {})
+    catalog.create_table('db.t', Schema(fields, partition_keys=['pt'], 
options=options), False)
+    table = catalog.get_table('db.t')
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow(pa.table({'id': [0, 1, 2, 3], 'pt': ['a', 'a', 'b', 
'b'],
+                                     'value': [10, 11, 12, 13],
+                                     'payload': payloads if payloads is not 
None else
+                                     [b'large' * 10, None, b'', b'last']}, 
schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema
+
+
+def _read(table, descriptors=False, projection=None, resolve_views=True):
+    table = table.copy({'blob-as-descriptor': str(descriptors).lower(),
+                        'blob-view.resolve.enabled': 
str(resolve_views).lower()})
+    builder = table.new_read_builder()
+    if projection is not None:
+        builder.with_projection(projection)
+    splits = builder.new_scan().plan().splits()
+    read = builder.new_read()
+    with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python read fallback')):
+        return read.to_arrow(splits).sort_by('id').to_pylist()
+
+
+def _native_only():
+    stack = ExitStack()
+    for cls, method in [(TableUpdateByRowId, '_load_existing_files_info'),
+                        (TableUpdate, '_build_predicate_update_table'),
+                        (TableUpsertByKey, '_upsert_partition'),
+                        (TableUpsertByKey, '_upsert_row_partition'),
+                        (TableDeleteByRowId, 'delete')]:
+        stack.enter_context(patch.object(cls, method, 
side_effect=AssertionError('Python update fallback')))
+    return stack
+
+
+def _commit(builder, messages, stream):
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 7) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+
+
[email protected]('stream', [False, True])
[email protected]('operation', ['row_id', 'predicate'])
+def test_native_updates_keep_blob_references(tmp_path, stream, operation):
+    table, _ = _table(tmp_path)
+    before = _read(table, descriptors=True)
+    assert BlobDescriptor.is_blob_descriptor(before[0]['payload'])
+    row_ids = {row['id']: row['_ROW_ID']
+               for row in _read(table, projection=['id', '_ROW_ID'])}
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    data = pa.table({'_ROW_ID': [row_ids[0], row_ids[2]],
+                     'value': pa.array([100, 200], type=pa.int32())})
+    with _native_only():
+        if operation == 'row_id':
+            messages = (update.update_by_arrow_with_row_id(data, 7) if stream
+                        else update.update_by_arrow_with_row_id(data))
+        else:
+            predicate = PredicateBuilder(table.fields).is_in('id', [0, 2])
+
+            def assignment(matched):
+                return pa.array([100 if value == 0 else 200
+                                 for value in matched['id'].to_pylist()], 
type=pa.int32())
+
+            args = (predicate, {'value': assignment})
+            messages = (update.update_by_predicate(*args, 7, 
read_columns=['id']) if stream
+                        else update.update_by_predicate(*args, 
read_columns=['id']))
+    _commit(builder, messages, stream)
+    after = _read(table, descriptors=True)
+    assert [row['payload'] for row in after] == [row['payload'] for row in 
before]
+    assert [row['value'] for row in after] == [100, 11, 200, 13]
+    assert [row['payload'] for row in _read(table)] == [b'large' * 10, None, 
b'', b'last']
+
+
[email protected]('stream', [False, True])
[email protected]('row_input', [False, True])
+def test_native_upsert_updates_normal_columns_and_appends_blob_rows(tmp_path, 
stream, row_input):
+    table, schema = _table(tmp_path)
+    before = _read(table, descriptors=True)
+    source = pa.table({'id': [0, 4], 'pt': ['a', 'b'], 'value': [100, 400],
+                       'payload': [b'ignored matched payload', b'new 
payload']}, schema=schema)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    with _native_only():
+        if row_input:
+            rows = [GenericRow([row[field.name] for field in table.fields], 
table.fields)
+                    for row in source.to_pylist()]
+            messages = (update.upsert_by_key(rows, ['id'], 7) if stream
+                        else update.upsert_by_key(rows, ['id']))
+        else:
+            messages = (update.upsert_by_arrow_with_key(source, ['id'], 7) if 
stream
+                        else update.upsert_by_arrow_with_key(source, ['id']))
+    _commit(builder, messages, stream)
+    rows = _read(table)
+    assert [row['value'] for row in rows] == [100, 11, 12, 13, 400]
+    assert [row['payload'] for row in rows] == [b'large' * 10, None, b'', 
b'last', b'new payload']
+    after = _read(table, descriptors=True)
+    assert [row['payload'] for row in after[:4]] == [row['payload'] for row in 
before]
+
+
[email protected]('stream', [False, True])
[email protected]('predicate_delete', [False, True])
+def test_native_blob_table_delete_preserves_remaining_rows(tmp_path, stream, 
predicate_delete):
+    table, _ = _table(tmp_path)
+    before = _read(table, descriptors=True)
+    ids = {row['id']: row['_ROW_ID'] for row in _read(table, projection=['id', 
'_ROW_ID'])}
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update()
+    with _native_only():
+        if predicate_delete:
+            predicate = PredicateBuilder(table.fields).is_in('id', [0, 2])
+            messages = (update.delete_by_predicate(predicate, 7) if stream
+                        else update.delete_by_predicate(predicate))
+        else:
+            row_ids = [ids[0], ids[2], ids[0]]
+            messages = (update.delete_by_row_id(row_ids, 7) if stream
+                        else update.delete_by_row_id(row_ids))
+    assert messages and all(not message.new_files for message in messages)
+    _commit(builder, messages, stream)
+    assert _read(table, descriptors=True) == [before[1], before[3]]
+    assert [row['payload'] for row in _read(table)] == [None, b'last']
+
+
+def test_normal_column_update_does_not_open_dedicated_blobs(tmp_path):
+    table, _ = _table(tmp_path)
+    ids = _read(table, projection=['id', '_ROW_ID'])
+    for path in (tmp_path / 'warehouse').rglob('*.blob'):
+        path.unlink()
+    builder = table.new_batch_write_builder()
+    with _native_only():
+        messages = builder.new_update().update_by_arrow_with_row_id(pa.table({
+            '_ROW_ID': [row['_ROW_ID'] for row in ids],
+            'value': pa.array([100, 101, 102, 103], type=pa.int32()),
+        }))
+    assert all(file.write_cols == ['value'] for message in messages for file 
in message.new_files)
+    _commit(builder, messages, False)
+    assert _read(table, projection=['id', 'value']) == [
+        {'id': index, 'value': 100 + index} for index in range(4)]
+
+
+def _inline_table(tmp_path, option, catalog=None):
+    source = tmp_path / 'references'
+    source.write_bytes(b'firstlast')
+    references = [BlobDescriptor(source.as_uri(), 0, 5).serialize(), None,
+                  BlobDescriptor(source.as_uri(), 5, 0).serialize(),
+                  BlobDescriptor(source.as_uri(), 5, 4).serialize()]
+    if option == 'blob-view-field':
+        references = [BlobViewStruct('db.absent_upstream', 3, 
row_id).serialize()
+                      if value is not None else None for row_id, value in 
enumerate(references)]
+    table, schema = _table(tmp_path, {option: 'payload'}, catalog=catalog, 
payloads=references)
+    return table, schema, references
+
+
[email protected]('stream', [False, True])
[email protected]('native', [False, True])
[email protected]('option', ['blob-descriptor-field', 
'blob.stored-descriptor-fields', 'blob-view-field'])
[email protected]('operation', ['row_id', 'incremental', 'row', 'rows', 
'predicate', 'upsert'])
+def test_native_inline_blob_updates_preserve_references(
+        tmp_path, native_rest_catalog, stream, native, option, operation):
+    # REST views reference an absent table. Updating the local reference must
+    # neither resolve it nor read an old upstream payload for unmatched rows.
+    table, schema, references = _inline_table(tmp_path, option, 
native_rest_catalog)
+    table = table.copy({'write.native.enabled': str(native).lower()})
+    ids = {row['id']: row['_ROW_ID'] for row in _read(table, projection=['id', 
'_ROW_ID'])}
+    replacement = references[0]
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['payload'])
+    data = pa.table({'_ROW_ID': [ids[1], ids[2]],
+                     'payload': pa.array([replacement, None], 
type=pa.large_binary())})
+    with _native_only() if native else ExitStack():
+        if operation == 'row_id':
+            messages = (update.update_by_arrow_with_row_id(data, 7) if stream
+                        else update.update_by_arrow_with_row_id(data))
+        elif operation == 'incremental':
+            updater = update.new_update_by_row_id(7) if stream else 
update.new_update_by_row_id()
+            assert isinstance(updater, NativeTableUpdateByRowId) == native
+            updater.update_columns(data.slice(0, 1), ['payload'])
+            updater.update_columns(data.slice(1), ['payload'])
+            messages = updater.commit_messages
+        elif operation in ('row', 'rows'):
+            updater = update.new_update_by_row_id(7) if stream else 
update.new_update_by_row_id()
+            assert isinstance(updater, NativeTableUpdateByRowId) == native
+            value = (Blob.from_view(BlobViewStruct.deserialize(replacement)) 
if option == 'blob-view-field'
+                     else Blob.from_descriptor_bytes(replacement, 
file_io=table.file_io))
+            rows = [GenericRow([1, 'a', 999, value], table.fields),
+                    GenericRow([2, 'b', 999, None], table.fields)]
+            if operation == 'row':
+                updater.update_row_columns(rows[0], [ids[1]], ['payload'])
+                updater.update_row_columns(rows[1], [ids[2]], ['payload'])
+            else:
+                updater.update_rows_columns(rows, [[ids[1]], [ids[2]]], 
['payload'])
+            messages = updater.commit_messages
+        elif operation == 'predicate':
+            predicate = PredicateBuilder(table.fields).is_in('id', [1, 2])
+
+            def assignment(matched):
+                return pa.array([replacement if row_id == 1 else None
+                                 for row_id in matched['id'].to_pylist()], 
type=pa.large_binary())
+
+            args = (predicate, {'payload': assignment})
+            messages = (update.update_by_predicate(*args, 7, 
read_columns=['id']) if stream
+                        else update.update_by_predicate(*args, 
read_columns=['id']))
+        else:
+            source = pa.table({'id': [1, 2], 'pt': ['a', 'b'], 'value': [999, 
999],
+                               'payload': [replacement, None]}, schema=schema)
+            messages = (update.upsert_by_arrow_with_key(source, ['id'], 7) if 
stream
+                        else update.upsert_by_arrow_with_key(source, ['id']))
+    assert all(file.file_name.endswith('.parquet') and file.write_cols == 
['payload']
+               for message in messages for file in message.new_files)
+    _commit(builder, messages, stream)
+    rows = _read(table, descriptors=True, resolve_views=False)
+    assert [row['payload'] for row in rows] == [references[0], replacement, 
None, references[3]]
+    assert [row['value'] for row in rows] == [10, 11, 12, 13]
+    if option != 'blob-view-field':
+        assert [row['payload'] for row in _read(table)] == [b'first', 
b'first', None, b'last']
+
+
+def test_raw_blob_incremental_updater_keeps_one_implementation(tmp_path):
+    # Later calls choose their columns independently. Keeping this operation
+    # on Python permits a scalar update followed by a raw Blob update.
+    table, _ = _table(tmp_path)
+    from pypaimon.write.native_update import create_native_update_by_row_id
+    assert create_native_update_by_row_id(table, 'test', 7) is None
+
+
+def test_view_prescan_keeps_inline_descriptor_predicate(tmp_path, 
native_rest_catalog):
+    source, _ = _table(tmp_path, catalog=native_rest_catalog)
+    expected = _read(source, descriptors=True)[0]['payload']
+    row_id = _read(source, projection=['id', '_ROW_ID'])[0]['_ROW_ID']
+    file = tmp_path / 'descriptor'
+    file.write_bytes(b'unopened')
+    reference = BlobDescriptor(file.as_uri(), 0, 8).serialize()
+    fields = [DataField(0, 'id', AtomicType('INT')), DataField(1, 'value', 
AtomicType('INT')),
+              DataField(2, 'reference', AtomicType('BLOB')), DataField(3, 
'payload', AtomicType('BLOB'))]
+    options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 
'true',
+               'blob-descriptor-field': 'reference', 'blob-view-field': 
'payload',
+               'write.native.enabled': 'true', 'read.native.enabled': 'true',
+               'scan.native-plan.enabled': 'true', 'blob-as-descriptor': 
'true'}
+    native_rest_catalog.create_table('db.mixed', Schema(fields, 
options=options), False)
+    table = native_rest_catalog.get_table('db.mixed')
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.write_arrow(pa.table({'id': [0, 1], 'value': [10, 11],
+                                     'reference': [reference, None],
+                                     'payload': [BlobViewStruct('db.t', 3, 
row_id).serialize(), None]}, schema=schema))
+        _commit(builder, writer.prepare_commit(), False)
+    finally:
+        writer.close()
+    with _native_only():
+        messages = builder.new_update().update_by_arrow_with_row_id(pa.table({
+            '_ROW_ID': [0], 'value': pa.array([100], type=pa.int32())}))
+    _commit(builder, messages, False)
+    for native in (False, True):
+        view = table.copy({'read.native.enabled': str(native).lower(),
+                           'scan.native-plan.enabled': str(native).lower()})
+        read_builder = view.new_read_builder().with_projection(['id', 
'payload']).with_limit(1)
+        
read_builder.with_filter(PredicateBuilder(view.fields).equal('reference', 
reference))
+        plan = read_builder.new_scan().plan()
+        read = read_builder.new_read()
+        with (patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python read fallback'))
+              if native else ExitStack()):
+            rows = read.to_arrow(plan.splits()).to_pylist()
+            assert len(rows) == 1 and rows[0]['id'] == 0
+            actual = BlobDescriptor.deserialize(rows[0]['payload'])
+            original = BlobDescriptor.deserialize(expected)
+            assert (urlparse(actual.uri).path, actual.offset, actual.length) 
== (
+                urlparse(original.uri).path, original.offset, original.length)
diff --git a/paimon-python/pypaimon/tests/native_variant_update_test.py 
b/paimon-python/pypaimon/tests/native_variant_update_test.py
new file mode 100644
index 0000000000..de2321172b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_variant_update_test.py
@@ -0,0 +1,195 @@
+# 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.
+
+"""Native partial updates retain Java's shredded VARIANT physical layout."""
+
+import json
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.data.generic_variant import GenericVariant
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.write.native_update import NativeTableUpdateByRowId
+from pypaimon.write.native_write import NativeTableWrite
+from pypaimon.write.table_update import TableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+pytestmark = pytest.mark.native_plan
+
+
+def _variants(values):
+    return GenericVariant.to_arrow_array([
+        GenericVariant.from_python(value) if value is not None else None for 
value in values])
+
+
+def _table(tmp_path, key, enabled, native_write=True):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = pa.schema([('id', pa.int32()), ('payload', _variants([]).type),
+                        ('unchanged', _variants([]).type), ('value', 
pa.int32())])
+    shredding = json.dumps({'type': 'ROW', 'fields': [
+        {'name': name, 'type': {'type': 'ROW', 'fields': [
+            {'name': 'count', 'type': 'BIGINT'}, {'name': 'name', 'type': 
'STRING'},
+        ]}} for name in ('payload', 'unchanged')
+    ]})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+        'write.native.enabled': str(native_write).lower(), 
'target-file-row-num': '2',
+        # This Python-only option does not exist in Java. It must not disable
+        # a configured physical schema or reassembly on either reader.
+        'variant.shredding.enabled': str(enabled).lower(), key: shredding,
+    }), False)
+    table = catalog.get_table('default.t')
+    schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+    original = [{'count': i, 'name': 'old', 'overflow': [i, None]} for i in 
range(4)]
+    unchanged = [{'count': i + 10, 'name': 'untouched', 'extra': True} for i 
in range(4)]
+    data = pa.table({'id': [0, 1, 2, 3], 'payload': _variants(original),
+                     'unchanged': _variants(unchanged), 'value': [10, 11, 12, 
13]}, schema=schema)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    assert isinstance(writer, NativeTableWrite) == native_write
+    try:
+        # Rolling occurs at batch boundaries. Keep the two incremental calls
+        # in distinct file groups, as required by the public updater contract.
+        writer.write_arrow(data.slice(0, 2))
+        writer.write_arrow(data.slice(2))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, schema, original, unchanged
+
+
+def _read(table, native, projection=None):
+    table = table.copy({'read.native.enabled': str(native).lower(),
+                        'scan.native-plan.enabled': str(native).lower()})
+    builder = table.new_read_builder()
+    if projection is not None:
+        builder.with_projection(projection)
+    splits = builder.new_scan().plan().splits()
+    read = builder.new_read()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(read, '_create_split_read',
+                                             
side_effect=AssertionError('Python read fallback')))
+        data = read.to_arrow(splits).sort_by('id')
+    rows = data.to_pylist()
+    for row in rows:
+        for name in ('payload', 'unchanged'):
+            if name in row and row[name] is not None:
+                row[name] = 
GenericVariant.from_arrow_struct(row[name]).to_python()
+    return rows
+
+
+def _native_only():
+    stack = ExitStack()
+    for cls, method in [(TableUpdateByRowId, '_load_existing_files_info'),
+                        (TableUpdate, '_build_predicate_update_table'),
+                        (TableUpsertByKey, '_upsert_partition')]:
+        stack.enter_context(patch.object(cls, method, 
side_effect=AssertionError('Python update fallback')))
+    return stack
+
+
+def test_python_configured_variant_schema_uses_java_semantics(tmp_path):
+    table, _, original, unchanged = _table(tmp_path, 
'variant.shreddingSchema', False, native_write=False)
+    for native in (False, True):
+        rows = _read(table, native)
+        assert [row['payload'] for row in rows] == original
+        assert [row['unchanged'] for row in rows] == unchanged
+    files = table.new_read_builder().new_scan().plan().splits()[0].files
+    for file in files:
+        physical = pq.read_schema(file.file_path)
+        assert 'typed_value' in [field.name for field in 
physical.field('payload').type]
+
+
[email protected]('stream', [False, True])
[email protected]('key,enabled', [
+    ('variant.shreddingSchema', True), ('parquet.variant.shreddingSchema', 
True),
+    ('variant.shreddingSchema', False),
+])
[email protected]('operation', ['row_id', 'incremental', 'predicate', 
'upsert', 'ordinary_column'])
+def test_native_variant_partial_updates(tmp_path, stream, key, enabled, 
operation):
+    table, schema, original, untouched = _table(tmp_path, key, enabled)
+    ids = {row['id']: row['_ROW_ID'] for row in _read(table, True, ['id', 
'_ROW_ID'])}
+    replacement = {'count': 100, 'name': 'new', 'overflow': {'nested': [1, 
None]}}
+    changed = _variants([replacement, None])
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['payload'])
+    data = pa.table({'_ROW_ID': [ids[0], ids[2]], 'payload': changed})
+    with _native_only():
+        if operation == 'row_id':
+            messages = (update.update_by_arrow_with_row_id(data, 7) if stream
+                        else update.update_by_arrow_with_row_id(data))
+        elif operation == 'incremental':
+            updater = update.new_update_by_row_id(7) if stream else 
update.new_update_by_row_id()
+            assert isinstance(updater, NativeTableUpdateByRowId)
+            updater.update_columns(data.slice(0, 1), ['payload'])
+            updater.update_columns(data.slice(1), ['payload'])
+            messages = updater.commit_messages
+        elif operation == 'predicate':
+            predicate = PredicateBuilder(table.fields).is_in('id', [0, 2])
+
+            def assignment(matched):
+                return _variants([replacement if index == 0 else None
+                                  for index in matched['id'].to_pylist()])
+
+            args = (predicate, {'payload': assignment})
+            messages = (update.update_by_predicate(*args, 7, 
read_columns=['id']) if stream
+                        else update.update_by_predicate(*args, 
read_columns=['id']))
+        elif operation == 'ordinary_column':
+            update = builder.new_update().with_update_type(['value'])
+            data = data.drop(['payload']).append_column('value', 
pa.array([100, 200], type=pa.int32()))
+            messages = (update.update_by_arrow_with_row_id(data, 7) if stream
+                        else update.update_by_arrow_with_row_id(data))
+        else:
+            source = pa.table({'id': [0, 2, 4], 'payload': 
_variants([replacement, None, [1, 2]]),
+                               'unchanged': _variants([None, None, {'count': 
14, 'name': 'untouched'}]),
+                               'value': [999, 999, 14]}, schema=schema)
+            messages = (update.upsert_by_arrow_with_key(source, ['id'], 7) if 
stream
+                        else update.upsert_by_arrow_with_key(source, ['id']))
+    assert messages
+    for message in messages:
+        for file in message.new_files:
+            physical = pq.read_schema(file.file_path)
+            if 'payload' in physical.names:
+                names = [field.name for field in 
physical.field('payload').type]
+                assert 'typed_value' in names
+            if file.write_cols == ['value']:
+                assert physical.names == ['value']
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages, 7) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    expected_payload = original if operation == 'ordinary_column' else 
[replacement, original[1], None, original[3]]
+    expected_values = [100, 11, 200, 13] if operation == 'ordinary_column' 
else [10, 11, 12, 13]
+    if operation == 'upsert':
+        expected_payload = expected_payload + [[1, 2]]
+        expected_values += [14]
+        untouched = untouched + [{'count': 14, 'name': 'untouched'}]
+    for native in (False, True):
+        rows = _read(table, native)
+        assert [row['payload'] for row in rows] == expected_payload
+        assert [row['unchanged'] for row in rows] == untouched
+        assert [row['value'] for row in rows] == expected_values
diff --git 
a/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py 
b/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
index 73fe088c29..17ba35de6a 100644
--- a/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
+++ b/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
@@ -143,15 +143,18 @@ class TestTimeTravelSchemaEvolution:
         assert 'vector-field' not in historical.table_schema.options
         assert self._read(historical) == [{'id': 1}]
 
-    def test_legacy_descriptor_option_keeps_historical_blob_layout(self):
+    def test_descriptor_fallback_keeps_historical_inline_layout(self):
         legacy_key = 'blob.stored-descriptor-fields'
         original = self._create([
             DataField(0, 'id', AtomicType('INT')),
             DataField(1, 'payload', AtomicType('BLOB')),
         ], {legacy_key: 'payload'})
-        payload = b'legacy-blob-payload'
-        messages = self._write(original, {'id': 1, 'payload': payload})
-        assert any(f.file_name.endswith('.blob') for msg in messages for f in 
msg.new_files)
+        source = self.root / 'blob-source'
+        payload = b'blob-payload'
+        source.write_bytes(payload)
+        messages = self._write(original, {'id': 1, 'payload': BlobDescriptor(
+            source.as_uri(), 0, len(payload)).serialize()})
+        assert all(f.file_name.endswith('.parquet') for msg in messages for f 
in msg.new_files)
         self.catalog.alter_table('test.t', [
             SchemaChange.drop_column('payload'),
             SchemaChange.add_column('reference', AtomicType('BYTES'), 
'__BLOB_DESCRIPTOR_FIELD'),
@@ -160,10 +163,9 @@ class TestTimeTravelSchemaEvolution:
 
         historical = current.copy({'scan.snapshot-id': '1'})
 
-        # Restore the original key, without turning it into an 
inline-descriptor layout switch.
         assert historical.table_schema.options[legacy_key] == 'payload'
         assert 'blob-descriptor-field' not in historical.table_schema.options
-        assert not historical.options.blob_descriptor_fields()
+        assert historical.options.blob_descriptor_fields() == {'payload'}
         assert self._read(historical) == [{'id': 1, 'payload': payload}]
 
     def test_explicit_field_overrides_survive_repeated_copies(self):
@@ -185,3 +187,17 @@ class TestTimeTravelSchemaEvolution:
 
         historical = current.copy({'scan.snapshot-id': '1'})
         assert historical._applied_dynamic_options == {'scan.snapshot-id': '1'}
+
+    def test_descriptor_fallback_override_keeps_canonical_group(self):
+        original = self._create([
+            DataField(0, 'id', AtomicType('INT')), DataField(1, 'payload', 
AtomicType('BLOB')),
+        ], {'blob.stored-descriptor-fields': 'payload'})
+        self._write(original, {'id': 1, 'payload': None})
+        self.catalog.alter_table('test.t', [SchemaChange.add_column(
+            'reference', AtomicType('BYTES'), '__BLOB_DESCRIPTOR_FIELD')])
+        current = self.catalog.get_table('test.t')
+        historical = current.copy({'blob.stored-descriptor-fields': 
None}).copy({'scan.snapshot-id': '1'})
+        repeated = historical.copy({'read.batch-size': '1'})
+        for table in (historical, repeated):
+            assert table.table_schema.options['blob-descriptor-field'] == 
'payload,reference'
+            assert 'blob.stored-descriptor-fields' not in 
table.table_schema.options
diff --git 
a/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py 
b/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py
index 3ce93701ee..8f88e872d9 100644
--- a/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py
+++ b/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py
@@ -40,7 +40,7 @@ class TableUpdateByRowIdChunkedTest(unittest.TestCase):
         table.fields = []
         options = table.options
         options.file_format.return_value = 'parquet'
-        options.variant_shredding_enabled.return_value = False
+        options.variant_shredding_schema.return_value = None
         options.data_evolution_row_sidecar_enabled.return_value = False
         options.with_vector_format.return_value = False
         options.changelog_producer.return_value = ChangelogProducer.NONE
@@ -59,7 +59,7 @@ class TableUpdateByRowIdChunkedTest(unittest.TestCase):
         table.fields = [field]
         options = table.options
         options.file_format.return_value = 'parquet'
-        options.variant_shredding_enabled.return_value = False
+        options.variant_shredding_schema.return_value = None
         options.data_evolution_row_sidecar_enabled.return_value = False
         options.with_vector_format.return_value = False
         options.changelog_producer.return_value = ChangelogProducer.NONE
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 6c44aa00a6..1aee337e65 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -19,7 +19,8 @@
 
 import pyarrow as pa
 
-from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
+from pypaimon.common.options.core_options import ChangelogProducer
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.snapshot.time_travel_util import SCAN_KEYS
 from pypaimon.table.file_store_table import FileStoreTable
@@ -27,7 +28,7 @@ from pypaimon.write.native_commit import (
     create_native_write_table, from_native_commit_messages,
 )
 from pypaimon.write.native_write import native_write_available, 
_native_partition_types_supported
-from pypaimon.write.table_update_by_row_id import TableUpdateByRowId, 
_supports_parquet_row_id_update
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
 from pypaimon.write.row_utils import value_for_arrow
 
 
@@ -37,7 +38,12 @@ def _native_row_id_table(table):
             or not table.options.native_write_enabled()
             or not table.options.data_evolution_enabled()
             or not table.options.row_tracking_enabled()
-            or not _supports_parquet_row_id_update(table)
+            or table.is_primary_key_table
+            or table.options.file_format() != 'parquet'
+            or table.options.data_evolution_row_sidecar_enabled(False)
+            or table.options.with_vector_format()
+            or table.options.video_frame_fields()
+            or table.options.changelog_producer() != ChangelogProducer.NONE
             or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
             or not native_write_available()):
         return None
@@ -79,6 +85,8 @@ def _native_update_paths_supported(table):
 
 def create_native_update(table, commit_user, columns):
     """Use the public core updater for direct and grouped row-ID updates."""
+    if not _native_update_columns_supported(table, columns):
+        return None
     native_table = _native_row_id_table(table)
     if native_table is None:
         return None
@@ -92,6 +100,11 @@ def create_native_update(table, commit_user, columns):
 
 def create_native_update_by_row_id(table, commit_user, commit_identifier):
     """Create a core updater sharing one snapshot across incremental calls."""
+    # Its columns are selected on each later call. Until core supports raw
+    # Blob updates, retain one Python updater for the whole operation on such
+    # tables rather than switching after some files have already been staged.
+    if not _native_update_columns_supported(table, None):
+        return None
     native_table = _native_row_id_table(table)
     if native_table is None:
         return None
@@ -114,8 +127,20 @@ def _supported_upsert_key_type(data_type):
     ))
 
 
+def _native_update_columns_supported(table, columns):
+    """Java updatableBlobFields: descriptor and view fields are inline."""
+    if type(table) is not FileStoreTable:
+        return False
+    inline = table.options.blob_descriptor_fields() | 
table.options.blob_view_fields()
+    names = set(table.field_names if columns is None else columns)
+    return not any(field.name in names and is_blob_file_field(field)
+                   and field.name not in inline for field in table.fields)
+
+
 def create_native_upsert(table, commit_user, data, keys, columns):
     """Prepare one core upsert from full Arrow rows or named row values."""
+    if not _native_update_columns_supported(table, columns):
+        return None
     native_table = _native_row_id_table(table)
     if native_table is None:
         return None
@@ -144,8 +169,10 @@ def create_native_upsert(table, commit_user, data, keys, 
columns):
     return NativeTableUpsert(table, writer, keys, data)
 
 
-def create_native_predicate_update(table, commit_user, predicate):
+def create_native_predicate_update(table, commit_user, predicate, 
columns=None):
     """Prepare a public core operation before any assignment can run."""
+    if not _native_update_columns_supported(table, columns):
+        return None
     native_table = _native_row_id_table(table)
     if native_table is None:
         return None
@@ -256,7 +283,8 @@ class NativeTableUpdateByRowId(TableUpdateByRowId):
         return from_native_commit_messages(self.table, messages)
 
     def _write_row_columns(self, data, column_names, blob_object_columns):
-        # This adapter is selected only for plain Parquet tables without blobs.
+        # Row input normalization happens in the public Python API; physical
+        # field layouts and Blob references are handled by the core updater.
         return self.update_columns(data, column_names)
 
 
diff --git a/paimon-python/pypaimon/write/row_utils.py 
b/paimon-python/pypaimon/write/row_utils.py
index 7ee2e45c41..c70ea44a7c 100644
--- a/paimon-python/pypaimon/write/row_utils.py
+++ b/paimon-python/pypaimon/write/row_utils.py
@@ -21,7 +21,7 @@ from typing import Any, Dict, Iterable, List
 import pyarrow as pa
 
 from pypaimon.schema.data_types import DataField, PyarrowFieldParser, 
is_blob_file_field
-from pypaimon.table.row.blob import Blob
+from pypaimon.table.row.blob import Blob, BlobView
 from pypaimon.table.row.internal_row import InternalRow
 from pypaimon.table.row.vector import Vector
 
@@ -62,6 +62,21 @@ def require_columns(
         )
 
 
+def inline_blob_value(value: Any, descriptor: bool, view: bool) -> Any:
+    """Serialize row objects to inline references without opening payloads."""
+    if descriptor and isinstance(value, Blob):
+        try:
+            return value.to_descriptor().serialize()
+        except Exception as error:
+            raise ValueError(
+                "blob-descriptor-field row values must be serialized "
+                "BlobDescriptor bytes or a Blob with a descriptor."
+            ) from error
+    if view and isinstance(value, BlobView):
+        return value.view_struct.serialize()
+    return value
+
+
 def value_for_arrow(value: Any, field: DataField) -> Any:
     if isinstance(value, Vector):
         return value.to_list()
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index e90b45195e..a2177c2efe 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -306,7 +306,7 @@ class TableUpdate:
             from pypaimon.write.native_update import 
create_native_predicate_update
             try:
                 native = create_native_predicate_update(
-                    self.table, self.commit_user, predicate)
+                    self.table, self.commit_user, predicate, list(assignments))
             except Exception as error:
                 logger.debug('Native predicate update preparation failed: %s', 
error)
             else:
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py 
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index ad6b47334b..32de0a2a98 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -42,6 +42,7 @@ from pypaimon.write.commit_message import CommitMessage
 from pypaimon.write.file_store_write import FileStoreWrite
 from pypaimon.write.row_id_file_index import RowIdFileIndex
 from pypaimon.write.row_utils import (
+    inline_blob_value,
     require_columns,
     row_to_named_values,
     value_for_arrow,
@@ -62,8 +63,7 @@ def _supports_parquet_row_id_update(table):
     return (not table.is_primary_key_table
             and options.file_format(CoreOptions.FILE_FORMAT_PARQUET)
             == CoreOptions.FILE_FORMAT_PARQUET
-            and not (options.variant_shredding_enabled()
-                     and options.variant_shredding_schema())
+            and not options.variant_shredding_schema()
             and not options.data_evolution_row_sidecar_enabled(False)
             and not options.with_vector_format()
             and options.changelog_producer() == ChangelogProducer.NONE
@@ -337,6 +337,8 @@ class TableUpdateByRowId:
         ]
         fields = [pa.field(SpecialFields.ROW_ID.name, pa.int64())]
         blob_object_columns: Dict[str, List[Any]] = {}
+        descriptor_fields = self.table.options.blob_descriptor_fields()
+        view_fields = self.table.options.blob_view_fields()
 
         for col_name in column_names:
             if self._is_blob_column(col_name):
@@ -351,7 +353,9 @@ class TableUpdateByRowId:
             arrays.append(
                 pa.array(
                     [
-                        value_for_arrow(values_by_name[col_name], table_field)
+                        value_for_arrow(inline_blob_value(
+                            values_by_name[col_name],
+                            col_name in descriptor_fields, col_name in 
view_fields), table_field)
                         for _, values_by_name in row_entries
                     ],
                     type=arrow_field.type,
@@ -480,8 +484,11 @@ class TableUpdateByRowId:
         # added after the original file was written, reading only those
         # missing columns can otherwise produce a zero-row table instead of
         # one null value per original row.
+        read_table = self.table
+        if wanted.intersection(self.table.options.blob_descriptor_fields() | 
self.table.options.blob_view_fields()):
+            read_table = self.table.copy({'blob-as-descriptor': 'true', 
'blob-view.resolve.enabled': 'false'})
         table_read = TableRead(
-            self.table,
+            read_table,
             predicate=None,
             read_type=read_fields + [SpecialFields.ROW_ID],
         )
@@ -858,6 +865,8 @@ class TableUpdateByRowId:
         return pa.array(pylist, type=target_type)
 
     def _is_blob_column(self, column_name: str) -> bool:
+        if column_name in (self.table.options.blob_descriptor_fields() | 
self.table.options.blob_view_fields()):
+            return False
         for table_field in self.table.fields:
             if table_field.name == column_name:
                 return is_blob_file_field(table_field)
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index 731727fbc2..a00287caf7 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -99,8 +99,7 @@ class DataWriter(ABC):
         )
         # Variant shredding (static mode) — col_name → (obj_fields, 
target_arrow_type)
         self._variant_shredding: Dict[str, Tuple] = {}
-        if self.file_format == CoreOptions.FILE_FORMAT_PARQUET \
-                and self.options.variant_shredding_enabled():
+        if self.file_format == CoreOptions.FILE_FORMAT_PARQUET:
             shredding_json = self.options.variant_shredding_schema()
             if shredding_json:
                 from pypaimon.data.variant_shredding import (
diff --git a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py 
b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
index dd0bb4096a..6ecfb23ad8 100644
--- a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
+++ b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
@@ -29,11 +29,11 @@ from pypaimon.schema.data_types import (
     is_blob_type,
 )
 from pypaimon.table.row.blob import (
-    Blob,
     BlobConsumer,
     video_payload_descriptor,
 )
 from pypaimon.write.row_utils import (
+    inline_blob_value,
     require_columns,
     row_to_named_values,
     row_values_to_arrow_table,
@@ -317,25 +317,8 @@ class DedicatedFormatWriter(CompositeDataWriter):
             raise e
 
     def _normal_row_value(self, field_name: str, value):
-        if field_name in self.blob_descriptor_fields and value is not None:
-            if isinstance(value, Blob):
-                try:
-                    return value.to_descriptor().serialize()
-                except Exception as e:
-                    raise ValueError(
-                        "blob-descriptor-field row values must be serialized "
-                        "BlobDescriptor bytes or a Blob with a descriptor."
-                    ) from e
-            return value
-
-        if field_name in self.blob_view_fields and value is not None:
-            from pypaimon.table.row.blob import BlobView
-
-            if isinstance(value, BlobView):
-                return value.view_struct.serialize()
-            return value
-
-        return value
+        return inline_blob_value(value, field_name in 
self.blob_descriptor_fields,
+                                 field_name in self.blob_view_fields)
 
     def abort(self):
         """Abort all writers and clean up resources."""

Reply via email to