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 32331329 fix(spec): reject a non-positive or non-integer bucket number 
(#956)
32331329 is described below

commit 3233132995356ae6ef1e5c511e9b533114bd908f
Author: jackylee <[email protected]>
AuthorDate: Sun Oct 4 22:24:57 2026 +0800

    fix(spec): reject a non-positive or non-integer bucket number (#956)
---
 crates/paimon/src/spec/schema.rs | 104 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 104 insertions(+)

diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index d3d1913a..2ff6bde6 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -1257,6 +1257,7 @@ impl Schema {
         CoreOptions::new(options).table_type()?;
         validate_no_reserved_field_names(fields)?;
         Self::validate_key_field_types(fields, partition_keys, primary_keys, 
options)?;
+        Self::validate_bucket_count(options)?;
         Self::validate_row_tracking(primary_keys, options)?;
         super::map_shredding::validate(fields, options)?;
         Self::validate_blob_fields(fields, partition_keys, primary_keys, 
options)?;
@@ -1285,6 +1286,38 @@ impl Schema {
         Ok(())
     }
 
+    /// Reject an out-of-range or non-integer `bucket` option at create/alter 
time.
+    ///
+    /// `bucket()` parses the option with `unwrap_or(DEFAULT_BUCKET)`, so 
`bucket='0'`
+    /// is taken verbatim (silently disabling read-side bucket pruning) and a
+    /// non-integer such as `bucket='abc'` silently falls back to dynamic (-1) 
rather
+    /// than the fixed bucketing the user asked for. Mirror Java 
`validateBucket`'s
+    /// "number of buckets needs to be greater than 0" check: allow -1 
(dynamic),
+    /// -2 (postpone), and any value >= 1. (The Java `bucket=-1` + 
`bucket-key` arm is
+    /// intentionally not ported here; it is enforced separately by bucket-key
+    /// validation.)
+    fn validate_bucket_count(options: &HashMap<String, String>) -> 
crate::Result<()> {
+        let Some(raw) = options.get("bucket") else {
+            return Ok(());
+        };
+        // Parse the raw value exactly as `CoreOptions::bucket()` does (no 
trim):
+        // it uses `v.parse().ok().unwrap_or(-1)`, so a padded value like `" 4 
"`
+        // silently runs as dynamic (-1). Validating a trimmed copy would 
accept
+        // `" 4 "` here yet persist a value the runtime reads as -1, so reject 
what
+        // the runtime cannot parse.
+        let bucket: i32 = raw.parse().map_err(|_| crate::Error::ConfigInvalid {
+            message: format!("Option 'bucket' must be an integer, got: 
'{raw}'."),
+        })?;
+        if bucket < 1 && bucket != -1 && bucket != -2 {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!(
+                    "The number of buckets needs to be greater than 0, got: 
{bucket}."
+                ),
+            });
+        }
+        Ok(())
+    }
+
     fn validate_row_tracking(
         primary_keys: &[String],
         options: &HashMap<String, String>,
@@ -6140,6 +6173,77 @@ mod tests {
         }
     }
 
+    #[test]
+    fn bucket_zero_is_rejected() {
+        // bucket='0' is taken verbatim and silently disables read-side bucket
+        // pruning; Java rejects it as "needs to be greater than 0".
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("bucket", "0")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("greater than 0")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn bucket_non_integer_is_rejected() {
+        // A non-integer would silently fall back to dynamic (-1) rather than 
the
+        // fixed bucketing the user intended.
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("bucket", "abc")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("must be an integer")),
+            "got {err:?}"
+        );
+    }
+
+    #[test]
+    fn bucket_dynamic_and_fixed_values_are_accepted() {
+        // -1 (dynamic), -2 (postpone) and any value >= 1 stay valid.
+        for value in ["-1", "-2", "1", "16"] {
+            Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .option("bucket", value)
+                .build()
+                .unwrap_or_else(|e| panic!("bucket={value} should be accepted, 
got {e:?}"));
+        }
+    }
+
+    #[test]
+    fn bucket_padded_value_is_rejected_matching_runtime() {
+        // `CoreOptions::bucket()` parses the raw value without trimming and 
falls
+        // back to -1, so a padded `" 4 "` silently runs as dynamic. Validation
+        // must use the same grammar and reject it, not persist a value the 
runtime
+        // reads as -1.
+        let padded = HashMap::from([("bucket".to_string(), " 4 
".to_string())]);
+        assert_eq!(
+            CoreOptions::new(&padded).bucket(),
+            -1,
+            "runtime reads a padded bucket value as dynamic"
+        );
+        let err = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("bucket", " 4 ")
+            .build()
+            .unwrap_err();
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("must be an integer")),
+            "got {err:?}"
+        );
+        // The accepted spelling runs as the fixed count the user asked for.
+        let fixed = HashMap::from([("bucket".to_string(), "4".to_string())]);
+        assert_eq!(CoreOptions::new(&fixed).bucket(), 4);
+    }
+
     #[test]
     fn rowkind_field_rejects_changelog_producer_input() {
         let err = Schema::builder()

Reply via email to