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 a0bf047d Support Paimon table sources in prepared MERGE (#1071)
a0bf047d is described below

commit a0bf047d44132f13d1ec75be81b3ba3d26a70c74
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 12:00:07 2026 +0800

    Support Paimon table sources in prepared MERGE (#1071)
---
 bindings/python/README.md                          |   6 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  14 +-
 bindings/python/src/write.rs                       |   9 +-
 bindings/python/tests/test_update.py               |  42 ++++
 crates/paimon/src/table/mod.rs                     |   1 +
 crates/paimon/src/table/table_merge_input.rs       | 174 ++++++++++++++++
 crates/paimon/src/table/table_merge_into.rs        | 147 ++++++++------
 crates/paimon/src/table/table_merge_into_tests.rs  | 223 ++++++++++++++++++++-
 8 files changed, 553 insertions(+), 63 deletions(-)

diff --git a/bindings/python/README.md b/bindings/python/README.md
index 8cdca711..3effeb7f 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -123,6 +123,12 @@ Self-merge uses the target's pinned row IDs. A later 
action failure retains file
 when the operation cannot return their messages. Returned messages may only be
 explicitly aborted by a caller that knows publication has been abandoned.
 
+The source may also be a native `Table`. Core resolves its branch and snapshot
+selectors once, projects the source keys and referenced columns, and reads the
+logical rows through the ordinary reader, including primary-key merge engines.
+The source does not require data evolution or row tracking. Arrow batches keep
+their chunk boundaries; matching gathers only the contributing source chunks.
+
 ### 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 8e91f096..52b26b71 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -263,10 +263,13 @@ class BatchTableWrite:
 
 class BatchTableUpdate:
     def merge_into(
-        self, source: Optional[pyarrow.Table], *, on: Sequence[Tuple[str, 
str]],
+        self, source: Optional[Union[pyarrow.Table, 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.
+        """Prepare MERGE against one target snapshot. None selects self-merge 
on _ROW_ID.
+
+        A Table source reads its selected full snapshot in core, independently
+        of the target. Arrow input preserves batches and source row order.
 
         Clauses carry assignments as (column, source/target/literal, value)
         tuples, a delete flag, and optional SQL condition metadata.
@@ -301,11 +304,14 @@ class TableUpdateByRowId:
 
 class StreamTableUpdate:
     def merge_into(
-        self, source: Optional[pyarrow.Table], *, on: Sequence[Tuple[str, 
str]],
+        self, source: Optional[Union[pyarrow.Table, 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.
+        """Prepare MERGE against one target snapshot. None selects self-merge 
on _ROW_ID.
+
+        A Table source reads its selected full snapshot in core, independently
+        of the target. Arrow input preserves batches and source row order.
 
         Clauses carry assignments as (column, source/target/literal, value)
         tuples, a delete flag, and optional SQL condition metadata.
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 7555cebc..ea65b1be 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -592,11 +592,18 @@ impl UpdateContext {
         when_not_matched: &Bound<'_, PyAny>,
     ) -> PyResult<Vec<PyCommitMessage>> {
         let source = match source {
-            Some(source) => 
paimon::table::MergeSource::Batches(arrow_table_batches(source)?),
+            Some(source) => match 
source.extract::<PyRef<crate::table::PyTable>>() {
+                Ok(table) => 
paimon::table::MergeSource::Table(table.inner.clone()),
+                Err(_) => 
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::Table(table) => {
+                
paimon::arrow::build_target_arrow_schema(table.schema().fields())
+                    .map_err(to_py_err)?
+            }
             paimon::table::MergeSource::SelfTable => {
                 let schema = 
paimon::arrow::build_target_arrow_schema(self.table.schema().fields())
                     .map_err(to_py_err)?;
diff --git a/bindings/python/tests/test_update.py 
b/bindings/python/tests/test_update.py
index 7bed73e2..68157654 100644
--- a/bindings/python/tests/test_update.py
+++ b/bindings/python/tests/test_update.py
@@ -466,3 +466,45 @@ def 
test_prepared_merge_conditions_chunks_and_lazy_literals(tmp_path, stream):
     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)]
+
+
[email protected]('stream', [False, True])
[email protected]('historical', [False, True])
+def test_prepared_merge_accepts_native_table_sources(tmp_path, stream, 
historical):
+    import json
+    from pathlib import Path
+
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+    context.sql('CREATE SCHEMA paimon.table_merge')
+    context.sql("""CREATE TABLE paimon.table_merge.target (id INT, value INT) 
WITH (
+        'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')""")
+    # An ordinary append source needs neither row tracking nor data evolution.
+    context.sql('CREATE TABLE paimon.table_merge.source (source_id INT, amount 
INT, flag INT)')
+    context.sql('INSERT INTO paimon.table_merge.target (id, value) VALUES (1, 
10), (2, 20)')
+    for values in ['(1, 11, 1)', '(3, 33, 1)', '(2, 22, 1)']:
+        context.sql('INSERT INTO paimon.table_merge.source (source_id, amount, 
flag) VALUES ' + values)
+    catalog = PaimonCatalog({'warehouse': str(tmp_path)})
+    target, source = catalog.get_table('table_merge.target'), 
catalog.get_table('table_merge.source')
+    if historical:
+        schema = json.loads((Path(source.location()) / 'schema' / 
'schema-0').read_text())
+        schema['options']['scan.snapshot-id'] = '2'
+        source = source.copy_with_resolved_schema(json.dumps(schema))
+    source_snapshot = source.latest_snapshot().id()
+    target_snapshot = target.latest_snapshot().id()
+    builder = 
(target.new_stream_write_builder().with_commit_user('source-merge')
+               if stream else target.new_batch_write_builder())
+    kwargs = dict(commit_identifier=42) if stream else {}
+    condition = dict(sql='"s.flag" = 1')
+    messages = builder.new_update().merge_into(
+        source, on=[('id', 'source_id')], when_matched=[
+            dict(delete=False, condition=condition, assignments=[('value', 
'source', 'amount')])],
+        when_not_matched=[dict(condition=condition, assignments=[
+            ('id', 'source', 'source_id'), ('value', 'source', 'amount')])], 
**kwargs)
+    assert target.latest_snapshot().id() == target_snapshot
+    assert source.latest_snapshot().id() == source_snapshot
+    commit = builder.new_commit()
+    commit.commit(42, messages) if stream else commit.commit(messages)
+    actual = pa.Table.from_batches(context.sql(
+        'SELECT id, value FROM paimon.table_merge.target ORDER BY 
id')).to_pylist()
+    assert actual == [dict(id=1, value=11), dict(id=2, value=20 if historical 
else 22), dict(id=3, value=33)]
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 24a3cf22..6200bc6b 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -142,6 +142,7 @@ mod sorted_global_index_options;
 mod source;
 mod stats_filter;
 pub(crate) mod table_commit;
+mod table_merge_input;
 mod table_merge_into;
 #[cfg(test)]
 mod table_merge_into_tests;
diff --git a/crates/paimon/src/table/table_merge_input.rs 
b/crates/paimon/src/table/table_merge_input.rs
new file mode 100644
index 00000000..a0a03d4c
--- /dev/null
+++ b/crates/paimon/src/table/table_merge_input.rs
@@ -0,0 +1,174 @@
+// 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.
+
+//! Materialized MERGE input preserves Arrow batches and source row order.
+
+use super::Table;
+use arrow_array::RecordBatch;
+use arrow_schema::SchemaRef;
+use arrow_select::interleave::interleave_record_batch;
+use futures::TryStreamExt;
+use std::collections::{HashMap, HashSet};
+
+fn invalid(message: impl Into<String>) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: message.into(),
+        source: None,
+    }
+}
+
+pub(super) struct MergeInput {
+    pub schema: SchemaRef,
+    pub batches: Vec<RecordBatch>,
+    ends: Vec<usize>,
+    pub row_count: usize,
+}
+
+impl MergeInput {
+    pub fn new(mut batches: Vec<RecordBatch>) -> crate::Result<Self> {
+        let first = batches
+            .first()
+            .ok_or_else(|| invalid("MERGE source needs an Arrow schema"))?;
+        let schema = first.schema();
+        if batches.iter().any(|batch| batch.schema() != schema) {
+            return Err(invalid("MERGE source batches must share a schema"));
+        }
+        let names = schema
+            .fields()
+            .iter()
+            .map(|field| field.name())
+            .collect::<HashSet<_>>();
+        if names.len() != schema.fields().len() {
+            return Err(invalid("MERGE source has duplicate column names"));
+        }
+        batches.retain(|batch| batch.num_rows() > 0);
+        if batches.is_empty() {
+            batches.push(RecordBatch::new_empty(schema.clone()));
+        }
+        let mut row_count = 0usize;
+        let ends = batches
+            .iter()
+            .map(|batch| {
+                row_count = row_count
+                    .checked_add(batch.num_rows())
+                    .ok_or_else(|| invalid("MERGE source row count 
overflow"))?;
+                Ok(row_count)
+            })
+            .collect::<crate::Result<_>>()?;
+        Ok(Self {
+            schema,
+            batches,
+            ends,
+            row_count,
+        })
+    }
+
+    pub async fn read(table: &Table, columns: &HashSet<String>) -> 
crate::Result<Self> {
+        if table.is_format_table() {
+            return Err(crate::Error::Unsupported {
+                message: "MERGE table sources require a Paimon table".into(),
+            });
+        }
+        
crate::spec::CoreOptions::new(table.schema().options()).validate_scan_options()?;
+        let schema = 
crate::arrow::build_target_arrow_schema(table.schema().fields())?;
+        for column in columns {
+            schema
+                .field_with_name(column)
+                .map_err(|error| invalid(error.to_string()))?;
+        }
+        // Schema order makes every projected batch interchangeable. An empty
+        // snapshot still carries its declared schema without fetching any 
file.
+        let indices = schema
+            .fields()
+            .iter()
+            .enumerate()
+            .filter_map(|(index, field)| 
columns.contains(field.name()).then_some(index))
+            .collect::<Vec<_>>();
+        let projected = std::sync::Arc::new(
+            schema
+                .project(&indices)
+                .map_err(|error| invalid(error.to_string()))?,
+        );
+        let names = projected
+            .fields()
+            .iter()
+            .map(|field| field.name().as_str())
+            .collect::<Vec<_>>();
+        let snapshot = super::time_travel::resolve_snapshot(table).await?;
+        // Pin the source independently, keeping branch/time-travel resolution
+        // and the caller's resolved schema. Include L0 as Python 
plan_for_write.
+        let table = snapshot.as_ref().map_or_else(
+            || table.clone(),
+            |snapshot| {
+                table
+                    .copy_with_pinned_snapshot(snapshot)
+                    .copy_with_options(HashMap::from([("scan.mode".into(), 
"default".into())]))
+            },
+        );
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&names)?;
+        let plan = if snapshot.is_some() {
+            builder.new_scan().with_scan_all_files().plan().await?
+        } else {
+            // Run the normal reader's policy checks even with no snapshot, but
+            // do not observe files from a commit arriving after resolution.
+            super::source::Plan::new(Vec::new())
+        };
+        let mut stream = builder.new_read()?.to_arrow(plan.splits())?;
+        let mut batches = Vec::new();
+        while let Some(batch) = stream.try_next().await? {
+            if batch.num_rows() > 0 {
+                batches.push(batch);
+            }
+        }
+        if batches.is_empty() {
+            batches.push(RecordBatch::new_empty(projected));
+        }
+        Self::new(batches)
+    }
+
+    pub fn row_offset(&self, batch: usize) -> usize {
+        if batch == 0 {
+            0
+        } else {
+            self.ends[batch - 1]
+        }
+    }
+
+    pub fn gather(&self, rows: &[usize]) -> crate::Result<RecordBatch> {
+        if rows.is_empty() {
+            return Ok(RecordBatch::new_empty(self.schema.clone()));
+        }
+        let mut used = Vec::new();
+        let mut positions = HashMap::new();
+        let mut indices = Vec::with_capacity(rows.len());
+        for &row in rows {
+            if row >= self.row_count {
+                return Err(invalid("MERGE source row is out of range"));
+            }
+            let batch = self.ends.partition_point(|end| *end <= row);
+            let position = *positions.entry(batch).or_insert_with(|| {
+                used.push(&self.batches[batch]);
+                used.len() - 1
+            });
+            indices.push((position, row - self.row_offset(batch)));
+        }
+        // Touch only contributing chunks, and preserve the requested order.
+        // Avoid concatenating entire source columns with 32-bit Arrow offsets.
+        interleave_record_batch(&used, &indices).map_err(|error| 
invalid(error.to_string()))
+    }
+}
diff --git a/crates/paimon/src/table/table_merge_into.rs 
b/crates/paimon/src/table/table_merge_into.rs
index e788867b..1eaae977 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -20,10 +20,11 @@
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
+use super::table_merge_input::MergeInput;
 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 arrow_select::take::take;
 use futures::{future::BoxFuture, TryStreamExt};
 
 use super::data_evolution_writer::RowIdFileIndex;
@@ -68,9 +69,11 @@ pub struct WhenNotMatched {
     pub assignments: Vec<(String, MergeAssignment)>,
 }
 
-/// Already materialized input, or the target itself joined on its row ID.
+/// Materialized input, a Paimon source table, or the target joined on its row 
ID.
 pub enum MergeSource {
     Batches(Vec<RecordBatch>),
+    /// Read a Paimon table at one independently pinned source snapshot.
+    Table(Arc<Table>),
     SelfTable,
 }
 
@@ -307,32 +310,49 @@ pub(super) async fn merge_into(
     };
     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()
+        MergeSource::Batches(batches) => Some(MergeInput::new(batches)?),
+        MergeSource::Table(source) => {
+            let mut columns = on
                 .iter()
-                .map(|field| field.name().clone())
+                .map(|(_, source)| source.clone())
                 .collect::<HashSet<_>>();
-            if names.len() != first.num_columns() {
-                return Err(invalid("MERGE source has duplicate column names"));
+            for (assignments, condition) in matched
+                .iter()
+                .map(|clause| (&clause.assignments, &clause.condition))
+                .chain(
+                    not_matched
+                        .iter()
+                        .map(|clause| (&clause.assignments, 
&clause.condition)),
+                )
+            {
+                for (_, assignment) in assignments {
+                    if let MergeAssignment::SourceColumn(name) = assignment {
+                        columns.insert(name.clone());
+                    }
+                    if matches!(
+                        assignment,
+                        MergeAssignment::Value(UpdateAssignment::Function(_))
+                    ) {
+                        columns.extend(
+                            source
+                                .schema()
+                                .fields()
+                                .iter()
+                                .map(|field| field.name().to_string()),
+                        );
+                    }
+                }
+                if let Some(condition) = condition {
+                    columns.extend(condition.source_columns.clone());
+                }
             }
-            Some(
-                concat_batches(&first.schema(), &batches)
-                    .map_err(|error| invalid(error.to_string()))?,
-            )
+            Some(MergeInput::read(&source, &columns).await?)
         }
         MergeSource::SelfTable => None,
     };
     let source_schema = source
         .as_ref()
-        .map_or_else(|| schema.clone(), RecordBatch::schema);
+        .map_or_else(|| schema.clone(), |source| source.schema.clone());
     for (assignments, condition, insert) in matched
         .iter()
         .map(|clause| (&clause.assignments, &clause.condition, false))
@@ -390,14 +410,16 @@ pub(super) async fn merge_into(
     }
     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)];
+    let mut seen_source = vec![false; source.as_ref().map_or(0, |source| 
source.row_count)];
     if let Some(source) = &source {
-        let keys = on
+        let fields = 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 source_type = source
+                    .schema
+                    .field_with_name(source_name)
+                    .map_err(|error| invalid(error.to_string()))?
+                    .data_type();
                 let target_type = if target == ROW_ID {
                     &DataType::Int64
                 } else {
@@ -406,31 +428,33 @@ pub(super) async fn merge_into(
                         .map_err(|error| invalid(error.to_string()))?
                         .data_type()
                 };
-                if source_column.data_type() != target_type
+                if source_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())
+                Ok(SortField::new(source_type.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);
+            .collect::<crate::Result<_>>()?;
+        let key_converter =
+            RowConverter::new(fields).map_err(|error| 
invalid(error.to_string()))?;
+        for (chunk, batch) in source.batches.iter().enumerate() {
+            let keys = on
+                .iter()
+                .map(|(_, name)| batch.column_by_name(name).unwrap().clone())
+                .collect::<Vec<_>>();
+            let rows = key_converter
+                .convert_columns(&keys)
+                .map_err(|error| invalid(error.to_string()))?;
+            for row in 0..batch.num_rows() {
+                if keys.iter().all(|column| !column.is_null(row)) {
+                    source_keys
+                        .entry(rows.row(row).as_ref().to_vec())
+                        .or_default()
+                        .push(source.row_offset(chunk) + row);
+                }
             }
         }
         converter = Some(key_converter);
@@ -474,7 +498,7 @@ pub(super) async fn merge_into(
             if !target_rows.is_empty() {
                 joined.push(aliases(
                     Some(&gather(&target, &target_rows)?),
-                    &gather(source, &source_rows)?,
+                    &source.gather(&source_rows)?,
                 )?);
             }
         } else if target.num_rows() > 0 {
@@ -523,26 +547,36 @@ pub(super) async fn merge_into(
     }
     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 mut pending = Vec::new();
+        for (chunk, batch) in source.batches.iter().enumerate() {
+            let offset = source.row_offset(chunk);
+            let unmatched = (0..batch.num_rows())
+                .filter(|row| !seen_source[offset + row])
+                .collect::<Vec<_>>();
+            if !unmatched.is_empty() {
+                pending.push(aliases(None, &gather(batch, &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 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);
+                }
             }
-            let (selected, rest) = select_clause(pending, 
&clause.condition).await?;
-            pending = rest;
-            if selected.num_rows() > 0 {
+            if !clause_rows.is_empty() {
                 for batch in assigned(
-                    std::slice::from_ref(&selected),
+                    &clause_rows,
                     &clause.assignments,
                     &columns,
                     schema.clone(),
@@ -555,6 +589,7 @@ pub(super) async fn merge_into(
                     );
                 }
             }
+            pending = remaining;
         }
     }
     // Materialize/validate every action before staging. Once prepared, 
messages
diff --git a/crates/paimon/src/table/table_merge_into_tests.rs 
b/crates/paimon/src/table/table_merge_into_tests.rs
index 4b4ec6db..b20c4753 100644
--- a/crates/paimon/src/table/table_merge_into_tests.rs
+++ b/crates/paimon/src/table/table_merge_into_tests.rs
@@ -28,6 +28,10 @@ use std::sync::atomic::{AtomicUsize, Ordering};
 use std::sync::Arc;
 
 fn table() -> Table {
+    table_at("merge")
+}
+
+fn table_at(name: &str) -> Table {
     let schema = Schema::builder()
         .column("id", DataType::Int(IntType::new()))
         .column("value", DataType::Int(IntType::new()))
@@ -38,8 +42,8 @@ fn table() -> Table {
         .unwrap();
     Table::new(
         FileIOBuilder::new("memory").build().unwrap(),
-        Identifier::new("db", "t"),
-        "memory:/merge".into(),
+        Identifier::new("db", name),
+        format!("memory:/{name}"),
         TableSchema::new(0, &schema),
         None,
     )
@@ -475,3 +479,218 @@ async fn 
core_merge_evaluates_functions_once_for_the_complete_clause() {
         vec![(Some(1), Some(100)), (Some(2), Some(200))]
     );
 }
+
+#[tokio::test]
+async fn core_merge_reads_table_source_at_the_selected_snapshot() {
+    let target = table();
+    let source = table_at("source");
+    seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+    seed(&source, &batch(vec![Some(1)], vec![Some(11)])).await;
+    seed(&source, &batch(vec![Some(2)], vec![Some(22)])).await;
+    let historical = 
source.copy_with_options(std::collections::HashMap::from([(
+        "scan.snapshot-id".into(),
+        "1".into(),
+    )]));
+    let messages = target
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Table(Arc::new(historical)),
+            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();
+    TableCommit::new(target.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(rows(&target).await, vec![(Some(1), Some(11))]);
+    assert_eq!(
+        rows(&source).await,
+        vec![(Some(1), Some(11)), (Some(2), Some(22))]
+    );
+}
+
+#[tokio::test]
+async fn core_merge_empty_table_source_keeps_no_match_assignments_lazy() {
+    let target = table();
+    seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+    let source = table_at("empty_source");
+    let called = Arc::new(AtomicUsize::new(0));
+    let evaluated = called.clone();
+    let value = UpdateAssignment::DeferredScalar(Arc::new(move || {
+        evaluated.fetch_add(1, Ordering::Relaxed);
+        Err(crate::Error::DataInvalid {
+            message: "Must remain lazy".into(),
+            source: None,
+        })
+    }));
+    let messages = target
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Table(Arc::new(source)),
+            vec![("id".into(), "id".into())],
+            vec![WhenMatched {
+                condition: None,
+                delete: false,
+                assignments: vec![("value".into(), 
MergeAssignment::Value(value))],
+            }],
+            Vec::new(),
+        )
+        .await
+        .unwrap();
+    assert!(messages.is_empty());
+    assert_eq!(called.load(Ordering::Relaxed), 0);
+    assert_eq!(rows(&target).await, vec![(Some(1), Some(10))]);
+}
+
+#[tokio::test]
+async fn core_merge_empty_table_source_obeys_reader_authorization() {
+    let target = table();
+    seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+    let source = 
table_at("empty_source").copy_with_options(std::collections::HashMap::from([(
+        "query-auth.enabled".into(),
+        "true".into(),
+    )]));
+    let error = target
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Table(Arc::new(source)),
+            vec![("id".into(), "id".into())],
+            vec![update(None)],
+            Vec::new(),
+        )
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("query-auth.enabled"));
+    assert_eq!(rows(&target).await, vec![(Some(1), Some(10))]);
+}
+
+#[tokio::test]
+async fn core_merge_checks_source_duplicates_across_batches() {
+    let target = table();
+    seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+    let source = vec![
+        batch(vec![Some(1)], vec![Some(11)]),
+        batch(vec![Some(1)], vec![Some(12)]),
+    ];
+    let error = target
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Batches(source),
+            vec![("id".into(), "id".into())],
+            vec![update(None)],
+            Vec::new(),
+        )
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("multiple source rows"));
+    assert_eq!(rows(&target).await, vec![(Some(1), Some(10))]);
+}
+
+#[tokio::test]
+async fn core_merge_does_not_reread_a_source_advanced_by_a_condition() {
+    let target = table();
+    let source = Arc::new(table_at("source_advanced"));
+    seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+    seed(&source, &batch(vec![Some(1)], vec![Some(11)])).await;
+    let advancing = source.clone();
+    let condition = MergeCondition {
+        target_columns: Vec::new(),
+        source_columns: Vec::new(),
+        evaluate: Arc::new(move |input| {
+            let source = advancing.clone();
+            Box::pin(async move {
+                seed(&source, &batch(vec![Some(2)], vec![Some(22)])).await;
+                Ok(BooleanArray::from(vec![true; input.num_rows()]))
+            })
+        }),
+    };
+    let messages = target
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Table(source.clone()),
+            vec![("id".into(), "id".into())],
+            vec![update(Some(condition))],
+            vec![WhenNotMatched {
+                condition: None,
+                assignments: vec![
+                    ("id".into(), MergeAssignment::SourceColumn("id".into())),
+                    (
+                        "value".into(),
+                        MergeAssignment::SourceColumn("value".into()),
+                    ),
+                ],
+            }],
+        )
+        .await
+        .unwrap();
+    TableCommit::new(target.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(rows(&target).await, vec![(Some(1), Some(11))]);
+    assert_eq!(
+        rows(&source).await,
+        vec![(Some(1), Some(11)), (Some(2), Some(22))]
+    );
+}
+
+#[tokio::test]
+async fn core_merge_insert_arrays_cover_selected_rows_across_source_batches() {
+    let target = table();
+    let messages = target
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .merge_into(
+            MergeSource::Batches(vec![
+                batch(vec![Some(1)], vec![Some(11)]),
+                batch(vec![Some(2)], vec![Some(22)]),
+            ]),
+            vec![("id".into(), "id".into())],
+            Vec::new(),
+            vec![WhenNotMatched {
+                condition: None,
+                assignments: vec![
+                    ("id".into(), MergeAssignment::SourceColumn("id".into())),
+                    (
+                        "value".into(),
+                        
MergeAssignment::Value(UpdateAssignment::Array(vec![Arc::new(
+                            Int32Array::from(vec![100, 200]),
+                        )])),
+                    ),
+                ],
+            }],
+        )
+        .await
+        .unwrap();
+    TableCommit::new(target.clone(), "merge".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&target).await,
+        vec![(Some(1), Some(100)), (Some(2), Some(200))]
+    );
+}

Reply via email to