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 fe51b14070 [python][ray] Push down self-merge matched conditions
(#9314)
fe51b14070 is described below
commit fe51b14070a06ae7836f7eb60910ea8ac6302c24
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Aug 21 08:52:26 2026 +0800
[python][ray] Push down self-merge matched conditions (#9314)
---
.../pypaimon/ray/data_evolution_merge_into.py | 22 +
.../pypaimon/ray/data_evolution_merge_join.py | 26 +
paimon-python/pypaimon/ray/merge_condition.py | 162 +++++-
paimon-python/pypaimon/ray/ray_paimon.py | 2 +
.../pypaimon/read/datasource/split_provider.py | 7 +-
.../tests/ray_data_evolution_merge_into_test.py | 647 +++++++++++++++++++++
6 files changed, 864 insertions(+), 2 deletions(-)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index b7ba555822..8695c07d77 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -24,6 +24,7 @@ from typing import Any, Dict, List, Mapping, Optional,
Sequence, Tuple
import pyarrow as pa
+from pypaimon.common.predicate import Predicate
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.ray.data_evolution_merge_join import (
_resolve_source_projection,
@@ -63,6 +64,7 @@ class _PrepareCtx:
full_pa_schema: pa.Schema
catalog_options: Dict[str, str]
is_self_merge: bool = False
+ self_merge_scan_predicate: Optional[Predicate] = None
def merge_into(
@@ -251,6 +253,23 @@ def _prepare(target, source, catalog_options,
when_matched, when_not_matched, on
update_pa_schema = pa.schema(
[full_pa_schema.field(c) for c in settable_field_names]
)
+ self_merge_scan_predicate = None
+ if (is_self_merge and matched_specs
+ and all(c.condition is not None for c in matched_specs)):
+ from pypaimon.common.predicate_builder import PredicateBuilder
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ predicates = [
+ try_parse_self_merge_predicate(
+ c.condition, table.table_schema.fields,
+ )
+ for c in matched_specs
+ ]
+ if all(predicate is not None for predicate in predicates):
+ self_merge_scan_predicate = PredicateBuilder.or_predicates(
+ predicates
+ )
ctx = _PrepareCtx(
target_on_cols=target_on_cols,
source_on_cols=source_on_cols,
@@ -260,6 +279,7 @@ def _prepare(target, source, catalog_options, when_matched,
when_not_matched, on
full_pa_schema=full_pa_schema,
catalog_options=catalog_options,
is_self_merge=is_self_merge,
+ self_merge_scan_predicate=self_merge_scan_predicate,
)
return table, source_ds, matched_specs, not_matched_specs, ctx
@@ -300,6 +320,7 @@ def _build_datasets(
catalog_options=ctx.catalog_options,
resolve_target_projection=_resolve_target_projection,
snapshot_id=base_snapshot_id,
+ scan_predicate=ctx.self_merge_scan_predicate,
ray_remote_args=ray_remote_args,
)
if any(c.delete for c in matched_specs):
@@ -310,6 +331,7 @@ def _build_datasets(
catalog_options=ctx.catalog_options,
resolve_target_projection=_resolve_target_projection,
snapshot_id=base_snapshot_id,
+ scan_predicate=ctx.self_merge_scan_predicate,
ray_remote_args=ray_remote_args,
)
return update_ds, delete_ds, insert_ds, update_cols_union
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 4a0dd74b35..863dfd5984 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -188,6 +188,7 @@ def build_self_merge_update_ds(
catalog_options: Dict[str, str],
resolve_target_projection,
snapshot_id: Optional[int] = None,
+ scan_predicate=None,
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> Tuple:
from pypaimon.ray.ray_paimon import read_paimon
@@ -213,9 +214,21 @@ def build_self_merge_update_ds(
c for c in target_field_names if c in needed_cols
]
+ read_kwargs = {}
+ if scan_predicate is not None:
+ from pypaimon.common.options.core_options import (
+ CoreOptions, GlobalIndexSearchMode,
+ )
+ read_kwargs["filter"] = scan_predicate
+ read_kwargs["dynamic_options"] = {
+ CoreOptions.SCALAR_INDEX_SEARCH_MODE.key():
+ GlobalIndexSearchMode.FULL.value,
+ }
target_ds = read_paimon(
target_identifier, catalog_options,
projection=projection, snapshot_id=snapshot_id,
+ _preserve_current_schema=True,
+ **read_kwargs,
)
update_schema = build_update_schema(target_pa_schema, update_cols,
row_id_name)
@@ -260,6 +273,7 @@ def build_self_merge_delete_ds(
catalog_options: Dict[str, str],
resolve_target_projection,
snapshot_id: Optional[int] = None,
+ scan_predicate=None,
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> Tuple:
from pypaimon.ray.ray_paimon import read_paimon
@@ -281,9 +295,21 @@ def build_self_merge_delete_ds(
c for c in target_field_names if c in needed_cols
]
+ read_kwargs = {}
+ if scan_predicate is not None:
+ from pypaimon.common.options.core_options import (
+ CoreOptions, GlobalIndexSearchMode,
+ )
+ read_kwargs["filter"] = scan_predicate
+ read_kwargs["dynamic_options"] = {
+ CoreOptions.SCALAR_INDEX_SEARCH_MODE.key():
+ GlobalIndexSearchMode.FULL.value,
+ }
target_ds = read_paimon(
target_identifier, catalog_options,
projection=projection, snapshot_id=snapshot_id,
+ _preserve_current_schema=True,
+ **read_kwargs,
)
delete_schema = build_delete_schema(row_id_name)
diff --git a/paimon-python/pypaimon/ray/merge_condition.py
b/paimon-python/pypaimon/ray/merge_condition.py
index 12710898d7..291c9f20d5 100644
--- a/paimon-python/pypaimon/ray/merge_condition.py
+++ b/paimon-python/pypaimon/ray/merge_condition.py
@@ -16,13 +16,19 @@
# limitations under the License.
################################################################################
+import logging
import re
-from typing import Mapping, Set
+from typing import Mapping, Optional, Set
import pyarrow as pa
+from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.schema.data_types import AtomicType
+
_COL_REF_PATTERN = re.compile(r'\b([st])\.(\w+)\b')
+logger = logging.getLogger(__name__)
def _load_datafusion():
@@ -103,3 +109,157 @@ def extract_target_columns(condition: str) -> Set[str]:
stripped = _strip_string_literals(condition)
return {m.group(2) for m in _COL_REF_PATTERN.finditer(stripped)
if m.group(1) == "t"}
+
+
+def try_parse_self_merge_predicate(condition, fields) -> Optional[Predicate]:
+ """Best-effort conversion of a self-merge condition for scan pruning."""
+ if not isinstance(condition, str):
+ return None
+
+ # Conversion failure keeps the original unfiltered DataFusion execution.
+ try:
+ expression = _parse_self_merge_expression(condition, fields)
+ return _to_paimon_predicate(
+ expression,
+ PredicateBuilder(fields),
+ {field.name: field for field in fields},
+ )
+ except Exception:
+ logger.debug(
+ "Unable to push down self-merge condition %r",
+ condition,
+ exc_info=True,
+ )
+ return None
+
+
+def _parse_self_merge_expression(condition, fields):
+ from pypaimon.schema.data_types import PyarrowFieldParser
+ from pypaimon.table.special_fields import SpecialFields
+
+ pa_schema = PyarrowFieldParser.from_paimon_schema(fields)
+ arrays, names = [], []
+ for alias in ('s', 't'):
+ for field in pa_schema:
+ arrays.append(pa.array([], type=field.type))
+ names.append('{}.{}'.format(alias, field.name))
+ arrays.append(pa.array([], type=pa.int64()))
+ names.append('{}.{}'.format(alias, SpecialFields.ROW_ID.name))
+
+ batch = pa.RecordBatch.from_arrays(arrays, names)
+ context = _load_datafusion().SessionContext()
+ context.register_record_batches('_self_merge', [[batch]])
+ df_schema = context.table(
+ '_self_merge'
+ ).logical_plan().to_variant().schema()
+ return context.parse_sql_expr(rewrite_condition(condition), df_schema)
+
+
+def _to_paimon_predicate(expression, builder, fields_by_name):
+ kind = expression.variant_name()
+ node = expression.to_variant()
+
+ if kind == 'BinaryExpr':
+ op = node.op().upper()
+ if op in ('AND', 'OR'):
+ left = _to_paimon_predicate(
+ node.left(), builder, fields_by_name,
+ )
+ right = _to_paimon_predicate(
+ node.right(), builder, fields_by_name,
+ )
+ if left is None or right is None:
+ return None
+ predicates = [left, right]
+ if op == 'AND':
+ return PredicateBuilder.and_predicates(predicates)
+ return PredicateBuilder.or_predicates(predicates)
+ return _comparison_predicate(node, builder, fields_by_name)
+
+ if kind == 'InList':
+ field = _datafusion_field(node.expr(), fields_by_name)
+ literals = [_datafusion_literal(item) for item in node.list()]
+ if (field is None or any(not found for found, _ in literals)
+ or not _safe_literals(field, [v for _, v in literals])):
+ return None
+ values = [value for _, value in literals]
+ if node.negated():
+ return builder.is_not_in(field.name, values)
+ return builder.is_in(field.name, values)
+
+ if kind == 'Between':
+ field = _datafusion_field(node.expr(), fields_by_name)
+ low_found, low = _datafusion_literal(node.low())
+ high_found, high = _datafusion_literal(node.high())
+ if (field is None or not low_found or not high_found
+ or not _safe_literals(field, [low, high])):
+ return None
+ if node.negated():
+ return builder.not_between(field.name, low, high)
+ return builder.between(field.name, low, high)
+
+ if kind in ('IsNull', 'IsNotNull'):
+ field = _datafusion_field(node.expr(), fields_by_name)
+ if field is None or not isinstance(field.type, AtomicType):
+ return None
+ if kind == 'IsNull':
+ return builder.is_null(field.name)
+ return builder.is_not_null(field.name)
+
+ return None
+
+
+def _comparison_predicate(node, builder, fields_by_name):
+ field = _datafusion_field(node.left(), fields_by_name)
+ found, literal = _datafusion_literal(node.right())
+ if field is None or not found or not _safe_literals(field, [literal]):
+ return None
+
+ methods = {
+ '=': builder.equal,
+ '!=': builder.not_equal,
+ '<': builder.less_than,
+ '<=': builder.less_or_equal,
+ '>': builder.greater_than,
+ '>=': builder.greater_or_equal,
+ }
+ method = methods.get(node.op())
+ if method is None:
+ return None
+ return method(field.name, literal)
+
+
+def _datafusion_field(expression, fields_by_name):
+ if expression.variant_name() != 'Column':
+ return None
+ name = expression.to_variant().name()
+ if not (name.startswith('s.') or name.startswith('t.')):
+ return None
+ return fields_by_name.get(name[2:])
+
+
+def _datafusion_literal(expression):
+ if expression.variant_name() != 'Literal':
+ return False, None
+ value = expression.python_value()
+ if isinstance(value, pa.Scalar):
+ value = value.as_py()
+ return True, value
+
+
+def _safe_literals(field, literals) -> bool:
+ if not isinstance(field.type, AtomicType):
+ return False
+ type_name = field.type.type.upper().split('(', 1)[0].strip()
+ if type_name in {'TINYINT', 'SMALLINT', 'INT', 'INTEGER', 'BIGINT'}:
+ return all(
+ isinstance(literal, int)
+ and not isinstance(literal, bool)
+ and -(1 << 63) <= literal <= (1 << 63) - 1
+ for literal in literals
+ )
+ if type_name == 'BOOLEAN':
+ return all(isinstance(literal, bool) for literal in literals)
+ if type_name in {'STRING', 'CHAR', 'VARCHAR'}:
+ return all(isinstance(literal, str) for literal in literals)
+ return False
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py
b/paimon-python/pypaimon/ray/ray_paimon.py
index 78309200ac..2a5bbf0a26 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -60,6 +60,7 @@ def read_paimon(
ray_remote_args: Optional[Dict[str, Any]] = None,
concurrency: Optional[int] = None,
override_num_blocks: Optional[int] = None,
+ _preserve_current_schema: bool = False,
**read_args,
) -> "ray.data.Dataset":
"""Read a Paimon table into a Ray Dataset.
@@ -109,6 +110,7 @@ def read_paimon(
snapshot_id=snapshot_id,
tag_name=tag_name,
dynamic_options=dynamic_options,
+ preserve_current_schema=_preserve_current_schema,
)
if not split_provider.splits():
diff --git a/paimon-python/pypaimon/read/datasource/split_provider.py
b/paimon-python/pypaimon/read/datasource/split_provider.py
index eb953c573e..6060ccce80 100644
--- a/paimon-python/pypaimon/read/datasource/split_provider.py
+++ b/paimon-python/pypaimon/read/datasource/split_provider.py
@@ -97,6 +97,7 @@ class CatalogSplitProvider(SplitProvider):
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
dynamic_options: Optional[Dict[str, str]] = None,
+ preserve_current_schema: bool = False,
):
if not table_identifier:
raise ValueError("table_identifier is required")
@@ -132,6 +133,7 @@ class CatalogSplitProvider(SplitProvider):
self._snapshot_id = snapshot_id
self._tag_name = tag_name
self._dynamic_options = dynamic_options
+ self._preserve_current_schema = preserve_current_schema
self._table_cached = None
self._splits_cached = None
self._read_type_cached = None
@@ -150,7 +152,10 @@ class CatalogSplitProvider(SplitProvider):
if self._dynamic_options:
dynamic_options.update(self._dynamic_options)
if dynamic_options:
- table = table.copy(dynamic_options)
+ if self._preserve_current_schema:
+ table = table.copy_without_time_travel(dynamic_options)
+ else:
+ table = table.copy(dynamic_options)
self._table_cached = table
return self._table_cached
diff --git a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
index 2e1e8b8e44..70a0a0d189 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -16,6 +16,7 @@
# limitations under the License.
################################################################################
+import datetime
import os
import shutil
import tempfile
@@ -2036,6 +2037,489 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
self.assertEqual(out['age'], [99, 99, 99])
self.assertEqual(out['name'], ['a', 'b', 'c'])
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_condition_pushes_down_predicate(self):
+ from pypaimon.common.options.core_options import (
+ CoreOptions, GlobalIndexSearchMode,
+ )
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ target = self._create_table()
+ self._write(
+ target,
+ pa.Table.from_pydict(
+ {
+ 'id': pa.array([1, 2, 3], type=pa.int32()),
+ 'name': ['a', 'b', 'c'],
+ 'age': pa.array([10, 20, 30], type=pa.int32()),
+ },
+ schema=self.pa_schema,
+ ),
+ )
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'age': lit(99)}, condition='t.id IN (1, 3)',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 2)
+ self.assertEqual(
+ self._read_sorted(target),
+ {
+ 'id': [1, 2, 3],
+ 'name': ['a', 'b', 'c'],
+ 'age': [99, 20, 99],
+ },
+ )
+ read_kwargs = mock_read.call_args[1]
+ predicate = read_kwargs['filter']
+ self.assertEqual(predicate.method, 'in')
+ self.assertEqual(predicate.field, 'id')
+ self.assertEqual(predicate.literals, [1, 3])
+ self.assertEqual(
+ read_kwargs['dynamic_options'][
+ CoreOptions.SCALAR_INDEX_SEARCH_MODE.key()
+ ],
+ GlobalIndexSearchMode.FULL.value,
+ )
+ self.assertTrue(read_kwargs['_preserve_current_schema'])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_pushdown_handles_evolved_file_groups(self):
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.schema.schema_change import SchemaChange
+
+ options = dict(self.de_options)
+ options.update({
+ 'global-index.enabled': 'true',
+ 'bucket': '-1',
+ })
+ target = self._create_table(options=options)
+ self._write(
+ target,
+ pa.Table.from_pydict(
+ {
+ 'id': pa.array([1, 2], type=pa.int32()),
+ 'name': ['a', 'b'],
+ 'age': pa.array([10, 20], type=pa.int32()),
+ },
+ schema=self.pa_schema,
+ ),
+ )
+
+ table = self.catalog.get_table(target)
+ self.assertGreater(table.create_global_index('id'), 0)
+
+ # This append is intentionally not covered by the existing index.
+ self._write(
+ target,
+ pa.Table.from_pydict(
+ {
+ 'id': pa.array([3, 4], type=pa.int32()),
+ 'name': ['c', 'd'],
+ 'age': pa.array([30, 40], type=pa.int32()),
+ },
+ schema=self.pa_schema,
+ ),
+ )
+
+ from pypaimon.index.index_file_handler import IndexFileHandler
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ indexed_ranges = {
+ (
+ entry.index_file.global_index_meta.row_range_start,
+ entry.index_file.global_index_meta.row_range_end,
+ )
+ for entry in IndexFileHandler(table).scan(snapshot)
+ }
+ self.assertEqual({(0, 1)}, indexed_ranges)
+
+ first_result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'age': lit(99)}, condition='t.id IN (2, 3)',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+ self.assertEqual(first_result['num_matched'], 2)
+
+ self.catalog.alter_table(
+ target,
+ [SchemaChange.add_column('note', AtomicType('STRING'))],
+ False,
+ )
+
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'name': lit('updated')},
+ condition='t.id IN (1, 4) AND t.note IS NULL',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 2)
+ self.assertEqual(
+ self._read_sorted(target),
+ {
+ 'id': [1, 2, 3, 4],
+ 'name': ['updated', 'b', 'c', 'updated'],
+ 'age': [10, 99, 99, 40],
+ 'note': [None, None, None, None],
+ },
+ )
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_delete_condition_pushes_down_predicate(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ options = dict(self.de_options)
+ options['deletion-vectors.enabled'] = 'true'
+ target = self._create_table(options=options)
+ self._write(target, self._source(ids=(1, 2, 3)))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.delete(condition='s.id = 2')],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 1)
+ self.assertEqual(self._read_sorted(target)['id'], [1, 3])
+ predicate = mock_read.call_args[1]['filter']
+ self.assertEqual((predicate.method, predicate.field,
predicate.literals),
+ ('equal', 'id', [2]))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_multiple_conditions_push_down_or(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ target = self._create_table()
+ self._write(target, self._source(ids=(1, 2, 3)))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[
+ WhenMatched.update(
+ {'age': lit(11)}, condition='t.id = 1',
+ ),
+ WhenMatched.update(
+ {'age': lit(33)}, condition='s.id = 3',
+ ),
+ ],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 2)
+ predicate = mock_read.call_args[1]['filter']
+ self.assertEqual(predicate.method, 'or')
+ self.assertEqual(
+ [(p.field, p.literals) for p in predicate.literals],
+ [('id', [1]), ('id', [3])],
+ )
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_unconditional_clause_disables_pushdown(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ target = self._create_table()
+ self._write(target, self._source(ids=(1, 2, 3)))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[
+ WhenMatched.update(
+ {'age': lit(11)}, condition='t.id = 1',
+ ),
+ WhenMatched.update({'age': lit(99)}),
+ ],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 3)
+ self.assertNotIn('filter', mock_read.call_args[1])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_column_comparison_fails_open(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ target = self._create_table()
+ self._write(target, self._source(ids=(1, 2, 3)))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'name': lit('same')}, condition='t.age = s.age',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 3)
+ self.assertNotIn('filter', mock_read.call_args[1])
+ self.assertEqual(self._read_sorted(target)['name'],
+ ['same', 'same', 'same'])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_pushdown_preserves_field_case(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ case_schema = pa.schema([
+ ('UserID', pa.int32()),
+ ('Value', pa.int32()),
+ ])
+ target = f'default.tbl_{uuid.uuid4().hex[:8]}'
+ schema = Schema.from_pyarrow_schema(
+ case_schema, options=self.de_options,
+ )
+ self.catalog.create_table(target, schema, False)
+ self._write(target, pa.Table.from_pydict(
+ {'UserID': [1, 2, 3], 'Value': [10, 20, 30]},
+ schema=case_schema,
+ ))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'Value': lit(99)},
+ condition='t.UserID IN (1, 3)',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 2)
+ predicate = mock_read.call_args[1]['filter']
+ self.assertEqual(predicate.field, 'UserID')
+ table = self.catalog.get_table(target)
+ read_builder = table.new_read_builder()
+ splits = read_builder.new_scan().plan().splits()
+ out = read_builder.new_read().to_arrow(splits).sort_by('UserID')
+ self.assertEqual(out['Value'].to_pylist(), [99, 20, 99])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_date_condition_fails_open(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ date_schema = pa.schema([
+ ('id', pa.int32()),
+ ('event_date', pa.date32()),
+ ('value', pa.int32()),
+ ])
+ target = f'default.tbl_{uuid.uuid4().hex[:8]}'
+ schema = Schema.from_pyarrow_schema(
+ date_schema, options=self.de_options,
+ )
+ self.catalog.create_table(target, schema, False)
+ self._write(target, pa.Table.from_pydict({
+ 'id': [1, 2],
+ 'event_date': [
+ datetime.date(2026, 1, 1),
+ datetime.date(2026, 1, 2),
+ ],
+ 'value': [10, 20],
+ }, schema=date_schema))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'value': lit(99)},
+ condition="t.event_date = '2026-01-01'",
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 1)
+ self.assertNotIn('filter', mock_read.call_args[1])
+ table = self.catalog.get_table(target)
+ read_builder = table.new_read_builder()
+ splits = read_builder.new_scan().plan().splits()
+ out = read_builder.new_read().to_arrow(splits).sort_by('id')
+ self.assertEqual(out['value'].to_pylist(), [99, 20])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_double_condition_fails_open(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ double_schema = pa.schema([
+ ('id', pa.int32()),
+ ('metric', pa.float64()),
+ ('value', pa.int32()),
+ ])
+ target = f'default.tbl_{uuid.uuid4().hex[:8]}'
+ schema = Schema.from_pyarrow_schema(
+ double_schema, options=self.de_options,
+ )
+ self.catalog.create_table(target, schema, False)
+ self._write(target, pa.Table.from_pydict({
+ 'id': [1, 2, 3],
+ 'metric': [float('nan'), -1.0, 1.0],
+ 'value': [10, 20, 30],
+ }, schema=double_schema))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'value': lit(99)}, condition='t.metric > 0',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 2)
+ self.assertNotIn('filter', mock_read.call_args[1])
+ table = self.catalog.get_table(target)
+ read_builder = table.new_read_builder()
+ splits = read_builder.new_scan().plan().splits()
+ out = read_builder.new_read().to_arrow(splits).sort_by('id')
+ self.assertEqual(out['value'].to_pylist(), [99, 20, 99])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_like_condition_fails_open(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ string_schema = pa.schema([
+ ('id', pa.int32()),
+ ('text', pa.string()),
+ ('value', pa.int32()),
+ ])
+ target = f'default.tbl_{uuid.uuid4().hex[:8]}'
+ schema = Schema.from_pyarrow_schema(
+ string_schema, options=self.de_options,
+ )
+ self.catalog.create_table(target, schema, False)
+ self._write(target, pa.Table.from_pydict({
+ 'id': [0, 1, 2, 3, 4],
+ 'text': ['n', '\\n', '\n', 'line\nbreak', 'linebreak'],
+ 'value': [10, 20, 30, 40, 50],
+ }, schema=string_schema))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'value': lit(99)}, condition=r"t.text LIKE '%\n%'",
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 4)
+ self.assertNotIn('filter', mock_read.call_args[1])
+ table = self.catalog.get_table(target)
+ read_builder = table.new_read_builder()
+ splits = read_builder.new_scan().plan().splits()
+ out = read_builder.new_read().to_arrow(splits).sort_by('id')
+ self.assertEqual(out['value'].to_pylist(), [99, 99, 30, 99, 99])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_out_of_range_integer_fails_open(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ int_schema = pa.schema([
+ ('id', pa.int64()),
+ ('value', pa.int32()),
+ ])
+ target = f'default.tbl_{uuid.uuid4().hex[:8]}'
+ schema = Schema.from_pyarrow_schema(
+ int_schema, options=self.de_options,
+ )
+ self.catalog.create_table(target, schema, False)
+ self._write(target, pa.Table.from_pydict({
+ 'id': [-1, 0, 1],
+ 'value': [10, 20, 30],
+ }, schema=int_schema))
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update(
+ {'value': lit(99)},
+ condition='t.id < 9223372036854775808',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 3)
+ self.assertNotIn('filter', mock_read.call_args[1])
+ table = self.catalog.get_table(target)
+ read_builder = table.new_read_builder()
+ splits = read_builder.new_scan().plan().splits()
+ out = read_builder.new_read().to_arrow(splits).sort_by('id')
+ self.assertEqual(out['value'].to_pylist(), [99, 99, 99])
+
def test_self_merge_update_star(self):
target = self._create_table()
self._write(
@@ -2530,6 +3014,20 @@ class TargetProjectionTest(unittest.TestCase):
class MergeConditionUnitTest(unittest.TestCase):
+ @staticmethod
+ def _predicate_fields():
+ return Schema.from_pyarrow_schema(pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ('MixedCase', pa.int32()),
+ ('flag', pa.bool_()),
+ ('event_date', pa.date32()),
+ ('event_time', pa.timestamp('us')),
+ ('amount', pa.decimal128(30, 2)),
+ ('float_value', pa.float32()),
+ ('double_value', pa.float64()),
+ ])).fields
+
def test_rewrite_condition(self):
from pypaimon.ray.merge_condition import rewrite_condition
self.assertEqual(
@@ -2592,6 +3090,155 @@ class MergeConditionUnitTest(unittest.TestCase):
{'s.id', 't.id', 's.age', 't.age'},
)
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_parse_simple_self_merge_predicate(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ predicate = try_parse_self_merge_predicate(
+ 't.id IN (1, 3) AND s.name = \'s.literal\'',
+ self._predicate_fields(),
+ )
+ self.assertEqual(predicate.method, 'and')
+ self.assertEqual(
+ [(p.field, p.literals) for p in predicate.literals],
+ [('id', [1, 3]), ('name', ['s.literal'])],
+ )
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_parse_self_merge_predicate_ast_subset(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ cases = [
+ ('t.id != 1', 'notEqual', 'id', [1]),
+ ('t.id NOT IN (1, 2)', 'notIn', 'id', [1, 2]),
+ ('t.id BETWEEN 1 AND 2', 'between', 'id', [1, 2]),
+ ('t.id NOT BETWEEN 1 AND 2', 'notBetween', 'id', [1, 2]),
+ ('t.event_date IS NULL', 'isNull', 'event_date', None),
+ ('t.event_date IS NOT NULL', 'isNotNull', 'event_date', None),
+ ]
+ for condition, method, field, literals in cases:
+ with self.subTest(condition=condition):
+ predicate = try_parse_self_merge_predicate(
+ condition, self._predicate_fields(),
+ )
+ self.assertEqual(
+ (predicate.method, predicate.field, predicate.literals),
+ (method, field, literals),
+ )
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_predicate_preserves_field_case(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ predicate = try_parse_self_merge_predicate(
+ 't.MixedCase = 1', self._predicate_fields(),
+ )
+ self.assertEqual(predicate.field, 'MixedCase')
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_column_comparison_is_not_pushed_down(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ self.assertIsNone(try_parse_self_merge_predicate(
+ 't.id = s.id', self._predicate_fields(),
+ ))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_function_is_not_pushed_down(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ self.assertIsNone(try_parse_self_merge_predicate(
+ 'abs(t.id) > 1', self._predicate_fields(),
+ ))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_predicate_parse_failure_is_logged(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ with self.assertLogs(
+ 'pypaimon.ray.merge_condition', level='DEBUG') as logs:
+ self.assertIsNone(try_parse_self_merge_predicate(
+ 't.id =', self._predicate_fields(),
+ ))
+ self.assertIn('Unable to push down', '\n'.join(logs.output))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_unsafe_literal_types_fail_open(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ conditions = [
+ "t.event_date = '2026-01-01'",
+ "t.event_time = '2026-01-01 01:02:03'",
+ 't.amount = 12345678901234567890.12',
+ 't.float_value > 0',
+ 't.double_value > 0',
+ ]
+ for condition in conditions:
+ with self.subTest(condition=condition):
+ self.assertIsNone(try_parse_self_merge_predicate(
+ condition, self._predicate_fields(),
+ ))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_invalid_literals_fail_open(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ conditions = [
+ "t.flag = 'true'",
+ 't.name = bare_value',
+ 't.name IN (NULL)',
+ ]
+ for condition in conditions:
+ with self.subTest(condition=condition):
+ self.assertIsNone(try_parse_self_merge_predicate(
+ condition, self._predicate_fields(),
+ ))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_boolean_literal_is_pushed_down(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ predicate = try_parse_self_merge_predicate(
+ 't.flag = TRUE', self._predicate_fields(),
+ )
+ self.assertEqual((predicate.field, predicate.literals),
+ ('flag', [True]))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_like_condition_is_not_pushed_down(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ self.assertIsNone(try_parse_self_merge_predicate(
+ r"t.name LIKE '%\n%'", self._predicate_fields(),
+ ))
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_out_of_range_integers_fail_open(self):
+ from pypaimon.ray.merge_condition import (
+ try_parse_self_merge_predicate,
+ )
+ conditions = [
+ 't.id < 9223372036854775808',
+ 't.id > -9223372036854775809',
+ 't.id IN (1, 9223372036854775808)',
+ 't.id BETWEEN -9223372036854775809 AND 1',
+ ]
+ for condition in conditions:
+ with self.subTest(condition=condition):
+ self.assertIsNone(try_parse_self_merge_predicate(
+ condition, self._predicate_fields(),
+ ))
+
@unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
def test_filter_batch(self):
from pypaimon.ray.merge_condition import filter_batch