This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 3def4702 [core] Share MERGE action evaluation for distributed 
execution (#1203)
3def4702 is described below

commit 3def4702fc0ed39026b6bba6c299d09c76558e36
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 09:26:27 2026 +0800

    [core] Share MERGE action evaluation for distributed execution (#1203)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  18 +
 bindings/python/src/context.rs                     |   1 +
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/merge_execution.rs             | 520 +++++++++++++++++++++
 bindings/python/src/merge_into.rs                  |  22 +-
 bindings/python/src/update_assignment.rs           |  10 +
 crates/paimon/src/table/mod.rs                     |   3 +-
 .../paimon/src/table/table_merge_actions_tests.rs  | 403 ++++++++++++++++
 crates/paimon/src/table/table_merge_into.rs        | 313 +++++++++----
 9 files changed, 1202 insertions(+), 89 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index f50293ef..ae393712 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -79,6 +79,24 @@ class RowIdPlan:
         ...
     def plan(self) -> Plan: ...
 
+class MergeBatchExecution:
+    """Evaluate ordered MERGE actions over already joined Arrow batches.
+
+    Input fields use the ``t.<column>`` and ``s.<column>`` aliases. This
+    operator evaluates expressions and assignments without reading or writing
+    a table; callers retain responsibility for joins and authorization.
+    Matched input carries ``t._ROW_ID`` or ``_ROW_ID``, never both. Update
+    output contains ``_ROW_ID`` and the requested target columns; delete output
+    contains only ``_ROW_ID``. Insert output uses the target schema.
+    """
+    def __init__(
+        self, input_schema: pyarrow.Schema, output_schema: pyarrow.Schema,
+        clauses: Sequence[Dict[str, Any]], mode: Literal["update", "delete", 
"insert"],
+    ) -> None: ...
+    def execute(self, batch: Union[pyarrow.Table, pyarrow.RecordBatch]) -> 
pyarrow.Table:
+        """Return selected actions; unselected assignments are never 
evaluated."""
+        ...
+
 class ScanStats:
     """Read-only counters from normal metadata planning."""
     @property
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 33fdd265..162c0444 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -1038,6 +1038,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::write::PyBatchTableWrite>()?;
     this.add_class::<crate::write::PyBatchTableUpdate>()?;
     this.add_class::<crate::write::PyTableUpdateByRowId>()?;
+    this.add_class::<crate::merge_execution::PyMergeBatchExecution>()?;
     this.add_class::<crate::shard_update::PyShardTableUpdator>()?;
     this.add_class::<crate::write::PyStreamTableUpdate>()?;
     this.add_class::<crate::write::PyStreamTableWrite>()?;
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 49b69420..e45485bf 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -29,6 +29,7 @@ mod full_text_search;
 mod global_index_result;
 mod hybrid_search;
 mod index_build;
+mod merge_execution;
 mod merge_into;
 mod oss_cpp;
 mod predicate;
diff --git a/bindings/python/src/merge_execution.rs 
b/bindings/python/src/merge_execution.rs
new file mode 100644
index 00000000..7310bfdc
--- /dev/null
+++ b/bindings/python/src/merge_execution.rs
@@ -0,0 +1,520 @@
+// 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.
+
+//! Arrow and Python transport for the core prejoined MERGE evaluator.
+
+use std::collections::HashSet;
+use std::sync::{Arc, Mutex};
+
+use arrow::array::Int64Array;
+use arrow::datatypes::{DataType, Schema, SchemaRef};
+use arrow::pyarrow::{FromPyArrow, ToPyArrow};
+use arrow::record_batch::RecordBatch;
+use paimon::table::{
+    evaluate_merge_actions, MergeActionOutput, MergeAssignment, WhenMatched, 
WhenNotMatched,
+};
+use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::{PyDict, PyList};
+
+use crate::error::to_py_err;
+
+#[pyclass(name = "MergeBatchExecution")]
+pub struct PyMergeBatchExecution {
+    input_schema: SchemaRef,
+    output_schema: SchemaRef,
+    target_schema: SchemaRef,
+    matched: Vec<WhenMatched>,
+    not_matched: Vec<WhenNotMatched>,
+    output: MergeActionOutput,
+    callback_error: Arc<Mutex<Option<PyErr>>>,
+    execution: Mutex<()>,
+}
+
+fn matched_schema(schema: SchemaRef) -> PyResult<SchemaRef> {
+    if schema.column_with_name("_ROW_ID").is_some() {
+        if schema.column_with_name("t._ROW_ID").is_some() {
+            return Err(PyValueError::new_err(
+                "Matched MERGE input has ambiguous _ROW_ID and t._ROW_ID 
fields",
+            ));
+        }
+        return Ok(schema);
+    }
+    let index = schema.index_of("t._ROW_ID").map_err(|error| {
+        PyValueError::new_err(format!("Matched MERGE batch needs t._ROW_ID: 
{error}"))
+    })?;
+    let mut fields = schema.fields().to_vec();
+    fields.push(Arc::new(
+        fields[index].as_ref().clone().with_name("_ROW_ID"),
+    ));
+    Ok(Arc::new(Schema::new_with_metadata(
+        fields,
+        schema.metadata().clone(),
+    )))
+}
+
+fn row_ids(batch: RecordBatch) -> PyResult<RecordBatch> {
+    let schema = matched_schema(batch.schema())?;
+    if schema.fields().len() == batch.num_columns() {
+        return Ok(batch);
+    }
+    let index = batch.schema().index_of("t._ROW_ID").unwrap();
+    let mut columns = batch.columns().to_vec();
+    columns.push(batch.column(index).clone());
+    RecordBatch::try_new(schema, columns).map_err(|error| 
PyValueError::new_err(error.to_string()))
+}
+
+#[pymethods]
+impl PyMergeBatchExecution {
+    #[new]
+    fn new(
+        py: Python<'_>,
+        input_schema: &Bound<'_, PyAny>,
+        output_schema: &Bound<'_, PyAny>,
+        clauses: &Bound<'_, PyAny>,
+        mode: &str,
+    ) -> PyResult<Self> {
+        let output = match mode {
+            "update" => MergeActionOutput::Updates,
+            "delete" => MergeActionOutput::Deletes,
+            "insert" => MergeActionOutput::Inserts,
+            _ => {
+                return Err(PyValueError::new_err(
+                    "MERGE execution mode must be update, delete or insert",
+                ));
+            }
+        };
+        let input_schema = Arc::new(Schema::from_pyarrow_bound(input_schema)?);
+        let output_schema = 
Arc::new(Schema::from_pyarrow_bound(output_schema)?);
+        if output != MergeActionOutput::Inserts {
+            let row_id = output_schema.field_with_name("_ROW_ID").map_err(|_| {
+                PyValueError::new_err("Matched MERGE output needs an Int64 
_ROW_ID field")
+            })?;
+            if row_id.data_type() != &DataType::Int64 {
+                return Err(PyValueError::new_err(
+                    "Matched MERGE output needs an Int64 _ROW_ID field",
+                ));
+            }
+            if output == MergeActionOutput::Deletes && 
output_schema.fields().len() != 1 {
+                return Err(PyValueError::new_err(
+                    "MERGE DELETE output must contain only _ROW_ID",
+                ));
+            }
+        }
+        let condition_schema = if output == MergeActionOutput::Inserts {
+            input_schema.clone()
+        } else {
+            matched_schema(input_schema.clone())?
+        };
+        let target_schema = Arc::new(Schema::new(
+            output_schema
+                .fields()
+                .iter()
+                .filter(|field| output == MergeActionOutput::Inserts || 
field.name() != "_ROW_ID")
+                .cloned()
+                .collect::<Vec<_>>(),
+        ));
+        let callback_error = Arc::new(Mutex::new(None));
+        let mut matched = Vec::new();
+        let mut not_matched = Vec::new();
+        for clause in clauses.try_iter()? {
+            let clause = clause?;
+            let clause = clause.cast::<PyDict>()?;
+            let condition = crate::merge_into::condition(py, clause, 
condition_schema.clone())?;
+            let assignments = if output == MergeActionOutput::Deletes {
+                // A DELETE route still consumes preceding UPDATE conditions,
+                // but must not inspect or evaluate those assignments.
+                Vec::new()
+            } else {
+                crate::merge_into::assignments(
+                    py,
+                    clause,
+                    target_schema.clone(),
+                    callback_error.clone(),
+                )?
+            };
+            if output == MergeActionOutput::Inserts {
+                not_matched.push(WhenNotMatched {
+                    condition,
+                    assignments,
+                });
+            } else {
+                matched.push(WhenMatched {
+                    condition,
+                    delete: clause
+                        .get_item("delete")?
+                        .ok_or_else(|| PyValueError::new_err("MERGE matched 
clause needs action"))?
+                        .extract()?,
+                    assignments,
+                });
+            }
+        }
+        if output == MergeActionOutput::Updates {
+            // The explicit output schema may request fields untouched by every
+            // clause. Include them in core's shared update-column projection.
+            let assigned = matched
+                .iter()
+                .flat_map(|clause| clause.assignments.iter().map(|(name, _)| 
name.clone()))
+                .collect::<HashSet<_>>();
+            if let Some(clause) = matched.iter_mut().find(|clause| 
!clause.delete) {
+                for field in target_schema.fields() {
+                    if !assigned.contains(field.name()) {
+                        clause.assignments.push((
+                            field.name().clone(),
+                            
MergeAssignment::TargetColumn(field.name().clone()),
+                        ));
+                    }
+                }
+            }
+        }
+        // Reuse core's clause and assignment validation before a worker starts
+        // consuming data. Empty input cannot evaluate lazy assignment values.
+        py.detach(|| {
+            runtime().block_on(evaluate_merge_actions(
+                target_schema.clone(),
+                Vec::new(),
+                Vec::new(),
+                &matched,
+                &not_matched,
+                output,
+            ))
+        })
+        .map_err(to_py_err)?;
+        Ok(Self {
+            input_schema,
+            output_schema,
+            target_schema,
+            matched,
+            not_matched,
+            output,
+            callback_error,
+            execution: Mutex::new(()),
+        })
+    }
+
+    fn execute(&self, py: Python<'_>, batch: &Bound<'_, PyAny>) -> 
PyResult<Py<PyAny>> {
+        let schema = Schema::from_pyarrow_bound(&batch.getattr("schema")?)?;
+        if &schema != self.input_schema.as_ref() {
+            return Err(PyValueError::new_err(
+                "MERGE batch schema differs from the compiled input schema",
+            ));
+        }
+        let pa = py.import("pyarrow")?;
+        let mut batches = if batch.is_instance(&pa.getattr("RecordBatch")?)? {
+            vec![crate::arrow_input::batch_from_pyarrow(batch)?]
+        } else {
+            batch
+                .call_method0("to_batches")?
+                .try_iter()?
+                .map(|batch| crate::arrow_input::batch_from_pyarrow(&batch?))
+                .collect::<PyResult<Vec<_>>>()?
+        };
+        if batches.is_empty() {
+            batches.push(RecordBatch::new_empty(self.input_schema.clone()));
+        }
+        let (matched, unmatched) = if self.output == 
MergeActionOutput::Inserts {
+            (Vec::new(), batches)
+        } else {
+            (
+                batches.into_iter().map(row_ids).collect::<PyResult<_>>()?,
+                Vec::new(),
+            )
+        };
+        let (result, callback_error) = py.detach(|| {
+            // Cached operators may be called by multiple Python threads. Take
+            // this lock without the GIL so callbacks retain their error owner.
+            let _execution = self.execution.lock().unwrap();
+            let result = runtime().block_on(evaluate_merge_actions(
+                self.target_schema.clone(),
+                matched,
+                unmatched,
+                &self.matched,
+                &self.not_matched,
+                self.output,
+            ));
+            (result, self.callback_error.lock().unwrap().take())
+        });
+        if let Some(error) = callback_error {
+            return Err(error);
+        }
+        let actions = result.map_err(to_py_err)?;
+        let batches = match self.output {
+            MergeActionOutput::Updates => actions.updates,
+            MergeActionOutput::Inserts => actions.inserts,
+            MergeActionOutput::Deletes => vec![RecordBatch::try_new(
+                self.output_schema.clone(),
+                vec![Arc::new(Int64Array::from(actions.deletes))],
+            )
+            .map_err(|error| PyValueError::new_err(error.to_string()))?],
+            MergeActionOutput::All => unreachable!(),
+        };
+        let batches = batches
+            .iter()
+            .map(|batch| {
+                let columns = self
+                    .output_schema
+                    .fields()
+                    .iter()
+                    .map(|field| {
+                        
batch.column_by_name(field.name()).cloned().ok_or_else(|| {
+                            PyValueError::new_err(format!(
+                                "Missing MERGE output column {}",
+                                field.name()
+                            ))
+                        })
+                    })
+                    .collect::<PyResult<_>>()?;
+                RecordBatch::try_new(self.output_schema.clone(), columns)
+                    .map_err(|error| PyValueError::new_err(error.to_string()))?
+                    .to_pyarrow(py)
+            })
+            .collect::<PyResult<Vec<_>>>()?;
+        Ok(py
+            .import("pyarrow")?
+            .getattr("Table")?
+            .call_method1(
+                "from_batches",
+                (
+                    PyList::new(py, batches)?,
+                    self.output_schema.to_pyarrow(py)?,
+                ),
+            )?
+            .unbind())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use pyo3::ffi::c_str;
+
+    fn locals(py: Python<'_>) -> Bound<'_, PyDict> {
+        let locals = PyDict::new(py);
+        locals
+            .set_item(
+                "MergeBatchExecution",
+                py.get_type::<PyMergeBatchExecution>(),
+            )
+            .unwrap();
+        locals
+    }
+
+    #[test]
+    fn ordered_matched_actions_keep_schema_and_callback_grouping() {
+        Python::attach(|py| {
+            let locals = locals(py);
+            py.run(
+                c_str!(
+                    r#"
+import pyarrow as pa
+input_schema = pa.schema([
+    pa.field('t._ROW_ID', pa.int64(), nullable=False),
+    pa.field('t.value', pa.int32()),
+    pa.field('s.value', pa.int32()),
+    pa.field('t.keep', pa.string()),
+    pa.field('t.unassigned', pa.int32()),
+], metadata={'input': 'joined'})
+first = pa.record_batch([
+    pa.array([1, 2, 3], pa.int64()), pa.array([1, 2, 3], pa.int32()),
+    pa.array([15, None, 99], pa.int32()), pa.array(['one', 'two', 'three']),
+    pa.array([101, 102, 103], pa.int32()),
+], schema=input_schema)
+second = pa.record_batch([
+    pa.array([4, 5], pa.int64()), pa.array([4, 5], pa.int32()),
+    pa.array([20, 5], pa.int32()), pa.array(['four', 'five']),
+    pa.array([104, 105], pa.int32()),
+], schema=input_schema)
+joined = pa.Table.from_batches([first, second])
+calls = []
+def update(rows):
+    calls.append(rows['t._ROW_ID'].to_pylist())
+    assert rows['s.value'].to_pylist() == [15, 20]
+    return pa.chunked_array([pa.array([30]), pa.array([40])])
+clauses = [
+    dict(condition={'sql': '"s.value" >= 90'}, delete=True, assignments=[]),
+    dict(condition={'sql': '"s.value" > 10'}, delete=False,
+         assignments=[('value', 'callable', update), ('keep', 'target', 
'keep')]),
+    dict(condition=None, delete=False, assignments=[('value', 'literal', 50)]),
+]
+output_schema = pa.schema([
+    pa.field('_ROW_ID', pa.int64(), nullable=False),
+    pa.field('keep', pa.string(), metadata={'field': 'kept'}),
+    pa.field('unassigned', pa.int32()),
+    pa.field('value', pa.int32()),
+], metadata={'output': 'updates'})
+execution = MergeBatchExecution(input_schema, output_schema, clauses, 'update')
+result = execution.execute(joined)
+assert result.schema.equals(output_schema, check_metadata=True)
+assert result.to_pydict() == {
+    '_ROW_ID': [1, 4, 2, 5], 'keep': ['one', 'four', 'two', 'five'],
+    'unassigned': [101, 104, 102, 105],
+    'value': [30, 40, 50, 50],
+}
+assert calls == [[1, 4]]
+delete_schema = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False)])
+deletes = MergeBatchExecution(input_schema, delete_schema, clauses, 
'delete').execute(joined)
+assert deletes.schema == delete_schema
+assert deletes.to_pydict() == {'_ROW_ID': [3]}
+assert calls == [[1, 4]]
+assert 
execution.execute(input_schema.empty_table()).schema.equals(output_schema, 
check_metadata=True)
+assert calls == [[1, 4]]
+"#
+                ),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+        });
+    }
+
+    #[test]
+    fn ordered_inserts_preserve_typed_nulls_and_evaluate_selected_rows_once() {
+        Python::attach(|py| {
+            let locals = locals(py);
+            py.run(
+                c_str!(
+                    r#"
+import pyarrow as pa
+source = pa.table({'s.value': pa.array([None, 20, 5], pa.int32()),
+                   's.id': pa.array([3, 1, 2], pa.int32())})
+source = pa.Table.from_batches(source.to_batches(max_chunksize=1))
+target = pa.schema([pa.field('label', pa.string()), pa.field('id', pa.int32()),
+                    pa.field('value', pa.int32()), pa.field('missing', 
pa.bool_())])
+calls = []
+def insert(rows):
+    calls.append(rows['s.id'].to_pylist())
+    return pa.array([21], pa.int32())
+clauses = [
+    dict(condition={'sql': '"s.value" > 10'},
+         assignments=[('id', 'source', 'id'), ('value', 'callable', insert),
+                      ('label', 'literal', 'first')]),
+    dict(condition=None,
+         assignments=[('id', 'source', 'id'), ('value', 'literal', 7),
+                      ('label', 'literal', 'last'), ('missing', 'literal', 
True)]),
+]
+execution = MergeBatchExecution(source.schema, target, clauses, 'insert')
+result = execution.execute(source)
+assert result.schema == target
+assert result.to_pydict() == {'label': ['first', 'last', 'last'], 'id': [1, 3, 
2],
+                            'value': [21, 7, 7], 'missing': [None, True, True]}
+assert calls == [[1]]
+assert execution.execute(source.slice(0, 0)).schema == target
+assert calls == [[1]]
+"#
+                ),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+        });
+    }
+
+    #[test]
+    fn callback_errors_keep_identity_and_do_not_poison_cached_execution() {
+        Python::attach(|py| {
+            let locals = locals(py);
+            py.run(
+                c_str!(
+                    r#"
+import pyarrow as pa
+joined = pa.table({'t._ROW_ID': pa.array([7], pa.int64()),
+                   's.value': pa.array([1], pa.int32())})
+output = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False),
+                    pa.field('value', pa.int32())])
+failure = LookupError('assignment failed')
+attempts = []
+def assignment(rows):
+    attempts.append(rows.num_rows)
+    if len(attempts) == 1:
+        raise failure
+    return pa.array([9], pa.int32())
+clauses = [dict(condition=None, delete=False,
+                assignments=[('value', 'callable', assignment)])]
+execution = MergeBatchExecution(joined.schema, output, clauses, 'update')
+try:
+    execution.execute(joined)
+except LookupError as error:
+    assert error is failure
+else:
+    raise AssertionError('callback did not raise')
+assert execution.execute(joined).to_pydict() == {'_ROW_ID': [7], 'value': [9]}
+assert attempts == [1, 1]
+assert execution.execute(joined.to_batches()[0]).to_pydict() == {'_ROW_ID': 
[7], 'value': [9]}
+assert attempts == [1, 1, 1]
+"#
+                ),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+        });
+    }
+
+    #[test]
+    fn invalid_conditions_lengths_and_schema_changes_fail_before_output() {
+        Python::attach(|py| {
+            let locals = locals(py);
+            py.run(
+                c_str!(
+                    r#"
+import pyarrow as pa
+joined = pa.table({'t._ROW_ID': pa.array([1, 2], pa.int64()),
+                   's.value': pa.array([2, 3], pa.int32())})
+output = pa.schema([pa.field('_ROW_ID', pa.int64(), nullable=False),
+                    pa.field('value', pa.int32())])
+def rejected(function, text):
+    try:
+        function()
+    except ValueError as error:
+        assert text in str(error), str(error)
+    else:
+        raise AssertionError('invalid MERGE operation succeeded')
+clause = dict(condition={'sql': '"s.absent" > 0'}, delete=False,
+              assignments=[('value', 'literal', 1)])
+rejected(lambda: MergeBatchExecution(joined.schema, output, [clause], 
'update'), 'absent')
+clause = dict(condition=None, delete=False,
+              assignments=[('value', 'callable', lambda rows: pa.array([1], 
pa.int32()))])
+execution = MergeBatchExecution(joined.schema, output, [clause], 'update')
+rejected(lambda: execution.execute(joined), 'length')
+rejected(lambda: execution.execute(joined.rename_columns(['t._ROW_ID', 
's.other'])), 'schema')
+rejected(lambda: MergeBatchExecution(joined.schema, output, [clause], 
'unknown'), 'mode')
+rejected(lambda: MergeBatchExecution(joined.schema, pa.schema([('value', 
pa.int32())]),
+                                    [clause], 'update'), '_ROW_ID')
+rejected(lambda: MergeBatchExecution(joined.schema, pa.schema([('_ROW_ID', 
pa.int32())]),
+                                    [clause], 'delete'), 'Int64')
+rejected(lambda: MergeBatchExecution(joined.schema, output, [clause], 
'delete'), 'only')
+rejected(lambda: MergeBatchExecution(joined.schema, output, [clause, clause], 
'update'), 'last')
+dual_ids = joined.append_column('_ROW_ID', pa.array([99, 98], pa.int64()))
+rejected(lambda: MergeBatchExecution(dual_ids.schema, output, [clause], 
'update'), 'ambiguous')
+bad_type = dict(condition=None, delete=False,
+                assignments=[('value', 'callable', lambda rows: [1, 2])])
+rejected(lambda: MergeBatchExecution(joined.schema, output, [bad_type], 
'update').execute(joined),
+         'pyarrow.Array')
+# A no-match clause must not convert an unused literal or invoke a callback.
+clause = dict(condition={'sql': '"s.value" < 0'}, delete=False,
+              assignments=[('value', 'literal', object())])
+assert MergeBatchExecution(joined.schema, output, [clause], 
'update').execute(joined).num_rows == 0
+"#
+                ),
+                Some(&locals),
+                Some(&locals),
+            )
+            .unwrap();
+        });
+    }
+}
diff --git a/bindings/python/src/merge_into.rs 
b/bindings/python/src/merge_into.rs
index 4d237d2f..3d6611ae 100644
--- a/bindings/python/src/merge_into.rs
+++ b/bindings/python/src/merge_into.rs
@@ -30,7 +30,7 @@ use crate::error::to_py_err;
 
 type CallbackError = Arc<Mutex<Option<PyErr>>>;
 
-fn condition(
+pub(crate) fn condition(
     py: Python<'_>,
     clause: &Bound<'_, PyDict>,
     schema: SchemaRef,
@@ -51,10 +51,10 @@ fn condition(
         .map_err(to_py_err)
 }
 
-fn assignments(
+pub(crate) fn assignments(
     py: Python<'_>,
     clause: &Bound<'_, PyDict>,
-    fields: &[paimon::spec::DataField],
+    schema: SchemaRef,
     errors: CallbackError,
 ) -> PyResult<Vec<(String, MergeAssignment)>> {
     let values = clause
@@ -75,10 +75,14 @@ fn assignments(
                     }
                     let values = PyDict::new(py);
                     values.set_item(&name, value)?;
-                    let assignment =
-                        crate::update_assignment::from_python(py, &values, 
fields, errors.clone())?
-                            .remove(0)
-                            .1;
+                    let assignment = 
crate::update_assignment::from_python_schema(
+                        py,
+                        &values,
+                        schema.clone(),
+                        errors.clone(),
+                    )?
+                    .remove(0)
+                    .1;
                     MergeAssignment::Value(assignment)
                 }
                 _ => return Err(PyValueError::new_err("Unknown MERGE 
assignment kind")),
@@ -123,7 +127,7 @@ pub(crate) fn from_python(
                     .get_item("delete")?
                     .ok_or_else(|| PyValueError::new_err("MERGE matched clause 
needs action"))?
                     .extract()?,
-                assignments: assignments(py, clause, table.schema().fields(), 
errors.clone())?,
+                assignments: assignments(py, clause, target_schema.clone(), 
errors.clone())?,
             })
         })
         .collect::<PyResult<Vec<_>>>()?;
@@ -146,7 +150,7 @@ pub(crate) fn from_python(
             let clause = item.cast::<PyDict>()?;
             Ok(WhenNotMatched {
                 condition: condition(py, clause, schema.clone())?,
-                assignments: assignments(py, clause, table.schema().fields(), 
errors.clone())?,
+                assignments: assignments(py, clause, target_schema.clone(), 
errors.clone())?,
             })
         })
         .collect::<PyResult<Vec<_>>>()?;
diff --git a/bindings/python/src/update_assignment.rs 
b/bindings/python/src/update_assignment.rs
index 290d9560..3df5a3a5 100644
--- a/bindings/python/src/update_assignment.rs
+++ b/bindings/python/src/update_assignment.rs
@@ -18,6 +18,7 @@
 //! Python object and callback conversion for core update assignments.
 
 use arrow::array::ArrayRef;
+use arrow::datatypes::SchemaRef;
 use arrow::pyarrow::ToPyArrow;
 use paimon::spec::DataField;
 use paimon::table::UpdateAssignment;
@@ -62,6 +63,15 @@ pub(crate) fn from_python(
     error: CallbackError,
 ) -> PyResult<Vec<(String, UpdateAssignment)>> {
     let schema = 
paimon::arrow::build_target_arrow_schema(fields).map_err(to_py_err)?;
+    from_python_schema(py, assignments, schema, error)
+}
+
+pub(crate) fn from_python_schema(
+    py: Python<'_>,
+    assignments: &Bound<'_, PyDict>,
+    schema: SchemaRef,
+    error: CallbackError,
+) -> PyResult<Vec<(String, UpdateAssignment)>> {
     let pa = py.import("pyarrow")?;
     let array_type = pa.getattr("Array")?;
     let chunked_type = pa.getattr("ChunkedArray")?;
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 1f95c5af..dc463d17 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -260,7 +260,8 @@ pub use source::{
 pub use stream_table_scan::StreamTableScan;
 pub use table_commit::TableCommit;
 pub use table_merge_into::{
-    MergeAssignment, MergeCondition, MergeSource, WhenMatched, WhenNotMatched,
+    evaluate_merge_actions, MergeActionOutput, MergeActions, MergeAssignment, 
MergeCondition,
+    MergeSource, WhenMatched, WhenNotMatched,
 };
 pub use table_read::{AuditLogRead, TableRead};
 pub use table_scan::{AuditLogScan, FileScanPlan, TableScan};
diff --git a/crates/paimon/src/table/table_merge_actions_tests.rs 
b/crates/paimon/src/table/table_merge_actions_tests.rs
new file mode 100644
index 00000000..b35d2c7b
--- /dev/null
+++ b/crates/paimon/src/table/table_merge_actions_tests.rs
@@ -0,0 +1,403 @@
+// 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.
+
+use super::{
+    evaluate_merge_actions, MergeActionOutput, MergeAssignment, 
MergeCondition, UpdateAssignment,
+    WhenMatched, WhenNotMatched,
+};
+use arrow_array::{
+    Array, ArrayRef, BooleanArray, Int32Array, Int64Array, RecordBatch, 
StringArray,
+};
+use arrow_schema::{DataType, Field, Schema, SchemaRef};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+fn target_schema() -> SchemaRef {
+    Arc::new(Schema::new(vec![
+        Field::new("value", DataType::Int32, true),
+        Field::new("other", DataType::Int32, true),
+    ]))
+}
+
+fn joined(ids: Vec<i64>, source: Vec<Option<i64>>) -> RecordBatch {
+    let values = ids.iter().map(|id| *id as i32 * 10).collect::<Vec<_>>();
+    let other = ids.iter().map(|id| *id as i32 * 100).collect::<Vec<_>>();
+    RecordBatch::try_from_iter([
+        ("_ROW_ID", Arc::new(Int64Array::from(ids)) as ArrayRef),
+        ("t.value", Arc::new(Int32Array::from(values)) as ArrayRef),
+        ("t.other", Arc::new(Int32Array::from(other)) as ArrayRef),
+        ("s.value", Arc::new(Int64Array::from(source)) as ArrayRef),
+    ])
+    .unwrap()
+}
+
+fn source(values: Vec<Option<i64>>) -> RecordBatch {
+    RecordBatch::try_from_iter([("s.value", Arc::new(Int64Array::from(values)) 
as ArrayRef)])
+        .unwrap()
+}
+
+fn greater_than(bound: i64) -> MergeCondition {
+    MergeCondition {
+        target_columns: Vec::new(),
+        source_columns: vec!["value".into()],
+        evaluate: Arc::new(move |batch| {
+            Box::pin(async move {
+                let values = batch
+                    .column_by_name("s.value")
+                    .unwrap()
+                    .as_any()
+                    .downcast_ref::<Int64Array>()
+                    .unwrap();
+                Ok(BooleanArray::from(
+                    values
+                        .iter()
+                        .map(|value| value.map(|value| value > bound))
+                        .collect::<Vec<_>>(),
+                ))
+            })
+        }),
+    }
+}
+
+fn int_values(batches: &[RecordBatch], name: &str) -> Vec<Option<i32>> {
+    batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column_by_name(name)
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .iter()
+                .collect::<Vec<_>>()
+        })
+        .collect()
+}
+
+fn row_ids(batches: &[RecordBatch]) -> Vec<i64> {
+    batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column_by_name("_ROW_ID")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect()
+}
+
+#[tokio::test]
+async fn 
prejoined_matched_actions_preserve_clause_order_null_fallthrough_and_target_values()
 {
+    let calls = Arc::new(AtomicUsize::new(0));
+    let observed = calls.clone();
+    let assignment = UpdateAssignment::Function(Arc::new(move |batches| {
+        observed.fetch_add(1, Ordering::Relaxed);
+        assert_eq!(row_ids(batches), vec![1, 2]);
+        // The function returns different chunk boundaries from the selected 
input.
+        Ok(vec![Arc::new(Int64Array::from(vec![-1, -2])) as ArrayRef])
+    }));
+    let actions = evaluate_merge_actions(
+        target_schema(),
+        vec![
+            joined(vec![1, 3], vec![None, Some(15)]),
+            joined(vec![2, 4, 5], vec![Some(5), Some(25), Some(35)]),
+        ],
+        Vec::new(),
+        &[
+            WhenMatched {
+                condition: Some(greater_than(20)),
+                delete: true,
+                assignments: Vec::new(),
+            },
+            WhenMatched {
+                condition: Some(greater_than(10)),
+                delete: false,
+                assignments: vec![(
+                    "value".into(),
+                    MergeAssignment::SourceColumn("value".into()),
+                )],
+            },
+            WhenMatched {
+                condition: None,
+                delete: false,
+                assignments: vec![("other".into(), 
MergeAssignment::Value(assignment))],
+            },
+        ],
+        &[],
+        MergeActionOutput::All,
+    )
+    .await
+    .unwrap();
+    assert_eq!(actions.update_columns, vec!["value", "other"]);
+    assert_eq!(row_ids(&actions.updates), vec![3, 1, 2]);
+    assert_eq!(
+        int_values(&actions.updates, "value"),
+        vec![Some(15), Some(10), Some(20)]
+    );
+    assert_eq!(
+        int_values(&actions.updates, "other"),
+        vec![Some(300), Some(-1), Some(-2)]
+    );
+    assert_eq!(actions.deletes, vec![4, 5]);
+    assert!(actions.inserts.is_empty());
+    assert_eq!(calls.load(Ordering::Relaxed), 1);
+}
+
+#[tokio::test]
+async fn prejoined_insert_actions_fill_typed_nulls_and_select_first_match() {
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("value", DataType::Int32, true),
+        Field::new("label", DataType::Utf8, true),
+    ]));
+    let literal = |value| {
+        vec![(
+            "value".into(),
+            
MergeAssignment::Value(UpdateAssignment::Scalar(Arc::new(Int64Array::from(vec![
+                value,
+            ])))),
+        )]
+    };
+    let calls = Arc::new(AtomicUsize::new(0));
+    let observed = calls.clone();
+    let fallback = UpdateAssignment::Function(Arc::new(move |batches| {
+        observed.fetch_add(1, Ordering::Relaxed);
+        assert_eq!(
+            batches
+                .iter()
+                .map(RecordBatch::num_rows)
+                .collect::<Vec<_>>(),
+            vec![1, 1]
+        );
+        Ok(batches
+            .iter()
+            .map(|batch| batch.column_by_name("s.value").unwrap().clone())
+            .collect())
+    }));
+    let actions = evaluate_merge_actions(
+        schema.clone(),
+        Vec::new(),
+        vec![
+            source(vec![None, Some(15)]),
+            source(vec![Some(5), Some(25)]),
+        ],
+        &[],
+        &[
+            WhenNotMatched {
+                condition: Some(greater_than(20)),
+                assignments: literal(20),
+            },
+            WhenNotMatched {
+                condition: Some(greater_than(10)),
+                assignments: literal(10),
+            },
+            WhenNotMatched {
+                condition: None,
+                assignments: vec![("value".into(), 
MergeAssignment::Value(fallback))],
+            },
+        ],
+        MergeActionOutput::Inserts,
+    )
+    .await
+    .unwrap();
+    assert_eq!(
+        int_values(&actions.inserts, "value"),
+        vec![Some(20), Some(10), None, Some(5)]
+    );
+    for batch in &actions.inserts {
+        assert_eq!(batch.schema(), schema);
+        let labels = batch
+            .column_by_name("label")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .unwrap();
+        assert_eq!(labels.null_count(), batch.num_rows());
+    }
+    assert!(actions.updates.is_empty());
+    assert!(actions.deletes.is_empty());
+    assert_eq!(calls.load(Ordering::Relaxed), 1);
+}
+
+#[tokio::test]
+async fn prejoined_delete_output_consumes_updates_without_evaluating_values() {
+    let clauses = [
+        WhenMatched {
+            condition: Some(greater_than(10)),
+            delete: false,
+            assignments: vec![(
+                "value".into(),
+                MergeAssignment::Value(UpdateAssignment::Function(Arc::new(|_| 
{
+                    panic!("delete output evaluated UPDATE")
+                }))),
+            )],
+        },
+        WhenMatched {
+            condition: None,
+            delete: true,
+            assignments: Vec::new(),
+        },
+    ];
+    // A delete-only caller needs no target output fields.
+    let actions = evaluate_merge_actions(
+        Arc::new(Schema::empty()),
+        vec![joined(vec![1, 2], vec![None, Some(15)])],
+        Vec::new(),
+        &clauses,
+        &[],
+        MergeActionOutput::Deletes,
+    )
+    .await
+    .unwrap();
+    assert_eq!(actions.deletes, vec![1]);
+    assert!(actions.updates.is_empty());
+}
+
+#[tokio::test]
+async fn prejoined_updates_consume_deletes_and_keep_empty_selections_lazy() {
+    let calls = Arc::new(AtomicUsize::new(0));
+    let observed = calls.clone();
+    let clauses = [
+        WhenMatched {
+            condition: Some(greater_than(10)),
+            delete: true,
+            assignments: Vec::new(),
+        },
+        WhenMatched {
+            condition: None,
+            delete: false,
+            assignments: vec![(
+                "value".into(),
+                
MergeAssignment::Value(UpdateAssignment::Function(Arc::new(move |batches| {
+                    observed.fetch_add(1, Ordering::Relaxed);
+                    assert_eq!(row_ids(batches), vec![1]);
+                    Ok(vec![Arc::new(Int32Array::from(vec![7])) as ArrayRef])
+                }))),
+            )],
+        },
+    ];
+    let actions = evaluate_merge_actions(
+        target_schema(),
+        vec![joined(vec![1, 2], vec![None, Some(15)])],
+        Vec::new(),
+        &clauses,
+        &[],
+        MergeActionOutput::Updates,
+    )
+    .await
+    .unwrap();
+    assert_eq!(row_ids(&actions.updates), vec![1]);
+    assert_eq!(int_values(&actions.updates, "value"), vec![Some(7)]);
+    assert!(actions.deletes.is_empty());
+    assert_eq!(calls.load(Ordering::Relaxed), 1);
+
+    evaluate_merge_actions(
+        target_schema(),
+        vec![joined(Vec::new(), Vec::new())],
+        Vec::new(),
+        &clauses,
+        &[],
+        MergeActionOutput::Updates,
+    )
+    .await
+    .unwrap();
+    assert_eq!(calls.load(Ordering::Relaxed), 1);
+}
+
+#[tokio::test]
+async fn 
prejoined_assignment_cast_errors_and_bad_condition_lengths_are_rejected() {
+    let error = evaluate_merge_actions(
+        target_schema(),
+        vec![joined(vec![1], vec![Some(15)])],
+        Vec::new(),
+        &[WhenMatched {
+            condition: None,
+            delete: false,
+            assignments: vec![(
+                "value".into(),
+                
MergeAssignment::Value(UpdateAssignment::Scalar(Arc::new(StringArray::from(
+                    vec!["invalid int"],
+                )))),
+            )],
+        }],
+        &[],
+        MergeActionOutput::Updates,
+    )
+    .await
+    .unwrap_err();
+    assert!(error.to_string().contains("invalid int"), "{error}");
+
+    let condition = MergeCondition {
+        target_columns: Vec::new(),
+        source_columns: Vec::new(),
+        evaluate: Arc::new(|_| Box::pin(async { 
Ok(BooleanArray::from(vec![true, false])) })),
+    };
+    let error = evaluate_merge_actions(
+        target_schema(),
+        vec![joined(vec![1], vec![Some(15)])],
+        Vec::new(),
+        &[WhenMatched {
+            condition: Some(condition),
+            delete: true,
+            assignments: Vec::new(),
+        }],
+        &[],
+        MergeActionOutput::Deletes,
+    )
+    .await
+    .unwrap_err();
+    assert!(error.to_string().contains("condition length"), "{error}");
+}
+
+#[tokio::test]
+async fn prejoined_matched_row_ids_are_validated_before_clause_callbacks() {
+    let missing = joined(vec![1], vec![Some(15)]).project(&[1, 2, 3]).unwrap();
+    let wrong_type =
+        RecordBatch::try_from_iter([("_ROW_ID", 
Arc::new(Int32Array::from(vec![1])) as ArrayRef)])
+            .unwrap();
+    let null_id = RecordBatch::try_from_iter([(
+        "_ROW_ID",
+        Arc::new(Int64Array::from(vec![None])) as ArrayRef,
+    )])
+    .unwrap();
+    let clauses = [WhenMatched {
+        condition: Some(MergeCondition {
+            target_columns: Vec::new(),
+            source_columns: Vec::new(),
+            evaluate: Arc::new(|_| panic!("invalid row IDs reached clause 
evaluation")),
+        }),
+        delete: true,
+        assignments: Vec::new(),
+    }];
+    for batch in [missing, wrong_type, null_id] {
+        let error = evaluate_merge_actions(
+            target_schema(),
+            vec![batch],
+            Vec::new(),
+            &clauses,
+            &[],
+            MergeActionOutput::Deletes,
+        )
+        .await
+        .unwrap_err();
+        assert!(error.to_string().contains("_ROW_ID"), "{error}");
+    }
+}
diff --git a/crates/paimon/src/table/table_merge_into.rs 
b/crates/paimon/src/table/table_merge_into.rs
index 4abdff74..c330c097 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -69,6 +69,218 @@ pub struct WhenNotMatched {
     pub assignments: Vec<(String, MergeAssignment)>,
 }
 
+/// Which MERGE outputs to materialize from already joined input.
+/// Other actions still consume their rows, without evaluating their 
assignments.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MergeActionOutput {
+    All,
+    Updates,
+    Deletes,
+    Inserts,
+}
+
+/// Materialized MERGE actions, grouped by clause and then input batch order.
+#[derive(Debug, Default)]
+pub struct MergeActions {
+    pub updates: Vec<RecordBatch>,
+    pub deletes: Vec<i64>,
+    pub inserts: Vec<RecordBatch>,
+    pub update_columns: Vec<String>,
+}
+
+/// Apply ordered clauses to prejoined matched and unmatched Arrow batches.
+/// Matched input has `_ROW_ID` and qualified `t.`/`s.` columns; unmatched 
input
+/// has qualified `s.` columns. Assignment callbacks see the complete selection
+/// of one clause, even when that selection spans several input batches.
+/// Updates contain `_ROW_ID` and the union of assigned columns in clause 
order,
+/// preserving target values for assignments omitted by a selected clause.
+/// Inserts contain every schema field, with typed NULLs for omitted 
assignments.
+pub async fn evaluate_merge_actions(
+    schema: SchemaRef,
+    matched_batches: Vec<RecordBatch>,
+    unmatched_batches: Vec<RecordBatch>,
+    matched: &[WhenMatched],
+    not_matched: &[WhenNotMatched],
+    output: MergeActionOutput,
+) -> crate::Result<MergeActions> {
+    let materialize_updates = matches!(output, MergeActionOutput::All | 
MergeActionOutput::Updates);
+    let materialize_deletes = matches!(output, MergeActionOutput::All | 
MergeActionOutput::Deletes);
+    let materialize_inserts = matches!(output, MergeActionOutput::All | 
MergeActionOutput::Inserts);
+    let mut actions = MergeActions::default();
+    for clause in matched {
+        if clause.delete && !clause.assignments.is_empty() {
+            return Err(invalid("MERGE DELETE cannot have assignments"));
+        }
+        if materialize_updates {
+            validate_assignments(&schema, &clause.assignments, false)?;
+            for (name, _) in &clause.assignments {
+                if !actions.update_columns.contains(name) {
+                    actions.update_columns.push(name.clone());
+                }
+            }
+        }
+    }
+    if materialize_inserts {
+        for clause in not_matched {
+            validate_assignments(&schema, &clause.assignments, true)?;
+            if clause
+                .condition
+                .as_ref()
+                .is_some_and(|condition| !condition.target_columns.is_empty())
+            {
+                return Err(invalid("WHEN NOT MATCHED cannot reference target 
columns"));
+            }
+        }
+    }
+    for conditions in [
+        matched
+            .iter()
+            .map(|clause| &clause.condition)
+            .collect::<Vec<_>>(),
+        not_matched.iter().map(|clause| &clause.condition).collect(),
+    ] {
+        if conditions
+            .iter()
+            .take(conditions.len().saturating_sub(1))
+            .any(|condition| condition.is_none())
+        {
+            return Err(invalid("Only the last MERGE clause may omit its 
condition"));
+        }
+    }
+    if materialize_updates || materialize_deletes {
+        let mut pending = matched_batches
+            .into_iter()
+            .filter(|batch| batch.num_rows() > 0)
+            .collect::<Vec<_>>();
+        for batch in &pending {
+            let ids = batch
+                .column_by_name(ROW_ID)
+                .and_then(|array| array.as_any().downcast_ref::<Int64Array>())
+                .ok_or_else(|| {
+                    invalid("Matched MERGE input must contain an Int64 _ROW_ID 
column")
+                })?;
+            if ids.null_count() != 0 {
+                return Err(invalid("Matched MERGE _ROW_ID must not contain 
NULL"));
+            }
+        }
+        for clause in matched {
+            let mut remaining = Vec::new();
+            let mut clause_rows = Vec::new();
+            for batch in pending {
+                let (selected, rest) = select_clause(batch, 
&clause.condition).await?;
+                if rest.num_rows() > 0 {
+                    remaining.push(rest);
+                }
+                if selected.num_rows() == 0 {
+                    continue;
+                }
+                if clause.delete {
+                    if materialize_deletes {
+                        let ids = selected
+                            .column_by_name(ROW_ID)
+                            .unwrap()
+                            .as_any()
+                            .downcast_ref::<Int64Array>()
+                            .unwrap();
+                        actions.deletes.extend(ids.values().iter().copied());
+                    }
+                } else if materialize_updates && 
!actions.update_columns.is_empty() {
+                    clause_rows.push(selected);
+                }
+            }
+            // Assignment cursors and callbacks cover the complete clause 
selection,
+            // independent of physical input and result chunk boundaries.
+            if !clause_rows.is_empty() {
+                actions.updates.extend(assigned(
+                    &clause_rows,
+                    &clause.assignments,
+                    &actions.update_columns,
+                    schema.clone(),
+                    false,
+                )?);
+            }
+            pending = remaining;
+        }
+    }
+    if materialize_inserts {
+        let mut pending = unmatched_batches
+            .into_iter()
+            .filter(|batch| batch.num_rows() > 0)
+            .map(with_insert_row_ids)
+            .collect::<crate::Result<Vec<_>>>()?;
+        let columns = schema
+            .fields()
+            .iter()
+            .map(|field| field.name().clone())
+            .collect::<Vec<_>>();
+        for clause in not_matched {
+            let mut remaining = Vec::new();
+            let mut clause_rows = Vec::new();
+            for batch in pending {
+                let (selected, rest) = select_clause(batch, 
&clause.condition).await?;
+                if rest.num_rows() > 0 {
+                    remaining.push(rest);
+                }
+                if selected.num_rows() > 0 {
+                    clause_rows.push(selected);
+                }
+            }
+            if !clause_rows.is_empty() {
+                for batch in assigned(
+                    &clause_rows,
+                    &clause.assignments,
+                    &columns,
+                    schema.clone(),
+                    true,
+                )? {
+                    actions.inserts.push(
+                        batch
+                            
.project(&(1..batch.num_columns()).collect::<Vec<_>>())
+                            .map_err(|error| invalid(error.to_string()))?,
+                    );
+                }
+            }
+            pending = remaining;
+        }
+    }
+    Ok(actions)
+}
+
+fn validate_assignments(
+    schema: &SchemaRef,
+    assignments: &[(String, MergeAssignment)],
+    insert: bool,
+) -> crate::Result<()> {
+    let mut names = HashSet::new();
+    for (name, value) in assignments {
+        if name == ROW_ID || !names.insert(name) {
+            return Err(invalid(format!(
+                "Invalid or duplicate MERGE assignment {name}"
+            )));
+        }
+        schema
+            .field_with_name(name)
+            .map_err(|error| invalid(error.to_string()))?;
+        if insert && matches!(value, MergeAssignment::TargetColumn(_)) {
+            return Err(invalid("WHEN NOT MATCHED cannot reference target 
columns"));
+        }
+    }
+    Ok(())
+}
+
+fn with_insert_row_ids(batch: RecordBatch) -> crate::Result<RecordBatch> {
+    if batch.column_by_name(ROW_ID).is_some() {
+        return Ok(batch);
+    }
+    let mut fields = vec![Arc::new(Field::new(ROW_ID, DataType::Int64, 
false))];
+    fields.extend(batch.schema().fields().iter().cloned());
+    let mut columns =
+        vec![Arc::new(Int64Array::from(vec![0; batch.num_rows()])) as 
arrow_array::ArrayRef];
+    columns.extend(batch.columns().iter().cloned());
+    RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
+        .map_err(|error| invalid(error.to_string()))
+}
+
 /// Materialized input, a Paimon source table, or the target joined on its row 
ID.
 pub enum MergeSource {
     Batches(Vec<RecordBatch>),
@@ -516,93 +728,32 @@ pub(super) async fn merge_into(
             joined.push(aliases(Some(&target), &target)?);
         }
     }
-    let mut updates = Vec::new();
-    let mut deletes = Vec::new();
-    let mut pending = joined;
-    for clause in matched {
-        let mut remaining = Vec::new();
-        let mut clause_rows = Vec::new();
-        for batch in pending {
-            let (selected, rest) = select_clause(batch, 
&clause.condition).await?;
-            if rest.num_rows() > 0 {
-                remaining.push(rest);
-            }
-            if selected.num_rows() == 0 {
-                continue;
-            }
-            if clause.delete {
-                let ids = selected
-                    .column_by_name(ROW_ID)
-                    .unwrap()
-                    .as_any()
-                    .downcast_ref::<Int64Array>()
-                    .unwrap();
-                deletes.extend(ids.values().iter().copied());
-            } else if !update_columns.is_empty() {
-                clause_rows.push(selected);
-            }
-        }
-        // Array/function assignments cover the complete clause selection, not
-        // each physical target batch. Shared assignment cursors consume chunks
-        // across these batches while preserving row IDs and scan order.
-        if !clause_rows.is_empty() {
-            updates.extend(assigned(
-                &clause_rows,
-                &clause.assignments,
-                &update_columns,
-                schema.clone(),
-                false,
-            )?);
-        }
-        pending = remaining;
-    }
-    let mut inserts = Vec::new();
+    let mut unmatched = Vec::new();
     if let Some(source) = source {
-        let mut pending = Vec::new();
         for (chunk, batch) in source.batches.iter().enumerate() {
             let offset = source.row_offset(chunk);
-            let unmatched = (0..batch.num_rows())
+            let rows = (0..batch.num_rows())
                 .filter(|row| !seen_source[offset + row])
                 .collect::<Vec<_>>();
-            if !unmatched.is_empty() {
-                pending.push(aliases(None, &gather(batch, &unmatched)?)?);
+            if !rows.is_empty() {
+                unmatched.push(aliases(None, &gather(batch, &rows)?)?);
             }
         }
-        let columns = schema
-            .fields()
-            .iter()
-            .map(|field| field.name().clone())
-            .collect::<Vec<_>>();
-        for clause in not_matched {
-            let mut remaining = Vec::new();
-            let mut clause_rows = Vec::new();
-            for batch in pending {
-                let (selected, rest) = select_clause(batch, 
&clause.condition).await?;
-                if rest.num_rows() > 0 {
-                    remaining.push(rest);
-                }
-                if selected.num_rows() > 0 {
-                    clause_rows.push(selected);
-                }
-            }
-            if !clause_rows.is_empty() {
-                for batch in assigned(
-                    &clause_rows,
-                    &clause.assignments,
-                    &columns,
-                    schema.clone(),
-                    true,
-                )? {
-                    inserts.push(
-                        batch
-                            
.project(&(1..batch.num_columns()).collect::<Vec<_>>())
-                            .map_err(|error| invalid(error.to_string()))?,
-                    );
-                }
-            }
-            pending = remaining;
-        }
     }
+    let MergeActions {
+        updates,
+        deletes,
+        inserts,
+        update_columns,
+    } = evaluate_merge_actions(
+        schema,
+        joined,
+        unmatched,
+        &matched,
+        &not_matched,
+        MergeActionOutput::All,
+    )
+    .await?;
     // Materialize/validate every action before staging. Once prepared, 
messages
     // may already be referenced by publication; a later error must retain 
their files.
     let mut messages = Vec::new();
@@ -634,3 +785,7 @@ pub(super) async fn merge_into(
     }
     Ok(messages)
 }
+
+#[cfg(test)]
+#[path = "table_merge_actions_tests.rs"]
+mod tests;

Reply via email to