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 174ea898 [core] Support column-subset append writes and upserts (#1054)
174ea898 is described below
commit 174ea898bac1652fec02a3ae638c55e6d52152f2
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 12:49:01 2026 +0800
[core] Support column-subset append writes and upserts (#1054)
---
.github/workflows/ci.yml | 3 +-
.../python/python/pypaimon_rust/datafusion.pyi | 2 +
bindings/python/src/write.rs | 36 +++
bindings/python/tests/test_write.py | 25 ++
.../src/table/dedicated_format_file_writer.rs | 144 ++++++---
crates/paimon/src/table/table_update.rs | 9 +-
crates/paimon/src/table/table_upsert.rs | 65 +++-
crates/paimon/src/table/table_write.rs | 126 +++++++-
crates/paimon/src/table/upsert_key_matcher.rs | 2 +
crates/paimon/tests/table_partial_write_test.rs | 344 +++++++++++++++++++++
crates/paimon/tests/table_update_test.rs | 278 +++++++++++++++++
11 files changed, 970 insertions(+), 64 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d0cca364..d568322f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -377,4 +377,5 @@ jobs:
run: |
cargo test --locked -p paimon --features
storage-oss-cpp,storage-jindo --lib \
io::storage_oss_cpp -- --include-ignored
- cargo clippy --locked -p paimon --features storage-oss-cpp --lib
--tests -- -D warnings
+ rustup toolchain install 1.98.0 --profile minimal --component clippy
+ cargo +1.98.0 clippy --locked -p paimon --features storage-oss-cpp
--lib --tests -- -D warnings
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index fcfe3c58..517222e3 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -235,6 +235,7 @@ class CommitMessage:
...
class BatchTableWrite:
+ def with_write_type(self, columns: List[str]) -> "BatchTableWrite": ...
def close(self) -> None: ...
def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
def prepare_commit(self) -> List[CommitMessage]:
@@ -287,6 +288,7 @@ class StreamTableUpdate:
...
class StreamTableWrite:
+ def with_write_type(self, columns: List[str]) -> "StreamTableWrite": ...
def close(self) -> None: ...
def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
def prepare_commit(self, wait_compaction: bool, commit_identifier: int) ->
List[CommitMessage]:
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 23d0cd62..3057a440 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -280,6 +280,18 @@ enum WriteTarget {
}
impl WriteTarget {
+ fn with_write_type(&mut self, columns: Vec<String>) -> paimon::Result<()> {
+ match self {
+ Self::Table(writer) => {
+ writer.with_write_type(columns)?;
+ Ok(())
+ }
+ Self::PostponeFixed(_) => Err(paimon::Error::Unsupported {
+ message: "with_write_type requires a Paimon append
table".into(),
+ }),
+ }
+ }
+
async fn write_arrow_batch(&mut self, batch: &RecordBatch) ->
paimon::Result<()> {
match self {
Self::Table(writer) => writer.write_arrow_batch(batch).await,
@@ -590,6 +602,14 @@ impl UpdateContext {
}
impl WriteState {
+ fn with_write_type(&mut self, columns: Vec<String>) -> PyResult<()> {
+ self.inner
+ .as_mut()
+ .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?
+ .with_write_type(columns)
+ .map_err(to_py_err)
+ }
+
fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
let batch = RecordBatch::from_pyarrow_bound(batch)?;
let inner = self
@@ -814,6 +834,14 @@ impl PyBatchTableUpdate {
#[pymethods]
impl PyBatchTableWrite {
+ fn with_write_type(
+ mut slf: PyRefMut<'_, Self>,
+ columns: Vec<String>,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ slf.state.with_write_type(columns)?;
+ Ok(slf)
+ }
+
fn close(&mut self, py: Python<'_>) {
if let Some(mut writer) = self.state.inner.take() {
py.detach(|| runtime().block_on(writer.close()));
@@ -846,6 +874,14 @@ pub struct PyStreamTableWrite {
#[pymethods]
impl PyStreamTableWrite {
+ fn with_write_type(
+ mut slf: PyRefMut<'_, Self>,
+ columns: Vec<String>,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ slf.state.with_write_type(columns)?;
+ Ok(slf)
+ }
+
fn close(&mut self, py: Python<'_>) {
if let Some(mut writer) = self.state.inner.take() {
py.detach(|| runtime().block_on(writer.close()));
diff --git a/bindings/python/tests/test_write.py
b/bindings/python/tests/test_write.py
index 59e256a3..900a1774 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -64,6 +64,31 @@ def test_write_commit_read_roundtrip():
assert result == {"id": [1, 2, 3], "name": ["a", "b", "c"]}
[email protected]("streaming", [False, True])
+def test_partial_write_type_roundtrip_and_lifecycle(tmp_path, streaming):
+ ctx = _make_empty_table(str(tmp_path))
+ table = _get_table(str(tmp_path))
+ builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
+ writer = builder.new_write()
+ assert writer.with_write_type(["id"]) is writer
+ writer.write_arrow(pa.record_batch([pa.array([], type=pa.int32())],
names=["id"]))
+ assert writer.with_write_type(["id"]) is writer
+ for columns in [[], ["missing"], ["id", "id"]]:
+ with pytest.raises(ValueError):
+ writer.with_write_type(columns)
+ writer.write_arrow(pa.record_batch([pa.array([1, 2], type=pa.int32())],
names=["id"]))
+ with pytest.raises(ValueError, match="before any data"):
+ writer.with_write_type(["id", "name"])
+ messages = writer.prepare_commit(True, 7) if streaming else
writer.prepare_commit()
+ commit = builder.new_commit()
+ commit.commit(7, messages) if streaming else commit.commit(messages)
+ writer.close()
+ with pytest.raises(RuntimeError, match="closed"):
+ writer.with_write_type(["id"])
+ assert pa.Table.from_batches(ctx.sql("SELECT id, name FROM paimon.wdb.t
ORDER BY id")).to_pydict() == {
+ "id": [1, 2], "name": [None, None]}
+
+
@pytest.mark.parametrize("primary_key", [False, True])
def test_custom_data_file_prefix_matches_table_option(tmp_path, primary_key):
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index d02c4618..975aee76 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -53,13 +53,15 @@ struct VectorFieldWriter {
/// If a blob value is already a serialized `BlobDescriptor`, the actual data
is
/// resolved from the referenced URI and written to the `.blob` file.
pub(crate) struct AppendDedicatedFormatFileWriter {
- normal_writer: DataFileWriter,
+ normal_writer: Option<DataFileWriter>,
blob_writers: Vec<BlobFieldWriter>,
vector_writer: Option<VectorFieldWriter>,
normal_column_indices: Vec<usize>,
normal_schema: Arc<arrow_schema::Schema>,
// Completed normal + dedicated file groups, still owned until
prepare_commit.
written_files: Vec<DataFileMeta>,
+ target_file_row_num: i64,
+ current_group_row_count: i64,
}
impl AppendDedicatedFormatFileWriter {
@@ -80,6 +82,7 @@ impl AppendDedicatedFormatFileWriter {
vector_target_file_size: i64,
vector_file_format: Option<&str>,
input_schema: &arrow_schema::Schema,
+ write_fields: &[DataField],
table_fields: &[DataField],
format_options: &HashMap<String, String>,
blob_inline_fields: &HashSet<String>,
@@ -99,7 +102,7 @@ impl AppendDedicatedFormatFileWriter {
let mut vector_table_fields = Vec::new();
let mut vector_field_names = Vec::new();
- for (idx, field) in table_fields.iter().enumerate() {
+ for (idx, field) in write_fields.iter().enumerate() {
let is_blob = field.data_type().is_blob_file_field();
let is_inline = blob_inline_fields.contains(field.name());
let is_dedicated_vector =
@@ -191,32 +194,38 @@ impl AppendDedicatedFormatFileWriter {
super::data_file_index_writer::FileIndexOptions::parse(format_options,
table_fields)?
.and_then(|options|
options.project_to_fields(&normal_table_fields))
.map(Arc::new);
- let normal_writer = DataFileWriter::new(
- file_io.clone(),
- table_location,
- partition_path,
- bucket,
- schema_id,
- target_file_size,
- file_compression,
- file_compression_zstd_level,
- write_buffer_size,
- file_format,
- normal_table_fields,
- format_options.clone(),
- Some(0),
- None,
- normal_write_cols,
- )?
- .with_path_factory(paths.clone())
- .with_file_index(normal_index)
- .with_target_file_row_num(target_file_row_num);
- // A view-only schema also uses this adapter to translate references,
- // but Java treats its physical writer as an ordinary inline writer.
- let normal_writer = if !blob_writers.is_empty() ||
vector_writer.is_some() {
- normal_writer.without_row_sidecar()
+ let normal_writer = if normal_table_fields.is_empty() {
+ // Java has no normal writer when the write type is entirely
+ // dedicated columns. Do not create an empty Parquet file.
+ None
} else {
- normal_writer
+ let normal_writer = DataFileWriter::new(
+ file_io.clone(),
+ table_location,
+ partition_path,
+ bucket,
+ schema_id,
+ target_file_size,
+ file_compression,
+ file_compression_zstd_level,
+ write_buffer_size,
+ file_format,
+ normal_table_fields,
+ format_options.clone(),
+ Some(0),
+ None,
+ normal_write_cols,
+ )?
+ .with_path_factory(paths.clone())
+ .with_file_index(normal_index)
+ .with_target_file_row_num(target_file_row_num);
+ // A view-only schema also uses this adapter to translate
references,
+ // but Java treats its physical writer as an ordinary inline
writer.
+ Some(if !blob_writers.is_empty() || vector_writer.is_some() {
+ normal_writer.without_row_sidecar()
+ } else {
+ normal_writer
+ })
};
Ok(Self {
@@ -226,11 +235,15 @@ impl AppendDedicatedFormatFileWriter {
vector_writer,
normal_column_indices,
normal_schema,
+ target_file_row_num,
+ current_group_row_count: 0,
})
}
pub(crate) fn with_resources(mut self, resources: Option<ResourceContext>)
-> Self {
- self.normal_writer.set_resources(resources.clone());
+ if let Some(normal) = &mut self.normal_writer {
+ normal.set_resources(resources.clone());
+ }
for blob in &mut self.blob_writers {
blob.writer.set_resources(resources.clone());
}
@@ -246,17 +259,19 @@ impl AppendDedicatedFormatFileWriter {
}
// Write normal columns
- let normal_columns: Vec<Arc<dyn arrow_array::Array>> = self
- .normal_column_indices
- .iter()
- .map(|&idx| batch.column(idx).clone())
- .collect();
- let normal_batch = RecordBatch::try_new(self.normal_schema.clone(),
normal_columns)
- .map_err(|e| crate::Error::DataInvalid {
- message: format!("Failed to project normal columns: {e}"),
- source: None,
- })?;
- self.normal_writer.write(&normal_batch).await?;
+ if let Some(normal) = &mut self.normal_writer {
+ let normal_columns: Vec<Arc<dyn arrow_array::Array>> = self
+ .normal_column_indices
+ .iter()
+ .map(|&idx| batch.column(idx).clone())
+ .collect();
+ let normal_batch =
RecordBatch::try_new(self.normal_schema.clone(), normal_columns)
+ .map_err(|e| crate::Error::DataInvalid {
+ message: format!("Failed to project normal columns: {e}"),
+ source: None,
+ })?;
+ normal.write(&normal_batch).await?;
+ }
// Write each blob column directly — BlobFormatWriter resolves
descriptors inline
for blob_writer in &mut self.blob_writers {
@@ -302,16 +317,34 @@ impl AppendDedicatedFormatFileWriter {
vector_writer.writer.write(&vector_batch).await?;
}
- if !self.normal_writer.has_open_file() {
+ self.current_group_row_count = self
+ .current_group_row_count
+ .saturating_add(batch.num_rows() as i64);
+ if self.current_group_row_count >= self.target_file_row_num
+ || self
+ .normal_writer
+ .as_ref()
+ .is_some_and(|writer| !writer.has_open_file())
+ {
self.close_group().await?;
}
Ok(())
}
pub(crate) async fn abort(&mut self) {
- self.normal_writer.delete_files(&self.written_files).await;
+ let cleanup = self
+ .normal_writer
+ .as_mut()
+ .or_else(|| self.blob_writers.first_mut().map(|blob| &mut
blob.writer))
+ .or_else(|| self.vector_writer.as_mut().map(|vector| &mut
vector.writer));
+ if let Some(writer) = cleanup {
+ writer.delete_files(&self.written_files).await;
+ }
self.written_files.clear();
- self.normal_writer.abort().await;
+ self.current_group_row_count = 0;
+ if let Some(normal) = &mut self.normal_writer {
+ normal.abort().await;
+ }
for writer in &mut self.blob_writers {
writer.writer.abort().await;
}
@@ -326,7 +359,9 @@ impl AppendDedicatedFormatFileWriter {
}
async fn close_group(&mut self) -> Result<()> {
- let writers = std::iter::once(&mut self.normal_writer)
+ let writers = self
+ .normal_writer
+ .iter_mut()
.chain(
self.blob_writers
.iter_mut()
@@ -344,6 +379,7 @@ impl AppendDedicatedFormatFileWriter {
return Err(error);
}
}
+ self.current_group_row_count = 0;
Ok(())
}
}
@@ -357,7 +393,16 @@ mod tests {
#[tokio::test]
async fn failed_blob_close_cleans_every_physical_column() {
- for (external, rolled) in [(false, false), (true, false), (false,
true), (true, true)] {
+ for (external, rolled, normal) in [
+ (false, false, true),
+ (true, false, true),
+ (false, true, true),
+ (true, true, true),
+ (false, false, false),
+ (true, false, false),
+ (false, true, false),
+ (true, true, false),
+ ] {
let io = FileIOBuilder::new("memory").build().unwrap();
let batch = RecordBatch::try_from_iter([
("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
@@ -376,6 +421,16 @@ mod tests {
DataField::new(1, "a".into(), DataType::Blob(BlobType::new())),
DataField::new(2, "b".into(), DataType::Blob(BlobType::new())),
];
+ let write_fields = if normal {
+ fields.clone()
+ } else {
+ fields[1..].to_vec()
+ };
+ let batch = if normal {
+ batch
+ } else {
+ batch.project(&[1, 2]).unwrap()
+ };
let mut options = if external {
HashMap::from([
(
@@ -411,6 +466,7 @@ mod tests {
i64::MAX,
None,
batch.schema().as_ref(),
+ &write_fields,
&fields,
&options,
&HashSet::new(),
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index e84c0053..49765fed 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -43,8 +43,8 @@ fn invalid(message: impl Into<String>) -> crate::Error {
/// [`TableCommit`](super::TableCommit).
///
/// Row-ID updates infer columns from the input unless configured with
-/// [`with_update_type`](Self::with_update_type). Key-based upserts currently
-/// require complete Arrow rows and match keys within each partition.
+/// [`with_update_type`](Self::with_update_type). Key-based upserts accept a
+/// column subset including the keys and match keys within each partition.
#[derive(Clone)]
pub struct TableUpdate {
table: Table,
@@ -190,10 +190,11 @@ impl TableUpdate {
.await
}
- /// Upsert complete Arrow rows by composite key through the core upsert
+ /// Upsert Arrow rows by composite key through the core upsert
/// writer. Keys match within each partition, including when partition
/// columns are omitted from `upsert_keys`. Existing keys update every
- /// matching row ID; new keys append.
+ /// 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.
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 7860825a..5ff0534f 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -26,6 +26,7 @@ use arrow_schema::{DataType, Field, Schema};
use arrow_select::{concat::concat_batches, take::take};
use futures::TryStreamExt;
+use super::update_input::unique_column_index;
use super::upsert_key_matcher::UpsertKeyMatcher;
use super::write_batch_normalize::normalize_write_array;
use crate::spec::{
@@ -74,7 +75,7 @@ fn partition_key(row: &BinaryRow, fields: &[DataField]) ->
crate::Result<Vec<u8>
Ok(builder.build().to_serialized_bytes())
}
-/// Upsert full Arrow rows into a data-evolution table without primary keys.
+/// Upsert Arrow rows into a data-evolution table without primary keys.
/// Internal executor for `TableUpdate::upsert_by_arrow_with_key`. Existing
/// keys are updated by row ID; new keys are appended.
#[must_use = "upsert must be used to call prepare_commit()"]
@@ -137,27 +138,48 @@ impl TableUpsert {
})
}
- /// Add full rows. Column order may differ from the table schema; names and
- /// Arrow layouts follow the same normalization as ordinary writes.
Multiple
- /// batches form one logical upsert input.
+ /// 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.
pub(super) fn add_batch(&mut self, batch: RecordBatch) ->
crate::Result<()> {
let target =
crate::arrow::build_target_arrow_schema(self.table.schema().fields())?;
- if batch.num_columns() != target.fields().len() {
- return Err(invalid("native upsert requires all table columns"));
+ let input_schema = batch.schema();
+ for field in input_schema.fields() {
+ unique_column_index(&input_schema, field.name())?;
+ target.field_with_name(field.name()).map_err(|_| {
+ invalid(format!(
+ "upsert column '{}' is not in table schema",
+ field.name()
+ ))
+ })?;
+ }
+ for key in &self.keys {
+ unique_column_index(&input_schema, key)?;
}
let mut columns = Vec::with_capacity(target.fields().len());
+ let mut fields = Vec::new();
for field in target.fields() {
- let column = batch
- .column_by_name(field.name())
- .ok_or_else(|| invalid(format!("missing upsert column '{}'",
field.name())))?;
+ let Some(column) = batch.column_by_name(field.name()) else {
+ continue;
+ };
columns.push(
normalize_write_array(column,
field.data_type()).map_err(|error| {
invalid(format!("Invalid upsert column '{}': {error}",
field.name()))
})?,
);
+ fields.push(field.clone());
}
- let ordered = RecordBatch::try_new(target, columns)
+ 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(())
}
@@ -183,7 +205,20 @@ impl TableUpsert {
.map_err(|error| invalid(error.to_string()))
})
.collect::<crate::Result<Vec<_>>>()?;
- let partitions: HashSet<_> = batch_to_serialized_bytes(source,
&indices, schema.fields())?
+ let input_fields = source
+ .schema()
+ .fields()
+ .iter()
+ .map(|field| {
+ schema
+ .fields()
+ .iter()
+ .find(|table_field| table_field.name() == field.name())
+ .unwrap()
+ .clone()
+ })
+ .collect::<Vec<_>>();
+ let partitions: HashSet<_> = batch_to_serialized_bytes(source,
&indices, &input_fields)?
.into_iter()
.collect();
let fields = schema.partition_fields();
@@ -251,6 +286,14 @@ impl TableUpsert {
.new_write_builder()
.with_commit_user(self.commit_user.clone())?
.new_write()?;
+ append.with_write_type(
+ new_rows
+ .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);
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 25f8e1df..d5d1a7e2 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -133,6 +133,8 @@ pub struct TableWrite {
table: Table,
branch_schema_checked: bool,
write_schema: Arc<arrow_schema::Schema>,
+ write_fields: Vec<DataField>,
+ written: bool,
partition_writers: HashMap<PartitionBucketKey, FileWriter>,
partition_computer: PartitionComputer,
partition_keys: Vec<String>,
@@ -198,6 +200,8 @@ impl TableWrite {
table: table.clone(),
branch_schema_checked: table.is_main_branch() ||
table.rest_env.is_some(),
write_schema: build_target_arrow_schema(schema.fields())?,
+ write_fields: schema.fields().to_vec(),
+ written: false,
partition_writers: HashMap::new(),
partition_computer: PartitionComputer::new(
schema.partition_keys(),
@@ -482,6 +486,8 @@ impl TableWrite {
table: table.clone(),
branch_schema_checked: table.is_main_branch() ||
table.rest_env.is_some(),
write_schema,
+ write_fields: fields.to_vec(),
+ written: false,
partition_writers: HashMap::new(),
partition_computer,
partition_keys,
@@ -602,6 +608,97 @@ impl TableWrite {
self
}
+ /// Select the top-level columns written by an append table, as Java
+ /// `BaseAppendFileStoreWrite.withWriteType` does. Input follows this
order;
+ /// omitted columns are recorded in file metadata rather than filled with
NULL.
+ /// Configure this before writing any nonempty batch.
+ pub fn with_write_type(&mut self, write_columns: Vec<String>) ->
Result<&mut Self> {
+ self.ensure_active()?;
+ if self.written {
+ return Err(crate::Error::DataInvalid {
+ message: "with_write_type must be called before any data is
written".into(),
+ source: None,
+ });
+ }
+ if self.format_writer.is_some() ||
!self.table.schema().primary_keys().is_empty() {
+ return Err(crate::Error::Unsupported {
+ message: "with_write_type requires a Paimon append
table".into(),
+ });
+ }
+ if write_columns.is_empty() {
+ return Err(crate::Error::DataInvalid {
+ message: "write columns must not be empty".into(),
+ source: None,
+ });
+ }
+ let schema = self.table.schema();
+ let mut seen = HashSet::new();
+ let fields = write_columns
+ .iter()
+ .map(|name| {
+ if !seen.insert(name) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Duplicate write column '{name}'"),
+ source: None,
+ });
+ }
+ schema
+ .fields()
+ .iter()
+ .find(|field| field.name() == name)
+ .cloned()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("Column '{name}' is not in table
schema"),
+ source: None,
+ })
+ })
+ .collect::<Result<Vec<_>>>()?;
+ let indices = |names: &[String]| {
+ names
+ .iter()
+ .map(|name| {
+ fields
+ .iter()
+ .position(|field| field.name() == name)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("Write type must include routing
column '{name}'"),
+ source: None,
+ })
+ })
+ .collect::<Result<Vec<_>>>()
+ };
+ let partition_indices = indices(&self.partition_keys)?;
+ let options = CoreOptions::new(schema.options());
+ let total_buckets = options.bucket();
+ let bucket_indices = if total_buckets > 1 {
+ indices(&schema.bucket_keys())?
+ } else {
+ Vec::new()
+ };
+ let assigner = if total_buckets <= 1 || bucket_indices.is_empty() {
+ BucketAssignerEnum::Constant(ConstantBucketAssigner::new(
+ partition_indices,
+ if total_buckets == POSTPONE_BUCKET {
+ POSTPONE_BUCKET
+ } else {
+ 0
+ },
+ ))
+ } else {
+ BucketAssignerEnum::Fixed(FixedBucketAssigner::new(
+ partition_indices,
+ bucket_indices,
+ options.bucket_function_type()?,
+ total_buckets,
+ ))
+ };
+ let write_schema = build_target_arrow_schema(&fields)?;
+ self.bucket_assigner = assigner;
+ self.write_schema = write_schema;
+ self.write_fields = fields;
+ Ok(self)
+ }
+
/// Write an Arrow RecordBatch. Rows are routed to the correct partition
and bucket.
pub async fn write_arrow_batch(&mut self, batch: &RecordBatch) ->
Result<()> {
if let Some(writer) = self.format_writer.as_mut() {
@@ -611,6 +708,7 @@ impl TableWrite {
let Some(batch) = self.normalize_write_batch(batch)? else {
return Ok(());
};
+ self.written = true;
let grouped = match self.divide_by_partition_bucket(&batch).await {
Ok(grouped) => grouped,
@@ -788,7 +886,7 @@ impl TableWrite {
}
}
- let fields = self.table.schema().fields().to_vec();
+ let fields = self.write_fields.clone();
let output = self.bucket_assigner.assign_batch(batch, &fields).await?;
if matches!(self.bucket_assigner,
BucketAssignerEnum::CrossPartition(_)) {
return self.divide_cross_partition_batch(batch, output);
@@ -1222,6 +1320,7 @@ impl TableWrite {
self.vector_target_file_size,
self.vector_file_format.as_deref(),
&self.write_schema,
+ &self.write_fields,
fields,
self.table.schema().options(),
&self.blob_inline_fields,
@@ -1241,13 +1340,32 @@ impl TableWrite {
self.file_compression_zstd_level,
self.write_buffer_size,
self.file_format.clone(),
- self.table.schema().fields().to_vec(),
+ self.write_fields.clone(),
self.table.schema().options().clone(),
Some(0),
None,
- None,
+ (!super::data_evolution_fields::can_omit_normal_write_cols(
+ self.table.schema().fields(),
+ &self
+ .write_fields
+ .iter()
+ .map(|field| field.name().to_string())
+ .collect::<Vec<_>>(),
+ &CoreOptions::new(self.table.schema().options()),
+ ))
+ .then(|| {
+ self.write_fields
+ .iter()
+ .map(|field| field.name().to_string())
+ .collect()
+ }),
)?
- .with_file_index(self.file_index_options.clone())
+ .with_file_index(
+ self.file_index_options
+ .as_ref()
+ .and_then(|options|
options.project_to_fields(&self.write_fields))
+ .map(Arc::new),
+ )
.with_target_file_row_num(self.target_file_row_num)
.with_resources(self.resources.clone()),
))
diff --git a/crates/paimon/src/table/upsert_key_matcher.rs
b/crates/paimon/src/table/upsert_key_matcher.rs
index e5257dc4..38e8e41f 100644
--- a/crates/paimon/src/table/upsert_key_matcher.rs
+++ b/crates/paimon/src/table/upsert_key_matcher.rs
@@ -52,6 +52,8 @@ pub(super) fn supported_key_type(data_type: &DataType) ->
bool {
| DataType::Int16
| DataType::Int32
| DataType::Int64
+ | DataType::Float32
+ | DataType::Float64
| DataType::UInt8
| DataType::UInt16
| DataType::UInt32
diff --git a/crates/paimon/tests/table_partial_write_test.rs
b/crates/paimon/tests/table_partial_write_test.rs
new file mode 100644
index 00000000..248b2735
--- /dev/null
+++ b/crates/paimon/tests/table_partial_write_test.rs
@@ -0,0 +1,344 @@
+// 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.
+
+mod common;
+
+use std::sync::Arc;
+
+use arrow_array::{Array, ArrayRef, Int32Array, RecordBatch};
+use futures::TryStreamExt;
+use paimon::spec::{BlobType, DataType, IntType, Schema, TableSchema};
+use paimon::table::Table;
+
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+
+async fn table(evolution: bool, fixed_bucket: bool, blob: bool) -> Table {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "omitted",
+ if blob {
+ DataType::Blob(BlobType::new())
+ } else {
+ DataType::Int(IntType::new())
+ },
+ )
+ .column("pt", DataType::Int(IntType::new()))
+ .partition_keys(["pt"])
+ .option("row-tracking.enabled", evolution.to_string())
+ .option("data-evolution.enabled", evolution.to_string());
+ if fixed_bucket {
+ schema = schema.option("bucket", "4").option("bucket-key", "id");
+ }
+ let (io, table) = memory_table(
+ "memory:/partial_write",
+ TableSchema::new(0, &schema.build().unwrap()),
+ );
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ table
+}
+
+fn partial(ids: Vec<i32>, partitions: Vec<Option<i32>>) -> RecordBatch {
+ RecordBatch::try_from_iter([
+ ("pt", Arc::new(Int32Array::from(partitions)) as ArrayRef),
+ ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+ ])
+ .unwrap()
+}
+
+async fn rows(table: &Table) -> Vec<Vec<Option<i32>>> {
+ 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 {
+ for row in 0..batch.num_rows() {
+ rows.push(
+ batch
+ .columns()
+ .iter()
+ .map(|column| {
+ let column =
column.as_any().downcast_ref::<Int32Array>().unwrap();
+ column.is_valid(row).then(|| column.value(row))
+ })
+ .collect(),
+ );
+ }
+ }
+ rows.sort();
+ rows
+}
+
+#[tokio::test]
+async fn partial_append_routes_projected_partition_and_bucket_indices() {
+ for evolution in [false, true] {
+ // Java disallows fixed buckets for data evolution tables.
+ for fixed_bucket in [false, true]
+ .into_iter()
+ .filter(|fixed| !evolution || !fixed)
+ {
+ let table = table(evolution, fixed_bucket, false).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ writer
+ .write_arrow_batch(&partial(vec![1, 2, 3], vec![Some(7),
Some(8), None]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(
+ messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .map(|f| f.row_count)
+ .sum::<i64>(),
+ 3
+ );
+ for file in messages.iter().flat_map(|m| &m.new_files) {
+ assert_eq!(file.write_cols, Some(vec!["pt".into(),
"id".into()]));
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ // Stream checkpoints must retain the selected type.
+ writer
+ .write_arrow_batch(&partial(vec![4], vec![Some(7)]))
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ vec![Some(1), None, Some(7)],
+ vec![Some(2), None, Some(8)],
+ vec![Some(3), None, None],
+ vec![Some(4), None, Some(7)],
+ ]
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn invalid_write_type_does_not_replace_previous_configuration() {
+ let table = table(false, true, false).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ for columns in [
+ vec![],
+ vec!["pt", "id", "id"],
+ vec!["pt", "missing"],
+ vec!["id"],
+ vec!["pt"],
+ ] {
+ assert!(writer
+ .with_write_type(columns.into_iter().map(str::to_string).collect())
+ .is_err());
+ }
+ // Empty batches do not lock the configuration; nonempty ones do, even
+ // after their files have been prepared.
+ writer
+ .write_arrow_batch(&partial(vec![], vec![]))
+ .await
+ .unwrap();
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ writer
+ .write_arrow_batch(&partial(vec![1], vec![Some(2)]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(writer
+ .with_write_type(vec!["id".into(), "omitted".into(), "pt".into()])
+ .is_err());
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(rows(&table).await, vec![vec![Some(1), None, Some(2)]]);
+}
+
+#[tokio::test]
+async fn omitted_managed_blob_does_not_create_dedicated_files() {
+ let table = table(true, false, true).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ writer
+ .write_arrow_batch(&partial(vec![1], vec![Some(2)]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let files = messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .collect::<Vec<_>>();
+ assert_eq!(files.len(), 1);
+ assert_eq!(files[0].write_cols, Some(vec!["pt".into(), "id".into()]));
+ assert!(files[0].file_name.ends_with(".parquet"));
+ builder.new_commit().commit(messages).await.unwrap();
+ 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();
+ assert_eq!(batches[0].column(1).null_count(), 1);
+}
+
+#[tokio::test]
+async fn
blob_only_partial_write_has_no_empty_normal_file_and_keeps_row_count() {
+ use arrow_array::LargeBinaryArray;
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("target-file-row-num", "2")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/blob_only_partial",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let builder = table.new_write_builder();
+ let mut seed = builder.new_write().unwrap();
+ seed.with_write_type(vec!["id".into()]).unwrap();
+ let seed_input =
+ RecordBatch::try_from_iter([("id", Arc::new(Int32Array::from(vec![1,
2, 3])) as ArrayRef)])
+ .unwrap();
+ seed.write_arrow_batch(&seed_input.slice(0, 2))
+ .await
+ .unwrap();
+ seed.write_arrow_batch(&seed_input.slice(2, 1))
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit(seed.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer.with_write_type(vec!["payload".into()]).unwrap();
+ let values = vec![Some(b"first".as_slice()), None,
Some(b"third".as_slice())];
+ let input = RecordBatch::try_from_iter([(
+ "payload",
+ Arc::new(LargeBinaryArray::from(values.clone())) as ArrayRef,
+ )])
+ .unwrap();
+ writer.write_arrow_batch(&input).await.unwrap();
+ let mut messages = writer.prepare_commit().await.unwrap();
+ let files = messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .collect::<Vec<_>>();
+ assert_eq!(
+ files.iter().map(|f| f.row_count).collect::<Vec<_>>(),
+ vec![2, 1]
+ );
+ assert!(files
+ .iter()
+ .all(|file| file.file_name.ends_with(".blob")
+ && file.write_cols == Some(vec!["payload".into()])));
+ // Dedicated-only files update an existing row range. As in Java, they
+ // require explicit row IDs because no normal file allocates new IDs.
+ let mut first_row_id = 0;
+ for file in messages
+ .iter_mut()
+ .flat_map(|message| &mut message.new_files)
+ {
+ file.first_row_id = Some(first_row_id);
+ first_row_id += file.row_count;
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ 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 actual = Vec::new();
+ for batch in batches {
+ assert_eq!(batch.column(0).null_count(), 0);
+ let payload = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ actual.extend(
+ (0..batch.num_rows())
+ .map(|row| payload.is_valid(row).then(||
payload.value(row).to_vec())),
+ );
+ }
+ let mut expected = values
+ .into_iter()
+ .map(|value| value.map(|bytes| bytes.to_vec()))
+ .collect::<Vec<_>>();
+ actual.sort();
+ expected.sort();
+ assert_eq!(actual, expected);
+}
+
+#[tokio::test]
+async fn selected_columns_validate_input_before_staging() {
+ let table = table(false, false, false).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .with_write_type(vec!["pt".into(), "id".into()])
+ .unwrap();
+ let input = partial(vec![1], vec![Some(2)]);
+ for bad in [
+ input.project(&[1, 0]).unwrap(),
+ input.project(&[0]).unwrap(),
+ ] {
+ assert!(writer.write_arrow_batch(&bad).await.is_err());
+ }
+ assert!(table
+ .file_io()
+ .list_status_recursive(table.location())
+ .await
+ .unwrap()
+ .iter()
+ .all(|status| !status.path.ends_with(".parquet")));
+ writer.write_arrow_batch(&input).await.unwrap();
+ assert_eq!(
+ writer.prepare_commit().await.unwrap()[0].new_files[0].row_count,
+ 1
+ );
+}
diff --git a/crates/paimon/tests/table_update_test.rs
b/crates/paimon/tests/table_update_test.rs
index 3908a3dc..220f7471 100644
--- a/crates/paimon/tests/table_update_test.rs
+++ b/crates/paimon/tests/table_update_test.rs
@@ -754,6 +754,284 @@ async fn
grouped_updates_on_an_empty_table_preserve_empty_input_semantics() {
assert!(parquet_files(&table).await.is_empty());
}
+#[tokio::test]
+async fn partial_upsert_updates_matches_and_appends_only_input_columns() {
+ use arrow_array::Array;
+ let table = evolution_table().await;
+ write_batch(
+ &table,
+ &batch(&[
+ ("id", vec![1, 1, 2]),
+ ("value", vec![10, 11, 20]),
+ ("score", vec![100, 101, 200]),
+ ]),
+ )
+ .await;
+ let builder = table.new_write_builder();
+ let mut update = builder.new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![batch(&[("value", vec![12, 30, 13]), ("id", vec![1, 3, 1])])],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ for file in messages.iter().flat_map(|message| &message.new_files) {
+ assert!(file
+ .write_cols
+ .as_ref()
+ .unwrap()
+ .iter()
+ .all(|column| column != "score"));
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ 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 {
+ let columns = batch
+ .columns()
+ .iter()
+ .map(|array| array.as_any().downcast_ref::<Int32Array>().unwrap())
+ .collect::<Vec<_>>();
+ for row in 0..batch.num_rows() {
+ rows.push(
+ columns
+ .iter()
+ .map(|column| column.is_valid(row).then(||
column.value(row)))
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+ rows.sort();
+ assert_eq!(
+ rows,
+ vec![
+ vec![Some(1), Some(13), Some(100)],
+ vec![Some(1), Some(13), Some(101)],
+ vec![Some(2), Some(20), Some(200)],
+ vec![Some(3), Some(30), None],
+ ]
+ );
+}
+
+#[tokio::test]
+async fn partial_upsert_requires_update_columns_only_for_matches() {
+ let table = evolution_table().await;
+ seed(&table).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["score".into()]).unwrap();
+ let before = parquet_files(&table).await;
+ let error = update
+ .upsert_by_arrow_with_key(
+ vec![batch(&[("id", vec![1]), ("value", vec![99])])],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("score"));
+ assert_eq!(parquet_files(&table).await, before);
+ // The update selection does not restrict the columns of unmatched rows.
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![batch(&[("id", vec![4]), ("value", vec![40])])],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ messages[0].new_files[0].write_cols,
+ Some(vec!["id".into(), "value".into()])
+ );
+ commit(&table, messages).await;
+}
+
+#[tokio::test]
+async fn partial_upsert_rejects_ambiguous_unknown_and_inconsistent_input() {
+ let table = evolution_table().await;
+ seed(&table).await;
+ let before = parquet_files(&table).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ for batches in [
+ vec![batch(&[
+ ("id", vec![1]),
+ ("id", vec![2]),
+ ("value", vec![99]),
+ ])],
+ vec![batch(&[("id", vec![1]), ("missing", vec![99])])],
+ vec![batch(&[("value", vec![99])])],
+ vec![
+ batch(&[("id", vec![1]), ("value", vec![99])]),
+ batch(&[("id", vec![1]), ("score", vec![99])]),
+ ],
+ ] {
+ 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(&[("value", vec![41]), ("id", vec![1])]),
+ batch(&[("id", vec![1]), ("value", vec![42])]),
+ ],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ commit(&table, messages).await;
+ assert_eq!(
+ read_rows(&table).await,
+ vec![vec![1, 42, 100], vec![2, 20, 200], vec![3, 30, 300]]
+ );
+}
+
+#[tokio::test]
+async fn floating_upsert_keys_preserve_fractions_nulls_and_duplicate_targets()
{
+ use arrow_array::{Array, Float32Array, Float64Array};
+ use paimon::spec::{DoubleType, FloatType};
+ for wide in [false, true] {
+ let schema = Schema::builder()
+ .column(
+ "key",
+ if wide {
+ DataType::Double(DoubleType::new())
+ } else {
+ DataType::Float(FloatType::new())
+ },
+ )
+ .column("value", DataType::Int(IntType::new()))
+ .column("keep", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/floating_upsert",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let keys = |values: Vec<Option<f64>>| -> ArrayRef {
+ if wide {
+ Arc::new(Float64Array::from(values))
+ } else {
+ Arc::new(Float32Array::from(
+ values
+ .into_iter()
+ .map(|value| value.map(|v| v as f32))
+ .collect::<Vec<_>>(),
+ ))
+ }
+ };
+ let original = RecordBatch::try_from_iter([
+ ("key", keys(vec![Some(-1.25), Some(-1.25), None, Some(1.5)])),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![10, 11, 20, 30])) as ArrayRef,
+ ),
+ (
+ "keep",
+ Arc::new(Int32Array::from(vec![1, 2, 3, 4])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ write_batch(&table, &original).await;
+ let input = RecordBatch::try_from_iter([
+ (
+ "key",
+ keys(vec![None, Some(-1.25), Some(2.75), Some(-1.25)]),
+ ),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![21, 12, 40, 13])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ commit(
+ &table,
+ update
+ .upsert_by_arrow_with_key(vec![input], vec!["key".into()])
+ .await
+ .unwrap(),
+ )
+ .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 actual = Vec::new();
+ for batch in batches {
+ let value = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let keep = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ let key = if batch.column(0).is_null(row) {
+ None
+ } else if wide {
+ Some(
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Float64Array>()
+ .unwrap()
+ .value(row),
+ )
+ } else {
+ Some(f64::from(
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .value(row),
+ ))
+ };
+ actual.push((
+ key,
+ value.value(row),
+ keep.is_valid(row).then(|| keep.value(row)),
+ ));
+ }
+ }
+ actual.sort_by_key(|row| row.1);
+ assert_eq!(
+ actual,
+ vec![
+ (Some(-1.25), 13, Some(1)),
+ (Some(-1.25), 13, Some(2)),
+ (None, 21, Some(3)),
+ (Some(1.5), 30, Some(4)),
+ (Some(2.75), 40, None),
+ ]
+ );
+ }
+}
+
#[tokio::test]
async fn configured_upsert_and_delete_are_core_operations() {
let table = evolution_table().await;