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 28971b56 fix(spec): validate sequence.field against table schema (#615)
28971b56 is described below

commit 28971b568d7073c509c7ccc1c02f4882b41f930f
Author: jackylee <[email protected]>
AuthorDate: Tue Aug 4 21:57:05 2026 +0800

    fix(spec): validate sequence.field against table schema (#615)
---
 crates/paimon/src/spec/aggregation.rs |  93 +++++++++---
 crates/paimon/src/spec/mod.rs         |   3 +-
 crates/paimon/src/spec/schema.rs      | 268 +++++++++++++++++++++++++++++++++-
 3 files changed, 337 insertions(+), 27 deletions(-)

diff --git a/crates/paimon/src/spec/aggregation.rs 
b/crates/paimon/src/spec/aggregation.rs
index 194d7952..7885a86a 100644
--- a/crates/paimon/src/spec/aggregation.rs
+++ b/crates/paimon/src/spec/aggregation.rs
@@ -185,9 +185,6 @@ impl<'a> AggregationConfig<'a> {
         fields: &[DataField],
         primary_keys: &[String],
     ) -> crate::Result<()> {
-        // Same source as the read path: `sequence.field` parsed by 
CoreOptions.
-        let core_options = CoreOptions::new(self.options);
-        let sequence_fields = core_options.sequence_fields();
         for (key, value) in self.options {
             let Some((col, kind)) = parse_field_scoped_option_key(key) else {
                 continue;
@@ -204,14 +201,6 @@ impl<'a> AggregationConfig<'a> {
                 });
             };
             if matches!(kind, FieldScopedOptionKind::AggregateFunction) {
-                if sequence_fields.contains(&col) {
-                    return Err(crate::Error::ConfigInvalid {
-                        message: format!(
-                            "Should not define aggregation on sequence field: 
'{col}'."
-                        ),
-                    });
-                }
-
                 if primary_keys.iter().any(|pk| pk == col) {
                     if !is_known_aggregator_name(value) {
                         return Err(crate::Error::ConfigInvalid {
@@ -460,6 +449,31 @@ pub(crate) fn validate_aggregator_for_type(
     }
 }
 
+/// Reject `fields.<col>.aggregate-function` on a column listed in
+/// `sequence.field`, mirroring Java `SchemaValidation#validateSequenceField`,
+/// which checks `options.fieldAggFunc(field) == null` for every sequence field
+/// regardless of the configured merge engine.
+pub(crate) fn validate_no_aggregation_on_sequence_field(
+    options: &HashMap<String, String>,
+) -> crate::Result<()> {
+    let core_options = CoreOptions::new(options);
+    let sequence_fields = core_options.sequence_fields();
+    if sequence_fields.is_empty() {
+        return Ok(());
+    }
+
+    for field in sequence_fields {
+        let key = format!("{FIELDS_PREFIX}{field}{AGG_FUNCTION_SUFFIX}");
+        if options.contains_key(&key) {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!("Should not define aggregation on sequence 
field: '{field}'."),
+            });
+        }
+    }
+
+    Ok(())
+}
+
 fn is_unsupported_aggregation_option(key: &str) -> bool {
     key == IGNORE_DELETE_OPTION
         || key.ends_with(IGNORE_DELETE_SUFFIX)
@@ -664,22 +678,53 @@ mod tests {
     }
 
     #[test]
-    fn test_validate_create_mode_rejects_aggregation_on_sequence_field() {
-        // Java rejects aggregation definitions on sequence fields during
-        // schema validation; the runtime still forces sequence fields to
-        // last_value when reading old or externally-created metadata.
+    fn test_rejects_aggregation_on_sequence_field_for_every_merge_engine() {
+        // Java rejects aggregation definitions on sequence fields inside
+        // `validateSequenceField`, which runs for every merge engine; the
+        // runtime still forces sequence fields to last_value when reading old
+        // or externally-created metadata.
+        for engine in [
+            None,
+            Some("deduplicate"),
+            Some("first-row"),
+            Some("partial-update"),
+            Some("aggregation"),
+        ] {
+            let mut options = HashMap::from([
+                ("sequence.field".to_string(), "amount".to_string()),
+                (
+                    "fields.amount.aggregate-function".to_string(),
+                    "listagg".to_string(),
+                ),
+            ]);
+            if let Some(engine) = engine {
+                options.insert(MERGE_ENGINE_OPTION.to_string(), 
engine.to_string());
+            }
+
+            let err = 
validate_no_aggregation_on_sequence_field(&options).unwrap_err();
+            assert!(
+                matches!(err, crate::Error::ConfigInvalid { ref message }
+                    if message.contains("sequence field") && 
message.contains("amount")),
+                "merge-engine={engine:?} should reject aggregation on a 
sequence field, got {err:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_accepts_aggregation_on_a_non_sequence_field() {
+        // Guard against over-rejecting: only the sequence field itself is
+        // off limits, other columns may carry an aggregate function.
         let options = aggregation_options(&[
             ("sequence.field", "amount"),
-            ("fields.amount.aggregate-function", "listagg"),
+            ("fields.price.aggregate-function", "sum"),
         ]);
-        let err = AggregationConfig::new(&options)
-            .validate_create_mode(&pk(), &sample_fields())
-            .unwrap_err();
-        assert!(
-            matches!(err, crate::Error::ConfigInvalid { ref message }
-                if message.contains("sequence field") && 
message.contains("amount")),
-            "expected sequence-field aggregation rejection, got {err:?}"
-        );
+        assert!(validate_no_aggregation_on_sequence_field(&options).is_ok());
+    }
+
+    #[test]
+    fn test_accepts_options_without_a_sequence_field() {
+        let options = 
aggregation_options(&[("fields.amount.aggregate-function", "sum")]);
+        assert!(validate_no_aggregation_on_sequence_field(&options).is_ok());
     }
 
     #[test]
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index d210beed..22780b91 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -40,7 +40,8 @@ pub(crate) use partial_update::PartialUpdateConfig;
 
 mod aggregation;
 pub(crate) use aggregation::{
-    remove_field_scoped_options, rename_field_scoped_options, 
AggregationConfig,
+    remove_field_scoped_options, rename_field_scoped_options,
+    validate_no_aggregation_on_sequence_field, AggregationConfig,
 };
 
 mod data_type_casts;
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index ce7d1c40..c1c7bb83 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -22,8 +22,9 @@ use crate::spec::core_options::{
 };
 use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType, 
VarCharType};
 use crate::spec::{
-    remove_field_scoped_options, rename_field_scoped_options, 
AggregationConfig, BlobType,
-    ColumnMove, ColumnMoveType, PartialUpdateConfig,
+    remove_field_scoped_options, rename_field_scoped_options,
+    validate_no_aggregation_on_sequence_field, AggregationConfig, BlobType, 
ColumnMove,
+    ColumnMoveType, PartialUpdateConfig,
 };
 use serde::{Deserialize, Serialize};
 use serde_with::serde_as;
@@ -1136,11 +1137,13 @@ impl Schema {
         Self::validate_blob_fields(fields, partition_keys, options)?;
         Self::validate_vector_store_fields(fields, partition_keys, options)?;
         
PartialUpdateConfig::new(options).validate_create_mode(!primary_keys.is_empty())?;
+        validate_no_aggregation_on_sequence_field(options)?;
         AggregationConfig::new(options).validate_create_mode(primary_keys, 
fields)?;
         Self::validate_first_row_changelog_producer(options)?;
         Self::validate_rowkind_field(options, primary_keys, fields)?;
         Self::validate_deletion_vectors(options)?;
         Self::validate_bucket_keys(options, fields, partition_keys, 
primary_keys)?;
+        Self::validate_sequence_field(options, fields, partition_keys, 
primary_keys)?;
         Self::validate_read_batch_size(options)?;
         Self::validate_primary_key_vector_index(fields, primary_keys, 
options)?;
         Self::validate_primary_key_full_text_index(fields, primary_keys, 
options)?;
@@ -1647,6 +1650,73 @@ impl Schema {
             .map_err(Self::options_error_to_config_invalid)
     }
 
+    /// Validate the `sequence.field` option against the schema, mirroring four
+    /// of the five checks in Java `SchemaValidation#validateSequenceField`:
+    /// * every listed field must exist in the table schema — otherwise the
+    ///   write path silently falls back to the auto-increment sequence
+    ///   (`TableWrite` resolves sequence fields with a lenient lookup) and
+    ///   merge results would ignore the user's ordering intent;
+    /// * a field must not be listed more than once;
+    /// * `merge-engine=first-row` does not support user-defined sequence
+    ///   fields;
+    /// * cross-partition update tables (primary key constraint not including
+    ///   all partition fields) do not support user-defined sequence fields,
+    ///   because partition migration retracts old rows with generated DELETEs
+    ///   whose ordering a user-provided sequence value could break.
+    ///
+    /// The fifth check — Java's `options.fieldAggFunc(field) == null` — is
+    /// [`validate_no_aggregation_on_sequence_field`], which is keyed only on 
the
+    /// option map so it applies to every merge engine.
+    fn validate_sequence_field(
+        options: &HashMap<String, String>,
+        fields: &[DataField],
+        partition_keys: &[String],
+        primary_keys: &[String],
+    ) -> crate::Result<()> {
+        let core = CoreOptions::new(options);
+        let sequence_fields = core.sequence_fields();
+        if sequence_fields.is_empty() {
+            return Ok(());
+        }
+
+        let mut seen: HashSet<&str> = HashSet::new();
+        for name in &sequence_fields {
+            if fields.iter().all(|f| f.name() != *name) {
+                return Err(crate::Error::ConfigInvalid {
+                    message: format!("Sequence field '{name}' can not be found 
in table schema."),
+                });
+            }
+            if !seen.insert(name) {
+                return Err(crate::Error::ConfigInvalid {
+                    message: format!("Sequence field '{name}' is defined 
repeatedly."),
+                });
+            }
+        }
+
+        let merge_engine = core
+            .merge_engine()
+            .map_err(Self::options_error_to_config_invalid)?;
+        if merge_engine == MergeEngine::FirstRow {
+            return Err(crate::Error::ConfigInvalid {
+                message: "Do not support use sequence field on FIRST_ROW merge 
engine.".to_string(),
+            });
+        }
+
+        let cross_partition_update = !primary_keys.is_empty()
+            && !partition_keys.is_empty()
+            && partition_keys.iter().any(|pt| !primary_keys.contains(pt));
+        if cross_partition_update {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!(
+                    "You can not use sequence.field in cross partition update 
case \
+                     (Primary key constraint '{primary_keys:?}' not include 
all partition fields '{partition_keys:?}')."
+                ),
+            });
+        }
+
+        Ok(())
+    }
+
     fn validate_primary_key_vector_index(
         fields: &[DataField],
         primary_keys: &[String],
@@ -4382,6 +4452,55 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn 
test_create_schema_rejects_aggregation_on_sequence_field_without_agg_engine() {
+        // Java `validateSequenceField` checks `fieldAggFunc(field) == null` 
for
+        // every merge engine, so the default (deduplicate) engine must reject
+        // this too, not just `merge-engine=aggregation`.
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("ts", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("sequence.field", "ts")
+            .option("fields.ts.aggregate-function", "sum")
+            .build()
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("sequence field") && 
message.contains("ts")),
+            "aggregation on a sequence field should be rejected on the default 
\
+             merge engine, got {err:?}"
+        );
+    }
+
+    #[test]
+    fn 
test_alter_set_aggregation_on_sequence_field_rejected_without_agg_engine() {
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("ts", DataType::Int(IntType::new()))
+                .primary_key(["id"])
+                .option("sequence.field", "ts")
+                .build()
+                .unwrap(),
+        );
+
+        let err = table_schema
+            .apply_changes(vec![crate::spec::SchemaChange::set_option(
+                "fields.ts.aggregate-function".to_string(),
+                "sum".to_string(),
+            )])
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("sequence field") && 
message.contains("ts")),
+            "alter adding aggregation on a sequence field should be rejected, 
got {err:?}"
+        );
+    }
+
     #[test]
     fn test_create_schema_rejects_reserved_field_names() {
         // Java `SpecialFields.SYSTEM_FIELD_NAMES` plus the `_KEY_` prefix. A
@@ -4689,6 +4808,151 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn test_create_schema_rejects_unknown_sequence_field() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("ts", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("sequence.field", "no_such_col")
+            .build()
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("no_such_col") && message.contains("can 
not be found")),
+            "sequence.field referencing a missing column should be rejected, 
got {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_create_schema_rejects_repeated_sequence_field() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("ts", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("sequence.field", "ts,ts")
+            .build()
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("ts") && message.contains("repeatedly")),
+            "repeated sequence.field should be rejected, got {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_create_schema_rejects_sequence_field_with_first_row() {
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("ts", DataType::Int(IntType::new()))
+            .primary_key(["id"])
+            .option("merge-engine", "first-row")
+            .option("sequence.field", "ts")
+            .build()
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("FIRST_ROW")),
+            "sequence.field with merge-engine=first-row should be rejected, 
got {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_alter_set_unknown_sequence_field_rejected() {
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("ts", DataType::Int(IntType::new()))
+                .primary_key(["id"])
+                .build()
+                .unwrap(),
+        );
+
+        let err = table_schema
+            .apply_changes(vec![crate::spec::SchemaChange::set_option(
+                "sequence.field".to_string(),
+                "no_such_col".to_string(),
+            )])
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("no_such_col") && message.contains("can 
not be found")),
+            "alter setting sequence.field to a missing column should be 
rejected, got {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_create_schema_rejects_sequence_field_with_cross_partition_update() 
{
+        // PK (id) does not include the partition field (pt): cross-partition
+        // update case, where user-defined sequence fields are not supported.
+        let err = Schema::builder()
+            .column("pt", DataType::Int(IntType::new()))
+            .column("id", DataType::Int(IntType::new()))
+            .column("ts", DataType::Int(IntType::new()))
+            .partition_keys(["pt"])
+            .primary_key(["id"])
+            .option("sequence.field", "ts")
+            .build()
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("cross partition update")),
+            "sequence.field with cross-partition update should be rejected, 
got {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_create_schema_accepts_sequence_field_when_pk_covers_partitions() {
+        // PK includes the partition field: not a cross-partition update case.
+        let schema = Schema::builder()
+            .column("pt", DataType::Int(IntType::new()))
+            .column("id", DataType::Int(IntType::new()))
+            .column("ts", DataType::Int(IntType::new()))
+            .partition_keys(["pt"])
+            .primary_key(["pt", "id"])
+            .option("sequence.field", "ts")
+            .build();
+
+        assert!(
+            schema.is_ok(),
+            "sequence.field with PK covering partition fields should be 
accepted, got {schema:?}"
+        );
+    }
+
+    #[test]
+    fn test_alter_set_sequence_field_with_cross_partition_update_rejected() {
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("pt", DataType::Int(IntType::new()))
+                .column("id", DataType::Int(IntType::new()))
+                .column("ts", DataType::Int(IntType::new()))
+                .partition_keys(["pt"])
+                .primary_key(["id"])
+                .build()
+                .unwrap(),
+        );
+
+        let err = table_schema
+            .apply_changes(vec![crate::spec::SchemaChange::set_option(
+                "sequence.field".to_string(),
+                "ts".to_string(),
+            )])
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("cross partition update")),
+            "alter setting sequence.field on cross-partition update table 
should be rejected, got {err:?}"
+        );
+    }
+
     #[test]
     fn test_drop_column_referenced_by_sequence_field_rejected() {
         let table_schema = TableSchema::new(

Reply via email to