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 952f6b81 [core] Complete nested updates and partition-aware temporal
upserts (#973)
952f6b81 is described below
commit 952f6b81205be273be33fcd481802ed111b048ca
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 27 22:57:02 2026 +0800
[core] Complete nested updates and partition-aware temporal upserts (#973)
---
crates/paimon/src/table/table_upsert.rs | 122 +++++-
crates/paimon/src/table/update_input.rs | 510 ++++++++++++++++++++++-
crates/paimon/src/table/upsert_key_matcher.rs | 61 +++
crates/paimon/src/table/write_batch_normalize.rs | 34 +-
crates/paimon/tests/table_update_nested_test.rs | 439 +++++++++++++++++++
5 files changed, 1142 insertions(+), 24 deletions(-)
diff --git a/crates/paimon/src/table/table_upsert.rs
b/crates/paimon/src/table/table_upsert.rs
index 3a62311a..aa8ee45f 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -17,6 +17,8 @@
//! Table-level append-only upsert by user-specified keys.
+use std::borrow::Cow;
+use std::collections::HashSet;
use std::sync::Arc;
use arrow_array::{ArrayRef, Int64Array, RecordBatch, UInt32Array};
@@ -25,7 +27,11 @@ use arrow_select::{concat::concat_batches, take::take};
use futures::TryStreamExt;
use super::upsert_key_matcher::UpsertKeyMatcher;
-use crate::spec::CoreOptions;
+use super::write_batch_normalize::normalize_write_array;
+use crate::spec::{
+ batch_to_serialized_bytes, extract_datum, BinaryRow, BinaryRowBuilder,
CoreOptions, DataField,
+};
+use crate::table::source::{DataSplit, Plan};
use crate::table::{CommitMessage, Table};
const ROW_ID: &str = "_ROW_ID";
@@ -55,6 +61,19 @@ fn selected_rows(batch: &RecordBatch, rows: &[usize]) ->
crate::Result<RecordBat
.map_err(|error| invalid(format!("cannot build selected upsert rows:
{error}")))
}
+// Java may reserve variable-length storage for NULL timestamps/decimals.
+// Compare logical partitions using one encoding, independent of that padding.
+fn partition_key(row: &BinaryRow, fields: &[DataField]) ->
crate::Result<Vec<u8>> {
+ let mut builder = BinaryRowBuilder::new(fields.len() as i32);
+ for (index, field) in fields.iter().enumerate() {
+ match extract_datum(row, index, field.data_type())? {
+ Some(value) => builder.write_datum(index, &value,
field.data_type()),
+ None => builder.set_null_at(index),
+ }
+ }
+ Ok(builder.build().to_serialized_bytes())
+}
+
/// Upsert full 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.
@@ -119,7 +138,8 @@ impl TableUpsert {
}
/// Add full rows. Column order may differ from the table schema; names and
- /// Arrow types must agree. Multiple batches form one logical upsert input.
+ /// Arrow layouts follow the same normalization as ordinary writes.
Multiple
+ /// 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() {
@@ -130,15 +150,11 @@ impl TableUpsert {
let column = batch
.column_by_name(field.name())
.ok_or_else(|| invalid(format!("missing upsert column '{}'",
field.name())))?;
- if column.data_type() != field.data_type() {
- return Err(invalid(format!(
- "upsert column '{}' type differs from table: {:?} != {:?}",
- field.name(),
- column.data_type(),
- field.data_type()
- )));
- }
- columns.push(column.clone());
+ columns.push(
+ normalize_write_array(column,
field.data_type()).map_err(|error| {
+ invalid(format!("Invalid upsert column '{}': {error}",
field.name()))
+ })?,
+ );
}
let ordered = RecordBatch::try_new(target, columns)
.map_err(|error| invalid(format!("cannot order upsert columns:
{error}")))?;
@@ -146,6 +162,40 @@ impl TableUpsert {
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>(
+ &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 indices = schema
+ .partition_keys()
+ .iter()
+ .map(|name| {
+ source
+ .schema()
+ .index_of(name)
+ .map_err(|error| invalid(error.to_string()))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ let partitions: HashSet<_> = batch_to_serialized_bytes(source,
&indices, schema.fields())?
+ .into_iter()
+ .collect();
+ let fields = schema.partition_fields();
+ let mut splits = Vec::new();
+ for split in plan.splits() {
+ if partitions.contains(&partition_key(split.partition(),
&fields)?) {
+ splits.push(split.clone());
+ }
+ }
+ Ok(Cow::Owned(splits))
+ }
+
/// Match source keys against the target snapshot, stage row-ID updates and
/// new rows, then return both sets of commit messages as one operation.
#[must_use = "commit messages must be passed to TableCommit"]
@@ -167,7 +217,8 @@ impl TableUpsert {
read_builder.with_projection(&projection)?;
let plan = read_builder.new_scan().plan().await?;
let read = read_builder.new_read()?;
- let mut stream = read.to_arrow(plan.splits())?;
+ let splits = self.matching_splits(&source, &plan)?;
+ let mut stream = read.to_arrow(splits.as_ref())?;
while let Some(batch) = stream.try_next().await? {
matcher.add_existing_batch(&batch)?;
}
@@ -221,3 +272,50 @@ impl TableUpsert {
result.map(|()| messages)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{DataType as PaimonType, DecimalType, TimestampType,
VarCharType};
+
+ #[test]
+ fn partition_identity_ignores_java_null_variable_storage() {
+ let fields = vec![
+ DataField::new(
+ 0,
+ "ts".into(),
+ PaimonType::Timestamp(TimestampType::new(6).unwrap()),
+ ),
+ DataField::new(
+ 1,
+ "amount".into(),
+ PaimonType::Decimal(DecimalType::new(20, 2).unwrap()),
+ ),
+ DataField::new(
+ 2,
+ "label".into(),
+ PaimonType::VarChar(VarCharType::new(100).unwrap()),
+ ),
+ ];
+ let mut builder = BinaryRowBuilder::new(3);
+ builder.set_null_at(0);
+ builder.set_null_at(1);
+ builder.write_string(2, "after-null-fields");
+ let canonical = builder.build();
+ let mut java = canonical.data()[..32].to_vec();
+ // BinaryWriter.writeTimestamp(NULL, 6) reserves 8 bytes;
+ // writeDecimal(NULL, 20) reserves another 16, even though both are
NULL.
+ java.extend_from_slice(&[0; 24]);
+ java.extend_from_slice(&canonical.data()[32..]);
+ java[8..16].copy_from_slice(&(32_u64 << 32).to_le_bytes());
+ java[16..24].copy_from_slice(&(40_u64 << 32).to_le_bytes());
+ let offset_and_size =
u64::from_le_bytes(canonical.data()[24..32].try_into().unwrap());
+ java[24..32].copy_from_slice(&(offset_and_size + (24_u64 <<
32)).to_le_bytes());
+ let java = BinaryRow::from_bytes(3, java);
+ assert_ne!(java.to_serialized_bytes(),
canonical.to_serialized_bytes());
+ assert_eq!(
+ partition_key(&java, &fields).unwrap(),
+ canonical.to_serialized_bytes()
+ );
+ }
+}
diff --git a/crates/paimon/src/table/update_input.rs
b/crates/paimon/src/table/update_input.rs
index 803a4660..d68de118 100644
--- a/crates/paimon/src/table/update_input.rs
+++ b/crates/paimon/src/table/update_input.rs
@@ -19,6 +19,7 @@
use std::sync::Arc;
+use arrow_array::cast::AsArray;
use arrow_array::{
new_null_array, Array, ArrayRef, FixedSizeListArray, Float32Array,
Float64Array,
GenericListArray, Int64Array, LargeListArray, ListArray, MapArray,
OffsetSizeTrait,
@@ -130,8 +131,24 @@ pub(super) fn cast_update_value(
mode: CastMode,
) -> crate::Result<ArrayRef> {
if mode == CastMode::RowUpdate {
- cast_values(array, target, CastMode::Assignment)
- .or_else(|_| cast_values(array, target, CastMode::Constructor))
+ cast_values(array, target, CastMode::Assignment).or_else(|_| {
+ validate_map_constructor_input(array, target)?;
+ let decoded = decode_dictionary(array)?;
+ // Python converts lists of pairs to tuples only for a top-level
MAP.
+ if let DataType::Map(field, sorted) = target {
+ if matches!(
+ decoded.data_type(),
+ DataType::List(_)
+ | DataType::LargeList(_)
+ | DataType::ListView(_)
+ | DataType::LargeListView(_)
+ | DataType::FixedSizeList(..)
+ ) {
+ return constructor_map(&decoded, field, *sorted);
+ }
+ }
+ cast_values(array, target, CastMode::Constructor)
+ })
} else {
cast_values(array, target, mode)
}
@@ -169,9 +186,26 @@ fn cast_values(array: &ArrayRef, target: &DataType, mode:
CastMode) -> crate::Re
"Run-end encoded update inputs are not supported by PyArrow casts",
));
}
+ if mode == CastMode::Constructor {
+ match (array.data_type(), target) {
+ (DataType::Struct(_), DataType::Map(field, sorted)) => {
+ return constructor_map(array, field, *sorted);
+ }
+ (DataType::Map(..), DataType::Struct(fields)) => {
+ return constructor_struct_from_map(
+ array.as_any().downcast_ref::<MapArray>().unwrap(),
+ fields,
+ );
+ }
+ _ => {}
+ }
+ }
let nested: Option<ArrayRef> = match (array.data_type(), target) {
(DataType::Struct(_), DataType::Struct(fields)) => {
let input = array.as_any().downcast_ref::<StructArray>().unwrap();
+ if mode == CastMode::Assignment {
+ validate_struct_cast_fields(input.fields(), fields)?;
+ }
let columns = fields
.iter()
.map(|field| match input.column_by_name(field.name()) {
@@ -289,6 +323,235 @@ fn cast_values(array: &ArrayRef, target: &DataType, mode:
CastMode) -> crate::Re
cast_primitive(array, target, mode)
}
+// PyPaimon's row-ID fallback reconstructs Python dicts only after a
whole-column
+// safe cast fails. At the top level it rejects ambiguous schema-less null
keys.
+fn validate_map_constructor_input(array: &ArrayRef, target: &DataType) ->
crate::Result<()> {
+ if matches!(target, DataType::Map(..)) {
+ if let Some(input) = array.as_any().downcast_ref::<StructArray>() {
+ if input.columns().iter().any(|column| {
+ column.logical_nulls().is_some_and(|nulls| {
+ (0..input.len()).any(|row| input.is_valid(row) &&
nulls.is_null(row))
+ })
+ }) {
+ return Err(invalid(
+ "Cannot coerce schema-less dict input with null values to
map type; pass an explicit map-typed array or list-of-pairs instead",
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_struct_cast_fields(
+ source: &arrow_schema::Fields,
+ target: &arrow_schema::Fields,
+) -> crate::Result<()> {
+ let mut previous = None;
+ for field in target {
+ if let Some(index) = source.iter().position(|f| f.name() ==
field.name()) {
+ if previous.is_some_and(|previous| index <= previous) {
+ return Err(invalid(
+ "Struct fields are in the wrong order for a safe cast",
+ ));
+ }
+ if source[index].is_nullable() && !field.is_nullable() {
+ return Err(invalid(
+ "Cannot safely cast a nullable Struct field to
non-nullable",
+ ));
+ }
+ previous = Some(index);
+ }
+ }
+ Ok(())
+}
+
+fn constructor_struct_from_map(
+ input: &MapArray,
+ fields: &arrow_schema::Fields,
+) -> crate::Result<ArrayRef> {
+ let keys = decode_dictionary(input.keys())?;
+ let columns = fields
+ .iter()
+ .enumerate()
+ .map(|(field_index, field)| {
+ let indices = (0..input.len())
+ .map(|row| {
+ let range = input.value_offsets();
+ if input.is_null(row) || field_index >= (range[row + 1] -
range[row]) as usize {
+ return Ok(None);
+ }
+ let index = range[row] as usize + field_index;
+ let key = match keys.data_type() {
+ DataType::Utf8 =>
keys.as_string::<i32>().value(index).as_bytes(),
+ DataType::LargeUtf8 =>
keys.as_string::<i64>().value(index).as_bytes(),
+ DataType::Utf8View =>
keys.as_string_view().value(index).as_bytes(),
+ DataType::Binary =>
keys.as_binary::<i32>().value(index),
+ DataType::LargeBinary =>
keys.as_binary::<i64>().value(index),
+ DataType::BinaryView =>
keys.as_binary_view().value(index),
+ DataType::FixedSizeBinary(_) =>
keys.as_fixed_size_binary().value(index),
+ _ => {
+ return Err(invalid(
+ "Expected a string or bytes field name in
MAP-to-ROW input",
+ ))
+ }
+ };
+ if key != field.name().as_bytes() {
+ return Err(invalid(format!(
+ "Expected field name '{}' in MAP-to-ROW input",
+ field.name()
+ )));
+ }
+ Ok(Some(index as u64))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ // Missing fields, NULL parents and trailing entries do not
participate
+ // in conversion, matching PyArrow's ordered pair constructor for
ROW.
+ let values = arrow_select::take::take(
+ input.values().as_ref(),
+ &arrow_array::UInt64Array::from(indices),
+ None,
+ )
+ .map_err(|error| invalid(error.to_string()))?;
+ cast_values(&values, field.data_type(), CastMode::Constructor)
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ Ok(Arc::new(
+ StructArray::try_new_with_length(
+ fields.clone(),
+ columns,
+ input.nulls().cloned(),
+ input.len(),
+ )
+ .map_err(|error| invalid(error.to_string()))?,
+ ))
+}
+
+fn constructor_map(
+ input: &ArrayRef,
+ entries_field: &arrow_schema::FieldRef,
+ sorted: bool,
+) -> crate::Result<ArrayRef> {
+ let DataType::Struct(fields) = entries_field.data_type() else {
+ return Err(invalid("Map entries must have a struct type"));
+ };
+ let mut offsets = vec![0_i32];
+ let (keys, values) = if let Some(input) =
input.as_any().downcast_ref::<StructArray>() {
+ let mut indices = Vec::new();
+ let mut keys = Vec::new();
+ for row in 0..input.len() {
+ if input.is_valid(row) {
+ for (index, field) in input.fields().iter().enumerate() {
+ keys.push(field.name().as_str());
+ indices.push((index, row));
+ }
+ }
+ offsets.push(i32::try_from(indices.len()).map_err(|_| invalid("Map
offset overflow"))?);
+ }
+ let columns = input
+ .columns()
+ .iter()
+ .map(|column| {
+ let visible = constructor_child(column, input.nulls(), 1,
CastMode::Constructor)?;
+ cast_values(&visible, fields[1].data_type(),
CastMode::Constructor)
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ let values = if columns.is_empty() {
+ arrow_array::new_empty_array(fields[1].data_type())
+ } else {
+ let columns = columns
+ .iter()
+ .map(|column| column.as_ref())
+ .collect::<Vec<_>>();
+ arrow_select::interleave::interleave(&columns, &indices)
+ .map_err(|error| invalid(error.to_string()))?
+ };
+ (Arc::new(StringArray::from(keys)) as ArrayRef, values)
+ } else {
+ let mut keys = Vec::new();
+ let mut values = Vec::new();
+ for row in 0..input.len() {
+ if input.is_valid(row) {
+ let pairs = constructor_list_value(input, row)?;
+ for index in 0..pairs.len() {
+ if pairs.is_null(index) {
+ return Err(invalid("Map key/value pair must not be
null"));
+ }
+ let pair = constructor_list_value(&pairs, index)?;
+ if pair.len() != 2 {
+ return Err(invalid(
+ "Map key/value pair must contain exactly two
values",
+ ));
+ }
+ keys.push(pair.slice(0, 1));
+ values.push(pair.slice(1, 1));
+ }
+ }
+ offsets.push(i32::try_from(keys.len()).map_err(|_| invalid("Map
offset overflow"))?);
+ }
+ (
+ concat_constructor_values(&keys, fields[0].data_type())?,
+ concat_constructor_values(&values, fields[1].data_type())?,
+ )
+ };
+ let keys = cast_values(&keys, fields[0].data_type(),
CastMode::Constructor)?;
+ let entries = StructArray::try_new(fields.clone(), vec![keys, values],
None)
+ .map_err(|error| invalid(error.to_string()))?;
+ Ok(Arc::new(
+ MapArray::try_new(
+ entries_field.clone(),
+ OffsetBuffer::new(offsets.into()),
+ entries,
+ input.nulls().cloned(),
+ sorted,
+ )
+ .map_err(|error| invalid(error.to_string()))?,
+ ))
+}
+
+fn constructor_list_value(input: &ArrayRef, index: usize) ->
crate::Result<ArrayRef> {
+ match input.data_type() {
+ DataType::List(_) => Ok(input
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .unwrap()
+ .value(index)),
+ DataType::LargeList(_) => Ok(input
+ .as_any()
+ .downcast_ref::<LargeListArray>()
+ .unwrap()
+ .value(index)),
+ DataType::FixedSizeList(..) => Ok(input
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .unwrap()
+ .value(index)),
+ DataType::ListView(_) => Ok(input
+ .as_any()
+ .downcast_ref::<arrow_array::ListViewArray>()
+ .unwrap()
+ .value(index)),
+ DataType::LargeListView(_) => Ok(input
+ .as_any()
+ .downcast_ref::<arrow_array::LargeListViewArray>()
+ .unwrap()
+ .value(index)),
+ _ => Err(invalid("Map input must contain key/value pairs")),
+ }
+}
+
+fn concat_constructor_values(values: &[ArrayRef], target: &DataType) ->
crate::Result<ArrayRef> {
+ if values.is_empty() {
+ return Ok(arrow_array::new_empty_array(target));
+ }
+ let values = values
+ .iter()
+ .map(|array| array.as_ref())
+ .collect::<Vec<_>>();
+ let combined =
+ arrow_select::concat::concat(&values).map_err(|error|
invalid(error.to_string()))?;
+ cast_values(&combined, target, CastMode::Constructor)
+}
+
fn cast_list<O: OffsetSizeTrait>(
input: &GenericListArray<O>,
field: &arrow_schema::FieldRef,
@@ -2086,4 +2349,247 @@ mod tests {
assert!(float_to_decimal(value, false, 5, 2).is_err());
}
}
+
+ fn test_map_type(key: DataType, value: DataType) -> DataType {
+ DataType::Map(
+ Arc::new(arrow_schema::Field::new(
+ "entries",
+ DataType::Struct(
+ vec![
+ arrow_schema::Field::new("key", key, false),
+ arrow_schema::Field::new("value", value, true),
+ ]
+ .into(),
+ ),
+ false,
+ )),
+ false,
+ )
+ }
+
+ #[test]
+ fn row_update_dict_to_map_distinguishes_root_and_nested_nulls() {
+ use arrow_schema::Field;
+ let input: ArrayRef = Arc::new(StructArray::new(
+ vec![Field::new("a", DataType::Float64, true)].into(),
+ vec![Arc::new(Float64Array::from(vec![
+ Some(1.5),
+ None,
+ Some(f64::NAN),
+ ]))],
+ Some(arrow_buffer::NullBuffer::from(vec![true, true, false])),
+ ));
+ let target = test_map_type(DataType::Utf8, DataType::Int32);
+ assert!(cast_update_value(&input, &target,
CastMode::RowUpdate).is_err());
+ assert!(cast_assignment(&input.slice(0, 1), &target).is_err());
+ let result = cast_update_value(&input.slice(0, 1), &target,
CastMode::RowUpdate).unwrap();
+ let map = result.as_any().downcast_ref::<MapArray>().unwrap();
+ assert_eq!(map.keys().to_data(),
StringArray::from(vec!["a"]).to_data());
+ assert_eq!(map.values().to_data(),
Int32Array::from(vec![1]).to_data());
+
+ let nested: ArrayRef = Arc::new(StructArray::from(vec![(
+ Arc::new(Field::new("m", input.data_type().clone(), true)),
+ input,
+ )]));
+ let nested_target = DataType::Struct(vec![Field::new("m", target,
true)].into());
+ let output = cast_update_value(&nested, &nested_target,
CastMode::RowUpdate).unwrap();
+ let map = output
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap()
+ .column(0)
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .unwrap();
+ assert_eq!(
+ map.values().to_data(),
+ Int32Array::from(vec![Some(1), None]).to_data()
+ );
+ assert!(map.is_null(2));
+ }
+
+ #[test]
+ fn map_to_row_constructor_checks_prefix_and_ignores_unused_entries() {
+ use arrow_schema::Field;
+ let entries = StructArray::from(vec![
+ (
+ Arc::new(Field::new("key", DataType::Utf8, false)),
+ Arc::new(StringArray::from(vec![
+ "a", "b", "ignored", "a", "wrong", "b",
+ ])) as ArrayRef,
+ ),
+ (
+ Arc::new(Field::new("value", DataType::Float64, true)),
+ Arc::new(Float64Array::from(vec![1.5, 2.5, f64::NAN, 3.5, 5.0,
6.0])) as ArrayRef,
+ ),
+ ]);
+ let input: ArrayRef = Arc::new(MapArray::new(
+ Arc::new(Field::new("entries", entries.data_type().clone(),
false)),
+ OffsetBuffer::new(vec![0_i32, 3, 4, 6].into()),
+ entries,
+ None,
+ false,
+ ));
+ let target = DataType::Struct(
+ vec![
+ Field::new("a", DataType::Int32, true),
+ Field::new("b", DataType::Int32, true),
+ ]
+ .into(),
+ );
+ for key_type in [
+ DataType::Utf8,
+ DataType::LargeUtf8,
+ DataType::Utf8View,
+ DataType::Binary,
+ DataType::LargeBinary,
+ DataType::BinaryView,
+ ] {
+ let map = input.as_map();
+ let keys = arrow_cast::cast(map.keys().as_ref(),
&key_type).unwrap();
+ let entries = StructArray::from(vec![
+ (Arc::new(Field::new("key", key_type, false)), keys),
+ (
+ Arc::new(Field::new("value", DataType::Float64, true)),
+ map.values().clone(),
+ ),
+ ]);
+ let input: ArrayRef = Arc::new(MapArray::new(
+ Arc::new(Field::new("entries", entries.data_type().clone(),
false)),
+ map.offsets().clone(),
+ entries,
+ None,
+ false,
+ ));
+ assert!(cast_assignment(&input.slice(0, 2), &target).is_err());
+ assert!(cast_update_value(&input, &target,
CastMode::RowUpdate).is_err());
+ let output =
+ cast_update_value(&input.slice(0, 2), &target,
CastMode::RowUpdate).unwrap();
+ let row = output.as_struct();
+ assert_eq!(
+ row.column(0).to_data(),
+ Int32Array::from(vec![1, 3]).to_data()
+ );
+ assert_eq!(
+ row.column(1).to_data(),
+ Int32Array::from(vec![Some(2), None]).to_data()
+ );
+ }
+ }
+
+ #[test]
+ fn list_pairs_constructor_is_top_level_only_and_preserves_slices() {
+ use arrow_array::builder::{Float64Builder, ListBuilder};
+ use arrow_schema::Field;
+ let mut builder =
ListBuilder::new(ListBuilder::new(Float64Builder::new()));
+ for row in [
+ Some(vec![vec![Some(999.0)]]),
+ Some(vec![vec![Some(1.9), Some(10.9)], vec![Some(2.0), None]]),
+ None,
+ Some(vec![]),
+ ] {
+ if let Some(row) = row {
+ for pair in row {
+ for value in pair {
+ builder.values().values().append_option(value);
+ }
+ builder.values().append(true);
+ }
+ builder.append(true);
+ } else {
+ builder.append(false);
+ }
+ }
+ let all: ArrayRef = Arc::new(builder.finish());
+ let input = all.slice(1, 3);
+ let target = test_map_type(DataType::Int32, DataType::Int32);
+ assert!(cast_update_value(&all, &target,
CastMode::RowUpdate).is_err());
+ let output = cast_update_value(&input, &target,
CastMode::RowUpdate).unwrap();
+ let map = output.as_any().downcast_ref::<MapArray>().unwrap();
+ assert_eq!(map.keys().to_data(), Int32Array::from(vec![1,
2]).to_data());
+ assert_eq!(
+ map.values().to_data(),
+ Int32Array::from(vec![Some(10), None]).to_data()
+ );
+ assert!(map.is_null(1));
+ assert_eq!(map.value_length(2), 0);
+ let DataType::List(field) = input.data_type() else {
+ unreachable!()
+ };
+ for layout in [
+ DataType::ListView(field.clone()),
+ DataType::LargeListView(field.clone()),
+ ] {
+ let view = arrow_cast::cast(input.as_ref(), &layout).unwrap();
+ assert_eq!(
+ cast_update_value(&view, &target, CastMode::RowUpdate)
+ .unwrap()
+ .to_data(),
+ output.to_data()
+ );
+ }
+ let nested: ArrayRef = Arc::new(StructArray::from(vec![(
+ Arc::new(Field::new("m", input.data_type().clone(), true)),
+ input,
+ )]));
+ let nested_target = DataType::Struct(vec![Field::new("m", target,
true)].into());
+ assert!(cast_update_value(&nested, &nested_target,
CastMode::RowUpdate).is_err());
+ }
+
+ #[test]
+ fn struct_cast_order_and_nullability_select_whole_column_constructor() {
+ use arrow_schema::Field;
+ let input: ArrayRef = Arc::new(StructArray::from(vec![
+ (
+ Arc::new(Field::new("a", DataType::Int32, true)),
+ Arc::new(Int32Array::from(vec![2])) as ArrayRef,
+ ),
+ (
+ Arc::new(Field::new("b", DataType::Int32, true)),
+ Arc::new(Int32Array::from(vec![1])) as ArrayRef,
+ ),
+ ]));
+ let reversed = DataType::Struct(
+ vec![
+ Field::new("b", DataType::Utf8, true),
+ Field::new("a", DataType::Int32, true),
+ ]
+ .into(),
+ );
+ assert!(cast_assignment(&input, &reversed).is_err());
+ // The reordered constructor cannot inherit the safe cast's
int->string conversion.
+ assert!(cast_update_value(&input, &reversed,
CastMode::RowUpdate).is_err());
+ let required = DataType::Struct(
+ vec![
+ Field::new("a", DataType::Int32, false),
+ Field::new("b", DataType::Int32, true),
+ ]
+ .into(),
+ );
+ assert!(cast_assignment(&input, &required).is_err());
+ assert!(cast_update_value(&input, &required,
CastMode::RowUpdate).is_ok());
+ }
+ #[test]
+ fn dict_map_ambiguity_checks_dictionary_logical_nulls() {
+ use arrow_array::{types::Int32Type, DictionaryArray};
+ use arrow_schema::Field;
+ let child: ArrayRef = Arc::new(
+ DictionaryArray::<Int32Type>::try_new(
+ Int32Array::from(vec![0, 1]),
+ Arc::new(Int32Array::from(vec![Some(1), None])),
+ )
+ .unwrap(),
+ );
+ let input: ArrayRef = Arc::new(StructArray::from(vec![(
+ Arc::new(Field::new("a", child.data_type().clone(), true)),
+ child,
+ )]));
+ let target = test_map_type(DataType::Utf8, DataType::Int32);
+ assert!(cast_update_value(&input, &target,
CastMode::RowUpdate).is_err());
+ let output = cast_update_value(&input.slice(0, 1), &target,
CastMode::RowUpdate).unwrap();
+ assert_eq!(
+ output.as_map().values().to_data(),
+ Int32Array::from(vec![1]).to_data()
+ );
+ }
}
diff --git a/crates/paimon/src/table/upsert_key_matcher.rs
b/crates/paimon/src/table/upsert_key_matcher.rs
index c17de65a..e5257dc4 100644
--- a/crates/paimon/src/table/upsert_key_matcher.rs
+++ b/crates/paimon/src/table/upsert_key_matcher.rs
@@ -63,6 +63,9 @@ pub(super) fn supported_key_type(data_type: &DataType) ->
bool {
| DataType::FixedSizeBinary(_)
| DataType::Date32
| DataType::Date64
+ | DataType::Time32(_)
+ | DataType::Time64(_)
+ | DataType::Timestamp(_, _)
| DataType::Decimal128(_, _)
| DataType::Decimal256(_, _)
)
@@ -243,4 +246,62 @@ mod tests {
.to_string()
.contains("upsert key type differs"));
}
+ #[test]
+ fn temporal_keys_keep_units_nulls_and_composite_identity() {
+ use arrow_schema::TimeUnit;
+ for data_type in [
+ DataType::Time32(TimeUnit::Millisecond),
+ DataType::Timestamp(TimeUnit::Millisecond, None),
+ DataType::Timestamp(TimeUnit::Microsecond, None),
+ DataType::Timestamp(TimeUnit::Nanosecond, None),
+ DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
+ DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
+ ] {
+ let temporal = |values: Vec<Option<i64>>| {
+ let array: ArrayRef = if matches!(data_type,
DataType::Time32(_)) {
+ Arc::new(Int32Array::from_iter(
+ values.into_iter().map(|v| v.map(|v| v as i32)),
+ ))
+ } else {
+ Arc::new(Int64Array::from(values))
+ };
+ arrow_cast::cast(array.as_ref(), &data_type).unwrap()
+ };
+ let source = RecordBatch::try_from_iter([
+ (
+ "key",
+ temporal(vec![Some(9), None, Some(10), Some(12), Some(10),
Some(10)]),
+ ),
+ (
+ "part",
+ Arc::new(StringArray::from(vec!["a", "a", "a", "a", "a",
"b"])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let existing = RecordBatch::try_from_iter([
+ (
+ "key",
+ temporal(vec![Some(10), Some(10), None, Some(9), Some(11),
Some(10)]),
+ ),
+ (
+ "part",
+ Arc::new(StringArray::from(vec!["a", "a", "a", "a", "a",
"b"])) as ArrayRef,
+ ),
+ (
+ ROW_ID,
+ Arc::new(Int64Array::from(vec![10, 11, 12, 13, 14, 15]))
as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let mut matcher =
+ UpsertKeyMatcher::new(&source, vec!["key".into(),
"part".into()]).unwrap();
+ matcher.add_existing_batch(&existing.slice(0, 2)).unwrap();
+ matcher.add_existing_batch(&existing.slice(2, 4)).unwrap();
+ assert_eq!(
+ matcher.finish(),
+ (vec![0, 1, 4, 4, 5], vec![13, 12, 10, 11, 15], vec![3]),
+ "{data_type:?}"
+ );
+ }
+ }
}
diff --git a/crates/paimon/src/table/write_batch_normalize.rs
b/crates/paimon/src/table/write_batch_normalize.rs
index 01529d9b..dcd0271a 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -86,13 +86,21 @@ pub(super) fn normalize_write_array(array: &ArrayRef,
expected: &DataType) -> Re
}
(DataType::Map(_, _), DataType::Map(expected_entries,
expected_ordered)) => {
let map = array.as_any().downcast_ref::<MapArray>().unwrap();
- let entries: ArrayRef = Arc::new(map.entries().clone());
- let entries = normalize_write_array(&entries,
expected_entries.data_type())?;
- let entries = entries
- .as_any()
- .downcast_ref::<StructArray>()
- .unwrap()
- .clone();
+ // MAP key/value roles are positional. Their Arrow field names are
+ // aliases, unlike user-defined ROW field names.
+ let DataType::Struct(fields) = expected_entries.data_type() else {
+ return Err(Error::DataInvalid {
+ message: "MAP entries must have a struct type".into(),
+ source: None,
+ });
+ };
+ let columns = [map.keys(), map.values()]
+ .into_iter()
+ .zip(fields)
+ .map(|(column, field)| normalize_write_array(column,
field.data_type()))
+ .collect::<Result<Vec<_>>>()?;
+ let entries =
+ StructArray::try_new(fields.clone(), columns,
None).map_err(normalization_error)?;
let normalized = MapArray::try_new(
expected_entries.clone(),
map.offsets().clone(),
@@ -193,9 +201,9 @@ mod tests {
#[test]
fn map_value_list_alias_is_normalized_recursively() {
- let key_field = Arc::new(Field::new("key", DataType::Int32, false));
+ let key_field = Arc::new(Field::new("source_key", DataType::Int32,
false));
let value_field = Arc::new(Field::new(
- "value",
+ "source_value",
DataType::List(Arc::new(Field::new("item", DataType::Int32,
true))),
true,
));
@@ -229,7 +237,13 @@ mod tests {
));
let expected_entries = Arc::new(Field::new(
"entries",
- DataType::Struct(vec![key_field, expected_value].into()),
+ DataType::Struct(
+ vec![
+ Arc::new(Field::new("key", DataType::Int32, false)),
+ expected_value,
+ ]
+ .into(),
+ ),
false,
));
let expected = DataType::Map(expected_entries, false);
diff --git a/crates/paimon/tests/table_update_nested_test.rs
b/crates/paimon/tests/table_update_nested_test.rs
new file mode 100644
index 00000000..8bf358b4
--- /dev/null
+++ b/crates/paimon/tests/table_update_nested_test.rs
@@ -0,0 +1,439 @@
+// 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::builder::{Int32Builder, ListBuilder};
+use arrow_array::{Array, ArrayRef, Int32Array, ListArray, RecordBatch,
StringArray};
+use arrow_schema::{DataType as ArrowType, Field};
+use futures::TryStreamExt;
+use paimon::spec::{ArrayType, DataType, IntType, Schema, TableSchema};
+use paimon::table::Table;
+
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs, write_batch};
+
+async fn table() -> Table {
+ let path = "memory:/nested_upsert";
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "value",
+ DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ )
+ .column("tag", DataType::Int(IntType::new()))
+ .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;
+ table
+}
+
+fn batch(ids: Vec<i32>, values: Vec<Option<Vec<Option<i32>>>>, tags: Vec<i32>)
-> RecordBatch {
+ let mut builder =
ListBuilder::new(Int32Builder::new()).with_field(Arc::new(Field::new(
+ "item",
+ ArrowType::Int32,
+ true,
+ )));
+ for value in values {
+ if let Some(values) = value {
+ for value in values {
+ builder.values().append_option(value);
+ }
+ builder.append(true);
+ } else {
+ builder.append(false);
+ }
+ }
+ // The upsert source may reorder top-level fields and use Arrow child
aliases.
+ RecordBatch::try_from_iter([
+ ("tag", Arc::new(Int32Array::from(tags)) as ArrayRef),
+ ("value", Arc::new(builder.finish()) as ArrayRef),
+ ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+ ])
+ .unwrap()
+}
+
+#[tokio::test]
+async fn nested_upsert_normalizes_before_matching_and_appending() {
+ let table = table().await;
+ let seed = batch(vec![1, 1, 2], vec![None, None, None], vec![10, 11, 12]);
+ write_batch(&table, &seed.project(&[2, 1, 0]).unwrap()).await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let source = batch(
+ vec![0, 1, 1, 3],
+ vec![
+ Some(vec![Some(999)]),
+ Some(vec![Some(99)]),
+ Some(vec![Some(10), None]),
+ Some(vec![]),
+ ],
+ vec![20, 21, 22, 23],
+ );
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![source.slice(1, 1), source.slice(2, 2)],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ let read = table.new_read_builder();
+ let plan = read.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let tags = batch
+ .column_by_name("tag")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .unwrap();
+ for i in 0..batch.num_rows() {
+ let value = (!values.is_null(i)).then(|| {
+ values
+ .value(i)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>()
+ });
+ rows.push((ids.value(i), tags.value(i), value));
+ }
+ }
+ rows.sort();
+ assert_eq!(
+ rows,
+ vec![
+ (1, 10, Some(vec![Some(10), None])),
+ (1, 11, Some(vec![Some(10), None])),
+ (2, 12, None),
+ (3, 23, Some(vec![]))
+ ]
+ );
+}
+
+#[tokio::test]
+async fn invalid_nested_upsert_batch_creates_no_staged_files() {
+ let table = table().await;
+ let seed = batch(vec![1], vec![None], vec![10]);
+ write_batch(&table, &seed.project(&[2, 1, 0]).unwrap()).await;
+ let paths = || async {
+ let mut files: Vec<_> = table
+ .file_io()
+ .list_status_recursive(table.location())
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|status| status.path)
+ .filter(|path| path.ends_with(".parquet"))
+ .collect();
+ files.sort();
+ files
+ };
+ let before = paths().await;
+ let invalid = RecordBatch::try_from_iter([
+ ("tag", Arc::new(Int32Array::from(vec![20])) as ArrayRef),
+ (
+ "value",
+ Arc::new(StringArray::from(vec!["not an array"])) as ArrayRef,
+ ),
+ ("id", Arc::new(Int32Array::from(vec![2])) as ArrayRef),
+ ])
+ .unwrap();
+ let update = table.new_write_builder().new_update().unwrap();
+ assert!(update
+ .upsert_by_arrow_with_key(vec![seed.clone(), invalid],
vec!["id".into()])
+ .await
+ .is_err());
+ assert_eq!(paths().await, before);
+ let messages = update
+ .upsert_by_arrow_with_key(vec![seed], vec!["id".into()])
+ .await
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn temporal_upsert_keys_match_at_declared_precision() {
+ use arrow_array::Int64Array;
+ use paimon::spec::{LocalZonedTimestampType, TimeType, TimestampType};
+ for key_type in [
+ DataType::Time(TimeType::new(3).unwrap()),
+ DataType::Timestamp(TimestampType::new(3).unwrap()),
+ DataType::Timestamp(TimestampType::new(6).unwrap()),
+ DataType::Timestamp(TimestampType::new(9).unwrap()),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap()),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(6).unwrap()),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(9).unwrap()),
+ ] {
+ let start = if matches!(key_type, DataType::Time(_)) {
+ 10
+ } else {
+ -10
+ };
+ let schema = Schema::builder()
+ .column("key", key_type.clone())
+ .column("value", DataType::Int(IntType::new()))
+ .column("tag", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let path = "memory:/temporal_upsert";
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ let arrow_schema =
+
paimon::arrow::build_target_arrow_schema(table.schema().fields()).unwrap();
+ let batch = |keys: Vec<Option<i64>>, values: Vec<i32>, tags: Vec<i32>|
{
+ let physical: ArrayRef = if matches!(key_type, DataType::Time(_)) {
+ Arc::new(Int32Array::from_iter(
+ keys.into_iter().map(|v| v.map(|v| v as i32)),
+ ))
+ } else {
+ Arc::new(Int64Array::from(keys))
+ };
+ RecordBatch::try_new(
+ arrow_schema.clone(),
+ vec![
+ arrow_cast::cast(physical.as_ref(),
arrow_schema.field(0).data_type()).unwrap(),
+ Arc::new(Int32Array::from(values)),
+ Arc::new(Int32Array::from(tags)),
+ ],
+ )
+ .unwrap()
+ };
+ write_batch(
+ &table,
+ &batch(
+ vec![Some(start), Some(start), None, Some(start + 1)],
+ vec![10; 4],
+ vec![0, 1, 2, 3],
+ ),
+ )
+ .await;
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let source = batch(
+ vec![None, Some(start), Some(start + 2), Some(start)],
+ vec![20, 30, 40, 50],
+ vec![99, 99, 4, 99],
+ );
+ let messages = update
+ .upsert_by_arrow_with_key(
+ vec![source.slice(0, 2), source.slice(2, 2)],
+ vec!["key".into()],
+ )
+ .await
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ let read = table.new_read_builder();
+ let plan = read.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut actual = Vec::new();
+ for batch in batches {
+ let keys = arrow_cast::cast(batch.column(0).as_ref(),
&ArrowType::Int64).unwrap();
+ let keys = keys.as_any().downcast_ref::<Int64Array>().unwrap();
+ let values = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let tags = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ actual.push((
+ tags.value(row),
+ (!keys.is_null(row)).then(|| keys.value(row)),
+ values.value(row),
+ ));
+ }
+ }
+ actual.sort();
+ assert_eq!(
+ actual,
+ vec![
+ (0, Some(start), 50),
+ (1, Some(start), 50),
+ (2, None, 20),
+ (3, Some(start + 1), 10),
+ (4, Some(start + 2), 40)
+ ],
+ "{key_type:?}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn upsert_does_not_read_unrelated_composite_partitions() {
+ use paimon::spec::VarCharType;
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("pt", DataType::VarChar(VarCharType::new(20).unwrap()))
+ .column("sub", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .partition_keys(["pt", "sub"])
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let path = "memory:/partition_upsert";
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ let arrow_schema =
paimon::arrow::build_target_arrow_schema(table.schema().fields()).unwrap();
+ let batch = |pt: Vec<Option<&str>>, sub: Vec<i32>, values: Vec<i32>| {
+ RecordBatch::try_new(
+ arrow_schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1; pt.len()])),
+ Arc::new(StringArray::from(pt)),
+ Arc::new(Int32Array::from(sub)),
+ Arc::new(Int32Array::from(values)),
+ ],
+ )
+ .unwrap()
+ };
+ write_batch(
+ &table,
+ &batch(
+ vec![Some("a"), Some("a"), Some("b"), None, None],
+ vec![1, 2, 1, 1, 2],
+ vec![10; 5],
+ ),
+ )
+ .await;
+ let scan = table.new_read_builder().new_scan().plan().await.unwrap();
+ let relevant = |path: &str| {
+ path.contains("/pt=a/sub=1/")
+ || path.contains("/pt=b/sub=2/")
+ || path.contains("/pt=__DEFAULT_PARTITION__/sub=1/")
+ };
+ let mut removed = 0;
+ for split in scan.splits() {
+ if !relevant(split.bucket_path()) {
+ for file in split.data_files() {
+ io.delete_file(&format!("{}/{}", split.bucket_path(),
file.file_name))
+ .await
+ .unwrap();
+ removed += 1;
+ }
+ }
+ }
+ assert_eq!(removed, 3);
+ // Implicit partition matching must use exact tuples, including NULL. A
+ // Cartesian product would still read a/sub=2, b/sub=1 and NULL/sub=2.
+ let source = batch(
+ vec![Some("a"), Some("b"), None, Some("a")],
+ vec![1, 2, 1, 1],
+ vec![15, 30, 40, 20],
+ );
+ 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![source], vec!["id".into()])
+ .await
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ let read = table.new_read_builder();
+ let plan = read.new_scan().plan().await.unwrap();
+ let splits: Vec<_> = plan
+ .splits()
+ .iter()
+ .filter(|split| relevant(split.bucket_path()))
+ .cloned()
+ .collect();
+ let batches: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(&splits)
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut values: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ values.sort();
+ assert_eq!(values, vec![20, 30, 40]);
+}