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 cd290a22ac [python] Prune source columns for Ray data-evolution merge
into (#8504)
cd290a22ac is described below
commit cd290a22acb42118fb37011e53f09fce6eb3f3fb
Author: umi <[email protected]>
AuthorDate: Thu Jul 9 13:08:37 2026 +0800
[python] Prune source columns for Ray data-evolution merge into (#8504)
---
.../pypaimon/ray/data_evolution_merge_into.py | 16 ++-
.../pypaimon/ray/data_evolution_merge_join.py | 32 ++++-
.../tests/ray_data_evolution_merge_into_test.py | 137 +++++++++++++++++++++
3 files changed, 179 insertions(+), 6 deletions(-)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index a49b049af6..d43ac21f36 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -26,6 +26,7 @@ import pyarrow as pa
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.ray.data_evolution_merge_join import (
+ _resolve_source_projection,
build_matched_delete_ds,
build_matched_update_ds,
build_not_matched_insert_ds,
@@ -202,16 +203,20 @@ def _prepare(target, source, catalog_options,
when_matched, when_not_matched, on
source_col_names = set(full_target_field_names) | set(source_on_cols)
else:
source_snapshot_id = None
+ source_read_projection = None
if isinstance(source, str):
- source_snapshot = (
- catalog.get_table(source)
- .snapshot_manager()
- .get_latest_snapshot()
+ source_table = catalog.get_table(source)
+ source_read_projection = _resolve_source_projection(
+ matched_specs + not_matched_specs,
+ source_on_cols,
+ source_table.field_names,
)
+ source_snapshot =
source_table.snapshot_manager().get_latest_snapshot()
if source_snapshot is not None:
source_snapshot_id = source_snapshot.id
source_ds = _normalize_source(
source, catalog_options, source_snapshot_id=source_snapshot_id,
+ projection=source_read_projection,
)
_validate_source_on_cols(source_ds, source_on_cols)
source_col_names = set(_source_schema_or_raise(source_ds).names)
@@ -660,6 +665,7 @@ def _normalize_source(
source: Any,
catalog_options: Dict[str, str],
source_snapshot_id: Optional[int] = None,
+ projection: Optional[List[str]] = None,
):
import ray.data
@@ -670,6 +676,8 @@ def _normalize_source(
read_kwargs = {}
if source_snapshot_id is not None:
read_kwargs["snapshot_id"] = source_snapshot_id
+ if projection is not None:
+ read_kwargs["projection"] = projection
return read_paimon(source, catalog_options, **read_kwargs)
if isinstance(source, pa.Table):
return ray.data.from_arrow(source)
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 5535b7ec1b..359af8e6ef 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -44,6 +44,28 @@ def _map_kwargs(
return kwargs
+def _resolve_source_projection(
+ clauses: List[_NormalizedClause],
+ source_on: Sequence[str],
+ source_field_names: Sequence[str],
+) -> list:
+ needed = set(source_on)
+ source_set = set(source_field_names)
+
+ for clause in clauses:
+ for value in clause.spec.values():
+ if isinstance(value, SourceColumnRef):
+ needed.add(value.column)
+ if clause.condition is not None:
+ from pypaimon.ray.merge_condition import extract_columns
+ for ref in extract_columns(clause.condition):
+ prefix, col = ref.split(".", 1)
+ if prefix == "s" and col in source_set:
+ needed.add(col)
+
+ return [c for c in source_field_names if c in needed]
+
+
def _build_matched_transform(
clauses: List[_NormalizedClause],
on_map: Dict[str, str],
@@ -330,7 +352,10 @@ def build_matched_update_ds(
target_renamed = target_ds.rename_columns(
{c: f"t.{c}" for c in target_ds.schema().names}
)
- source_cols = list(source_ds.schema().names)
+ source_cols = _resolve_source_projection(
+ clauses, source_on, source_ds.schema().names,
+ )
+ source_ds = source_ds.select_columns(source_cols)
source_renamed = source_ds.rename_columns(
{c: f"s.{c}" for c in source_cols}
)
@@ -880,7 +905,10 @@ def build_not_matched_insert_ds(
captured_field_names = list(target_field_names)
out_schema = target_pa_schema
- source_cols = list(source_ds.schema().names)
+ source_cols = _resolve_source_projection(
+ clauses, source_on, source_ds.schema().names,
+ )
+ source_ds = source_ds.select_columns(source_cols)
source_renamed = source_ds.rename_columns(
{c: f"s.{c}" for c in source_cols}
)
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 36f10d1aff..cddb11ac37 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
@@ -140,6 +140,7 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
source,
self.catalog_options,
snapshot_id=expected_snapshot_id,
+ projection=['id', 'name', 'age'],
)
def test_no_clause_raises(self):
@@ -2372,6 +2373,142 @@ class TargetProjectionTest(unittest.TestCase):
self.assertIn('age', cols)
self.assertIn('id', cols)
+ def test_matched_source_projection_prunes_unneeded_cols(self):
+ from pypaimon.ray.data_evolution_merge_join import (
+ _resolve_source_projection,
+ )
+ from pypaimon.ray.data_evolution_merge_transform import (
+ LiteralValue,
+ SourceColumnRef,
+ TargetColumnRef,
+ )
+
+ cols = _resolve_source_projection(
+ [
+ self._clause(
+ {
+ 'age': SourceColumnRef('id'),
+ 'name': TargetColumnRef('name'),
+ 'note': LiteralValue('literal'),
+ },
+ condition="s.status = 't.fake' AND s.score > t.score",
+ )
+ ],
+ ['uid'],
+ ['uid', 'id', 'name', 'status', 'score', 'payload'],
+ )
+ self.assertEqual(['uid', 'id', 'status', 'score'], cols)
+
+ def test_literal_update_source_projection_keeps_only_join_key(self):
+ from pypaimon.ray.data_evolution_merge_join import (
+ _resolve_source_projection,
+ )
+ from pypaimon.ray.data_evolution_merge_transform import LiteralValue
+
+ cols = _resolve_source_projection(
+ [self._clause({'name': LiteralValue('updated')})],
+ ['id'],
+ ['id', 'name', 'age', 'payload'],
+ )
+ self.assertEqual(['id'], cols)
+
+ def test_matched_update_selects_needed_source_cols(self):
+ from pypaimon.ray.data_evolution_merge_join import
build_matched_update_ds
+ from pypaimon.ray.data_evolution_merge_transform import SourceColumnRef
+
+ source_ds = Mock()
+ source_ds.schema.return_value = pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ('payload', pa.string()),
+ ])
+ selected_ds = Mock()
+ source_renamed = Mock()
+ source_ds.select_columns.return_value = selected_ds
+ selected_ds.rename_columns.return_value = source_renamed
+
+ target_ds = Mock()
+ target_ds.schema.return_value = pa.schema([
+ ('_ROW_ID', pa.int64()),
+ ('id', pa.int32()),
+ ])
+ target_renamed = Mock()
+ joined = Mock()
+ result = object()
+ target_ds.rename_columns.return_value = target_renamed
+ target_renamed.join.return_value = joined
+ joined.map_batches.return_value = result
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ return_value=target_ds,
+ ):
+ out = build_matched_update_ds(
+ target_identifier='default.target',
+ source_ds=source_ds,
+ target_on=['id'],
+ source_on=['id'],
+ clauses=[self._clause({'name': SourceColumnRef('name')})],
+ target_field_names=['id', 'name'],
+ target_pa_schema=pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ]),
+ update_cols=['name'],
+ catalog_options={'warehouse': '/tmp/warehouse'},
+ num_partitions=1,
+ resolve_target_projection=lambda *args: ['id'],
+ )
+
+ self.assertIs(out, result)
+ source_ds.select_columns.assert_called_once_with(['id', 'name'])
+ selected_ds.rename_columns.assert_called_once_with({
+ 'id': 's.id',
+ 'name': 's.name',
+ })
+
+ def test_not_matched_insert_selects_needed_source_cols(self):
+ from pypaimon.ray.data_evolution_merge_join import (
+ build_not_matched_insert_ds,
+ )
+ from pypaimon.ray.data_evolution_merge_transform import SourceColumnRef
+
+ source_ds = Mock()
+ source_ds.schema.return_value = pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ('payload', pa.string()),
+ ])
+ selected_ds = Mock()
+ source_renamed = Mock()
+ result = object()
+ source_ds.select_columns.return_value = selected_ds
+ selected_ds.rename_columns.return_value = source_renamed
+ source_renamed.map_batches.return_value = result
+
+ out = build_not_matched_insert_ds(
+ target_identifier='default.target',
+ source_ds=source_ds,
+ target_on=['id'],
+ source_on=['id'],
+ clauses=[self._clause({'name': SourceColumnRef('name')})],
+ target_field_names=['id', 'name'],
+ target_pa_schema=pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ]),
+ catalog_options={'warehouse': '/tmp/warehouse'},
+ num_partitions=1,
+ target_empty=True,
+ )
+
+ self.assertIs(out, result)
+ source_ds.select_columns.assert_called_once_with(['id', 'name'])
+ selected_ds.rename_columns.assert_called_once_with({
+ 'id': 's.id',
+ 'name': 's.name',
+ })
+
class MergeConditionUnitTest(unittest.TestCase):