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 cac63da3 partial_update: support field aggregation reads (#598)
cac63da3 is described below
commit cac63da3a6ce2ab05d4f9d091237a3004d270d7f
Author: shyjsarah <[email protected]>
AuthorDate: Thu Jul 23 20:58:01 2026 +0800
partial_update: support field aggregation reads (#598)
---
crates/integrations/datafusion/tests/pk_tables.rs | 104 +++++
crates/paimon/src/spec/partial_update.rs | 373 ++++++++++++++++-
crates/paimon/src/table/aggregator/bool_agg.rs | 23 ++
crates/paimon/src/table/aggregator/listagg.rs | 68 +++-
crates/paimon/src/table/aggregator/mod.rs | 8 +
crates/paimon/src/table/aggregator/numeric.rs | 256 +++++++++++-
crates/paimon/src/table/aggregator/value.rs | 122 +++++-
crates/paimon/src/table/kv_file_reader.rs | 105 ++++-
crates/paimon/src/table/sort_merge.rs | 461 ++++++++++++++++++++--
docs/src/sql.md | 18 +-
10 files changed, 1476 insertions(+), 62 deletions(-)
diff --git a/crates/integrations/datafusion/tests/pk_tables.rs
b/crates/integrations/datafusion/tests/pk_tables.rs
index 0bad6e3b..d51b0052 100644
--- a/crates/integrations/datafusion/tests/pk_tables.rs
+++ b/crates/integrations/datafusion/tests/pk_tables.rs
@@ -38,6 +38,8 @@ use datafusion::arrow::datatypes::{
};
use paimon::catalog::Identifier;
use paimon::Catalog;
+use paimon_datafusion::PaimonTableProvider;
+use std::collections::HashMap;
use std::sync::Arc;
// ======================= Basic PK Write + Read =======================
@@ -178,6 +180,108 @@ async fn test_pk_partial_update_fixed_bucket_e2e() {
);
}
+#[tokio::test]
+async fn test_pk_partial_update_sequence_group_aggregation_read_e2e() {
+ let (_tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ sql_context
+ .sql("CREATE SCHEMA paimon.test_db")
+ .await
+ .unwrap();
+
+ sql_context
+ .sql(
+ "CREATE TABLE paimon.test_db.t_partial_update_aggregation (
+ id INT NOT NULL,
+ version INT,
+ amount INT,
+ tag STRING,
+ PRIMARY KEY (id)
+ ) WITH (
+ 'bucket' = '1',
+ 'merge-engine' = 'partial-update'
+ )",
+ )
+ .await
+ .unwrap();
+
+ for values in [
+ "(1, 10, 10, 'b'), (2, 5, 3, 'x')",
+ "(1, 9, 20, 'a'), (2, 4, 4, 'w')",
+ "(1, 11, 5, 'c')",
+ ] {
+ sql_context
+ .sql(&format!(
+ "INSERT INTO paimon.test_db.t_partial_update_aggregation
VALUES {values}"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ }
+
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "t_partial_update_aggregation"))
+ .await
+ .unwrap()
+ .copy_with_options(HashMap::from([
+ (
+ "fields.version.sequence-group".to_string(),
+ "amount,tag".to_string(),
+ ),
+ (
+ "fields.amount.aggregate-function".to_string(),
+ "sum".to_string(),
+ ),
+ (
+ "fields.tag.aggregate-function".to_string(),
+ "listagg".to_string(),
+ ),
+ ]));
+ let provider = PaimonTableProvider::try_new(table).unwrap();
+ sql_context
+ .register_temp_table(
+ "paimon.test_db.t_partial_update_aggregation",
+ Arc::new(provider),
+ )
+ .unwrap();
+
+ let batches = sql_context
+ .sql(
+ "SELECT id
+ FROM paimon.test_db.t_partial_update_aggregation
+ WHERE amount = 7 AND tag = 'w,x'",
+ )
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+ let batch = &batches[0];
+ assert_eq!(
+ batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 2
+ );
+ assert_eq!(
+ batch
+ .schema()
+ .fields()
+ .iter()
+ .map(|field| field.name().as_str())
+ .collect::<Vec<_>>(),
+ vec!["id"]
+ );
+}
+
#[tokio::test]
async fn test_pk_partial_update_ignore_delete_alias_e2e() {
let (_tmp, catalog) = create_test_env();
diff --git a/crates/paimon/src/spec/partial_update.rs
b/crates/paimon/src/spec/partial_update.rs
index f1c84e45..b8a0018e 100644
--- a/crates/paimon/src/spec/partial_update.rs
+++ b/crates/paimon/src/spec/partial_update.rs
@@ -17,6 +17,7 @@
use std::collections::{HashMap, HashSet};
+use super::aggregation::{is_known_aggregator_name,
validate_aggregator_for_type};
use crate::spec::DataField;
const MERGE_ENGINE_OPTION: &str = "merge-engine";
@@ -32,6 +33,11 @@ const FIELDS_DEFAULT_AGG_FUNCTION_OPTION: &str =
"fields.default-aggregate-funct
const FIELDS_PREFIX: &str = "fields.";
const SEQUENCE_GROUP_SUFFIX: &str = ".sequence-group";
const AGGREGATION_FUNCTION_SUFFIX: &str = ".aggregate-function";
+const LIST_AGG_DELIMITER_SUFFIX: &str = ".list-agg-delimiter";
+const IGNORE_RETRACT_SUFFIX: &str = ".ignore-retract";
+const DISTINCT_SUFFIX: &str = ".distinct";
+const NESTED_KEY_SUFFIX: &str = ".nested-key";
+const COUNT_LIMIT_SUFFIX: &str = ".count-limit";
/// Partial-update mode recognized by the current Rust implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -57,8 +63,8 @@ impl SequenceGroup {
/// Partial-update-specific option inspection and validation.
///
-/// Reads support basic partial update and sequence groups. Table creation and
-/// writes remain restricted to basic partial update.
+/// Reads support basic partial update, sequence groups, and field aggregation.
+/// Table creation and writes remain restricted to basic partial update.
#[derive(Debug, Clone, Copy)]
pub(crate) struct PartialUpdateConfig<'a> {
options: &'a HashMap<String, String>,
@@ -243,6 +249,120 @@ impl<'a> PartialUpdateConfig<'a> {
Ok(required)
}
+ pub(crate) fn validated_aggregate_functions(
+ &self,
+ fields: &[DataField],
+ primary_keys: &[String],
+ ) -> crate::Result<HashMap<String, String>> {
+ let groups = self.validated_sequence_groups(fields, primary_keys)?;
+ let sequence_fields: HashSet<&str> = groups
+ .iter()
+ .flat_map(|group| group.sequence_fields.iter().map(String::as_str))
+ .collect();
+ let protected_fields: HashSet<&str> = groups
+ .iter()
+ .flat_map(|group|
group.protected_fields.iter().map(String::as_str))
+ .collect();
+ let field_names: HashSet<&str> =
fields.iter().map(DataField::name).collect();
+ let primary_keys: HashSet<&str> =
primary_keys.iter().map(String::as_str).collect();
+
+ for (key, value) in self
+ .options
+ .iter()
+ .filter(|(key, _)| is_fields_option_with_suffix(key,
LIST_AGG_DELIMITER_SUFFIX))
+ {
+ let field_name = key
+ .strip_prefix(FIELDS_PREFIX)
+ .and_then(|key| key.strip_suffix(LIST_AGG_DELIMITER_SUFFIX))
+ .filter(|field| !field.is_empty())
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: format!("Invalid partial-update listagg option
'{key}={value}'"),
+ })?;
+ if !field_names.contains(field_name) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Aggregation field '{field_name}' referenced by
'{key}' is not declared \
+ in the table schema"
+ ),
+ });
+ }
+ }
+
+ let mut per_field = HashMap::new();
+ for (key, value) in self
+ .options
+ .iter()
+ .filter(|(key, _)| is_fields_option_with_suffix(key,
AGGREGATION_FUNCTION_SUFFIX))
+ {
+ let field_name = key
+ .strip_prefix(FIELDS_PREFIX)
+ .and_then(|key| key.strip_suffix(AGGREGATION_FUNCTION_SUFFIX))
+ .filter(|field| !field.is_empty())
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: format!(
+ "Invalid partial-update aggregate-function option
'{key}={value}'"
+ ),
+ })?;
+ if !field_names.contains(field_name) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Aggregation field '{field_name}' referenced by
'{key}' is not declared \
+ in the table schema"
+ ),
+ });
+ }
+ if !is_known_aggregator_name(value) {
+ validate_aggregator_for_type(
+ value,
+ field_name,
+ fields
+ .iter()
+ .find(|field| field.name() == field_name)
+ .expect("field existence checked above")
+ .data_type(),
+ )?;
+ }
+ per_field.insert(field_name, value.as_str());
+ }
+
+ let default = self
+ .options
+ .get(FIELDS_DEFAULT_AGG_FUNCTION_OPTION)
+ .map(String::as_str);
+ if let Some(default) = default {
+ if !is_known_aggregator_name(default) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Unknown aggregate function '{default}' configured via
\
+ '{FIELDS_DEFAULT_AGG_FUNCTION_OPTION}'"
+ ),
+ });
+ }
+ }
+
+ let mut functions = HashMap::new();
+ for field in fields {
+ let field_name = field.name();
+ if sequence_fields.contains(field_name) ||
primary_keys.contains(field_name) {
+ continue;
+ }
+ let Some(function) =
per_field.get(field_name).copied().or(default) else {
+ continue;
+ };
+ validate_aggregator_for_type(function, field_name,
field.data_type())?;
+ if function != "last_non_null_value" &&
!protected_fields.contains(field_name) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Must use sequence group for aggregate function
'{function}' on field \
+ '{field_name}'"
+ ),
+ });
+ }
+ functions.insert(field_name.to_string(), function.to_string());
+ }
+ Ok(functions)
+ }
+
fn validated_mode(
&self,
has_primary_keys: bool,
@@ -277,6 +397,9 @@ impl<'a> PartialUpdateConfig<'a> {
.filter(|key| {
is_unsupported_partial_update_option(key)
&& !is_fields_option_with_suffix(key,
SEQUENCE_GROUP_SUFFIX)
+ && !is_fields_option_with_suffix(key,
AGGREGATION_FUNCTION_SUFFIX)
+ && !is_fields_option_with_suffix(key,
LIST_AGG_DELIMITER_SUFFIX)
+ && key.as_str() != FIELDS_DEFAULT_AGG_FUNCTION_OPTION
})
.cloned()
.collect();
@@ -294,6 +417,11 @@ fn is_unsupported_partial_update_option(key: &str) -> bool
{
|| key == FIELDS_DEFAULT_AGG_FUNCTION_OPTION
|| is_fields_option_with_suffix(key, SEQUENCE_GROUP_SUFFIX)
|| is_fields_option_with_suffix(key, AGGREGATION_FUNCTION_SUFFIX)
+ || is_fields_option_with_suffix(key, LIST_AGG_DELIMITER_SUFFIX)
+ || is_fields_option_with_suffix(key, IGNORE_RETRACT_SUFFIX)
+ || is_fields_option_with_suffix(key, DISTINCT_SUFFIX)
+ || is_fields_option_with_suffix(key, NESTED_KEY_SUFFIX)
+ || is_fields_option_with_suffix(key, COUNT_LIMIT_SUFFIX)
}
fn is_fields_option_with_suffix(key: &str, suffix: &str) -> bool {
@@ -395,6 +523,11 @@ mod tests {
"fields.price.ignore-delete",
"fields.price.sequence-group",
"fields.price.aggregate-function",
+ "fields.price.list-agg-delimiter",
+ "fields.price.ignore-retract",
+ "fields.price.distinct",
+ "fields.price.nested-key",
+ "fields.price.count-limit",
FIELDS_DEFAULT_AGG_FUNCTION_OPTION,
] {
let options = partial_update_options(&[(key, "value")]);
@@ -433,6 +566,51 @@ mod tests {
);
}
+ #[test]
+ fn test_validate_read_mode_accepts_field_aggregation() {
+ let options =
+ partial_update_options(&[("fields.price.aggregate-function",
"last_non_null_value")]);
+ let config = PartialUpdateConfig::new(&options);
+
+ assert_eq!(
+ config.validate_read_mode(true, "default.t").unwrap(),
+ Some(PartialUpdateMode::Basic)
+ );
+ }
+
+ #[test]
+ fn test_validate_read_mode_accepts_sequence_group_field_aggregation() {
+ let options = partial_update_options(&[
+ ("fields.version.sequence-group", "price"),
+ ("fields.price.aggregate-function", "sum"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+
+ assert_eq!(
+ config.validate_read_mode(true, "default.t").unwrap(),
+ Some(PartialUpdateMode::SequenceGroup)
+ );
+ }
+
+ #[test]
+ fn test_validate_read_mode_rejects_unsupported_aggregation_modifiers() {
+ for key in [
+ "fields.price.ignore-retract",
+ "fields.price.distinct",
+ "fields.price.nested-key",
+ "fields.price.count-limit",
+ ] {
+ let options = partial_update_options(&[(key, "value")]);
+ let config = PartialUpdateConfig::new(&options);
+ let err = config.validate_read_mode(true,
"default.t").unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::Unsupported { ref message } if
message.contains(key)),
+ "expected read-time rejection to mention '{key}', got {err:?}"
+ );
+ }
+ }
+
#[test]
fn test_parse_sequence_groups() {
let options = partial_update_options(&[
@@ -564,4 +742,195 @@ mod tests {
]
);
}
+
+ #[test]
+ fn
test_validate_aggregate_functions_accepts_last_non_null_without_sequence_group()
{
+ let options =
+ partial_update_options(&[("fields.price.aggregate-function",
"last_non_null_value")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ assert_eq!(
+ config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap(),
+ HashMap::from([("price".to_string(),
"last_non_null_value".to_string())])
+ );
+ }
+
+ #[test]
+ fn
test_validate_aggregate_functions_rejects_non_last_without_sequence_group() {
+ let options =
partial_update_options(&[("fields.price.aggregate-function", "sum")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("sum")
+ && message.contains("price")
+ && message.contains("sequence group")),
+ "expected missing sequence-group error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_aggregate_functions_rejects_unknown_function() {
+ let options = partial_update_options(&[
+ ("fields.version.sequence-group", "price"),
+ ("fields.price.aggregate-function", "sume"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("sume") && message.contains("price")),
+ "expected unknown function error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_aggregate_functions_rejects_unknown_field() {
+ let options =
+ partial_update_options(&[("fields.prcie.aggregate-function",
"last_non_null_value")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("prcie")
+ && message.contains("fields.prcie.aggregate-function")),
+ "expected unknown field error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn
test_validate_aggregate_functions_rejects_unknown_listagg_delimiter_field() {
+ let options = partial_update_options(&[
+ ("fields.version.sequence-group", "tag"),
+ ("fields.tag.aggregate-function", "listagg"),
+ ("fields.tga.list-agg-delimiter", "|"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(
+ 2,
+ "tag".to_string(),
+ DataType::VarChar(crate::spec::VarCharType::string_type()),
+ ),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("tga")
+ && message.contains("fields.tga.list-agg-delimiter")),
+ "expected unknown delimiter field error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_aggregate_functions_rejects_incompatible_type() {
+ let options = partial_update_options(&[
+ ("fields.version.sequence-group", "name"),
+ ("fields.name.aggregate-function", "sum"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(
+ 2,
+ "name".to_string(),
+ DataType::VarChar(crate::spec::VarCharType::string_type()),
+ ),
+ ];
+
+ let err = config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("sum") && message.contains("name")),
+ "expected incompatible type error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_default_aggregate_function_applies_to_protected_fields() {
+ let options = partial_update_options(&[
+ ("fields.version.sequence-group", "price"),
+ (FIELDS_DEFAULT_AGG_FUNCTION_OPTION, "sum"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ assert_eq!(
+ config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap(),
+ HashMap::from([("price".to_string(), "sum".to_string())])
+ );
+ }
+
+ #[test]
+ fn test_per_field_aggregate_function_overrides_default() {
+ let options = partial_update_options(&[
+ ("fields.version.sequence-group", "price"),
+ ("fields.price.aggregate-function", "sum"),
+ (FIELDS_DEFAULT_AGG_FUNCTION_OPTION, "last_non_null_value"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "price".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(3, "note".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ assert_eq!(
+ config
+ .validated_aggregate_functions(&fields, &["id".to_string()])
+ .unwrap(),
+ HashMap::from([
+ ("price".to_string(), "sum".to_string()),
+ ("note".to_string(), "last_non_null_value".to_string()),
+ ])
+ );
+ }
}
diff --git a/crates/paimon/src/table/aggregator/bool_agg.rs
b/crates/paimon/src/table/aggregator/bool_agg.rs
index 31f4f3a8..9cb5b6a6 100644
--- a/crates/paimon/src/table/aggregator/bool_agg.rs
+++ b/crates/paimon/src/table/aggregator/bool_agg.rs
@@ -82,6 +82,10 @@ impl FieldAggregator for BoolAndAgg {
Ok(())
}
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ self.agg(array, row_idx)
+ }
+
fn result(&self) -> crate::Result<ArrayRef> {
Ok(Arc::new(BooleanArray::from(vec![self.acc])))
}
@@ -132,6 +136,10 @@ impl FieldAggregator for BoolOrAgg {
Ok(())
}
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ self.agg(array, row_idx)
+ }
+
fn result(&self) -> crate::Result<ArrayRef> {
Ok(Arc::new(BooleanArray::from(vec![self.acc])))
}
@@ -197,6 +205,21 @@ mod tests {
assert_eq!(collect(or_agg.result().unwrap()), None);
}
+ #[test]
+ fn test_bool_aggregators_accept_reversed_inputs() {
+ let arr = BooleanArray::from(vec![Some(true), Some(false)]);
+
+ let mut and_agg = BoolAndAgg::new("b",
&DataType::Boolean(BooleanType::new())).unwrap();
+ and_agg.agg(&arr, 0).unwrap();
+ and_agg.agg_reversed(&arr, 1).unwrap();
+ assert_eq!(collect(and_agg.result().unwrap()), Some(false));
+
+ let mut or_agg = BoolOrAgg::new("b",
&DataType::Boolean(BooleanType::new())).unwrap();
+ or_agg.agg(&arr, 1).unwrap();
+ or_agg.agg_reversed(&arr, 0).unwrap();
+ assert_eq!(collect(or_agg.result().unwrap()), Some(true));
+ }
+
#[test]
fn test_bool_and_rejects_non_boolean_type() {
let err = BoolAndAgg::new("b",
&DataType::Int(IntType::new())).unwrap_err();
diff --git a/crates/paimon/src/table/aggregator/listagg.rs
b/crates/paimon/src/table/aggregator/listagg.rs
index 945cb370..c94a6bd2 100644
--- a/crates/paimon/src/table/aggregator/listagg.rs
+++ b/crates/paimon/src/table/aggregator/listagg.rs
@@ -43,6 +43,23 @@ fn list_agg_delimiter<'a>(field_name: &str, options: &'a
HashMap<String, String>
.unwrap_or(DEFAULT_DELIMITER)
}
+fn java_is_blank(value: &str) -> bool {
+ value.chars().all(|ch| {
+ matches!(
+ ch,
+ '\u{0009}'..='\u{000d}'
+ | '\u{001c}'..='\u{0020}'
+ | '\u{1680}'
+ | '\u{2000}'..='\u{2006}'
+ | '\u{2008}'..='\u{200a}'
+ | '\u{2028}'
+ | '\u{2029}'
+ | '\u{205f}'
+ | '\u{3000}'
+ )
+ })
+}
+
#[derive(Debug)]
pub(crate) struct ListaggAgg {
field_name: String,
@@ -103,6 +120,9 @@ impl FieldAggregator for ListaggAgg {
source: None,
})?;
let v = arr.value(row_idx);
+ if java_is_blank(v) {
+ return Ok(());
+ }
match &mut self.acc {
None => self.acc = Some(v.to_string()),
Some(prev) => {
@@ -113,6 +133,32 @@ impl FieldAggregator for ListaggAgg {
Ok(())
}
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ if array.is_null(row_idx) {
+ return Ok(());
+ }
+ let arr = array
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "listagg column '{}' received non-Utf8 Arrow array {:?}",
+ self.field_name,
+ array.data_type()
+ ),
+ source: None,
+ })?;
+ let value = arr.value(row_idx);
+ if java_is_blank(value) {
+ return Ok(());
+ }
+ self.acc = Some(match self.acc.take() {
+ None => value.to_string(),
+ Some(current) => format!("{value}{}{current}", self.delimiter),
+ });
+ Ok(())
+ }
+
fn result(&self) -> crate::Result<ArrayRef> {
Ok(Arc::new(StringArray::from(vec![self.acc.clone()])))
}
@@ -140,7 +186,7 @@ mod tests {
#[test]
fn test_listagg_default_delimiter_skips_null() {
let mut agg = ListaggAgg::new("v", &varchar_type(),
&HashMap::new()).unwrap();
- let arr = StringArray::from(vec![Some("a"), None, Some("b"),
Some("c")]);
+ let arr = StringArray::from(vec![Some("a"), None, Some(" "),
Some("b"), Some("c")]);
for i in 0..arr.len() {
agg.agg(&arr, i).unwrap();
}
@@ -228,4 +274,24 @@ mod tests {
agg.reset();
assert_eq!(collect(agg.result().unwrap()), None);
}
+
+ #[test]
+ fn test_listagg_reversed_prepends_non_blank_value() {
+ let mut agg = ListaggAgg::new("v", &varchar_type(),
&HashMap::new()).unwrap();
+ let arr = StringArray::from(vec![Some("b"), Some("a"), Some(" ")]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ agg.agg_reversed(&arr, 2).unwrap();
+ assert_eq!(collect(agg.result().unwrap()), Some("a,b".to_string()));
+ }
+
+ #[test]
+ fn test_listagg_java_blank_semantics_preserve_non_breaking_space() {
+ let mut agg = ListaggAgg::new("v", &varchar_type(),
&HashMap::new()).unwrap();
+ let arr = StringArray::from(vec![Some(" "), Some("\u{00a0}")]);
+ for i in 0..arr.len() {
+ agg.agg(&arr, i).unwrap();
+ }
+ assert_eq!(collect(agg.result().unwrap()),
Some("\u{00a0}".to_string()));
+ }
}
diff --git a/crates/paimon/src/table/aggregator/mod.rs
b/crates/paimon/src/table/aggregator/mod.rs
index 02e441be..787c8d60 100644
--- a/crates/paimon/src/table/aggregator/mod.rs
+++ b/crates/paimon/src/table/aggregator/mod.rs
@@ -67,6 +67,14 @@ pub(crate) trait FieldAggregator: Send + Sync +
std::fmt::Debug {
/// Accumulate one input cell.
fn agg(&mut self, array: &dyn Array, row_idx: usize) -> crate::Result<()>;
+ /// Accumulate an input that sorts before the current accumulator.
+ ///
+ /// This mirrors Java `FieldAggregator#aggReversed(accumulator, input)`,
+ /// whose default semantics are `agg(input, accumulator)`. Implementations
+ /// must define this explicitly so order-sensitive aggregators cannot
+ /// silently fall back to forward accumulation.
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()>;
+
/// Materialize the current accumulator as a 1-row Arrow array.
fn result(&self) -> crate::Result<ArrayRef>;
}
diff --git a/crates/paimon/src/table/aggregator/numeric.rs
b/crates/paimon/src/table/aggregator/numeric.rs
index 8ab7b6d2..f4851193 100644
--- a/crates/paimon/src/table/aggregator/numeric.rs
+++ b/crates/paimon/src/table/aggregator/numeric.rs
@@ -25,7 +25,7 @@
//! produce misleading aggregated values. A Decimal `sum` whose result no
//! longer fits the declared precision yields a NULL cell, matching Java
//! `DecimalUtils.add` / `Decimal.fromBigDecimal` (which return null on
-//! precision overflow rather than throwing).
+//! precision or backing `i128` overflow rather than throwing).
//!
//! `min` / `max` extend to every ordered Paimon type: numerics, Decimal,
//! Date, Time, Timestamp, and Char/VarChar. Comparison is by native value
@@ -166,19 +166,37 @@ impl FieldAggregator for SumAgg {
let v = downcast::<Float64Array>(array,
&self.field_name)?.value(row_idx);
*acc = Some(acc.map_or(v, |prev| prev + v));
}
- SumState::Decimal128 { acc, .. } => {
+ SumState::Decimal128 { precision, acc, .. } => {
let v = downcast::<Decimal128Array>(array,
&self.field_name)?.value(row_idx);
- *acc = Some(match *acc {
- None => v,
- Some(prev) => prev
- .checked_add(v)
- .ok_or_else(|| overflow_error("sum",
&self.field_name))?,
- });
+ let next = match *acc {
+ None => Some(v),
+ Some(prev) => prev.checked_add(v),
+ };
+ *acc = next.filter(|value| decimal_fits_precision(*value,
*precision));
}
}
Ok(())
}
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ if array.is_null(row_idx) {
+ return Ok(());
+ }
+ match &mut self.state {
+ SumState::F32(acc) => {
+ let v = downcast::<Float32Array>(array,
&self.field_name)?.value(row_idx);
+ *acc = Some(acc.map_or(v, |prev| v + prev));
+ Ok(())
+ }
+ SumState::F64(acc) => {
+ let v = downcast::<Float64Array>(array,
&self.field_name)?.value(row_idx);
+ *acc = Some(acc.map_or(v, |prev| v + prev));
+ Ok(())
+ }
+ _ => self.agg(array, row_idx),
+ }
+ }
+
fn result(&self) -> crate::Result<ArrayRef> {
Ok(match &self.state {
SumState::I8(acc) => Arc::new(Int8Array::from(vec![*acc])),
@@ -194,8 +212,9 @@ impl FieldAggregator for SumAgg {
} => {
// Java parity: `DecimalUtils.add` -> `Decimal.fromBigDecimal`
// returns null when the summed value no longer fits the
- // declared precision, so an overflowing sum yields a NULL cell
- // rather than a silently out-of-range Decimal.
+ // declared precision (or the backing i128), so an overflowing
+ // sum yields a NULL cell rather than a silently out-of-range
+ // Decimal.
let fitted = acc.filter(|v| decimal_fits_precision(*v,
*precision));
decimal_array(*precision, *scale, fitted, "sum",
&self.field_name)?
}
@@ -328,6 +347,25 @@ impl FieldAggregator for ProductAgg {
Ok(())
}
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ if array.is_null(row_idx) {
+ return Ok(());
+ }
+ match &mut self.state {
+ ProductState::F32(acc) => {
+ let v = downcast::<Float32Array>(array,
&self.field_name)?.value(row_idx);
+ *acc = Some(acc.map_or(v, |prev| v * prev));
+ Ok(())
+ }
+ ProductState::F64(acc) => {
+ let v = downcast::<Float64Array>(array,
&self.field_name)?.value(row_idx);
+ *acc = Some(acc.map_or(v, |prev| v * prev));
+ Ok(())
+ }
+ _ => self.agg(array, row_idx),
+ }
+ }
+
fn result(&self) -> crate::Result<ArrayRef> {
Ok(match &self.state {
ProductState::I8(acc) => Arc::new(Int8Array::from(vec![*acc])),
@@ -415,6 +453,7 @@ fn agg_minmax(
row_idx: usize,
field_name: &str,
keep_smaller: bool,
+ reversed: bool,
) -> crate::Result<()> {
if array.is_null(row_idx) {
return Ok(());
@@ -425,7 +464,18 @@ fn agg_minmax(
*$acc = Some(match *$acc {
None => v,
Some(prev) => {
- if (keep_smaller && v < prev) || (!keep_smaller && v >
prev) {
+ let take_new = if keep_smaller {
+ if reversed {
+ v < prev
+ } else {
+ v <= prev
+ }
+ } else if reversed {
+ v >= prev
+ } else {
+ v > prev
+ };
+ if take_new {
v
} else {
prev
@@ -451,8 +501,16 @@ fn agg_minmax(
(false, false) => v.total_cmp(&prev),
};
let take_new = if keep_smaller {
- // Java `FieldMinAgg` returns the input on ties.
- cmp.is_le()
+ if reversed {
+ cmp.is_lt()
+ } else {
+ // Java `FieldMinAgg` returns the input on ties.
+ cmp.is_le()
+ }
+ } else if reversed {
+ // Reversed max treats the older input as the
+ // accumulator, so ties select it.
+ cmp.is_ge()
} else {
// Java `FieldMaxAgg` retains the accumulator on ties.
cmp.is_gt()
@@ -495,7 +553,13 @@ fn agg_minmax(
None => v.to_string(),
Some(prev) => {
let take_new = if keep_smaller {
- v < prev.as_str()
+ if reversed {
+ v < prev.as_str()
+ } else {
+ v <= prev.as_str()
+ }
+ } else if reversed {
+ v >= prev.as_str()
} else {
v > prev.as_str()
};
@@ -584,7 +648,25 @@ impl FieldAggregator for MinAgg {
}
fn agg(&mut self, array: &dyn Array, row_idx: usize) -> crate::Result<()> {
- agg_minmax(&mut self.state, array, row_idx, &self.field_name, true)
+ agg_minmax(
+ &mut self.state,
+ array,
+ row_idx,
+ &self.field_name,
+ true,
+ false,
+ )
+ }
+
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ agg_minmax(
+ &mut self.state,
+ array,
+ row_idx,
+ &self.field_name,
+ true,
+ true,
+ )
}
fn result(&self) -> crate::Result<ArrayRef> {
@@ -617,7 +699,25 @@ impl FieldAggregator for MaxAgg {
}
fn agg(&mut self, array: &dyn Array, row_idx: usize) -> crate::Result<()> {
- agg_minmax(&mut self.state, array, row_idx, &self.field_name, false)
+ agg_minmax(
+ &mut self.state,
+ array,
+ row_idx,
+ &self.field_name,
+ false,
+ false,
+ )
+ }
+
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ agg_minmax(
+ &mut self.state,
+ array,
+ row_idx,
+ &self.field_name,
+ false,
+ true,
+ )
}
fn result(&self) -> crate::Result<ArrayRef> {
@@ -869,6 +969,86 @@ mod tests {
);
}
+ #[test]
+ fn test_sum_decimal_recovers_after_intermediate_precision_overflow() {
+ let mut agg = sum_agg(DataType::Decimal(DecimalType::new(3,
0).unwrap()));
+ let mut builder = Decimal128Builder::with_capacity(3)
+ .with_precision_and_scale(3, 0)
+ .unwrap();
+ builder.append_value(900);
+ builder.append_value(200);
+ builder.append_value(-200);
+ let arr = builder.finish();
+
+ for i in 0..arr.len() {
+ agg.agg(&arr, i).unwrap();
+ }
+
+ let result = agg.result().unwrap();
+ assert_eq!(
+ result
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .unwrap()
+ .value(0),
+ -200
+ );
+ }
+
+ #[test]
+ fn test_sum_decimal_recovers_after_intermediate_i128_overflow() {
+ let mut agg = sum_agg(DataType::Decimal(DecimalType::new(38,
0).unwrap()));
+ let max_decimal = 10_i128.pow(38) - 1;
+ let mut builder = Decimal128Builder::with_capacity(3)
+ .with_precision_and_scale(38, 0)
+ .unwrap();
+ builder.append_value(max_decimal);
+ builder.append_value(max_decimal);
+ builder.append_value(-max_decimal);
+ let arr = builder.finish();
+
+ for i in 0..arr.len() {
+ agg.agg(&arr, i).unwrap();
+ }
+
+ let result = agg.result().unwrap();
+ assert_eq!(
+ result
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .unwrap()
+ .value(0),
+ -max_decimal
+ );
+ }
+
+ #[test]
+ fn test_sum_decimal_reversed_recovers_after_intermediate_i128_overflow() {
+ let mut agg = sum_agg(DataType::Decimal(DecimalType::new(38,
0).unwrap()));
+ let max_decimal = 10_i128.pow(38) - 1;
+ let mut builder = Decimal128Builder::with_capacity(3)
+ .with_precision_and_scale(38, 0)
+ .unwrap();
+ builder.append_value(max_decimal);
+ builder.append_value(max_decimal);
+ builder.append_value(-max_decimal);
+ let arr = builder.finish();
+
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ agg.agg_reversed(&arr, 2).unwrap();
+
+ let result = agg.result().unwrap();
+ assert_eq!(
+ result
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .unwrap()
+ .value(0),
+ -max_decimal
+ );
+ }
+
#[test]
fn test_product_int_aggregates() {
let mut agg = ProductAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
@@ -879,6 +1059,15 @@ mod tests {
assert_eq!(collect_i32(agg.result().unwrap()), Some(24));
}
+ #[test]
+ fn test_product_reversed_aggregates_input() {
+ let mut agg = ProductAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
+ let arr = Int32Array::from(vec![Some(2), Some(3)]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), Some(6));
+ }
+
#[test]
fn test_product_rejects_overflow() {
let mut agg = ProductAgg::new("v",
&DataType::SmallInt(SmallIntType::new())).unwrap();
@@ -1024,6 +1213,41 @@ mod tests {
}
}
+ #[test]
+ fn test_min_max_reversed_nan_ties_match_java_operand_order() {
+ let current = f32::from_bits(0xffc0_0001);
+ let older = f32::from_bits(0x7fc0_0002);
+ let arr = Float32Array::from(vec![Some(current), Some(older)]);
+
+ let mut min = min_agg(DataType::Float(FloatType::new()));
+ min.agg(&arr, 0).unwrap();
+ min.agg_reversed(&arr, 1).unwrap();
+ let min_result = min.result().unwrap();
+ assert_eq!(
+ min_result
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .value(0)
+ .to_bits(),
+ current.to_bits()
+ );
+
+ let mut max = max_agg(DataType::Float(FloatType::new()));
+ max.agg(&arr, 0).unwrap();
+ max.agg_reversed(&arr, 1).unwrap();
+ let max_result = max.result().unwrap();
+ assert_eq!(
+ max_result
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .value(0)
+ .to_bits(),
+ older.to_bits()
+ );
+ }
+
#[test]
fn test_min_max_all_null_returns_null() {
let mut agg = max_agg(DataType::Int(IntType::new()));
diff --git a/crates/paimon/src/table/aggregator/value.rs
b/crates/paimon/src/table/aggregator/value.rs
index 499c85fe..147b13cb 100644
--- a/crates/paimon/src/table/aggregator/value.rs
+++ b/crates/paimon/src/table/aggregator/value.rs
@@ -55,6 +55,7 @@ enum PickPolicy {
struct PickValueAgg {
policy: PickPolicy,
arrow_type: ArrowDataType,
+ initialized: bool,
/// 1-row Arrow array holding the currently-winning value; `None` means
/// no winning row has been observed yet for the current group.
winner: Option<ArrayRef>,
@@ -65,26 +66,72 @@ impl PickValueAgg {
Ok(Self {
policy,
arrow_type: paimon_type_to_arrow(data_type)?,
+ initialized: false,
winner: None,
})
}
- fn should_replace(&self, is_null: bool) -> bool {
- match self.policy {
- PickPolicy::Last => true,
- PickPolicy::First => self.winner.is_none(),
- PickPolicy::LastNonNull => !is_null,
- PickPolicy::FirstNonNull => self.winner.is_none() && !is_null,
- }
- }
-
fn reset(&mut self) {
+ self.initialized = false;
self.winner = None;
}
fn agg(&mut self, array: &dyn Array, row_idx: usize) {
- if self.should_replace(array.is_null(row_idx)) {
- self.winner = Some(array.slice(row_idx, 1));
+ let is_null = array.is_null(row_idx);
+ match self.policy {
+ PickPolicy::Last => {
+ self.winner = Some(array.slice(row_idx, 1));
+ }
+ PickPolicy::First if !self.initialized => {
+ self.initialized = true;
+ self.winner = Some(array.slice(row_idx, 1));
+ }
+ PickPolicy::LastNonNull if !is_null => {
+ self.winner = Some(array.slice(row_idx, 1));
+ }
+ PickPolicy::FirstNonNull if !self.initialized && !is_null => {
+ self.initialized = true;
+ self.winner = Some(array.slice(row_idx, 1));
+ }
+ _ => {}
+ }
+ }
+
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) {
+ let is_null = array.is_null(row_idx);
+ match self.policy {
+ // Java default: last_value.agg(input, accumulator) returns the
+ // existing accumulator.
+ PickPolicy::Last => {}
+ // Java first_value returns the older input once the aggregator has
+ // already been initialized by the current accumulator.
+ PickPolicy::First => {
+ if self.initialized {
+ self.winner = Some(array.slice(row_idx, 1));
+ } else {
+ self.initialized = true;
+ }
+ }
+ // Java last_non_null_value keeps a non-null accumulator, otherwise
+ // it falls back to the older input.
+ PickPolicy::LastNonNull => {
+ if self.winner.is_none() && !is_null {
+ self.winner = Some(array.slice(row_idx, 1));
+ }
+ }
+ // Preserve Java's stateful first_non_null_value behavior for
+ // aggReversed(accumulator, input) == agg(input, accumulator).
+ PickPolicy::FirstNonNull => {
+ let current_is_non_null = self
+ .winner
+ .as_ref()
+ .is_some_and(|winner| !winner.is_null(0));
+ if !self.initialized && current_is_non_null {
+ self.initialized = true;
+ } else {
+ self.winner = Some(array.slice(row_idx, 1));
+ }
+ }
}
}
@@ -118,6 +165,10 @@ macro_rules! pick_agg {
self.0.agg(array, row_idx);
Ok(())
}
+ fn agg_reversed(&mut self, array: &dyn Array, row_idx: usize) ->
crate::Result<()> {
+ self.0.agg_reversed(array, row_idx);
+ Ok(())
+ }
fn result(&self) -> crate::Result<ArrayRef> {
Ok(self.0.result())
}
@@ -237,4 +288,53 @@ mod tests {
agg.reset();
assert!(agg.result().unwrap().is_null(0));
}
+
+ #[test]
+ fn test_last_value_reversed_keeps_current_value() {
+ let mut agg = LastValueAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
+ let arr = Int32Array::from(vec![Some(10), Some(5)]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), Some(10));
+ }
+
+ #[test]
+ fn test_first_value_reversed_uses_older_null() {
+ let mut agg = FirstValueAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
+ let arr = Int32Array::from(vec![Some(10), None]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), None);
+ }
+
+ #[test]
+ fn test_last_non_null_reversed_fills_only_empty_accumulator() {
+ let mut agg = LastNonNullValueAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
+ let arr = Int32Array::from(vec![None, Some(5), Some(3)]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ agg.agg_reversed(&arr, 2).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), Some(5));
+ }
+
+ #[test]
+ fn test_first_non_null_reversed_matches_java_initialized_state() {
+ let mut agg = FirstNonNullValueAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
+ let arr = Int32Array::from(vec![None, Some(7), Some(5)]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ agg.agg_reversed(&arr, 2).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), Some(7));
+ }
+
+ #[test]
+ fn
test_first_non_null_reversed_after_forward_initialization_uses_older_input() {
+ let mut agg = FirstNonNullValueAgg::new("v",
&DataType::Int(IntType::new())).unwrap();
+ let arr = Int32Array::from(vec![Some(10), None, Some(5)]);
+ agg.agg(&arr, 0).unwrap();
+ agg.agg_reversed(&arr, 1).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), None);
+ agg.agg_reversed(&arr, 2).unwrap();
+ assert_eq!(collect_i32(agg.result().unwrap()), Some(5));
+ }
}
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index bb36e369..ec221af5 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -533,10 +533,12 @@ mod tests {
use super::*;
use crate::catalog::Identifier;
use crate::io::FileIOBuilder;
- use crate::spec::{DataType, Datum, IntType, PredicateBuilder, Schema,
TableSchema};
+ use crate::spec::{
+ DataType, Datum, IntType, PredicateBuilder, Schema, TableSchema,
VarCharType,
+ };
use crate::table::table_commit::TableCommit;
use crate::table::{Table, TableWrite};
- use arrow_array::{Array, Int32Array};
+ use arrow_array::{Array, Int32Array, StringArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
use futures::TryStreamExt;
use std::sync::Arc;
@@ -1318,6 +1320,105 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn
kv_read_partial_update_sequence_group_aggregation_with_projection() {
+ let file_io = test_file_io();
+ let table_path =
"memory:/kv_partial_update_sequence_group_aggregation";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("version", DataType::Int(IntType::new()))
+ .column("value", DataType::VarChar(VarCharType::string_type()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("merge-engine", "partial-update")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default",
"kv_partial_update_sequence_group_aggregation_t"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("version", ArrowDataType::Int32, true),
+ ArrowField::new("value", ArrowDataType::Utf8, true),
+ ]));
+ let make = |id, version, value| {
+ RecordBatch::try_new(
+ arrow_schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![id])),
+ Arc::new(Int32Array::from(vec![version])),
+ Arc::new(StringArray::from(vec![value])),
+ ],
+ )
+ .unwrap()
+ };
+
+ write_commit(&table, &make(1, 10, "b")).await;
+ write_commit(&table, &make(1, 9, "a")).await;
+ write_commit(&table, &make(1, 11, "c")).await;
+ write_commit(&table, &make(2, 5, "x")).await;
+ write_commit(&table, &make(2, 6, "y")).await;
+
+ let aggregation_schema =
table.schema().copy_with_options(HashMap::from([
+ (
+ "fields.version.sequence-group".to_string(),
+ "value".to_string(),
+ ),
+ (
+ "fields.value.aggregate-function".to_string(),
+ "listagg".to_string(),
+ ),
+ ]));
+ let aggregation_table = Table::new(
+ file_io,
+ Identifier::new("default",
"kv_partial_update_sequence_group_aggregation_t"),
+ table_path.to_string(),
+ aggregation_schema,
+ None,
+ );
+
+ let batches = read_rows(&aggregation_table, Some(&["id", "value"]),
None).await;
+
+ let mut rows = batches
+ .iter()
+ .flat_map(|batch| {
+ let ids = batch
+ .column(batch.schema().index_of("id").unwrap())
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let index = batch.schema().index_of("value").unwrap();
+ let array = batch
+ .column(index)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ (0..array.len())
+ .map(|row| (ids.value(row), array.value(row).to_string()))
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+ rows.sort_by_key(|row| row.0);
+ assert_eq!(rows, vec![(1, "a,b,c".to_string()), (2,
"x,y".to_string())]);
+ for batch in batches {
+ assert_eq!(
+ batch
+ .schema()
+ .fields()
+ .iter()
+ .map(|field| field.name().as_str())
+ .collect::<Vec<_>>(),
+ vec!["id", "value"]
+ );
+ }
+ }
+
/// An AlwaysFalse filter on a PK table must return nothing, end to end.
/// Two layers enforce it: scan-side stats pruning treats AlwaysFalse as
/// prune-everything (plans no files), and the post-merge residual masks
diff --git a/crates/paimon/src/table/sort_merge.rs
b/crates/paimon/src/table/sort_merge.rs
index 4a6d5835..39aba7c5 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -182,18 +182,25 @@ impl MergeFunction for DeduplicateMergeFunction {
}
}
-/// Basic partial-update merge: for each non-key column, keep the latest
-/// non-null value ordered by user sequence (if configured) then system
sequence.
+/// Partial-update merge: for each non-key column, keep the latest non-null
+/// value or apply its configured field aggregator.
+///
+/// Sequence-group aggregators use forward accumulation when the incoming
+/// group sequence is newer or equal and reversed accumulation when it is
+/// older, matching Java `PartialUpdateMergeFunction`.
///
/// DELETE / UPDATE_BEFORE rows are ignored when `ignore-delete=true` and
/// treated as unsupported otherwise.
-#[derive(Debug, Clone)]
+#[derive(Debug)]
pub(crate) struct PartialUpdateMergeFunction {
ignore_delete: bool,
sequence_groups: Vec<RuntimeSequenceGroup>,
grouped_fields: HashSet<usize>,
+ aggregators: Option<Mutex<FieldAggregatorSlots>>,
}
+type FieldAggregatorSlots = Vec<Option<Box<dyn FieldAggregator>>>;
+
#[derive(Debug, Clone)]
struct RuntimeSequenceGroup {
sequence_indices: Vec<usize>,
@@ -211,6 +218,7 @@ impl PartialUpdateMergeFunction {
ignore_delete: CoreOptions::new(table_options).ignore_delete(),
sequence_groups: Vec::new(),
grouped_fields: HashSet::new(),
+ aggregators: None,
})
}
@@ -224,6 +232,8 @@ impl PartialUpdateMergeFunction {
let config = PartialUpdateConfig::new(table_options);
config.validate_read_mode(true, table_name)?;
let groups = config.validated_sequence_groups(table_fields,
primary_keys)?;
+ let aggregate_functions =
+ config.validated_aggregate_functions(table_fields, primary_keys)?;
let field_indices: HashMap<&str, usize> = output_fields
.iter()
.enumerate()
@@ -275,11 +285,29 @@ impl PartialUpdateMergeFunction {
})
.copied()
.collect();
+ let aggregators = output_fields
+ .iter()
+ .map(|field| -> crate::Result<Option<Box<dyn FieldAggregator>>> {
+ let Some(function) = aggregate_functions.get(field.name())
else {
+ return Ok(None);
+ };
+ Ok(Some(new_aggregator(
+ function,
+ field.name(),
+ field.data_type(),
+ table_options,
+ )?))
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
Ok(Self {
ignore_delete: CoreOptions::new(table_options).ignore_delete(),
sequence_groups,
grouped_fields,
+ aggregators: aggregators
+ .iter()
+ .any(Option::is_some)
+ .then(|| Mutex::new(aggregators)),
})
}
}
@@ -309,6 +337,18 @@ impl MergeFunction for PartialUpdateMergeFunction {
vec![None; output_schema.fields().len()];
let mut group_sequence_rows: Vec<Option<(usize, usize)>> =
vec![None; self.sequence_groups.len()];
+ let mut aggregators = match &self.aggregators {
+ Some(aggregators) => Some(aggregators.lock().map_err(|e|
Error::UnexpectedError {
+ message: format!("PartialUpdateMergeFunction aggregator mutex
poisoned: {e}"),
+ source: None,
+ })?),
+ None => None,
+ };
+ if let Some(aggregators) = aggregators.as_mut() {
+ for aggregator in aggregators.iter_mut().flatten() {
+ aggregator.reset();
+ }
+ }
let mut saw_add = false;
for row_idx in ordered_row_indices {
@@ -329,7 +369,13 @@ impl MergeFunction for PartialUpdateMergeFunction {
}
let source_array = batch_buffer[row.batch_idx]
.column_for_output(output_col_idx,
source_output_col_indices);
- if !source_array.is_null(row.row_idx) {
+ if let Some(aggregator) = aggregators
+ .as_mut()
+ .and_then(|aggregators|
aggregators.get_mut(output_col_idx))
+ .and_then(Option::as_mut)
+ {
+ aggregator.agg(source_array, row.row_idx)?;
+ } else if !source_array.is_null(row.row_idx) {
*selected = Some((row.batch_idx, row.row_idx));
}
}
@@ -344,8 +390,8 @@ impl MergeFunction for PartialUpdateMergeFunction {
continue;
}
- let should_advance = match group_sequence_rows[group_idx] {
- None => true,
+ let sequence_ordering = match group_sequence_rows[group_idx] {
+ None => Ordering::Greater,
Some((current_batch_idx, current_row_idx)) =>
compare_sequence_group_rows(
row.batch_idx,
row.row_idx,
@@ -354,9 +400,28 @@ impl MergeFunction for PartialUpdateMergeFunction {
&group.sequence_indices,
batch_buffer,
source_output_col_indices,
- )?
- .is_ge(),
+ )?,
};
+ let should_advance = sequence_ordering.is_ge();
+
+ for &output_col_idx in &group.protected_indices {
+ if let Some(aggregator) = aggregators
+ .as_mut()
+ .and_then(|aggregators|
aggregators.get_mut(output_col_idx))
+ .and_then(Option::as_mut)
+ {
+ let source_array = batch_buffer[row.batch_idx]
+ .column_for_output(output_col_idx,
source_output_col_indices);
+ if should_advance {
+ aggregator.agg(source_array, row.row_idx)?;
+ } else {
+ aggregator.agg_reversed(source_array,
row.row_idx)?;
+ }
+ } else if should_advance {
+ selected_by_col[output_col_idx] = Some((row.batch_idx,
row.row_idx));
+ }
+ }
+
if !should_advance {
continue;
}
@@ -365,9 +430,6 @@ impl MergeFunction for PartialUpdateMergeFunction {
for &output_col_idx in &group.sequence_indices {
selected_by_col[output_col_idx] = Some((row.batch_idx,
row.row_idx));
}
- for &output_col_idx in &group.protected_indices {
- selected_by_col[output_col_idx] = Some((row.batch_idx,
row.row_idx));
- }
}
}
@@ -380,23 +442,29 @@ impl MergeFunction for PartialUpdateMergeFunction {
.iter()
.enumerate()
.map(|(output_col_idx, field)| {
- Ok(match selected_by_col[output_col_idx] {
- Some((batch_idx, row_idx)) => batch_buffer[batch_idx]
- .column_for_output(output_col_idx,
source_output_col_indices)
- .slice(row_idx, 1),
- None => {
- if !field.is_nullable() {
- return Err(Error::DataInvalid {
- message: format!(
- "merge-engine=partial-update produced NULL
for non-nullable field '{}'",
- field.name()
- ),
- source: None,
- });
- }
- new_null_array(field.data_type(), 1)
- }
- })
+ let column = match aggregators
+ .as_ref()
+ .and_then(|aggregators| aggregators.get(output_col_idx))
+ .and_then(Option::as_ref)
+ {
+ Some(aggregator) => aggregator.result()?,
+ None => match selected_by_col[output_col_idx] {
+ Some((batch_idx, row_idx)) => batch_buffer[batch_idx]
+ .column_for_output(output_col_idx,
source_output_col_indices)
+ .slice(row_idx, 1),
+ None => new_null_array(field.data_type(), 1),
+ },
+ };
+ if !field.is_nullable() && column.is_null(0) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "merge-engine=partial-update produced NULL for
non-nullable field '{}'",
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ Ok(column)
})
.collect::<crate::Result<Vec<_>>>()?;
@@ -2303,6 +2371,300 @@ mod tests {
assert!(batch.column(3).is_null(0));
}
+ #[tokio::test]
+ async fn
test_partial_update_aggregation_composite_sequence_accepts_partial_null_tuple()
{
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("_SEQUENCE_NUMBER", DataType::Int64, false),
+ Field::new("_VALUE_KIND", DataType::Int8, false),
+ Field::new("seq_major", DataType::Int32, true),
+ Field::new("seq_minor", DataType::Int32, true),
+ Field::new("value", DataType::Utf8, true),
+ ]));
+ let output_schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("seq_major", DataType::Int32, true),
+ Field::new("seq_minor", DataType::Int32, true),
+ Field::new("value", DataType::Utf8, true),
+ ]));
+ let output_fields = vec![
+ DataField::new(0, "pk".into(),
crate::spec::DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "seq_major".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 2,
+ "seq_minor".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 3,
+ "value".into(),
+ crate::spec::DataType::VarChar(VarCharType::string_type()),
+ ),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.seq_major,seq_minor.sequence-group".to_string(),
+ "value".to_string(),
+ ),
+ (
+ "fields.value.aggregate-function".to_string(),
+ "listagg".to_string(),
+ ),
+ ]);
+ let stream = stream_from_batches(vec![RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1, 1, 1])),
+ Arc::new(Int64Array::from(vec![1, 2, 3])),
+ Arc::new(Int8Array::from(vec![0, 0, 0])),
+ Arc::new(Int32Array::from(vec![Some(10), Some(11), None])),
+ Arc::new(Int32Array::from(vec![Some(1), None, None])),
+ Arc::new(StringArray::from(vec![
+ Some("base"),
+ Some("partial"),
+ Some("ignored"),
+ ])),
+ ],
+ )
+ .unwrap()]);
+
+ let result = SortMergeReaderBuilder::new(
+ vec![stream],
+ schema,
+ vec![0],
+ 1,
+ 2,
+ vec![],
+ vec![3, 4, 5],
+ output_schema,
+ Box::new(
+ PartialUpdateMergeFunction::new_with_schema(
+ &options,
+ "test_table",
+ &output_fields,
+ &output_fields,
+ &["pk".to_string()],
+ )
+ .unwrap(),
+ ),
+ )
+ .build()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let batch = &result[0];
+ assert_eq!(
+ batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 11
+ );
+ assert!(batch.column(2).is_null(0));
+ assert_eq!(
+ batch
+ .column(3)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .value(0),
+ "base,partial"
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_partial_update_last_non_null_aggregation_without_sequence_group() {
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("_SEQUENCE_NUMBER", DataType::Int64, false),
+ Field::new("_VALUE_KIND", DataType::Int8, false),
+ Field::new("value", DataType::Int32, true),
+ ]));
+ let output_schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("value", DataType::Int32, true),
+ ]));
+ let output_fields = vec![
+ DataField::new(0, "pk".into(),
crate::spec::DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "value".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.value.aggregate-function".to_string(),
+ "last_non_null_value".to_string(),
+ ),
+ ]);
+ let stream = stream_from_batches(vec![RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1, 1, 1])),
+ Arc::new(Int64Array::from(vec![1, 2, 3])),
+ Arc::new(Int8Array::from(vec![0, 0, 0])),
+ Arc::new(Int32Array::from(vec![Some(10), Some(30), None])),
+ ],
+ )
+ .unwrap()]);
+
+ let result = SortMergeReaderBuilder::new(
+ vec![stream],
+ schema,
+ vec![0],
+ 1,
+ 2,
+ vec![],
+ vec![3],
+ output_schema,
+ Box::new(
+ PartialUpdateMergeFunction::new_with_schema(
+ &options,
+ "test_table",
+ &output_fields,
+ &output_fields,
+ &["pk".to_string()],
+ )
+ .unwrap(),
+ ),
+ )
+ .build()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ result[0]
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 30
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_partial_update_sequence_group_listagg_preserves_group_sequence_order() {
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("_SEQUENCE_NUMBER", DataType::Int64, false),
+ Field::new("_VALUE_KIND", DataType::Int8, false),
+ Field::new("version", DataType::Int32, true),
+ Field::new("value", DataType::Utf8, true),
+ ]));
+ let output_schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("version", DataType::Int32, true),
+ Field::new("value", DataType::Utf8, true),
+ ]));
+ let output_fields = vec![
+ DataField::new(0, "pk".into(),
crate::spec::DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "version".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 2,
+ "value".into(),
+ crate::spec::DataType::VarChar(VarCharType::string_type()),
+ ),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.version.sequence-group".to_string(),
+ "value".to_string(),
+ ),
+ (
+ "fields.value.aggregate-function".to_string(),
+ "listagg".to_string(),
+ ),
+ ]);
+ let stream = stream_from_batches(vec![RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1, 1, 1, 1, 1])),
+ Arc::new(Int64Array::from(vec![1, 2, 3, 4, 5])),
+ Arc::new(Int8Array::from(vec![0, 0, 0, 0, 0])),
+ Arc::new(Int32Array::from(vec![
+ Some(10),
+ Some(12),
+ Some(11),
+ Some(12),
+ None,
+ ])),
+ Arc::new(StringArray::from(vec![
+ Some("b"),
+ Some("d"),
+ Some("c"),
+ Some("e"),
+ Some("ignored"),
+ ])),
+ ],
+ )
+ .unwrap()]);
+
+ let result = SortMergeReaderBuilder::new(
+ vec![stream],
+ schema,
+ vec![0],
+ 1,
+ 2,
+ vec![],
+ vec![3, 4],
+ output_schema,
+ Box::new(
+ PartialUpdateMergeFunction::new_with_schema(
+ &options,
+ "test_table",
+ &output_fields,
+ &output_fields,
+ &["pk".to_string()],
+ )
+ .unwrap(),
+ ),
+ )
+ .build()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let batch = &result[0];
+ assert_eq!(
+ batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 12
+ );
+ assert_eq!(
+ batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .value(0),
+ "c,b,d,e"
+ );
+ }
+
#[tokio::test]
async fn test_partial_update_merge_rejects_delete_like_rows() {
let schema = make_schema();
@@ -2517,6 +2879,49 @@ mod tests {
));
}
+ #[test]
+ fn test_partial_update_new_with_schema_validates_aggregate_functions() {
+ let fields = vec![
+ DataField::new(0, "pk".into(),
crate::spec::DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "version".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 2,
+ "value".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.version.sequence-group".to_string(),
+ "value".to_string(),
+ ),
+ (
+ "fields.value.aggregate-function".to_string(),
+ "sume".to_string(),
+ ),
+ ]);
+
+ let err = PartialUpdateMergeFunction::new_with_schema(
+ &options,
+ "default.t",
+ &fields,
+ &fields,
+ &["pk".to_string()],
+ )
+ .unwrap_err();
+
+ assert!(
+ matches!(err, Error::ConfigInvalid { ref message }
+ if message.contains("sume") && message.contains("value")),
+ "expected schema-aware aggregate validation, got {err:?}"
+ );
+ }
+
// ---------- AggregateMergeFunction ----------
use crate::spec::{DataType as PaimonDataType, IntType, VarCharType};
diff --git a/docs/src/sql.md b/docs/src/sql.md
index a84a956e..08b76482 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1894,8 +1894,22 @@ composite sequence fields, multiple independent groups,
and projected reads are
supported. Rows whose sequence tuple is entirely null do not update the group;
an accepted group update can set protected fields to null. Rust table creation
and writes still reject sequence-group options because write-side group merging
-is not implemented. Partial aggregation inside sequence groups and
-remove-record options are also not supported.
+is not implemented.
+
+Existing partial-update tables may also configure
+`fields.<field>.aggregate-function` or
+`fields.default-aggregate-function`. Rust supports the same aggregate-function
+set as the basic aggregation engine: `sum`, `product`, `min`, `max`,
+`last_value`, `first_value`, `last_non_null_value`,
+`first_non_null_value`, `bool_and`, `bool_or`, and `listagg`.
+`last_non_null_value` may be used without a sequence group; other functions
+require the target field to be protected by a sequence group. When an older
+group sequence arrives after a newer one, order-sensitive functions use Java
+compatible reversed aggregation. Unknown functions and incompatible
+function/type combinations fail closed. Rust table creation and writes still
+reject partial-update aggregation options.
+
+Partial-update remove-record options and retract semantics are not supported.
Rust can read fully materialized compacted files from deletion-vector-enabled
partial-update and aggregation tables. Every split must be raw-convertible,