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 ca54d4a4 Preserve supplied fields across key upsert batches (#1061)
ca54d4a4 is described below
commit ca54d4a4d4f2baf0a5cb469c2d4dc098ef6e86ac
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 19:32:50 2026 +0800
Preserve supplied fields across key upsert batches (#1061)
---
.../python/python/pypaimon_rust/datafusion.pyi | 8 +-
bindings/python/src/write.rs | 14 +-
bindings/python/tests/test_update.py | 43 ++++
crates/paimon/src/table/table_update.rs | 3 +
crates/paimon/src/table/table_upsert.rs | 177 ++++++++++++-----
crates/paimon/tests/table_update_test.rs | 216 +++++++++++++++++++++
6 files changed, 405 insertions(+), 56 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 1eb2f4bf..d76b942f 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -256,9 +256,10 @@ class BatchTableUpdate:
def with_update_type(self, update_cols: List[str]) -> "BatchTableUpdate":
...
def delete_by_row_id(self, row_ids: Sequence[int]) -> List[CommitMessage]:
...
def upsert_by_arrow_with_key(
- self, table: pyarrow.Table, upsert_keys: List[str]
+ self, table: Union[pyarrow.Table, Sequence[pyarrow.RecordBatch]],
upsert_keys: List[str]
) -> List[CommitMessage]:
- """Prepare one key upsert; commit the returned messages separately."""
+ """Prepare one key upsert. Batches may have different non-key fields;
+ appended rows in each partition must share a field set."""
...
class TableUpdateByRowId:
@@ -283,7 +284,8 @@ class StreamTableUpdate:
self, row_ids: Sequence[int], commit_identifier: int
) -> List[CommitMessage]: ...
def upsert_by_arrow_with_key(
- self, table: pyarrow.Table, upsert_keys: List[str], commit_identifier:
int
+ self, table: Union[pyarrow.Table, Sequence[pyarrow.RecordBatch]],
+ upsert_keys: List[str], commit_identifier: int
) -> List[CommitMessage]:
"""Prepare one stream upsert; commit with the same identifier."""
...
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index eea7870a..20abb7df 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -492,6 +492,18 @@ fn arrow_table_batches(table: &Bound<'_, PyAny>) ->
PyResult<Vec<RecordBatch>> {
Ok(batches)
}
+/// Preserve each batch's supplied fields; core owns matching, deduplication
+/// and the distinction between missing columns and explicit NULL values.
+fn arrow_upsert_batches(input: &Bound<'_, PyAny>) ->
PyResult<Vec<RecordBatch>> {
+ if input.hasattr("to_batches")? {
+ return arrow_table_batches(input);
+ }
+ input
+ .try_iter()?
+ .map(|batch| RecordBatch::from_pyarrow_bound(&batch?))
+ .collect()
+}
+
struct UpdateContext {
inner: TableUpdate,
table: Arc<Table>,
@@ -621,7 +633,7 @@ impl UpdateContext {
input: &Bound<'_, PyAny>,
keys: Vec<String>,
) -> PyResult<Vec<PyCommitMessage>> {
- let batches = arrow_table_batches(input)?;
+ let batches = arrow_upsert_batches(input)?;
let messages = py
.detach(||
runtime().block_on(self.inner.upsert_by_arrow_with_key(batches, keys)))
.map_err(to_py_err)?;
diff --git a/bindings/python/tests/test_update.py
b/bindings/python/tests/test_update.py
index 33bf8a09..3702f645 100644
--- a/bindings/python/tests/test_update.py
+++ b/bindings/python/tests/test_update.py
@@ -22,6 +22,49 @@ import pypaimon_rust.datafusion as datafusion
from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+def
test_upsert_batches_preserve_missing_fields_nulls_and_stream_updates(tmp_path):
+ context = SQLContext()
+ context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+ context.sql('CREATE SCHEMA paimon.shapes')
+ context.sql("""CREATE TABLE paimon.shapes.t (id INT, name STRING, score
INT) WITH (
+ 'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')""")
+ context.sql("INSERT INTO paimon.shapes.t (id, name, score) VALUES (1, 'a',
10), (1, 'b', 11), (2, 'c', 20)")
+ table = PaimonCatalog({'warehouse': str(tmp_path)}).get_table('shapes.t')
+
+ def batch(values):
+ schema = pa.schema([(name, pa.string() if name == 'name' else
pa.int32()) for name in values])
+ return pa.RecordBatch.from_pydict(values, schema=schema)
+
+ builder = table.new_batch_write_builder()
+ update = builder.new_update().with_update_type(['name'])
+ messages = update.upsert_by_arrow_with_key([
+ batch({'id': [1], 'score': [999]}),
+ batch({'id': [3], 'score': [30]}),
+ batch({'name': [None], 'id': [1]}),
+ batch({'id': [2], 'name': ['new'], 'score': [999]}),
+ ], ['id'])
+ builder.new_commit().commit(messages)
+ assert pa.Table.from_batches(context.sql(
+ 'SELECT id, name, score FROM paimon.shapes.t ORDER BY id,
score')).to_pylist() == [
+ {'id': 1, 'name': None, 'score': 10},
+ {'id': 1, 'name': None, 'score': 11},
+ {'id': 2, 'name': 'new', 'score': 20},
+ {'id': 3, 'name': None, 'score': 30},
+ ]
+ stream = table.new_stream_write_builder().with_commit_user('shape-stream')
+ messages =
stream.new_update().with_update_type(['score']).upsert_by_arrow_with_key(
+ [batch({'id': [1], 'score': [100]}), batch({'id': [3], 'score': [300],
'name': ['ignored']})],
+ ['id'], 101)
+ stream.new_commit().commit(101, messages)
+ assert pa.Table.from_batches(context.sql(
+ 'SELECT id, name, score FROM paimon.shapes.t ORDER BY id,
score')).to_pylist() == [
+ {'id': 1, 'name': None, 'score': 100},
+ {'id': 1, 'name': None, 'score': 100},
+ {'id': 2, 'name': 'new', 'score': 20},
+ {'id': 3, 'name': None, 'score': 300},
+ ]
+
+
def test_table_upsert_updates_duplicate_targets_and_appends(tmp_path):
assert not hasattr(datafusion, '_match_upsert_keys')
assert not hasattr(datafusion, 'UpsertKeyMatcher')
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index 49765fed..480b776f 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -195,6 +195,9 @@ impl TableUpdate {
/// columns are omitted from `upsert_keys`. Existing keys update every
/// matching row ID; new keys append all input columns. Omitted columns on
/// matched rows retain their values; on new rows they are absent from the
file.
+ /// Input batches may have different non-key columns. After last-write-wins
+ /// deduplication, matched rows must supply their update columns and
appended
+ /// rows in each partition must share a field set.
pub async fn upsert_by_arrow_with_key(
&self,
batches: Vec<RecordBatch>,
diff --git a/crates/paimon/src/table/table_upsert.rs
b/crates/paimon/src/table/table_upsert.rs
index 5ff0534f..0ce5aa75 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -19,12 +19,14 @@
use std::borrow::Cow;
use std::collections::HashSet;
+use std::ops::Range;
use std::sync::Arc;
use arrow_array::{ArrayRef, Int64Array, RecordBatch, UInt32Array};
use arrow_schema::{DataType, Field, Schema};
use arrow_select::{concat::concat_batches, take::take};
use futures::TryStreamExt;
+use indexmap::IndexMap;
use super::update_input::unique_column_index;
use super::upsert_key_matcher::UpsertKeyMatcher;
@@ -140,7 +142,8 @@ impl TableUpsert {
/// Add rows containing the keys and columns to write. Column order may
/// differ from the table schema; names and Arrow layouts follow the same
- /// normalization as ordinary writes. Batches form one logical upsert
input.
+ /// normalization as ordinary writes. Batches form one logical upsert
input,
+ /// but may carry different non-key columns, preserving absent fields.
pub(super) fn add_batch(&mut self, batch: RecordBatch) ->
crate::Result<()> {
let target =
crate::arrow::build_target_arrow_schema(self.table.schema().fields())?;
let input_schema = batch.schema();
@@ -171,41 +174,71 @@ impl TableUpsert {
}
let ordered = RecordBatch::try_new(Arc::new(Schema::new(fields)),
columns)
.map_err(|error| invalid(format!("cannot order upsert columns:
{error}")))?;
- if self
- .source
- .first()
- .is_some_and(|first| first.schema() != ordered.schema())
- {
- return Err(invalid(
- "Arrow batches in one upsert input must have the same columns",
- ));
- }
self.source.push(ordered);
Ok(())
}
- /// Match only the source partitions, as PyPaimon does. Filter planned
splits
- /// after global row IDs have been assigned so pruning cannot renumber
rows.
- fn matching_splits<'a>(
+ /// Concatenate only match keys. Padding payload columns with NULL would
+ /// lose the distinction between an absent field and an explicit NULL.
+ fn key_rows(&self) -> crate::Result<RecordBatch> {
+ let batches = self
+ .source
+ .iter()
+ .map(|batch| {
+ let columns = batch
+ .schema()
+ .fields()
+ .iter()
+ .enumerate()
+ .filter(|(_, field)| self.keys.contains(field.name()))
+ .map(|(index, _)| index)
+ .collect::<Vec<_>>();
+ batch
+ .project(&columns)
+ .map_err(|error| invalid(error.to_string()))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ let first = batches
+ .first()
+ .ok_or_else(|| invalid("Input data is empty"))?;
+ concat_batches(&first.schema(), &batches)
+ .map_err(|error| invalid(format!("cannot concatenate upsert keys:
{error}")))
+ }
+
+ /// Matcher indices are sorted by their original input position, with one
+ /// repeated index per matching target row. The range retains alignment
+ /// with the matcher's parallel row-ID vector across source batch
boundaries.
+ fn selected_batches(
&self,
- source: &RecordBatch,
- plan: &'a Plan,
- ) -> crate::Result<Cow<'a, [DataSplit]>> {
- let schema = self.table.schema();
- if schema.partition_keys().is_empty() {
- return Ok(Cow::Borrowed(plan.splits()));
+ indices: &[usize],
+ ) -> crate::Result<Vec<(RecordBatch, Range<usize>)>> {
+ let mut groups = Vec::new();
+ let (mut offset, mut selected) = (0, 0);
+ for source in &self.source {
+ let end = offset + source.num_rows();
+ let count = indices[selected..].partition_point(|index| *index <
end);
+ if count > 0 {
+ let range = selected..selected + count;
+ let rows = indices[range.clone()]
+ .iter()
+ .map(|index| index - offset)
+ .collect::<Vec<_>>();
+ groups.push((selected_rows(source, &rows)?, range));
+ selected += count;
+ }
+ offset = end;
}
+ Ok(groups)
+ }
+
+ fn serialized_partitions(&self, batch: &RecordBatch) ->
crate::Result<Vec<Vec<u8>>> {
+ let schema = self.table.schema();
let indices = schema
.partition_keys()
.iter()
- .map(|name| {
- source
- .schema()
- .index_of(name)
- .map_err(|error| invalid(error.to_string()))
- })
+ .map(|name| unique_column_index(&batch.schema(), name))
.collect::<crate::Result<Vec<_>>>()?;
- let input_fields = source
+ let fields = batch
.schema()
.fields()
.iter()
@@ -218,9 +251,45 @@ impl TableUpsert {
.clone()
})
.collect::<Vec<_>>();
- let partitions: HashSet<_> = batch_to_serialized_bytes(source,
&indices, &input_fields)?
- .into_iter()
- .collect();
+ batch_to_serialized_bytes(batch, &indices, &fields)
+ }
+
+ /// Java append writers use one write type for a partition's input. Like
+ /// PyPaimon named-row upserts, validate only surviving unmatched rows;
+ /// different partitions may append different field sets.
+ fn append_groups(&self, indices: &[usize]) ->
crate::Result<Vec<Vec<RecordBatch>>> {
+ let mut groups: IndexMap<Vec<u8>, Vec<RecordBatch>> = IndexMap::new();
+ for (batch, _) in self.selected_batches(indices)? {
+ let mut by_partition: IndexMap<Vec<u8>, Vec<usize>> =
IndexMap::new();
+ for (row, partition) in
self.serialized_partitions(&batch)?.into_iter().enumerate() {
+ by_partition.entry(partition).or_default().push(row);
+ }
+ for (partition, rows) in by_partition {
+ let group = groups.entry(partition).or_default();
+ if group
+ .first()
+ .is_some_and(|first| first.schema() != batch.schema())
+ {
+ return Err(invalid("upsert_by_key requires appended rows
in the same partition to have the same field set"));
+ }
+ group.push(selected_rows(&batch, &rows)?);
+ }
+ }
+ Ok(groups.into_values().collect())
+ }
+
+ /// Match only the source partitions, as PyPaimon does. Filter planned
splits
+ /// after global row IDs have been assigned so pruning cannot renumber
rows.
+ fn matching_splits<'a>(
+ &self,
+ source: &RecordBatch,
+ plan: &'a Plan,
+ ) -> crate::Result<Cow<'a, [DataSplit]>> {
+ let schema = self.table.schema();
+ if schema.partition_keys().is_empty() {
+ return Ok(Cow::Borrowed(plan.splits()));
+ }
+ let partitions: HashSet<_> =
self.serialized_partitions(source)?.into_iter().collect();
let fields = schema.partition_fields();
let mut splits = Vec::new();
for split in plan.splits() {
@@ -236,11 +305,7 @@ impl TableUpsert {
#[must_use = "commit messages must be passed to TableCommit"]
pub(super) async fn prepare_commit(self) ->
crate::Result<Vec<CommitMessage>> {
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
- let Some(first) = self.source.first() else {
- return Err(invalid("Input data is empty"));
- };
- let source = concat_batches(&first.schema(), &self.source)
- .map_err(|error| invalid(format!("cannot concatenate upsert input:
{error}")))?;
+ let source = self.key_rows()?;
if source.num_rows() == 0 {
return Err(invalid("Input data is empty"));
}
@@ -259,44 +324,52 @@ impl TableUpsert {
}
let (matched_indices, row_ids, new_indices) = matcher.finish();
+ // Validate append field sets before staging any updates or new files.
+ let append_groups = self.append_groups(&new_indices)?;
+ let matched_batches = self.selected_batches(&matched_indices)?;
+
let mut messages = Vec::new();
let result: crate::Result<()> = async {
if !matched_indices.is_empty() {
- let selected = selected_rows(&source, &matched_indices)?;
- let mut fields = selected.schema().fields().to_vec();
- fields.push(Arc::new(Field::new(ROW_ID, DataType::Int64,
false)));
- let mut columns: Vec<ArrayRef> = selected.columns().to_vec();
- columns.push(Arc::new(Int64Array::from(row_ids)));
- let matched =
RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
- .map_err(|error| {
- invalid(format!("cannot build matched upsert rows:
{error}"))
- })?;
- let mut update =
- super::DataEvolutionWriter::for_row_id(&self.table,
self.update_columns)?;
+ let mut update = super::DataEvolutionWriter::for_row_id(
+ &self.table,
+ self.update_columns.clone(),
+ )?;
if let Some(snapshot_id) = plan.snapshot_id() {
update.pin_read_snapshot(snapshot_id);
}
- update.add_matched_batch(matched)?;
+ for (selected, range) in matched_batches {
+ let mut fields = selected.schema().fields().to_vec();
+ fields.push(Arc::new(Field::new(ROW_ID, DataType::Int64,
false)));
+ let mut columns: Vec<ArrayRef> =
selected.columns().to_vec();
+
columns.push(Arc::new(Int64Array::from(row_ids[range].to_vec())));
+ let matched =
RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
+ .map_err(|error| {
+ invalid(format!("cannot build matched upsert rows:
{error}"))
+ })?;
+ update.add_matched_batch(matched)?;
+ }
messages.extend(update.prepare_commit().await?);
}
- if !new_indices.is_empty() {
- let new_rows = selected_rows(&source, &new_indices)?;
+ for group in append_groups {
let mut append = self
.table
.new_write_builder()
.with_commit_user(self.commit_user.clone())?
.new_write()?;
append.with_write_type(
- new_rows
+ group[0]
.schema()
.fields()
.iter()
.map(|field| field.name().to_string())
.collect(),
)?;
- if let Err(error) = append.write_arrow_batch(&new_rows).await {
- append.close().await;
- return Err(error);
+ for batch in group {
+ if let Err(error) = append.write_arrow_batch(&batch).await
{
+ append.close().await;
+ return Err(error);
+ }
}
messages.extend(append.prepare_commit().await?);
}
diff --git a/crates/paimon/tests/table_update_test.rs
b/crates/paimon/tests/table_update_test.rs
index 220f7471..8689dd5e 100644
--- a/crates/paimon/tests/table_update_test.rs
+++ b/crates/paimon/tests/table_update_test.rs
@@ -824,6 +824,78 @@ async fn
partial_upsert_updates_matches_and_appends_only_input_columns() {
);
}
+#[tokio::test]
+async fn heterogeneous_upsert_deduplicates_before_matched_column_validation() {
+ let table = evolution_table().await;
+ seed(&table).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![
+ // Missing update columns on a superseded source row are
irrelevant.
+ batch(&[("id", vec![1]), ("score", vec![999])]),
+ // New rows need not contain the matched-row update projection.
+ batch(&[("id", vec![4]), ("score", vec![400])]),
+ batch(&[("id", vec![1]), ("value", vec![111])]),
+ ],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ let appended = messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .filter(|file| {
+ file.write_cols
+ .as_ref()
+ .is_some_and(|cols| cols.contains(&"id".into()))
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(appended.len(), 1);
+ assert_eq!(
+ appended[0].write_cols,
+ Some(vec!["id".into(), "score".into()])
+ );
+ commit(&table, messages).await;
+ let reader = table.new_read_builder();
+ let plan = reader.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = reader
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ use arrow_array::Array;
+ let cols = batch
+ .columns()
+ .iter()
+ .map(|array| array.as_any().downcast_ref::<Int32Array>().unwrap())
+ .collect::<Vec<_>>();
+ for row in 0..batch.num_rows() {
+ rows.push(
+ cols.iter()
+ .map(|col| col.is_valid(row).then(|| col.value(row)))
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+ rows.sort();
+ assert_eq!(
+ rows,
+ vec![
+ vec![Some(1), Some(111), Some(100)],
+ vec![Some(2), Some(20), Some(200)],
+ vec![Some(3), Some(30), Some(300)],
+ vec![Some(4), None, Some(400)],
+ ]
+ );
+}
+
#[tokio::test]
async fn partial_upsert_requires_update_columns_only_for_matches() {
let table = evolution_table().await;
@@ -855,6 +927,150 @@ async fn
partial_upsert_requires_update_columns_only_for_matches() {
commit(&table, messages).await;
}
+#[tokio::test]
+async fn heterogeneous_upsert_keeps_append_shapes_separate_by_partition() {
+ let path = "memory:/upsert_partition_shapes";
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .column("score", DataType::Int(IntType::new()))
+ .column("region", DataType::Int(IntType::new()))
+ .partition_keys(["region"])
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ write_batch(
+ &table,
+ &batch(&[
+ ("id", vec![1, 1]),
+ ("value", vec![10, 20]),
+ ("score", vec![100, 200]),
+ ("region", vec![1, 2]),
+ ]),
+ )
+ .await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![
+ batch(&[
+ ("region", vec![1, 1]),
+ ("value", vec![111, 40]),
+ ("id", vec![1, 4]),
+ ]),
+ batch(&[("id", vec![4]), ("score", vec![400]), ("region",
vec![2])]),
+ batch(&[
+ ("id", vec![1]),
+ ("value", vec![222]),
+ ("score", vec![999]),
+ ("region", vec![2]),
+ ]),
+ batch(&[("id", vec![4]), ("value", vec![41]), ("region",
vec![1])]),
+ ],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ let shapes = messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .filter_map(|file| file.write_cols.as_ref())
+ .filter(|columns| columns.contains(&"id".into()))
+ .cloned()
+ .collect::<std::collections::HashSet<_>>();
+ assert_eq!(
+ shapes,
+ std::collections::HashSet::from([
+ vec!["id".into(), "value".into(), "region".into()],
+ vec!["id".into(), "score".into(), "region".into()],
+ ])
+ );
+ commit(&table, messages).await;
+ let reader = table.new_read_builder();
+ let plan = reader.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = reader
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ use arrow_array::Array;
+ let cols = batch
+ .columns()
+ .iter()
+ .map(|array| array.as_any().downcast_ref::<Int32Array>().unwrap())
+ .collect::<Vec<_>>();
+ for row in 0..batch.num_rows() {
+ rows.push(
+ cols.iter()
+ .map(|col| col.is_valid(row).then(|| col.value(row)))
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+ rows.sort();
+ assert_eq!(
+ rows,
+ vec![
+ vec![Some(1), Some(111), Some(100), Some(1)],
+ vec![Some(1), Some(222), Some(200), Some(2)],
+ vec![Some(4), None, Some(400), Some(2)],
+ vec![Some(4), Some(41), None, Some(1)],
+ ]
+ );
+}
+
+#[tokio::test]
+async fn heterogeneous_upsert_validates_only_surviving_shapes_before_staging()
{
+ let table = evolution_table().await;
+ seed(&table).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let before = parquet_files(&table).await;
+ for batches in [
+ // The winning matched row must supply the requested update columns.
+ vec![
+ batch(&[("id", vec![1]), ("value", vec![111])]),
+ batch(&[("id", vec![2]), ("score", vec![222])]),
+ ],
+ // Appended rows in one partition cannot silently acquire NULL padding.
+ vec![
+ batch(&[("id", vec![4]), ("value", vec![40])]),
+ batch(&[("id", vec![5]), ("score", vec![500])]),
+ ],
+ ] {
+ assert!(update
+ .upsert_by_arrow_with_key(batches, vec!["id".into()])
+ .await
+ .is_err());
+ assert_eq!(parquet_files(&table).await, before);
+ }
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![
+ batch(&[("id", vec![4]), ("value", vec![40])]),
+ batch(&[("id", vec![4]), ("score", vec![400])]),
+ ],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ messages[0].new_files[0].write_cols,
+ Some(vec!["id".into(), "score".into()])
+ );
+ commit(&table, messages).await;
+}
+
#[tokio::test]
async fn partial_upsert_rejects_ambiguous_unknown_and_inconsistent_input() {
let table = evolution_table().await;