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 5b2eceb8 feat(table): add prepared MERGE operations to core updates 
(#1068)
5b2eceb8 is described below

commit 5b2eceb820d2a4598b3fe71e495350a945b0a58d
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 09:31:56 2026 +0800

    feat(table): add prepared MERGE operations to core updates (#1068)
---
 bindings/python/README.md                          |  15 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  23 +
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/merge_into.rs                  | 149 ++++++
 bindings/python/src/write.rs                       |  76 +++
 bindings/python/tests/test_update.py               |  37 ++
 crates/integrations/datafusion/src/lib.rs          |   2 +
 .../integrations/datafusion/src/merge_condition.rs | 267 ++++++++++
 crates/paimon/src/table/mod.rs                     |   6 +
 crates/paimon/src/table/table_merge_into.rs        | 588 +++++++++++++++++++++
 crates/paimon/src/table/table_merge_into_tests.rs  | 477 +++++++++++++++++
 crates/paimon/src/table/table_update.rs            |  20 +
 12 files changed, 1661 insertions(+)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index f436eeef..8cdca711 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -108,6 +108,21 @@ print(f"\nRead: {batches_tt[0].num_rows} rows")
 print(batches_tt[0])
 ```
 
+### Prepared MERGE operations
+
+Batch and stream update objects expose `merge_into` to prepare data-evolution
+updates, deletion vectors and inserts without publishing a snapshot. Rust core
+owns matching, ordered action selection and file staging. SQL conditions are
+compiled by the Rust DataFusion adapter. PyPaimon's existing `WhenMatched` and
+`WhenNotMatched` APIs normalize into these core clauses; no Python join runs on
+the native path.
+
+Source NULL keys do not match target NULL keys. Multiple source matches are
+rejected before action conditions, except for a sole unconditional DELETE.
+Self-merge uses the target's pinned row IDs. A later action failure retains 
files already prepared by earlier actions, even
+when the operation cannot return their messages. Returned messages may only be
+explicitly aborted by a caller that knows publication has been abandoned.
+
 ### Custom Blob URI sources
 
 Before the first Arrow write, call
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index ed8a1b4a..8e91f096 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -262,6 +262,17 @@ class BatchTableWrite:
         ...
 
 class BatchTableUpdate:
+    def merge_into(
+        self, source: Optional[pyarrow.Table], *, on: Sequence[Tuple[str, 
str]],
+        when_matched: Sequence[Dict[str, Any]], when_not_matched: 
Sequence[Dict[str, Any]]
+    ) -> List[CommitMessage]:
+        """Prepare MERGE against one snapshot. None selects self-merge on 
_ROW_ID.
+
+        Clauses carry assignments as (column, source/target/literal, value)
+        tuples, a delete flag, and optional SQL condition metadata.
+        Commit returned messages through the matching builder's committer.
+        """
+        ...
     def update_by_predicate(
         self, predicate: Optional[Dict[str, Any]], assignments: Dict[str, Any],
         read_columns: Optional[Sequence[str]] = None
@@ -289,6 +300,18 @@ class TableUpdateByRowId:
     def _abort(self) -> None: ...
 
 class StreamTableUpdate:
+    def merge_into(
+        self, source: Optional[pyarrow.Table], *, on: Sequence[Tuple[str, 
str]],
+        when_matched: Sequence[Dict[str, Any]], when_not_matched: 
Sequence[Dict[str, Any]],
+        commit_identifier: int
+    ) -> List[CommitMessage]:
+        """Prepare MERGE against one snapshot. None selects self-merge on 
_ROW_ID.
+
+        Clauses carry assignments as (column, source/target/literal, value)
+        tuples, a delete flag, and optional SQL condition metadata.
+        Commit returned messages through the matching builder's committer.
+        """
+        ...
     def update_by_predicate(
         self, predicate: Optional[Dict[str, Any]], assignments: Dict[str, Any],
         commit_identifier: int, read_columns: Optional[Sequence[str]] = None
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 30446a3f..b4e270f7 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -21,6 +21,7 @@ mod blob;
 mod blob_uri_reader;
 mod context;
 mod error;
+mod merge_into;
 mod oss_cpp;
 mod predicate;
 mod read;
diff --git a/bindings/python/src/merge_into.rs 
b/bindings/python/src/merge_into.rs
new file mode 100644
index 00000000..17e8683b
--- /dev/null
+++ b/bindings/python/src/merge_into.rs
@@ -0,0 +1,149 @@
+// 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.
+
+//! Language transport and DataFusion expression compilation for core MERGE.
+
+use std::sync::{Arc, Mutex};
+
+use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
+use paimon::table::{MergeAssignment, MergeCondition, WhenMatched, 
WhenNotMatched};
+use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::PyDict;
+
+use crate::error::to_py_err;
+
+type CallbackError = Arc<Mutex<Option<PyErr>>>;
+
+fn condition(
+    py: Python<'_>,
+    clause: &Bound<'_, PyDict>,
+    schema: SchemaRef,
+) -> PyResult<Option<MergeCondition>> {
+    let Some(value) = clause
+        .get_item("condition")?
+        .filter(|value| !value.is_none())
+    else {
+        return Ok(None);
+    };
+    let value = value.cast::<PyDict>()?;
+    let sql: String = value
+        .get_item("sql")?
+        .ok_or_else(|| PyValueError::new_err("MERGE condition needs SQL"))?
+        .extract()?;
+    py.detach(|| 
runtime().block_on(paimon_datafusion::compile_merge_condition(sql, schema)))
+        .map(Some)
+        .map_err(to_py_err)
+}
+
+fn assignments(
+    py: Python<'_>,
+    clause: &Bound<'_, PyDict>,
+    fields: &[paimon::spec::DataField],
+    errors: CallbackError,
+) -> PyResult<Vec<(String, MergeAssignment)>> {
+    let values = clause
+        .get_item("assignments")?
+        .ok_or_else(|| PyValueError::new_err("MERGE clause needs 
assignments"))?;
+    values
+        .try_iter()?
+        .map(|item| {
+            let (name, kind, value): (String, String, Py<PyAny>) = 
item?.extract()?;
+            let assignment = match kind.as_str() {
+                "source" => 
MergeAssignment::SourceColumn(value.bind(py).extract()?),
+                "target" => 
MergeAssignment::TargetColumn(value.bind(py).extract()?),
+                "literal" => {
+                    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;
+                    MergeAssignment::Value(assignment)
+                }
+                _ => return Err(PyValueError::new_err("Unknown MERGE 
assignment kind")),
+            };
+            Ok((name, assignment))
+        })
+        .collect()
+}
+
+pub(crate) fn from_python(
+    py: Python<'_>,
+    table: &paimon::table::Table,
+    source_schema: SchemaRef,
+    matched: &Bound<'_, PyAny>,
+    not_matched: &Bound<'_, PyAny>,
+    errors: CallbackError,
+) -> PyResult<(Vec<WhenMatched>, Vec<WhenNotMatched>)> {
+    let target_schema =
+        
paimon::arrow::build_target_arrow_schema(table.schema().fields()).map_err(to_py_err)?;
+    let mut fields = vec![Field::new("t._ROW_ID", DataType::Int64, false)];
+    fields.extend(target_schema.fields().iter().map(|field| {
+        field
+            .as_ref()
+            .clone()
+            .with_name(format!("t.{}", field.name()))
+    }));
+    fields.extend(source_schema.fields().iter().map(|field| {
+        field
+            .as_ref()
+            .clone()
+            .with_name(format!("s.{}", field.name()))
+    }));
+    let schema = Arc::new(Schema::new(fields));
+    let matched = matched
+        .try_iter()?
+        .map(|item| {
+            let item = item?;
+            let clause = item.cast::<PyDict>()?;
+            Ok(WhenMatched {
+                condition: condition(py, clause, schema.clone())?,
+                delete: clause
+                    .get_item("delete")?
+                    .ok_or_else(|| PyValueError::new_err("MERGE matched clause 
needs action"))?
+                    .extract()?,
+                assignments: assignments(py, clause, table.schema().fields(), 
errors.clone())?,
+            })
+        })
+        .collect::<PyResult<Vec<_>>>()?;
+    let schema = Arc::new(Schema::new(
+        source_schema
+            .fields()
+            .iter()
+            .map(|field| {
+                field
+                    .as_ref()
+                    .clone()
+                    .with_name(format!("s.{}", field.name()))
+            })
+            .collect::<Vec<_>>(),
+    ));
+    let not_matched = not_matched
+        .try_iter()?
+        .map(|item| {
+            let item = item?;
+            let clause = item.cast::<PyDict>()?;
+            Ok(WhenNotMatched {
+                condition: condition(py, clause, schema.clone())?,
+                assignments: assignments(py, clause, table.schema().fields(), 
errors.clone())?,
+            })
+        })
+        .collect::<PyResult<Vec<_>>>()?;
+    Ok((matched, not_matched))
+}
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index d1de7f8a..7555cebc 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -583,6 +583,53 @@ impl UpdateContext {
         })
     }
 
+    fn merge_into(
+        &self,
+        py: Python<'_>,
+        source: Option<&Bound<'_, PyAny>>,
+        on: Vec<(String, String)>,
+        when_matched: &Bound<'_, PyAny>,
+        when_not_matched: &Bound<'_, PyAny>,
+    ) -> PyResult<Vec<PyCommitMessage>> {
+        let source = match source {
+            Some(source) => 
paimon::table::MergeSource::Batches(arrow_table_batches(source)?),
+            None => paimon::table::MergeSource::SelfTable,
+        };
+        let source_schema = match &source {
+            paimon::table::MergeSource::Batches(batches) => 
batches[0].schema(),
+            paimon::table::MergeSource::SelfTable => {
+                let schema = 
paimon::arrow::build_target_arrow_schema(self.table.schema().fields())
+                    .map_err(to_py_err)?;
+                let mut fields = schema.fields().to_vec();
+                fields.push(Arc::new(arrow::datatypes::Field::new(
+                    "_ROW_ID",
+                    arrow::datatypes::DataType::Int64,
+                    false,
+                )));
+                Arc::new(ArrowSchema::new(fields))
+            }
+        };
+        let errors = Arc::new(Mutex::new(None));
+        let (matched, not_matched) = crate::merge_into::from_python(
+            py,
+            &self.table,
+            source_schema,
+            when_matched,
+            when_not_matched,
+            errors.clone(),
+        )?;
+        let result = py
+            .detach(|| runtime().block_on(self.inner.merge_into(source, on, 
matched, not_matched)));
+        if let Some(error) = errors.lock().unwrap().take() {
+            return Err(error);
+        }
+        Ok(wrap_messages(
+            result.map_err(to_py_err)?,
+            &self.table_location,
+            &self.commit_user,
+        ))
+    }
+
     fn update_by_predicate(
         &self,
         py: Python<'_>,
@@ -862,6 +909,22 @@ pub struct PyStreamTableUpdate {
 
 #[pymethods]
 impl PyStreamTableUpdate {
+    #[pyo3(signature = (source, *, on, when_matched, when_not_matched, 
commit_identifier))]
+    fn merge_into(
+        &self,
+        py: Python<'_>,
+        source: Option<&Bound<'_, PyAny>>,
+        on: Vec<(String, String)>,
+        when_matched: &Bound<'_, PyAny>,
+        when_not_matched: &Bound<'_, PyAny>,
+        commit_identifier: i64,
+    ) -> PyResult<Vec<PyCommitMessage>> {
+        // The stream committer applies the identifier to the returned 
messages.
+        let _ = commit_identifier;
+        self.context
+            .merge_into(py, source, on, when_matched, when_not_matched)
+    }
+
     #[pyo3(signature = (predicate, assignments, commit_identifier, 
read_columns=None))]
     fn update_by_predicate(
         &self,
@@ -933,6 +996,19 @@ impl PyStreamTableUpdate {
 
 #[pymethods]
 impl PyBatchTableUpdate {
+    #[pyo3(signature = (source, *, on, when_matched, when_not_matched))]
+    fn merge_into(
+        &self,
+        py: Python<'_>,
+        source: Option<&Bound<'_, PyAny>>,
+        on: Vec<(String, String)>,
+        when_matched: &Bound<'_, PyAny>,
+        when_not_matched: &Bound<'_, PyAny>,
+    ) -> PyResult<Vec<PyCommitMessage>> {
+        self.context
+            .merge_into(py, source, on, when_matched, when_not_matched)
+    }
+
     #[pyo3(signature = (predicate, assignments, read_columns=None))]
     fn update_by_predicate(
         &self,
diff --git a/bindings/python/tests/test_update.py 
b/bindings/python/tests/test_update.py
index 3702f645..7bed73e2 100644
--- a/bindings/python/tests/test_update.py
+++ b/bindings/python/tests/test_update.py
@@ -429,3 +429,40 @@ def 
test_update_can_transfer_and_be_collected_on_another_thread(tmp_path, kind,
         finally:
             if enabled:
                 gc.enable()
+
+
[email protected]('stream', [False, True])
+def test_prepared_merge_conditions_chunks_and_lazy_literals(tmp_path, stream):
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+    context.sql('CREATE SCHEMA paimon.prepared_merge')
+    context.sql("""CREATE TABLE paimon.prepared_merge.t (id INT, value INT) 
WITH (
+        'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
+        'deletion-vectors.enabled' = 'true')""")
+    # Separate commits force the clause to consume values across target files.
+    context.sql('INSERT INTO paimon.prepared_merge.t (id, value) VALUES (1, 
10)')
+    context.sql('INSERT INTO paimon.prepared_merge.t (id, value) VALUES (2, 
20)')
+    table = PaimonCatalog({'warehouse': 
str(tmp_path)}).get_table('prepared_merge.t')
+    builder = 
(table.new_stream_write_builder().with_commit_user('prepared-merge')
+               if stream else table.new_batch_write_builder())
+    source = pa.table({'id': [1, 2], 'value': [11, 22]}, schema=pa.schema([
+        ('id', pa.int32()), ('value', pa.int32())]))
+    update = builder.new_update()
+    kwargs = dict(commit_identifier=42) if stream else {}
+    false_clause = dict(delete=False, condition=dict(sql='"s.value" < 0'),
+                        assignments=[('value', 'literal', 'invalid int')])
+    assert update.merge_into(source, on=[('id', 'id')], 
when_matched=[false_clause],
+                             when_not_matched=[], **kwargs) == []
+    messages = update.merge_into(source, on=[('id', 'id')], when_matched=[
+        dict(delete=False, condition=dict(sql='"s.value" IN (SELECT v FROM 
(VALUES (11), (22)) AS m(v))'),
+             assignments=[('value', 'literal', pa.chunked_array([[100], 
[200]]))])],
+        when_not_matched=[], **kwargs)
+    # Preparation leaves the visible snapshot unchanged.
+
+    def rows():
+        return pa.Table.from_batches(context.sql(
+            'SELECT id, value FROM paimon.prepared_merge.t ORDER BY 
id')).to_pylist()
+    assert rows() == [dict(id=1, value=10), dict(id=2, value=20)]
+    commit = builder.new_commit()
+    commit.commit(42, messages) if stream else commit.commit(messages)
+    assert rows() == [dict(id=1, value=100), dict(id=2, value=200)]
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index 7043303e..cfab74c0 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -52,6 +52,7 @@ mod full_text_search;
 mod hybrid_search;
 mod lateral_vector_search;
 mod memory;
+mod merge_condition;
 mod merge_into;
 mod partition_count_pushdown;
 mod physical_plan;
@@ -88,6 +89,7 @@ pub use error::to_datafusion_error;
 #[cfg(feature = "fulltext")]
 pub use full_text_search::{register_full_text_search, FullTextSearchFunction};
 pub use hybrid_search::{register_hybrid_search, HybridSearchFunction};
+pub use merge_condition::compile_merge_condition;
 pub use physical_plan::PaimonTableScan;
 pub use relation_planner::PaimonRelationPlanner;
 pub use sql_context::{SQLContext, SQLContextBuilder};
diff --git a/crates/integrations/datafusion/src/merge_condition.rs 
b/crates/integrations/datafusion/src/merge_condition.rs
new file mode 100644
index 00000000..b1a6363f
--- /dev/null
+++ b/crates/integrations/datafusion/src/merge_condition.rs
@@ -0,0 +1,267 @@
+// 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.
+
+//! DataFusion conditions for the engine-independent core MERGE operation.
+
+use datafusion::arrow::array::{BooleanArray, UInt64Array};
+use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
+use datafusion::arrow::record_batch::RecordBatch;
+use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
+use datafusion::logical_expr::{Expr, LogicalPlan};
+use datafusion::physical_expr::PhysicalExpr;
+use datafusion::prelude::SessionContext;
+use paimon::table::MergeCondition;
+use std::collections::BTreeSet;
+use std::sync::{Arc, Mutex};
+
+fn invalid(error: impl std::fmt::Display) -> paimon::Error {
+    paimon::Error::DataInvalid {
+        message: format!("Invalid MERGE condition: {error}"),
+        source: None,
+    }
+}
+
+const ROW_INDEX: &str = "__merge_condition_row";
+
+// Full SQL planning handles scalar/IN subqueries. Preserve row identity 
because
+// their join rewrites may reorder the input. Core consumes a mask in input 
order.
+async fn evaluate_query(sql: &str, batch: RecordBatch) -> 
paimon::Result<BooleanArray> {
+    let mut fields = batch.schema().fields().to_vec();
+    fields.push(Arc::new(Field::new(ROW_INDEX, DataType::UInt64, false)));
+    let mut columns = batch.columns().to_vec();
+    columns.push(Arc::new(UInt64Array::from_iter_values(
+        0..batch.num_rows() as u64,
+    )));
+    let input = RecordBatch::try_new(Arc::new(Schema::new(fields)), 
columns).map_err(invalid)?;
+    let context = SessionContext::new();
+    context
+        .register_batch("_merge_batch", input)
+        .map_err(invalid)?;
+    let output = context
+        .sql(&format!("SELECT {ROW_INDEX} FROM _merge_batch WHERE {sql}"))
+        .await
+        .map_err(invalid)?
+        .collect()
+        .await
+        .map_err(invalid)?;
+    let mut mask = vec![false; batch.num_rows()];
+    for output in output {
+        let indices = output
+            .column(0)
+            .as_any()
+            .downcast_ref::<UInt64Array>()
+            .ok_or_else(|| invalid("Missing condition row index"))?;
+        for row in 0..output.num_rows() {
+            let index = indices.value(row) as usize;
+            if index >= mask.len() || mask[index] {
+                return Err(invalid("Condition changed input row cardinality"));
+            }
+            mask[index] = true;
+        }
+    }
+    Ok(BooleanArray::from(mask))
+}
+
+/// Compile a SQL condition over `t.<column>` and `s.<column>` Arrow fields.
+/// Target and source dependencies include correlated subquery references.
+/// Core controls matching and action order; this adapter only evaluates SQL.
+pub async fn compile_merge_condition(
+    sql: String,
+    schema: SchemaRef,
+) -> paimon::Result<MergeCondition> {
+    let context = SessionContext::new();
+    context
+        .register_batch("_merge_batch", RecordBatch::new_empty(schema))
+        .map_err(invalid)?;
+    let frame = context
+        .sql(&format!("SELECT * FROM _merge_batch WHERE {sql}"))
+        .await
+        .map_err(invalid)?;
+    let plan = frame.into_unoptimized_plan();
+    let LogicalPlan::Projection(projection) = plan else {
+        return Err(invalid("Expected a condition expression"));
+    };
+    let LogicalPlan::Filter(filter) = projection.input.as_ref() else {
+        return Err(invalid("Expected a condition expression"));
+    };
+    let mut target_columns = BTreeSet::new();
+    let mut source_columns = BTreeSet::new();
+    // Start at the filter so SELECT * does not turn every field into a
+    // dependency. Subquery outer references are not in Expr::column_refs().
+    LogicalPlan::Filter(filter.clone())
+        .apply_with_subqueries(|plan| {
+            for expression in plan.expressions() {
+                expression.apply(|expression| {
+                    if let Expr::Column(column) | 
Expr::OuterReferenceColumn(_, column) = expression
+                    {
+                        if let Some(name) = column.name.strip_prefix("t.") {
+                            target_columns.insert(name.to_string());
+                        }
+                        if let Some(name) = column.name.strip_prefix("s.") {
+                            source_columns.insert(name.to_string());
+                        }
+                    }
+                    Ok(TreeNodeRecursion::Continue)
+                })?;
+            }
+            Ok(TreeNodeRecursion::Continue)
+        })
+        .map_err(invalid)?;
+    // Ordinary expressions compile once. Subqueries need the complete planner;
+    // validate that plan before matching, then execute against each action 
batch.
+    let compiled = match context
+        .state()
+        .create_physical_expr(filter.predicate.clone(), filter.input.schema())
+    {
+        Ok(expression) => Some(expression),
+        Err(_) => {
+            let plan = context
+                .state()
+                .optimize(&LogicalPlan::Projection(projection))
+                .map_err(invalid)?;
+            context
+                .state()
+                .create_physical_plan(&plan)
+                .await
+                .map_err(invalid)?;
+            None
+        }
+    };
+    let cached = Arc::new(Mutex::new(None::<(SchemaRef, Arc<dyn 
PhysicalExpr>)>));
+    Ok(MergeCondition {
+        target_columns: target_columns.into_iter().collect(),
+        source_columns: source_columns.into_iter().collect(),
+        evaluate: Arc::new(move |batch| {
+            let compiled = compiled.clone();
+            let cached = cached.clone();
+            let sql = sql.clone();
+            Box::pin(async move {
+                let Some(compiled) = compiled else {
+                    return evaluate_query(&sql, batch).await;
+                };
+                let mut cached = cached.lock().unwrap();
+                if cached
+                    .as_ref()
+                    .is_none_or(|(schema, _)| schema != &batch.schema())
+                {
+                    let expression = 
datafusion::physical_expr::utils::reassign_expr_columns(
+                        compiled,
+                        batch.schema().as_ref(),
+                    )
+                    .map_err(invalid)?;
+                    *cached = Some((batch.schema(), expression));
+                }
+                let value = cached
+                    .as_ref()
+                    .unwrap()
+                    .1
+                    .evaluate(&batch)
+                    .map_err(invalid)?
+                    .into_array(batch.num_rows())
+                    .map_err(invalid)?;
+                value
+                    .as_any()
+                    .downcast_ref::<BooleanArray>()
+                    .cloned()
+                    .ok_or_else(|| invalid("Condition must be BOOLEAN"))
+            })
+        }),
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use datafusion::arrow::array::{ArrayRef, Int32Array};
+
+    fn batch(names: &[&str], values: &[Vec<Option<i32>>]) -> RecordBatch {
+        RecordBatch::try_from_iter(names.iter().zip(values).map(|(name, 
values)| {
+            (
+                *name,
+                Arc::new(Int32Array::from(values.clone())) as ArrayRef,
+            )
+        }))
+        .unwrap()
+    }
+
+    // Runs on a current-thread runtime: condition evaluation must await its
+    // plan rather than synchronously blocking on another Tokio runtime.
+    #[tokio::test]
+    async fn projected_and_reordered_conditions_preserve_nulls() {
+        let input = batch(
+            &["t.id", "t.value", "s.value"],
+            &[
+                vec![Some(1), Some(2)],
+                vec![Some(10), Some(20)],
+                vec![Some(11), None],
+            ],
+        );
+        let condition = compile_merge_condition("\"s.value\" > 
\"t.value\"".into(), input.schema())
+            .await
+            .unwrap();
+        let projected = input.project(&[2, 1]).unwrap();
+        let result = (condition.evaluate)(projected).await.unwrap();
+        assert_eq!(result.iter().collect::<Vec<_>>(), vec![Some(true), None]);
+        assert_eq!(condition.source_columns, vec!["value"]);
+    }
+
+    #[tokio::test]
+    async fn scalar_and_in_subqueries_preserve_input_order() {
+        let input = batch(&["s.value"], &[vec![Some(3), None, Some(1), 
Some(2)]]);
+        for sql in [
+            "\"s.value\" > (SELECT 1)",
+            "\"s.value\" IN (SELECT v FROM (VALUES (2), (3)) AS m(v))",
+            "\"s.value\" > (SELECT MAX(v) FROM (VALUES (0), (1)) AS m(v))",
+        ] {
+            let condition = compile_merge_condition(sql.into(), input.schema())
+                .await
+                .unwrap();
+            let result = (condition.evaluate)(input.clone()).await.unwrap();
+            assert_eq!(
+                result.iter().collect::<Vec<_>>(),
+                vec![Some(true), Some(false), Some(false), Some(true)],
+                "{sql}"
+            );
+        }
+    }
+    #[tokio::test]
+    async fn self_conditions_collect_correlated_subquery_dependencies() {
+        let input = batch(
+            &["s.id", "t.value", "s.unused"],
+            &[
+                vec![Some(1), Some(2)],
+                vec![Some(10), Some(20)],
+                vec![Some(3), Some(4)],
+            ],
+        );
+        let condition = compile_merge_condition(
+            "EXISTS (SELECT 1 WHERE \"s.id\" = 1 AND \"t.value\" > 5)".into(),
+            input.schema(),
+        )
+        .await
+        .unwrap();
+        assert_eq!(condition.source_columns, vec!["id"]);
+        assert_eq!(condition.target_columns, vec!["value"]);
+        let result = (condition.evaluate)(input.project(&[1, 0]).unwrap())
+            .await
+            .unwrap();
+        assert_eq!(
+            result.iter().collect::<Vec<_>>(),
+            vec![Some(true), Some(false)]
+        );
+    }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 168f3d91..24a3cf22 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -142,6 +142,9 @@ mod sorted_global_index_options;
 mod source;
 mod stats_filter;
 pub(crate) mod table_commit;
+mod table_merge_into;
+#[cfg(test)]
+mod table_merge_into_tests;
 mod table_read;
 mod table_scan;
 mod table_update;
@@ -219,6 +222,9 @@ pub use source::{
     merge_row_ranges, DataSplit, DataSplitBuilder, DeletionFile, 
PartitionBucket, Plan, RowRange,
 };
 pub use table_commit::TableCommit;
+pub use table_merge_into::{
+    MergeAssignment, MergeCondition, MergeSource, WhenMatched, WhenNotMatched,
+};
 pub use table_read::{AuditLogRead, TableRead};
 pub use table_scan::{AuditLogScan, TableScan};
 pub use table_update::TableUpdate;
diff --git a/crates/paimon/src/table/table_merge_into.rs 
b/crates/paimon/src/table/table_merge_into.rs
new file mode 100644
index 00000000..e788867b
--- /dev/null
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -0,0 +1,588 @@
+// 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.
+
+//! Data-evolution MERGE orchestration, independent of an expression engine.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+use arrow_array::{Array, BooleanArray, Int64Array, RecordBatch, UInt64Array};
+use arrow_row::{RowConverter, SortField};
+use arrow_schema::{DataType, Field, Schema, SchemaRef};
+use arrow_select::{concat::concat_batches, take::take};
+use futures::{future::BoxFuture, TryStreamExt};
+
+use super::data_evolution_writer::RowIdFileIndex;
+use super::{CommitMessage, DataEvolutionWriter, Table, TableUpdateByRowId, 
UpdateAssignment};
+
+const ROW_ID: &str = "_ROW_ID";
+type ConditionFunction =
+    dyn Fn(RecordBatch) -> BoxFuture<'static, crate::Result<BooleanArray>> + 
Send + Sync;
+
+/// An engine-compiled condition. NULL evaluates to false when choosing a 
clause.
+/// Dependencies control target projection, including source fields for self 
MERGE.
+/// Evaluation is asynchronous so expression engines can execute subquery 
plans.
+#[derive(Clone)]
+pub struct MergeCondition {
+    pub target_columns: Vec<String>,
+    pub source_columns: Vec<String>,
+    pub evaluate: Arc<ConditionFunction>,
+}
+
+/// SET/INSERT values. Column references are evaluated by core, without 
callbacks.
+#[derive(Clone)]
+pub enum MergeAssignment {
+    SourceColumn(String),
+    TargetColumn(String),
+    /// Evaluate once across all selected rows of this clause.
+    /// Matched rows follow target scan order; inserts follow source input 
order.
+    Value(UpdateAssignment),
+}
+
+/// Ordered matched clauses, corresponding to WHEN MATCHED UPDATE/DELETE.
+#[derive(Clone)]
+pub struct WhenMatched {
+    pub condition: Option<MergeCondition>,
+    pub delete: bool,
+    pub assignments: Vec<(String, MergeAssignment)>,
+}
+
+/// Ordered WHEN NOT MATCHED INSERT clauses.
+#[derive(Clone)]
+pub struct WhenNotMatched {
+    pub condition: Option<MergeCondition>,
+    pub assignments: Vec<(String, MergeAssignment)>,
+}
+
+/// Already materialized input, or the target itself joined on its row ID.
+pub enum MergeSource {
+    Batches(Vec<RecordBatch>),
+    SelfTable,
+}
+
+fn invalid(message: impl Into<String>) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: message.into(),
+        source: None,
+    }
+}
+
+fn gather(batch: &RecordBatch, rows: &[usize]) -> crate::Result<RecordBatch> {
+    let indices = UInt64Array::from(rows.iter().map(|&row| row as 
u64).collect::<Vec<_>>());
+    let columns = batch
+        .columns()
+        .iter()
+        .map(|array| {
+            take(array.as_ref(), &indices, None).map_err(|error| 
invalid(error.to_string()))
+        })
+        .collect::<crate::Result<_>>()?;
+    RecordBatch::try_new(batch.schema(), columns).map_err(|error| 
invalid(error.to_string()))
+}
+
+fn aliases(target: Option<&RecordBatch>, source: &RecordBatch) -> 
crate::Result<RecordBatch> {
+    let mut fields = Vec::new();
+    let mut columns = Vec::new();
+    if let Some(target) = target {
+        fields.push(Arc::new(Field::new(ROW_ID, DataType::Int64, false)));
+        columns.push(target.column_by_name(ROW_ID).unwrap().clone());
+        for (field, column) in 
target.schema().fields().iter().zip(target.columns()) {
+            fields.push(Arc::new(
+                field
+                    .as_ref()
+                    .clone()
+                    .with_name(format!("t.{}", field.name())),
+            ));
+            columns.push(column.clone());
+        }
+    } else {
+        fields.push(Arc::new(Field::new(ROW_ID, DataType::Int64, false)));
+        columns.push(Arc::new(Int64Array::from(vec![0; source.num_rows()])));
+    }
+    for (field, column) in 
source.schema().fields().iter().zip(source.columns()) {
+        fields.push(Arc::new(
+            field
+                .as_ref()
+                .clone()
+                .with_name(format!("s.{}", field.name())),
+        ));
+        columns.push(column.clone());
+    }
+    RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
+        .map_err(|error| invalid(error.to_string()))
+}
+
+async fn select_clause(
+    batch: RecordBatch,
+    condition: &Option<MergeCondition>,
+) -> crate::Result<(RecordBatch, RecordBatch)> {
+    let mask = match condition {
+        Some(condition) => Some((condition.evaluate)(batch.clone()).await?),
+        None => None,
+    };
+    if mask
+        .as_ref()
+        .is_some_and(|mask| mask.len() != batch.num_rows())
+    {
+        return Err(invalid("MERGE condition length must match input row 
count"));
+    }
+    let mut selected = Vec::new();
+    let mut remaining = Vec::new();
+    for row in 0..batch.num_rows() {
+        if mask
+            .as_ref()
+            .is_none_or(|mask| !mask.is_null(row) && mask.value(row))
+        {
+            selected.push(row);
+        } else {
+            remaining.push(row);
+        }
+    }
+    Ok((gather(&batch, &selected)?, gather(&batch, &remaining)?))
+}
+
+fn column_assignment(
+    batches: &[RecordBatch],
+    prefix: &str,
+    name: &str,
+) -> crate::Result<UpdateAssignment> {
+    Ok(UpdateAssignment::Array(
+        batches
+            .iter()
+            .map(|batch| {
+                batch
+                    .column_by_name(&format!("{prefix}.{name}"))
+                    .cloned()
+                    .ok_or_else(|| invalid(format!("Missing MERGE {prefix} 
column {name}")))
+            })
+            .collect::<crate::Result<_>>()?,
+    ))
+}
+
+fn assigned(
+    batches: &[RecordBatch],
+    assignments: &[(String, MergeAssignment)],
+    columns: &[String],
+    schema: SchemaRef,
+    insert: bool,
+) -> crate::Result<Vec<RecordBatch>> {
+    let mut values = Vec::new();
+    for name in columns {
+        let value = assignments
+            .iter()
+            .find(|(column, _)| column == name)
+            .map(|(_, value)| value);
+        let assignment = match value {
+            Some(MergeAssignment::Value(value)) => value.clone(),
+            Some(MergeAssignment::SourceColumn(column)) => 
column_assignment(batches, "s", column)?,
+            Some(MergeAssignment::TargetColumn(column)) => 
column_assignment(batches, "t", column)?,
+            None if insert => 
UpdateAssignment::Scalar(arrow_array::new_null_array(
+                schema.field_with_name(name).unwrap().data_type(),
+                1,
+            )),
+            None => column_assignment(batches, "t", name)?,
+        };
+        values.push((name.clone(), assignment));
+    }
+    super::update_assignment::assigned_batches(batches, values, schema)
+}
+
+pub(super) async fn merge_into(
+    table: &Table,
+    commit_user: &str,
+    source: MergeSource,
+    on: Vec<(String, String)>,
+    matched: Vec<WhenMatched>,
+    not_matched: Vec<WhenNotMatched>,
+) -> crate::Result<Vec<CommitMessage>> {
+    if on.is_empty() || matched.is_empty() && not_matched.is_empty() {
+        return Err(invalid("MERGE requires ON keys and at least one action"));
+    }
+    let schema = 
crate::arrow::build_target_arrow_schema(table.schema().fields())?;
+    let mut update_columns = Vec::new();
+    let mut projection: HashSet<String> = on.iter().map(|(target, _)| 
target.clone()).collect();
+    projection.insert(ROW_ID.into());
+    for clause in &matched {
+        if clause.delete && !clause.assignments.is_empty() {
+            return Err(invalid("MERGE DELETE cannot have assignments"));
+        }
+        for (name, value) in &clause.assignments {
+            schema
+                .field_with_name(name)
+                .map_err(|error| invalid(error.to_string()))?;
+            if table.schema().partition_keys().contains(name) {
+                return Err(invalid("MERGE does not support updating partition 
columns"));
+            }
+            if !update_columns.contains(name) {
+                update_columns.push(name.clone());
+            }
+            if let MergeAssignment::TargetColumn(name) = value {
+                projection.insert(name.clone());
+            }
+            if matches!(value, 
MergeAssignment::Value(UpdateAssignment::Function(_))) {
+                projection.extend(schema.fields().iter().map(|field| 
field.name().clone()));
+            }
+        }
+        if let Some(condition) = &clause.condition {
+            projection.extend(condition.target_columns.clone());
+        }
+    }
+    projection.extend(update_columns.clone());
+    for clause in &not_matched {
+        if clause
+            .condition
+            .as_ref()
+            .is_some_and(|condition| !condition.target_columns.is_empty())
+            || clause
+                .assignments
+                .iter()
+                .any(|(_, value)| matches!(value, 
MergeAssignment::TargetColumn(_)))
+        {
+            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"));
+        }
+    }
+    let _ = DataEvolutionWriter::new(table, update_columns.clone())?;
+    let snapshot = super::time_travel::resolve_snapshot(table).await?;
+    let scan_table = match &snapshot {
+        Some(snapshot) => table.copy_with_pinned_snapshot(snapshot),
+        None => table.clone(),
+    }
+    .copy_with_options(HashMap::from([
+        ("scalar-index.search-mode".into(), "FULL".into()),
+        ("scan.mode".into(), "default".into()),
+    ]));
+    let self_merge = matches!(source, MergeSource::SelfTable);
+    if self_merge {
+        if on != [(ROW_ID.into(), ROW_ID.into())] || !not_matched.is_empty() {
+            return Err(invalid(
+                "Self MERGE requires ON _ROW_ID and no insert clauses",
+            ));
+        }
+        for clause in &matched {
+            if let Some(condition) = &clause.condition {
+                projection.extend(condition.source_columns.clone());
+            }
+            for (_, assignment) in &clause.assignments {
+                if let MergeAssignment::SourceColumn(name) = assignment {
+                    projection.insert(name.clone());
+                }
+            }
+        }
+    }
+    let projection = projection.into_iter().collect::<Vec<_>>();
+    let mut builder = scan_table.new_read_builder();
+    
builder.with_projection(&projection.iter().map(String::as_str).collect::<Vec<_>>())?;
+    let plan = if snapshot.is_some() {
+        builder.new_scan().with_scan_all_files().plan().await?
+    } else {
+        super::source::Plan::new(Vec::new())
+    };
+    let index = RowIdFileIndex::from_splits(scan_table.clone(), 
plan.splits())?;
+    let source = match source {
+        MergeSource::Batches(batches) => {
+            let first = batches
+                .first()
+                .ok_or_else(|| invalid("MERGE source needs an Arrow schema"))?;
+            if batches.iter().any(|batch| batch.schema() != first.schema()) {
+                return Err(invalid("MERGE source batches must share a 
schema"));
+            }
+            let names = first
+                .schema()
+                .fields()
+                .iter()
+                .map(|field| field.name().clone())
+                .collect::<HashSet<_>>();
+            if names.len() != first.num_columns() {
+                return Err(invalid("MERGE source has duplicate column names"));
+            }
+            Some(
+                concat_batches(&first.schema(), &batches)
+                    .map_err(|error| invalid(error.to_string()))?,
+            )
+        }
+        MergeSource::SelfTable => None,
+    };
+    let source_schema = source
+        .as_ref()
+        .map_or_else(|| schema.clone(), RecordBatch::schema);
+    for (assignments, condition, insert) in matched
+        .iter()
+        .map(|clause| (&clause.assignments, &clause.condition, false))
+        .chain(
+            not_matched
+                .iter()
+                .map(|clause| (&clause.assignments, &clause.condition, true)),
+        )
+    {
+        let mut seen = HashSet::new();
+        for (name, value) in assignments {
+            if !seen.insert(name) {
+                return Err(invalid(format!("Duplicate MERGE assignment 
{name}")));
+            }
+            schema
+                .field_with_name(name)
+                .map_err(|error| invalid(error.to_string()))?;
+            match value {
+                MergeAssignment::SourceColumn(name) => {
+                    if !(self_merge && name == ROW_ID) {
+                        source_schema
+                            .field_with_name(name)
+                            .map_err(|error| invalid(error.to_string()))?;
+                    }
+                }
+                MergeAssignment::TargetColumn(name) => {
+                    if insert {
+                        return Err(invalid("INSERT cannot reference target 
columns"));
+                    }
+                    if name != ROW_ID {
+                        schema
+                            .field_with_name(name)
+                            .map_err(|error| invalid(error.to_string()))?;
+                    }
+                }
+                MergeAssignment::Value(_) => {}
+            }
+        }
+        if let Some(condition) = condition {
+            for name in &condition.target_columns {
+                if name != ROW_ID {
+                    schema
+                        .field_with_name(name)
+                        .map_err(|error| invalid(error.to_string()))?;
+                }
+            }
+        }
+    }
+    if matched.iter().any(|clause| clause.delete)
+        && 
!crate::spec::CoreOptions::new(table.schema().options()).deletion_vectors_enabled()
+    {
+        return Err(invalid(
+            "MERGE DELETE requires deletion-vectors.enabled=true",
+        ));
+    }
+    let mut converter = None;
+    let mut source_keys = HashMap::<Vec<u8>, Vec<usize>>::new();
+    let mut seen_source = vec![false; source.as_ref().map_or(0, 
RecordBatch::num_rows)];
+    if let Some(source) = &source {
+        let keys = on
+            .iter()
+            .map(|(target, source_name)| {
+                let source_column = source
+                    .column_by_name(source_name)
+                    .ok_or_else(|| invalid(format!("Missing source ON key 
{source_name}")))?;
+                let target_type = if target == ROW_ID {
+                    &DataType::Int64
+                } else {
+                    schema
+                        .field_with_name(target)
+                        .map_err(|error| invalid(error.to_string()))?
+                        .data_type()
+                };
+                if source_column.data_type() != target_type
+                    || 
!super::upsert_key_matcher::supported_key_type(target_type)
+                {
+                    return Err(invalid(
+                        "MERGE ON key types must match and support row 
encoding",
+                    ));
+                }
+                Ok(source_column.clone())
+            })
+            .collect::<crate::Result<Vec<_>>>()?;
+        let key_converter = RowConverter::new(
+            keys.iter()
+                .map(|column| SortField::new(column.data_type().clone()))
+                .collect(),
+        )
+        .map_err(|error| invalid(error.to_string()))?;
+        let rows = key_converter
+            .convert_columns(&keys)
+            .map_err(|error| invalid(error.to_string()))?;
+        for row in 0..source.num_rows() {
+            if keys.iter().all(|column| !column.is_null(row)) {
+                source_keys
+                    .entry(rows.row(row).as_ref().to_vec())
+                    .or_default()
+                    .push(row);
+            }
+        }
+        converter = Some(key_converter);
+    }
+    // Spark validates cardinality before evaluating action predicates. A sole
+    // unconditional DELETE may match multiple source rows without ambiguity.
+    let check_cardinality = !matched.is_empty()
+        && !(matched.len() == 1 && matched[0].delete && 
matched[0].condition.is_none());
+    let mut joined = Vec::new();
+    let mut stream = builder.new_read()?.to_arrow(plan.splits())?;
+    while let Some(target) = stream.try_next().await? {
+        if let Some(source) = &source {
+            let keys = on
+                .iter()
+                .map(|(name, _)| target.column_by_name(name).unwrap().clone())
+                .collect::<Vec<_>>();
+            let rows = converter
+                .as_ref()
+                .unwrap()
+                .convert_columns(&keys)
+                .map_err(|error| invalid(error.to_string()))?;
+            let mut target_rows = Vec::new();
+            let mut source_rows = Vec::new();
+            for row in 0..target.num_rows() {
+                if keys.iter().any(|column| column.is_null(row)) {
+                    continue;
+                }
+                if let Some(sources) = source_keys.get(rows.row(row).as_ref()) 
{
+                    if check_cardinality && sources.len() > 1 {
+                        return Err(invalid(
+                            "MERGE matched multiple source rows to the same 
target _ROW_ID",
+                        ));
+                    }
+                    for &source in sources {
+                        seen_source[source] = true;
+                    }
+                    target_rows.push(row);
+                    source_rows.push(sources[0]);
+                }
+            }
+            if !target_rows.is_empty() {
+                joined.push(aliases(
+                    Some(&gather(&target, &target_rows)?),
+                    &gather(source, &source_rows)?,
+                )?);
+            }
+        } else if target.num_rows() > 0 {
+            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();
+    if let Some(source) = source {
+        let unmatched = seen_source
+            .iter()
+            .enumerate()
+            .filter_map(|(row, seen)| (!seen).then_some(row))
+            .collect::<Vec<_>>();
+        let mut pending = aliases(None, &gather(&source, &unmatched)?)?;
+        let columns = schema
+            .fields()
+            .iter()
+            .map(|field| field.name().clone())
+            .collect::<Vec<_>>();
+        for clause in not_matched {
+            if pending.num_rows() == 0 {
+                break;
+            }
+            let (selected, rest) = select_clause(pending, 
&clause.condition).await?;
+            pending = rest;
+            if selected.num_rows() > 0 {
+                for batch in assigned(
+                    std::slice::from_ref(&selected),
+                    &clause.assignments,
+                    &columns,
+                    schema.clone(),
+                    true,
+                )? {
+                    inserts.push(
+                        batch
+                            
.project(&(1..batch.num_columns()).collect::<Vec<_>>())
+                            .map_err(|error| invalid(error.to_string()))?,
+                    );
+                }
+            }
+        }
+    }
+    // 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();
+    if !updates.is_empty() {
+        let mut updater = TableUpdateByRowId::with_index(table, index)?;
+        messages.extend(updater.update_columns(updates, 
update_columns).await?);
+    }
+    if !deletes.is_empty() {
+        let mut writer = super::DataEvolutionDeleteWriter::new(&scan_table)?;
+        writer.add_row_ids(deletes)?;
+        messages.extend(writer.prepare_commit().await?);
+    }
+    if !inserts.is_empty() {
+        let mut writer = table
+            .new_write_builder()
+            .with_commit_user(commit_user.to_string())?
+            .new_write()?;
+        let result = async {
+            for batch in inserts {
+                writer.write_arrow_batch(&batch).await?;
+            }
+            writer.prepare_commit().await
+        }
+        .await;
+        writer.close().await;
+        messages.extend(result?);
+    }
+    Ok(messages)
+}
diff --git a/crates/paimon/src/table/table_merge_into_tests.rs 
b/crates/paimon/src/table/table_merge_into_tests.rs
new file mode 100644
index 00000000..4b4ec6db
--- /dev/null
+++ b/crates/paimon/src/table/table_merge_into_tests.rs
@@ -0,0 +1,477 @@
+// 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::{
+    MergeAssignment, MergeCondition, MergeSource, Table, TableCommit, 
UpdateAssignment,
+    WhenMatched, WhenNotMatched,
+};
+use crate::catalog::Identifier;
+use crate::io::FileIOBuilder;
+use crate::spec::{DataType, IntType, Schema, TableSchema};
+use arrow_array::{Array, ArrayRef, BooleanArray, Int32Array, RecordBatch};
+use futures::TryStreamExt;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+fn table() -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .option("data-evolution.enabled", "true")
+        .option("row-tracking.enabled", "true")
+        .option("deletion-vectors.enabled", "true")
+        .build()
+        .unwrap();
+    Table::new(
+        FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("db", "t"),
+        "memory:/merge".into(),
+        TableSchema::new(0, &schema),
+        None,
+    )
+}
+
+fn batch(ids: Vec<Option<i32>>, values: Vec<Option<i32>>) -> RecordBatch {
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+        ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+    ])
+    .unwrap()
+}
+
+async fn seed(table: &Table, data: &RecordBatch) {
+    let mut writer = table
+        .new_write_builder()
+        .with_commit_user("merge")
+        .unwrap()
+        .new_write()
+        .unwrap();
+    writer.write_arrow_batch(data).await.unwrap();
+    TableCommit::new(table.clone(), "merge".into())
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+    writer.close().await;
+}
+
+async fn rows(table: &Table) -> Vec<(Option<i32>, Option<i32>)> {
+    let builder = table.new_read_builder();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let mut stream = 
builder.new_read().unwrap().to_arrow(plan.splits()).unwrap();
+    let mut values = Vec::new();
+    while let Some(batch) = stream.try_next().await.unwrap() {
+        let ids = batch
+            .column_by_name("id")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let data = batch
+            .column_by_name("value")
+            .unwrap()
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        values.extend(ids.iter().zip(data.iter()));
+    }
+    values.sort();
+    values
+}
+
+fn update(condition: Option<MergeCondition>) -> WhenMatched {
+    WhenMatched {
+        condition,
+        delete: false,
+        assignments: vec![(
+            "value".into(),
+            MergeAssignment::SourceColumn("value".into()),
+        )],
+    }
+}
+
+#[tokio::test]
+async fn core_merge_preserves_row_ids_and_handles_sql_null_keys() {
+    let table = table();
+    seed(
+        &table,
+        &batch(
+            vec![Some(1), Some(2), None],
+            vec![Some(10), Some(20), Some(90)],
+        ),
+    )
+    .await;
+    let source = batch(
+        vec![Some(1), Some(3), None],
+        vec![Some(11), Some(30), Some(91)],
+    );
+    let updater = table
+        .new_write_builder()
+        .with_commit_user("merge")
+        .unwrap()
+        .new_update()
+        .unwrap();
+    let messages = updater
+        .merge_into(
+            MergeSource::Batches(vec![source]),
+            vec![("id".into(), "id".into())],
+            vec![update(None)],
+            vec![WhenNotMatched {
+                condition: None,
+                assignments: vec![
+                    ("id".into(), MergeAssignment::SourceColumn("id".into())),
+                    (
+                        "value".into(),
+                        MergeAssignment::SourceColumn("value".into()),
+                    ),
+                ],
+            }],
+        )
+        .await
+        .unwrap();
+    assert!(messages
+        .iter()
+        .filter(|message| !message.new_files.is_empty())
+        .any(|message| message.check_from_snapshot == Some(1)));
+    TableCommit::new(table.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![
+            (None, Some(90)),
+            (None, Some(91)),
+            (Some(1), Some(11)),
+            (Some(2), Some(20)),
+            (Some(3), Some(30))
+        ]
+    );
+}
+
+#[tokio::test]
+async fn duplicate_source_fails_before_condition_evaluation_and_staging() {
+    let table = table();
+    seed(&table, &batch(vec![Some(1)], vec![Some(10)])).await;
+    let files = table
+        .file_io()
+        .list_status_recursive(table.location())
+        .await
+        .unwrap()
+        .len();
+    let calls = Arc::new(AtomicUsize::new(0));
+    let evaluated = calls.clone();
+    let condition = MergeCondition {
+        target_columns: Vec::new(),
+        source_columns: Vec::new(),
+        evaluate: Arc::new(move |batch| {
+            evaluated.fetch_add(1, Ordering::Relaxed);
+            Box::pin(async move { Ok(BooleanArray::from(vec![false; 
batch.num_rows()])) })
+        }),
+    };
+    let updater = table.new_write_builder().new_update().unwrap();
+    let error = updater
+        .merge_into(
+            MergeSource::Batches(vec![batch(
+                vec![Some(1), Some(1)],
+                vec![Some(11), Some(12)],
+            )]),
+            vec![("id".into(), "id".into())],
+            vec![update(Some(condition))],
+            Vec::new(),
+        )
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("multiple source rows"));
+    assert_eq!(calls.load(Ordering::Relaxed), 0);
+    assert_eq!(
+        table
+            .file_io()
+            .list_status_recursive(table.location())
+            .await
+            .unwrap()
+            .len(),
+        files
+    );
+}
+
+#[tokio::test]
+async fn unconditional_delete_collapses_duplicate_source_matches() {
+    let table = table();
+    seed(
+        &table,
+        &batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
+    )
+    .await;
+    let updater = table.new_write_builder().new_update().unwrap();
+    let messages = updater
+        .merge_into(
+            MergeSource::Batches(vec![batch(
+                vec![Some(1), Some(1)],
+                vec![Some(11), Some(12)],
+            )]),
+            vec![("id".into(), "id".into())],
+            vec![WhenMatched {
+                condition: None,
+                delete: true,
+                assignments: Vec::new(),
+            }],
+            Vec::new(),
+        )
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(rows(&table).await, vec![(Some(2), Some(20))]);
+}
+
+#[tokio::test]
+async fn self_merge_null_condition_falls_through_to_next_clause() {
+    let table = table();
+    seed(
+        &table,
+        &batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
+    )
+    .await;
+    let null_condition = MergeCondition {
+        target_columns: Vec::new(),
+        source_columns: Vec::new(),
+        evaluate: Arc::new(|batch| {
+            Box::pin(async move { Ok(BooleanArray::from(vec![None; 
batch.num_rows()])) })
+        }),
+    };
+    let value = 
MergeAssignment::Value(UpdateAssignment::Scalar(Arc::new(Int32Array::from(vec![
+        77,
+    ]))));
+    let updater = table.new_write_builder().new_update().unwrap();
+    let messages = updater
+        .merge_into(
+            MergeSource::SelfTable,
+            vec![("_ROW_ID".into(), "_ROW_ID".into())],
+            vec![
+                update(Some(null_condition)),
+                WhenMatched {
+                    condition: None,
+                    delete: false,
+                    assignments: vec![("value".into(), value)],
+                },
+            ],
+            Vec::new(),
+        )
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![(Some(1), Some(77)), (Some(2), Some(77))]
+    );
+}
+
+#[tokio::test]
+async fn core_merge_preserves_all_assignment_chunks() {
+    let table = table();
+    seed(
+        &table,
+        &batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
+    )
+    .await;
+    let values = MergeAssignment::Value(UpdateAssignment::Array(vec![
+        Arc::new(Int32Array::from(vec![100])),
+        Arc::new(Int32Array::from(vec![200])),
+    ]));
+    let messages = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Batches(vec![batch(vec![Some(1), Some(2)], 
vec![Some(0), Some(0)])]),
+            vec![("id".into(), "id".into())],
+            vec![WhenMatched {
+                condition: None,
+                delete: false,
+                assignments: vec![("value".into(), values)],
+            }],
+            Vec::new(),
+        )
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![(Some(1), Some(100)), (Some(2), Some(200))]
+    );
+}
+
+#[tokio::test]
+async fn core_merge_rejects_inconsistent_source_schemas_before_staging() {
+    let table = table();
+    seed(&table, &batch(vec![Some(1)], vec![Some(10)])).await;
+    let source = batch(vec![Some(1)], vec![Some(11)]);
+    let reversed = source.project(&[1, 0]).unwrap();
+    let updater = table.new_write_builder().new_update().unwrap();
+    let error = updater
+        .merge_into(
+            MergeSource::Batches(vec![source, reversed]),
+            vec![("id".into(), "id".into())],
+            vec![update(None)],
+            Vec::new(),
+        )
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("must share a schema"));
+    assert_eq!(rows(&table).await, vec![(Some(1), Some(10))]);
+}
+
+#[tokio::test]
+async fn core_merge_consumes_assignments_across_all_selected_files() {
+    for chunked in [false, true] {
+        let table = table();
+        for id in 1..=3 {
+            seed(&table, &batch(vec![Some(id)], vec![Some(id * 10)])).await;
+        }
+        let values: Vec<ArrayRef> = if chunked {
+            vec![
+                Arc::new(Int32Array::from(vec![100])),
+                Arc::new(Int32Array::from(vec![200])),
+            ]
+        } else {
+            vec![Arc::new(Int32Array::from(vec![100, 200]))]
+        };
+        let condition = MergeCondition {
+            target_columns: Vec::new(),
+            source_columns: vec!["id".into()],
+            evaluate: Arc::new(|batch| {
+                Box::pin(async move {
+                    let ids = batch
+                        .column_by_name("s.id")
+                        .unwrap()
+                        .as_any()
+                        .downcast_ref::<Int32Array>()
+                        .unwrap();
+                    Ok(BooleanArray::from(
+                        ids.iter().map(|id| id.map(|id| id < 
3)).collect::<Vec<_>>(),
+                    ))
+                })
+            }),
+        };
+        let messages = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .merge_into(
+                MergeSource::Batches(vec![batch(
+                    vec![Some(1), Some(2), Some(3)],
+                    vec![Some(11), Some(22), Some(33)],
+                )]),
+                vec![("id".into(), "id".into())],
+                vec![
+                    WhenMatched {
+                        condition: Some(condition),
+                        delete: false,
+                        assignments: vec![
+                            ("id".into(), 
MergeAssignment::TargetColumn("id".into())),
+                            (
+                                "value".into(),
+                                
MergeAssignment::Value(UpdateAssignment::Array(values)),
+                            ),
+                        ],
+                    },
+                    update(None),
+                ],
+                Vec::new(),
+            )
+            .await
+            .unwrap();
+        TableCommit::new(table.clone(), "merge".into())
+            .commit(messages)
+            .await
+            .unwrap();
+        assert_eq!(
+            rows(&table).await,
+            vec![
+                (Some(1), Some(100)),
+                (Some(2), Some(200)),
+                (Some(3), Some(33))
+            ]
+        );
+    }
+}
+
+#[tokio::test]
+async fn core_merge_evaluates_functions_once_for_the_complete_clause() {
+    let table = table();
+    for id in 1..=2 {
+        seed(&table, &batch(vec![Some(id)], vec![Some(id * 10)])).await;
+    }
+    let calls = Arc::new(AtomicUsize::new(0));
+    let evaluated = calls.clone();
+    let values = UpdateAssignment::Function(Arc::new(move |batches| {
+        evaluated.fetch_add(1, Ordering::Relaxed);
+        assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 
2);
+        Ok(batches
+            .iter()
+            .map(|batch| {
+                let ids = batch
+                    .column_by_name("s.id")
+                    .unwrap()
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap();
+                Arc::new(Int32Array::from(
+                    ids.iter()
+                        .map(|id| id.map(|id| id * 100))
+                        .collect::<Vec<_>>(),
+                )) as ArrayRef
+            })
+            .collect())
+    }));
+    let messages = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Batches(vec![batch(
+                vec![Some(1), Some(2)],
+                vec![Some(11), Some(22)],
+            )]),
+            vec![("id".into(), "id".into())],
+            vec![WhenMatched {
+                condition: None,
+                delete: false,
+                assignments: vec![("value".into(), 
MergeAssignment::Value(values))],
+            }],
+            Vec::new(),
+        )
+        .await
+        .unwrap();
+    assert_eq!(calls.load(Ordering::Relaxed), 1);
+    TableCommit::new(table.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![(Some(1), Some(100)), (Some(2), Some(200))]
+    );
+}
diff --git a/crates/paimon/src/table/table_update.rs 
b/crates/paimon/src/table/table_update.rs
index 480b776f..012fa056 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -218,6 +218,26 @@ impl TableUpdate {
         writer.prepare_commit().await
     }
 
+    /// Prepare MERGE actions against one target snapshot. Clauses run in 
order,
+    /// SQL NULL keys do not match, and ambiguous source matches are rejected.
+    pub async fn merge_into(
+        &self,
+        source: super::MergeSource,
+        on: Vec<(String, String)>,
+        when_matched: Vec<super::WhenMatched>,
+        when_not_matched: Vec<super::WhenNotMatched>,
+    ) -> crate::Result<Vec<CommitMessage>> {
+        super::table_merge_into::merge_into(
+            &self.table,
+            &self.commit_user,
+            source,
+            on,
+            when_matched,
+            when_not_matched,
+        )
+        .await
+    }
+
     /// Delete rows by row ID using deletion vectors.
     pub async fn delete_by_row_id(&self, row_ids: Vec<i64>) -> 
crate::Result<Vec<CommitMessage>> {
         if row_ids.is_empty() {

Reply via email to