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 b72adaee [core] Support logical Arrow keys and selected upsert values
(#1147)
b72adaee is described below
commit b72adaeeecea9ae946082c8a218afcdf3fb9bb51
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 21:19:10 2026 +0800
[core] Support logical Arrow keys and selected upsert values (#1147)
---
crates/paimon/src/table/key_normalization.rs | 222 ++++++++++++
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/table_merge_into.rs | 22 +-
crates/paimon/src/table/table_merge_into_tests.rs | 145 ++++++++
crates/paimon/src/table/table_update.rs | 2 +
crates/paimon/src/table/table_upsert.rs | 401 ++++++++++++++++++++--
crates/paimon/src/table/update_input.rs | 23 +-
crates/paimon/src/table/write_batch_normalize.rs | 173 +++++++++-
8 files changed, 957 insertions(+), 32 deletions(-)
diff --git a/crates/paimon/src/table/key_normalization.rs
b/crates/paimon/src/table/key_normalization.rs
new file mode 100644
index 00000000..3ea04dc5
--- /dev/null
+++ b/crates/paimon/src/table/key_normalization.rs
@@ -0,0 +1,222 @@
+// 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.
+
+//! Logical keys used by row-level matching, independent of Arrow encoding.
+
+use arrow_array::{new_null_array, ArrayRef};
+use arrow_schema::DataType;
+
+use super::update_input::{cast_update_value, decode_dictionary, CastMode};
+use super::write_batch_normalize::normalize_write_array;
+
+/// Unify Arrow representations without changing key identity. Dictionary
+/// indices are batch-local; integer widths, decimal scales and temporal units
+/// may differ only when conversion is exact. Callers validate the logical type
+/// families and decide whether NULL keys match. Payload write rules are
separate.
+pub(super) fn normalize_match_key(
+ array: &ArrayRef,
+ expected: &DataType,
+) -> crate::Result<ArrayRef> {
+ let decoded = decode_dictionary(array)?;
+ if decoded.data_type() == &DataType::Null {
+ return Ok(new_null_array(expected, decoded.len()));
+ }
+ let source = decoded.data_type();
+ if source.is_decimal() && matches!(expected, DataType::Decimal128(..)) {
+ let source_scale = match source {
+ DataType::Decimal32(_, scale)
+ | DataType::Decimal64(_, scale)
+ | DataType::Decimal128(_, scale)
+ | DataType::Decimal256(_, scale) => *scale,
+ _ => unreachable!(),
+ };
+ let DataType::Decimal128(_, target_scale) = expected else {
+ unreachable!()
+ };
+ let difference = i16::from(*target_scale) - i16::from(source_scale);
+ let text_fallback = || {
+ // Arrow's decimal rescaler can reject a large scale difference
+ // before inspecting values, even for zero and NULL. Reuse the
+ // exact text parser as a fallback; it still rejects nonzero values
+ // that exceed precision or lose fractional digits.
+ let text = cast_update_value(&decoded, &DataType::Utf8,
CastMode::Assignment)?;
+ cast_update_value(&text, expected, CastMode::Assignment)
+ };
+ // Arrow computes scale differences in i8 and uses a Decimal128 power
+ // table. Skip that kernel before either can overflow.
+ return if !(-38..=38).contains(&difference) {
+ text_fallback()
+ } else {
+ cast_update_value(&decoded, expected,
CastMode::Assignment).or_else(|_| text_fallback())
+ };
+ }
+ let exact_family = source.is_integer() && expected.is_integer()
+ || source.is_decimal() && expected.is_decimal()
+ || matches!(
+ (source, expected),
+ (
+ DataType::Date32 | DataType::Date64,
+ DataType::Date32 | DataType::Date64
+ ) | (
+ DataType::Time32(_) | DataType::Time64(_),
+ DataType::Time32(_) | DataType::Time64(_)
+ )
+ )
+ || matches!((source, expected), (DataType::Timestamp(_, from),
DataType::Timestamp(_, to))
+ if from.is_some() == to.is_some());
+ if exact_family {
+ cast_update_value(&decoded, expected, CastMode::Assignment)
+ } else {
+ normalize_write_array(&decoded, expected)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::types::Int8Type;
+ use arrow_array::{
+ Array, Decimal128Array, DictionaryArray, Int32Array, Int64Array,
Int8Array, StringArray,
+ TimestampMicrosecondArray, TimestampMillisecondArray,
+ };
+ use arrow_schema::TimeUnit;
+ use std::sync::Arc;
+
+ #[test]
+ fn dictionary_keys_use_value_validity_as_well_as_index_validity() {
+ let input: ArrayRef = Arc::new(
+ DictionaryArray::<Int8Type>::try_new(
+ Int8Array::from(vec![Some(0), None, Some(1), Some(0)]),
+ Arc::new(StringArray::from(vec![None, Some("key")])),
+ )
+ .unwrap(),
+ );
+ let output = normalize_match_key(&input, &DataType::Utf8).unwrap();
+ assert_eq!(
+ output.as_any().downcast_ref::<StringArray>().unwrap(),
+ &StringArray::from(vec![None, None, Some("key"), None])
+ );
+ }
+
+ #[test]
+ fn integer_keys_are_exact_and_keep_nulls() {
+ let input: ArrayRef = Arc::new(Int64Array::from(vec![
+ Some(-1),
+ None,
+ Some(i32::MAX as i64),
+ ]));
+ let output = normalize_match_key(&input, &DataType::Int32).unwrap();
+ assert_eq!(
+ output.as_any().downcast_ref::<Int32Array>().unwrap(),
+ &Int32Array::from(vec![Some(-1), None, Some(i32::MAX)])
+ );
+ let overflow: ArrayRef = Arc::new(Int64Array::from(vec![i32::MAX as
i64 + 1]));
+ assert!(normalize_match_key(&overflow, &DataType::Int32).is_err());
+ let text: ArrayRef = Arc::new(StringArray::from(vec!["42"]));
+ assert!(normalize_match_key(&text, &DataType::Int32).is_err());
+ }
+
+ #[test]
+ fn decimal_keys_cannot_round_into_existing_keys() {
+ let array = |value| {
+ Arc::new(
+ Decimal128Array::from(vec![value])
+ .with_precision_and_scale(15, 4)
+ .unwrap(),
+ ) as ArrayRef
+ };
+ let expected = DataType::Decimal128(10, 2);
+ let output = normalize_match_key(&array(12500), &expected).unwrap();
+ assert_eq!(
+ output
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .unwrap()
+ .value(0),
+ 125
+ );
+ assert!(normalize_match_key(&array(12501), &expected).is_err());
+ }
+
+ #[test]
+ fn decimal_zero_and_null_keys_rescale_across_large_scale_differences() {
+ for (from, to) in [(-2, 38), (38, -2), (-90, 38), (-128, 38), (38,
-128)] {
+ let input: ArrayRef = Arc::new(
+ Decimal128Array::from(vec![Some(0), None])
+ .with_precision_and_scale(38, from)
+ .unwrap(),
+ );
+ let expected = DataType::Decimal128(38, to);
+ let output = normalize_match_key(&input, &expected).unwrap();
+ assert_eq!(output.data_type(), &expected);
+ assert_eq!(
+ output.as_any().downcast_ref::<Decimal128Array>().unwrap(),
+ &Decimal128Array::from(vec![Some(0), None])
+ .with_precision_and_scale(38, to)
+ .unwrap()
+ );
+ let nonzero: ArrayRef = Arc::new(
+ Decimal128Array::from(vec![1])
+ .with_precision_and_scale(38, from)
+ .unwrap(),
+ );
+ assert!(normalize_match_key(&nonzero, &expected).is_err());
+ }
+ }
+
+ #[test]
+ fn temporal_keys_require_lossless_units_and_preserve_instant() {
+ let input: ArrayRef = Arc::new(TimestampMicrosecondArray::from(vec![
+ Some(-1000),
+ None,
+ Some(2000),
+ ]));
+ let output =
+ normalize_match_key(&input,
&DataType::Timestamp(TimeUnit::Millisecond, None)).unwrap();
+ assert_eq!(
+ output
+ .as_any()
+ .downcast_ref::<TimestampMillisecondArray>()
+ .unwrap(),
+ &TimestampMillisecondArray::from(vec![Some(-1), None, Some(2)])
+ );
+ let fractional: ArrayRef =
Arc::new(TimestampMicrosecondArray::from(vec![-1001]));
+ assert!(normalize_match_key(
+ &fractional,
+ &DataType::Timestamp(TimeUnit::Millisecond, None)
+ )
+ .is_err());
+ let local: ArrayRef =
+
Arc::new(TimestampMicrosecondArray::from(vec![0]).with_timezone("America/New_York"));
+ let output = normalize_match_key(
+ &local,
+ &DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())),
+ )
+ .unwrap();
+ assert_eq!(
+ output
+ .as_any()
+ .downcast_ref::<TimestampMillisecondArray>()
+ .unwrap()
+ .value(0),
+ 0
+ );
+ assert!(
+ normalize_match_key(&local,
&DataType::Timestamp(TimeUnit::Microsecond, None)).is_err()
+ );
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 33382b11..4b0b155d 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -133,6 +133,7 @@ mod query_auth;
mod system_table;
pub use system_table::SystemRead;
+mod key_normalization;
mod read_builder;
mod read_limit;
pub mod referenced_files;
diff --git a/crates/paimon/src/table/table_merge_into.rs
b/crates/paimon/src/table/table_merge_into.rs
index 0004e3b7..00652277 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -427,14 +427,18 @@ pub(super) async fn merge_into(
.map_err(|error| invalid(error.to_string()))?
.data_type()
};
- if source_type != target_type
+ let mut logical_source_type = source_type;
+ while let DataType::Dictionary(_, value) = logical_source_type
{
+ logical_source_type = value;
+ }
+ if logical_source_type != target_type
||
!super::upsert_key_matcher::supported_key_type(target_type)
{
return Err(invalid(
"MERGE ON key types must match and support row
encoding",
));
}
- Ok(SortField::new(source_type.clone()))
+ Ok(SortField::new(target_type.clone()))
})
.collect::<crate::Result<_>>()?;
let key_converter =
@@ -442,8 +446,18 @@ pub(super) async fn merge_into(
for (chunk, batch) in source.batches.iter().enumerate() {
let keys = on
.iter()
- .map(|(_, name)| batch.column_by_name(name).unwrap().clone())
- .collect::<Vec<_>>();
+ .map(|(target, name)| {
+ let expected = if target == ROW_ID {
+ &DataType::Int64
+ } else {
+ schema.field_with_name(target).unwrap().data_type()
+ };
+ super::key_normalization::normalize_match_key(
+ batch.column_by_name(name).unwrap(),
+ expected,
+ )
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
let rows = key_converter
.convert_columns(&keys)
.map_err(|error| invalid(error.to_string()))?;
diff --git a/crates/paimon/src/table/table_merge_into_tests.rs
b/crates/paimon/src/table/table_merge_into_tests.rs
index b20c4753..2c520d21 100644
--- a/crates/paimon/src/table/table_merge_into_tests.rs
+++ b/crates/paimon/src/table/table_merge_into_tests.rs
@@ -107,6 +107,151 @@ fn update(condition: Option<MergeCondition>) ->
WhenMatched {
}
}
+fn dictionary_batch(ids: Vec<Option<i32>>, values: Vec<Option<i32>>) ->
RecordBatch {
+ let keys = arrow_cast::cast(
+ &Int32Array::from(ids),
+ &arrow_schema::DataType::Dictionary(
+ Box::new(arrow_schema::DataType::Int8),
+ Box::new(arrow_schema::DataType::Int32),
+ ),
+ )
+ .unwrap();
+ let schema = arrow_schema::Schema::new(vec![
+ arrow_schema::Field::new("id", keys.data_type().clone(), true),
+ arrow_schema::Field::new("value", arrow_schema::DataType::Int32, true),
+ ]);
+ RecordBatch::try_new(
+ Arc::new(schema),
+ vec![keys, Arc::new(Int32Array::from(values))],
+ )
+ .unwrap()
+}
+
+#[tokio::test]
+async fn dictionary_merge_keys_use_values_and_sql_null_semantics() {
+ let target = table_at("dictionary_merge");
+ seed(
+ &target,
+ &batch(
+ vec![Some(1), Some(1), Some(2), None],
+ vec![Some(10), Some(11), Some(20), Some(90)],
+ ),
+ )
+ .await;
+ // Every non-null source key has dictionary index zero in its own chunk.
+ let source = vec![
+ dictionary_batch(vec![Some(1)], vec![Some(42)]),
+ dictionary_batch(vec![Some(3)], vec![Some(30)]),
+ dictionary_batch(vec![None], vec![Some(91)]),
+ ];
+ let messages = target
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .merge_into(
+ MergeSource::Batches(source),
+ vec![("id".into(), "id".into())],
+ vec![update(None)],
+ vec![WhenNotMatched {
+ condition: None,
+ assignments: vec![
+ ("id".into(), MergeAssignment::SourceColumn("id".into())),
+ (
+ "value".into(),
+ MergeAssignment::SourceColumn("value".into()),
+ ),
+ ],
+ }],
+ )
+ .await
+ .unwrap();
+ TableCommit::new(target.clone(), "merge".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(
+ rows(&target).await,
+ vec![
+ (None, Some(90)),
+ (None, Some(91)),
+ (Some(1), Some(42)),
+ (Some(1), Some(42)),
+ (Some(2), Some(20)),
+ (Some(3), Some(30))
+ ]
+ );
+}
+
+#[tokio::test]
+async fn dictionary_merge_cardinality_is_checked_before_conditions() {
+ let target = table_at("dictionary_cardinality");
+ seed(&target, &batch(vec![Some(1)], vec![Some(10)])).await;
+ let source = vec![
+ dictionary_batch(vec![Some(1)], vec![Some(11)]),
+ dictionary_batch(vec![Some(3), Some(1)], vec![Some(30), Some(12)]),
+ ];
+ let calls = Arc::new(AtomicUsize::new(0));
+ let observed = calls.clone();
+ let condition = MergeCondition {
+ target_columns: Vec::new(),
+ source_columns: Vec::new(),
+ evaluate: Arc::new(move |batch| {
+ observed.fetch_add(1, Ordering::Relaxed);
+ Box::pin(async move { Ok(BooleanArray::from(vec![false;
batch.num_rows()])) })
+ }),
+ };
+ let error = target
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .merge_into(
+ MergeSource::Batches(source),
+ vec![("id".into(), "id".into())],
+ vec![update(Some(condition))],
+ Vec::new(),
+ )
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("multiple source rows"));
+ assert_eq!(calls.load(Ordering::Relaxed), 0);
+ assert_eq!(rows(&target).await, vec![(Some(1), Some(10))]);
+}
+
+#[tokio::test]
+async fn dictionary_merge_duplicate_unconditional_delete_is_allowed() {
+ let target = table_at("dictionary_delete");
+ seed(
+ &target,
+ &batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
+ )
+ .await;
+ let source = vec![
+ dictionary_batch(vec![Some(1)], vec![Some(11)]),
+ dictionary_batch(vec![Some(3), Some(1)], vec![Some(30), Some(12)]),
+ ];
+ let messages = target
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .merge_into(
+ MergeSource::Batches(source),
+ vec![("id".into(), "id".into())],
+ vec![WhenMatched {
+ condition: None,
+ delete: true,
+ assignments: Vec::new(),
+ }],
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ TableCommit::new(target.clone(), "merge".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(rows(&target).await, vec![(Some(2), Some(20))]);
+}
+
#[tokio::test]
async fn core_merge_preserves_row_ids_and_handles_sql_null_keys() {
let table = table();
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index 546dc8a2..fdaa256e 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -243,6 +243,8 @@ 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.
+ /// After last-write-wins, matched values follow row-ID update conversion
+ /// rules; new rows must satisfy the ordinary Arrow write type.
/// 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.
diff --git a/crates/paimon/src/table/table_upsert.rs
b/crates/paimon/src/table/table_upsert.rs
index 8869e527..4b51ff66 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -150,9 +150,9 @@ 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,
- /// but may carry different non-key columns, preserving absent fields.
+ /// differ from the table schema. Keep original payloads, including keys,
+ /// until last-write-wins and matching have selected their action.
+ /// Batches 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();
@@ -170,48 +170,116 @@ impl TableUpsert {
}
let mut columns = Vec::with_capacity(target.fields().len());
let mut fields = Vec::new();
+ for field in target.fields() {
+ let Some(column) = batch.column_by_name(field.name()) else {
+ continue;
+ };
+ columns.push(column.clone());
+
fields.push(input_schema.field_with_name(field.name()).unwrap().clone());
+ }
+ let ordered = RecordBatch::try_new(Arc::new(Schema::new(fields)),
columns)
+ .map_err(|error| invalid(format!("cannot order upsert columns:
{error}")))?;
+ self.source.push(ordered);
+ Ok(())
+ }
+
+ /// Appends use the ordinary write contract; matched values use the row-ID
+ /// updater's conversion rules. Neither action touches discarded source
rows.
+ fn normalize_selected_batch(
+ &self,
+ batch: RecordBatch,
+ append: bool,
+ ) -> crate::Result<RecordBatch> {
+ let target =
crate::arrow::build_target_arrow_schema(self.table.schema().fields())?;
+ let update_fields = super::data_evolution_fields::project_by_paths(
+ self.table.schema().fields(),
+ &self.update_columns,
+ )?;
let inline =
CoreOptions::new(self.table.schema().options()).blob_inline_fields();
+ let mut fields = Vec::new();
+ let mut columns = Vec::new();
for (index, field) in target.fields().iter().enumerate() {
let Some(column) = batch.column_by_name(field.name()) else {
+ // The row-ID updater diagnoses missing update columns. Absent
+ // append columns stay absent in the file's write_cols
metadata.
continue;
};
let logical = &self.table.schema().fields()[index];
- let normalized =
- if logical.data_type().is_blob_file_field() &&
!inline.contains(logical.name()) {
+ let update_field = update_fields
+ .iter()
+ .find(|updated| updated.name() == field.name());
+ let normalized = if !append && update_field.is_none() {
+ Ok((
+ batch
+ .schema()
+ .field_with_name(field.name())
+ .unwrap()
+ .clone(),
+ column.clone(),
+ ))
+ } else if logical.data_type().is_blob_file_field()
+ && !inline.contains(logical.name())
+ && (append || matches!(logical.data_type(),
crate::spec::DataType::Map(_)))
+ {
+ // Named-row Blob maps carry nullable keys and decimal text in
+ // their dedicated physical layout. Keep that transport intact.
+ if append {
normalize_blob_write_field(column, field)
} else {
- normalize_write_array(column, field.data_type())
- .map(|column| (field.as_ref().clone(), column))
- };
+
super::write_batch_normalize::normalize_blob_update_field(column, field)
+ }
+ } else if append {
+ normalize_write_array(column, field.data_type())
+ .map(|column| (field.as_ref().clone(), column))
+ } else {
+ Ok((
+ batch
+ .schema()
+ .field_with_name(field.name())
+ .unwrap()
+ .clone(),
+ column.clone(),
+ ))
+ };
let (field, column) = normalized.map_err(|error| {
invalid(format!("Invalid upsert column '{}': {error}",
field.name()))
})?;
+ fields.push(field);
columns.push(column);
- fields.push(Arc::new(field));
}
- let ordered = RecordBatch::try_new(Arc::new(Schema::new(fields)),
columns)
- .map_err(|error| invalid(format!("cannot order upsert columns:
{error}")))?;
- self.source.push(ordered);
- Ok(())
+ RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
+ .map_err(|error| invalid(format!("cannot normalize selected upsert
rows: {error}")))
}
/// 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 target =
crate::arrow::build_target_arrow_schema(self.table.schema().fields())?;
+ let fields = target
+ .fields()
+ .iter()
+ .filter(|field| self.keys.contains(field.name()))
+ .cloned()
+ .collect::<Vec<_>>();
+ let key_schema = Arc::new(Schema::new(fields));
let batches = self
.source
.iter()
.map(|batch| {
- let columns = batch
- .schema()
+ let columns = key_schema
.fields()
.iter()
- .enumerate()
- .filter(|(_, field)| self.keys.contains(field.name()))
- .map(|(index, _)| index)
- .collect::<Vec<_>>();
- batch
- .project(&columns)
+ .map(|field| {
+ super::key_normalization::normalize_match_key(
+ batch.column_by_name(field.name()).unwrap(),
+ field.data_type(),
+ )
+ .map_err(|error| {
+ invalid(format!("Invalid upsert key '{}':
{error}", field.name()))
+ })
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ RecordBatch::try_new(key_schema.clone(), columns)
.map_err(|error| invalid(error.to_string()))
})
.collect::<crate::Result<Vec<_>>>()?;
@@ -277,6 +345,7 @@ impl TableUpsert {
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 batch = self.normalize_selected_batch(batch, true)?;
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);
@@ -357,6 +426,7 @@ impl TableUpsert {
update.pin_read_snapshot(snapshot_id);
}
for (selected, range) in matched_batches {
+ let selected = self.normalize_selected_batch(selected,
false)?;
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();
@@ -404,7 +474,296 @@ impl TableUpsert {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::catalog::Identifier;
+ use crate::io::FileIOBuilder;
use crate::spec::{DataType as PaimonType, DecimalType, TimestampType,
VarCharType};
+ use crate::spec::{IntType, Schema as PaimonSchema, TableSchema};
+ use crate::table::TableCommit;
+ use arrow_array::{Array, Int32Array, StringArray};
+
+ async fn numeric_table(path: &str) -> Table {
+ let schema = PaimonSchema::builder()
+ .column("key", PaimonType::Int(IntType::new()))
+ .column("value", PaimonType::Int(IntType::new()))
+ .column("keep", PaimonType::Int(IntType::new()))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("db", "upsert"),
+ path.into(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let initial = RecordBatch::try_from_iter([
+ ("key", Arc::new(Int32Array::from(vec![1, 1, 2])) as ArrayRef),
+ ("value", Arc::new(Int32Array::from(vec![0; 3])) as ArrayRef),
+ (
+ "keep",
+ Arc::new(Int32Array::from(vec![10, 11, 12])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let mut write = table.new_write_builder().new_write().unwrap();
+ write.write_arrow_batch(&initial).await.unwrap();
+ TableCommit::new(table.clone(), "seed".into())
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ write.close().await;
+ table
+ }
+
+ fn numeric_source(keys: Vec<i32>, value: ArrayRef) -> RecordBatch {
+ RecordBatch::try_from_iter([
+ ("key", Arc::new(Int32Array::from(keys)) as ArrayRef),
+ ("value", value),
+ ])
+ .unwrap()
+ }
+
+ async fn numeric_rows(table: &Table) -> Vec<(i64, i32, Option<i32>,
Option<i32>)> {
+ let mut builder = table.new_read_builder();
+ builder
+ .with_projection(&["_ROW_ID", "key", "value", "keep"])
+ .unwrap();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = builder
+ .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(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ let keys = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let keep = batch
+ .column(3)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ rows.extend((0..batch.num_rows()).map(|row| {
+ (
+ ids.value(row),
+ keys.value(row),
+ (!values.is_null(row)).then(|| values.value(row)),
+ (!keep.is_null(row)).then(|| keep.value(row)),
+ )
+ }));
+ }
+ rows.sort();
+ rows
+ }
+
+ #[tokio::test]
+ async fn
upsert_casts_surviving_matched_values_and_ignores_other_payloads() {
+ let table = numeric_table("memory:/upsert_selected").await;
+ let first = numeric_source(vec![1],
Arc::new(StringArray::from(vec!["not-an-int"])));
+ let mut last = numeric_source(vec![1],
Arc::new(StringArray::from(vec!["42"])));
+ last = RecordBatch::try_from_iter([
+ ("key", last.column(0).clone()),
+ ("value", last.column(1).clone()),
+ (
+ "keep",
+ Arc::new(StringArray::from(vec!["unwritten"])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ 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![first, last], vec!["key".into()])
+ .await
+ .unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ TableCommit::new(table.clone(), "update".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(
+ numeric_rows(&table).await,
+ vec![
+ (0, 1, Some(42), Some(10)),
+ (1, 1, Some(42), Some(11)),
+ (2, 2, Some(0), Some(12)),
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn upsert_keeps_update_casts_separate_from_append_validation() {
+ let table = numeric_table("memory:/upsert_mixed_types").await;
+ let matched = numeric_source(vec![1],
Arc::new(StringArray::from(vec!["42"])));
+ let append = numeric_source(vec![3],
Arc::new(Int32Array::from(vec![7])));
+ 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![matched, append],
vec!["key".into()])
+ .await
+ .unwrap();
+ TableCommit::new(table.clone(), "update".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(
+ numeric_rows(&table).await,
+ vec![
+ (0, 1, Some(42), Some(10)),
+ (1, 1, Some(42), Some(11)),
+ (2, 2, Some(0), Some(12)),
+ (3, 3, Some(7), None),
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn upsert_rejects_invalid_appends_before_staging_matched_updates() {
+ let table = numeric_table("memory:/upsert_invalid_append").await;
+ let batch = numeric_source(vec![1, 3],
Arc::new(StringArray::from(vec!["42", "7"])));
+ let mut update = table.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let error = update
+ .upsert_by_arrow_with_key(vec![batch], vec!["key".into()])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("Invalid upsert column 'value'"));
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ Some(1)
+ );
+ assert_eq!(
+ numeric_rows(&table).await,
+ vec![
+ (0, 1, Some(0), Some(10)),
+ (1, 1, Some(0), Some(11)),
+ (2, 2, Some(0), Some(12)),
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn upsert_matched_binary_values_use_existing_row_id_casts() {
+ use arrow_array::{BinaryArray, LargeBinaryArray};
+ let schema = PaimonSchema::builder()
+ .column("key", PaimonType::Int(IntType::new()))
+ .column(
+ "value",
+ PaimonType::VarBinary(crate::spec::VarBinaryType::default()),
+ )
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("db", "upsert"),
+ "memory:/upsert_binary".into(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let initial = numeric_source(
+ vec![1],
+ Arc::new(BinaryArray::from(vec![b"old".as_slice()])),
+ );
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer.write_arrow_batch(&initial).await.unwrap();
+ TableCommit::new(table.clone(), "seed".into())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ writer.close().await;
+ let update = table.new_write_builder().new_update().unwrap();
+ let input = numeric_source(
+ vec![1],
+ Arc::new(LargeBinaryArray::from(vec![b"\xff\0new".as_slice()])),
+ );
+ let messages = update
+ .upsert_by_arrow_with_key(vec![input], vec!["key".into()])
+ .await
+ .unwrap();
+ TableCommit::new(table.clone(), "update".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ let mut builder = table.new_read_builder();
+ builder.with_projection(&["value"]).unwrap();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
1);
+ let values = batches[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .unwrap();
+ assert_eq!(values.value(0), b"\xff\0new");
+ }
+
+ #[tokio::test]
+ async fn upsert_matches_integer_key_widths_but_keeps_append_input_types() {
+ let table = numeric_table("memory:/upsert_integer_key_widths").await;
+ let source = |key, value| {
+ RecordBatch::try_from_iter([
+ ("key", Arc::new(Int64Array::from(vec![key])) as ArrayRef),
+ ("value", Arc::new(Int32Array::from(vec![value])) as ArrayRef),
+ ])
+ .unwrap()
+ };
+ 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(1, 42)], vec!["key".into()])
+ .await
+ .unwrap();
+ TableCommit::new(table.clone(), "update".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(
+ numeric_rows(&table).await,
+ vec![
+ (0, 1, Some(42), Some(10)),
+ (1, 1, Some(42), Some(11)),
+ (2, 2, Some(0), Some(12))
+ ]
+ );
+ let error = update
+ .upsert_by_arrow_with_key(vec![source(3, 7)], vec!["key".into()])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("Invalid upsert column 'key'"));
+ assert_eq!(numeric_rows(&table).await.len(), 3);
+ }
#[tokio::test]
async fn upsert_normalizes_string_offsets_before_matching_and_writing() {
diff --git a/crates/paimon/src/table/update_input.rs
b/crates/paimon/src/table/update_input.rs
index d68de118..f6a809db 100644
--- a/crates/paimon/src/table/update_input.rs
+++ b/crates/paimon/src/table/update_input.rs
@@ -98,7 +98,7 @@ pub(super) fn normalize_row_ids(batch: RecordBatch) ->
crate::Result<RecordBatch
RecordBatch::try_new(schema, columns).map_err(|error|
invalid(error.to_string()))
}
-fn decode_dictionary(array: &ArrayRef) -> crate::Result<ArrayRef> {
+pub(super) fn decode_dictionary(array: &ArrayRef) -> crate::Result<ArrayRef> {
let mut array = array.clone();
while let DataType::Dictionary(_, values) = array.data_type() {
array = arrow_cast::cast_with_options(
@@ -2014,6 +2014,27 @@ mod tests {
assert!(cast_assignment(&input, &target).is_err());
}
+ #[test]
+ fn row_update_casts_allow_text_numeric_and_null_binary_values() {
+ let text: ArrayRef = Arc::new(StringArray::from(vec![Some("42"),
None]));
+ let binary = cast_update_value(&text, &DataType::Binary,
CastMode::RowUpdate).unwrap();
+ let integers = cast_update_value(&binary, &DataType::Int32,
CastMode::RowUpdate).unwrap();
+ assert_eq!(
+ integers.to_data(),
+ Int32Array::from(vec![Some(42), None]).to_data()
+ );
+ assert_eq!(
+ cast_update_value(
+ &new_null_array(&DataType::Null, 2),
+ &DataType::Binary,
+ CastMode::RowUpdate
+ )
+ .unwrap()
+ .null_count(),
+ 2
+ );
+ }
+
#[test]
fn binary_assignments_parse_text_and_never_reinterpret_numeric_bytes() {
let binary: ArrayRef = Arc::new(arrow_array::BinaryArray::from(vec![
diff --git a/crates/paimon/src/table/write_batch_normalize.rs
b/crates/paimon/src/table/write_batch_normalize.rs
index e7de7ec7..44b689b0 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -65,6 +65,33 @@ pub(super) fn blob_map_row_type(expected: &DataType) ->
Option<DataType> {
pub(super) fn normalize_blob_write_field(
array: &ArrayRef,
expected: &Field,
+) -> Result<(Field, ArrayRef)> {
+ normalize_blob_field(array, expected, true)
+}
+
+/// Normalize Blob map keys and their physical transport, retaining the value
+/// input type for the row-ID updater's conversion at the existing read layout.
+pub(super) fn normalize_blob_update_field(
+ array: &ArrayRef,
+ expected: &Field,
+) -> Result<(Field, ArrayRef)> {
+ let array = super::update_input::decode_dictionary(array)?;
+ if matches!(array.data_type(), DataType::Map(..) | DataType::List(_)) {
+ normalize_blob_field(&array, expected, false)
+ } else {
+ // NULLs and other row-ID constructor inputs retain their conversion
+ // at the existing DE writer position, rather than the declared schema.
+ Ok((
+ expected.clone().with_data_type(array.data_type().clone()),
+ array,
+ ))
+ }
+}
+
+fn normalize_blob_field(
+ array: &ArrayRef,
+ expected: &Field,
+ normalize_values: bool,
) -> Result<(Field, ArrayRef)> {
let Some(target) = blob_map_row_type(expected.data_type()) else {
return Ok((
@@ -121,17 +148,39 @@ pub(super) fn normalize_blob_write_field(
.enumerate()
.map(|(index, (column, field))| {
if index == 0 {
- normalize_blob_map_key(column, field.data_type())
- } else {
+ if normalize_values {
+ normalize_blob_map_key(column, field.data_type())
+ } else {
+ normalize_blob_update_key(column, field.data_type())
+ }
+ } else if normalize_values {
normalize_write_array(column, field.data_type())
+ } else {
+ Ok(column.clone())
}
})
.collect::<Result<Vec<_>>>()?;
- let entries =
- StructArray::try_new(fields.clone(), columns,
None).map_err(normalization_error)?;
- let list = ListArray::try_new(child.clone(), offsets, Arc::new(entries),
nulls.cloned())
+ let mut fields = fields.to_vec();
+ fields[1] = Arc::new(
+ fields[1]
+ .as_ref()
+ .clone()
+ .with_data_type(columns[1].data_type().clone()),
+ );
+ let fields = fields.into();
+ let entries = StructArray::try_new(fields, columns,
None).map_err(normalization_error)?;
+ let child = Arc::new(
+ child
+ .as_ref()
+ .clone()
+ .with_data_type(entries.data_type().clone()),
+ );
+ let list = ListArray::try_new(child, offsets, Arc::new(entries),
nulls.cloned())
.map_err(normalization_error)?;
- Ok((expected.clone().with_data_type(target), Arc::new(list)))
+ Ok((
+ expected.clone().with_data_type(list.data_type().clone()),
+ Arc::new(list),
+ ))
}
fn normalize_blob_map_key(array: &ArrayRef, expected: &DataType) ->
Result<ArrayRef> {
@@ -165,6 +214,40 @@ fn normalize_blob_map_key(array: &ArrayRef, expected:
&DataType) -> Result<Array
normalize_write_array(array, expected)
}
+fn normalize_blob_update_key(array: &ArrayRef, expected: &DataType) ->
Result<ArrayRef> {
+ let decoded = super::update_input::decode_dictionary(array)?;
+ if matches!(expected, DataType::Decimal128(..)) && decoded.data_type() !=
&DataType::Null {
+ // Blob map keys follow Java Decimal.fromBigDecimal (HALF_UP), rather
+ // than the exact decimal conversion used to match table upsert keys.
+ let text = if decoded.data_type().is_decimal() {
+ super::update_input::cast_update_value(
+ &decoded,
+ &DataType::Utf8,
+ super::update_input::CastMode::Assignment,
+ )?
+ } else {
+ decoded
+ };
+ return normalize_blob_map_key(&text, expected);
+ }
+ if matches!(
+ (decoded.data_type(), expected),
+ (
+ DataType::Time32(_) | DataType::Time64(_),
+ DataType::Time32(_) | DataType::Time64(_)
+ )
+ ) {
+ // Java/Python serialize TIME Blob keys in milliseconds. This is a
+ // value normalization step, unlike lossless table-key matching.
+ return super::update_input::cast_update_value(
+ &decoded,
+ expected,
+ super::update_input::CastMode::RowUpdate,
+ );
+ }
+ super::key_normalization::normalize_match_key(&decoded, expected)
+}
+
/// Decimal.fromBigDecimal: round magnitude HALF_UP, then check precision.
/// Discarded fractional digits are never accumulated into i128, so source
/// decimals can have more digits than the target's maximum precision of 38.
@@ -972,6 +1055,84 @@ mod tests {
assert!(normalize_blob_write_field(&visible_overflow,
&expected).is_err());
}
+ #[test]
+ fn blob_map_updates_normalize_keys_and_transport_before_value_casts() {
+ let entries = StructArray::new(
+ vec![
+ Field::new("k", DataType::Utf8, false),
+ Field::new("v", DataType::Utf8, true),
+ ]
+ .into(),
+ vec![
+ Arc::new(StringArray::from(vec!["1.235"])),
+ Arc::new(StringArray::from(vec!["new"])),
+ ],
+ None,
+ );
+ let entry_field = Arc::new(Field::new("input",
entries.data_type().clone(), false));
+ let offsets = OffsetBuffer::new(vec![0_i32, 1].into());
+ let map: ArrayRef = Arc::new(MapArray::new(
+ entry_field.clone(),
+ offsets.clone(),
+ entries.clone(),
+ None,
+ false,
+ ));
+ let list: ArrayRef = Arc::new(ListArray::new(
+ entry_field,
+ offsets,
+ Arc::new(entries),
+ None,
+ ));
+ let expected = Field::new(
+ "payload",
+ DataType::Map(
+ Arc::new(Field::new(
+ "entries",
+ DataType::Struct(
+ vec![
+ Field::new("key", DataType::Decimal128(10, 2),
false),
+ Field::new("value", DataType::LargeBinary, true),
+ ]
+ .into(),
+ ),
+ false,
+ )),
+ false,
+ ),
+ true,
+ );
+ let normalized = [map, list]
+ .iter()
+ .map(|array| normalize_blob_update_field(array,
&expected).unwrap())
+ .collect::<Vec<_>>();
+ assert_eq!(normalized[0].0, normalized[1].0);
+ assert_eq!(normalized[0].1.to_data(), normalized[1].1.to_data());
+ let list = normalized[0]
+ .1
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .unwrap();
+ let entries = list
+ .values()
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .unwrap();
+ assert_eq!(
+ entries
+ .column(0)
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .unwrap()
+ .value(0),
+ 124
+ );
+ // Preserve the original input value type until the row-ID updater
+ // casts against the actual read layout, including its nullability.
+ assert_eq!(entries.column(1).data_type(), &DataType::Utf8);
+ assert!(normalize_blob_write_field(&normalized[0].1,
&expected).is_err());
+ }
+
#[test]
fn sliced_blob_map_inputs_drop_unreferenced_entries() {
let input = blob_map_rows(