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 64dc7c1cbb [Python] Enable native column-subset writes and upserts
(#10411)
64dc7c1cbb is described below
commit 64dc7c1cbb81f01d437083da0d4a5568e11c5b50
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 12:49:10 2026 +0800
[Python] Enable native column-subset writes and upserts (#10411)
---
paimon-python/pypaimon/tests/native_update_test.py | 7 +-
.../tests/native_upsert_capabilities_test.py | 73 +++++++++-
paimon-python/pypaimon/tests/native_write_test.py | 150 ++++++++++++++++++++-
.../pypaimon/tests/test_sequence_field_write.py | 20 +--
paimon-python/pypaimon/write/native_update.py | 20 ++-
paimon-python/pypaimon/write/native_write.py | 43 ++++--
paimon-python/pypaimon/write/row_key_extractor.py | 23 +++-
.../pypaimon/write/table_upsert_by_key.py | 4 +-
8 files changed, 294 insertions(+), 46 deletions(-)
diff --git a/paimon-python/pypaimon/tests/native_update_test.py
b/paimon-python/pypaimon/tests/native_update_test.py
index eb5870a07c..e0d6ceca12 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -741,7 +741,7 @@ def
test_native_row_upsert_uses_public_operation_with_composite_null_keys(tmp_pa
@pytest.mark.parametrize('case', ['partial', 'float-key', 'empty-columns'])
-def test_row_upsert_unsupported_inputs_keep_python_semantics(tmp_path, case):
+def test_row_upsert_input_capabilities(tmp_path, case):
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.native_update import NativeTableUpsert
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
@@ -782,8 +782,9 @@ def
test_row_upsert_unsupported_inputs_keep_python_semantics(tmp_path, case):
'id': [1, 2], 'age': [10, 20], 'region': ['east', 'west'],
}
return
- with patch.object(NativeTableUpsert, 'upsert',
- side_effect=AssertionError('Unsupported native upsert
selected')):
+ from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+ with patch.object(TableUpsertByKey, '_upsert_row_partition',
+ side_effect=AssertionError('Python upsert fallback')):
messages =
builder.new_update().with_update_type(['age']).upsert_by_key(rows, ['id'])
builder.new_commit().commit(messages)
read = table.new_read_builder()
diff --git a/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py
b/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py
index 888edf112e..b1ae5e3f11 100644
--- a/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py
+++ b/paimon-python/pypaimon/tests/native_upsert_capabilities_test.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Native upserts preserve temporal keys and read only input partitions."""
+"""Native upserts preserve typed keys, partial inputs and partition
matching."""
from contextlib import ExitStack
from unittest.mock import patch
@@ -32,6 +32,77 @@ from pypaimon.write.table_upsert_by_key import
TableUpsertByKey
pytestmark = pytest.mark.native_plan
[email protected]('native', [False, True])
[email protected]('streaming', [False, True])
[email protected]('row_input', [False, True])
[email protected]('key_type', [pa.int32(), pa.float32(), pa.float64()])
+def test_partial_upsert_keys_and_values(tmp_path, native, streaming,
row_input, key_type):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ schema = pa.schema([('key', key_type), ('keep', pa.string()),
+ ('pt', pa.string()), ('value', pa.int32())])
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
partition_keys=['pt'], options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'write.native.enabled': str(native).lower(),
+ }), False)
+ table = catalog.get_table('default.t')
+ schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+ existing_key = 1 if pa.types.is_integer(key_type) else 1.25
+ new_key = 2 if pa.types.is_integer(key_type) else 2.5
+ seed = pa.table({'key': [existing_key] * 3 + [None], 'keep': ['a', 'b',
'c', 'd'],
+ 'pt': ['a', 'a', 'b', 'a'], 'value': [10, 11, 20, 30]},
schema=schema)
+ batch_builder = table.new_batch_write_builder()
+ writer = batch_builder.new_write()
+ try:
+ writer.write_arrow(seed)
+ batch_builder.new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ # Reordered subset, duplicate source key, NULL key, and a new key. The
+ # same key in another partition must retain its value.
+ partial_schema = pa.schema([schema.field(name) for name in ['value', 'pt',
'key']])
+ source = pa.table({'value': [12, 40, 31, 13], 'pt': ['a'] * 4,
+ 'key': [existing_key, new_key, None, existing_key]},
schema=partial_schema)
+ builder = table.new_stream_write_builder() if streaming else batch_builder
+ update = builder.new_update().with_update_type(['value'])
+ with ExitStack() as stack:
+ if native:
+ for method in ('_upsert_partition', '_upsert_row_partition'):
+ stack.enter_context(patch.object(TableUpsertByKey, method,
+
side_effect=AssertionError('Python upsert fallback')))
+ if row_input:
+ fields = [field for field in table.fields if field.name in
source.column_names]
+ rows = [GenericRow([row[field.name] for field in fields], fields)
+ for row in source.to_pylist()]
+ messages = update.upsert_by_key(rows, ['key'], 7) if streaming
else update.upsert_by_key(rows, ['key'])
+ else:
+ # Two chunks exercise core batch concatenation after normalization.
+ source = pa.concat_tables([source.slice(0, 2), source.slice(2)])
+ messages = (update.upsert_by_arrow_with_key(source, ['key'], 7) if
streaming
+ else update.upsert_by_arrow_with_key(source, ['key']))
+ for message in messages:
+ for file in message.new_files:
+ assert file.write_cols is not None and 'keep' not in
file.write_cols
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 7) if streaming else commit.commit(messages)
+ finally:
+ commit.close()
+ expected = [
+ {'key': existing_key, 'keep': 'a', 'pt': 'a', 'value': 13},
+ {'key': existing_key, 'keep': 'b', 'pt': 'a', 'value': 13},
+ {'key': existing_key, 'keep': 'c', 'pt': 'b', 'value': 20},
+ {'key': None, 'keep': 'd', 'pt': 'a', 'value': 31},
+ {'key': new_key, 'keep': None, 'pt': 'a', 'value': 40},
+ ]
+ for native_read in (False, True):
+ copy = table.copy({'read.native.enabled': str(native_read).lower(),
+ 'scan.native-plan.enabled':
str(native_read).lower()})
+ reader = copy.new_read_builder()
+ actual = reader.new_read().to_arrow(reader.new_scan().plan().splits())
+ assert sorted(actual.to_pylist(), key=lambda row: row['keep'] or 'z')
== expected
+
+
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('streaming', [False, True])
@pytest.mark.parametrize('row_input', [False, True])
diff --git a/paimon-python/pypaimon/tests/native_write_test.py
b/paimon-python/pypaimon/tests/native_write_test.py
index 85145cfa52..132d651ed2 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -304,13 +304,161 @@ def
test_writer_abort_preserves_native_commit_files(native_rest_catalog, failure
commit.close()
+@requires_native
[email protected]('evolution,omitted_type', [
+ (False, pa.string()), (True, pa.string()), (True, pa.large_binary()),
+])
[email protected]('streaming', [False, True])
[email protected]('input_kind', ['arrow', 'full-arrow', 'pandas',
'row'])
+def test_partial_native_write_preserves_write_columns(tmp_path, evolution,
streaming, input_kind, omitted_type):
+ from pypaimon.schema.data_types import PyarrowFieldParser
+ from pypaimon.table.row.generic_row import GenericRow
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ schema = pa.schema([('id', pa.int32()), ('omitted', omitted_type), ('pt',
pa.string())])
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
partition_keys=['pt'], options={
+ 'write.native.enabled': 'true', 'row-tracking.enabled':
str(evolution).lower(),
+ 'data-evolution.enabled': str(evolution).lower(),
+ }), False)
+ table = catalog.get_table('default.t')
+ builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
+ writer = builder.new_write()
+ try:
+ assert writer.with_write_type(['pt', 'id']) is writer
+ assert writer._python_writer is None
+ partial =
pa.schema([PyarrowFieldParser.from_paimon_schema(table.fields).field(name)
+ for name in ['pt', 'id']])
+ data = pa.table({'pt': ['b', 'a', None], 'id': [1, 2, 3]},
schema=partial)
+ with patch.object(NativeTableWrite, '_switch_to_python',
+ side_effect=AssertionError('Partial write
fallback')):
+ if input_kind == 'arrow':
+ writer.write_arrow(data)
+ elif input_kind == 'full-arrow':
+ full_schema =
PyarrowFieldParser.from_paimon_schema(table.fields)
+ # Invalid Blob descriptor bytes in an omitted field must never
+ # be parsed or copied by the core writer.
+ omitted = b'not a descriptor' if
pa.types.is_large_binary(omitted_type) else 'ignored'
+ writer.write_arrow(pa.table({'id': [1, 2, 3], 'omitted':
[omitted] * 3,
+ 'pt': ['b', 'a', None]},
schema=full_schema))
+ elif input_kind == 'pandas':
+ writer.write_pandas(data.to_pandas())
+ else:
+ fields = [field for name in ['pt', 'id'] for field in
table.fields if field.name == name]
+ for row in data.to_pylist():
+ writer.write_row(GenericRow([row[field.name] for field in
fields], fields))
+ messages = writer.prepare_commit(7) if streaming else
writer.prepare_commit()
+ assert all(file.write_cols == ['pt', 'id'] for message in messages for
file in message.new_files)
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 7) if streaming else
commit.commit(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ for native_read in (False, True):
+ copy = table.copy({'read.native.enabled': str(native_read).lower(),
+ 'scan.native-plan.enabled':
str(native_read).lower()})
+ assert _rows(copy) == [
+ {'id': 1, 'omitted': None, 'pt': 'b'},
+ {'id': 2, 'omitted': None, 'pt': 'a'},
+ {'id': 3, 'omitted': None, 'pt': None},
+ ]
+
+
+@requires_native
[email protected]('arrow_groups', [False, True])
[email protected]('native', [False, True])
[email protected]('fixed_bucket', [False, True])
+def test_partial_write_routes_input_partition_and_bucket_columns(tmp_path,
arrow_groups, native, fixed_bucket):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ schema = pa.schema([('id', pa.int32()), ('keep', pa.string()), ('pt',
pa.string())])
+ options = {'write.native.enabled': str(native).lower()}
+ if fixed_bucket:
+ options.update({'bucket': '4', 'bucket-key': 'id'})
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
partition_keys=['pt'], options=options), False)
+ table = catalog.get_table('default.t')
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write().with_write_type(['pt', 'id'])
+ try:
+ data = pa.table({'pt': ['b', 'a', None], 'id': [1, 2, 3]},
+ schema=pa.schema([schema.field('pt'),
schema.field('id')]))
+ with
patch('pypaimon.write.row_key_extractor._ARROW_GROUP_BY_SUPPORTED',
arrow_groups):
+ writer.write_arrow(data)
+ builder.new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ assert _rows(table) == [
+ {'id': 1, 'keep': None, 'pt': 'b'},
+ {'id': 2, 'keep': None, 'pt': 'a'},
+ {'id': 3, 'keep': None, 'pt': None},
+ ]
+
+
+@requires_native
+def
test_blob_only_partial_write_uses_native_and_updates_existing_range(tmp_path):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ schema = pa.schema([('id', pa.int32()), ('payload', pa.large_binary())])
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
options={
+ 'write.native.enabled': 'true', 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ }), False)
+ table = catalog.get_table('default.t')
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write().with_write_type(['id'])
+ try:
+ writer.write_arrow(pa.table({'id': [1, 2, 3]},
schema=pa.schema([schema.field('id')])))
+ builder.new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ writer = builder.new_write().with_write_type(['payload'])
+ try:
+ with patch.object(NativeTableWrite, '_switch_to_python',
+ side_effect=AssertionError('Blob-only write
fallback')):
+ writer.write_arrow(pa.table(
+ {'payload': [b'first', None, b'third']},
+ schema=pa.schema([schema.field('payload')]),
+ ))
+ messages = writer.prepare_commit()
+ files = [file for message in messages for file in message.new_files]
+ assert len(files) == 1 and files[0].file_name.endswith('.blob')
+ assert files[0].row_count == 3 and files[0].write_cols == ['payload']
+ files[0].first_row_id = 0
+ builder.new_commit().commit(messages)
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'payload': b'first'},
+ {'id': 2, 'payload': None}, {'id': 3, 'payload':
b'third'}]
+
+
+@requires_native
[email protected]('streaming', [False, True])
+def test_primary_key_full_write_type_selects_writer_before_staging(tmp_path,
streaming):
+ table = _table(tmp_path, primary_key=True)
+ builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
+ writer = builder.new_write()
+ try:
+ assert writer.with_write_type(table.field_names) is
writer._python_writer
+ writer.write_arrow_batch(_batch([1], ['a']))
+ messages = writer.prepare_commit(7) if streaming else
writer.prepare_commit()
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages, 7) if streaming else
commit.commit(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}]
+
+
@requires_native
def test_advanced_api_switches_before_write_and_rejects_late_switch(tmp_path):
table = _table(tmp_path)
builder = table.new_batch_write_builder()
writer = builder.new_write()
assert isinstance(writer, NativeTableWrite)
- assert writer.with_write_type(['id', 'pt']) is writer._python_writer
+ assert writer.with_blob_uri_reader_factory(None) is writer._python_writer
assert writer._native_writer is None
writer.write_arrow_batch(_batch([1], ['a']))
builder.new_commit().commit(writer.prepare_commit())
diff --git a/paimon-python/pypaimon/tests/test_sequence_field_write.py
b/paimon-python/pypaimon/tests/test_sequence_field_write.py
index 58004b5401..5a3231189f 100644
--- a/paimon-python/pypaimon/tests/test_sequence_field_write.py
+++ b/paimon-python/pypaimon/tests/test_sequence_field_write.py
@@ -183,9 +183,8 @@ def test_floating_sequence_order(make_table, type_, order,
grouping, streaming):
}, sequence_type=type_)
# Pairs are in Java Float/Double.compare order, with null always first.
pairs = [
- (1.0, float('nan')), (float('inf'), float('nan')),
(float('-inf'), 1.0), (1.0, float('inf')),
- (-0.0, 0.0), (None, float('nan')), (None, 0.0),
+ (-0.0, 0.0), (None, 0.0),
]
rows, expected = [], []
for low, high in pairs:
@@ -196,7 +195,7 @@ def test_floating_sequence_order(make_table, type_, order,
grouping, streaming):
rows.extend(reversed(pair) if reverse else pair)
expected.append({'id': key, 'val': 'high' if low is None or order
== 'ascending'
else 'low'})
- for values in ((float('nan'), -float('nan')), (None, None), (-0.0, -0.0)):
+ for values in ((None, None), (-0.0, -0.0)):
key = len(expected)
rows.extend({'id': key, 'seq': value, 'val': label}
for value, label in zip(values, ('first', 'last')))
@@ -205,21 +204,6 @@ def test_floating_sequence_order(make_table, type_, order,
grouping, streaming):
assert read_rows(table, ['id', 'val']) == expected
[email protected]('order', ['ascending', 'descending'])
[email protected]('grouping', ['batch', 'commits'])
-def test_nan_ties_compare_next_sequence_field(make_table, order, grouping):
- table, schema = make_table({
- 'sequence.field': 'seq,seq2', 'sequence.field.sort-order': order,
- 'write.native.enabled': 'true', 'read.native.enabled': 'true',
- }, sequence_type=pa.float64())
- write_rows(table, schema, [
- {'id': 1, 'seq': float('nan'), 'seq2': 2, 'val': 'high'},
- {'id': 1, 'seq': -float('nan'), 'seq2': 1, 'val': 'low'},
- ], grouping)
- assert read_rows(table, ['id', 'val']) == [
- {'id': 1, 'val': 'high' if order == 'ascending' else 'low'}]
-
-
@pytest.mark.parametrize('streaming', [False, True])
@pytest.mark.parametrize('direct', [False, True])
@pytest.mark.parametrize('sequence,type_,error', [
diff --git a/paimon-python/pypaimon/write/native_update.py
b/paimon-python/pypaimon/write/native_update.py
index ec077b7b30..96b93109cb 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -122,6 +122,7 @@ def _supported_upsert_key_type(data_type):
pa.types.is_large_string, pa.types.is_binary, pa.types.is_large_binary,
pa.types.is_fixed_size_binary, pa.types.is_date, pa.types.is_decimal,
pa.types.is_time, pa.types.is_timestamp,
+ pa.types.is_floating,
))
@@ -136,7 +137,7 @@ def _native_update_columns_supported(table, columns):
def create_native_upsert(table, commit_user, data, keys, columns):
- """Prepare one core upsert from full Arrow rows or named row values."""
+ """Prepare one core upsert from Arrow columns or named row values."""
if table.options.video_frame_fields() or not
_native_update_columns_supported(table, columns):
# An upsert can append unmatched rows; packed video writing is still
# provided by the Python writer.
@@ -149,18 +150,23 @@ def create_native_upsert(table, commit_user, data, keys,
columns):
if any(not _supported_upsert_key_type(schema.field(key).type) for key in
set(keys + table.partition_keys)):
return None
if not isinstance(data, pa.Table):
- # Missing fields retain their row-object semantics on the fallback
- # path; converting them to Arrow NULLs would change update behavior.
- if not columns or any(set(values) != set(schema.names) for values in
data):
+ # Keep absent fields absent. Heterogeneous row field sets still need
+ # the row-aware updater to distinguish absence from an explicit NULL.
+ if not columns or not data:
return None
+ names = set(data[0])
+ if not names <= set(schema.names) or any(set(values) != names for
values in data):
+ return None
+ fields = [field for field in fields if field.name in names]
+ schema = pa.schema([schema.field(field.name) for field in fields])
data = pa.Table.from_pydict({
field.name: [value_for_arrow(values[field.name], field) for values
in data]
for field in fields
}, schema=schema)
- if (len(data.column_names) != len(schema.names)
- or set(data.column_names) != set(schema.names)
+ if (len(data.column_names) != len(set(data.column_names))
+ or not set(data.column_names) <= set(schema.names)
or any(data.schema.field(name).type != schema.field(name).type
- for name in schema.names)):
+ for name in data.column_names)):
return None
writer = (native_table.new_batch_write_builder()
._with_commit_user(commit_user)
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index 43cf8e40c2..317940226d 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -103,7 +103,9 @@ class NativeTableWrite:
self._native_writer = native_writer
self._python_writer = None
self._written = False
- self._schema =
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+ self._table_schema =
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+ self._schema = self._table_schema
+ self._write_cols = None
def _switch_to_python(self):
if self._python_writer is not None:
@@ -119,14 +121,33 @@ class NativeTableWrite:
def _new_python_writer(self):
from pypaimon.write.table_write import BatchTableWrite,
StreamTableWrite
if self.stream:
- return StreamTableWrite(self.table, self.commit_user)
- return BatchTableWrite(self.table, self.commit_user,
self.static_partition)
+ writer = StreamTableWrite(self.table, self.commit_user)
+ else:
+ writer = BatchTableWrite(self.table, self.commit_user,
self.static_partition)
+ if self._write_cols is not None:
+ writer.with_write_type(self._write_cols)
+ return writer
def __getattr__(self, name):
if name.startswith('_'):
raise AttributeError(name)
return getattr(self._switch_to_python(), name)
+ def with_write_type(self, write_cols):
+ if self._python_writer is not None:
+ return self._python_writer.with_write_type(write_cols)
+ if self._written:
+ raise RuntimeError('with_write_type must be called before writing;
after native data it cannot change')
+ if self.table.is_primary_key_table:
+ # Core's write type is an append-table operation. Keep the existing
+ # Python writer contract for PK setters before any data is staged.
+ return self._switch_to_python().with_write_type(write_cols)
+ names = list(write_cols)
+ self._native_writer.with_write_type(names)
+ self._schema = pa.schema([self._table_schema.field(name) for name in
names])
+ self._write_cols = names
+ return self
+
def write_arrow(self, data):
if self._python_writer is not None:
return self._python_writer.write_arrow(data)
@@ -138,13 +159,19 @@ class NativeTableWrite:
def write_arrow_batch(self, data):
if self._python_writer is not None:
return self._python_writer.write_arrow_batch(data)
+ if self._write_cols is not None and arrow_schemas_compatible(
+ data.schema, self._table_schema,
check_top_level_nullability=False,
+ allow_binary_compatibility=True):
+ # Python accepts either the table schema or the selected schema.
+ # Project the input before handing its actual write type to core.
+ data = data.select(self._write_cols)
if not arrow_schemas_compatible(
data.schema, self._schema, check_top_level_nullability=False,
allow_binary_compatibility=True):
raise ValueError(
"Input schema isn't consistent with table schema and write
cols. "
f"Input schema is: {data.schema} Table schema is:
{self._schema} "
- "Write cols is: None")
+ f"Write cols is: {self._write_cols}")
data = normalize_arrow_strings(data)
if data.num_rows:
# A failed native write may already have produced files. Never
@@ -155,18 +182,18 @@ class NativeTableWrite:
def write_pandas(self, dataframe):
if self._python_writer is not None:
return self._python_writer.write_pandas(dataframe)
- schema =
PyarrowFieldParser.from_paimon_schema(self.table.table_schema.fields)
- self.write_arrow_batch(pa.RecordBatch.from_pandas(dataframe,
schema=schema))
+ self.write_arrow_batch(pa.RecordBatch.from_pandas(dataframe,
schema=self._schema))
def write_row(self, row):
if self._python_writer is not None:
return self._python_writer.write_row(row)
- if any(is_blob_file_field(field) for field in
self.table.table_schema.fields):
+ if any(is_blob_file_field(field) and field.name in self._schema.names
+ for field in self.table.table_schema.fields):
# Select the row-aware writer from the first row, including byte
# values: later rows may provide custom Blob streams or URI
readers.
return self._switch_to_python().write_row(row)
values = row_to_named_values(row, self.table.table_schema.fields)
- names = list(self.table.field_names)
+ names = self._schema.names
self.write_arrow_batch(row_values_to_arrow_table(
values, self.table.table_schema.fields, names).to_batches()[0])
diff --git a/paimon-python/pypaimon/write/row_key_extractor.py
b/paimon-python/pypaimon/write/row_key_extractor.py
index 16c46fa6f2..1cb629b7b2 100644
--- a/paimon-python/pypaimon/write/row_key_extractor.py
+++ b/paimon-python/pypaimon/write/row_key_extractor.py
@@ -175,9 +175,9 @@ class RowKeyExtractor(ABC):
num_rows = data.num_rows
columns = {}
key_names = []
- for k, pi in enumerate(self.partition_indices):
+ for k, column in enumerate(self._input_columns(data,
self.partition_indices)):
name = f"__p{k}"
- columns[name] = data.column(pi)
+ columns[name] = column
key_names.append(name)
columns["__bucket"] = pa.array(buckets, type=pa.int32())
key_names.append("__bucket")
@@ -248,7 +248,7 @@ class RowKeyExtractor(ABC):
if not self.partition_indices:
return [() for _ in range(data.num_rows)]
- partition_columns = [data.column(i) for i in self.partition_indices]
+ partition_columns = self._input_columns(data, self.partition_indices)
partitions = []
for row_idx in range(data.num_rows):
@@ -257,6 +257,17 @@ class RowKeyExtractor(ABC):
return partitions
+ def _input_columns(self, data: pa.RecordBatch, field_indices: List[int]):
+ """Resolve routing fields in the actual write type, as Java's
converter does."""
+ columns = []
+ for field_index in field_indices:
+ name = self.table_schema.fields[field_index].name
+ index = data.schema.get_field_index(name)
+ if index < 0:
+ raise ValueError(f"Input must contain exactly one routing
column '{name}'.")
+ columns.append(data.column(index))
+ return columns
+
@staticmethod
def _binary_row_hash_code(values: Tuple, fields: List) -> int:
return _hash_bytes_by_words(
@@ -292,7 +303,7 @@ class FixedBucketRowKeyExtractor(RowKeyExtractor):
self._bucket_key_fields = table_schema.logical_bucket_key_fields
def _extract_buckets_batch(self, data: pa.RecordBatch) -> List[int]:
- columns = [data.column(i) for i in self.bucket_key_indices]
+ columns = self._input_columns(data, self.bucket_key_indices)
return [
_bucket_from_hash(
self._binary_row_hash_code(
@@ -519,7 +530,7 @@ class DynamicBucketRowKeyExtractor(RowKeyExtractor):
return partitions, partition_hashes, key_hashes
def _extract_key_hashes_batch(self, data: pa.RecordBatch) -> List[int]:
- key_columns = [data.column(i) for i in self.bucket_key_indices]
+ key_columns = self._input_columns(data, self.bucket_key_indices)
return [
self._binary_row_hash_code(
tuple(column[row_idx].as_py() for column in key_columns),
@@ -765,7 +776,7 @@ class PostponeFixedBucketRowKeyExtractor(RowKeyExtractor):
self, data: pa.RecordBatch
) -> Tuple[List[Tuple], List[int]]:
partitions = self._extract_partitions_batch(data)
- columns = [data.column(i) for i in self.bucket_key_indices]
+ columns = self._input_columns(data, self.bucket_key_indices)
buckets = [
_bucket_from_hash(
self._binary_row_hash_code(
diff --git a/paimon-python/pypaimon/write/table_upsert_by_key.py
b/paimon-python/pypaimon/write/table_upsert_by_key.py
index f3b4064048..c175b375f2 100644
--- a/paimon-python/pypaimon/write/table_upsert_by_key.py
+++ b/paimon-python/pypaimon/write/table_upsert_by_key.py
@@ -589,8 +589,8 @@ class TableUpsertByKey:
"""
Append rows that have no matching upsert key.
- New rows are always written with *all* columns — ``update_cols``
- only restricts which columns are rewritten for *matched* rows.
+ New rows write all supplied columns; omitted columns stay absent from
+ the file. ``update_cols`` only restricts matched-row updates.
:class:`StreamTableWrite` is used so the produced commit messages
carry this upsert's ``commit_identifier``; in batch mode the