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 78e7ebb3 fix(spec): accept the legacy first_not_null_value aggregate 
name (#794)
78e7ebb3 is described below

commit 78e7ebb3bede40ef8783bda39721dad34a3de8d2
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 10 15:41:30 2026 +0800

    fix(spec): accept the legacy first_not_null_value aggregate name (#794)
---
 crates/integrations/datafusion/tests/pk_tables.rs | 51 +++++++++++++++
 crates/paimon/src/spec/aggregation.rs             | 75 +++++++++++++++++++++--
 crates/paimon/src/spec/mod.rs                     |  2 +-
 crates/paimon/src/spec/partial_update.rs          | 31 ++++++++++
 crates/paimon/src/table/aggregator/mod.rs         | 69 ++++++++++++++++++++-
 5 files changed, 220 insertions(+), 8 deletions(-)

diff --git a/crates/integrations/datafusion/tests/pk_tables.rs 
b/crates/integrations/datafusion/tests/pk_tables.rs
index 83a24d24..3e675636 100644
--- a/crates/integrations/datafusion/tests/pk_tables.rs
+++ b/crates/integrations/datafusion/tests/pk_tables.rs
@@ -2674,6 +2674,57 @@ async fn test_pk_aggregation_mixed_aggregators() {
     assert_eq!(string_value(first_seen.as_ref(), 0), "a"); // first non-null 
wins
 }
 
+/// Java registers `first_not_null_value` as an SPI alias of
+/// `first_non_null_value`, so a schema written by Java/Flink can carry it. 
Both
+/// CREATE TABLE and the read-side merge function must resolve it. Planning 
does
+/// not build the merge function, so before the fix a table carrying the alias
+/// planned fine and only failed once rows were pulled.
+///
+/// The read side also has unit coverage in `paimon`; if this ever goes red, 
the
+/// alias belongs in both places, not out of CREATE.
+#[tokio::test]
+async fn test_pk_aggregation_accepts_legacy_first_not_null_value() {
+    let (_tmp, sql_context) = setup_sql_context().await;
+
+    sql_context
+        .sql(
+            "CREATE TABLE paimon.test_db.t_agg_legacy_name (
+                id INT NOT NULL, first_seen STRING,
+                PRIMARY KEY (id)
+            ) WITH (
+                'bucket' = '1',
+                'merge-engine' = 'aggregation',
+                'fields.first_seen.aggregate-function' = 'first_not_null_value'
+            )",
+        )
+        .await
+        .unwrap();
+
+    for values in ["(1, NULL)", "(1, 'b')", "(1, 'c')"] {
+        sql_context
+            .sql(&format!(
+                "INSERT INTO paimon.test_db.t_agg_legacy_name VALUES {values}"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+    }
+
+    let batches = sql_context
+        .sql("SELECT id, first_seen FROM paimon.test_db.t_agg_legacy_name")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 1);
+    let first_seen = batches[0].column_by_name("first_seen").unwrap();
+    assert_eq!(string_value(first_seen.as_ref(), 0), "b");
+}
+
 /// `sequence.field` forces the named column to `last_value`, even when a
 /// table-level default aggregator would otherwise apply.
 #[tokio::test]
diff --git a/crates/paimon/src/spec/aggregation.rs 
b/crates/paimon/src/spec/aggregation.rs
index 7885a86a..5f2b4c28 100644
--- a/crates/paimon/src/spec/aggregation.rs
+++ b/crates/paimon/src/spec/aggregation.rs
@@ -358,13 +358,33 @@ pub(crate) fn remove_field_scoped_options(options: &mut 
HashMap<String, String>,
 const SUPPORTED_AGGREGATOR_NAMES_HINT: &str = "supported: sum, product, min, 
max, last_value, \
     first_value, last_non_null_value, first_non_null_value, bool_and, bool_or, 
listagg";
 
+/// Java keeps `first_not_null_value` registered as an SPI alias of
+/// `first_non_null_value`: `FieldFirstNonNullValueAggLegacyFactory` is listed 
in
+/// 
`paimon-core/src/main/resources/META-INF/services/org.apache.paimon.factories.Factory`
+/// next to the canonical factory, and both build the same
+/// `FieldFirstNonNullValueAgg`. The alias survives from the switch-case to SPI
+/// refactor and is deliberately undocumented, so a Java-written schema can 
carry
+/// it even though nothing suggests it — which is why it is resolved here but
+/// stays out of [`SUPPORTED_AGGREGATOR_NAMES_HINT`].
+///
+/// Matching is exact: Java's `FactoryUtil#discoverFactory` selects a factory
+/// with `identifier().equals(identifier)`, so neither case nor `-`/`_` is
+/// normalised.
+pub(crate) fn canonical_aggregator_name(name: &str) -> &str {
+    match name {
+        "first_not_null_value" => "first_non_null_value",
+        other => other,
+    }
+}
+
 /// Whether `name` matches one of the basic-mode aggregator identifiers.  Must
 /// stay in sync with the `match` arms in
-/// `crate::table::aggregator::new_aggregator` — guarded by
-/// `tests::validation_table_matches_constructors`.
+/// `crate::table::aggregator::new_aggregator`.  Both resolve the name through
+/// [`canonical_aggregator_name`] first; 
`tests::validation_table_matches_constructors`
+/// guards the type table below rather than this predicate.
 pub(crate) fn is_known_aggregator_name(name: &str) -> bool {
     matches!(
-        name,
+        canonical_aggregator_name(name),
         "sum"
             | "product"
             | "min"
@@ -388,7 +408,9 @@ pub(crate) fn validate_aggregator_for_type(
     field_name: &str,
     dt: &DataType,
 ) -> crate::Result<()> {
-    let ok = match name {
+    // Errors below echo the caller's `name`, not the canonical one, so a user
+    // never reads back a function name they did not write.
+    let ok = match canonical_aggregator_name(name) {
         "sum" => matches!(
             dt,
             DataType::TinyInt(_)
@@ -677,6 +699,48 @@ mod tests {
         );
     }
 
+    /// Java registers `first_not_null_value` as an SPI alias of
+    /// `first_non_null_value`, so a Java-written schema can carry it on all 
three
+    /// name-keyed paths: a non-primary-key column (type-checked), a 
primary-key
+    /// column (name-only), and `fields.default-aggregate-function`.
+    #[test]
+    fn test_legacy_first_not_null_value_alias_is_accepted() {
+        for (key, value) in [
+            ("fields.amount.aggregate-function", "first_not_null_value"),
+            ("fields.id.aggregate-function", "first_not_null_value"),
+            (FIELDS_DEFAULT_AGG_FUNCTION_OPTION, "first_not_null_value"),
+        ] {
+            let options = aggregation_options(&[(key, value)]);
+            AggregationConfig::new(&options)
+                .validate_create_mode(&pk(), &sample_fields())
+                .unwrap_or_else(|err| panic!("'{key}' = '{value}' should be 
accepted: {err:?}"));
+        }
+    }
+
+    /// The alias resolves by exact match, mirroring Java's
+    /// `FactoryUtil#discoverFactory`, which compares identifiers with 
`equals`.
+    /// Neither case nor `-`/`_` is normalised, and the reported name is the 
one
+    /// the user wrote.
+    #[test]
+    fn test_legacy_alias_near_misses_are_still_rejected() {
+        for name in [
+            "first_not_null_valu",
+            "first-not-null-value",
+            "FIRST_NOT_NULL_VALUE",
+            "not_null_value",
+        ] {
+            let options = 
aggregation_options(&[("fields.amount.aggregate-function", name)]);
+            let err = AggregationConfig::new(&options)
+                .validate_create_mode(&pk(), &sample_fields())
+                .unwrap_err();
+            assert!(
+                matches!(err, crate::Error::ConfigInvalid { ref message }
+                    if message.contains(name) && message.contains("amount")),
+                "'{name}' should be rejected and echoed back verbatim, got 
{err:?}"
+            );
+        }
+    }
+
     #[test]
     fn test_rejects_aggregation_on_sequence_field_for_every_merge_engine() {
         // Java rejects aggregation definitions on sequence fields inside
@@ -791,6 +855,9 @@ mod tests {
             "first_value",
             "last_non_null_value",
             "first_non_null_value",
+            // Java's SPI alias of the entry above; both sides must resolve it
+            // identically, which is exactly what this test locks.
+            "first_not_null_value",
             "bool_and",
             "bool_or",
             "listagg",
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index a165ffe0..8eac9957 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -43,7 +43,7 @@ pub(crate) use partial_update::PartialUpdateConfig;
 
 mod aggregation;
 pub(crate) use aggregation::{
-    remove_field_scoped_options, rename_field_scoped_options,
+    canonical_aggregator_name, remove_field_scoped_options, 
rename_field_scoped_options,
     validate_no_aggregation_on_sequence_field, AggregationConfig,
 };
 
diff --git a/crates/paimon/src/spec/partial_update.rs 
b/crates/paimon/src/spec/partial_update.rs
index d7fd9d73..25d6771f 100644
--- a/crates/paimon/src/spec/partial_update.rs
+++ b/crates/paimon/src/spec/partial_update.rs
@@ -350,6 +350,10 @@ impl<'a> PartialUpdateConfig<'a> {
                 continue;
             };
             validate_aggregator_for_type(function, field_name, 
field.data_type())?;
+            // Compared raw on purpose: Java `PartialUpdateMergeFunction` tests
+            // `aggFuncName.equals(FieldLastNonNullValueAggFactory.NAME)`, and 
that
+            // factory has no legacy alias — only `first_non_null_value` does 
— so
+            // there is nothing here to canonicalize.
             if function != "last_non_null_value" && 
!protected_fields.contains(field_name) {
                 return Err(crate::Error::ConfigInvalid {
                     message: format!(
@@ -807,6 +811,33 @@ mod tests {
         );
     }
 
+    /// The aggregate-function name helpers are shared with the aggregation 
merge
+    /// engine, so Java's `first_not_null_value` alias has to be accepted here
+    /// too. It is not `last_non_null_value`, so it still needs a sequence 
group.
+    #[test]
+    fn test_validate_aggregate_functions_accepts_legacy_first_not_null_value() 
{
+        let options = partial_update_options(&[
+            ("fields.version.sequence-group", "price"),
+            ("fields.price.aggregate-function", "first_not_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())),
+        ];
+
+        let functions = config
+            .validated_aggregate_functions(&fields, &["id".to_string()])
+            .unwrap();
+
+        assert_eq!(
+            functions.get("price").map(String::as_str),
+            Some("first_not_null_value"),
+            "the configured spelling must be preserved, got {functions:?}"
+        );
+    }
+
     #[test]
     fn test_validate_aggregate_functions_rejects_unknown_field() {
         let options =
diff --git a/crates/paimon/src/table/aggregator/mod.rs 
b/crates/paimon/src/table/aggregator/mod.rs
index 787c8d60..d92e02f3 100644
--- a/crates/paimon/src/table/aggregator/mod.rs
+++ b/crates/paimon/src/table/aggregator/mod.rs
@@ -96,7 +96,16 @@ pub(crate) fn new_aggregator(
     data_type: &DataType,
     table_options: &HashMap<String, String>,
 ) -> crate::Result<Box<dyn FieldAggregator>> {
-    match name {
+    // `canonical_aggregator_name` folds Java's undocumented SPI alias
+    // `first_not_null_value` onto `first_non_null_value`; the error arm still
+    // echoes the caller's spelling. The aggregator itself reports the 
canonical
+    // name from `FieldAggregator::name`, which costs no parity: the one Java
+    // message that repeats the configured identifier is the retract rejection 
in
+    // `FieldAggregator`, and retract is rejected here before an aggregator is
+    // ever built. Java's non-nullable diagnostic
+    // (`AggregateMergeFunction`: "Field <i> can not be null") names no 
function
+    // at all, so the Rust equivalent in `sort_merge` is strictly more 
specific.
+    match crate::spec::canonical_aggregator_name(name) {
         "sum" => Ok(Box::new(SumAgg::new(field_name, data_type)?)),
         "product" => Ok(Box::new(ProductAgg::new(field_name, data_type)?)),
         "min" => Ok(Box::new(MinAgg::new(field_name, data_type)?)),
@@ -112,9 +121,9 @@ pub(crate) fn new_aggregator(
             data_type,
             table_options,
         )?)),
-        other => Err(crate::Error::ConfigInvalid {
+        _ => Err(crate::Error::ConfigInvalid {
             message: format!(
-                "Unknown aggregate function '{other}' for field 
'{field_name}'; \
+                "Unknown aggregate function '{name}' for field '{field_name}'; 
\
                  supported: sum, product, min, max, last_value, first_value, \
                  last_non_null_value, first_non_null_value, bool_and, bool_or, 
listagg"
             ),
@@ -136,3 +145,57 @@ pub(crate) fn unsupported_type_error(
         ),
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use arrow_array::Int32Array;
+
+    use super::*;
+    use crate::spec::IntType;
+
+    /// Java's legacy alias must build the very same aggregator, not merely 
pass
+    /// validation: `FieldFirstNonNullValueAggLegacyFactory` returns a
+    /// `FieldFirstNonNullValueAgg`, so both names have to lock the first
+    /// non-null value.
+    #[test]
+    fn test_legacy_first_not_null_value_aggregates_like_the_canonical_name() {
+        let data_type = DataType::Int(IntType::new());
+        let options = HashMap::new();
+        let input = Int32Array::from(vec![None, Some(5), Some(7)]);
+
+        let mut results = Vec::new();
+        for name in ["first_non_null_value", "first_not_null_value"] {
+            let mut agg = new_aggregator(name, "v", &data_type, &options)
+                .unwrap_or_else(|err| panic!("'{name}' should construct: 
{err:?}"));
+            for row in 0..input.len() {
+                agg.agg(&input, row).unwrap();
+            }
+            let out = agg.result().unwrap();
+            let out = out
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .expect("Int32 result");
+            results.push(out.is_valid(0).then(|| out.value(0)));
+        }
+        assert_eq!(results[0], Some(5));
+        assert_eq!(results[0], results[1], "alias diverged from canonical 
name");
+    }
+
+    /// An unknown name is echoed back exactly as written, so resolving the 
alias
+    /// never renames what the user configured.
+    #[test]
+    fn test_unknown_aggregate_function_is_echoed_verbatim() {
+        let err = new_aggregator(
+            "first_not_null_valu",
+            "v",
+            &DataType::Int(IntType::new()),
+            &HashMap::new(),
+        )
+        .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("'first_not_null_valu'")),
+            "expected the caller's spelling, got {err:?}"
+        );
+    }
+}

Reply via email to