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 23e6f84c9b [python] Run data evolution MERGE through native core
(#10418)
23e6f84c9b is described below
commit 23e6f84c9be3d76e750fca545bfc845729cc7299
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 09:32:49 2026 +0800
[python] Run data evolution MERGE through native core (#10418)
---
paimon-python/README.md | 16 ++
paimon-python/conftest.py | 4 +-
.../pypaimon/table/data_evolution_merge_into.py | 30 ++-
.../pypaimon/tests/native_merge_into_test.py | 230 +++++++++++++++++++++
paimon-python/pypaimon/write/native_merge_into.py | 97 +++++++++
5 files changed, 375 insertions(+), 2 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index dedf06db7d..a6cbfcbd75 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -168,6 +168,22 @@ path select the Python writer before native data is
written. If the runtime or t
unavailable, write uses Python. Once Rust starts writing a batch, errors
propagate without retrying that batch through Python.
+Batch and stream `merge_into` also use Rust core for eligible data-evolution
+Parquet tables when `write.native.enabled=true`. Existing `WhenMatched` and
+`WhenNotMatched` clauses accept Arrow/pandas input or self-merge on `_ROW_ID`.
+Core pins the target snapshot, matches keys, selects the first satisfied clause
+and prepares updates, deletion vectors and inserts. SQL conditions, including
+subqueries, execute in the Rust DataFusion adapter; Python transports the
+normalized clauses and returned commit messages. Conditional MERGE requires
+Python 3.10+ and the existing `pypaimon[sql]` extra, which installs both Python
+DataFusion and `pypaimon-rust`. Shared clause validation checks that dependency
+before native execution. NULL keys do not match.
+Multiple source rows matching a target are rejected before action conditions,
+except for a sole unconditional DELETE, following Paimon Spark MERGE.
+Non-self table sources and packed-video inserts use the Python path selected
+before native execution starts. Native failures propagate without Python retry
+or deleting files from earlier prepared actions.
+
MAP columns configured with `fields.<name>.map.storage-layout=shared-shredding`
also use native Parquet writes and data-evolution updates, including predicate
updates and upserts. Rust applies Java's `plain`, `sequential` and `lru` column
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index 4288d9b997..0ca0ac11a0 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -28,7 +28,7 @@ _native_plan_count = 0
_native_read_count = 0
_native_write_count = 0
_native_commit_count = 0
-_native_update_counts = dict.fromkeys(('row_id', 'grouped', 'predicate',
'upsert', 'incremental'), 0)
+_native_update_counts = dict.fromkeys(('row_id', 'grouped', 'predicate',
'upsert', 'incremental', 'merge'), 0)
_force_native_for_test = False
_force_native_read_for_test = False
_force_native_write_for_test = False
@@ -160,6 +160,7 @@ def pytest_configure(config):
TableCommit._prepare_native_commit = tracked_prepare
if _native_update_enabled():
+ from pypaimon.write.native_merge_into import NativeTableMergeInto
from pypaimon.write.native_update import (
NativeBatchTableUpdate, NativePredicateTableUpdate,
NativeTableUpdateByRowId, NativeTableUpsert,
@@ -181,6 +182,7 @@ def pytest_configure(config):
track_update(NativePredicateTableUpdate, 'update', 'predicate')
track_update(NativeTableUpsert, 'upsert', 'upsert')
track_update(NativeTableUpdateByRowId, 'update_columns', 'incremental')
+ track_update(NativeTableMergeInto, 'prepare_commit', 'merge')
def pytest_collection_modifyitems(items):
diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
index 58c02d4517..d5baff21bc 100644
--- a/paimon-python/pypaimon/table/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -92,6 +92,14 @@ def merge_into(
The returned messages must be committed by the caller's matching
``TableCommit``.
"""
+ if target_table.options.native_write_enabled():
+ from pypaimon.write.native_merge_into import create_native_merge_into
+
+ native = create_native_merge_into(target_table, source, on,
when_matched, when_not_matched,
+ commit_user, commit_identifier)
+ if native is not None:
+ return native.prepare_commit()
+
base_snapshot = target_table.snapshot_manager().get_latest_snapshot()
source_table, matched_specs, not_matched_specs, ctx = _prepare(
target_table,
@@ -326,6 +334,14 @@ def _build_tables(
if matched_specs and base_snapshot is not None:
update_cols_union = _union_update_cols(matched_specs)
+ if not update_cols_union and not any(c.delete for c in matched_specs):
+ names = list(dict.fromkeys(ctx.target_on_cols +
[SpecialFields.ROW_ID.name]))
+ target = _read_table(target_table, projection=names,
snapshot_id=base_snapshot_id)
+ target = _rename_with_prefix(target, "t.")
+ _join_with_row_indices(target, _rename_with_prefix(source_table,
"s."),
+ ["t." + name for name in
ctx.target_on_cols],
+ ["s." + name for name in
ctx.source_on_cols],
+ "inner", check_cardinality=True)
if update_cols_union:
update_table = _build_matched_update_table(
target_table,
@@ -491,6 +507,7 @@ def _build_matched_update_table(
keys=["t.{}".format(c) for c in ctx.target_on_cols],
right_keys=["s.{}".format(c) for c in ctx.source_on_cols],
join_type="inner",
+ check_cardinality=True,
)
transform = _build_matched_transform(
clauses,
@@ -531,6 +548,8 @@ def _build_matched_delete_table(
keys=["t.{}".format(c) for c in ctx.target_on_cols],
right_keys=["s.{}".format(c) for c in ctx.source_on_cols],
join_type="inner",
+ check_cardinality=not (len(clauses) == 1 and clauses[0].delete and
clauses[0].condition is None),
+ deduplicate_left=len(clauses) == 1 and clauses[0].delete and
clauses[0].condition is None,
)
transform = _build_matched_delete_transform(
clauses,
@@ -579,7 +598,8 @@ def _build_not_matched_insert_table(
return transform(unmatched)
-def _join_with_row_indices(left, right, keys, right_keys, join_type):
+def _join_with_row_indices(left, right, keys, right_keys, join_type,
+ check_cardinality=False, deduplicate_left=False):
"""Keep nested payloads out of Arrow's join, then gather matched rows."""
key_names = ["key_%d" % index for index in range(len(keys))]
left_keys = left.select(keys).rename_columns(key_names).append_column(
@@ -589,6 +609,14 @@ def _join_with_row_indices(left, right, keys, right_keys,
join_type):
right_keys_table = right_keys_table.append_column(
"right_index", pa.array(range(right.num_rows), type=pa.int64()))
indices = left_keys.join(right_keys_table, keys=key_names,
join_type=join_type)
+ # Spark checks source-match cardinality before action predicates. A sole
+ # unconditional DELETE is unambiguous and may collapse duplicate matches.
+ if check_cardinality and pc.count_distinct(indices["left_index"]).as_py()
!= indices.num_rows:
+ raise ValueError("MERGE matched multiple source rows to the same
target _ROW_ID. "
+ "Deduplicate the source before merging.")
+ if deduplicate_left:
+ indices = indices.group_by("left_index", use_threads=False).aggregate([
+ ("right_index", "first")]).rename_columns(["left_index",
"right_index"])
result = left.take(indices["left_index"])
if join_type == "inner":
# Match Table.join's coalesced-key schema: keep only the left keys.
diff --git a/paimon-python/pypaimon/tests/native_merge_into_test.py
b/paimon-python/pypaimon/tests/native_merge_into_test.py
new file mode 100644
index 0000000000..d7713a70a5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_merge_into_test.py
@@ -0,0 +1,230 @@
+# 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.
+
+"""MERGE must match, select actions and stage files in native core."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.data_evolution_merge_into import WhenMatched,
WhenNotMatched, source_col, lit
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('name',
pa.string())])
+
+
+def _table(tmp_path, rows=None):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(_SCHEMA, options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'write.native.enabled': 'true', 'deletion-vectors.enabled': 'true',
+ }), False)
+ table = catalog.get_table('db.t')
+ if rows:
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ return table
+
+
+def _rows(table):
+ reader = table.new_read_builder()
+ return
sorted(reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist(),
+ key=lambda row: row['id'] if row['id'] is not None else -1)
+
+
+def test_native_merge_updates_and_inserts_without_python_join(tmp_path):
+ table = _table(tmp_path, [dict(id=1, value=10, name='old'), dict(id=2,
value=20, name='keep')])
+ source = pa.Table.from_pylist([dict(id=1, value=11, name='new'),
dict(id=3, value=30, name='insert')],
+ schema=_SCHEMA)
+ builder = table.new_batch_write_builder()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python merge orchestration')):
+ messages = builder.new_update().merge_into(source, on=['id'],
+
when_matched=[WhenMatched.update({'value': source_col('value')})],
+
when_not_matched=[WhenNotMatched('*')])
+ builder.new_commit().commit(messages)
+ assert _rows(table) == [dict(id=1, value=11, name='old'), dict(id=2,
value=20, name='keep'),
+ dict(id=3, value=30, name='insert')]
+
+
[email protected]('native', [False, True])
+def test_duplicate_source_is_rejected_before_action_conditions(tmp_path,
native):
+ table = _table(tmp_path, [dict(id=1, value=10,
name='old')]).copy({'write.native.enabled': str(native).lower()})
+ source = pa.Table.from_pylist([dict(id=1, value=11, name='a'), dict(id=1,
value=12, name='b')], schema=_SCHEMA)
+ files = set(tmp_path.rglob('*.parquet'))
+ with pytest.raises(ValueError, match='multiple source rows'):
+ table.new_batch_write_builder().new_update().merge_into(
+ source, on=['id'], when_matched=[
+ WhenMatched.update({'value': source_col('value')},
condition='s.value = 11')])
+ assert set(tmp_path.rglob('*.parquet')) == files
+ assert _rows(table) == [dict(id=1, value=10, name='old')]
+
+
[email protected]('native', [False, True])
+def test_unconditional_delete_allows_multiple_source_matches(tmp_path, native):
+ table = _table(tmp_path, [dict(id=1, value=10, name='remove'), dict(id=2,
value=20, name='keep')])
+ table = table.copy({'write.native.enabled': str(native).lower()})
+ source = pa.Table.from_pylist([dict(id=1, value=11, name='a'), dict(id=1,
value=12, name='b')], schema=_SCHEMA)
+ builder = table.new_batch_write_builder()
+ messages = builder.new_update().merge_into(source, on=['id'],
when_matched=[WhenMatched.delete()])
+ builder.new_commit().commit(messages)
+ assert _rows(table) == [dict(id=2, value=20, name='keep')]
+
+
[email protected]('stream', [False, True])
+def test_ordered_conditions_null_keys_and_three_action_types(tmp_path, stream):
+ table = _table(tmp_path, [
+ dict(id=1, value=10, name='remove'), dict(id=2, value=20, name='keep'),
+ dict(id=None, value=90, name='null-old')])
+ source = pa.Table.from_pylist([dict(id=1, value=-1, name='remove'),
dict(id=2, value=None, name='new'),
+ dict(id=3, value=30, name='insert'),
dict(id=None, value=91, name='null-new')],
+ schema=_SCHEMA)
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python merge orchestration')):
+ update = builder.new_update()
+ kwargs = {'commit_identifier': 27} if stream else {}
+ messages = update.merge_into(source, on=['id'], when_matched=[
+ WhenMatched.delete('s.value < 0'),
+ WhenMatched.update({'value': source_col('value')},
condition='s.value > t.value'),
+ WhenMatched.update({'value': lit(73)}),
+ ], when_not_matched=[WhenNotMatched('*')], **kwargs)
+ commit = builder.new_commit()
+ commit.commit(messages, 27) if stream else commit.commit(messages)
+ assert sorted(_rows(table), key=lambda row: row['name']) == [
+ dict(id=3, value=30, name='insert'), dict(id=2, value=73, name='keep'),
+ dict(id=None, value=91, name='null-new'), dict(id=None, value=90,
name='null-old')]
+
+
+def test_native_self_merge_and_empty_target_insert(tmp_path):
+ table = _table(tmp_path, [dict(id=1, value=10, name='old'), dict(id=2,
value=20, name='keep')])
+ builder = table.new_batch_write_builder()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python self merge orchestration')):
+ messages = builder.new_update().merge_into(table, on=['_ROW_ID'],
when_matched=[
+ WhenMatched.update({'value': lit(99)}, condition="s.id = 1 AND
t.name = 'old'")])
+ builder.new_commit().commit(messages)
+ assert _rows(table) == [dict(id=1, value=99, name='old'), dict(id=2,
value=20, name='keep')]
+ empty = _table(tmp_path / 'empty')
+ source = pa.Table.from_pylist([dict(id=3, value=30, name='new')],
schema=_SCHEMA)
+ builder = empty.new_batch_write_builder()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python merge orchestration')):
+ messages = builder.new_update().merge_into(source, on=['id'],
when_not_matched=[
+ WhenNotMatched({'value': source_col('value'), 'name': lit('literal
s.id')}, condition='s.value > 10')])
+ builder.new_commit().commit(messages)
+ assert _rows(empty) == [dict(id=3, value=30, name='literal s.id')]
+
+
[email protected]('condition', [
+ 's.value > (SELECT 10)',
+ 's.value IN (SELECT v FROM (VALUES (11), (30)) AS m(v))',
+ 's.value > (SELECT MAX(v) FROM (VALUES (0), (10)) AS m(v))',
+])
+def test_native_merge_preserves_sql_subquery_conditions(tmp_path, condition):
+ table = _table(tmp_path, [dict(id=1, value=10, name='a'), dict(id=2,
value=20, name='b')])
+ source = pa.Table.from_pylist([dict(id=2, value=None, name='b'),
dict(id=1, value=11, name='a'),
+ dict(id=3, value=30, name='c')],
schema=_SCHEMA)
+ builder = table.new_batch_write_builder()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python merge orchestration')):
+ messages = builder.new_update().merge_into(source, on=['id'],
when_matched=[
+ WhenMatched.update({'value': source_col('value')},
condition=condition)],
+ when_not_matched=[WhenNotMatched('*', condition=condition)])
+ builder.new_commit().commit(messages)
+ assert _rows(table) == [dict(id=1, value=11, name='a'), dict(id=2,
value=20, name='b'),
+ dict(id=3, value=30, name='c')]
+
+
+def test_native_merge_renamed_keys_pandas_and_duplicate_targets(tmp_path):
+ import pandas as pd
+
+ table = _table(tmp_path, [dict(id=1, value=10, name='a'), dict(id=1,
value=20, name='b')])
+ source = pd.DataFrame({'key': [1, 3], 'value': [99, 30], 'name':
['updated', 'new']})
+ # Preserve the declared ON type when converting a pandas frame to Arrow.
+ source['key'] = source['key'].astype('int32')
+ source['value'] = source['value'].astype('int32')
+ builder = table.new_batch_write_builder()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python merge orchestration')):
+ messages = builder.new_update().merge_into(source, on={'id': 'key'},
when_matched=[
+ WhenMatched.update({'value': source_col('value')})],
when_not_matched=[WhenNotMatched('*')])
+ builder.new_commit().commit(messages)
+ assert _rows(table) == [dict(id=1, value=99, name='a'), dict(id=1,
value=99, name='b'),
+ dict(id=3, value=30, name='new')]
+
+
+def test_native_merge_defers_literals_until_a_clause_is_selected(tmp_path):
+ table = _table(tmp_path, [dict(id=1, value=10, name='a')])
+ source = pa.Table.from_pylist([dict(id=1, value=11, name='a')],
schema=_SCHEMA)
+ update = table.new_batch_write_builder().new_update()
+ files = set(tmp_path.rglob('*.parquet'))
+ messages = update.merge_into(source, on=['id'], when_matched=[
+ WhenMatched.update({'value': lit('invalid int')}, condition='s.value <
0')])
+ assert messages == []
+ with pytest.raises((ValueError, pa.ArrowInvalid)):
+ update.merge_into(source, on=['id'],
when_matched=[WhenMatched.update({'value': lit('invalid int')})])
+ assert set(tmp_path.rglob('*.parquet')) == files
+ assert _rows(table) == [dict(id=1, value=10, name='a')]
+
+
+def test_native_merge_keeps_video_inserts_on_python_path(tmp_path):
+ from pypaimon.write.native_merge_into import create_native_merge_into
+ from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+
+ table = _table(tmp_path).copy({'video-frame-field': 'name'})
+ source = pa.Table.from_pylist([dict(id=1, value=10, name='frame')],
schema=_SCHEMA)
+ assert create_native_merge_into(table, source, ['id'], [],
[WhenNotMatched('*')],
+ 'merge', BATCH_COMMIT_IDENTIFIER) is None
+
+
[email protected]('condition', [None, 's.id = 1', 'EXISTS (SELECT 1
WHERE s.id = 1)'])
+def test_native_self_merge_does_not_read_unreferenced_blob(tmp_path,
condition):
+ from pypaimon.table.row.blob import BlobDescriptor
+
+ schema = pa.schema([('id', pa.int32()), ('name', pa.string()), ('payload',
pa.large_binary())])
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(schema, options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'blob-descriptor-field': 'payload', 'blob-as-descriptor': 'false',
'write.native.enabled': 'true',
+ }), False)
+ table = catalog.get_table('db.t')
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ descriptor = BlobDescriptor(str(tmp_path / 'unreadable.bin'), 0,
1).serialize()
+ try:
+ writer.write_arrow(pa.Table.from_pylist([dict(id=1, name='before',
payload=descriptor)], schema=schema))
+ builder.new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ with patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python self merge orchestration')):
+ messages = builder.new_update().merge_into(table, on=['_ROW_ID'],
when_matched=[
+ WhenMatched.update({'name': lit('after')}, condition=condition)])
+ builder.new_commit().commit(messages)
+ reader = table.new_read_builder().with_projection(['id', 'name'])
+ assert
reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist() ==
[dict(id=1, name='after')]
diff --git a/paimon-python/pypaimon/write/native_merge_into.py
b/paimon-python/pypaimon/write/native_merge_into.py
new file mode 100644
index 0000000000..1e12185f9e
--- /dev/null
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -0,0 +1,97 @@
+# 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.
+
+"""Transport existing MERGE inputs into the core operation."""
+
+import pyarrow as pa
+
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.write.native_commit import from_native_commit_messages
+from pypaimon.write.native_update import (
+ _native_row_id_table, _native_update_columns_supported,
_supported_upsert_key_type,
+)
+
+
+def create_native_merge_into(table, source, on, matched, not_matched,
commit_user, commit_identifier):
+ from pypaimon.table.data_evolution_merge_into import _is_self_merge,
_is_table_like, _prepare
+ from pypaimon.ray.data_evolution_merge_into import _normalize_on,
_union_update_cols
+ from pypaimon.ray.data_evolution_merge_transform import SourceColumnRef,
TargetColumnRef, LiteralValue
+ from pypaimon.ray.merge_condition import rewrite_condition
+
+ target_keys, source_keys = _normalize_on(on)
+ self_merge = _is_self_merge(table, source, target_keys, source_keys)
+ if not_matched and table.options.video_frame_fields():
+ return None
+ if _is_table_like(source) and not self_merge:
+ return None
+ native_table = _native_row_id_table(table)
+ if native_table is None:
+ return None
+ source, matched, not_matched, context = _prepare(table, source,
list(matched), list(not_matched), on)
+ if any(callable(value) for clause in matched + not_matched for value in
clause.spec.values()):
+ return None
+ if not _native_update_columns_supported(table,
_union_update_cols(matched)):
+ return None
+ for target, origin in zip(target_keys, source_keys):
+ target_type = pa.int64() if target == '_ROW_ID' else
context.full_pa_schema.field(target).type
+ source_type = target_type if self_merge else
source.schema.field(origin).type
+ if source_type != target_type or not
_supported_upsert_key_type(target_type):
+ return None
+
+ def encode(clause):
+ values = []
+ for name, value in clause.spec.items():
+ if isinstance(value, SourceColumnRef):
+ values.append((name, 'source', value.column))
+ elif isinstance(value, TargetColumnRef):
+ values.append((name, 'target', value.column))
+ elif isinstance(value, LiteralValue):
+ values.append((name, 'literal', value.value))
+ else:
+ return None
+ condition = None if clause.condition is None else
dict(sql=rewrite_condition(clause.condition))
+ return dict(assignments=values, condition=condition,
delete=clause.delete)
+
+ encoded_matched = [encode(clause) for clause in matched]
+ encoded_not_matched = [encode(clause) for clause in not_matched]
+ if any(clause is None for clause in encoded_matched + encoded_not_matched):
+ return None
+ builder =
(native_table.new_batch_write_builder()._with_commit_user(commit_user)
+ if commit_identifier == BATCH_COMMIT_IDENTIFIER else
+
native_table.new_stream_write_builder().with_commit_user(commit_user))
+ writer = builder.new_update()
+ return NativeTableMergeInto(table, writer, source, list(zip(target_keys,
source_keys)),
+ encoded_matched, encoded_not_matched,
commit_identifier)
+
+
+class NativeTableMergeInto:
+ def __init__(self, table, writer, source, on, matched, not_matched,
commit_identifier):
+ self.table = table
+ self.writer = writer
+ self.source = source
+ self.on = on
+ self.matched = matched
+ self.not_matched = not_matched
+ self.commit_identifier = commit_identifier
+
+ def prepare_commit(self):
+ # The core operation owns the snapshot, matching, action selection and
staging.
+ # Never retry via Python after it starts or delete prepared messages
on failure.
+ kwargs = ({} if self.commit_identifier == BATCH_COMMIT_IDENTIFIER else
+ dict(commit_identifier=self.commit_identifier))
+ return from_native_commit_messages(self.table, self.writer.merge_into(
+ self.source, on=self.on, when_matched=self.matched,
when_not_matched=self.not_matched, **kwargs))