laskoviymishka commented on code in PR #3256:
URL: https://github.com/apache/iceberg-rust/pull/3256#discussion_r4153248498


##########
crates/iceberg/src/spec/datatypes.rs:
##########
@@ -347,19 +344,39 @@ impl Serialize for PrimitiveType {
     }
 }
 
+fn validate_decimal_precision(precision: u32) -> Result<()> {
+    ensure_data_valid!(precision > 0, "Decimal precision must be greater than 
zero",);
+    ensure_data_valid!(
+        precision <= MAX_DECIMAL_PRECISION,
+        "Decimals with precision larger than {MAX_DECIMAL_PRECISION} are not 
supported: {precision}",
+    );
+    Ok(())
+}
+
 fn deserialize_decimal<'de, D>(deserializer: D) -> 
std::result::Result<PrimitiveType, D::Error>
 where D: Deserializer<'de> {
     let s = String::deserialize(deserializer)?;
+    let malformed = || D::Error::custom(format!("Invalid decimal type: {s}"));
+
     let (precision, scale) = s
-        .trim_start_matches(r"decimal(")
-        .trim_end_matches(')')
+        .strip_prefix("decimal(")
+        .and_then(|inner| inner.strip_suffix(')'))
+        .ok_or_else(malformed)?
         .split_once(',')
-        .ok_or_else(|| D::Error::custom("Decimal requires precision and scale: 
{s}"))?;
+        .ok_or_else(|| D::Error::custom(format!("Decimal requires precision 
and scale: {s}")))?;
 
-    Ok(PrimitiveType::Decimal {
-        precision: precision.trim().parse().map_err(D::Error::custom)?,
-        scale: scale.trim().parse().map_err(D::Error::custom)?,
-    })
+    let (precision, scale) = (precision.trim(), scale.trim());
+    if ![precision, scale]
+        .iter()
+        .all(|token| token.bytes().all(|byte| byte.is_ascii_digit()))

Review Comment:
   `[precision, scale].iter().all(|t| t.bytes().all(is_ascii_digit))` is 
vacuously true on an empty token, so `decimal(, 2)` and `decimal(5,)` slip past 
this guard and only get rejected downstream by `.parse()` — same for `fixed[]` 
via the `.filter(...)` in `deserialize_fixed`. The observable behavior is 
already correct (and matches the `decimal()` / `fixed[]` reject cases in 
apache/iceberg-verification#9), so this is clarity rather than a bug — but the 
guard isn't what's doing the work. I'd fold in the emptiness check and flip the 
negation to `any` while we're here:
   
   ```rust
   if [precision, scale]
       .iter()
       .any(|token| token.is_empty() || !token.bytes().all(|b| 
b.is_ascii_digit()))
   {
       return Err(malformed());
   }
   ```
   
   Worth adding `decimal(, 2)` / `decimal(5,)` to the rejection test so the 
guard path is what's exercised, not the parse fallback.



##########
crates/iceberg/src/spec/datatypes.rs:
##########
@@ -347,19 +344,39 @@ impl Serialize for PrimitiveType {
     }
 }
 
+fn validate_decimal_precision(precision: u32) -> Result<()> {
+    ensure_data_valid!(precision > 0, "Decimal precision must be greater than 
zero",);
+    ensure_data_valid!(
+        precision <= MAX_DECIMAL_PRECISION,
+        "Decimals with precision larger than {MAX_DECIMAL_PRECISION} are not 
supported: {precision}",
+    );
+    Ok(())
+}
+
 fn deserialize_decimal<'de, D>(deserializer: D) -> 
std::result::Result<PrimitiveType, D::Error>
 where D: Deserializer<'de> {
     let s = String::deserialize(deserializer)?;
+    let malformed = || D::Error::custom(format!("Invalid decimal type: {s}"));
+
     let (precision, scale) = s
-        .trim_start_matches(r"decimal(")
-        .trim_end_matches(')')
+        .strip_prefix("decimal(")
+        .and_then(|inner| inner.strip_suffix(')'))
+        .ok_or_else(malformed)?
         .split_once(',')
-        .ok_or_else(|| D::Error::custom("Decimal requires precision and scale: 
{s}"))?;
+        .ok_or_else(|| D::Error::custom(format!("Decimal requires precision 
and scale: {s}")))?;
 
-    Ok(PrimitiveType::Decimal {
-        precision: precision.trim().parse().map_err(D::Error::custom)?,
-        scale: scale.trim().parse().map_err(D::Error::custom)?,
-    })
+    let (precision, scale) = (precision.trim(), scale.trim());
+    if ![precision, scale]
+        .iter()
+        .all(|token| token.bytes().all(|byte| byte.is_ascii_digit()))
+    {
+        return Err(malformed());
+    }
+    let precision: u32 = precision.parse().map_err(|_| malformed())?;

Review Comment:
   Once a token clears the digit filter, the only thing `.parse::<u32>()` can 
still reject is overflow (e.g. `decimal(9999999999, 2)`), and `map_err(|_| 
malformed())` collapses that into a generic `Invalid decimal type` — the old 
code forwarded the real `ParseIntError`. Minor, but I'd keep 
`map_err(D::Error::custom)` on the parse so overflow still says what it is. 
Same story with `deserialize_fixed`'s `.parse().ok()`.



##########
crates/iceberg/src/spec/datatypes.rs:
##########
@@ -1326,6 +1343,135 @@ mod tests {
         assert_eq!(16, Type::decimal_required_bytes(38).unwrap());
     }
 
+    #[test]
+    fn test_reject_malformed_decimal_and_fixed_type_strings() {
+        for invalid in [
+            r#""decimal(50, 2)""#,
+            r#""decimal(0, 0)""#,
+            r#""decimal(decimal(5, 2)))""#,
+            r#""decimal(decimal(5, 2)""#,
+            r#""decimal(5, 2""#,
+            r#""decimal(5, 2)))))""#,
+            r#""decimal(5, 2, 3)""#,
+            r#""decimal(-5, 2)""#,
+            r#""decimal(5, -2)""#,
+            r#""decimal(-5, -2)""#,
+            r#""decimal(-2, -5)""#,
+            r#""decimal((5, 2))""#,
+            r#""decimal[5, 2]""#,
+            r#""decimal()""#,
+            r#""fixed[fixed[16]]]""#,
+            r#""fixed[16""#,
+            r#""fixed[16]]]""#,
+            r#""fixed[[16]]""#,
+            r#""fixed[[16]""#,
+            r#""fixed(16)""#,
+            r#""fixed[]""#,
+        ] {
+            assert!(
+                serde_json::from_str::<Type>(invalid).is_err(),
+                "expected {invalid} to be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn test_reject_leading_plus_in_type_strings() {
+        for invalid in [
+            r#""decimal(+5, 2)""#,
+            r#""decimal(5, +2)""#,
+            r#""decimal(+5, +2)""#,
+            r#""fixed[+16]""#,
+        ] {
+            assert!(serde_json::from_str::<Type>(invalid).is_err(), 
"{invalid}");
+            assert!(
+                serde_json::from_str::<PrimitiveType>(invalid).is_err(),
+                "{invalid}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_decimal_constructor_json_roundtrip() {
+        for (precision, scale) in [(1, 0), (5, 5), (5, 8), (38, 38)] {
+            let decimal = Type::decimal(precision, scale).unwrap();
+            let serialized = serde_json::to_string(&decimal).unwrap();
+            let reparsed: Type = serde_json::from_str(&serialized).unwrap();
+            assert_eq!(reparsed, decimal);
+            let primitive: PrimitiveType = 
serde_json::from_str(&serialized).unwrap();
+            assert_eq!(Type::Primitive(primitive), decimal);
+        }
+        for precision in [0, 39] {
+            assert!(Type::decimal(precision, 0).is_err());
+            let json = format!(r#""decimal({precision}, 0)""#);
+            assert!(serde_json::from_str::<Type>(&json).is_err());
+            assert!(serde_json::from_str::<PrimitiveType>(&json).is_err());
+        }
+    }
+
+    #[test]
+    fn test_decimal_zero_precision_error() {
+        let error = serde_json::from_str::<PrimitiveType>(r#""decimal(0, 
0)""#).unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("Decimal precision must be greater than zero"),
+            "unexpected error: {error}"
+        );
+    }
+
+    #[test]
+    fn test_accept_valid_decimal_and_fixed_type_strings() {
+        for (json, expected) in [
+            (
+                r#""decimal(9, 2)""#,
+                Type::Primitive(PrimitiveType::Decimal {
+                    precision: 9,
+                    scale: 2,
+                }),
+            ),
+            (
+                r#""decimal(38, 10)""#,
+                Type::Primitive(PrimitiveType::Decimal {
+                    precision: 38,
+                    scale: 10,
+                }),
+            ),
+            (
+                r#""decimal(5,2)""#,
+                Type::Primitive(PrimitiveType::Decimal {
+                    precision: 5,
+                    scale: 2,
+                }),
+            ),
+            (
+                r#""decimal(5, 0)""#,
+                Type::Primitive(PrimitiveType::Decimal {
+                    precision: 5,
+                    scale: 0,
+                }),
+            ),
+            (
+                r#""decimal(5, 5)""#,
+                Type::Primitive(PrimitiveType::Decimal {
+                    precision: 5,
+                    scale: 5,
+                }),
+            ),
+            (r#""fixed[16]""#, Type::Primitive(PrimitiveType::Fixed(16))),
+        ] {
+            check_type_serde_roundtrip_value(json, expected);
+        }
+    }
+
+    fn check_type_serde_roundtrip_value(json: &str, expected_type: Type) {

Review Comment:
   This checks `parse(json) == expected` and `parse(serialize(expected)) == 
expected`, but never that `serialize(parse(json))` matches the input — so 
`decimal(5,2)` (no space) doesn't actually round-trip through the string form. 
That canonical write direction is exactly what the conformance suite makes 
normative: apache/iceberg-verification#9 pins `decimal(9,2)` re-serializing to 
the spaced `decimal(9, 2)` byte-for-byte. So asserting `to_string(parse(json))` 
equals the canonical form here isn't just naming hygiene — it's the check that 
proves the write direction. I'd either reuse the existing `check_type_serde` 
for the canonical cases or add that assertion (and drop `roundtrip` from the 
name if you keep it weak).



##########
crates/iceberg/src/spec/datatypes.rs:
##########
@@ -1326,6 +1343,135 @@ mod tests {
         assert_eq!(16, Type::decimal_required_bytes(38).unwrap());
     }
 
+    #[test]
+    fn test_reject_malformed_decimal_and_fixed_type_strings() {
+        for invalid in [
+            r#""decimal(50, 2)""#,
+            r#""decimal(0, 0)""#,
+            r#""decimal(decimal(5, 2)))""#,
+            r#""decimal(decimal(5, 2)""#,
+            r#""decimal(5, 2""#,
+            r#""decimal(5, 2)))))""#,
+            r#""decimal(5, 2, 3)""#,
+            r#""decimal(-5, 2)""#,
+            r#""decimal(5, -2)""#,
+            r#""decimal(-5, -2)""#,
+            r#""decimal(-2, -5)""#,
+            r#""decimal((5, 2))""#,
+            r#""decimal[5, 2]""#,
+            r#""decimal()""#,
+            r#""fixed[fixed[16]]]""#,
+            r#""fixed[16""#,
+            r#""fixed[16]]]""#,
+            r#""fixed[[16]]""#,
+            r#""fixed[[16]""#,
+            r#""fixed(16)""#,
+            r#""fixed[]""#,
+        ] {
+            assert!(
+                serde_json::from_str::<Type>(invalid).is_err(),
+                "expected {invalid} to be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn test_reject_leading_plus_in_type_strings() {
+        for invalid in [
+            r#""decimal(+5, 2)""#,
+            r#""decimal(5, +2)""#,
+            r#""decimal(+5, +2)""#,
+            r#""fixed[+16]""#,
+        ] {
+            assert!(serde_json::from_str::<Type>(invalid).is_err(), 
"{invalid}");
+            assert!(
+                serde_json::from_str::<PrimitiveType>(invalid).is_err(),
+                "{invalid}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_decimal_constructor_json_roundtrip() {
+        for (precision, scale) in [(1, 0), (5, 5), (5, 8), (38, 38)] {

Review Comment:
   The `(5, 8)` case pins `scale > precision` as supported, and the 
deserializer has no `scale <= precision` guard. Worth knowing this is a 
genuinely unpinned corner: the cross-client conformance suite explicitly leaves 
`scale > precision` "out on purpose" (apache/iceberg-verification#9, 
`table-spec/types/README.md`) because the spec neither permits nor forbids it. 
So accepting it isn't non-conformant — but a Rust- or Java-written `decimal(5, 
8)` can still fail to load in iceberg-go (`validateDecimalPrecisionScale`) and 
PyIceberg (`check_scale`), and no conformance run will catch that.
   
   Since this test makes the choice documented behavior, I'd add a one-line 
comment on `validate_decimal_precision` noting scale is deliberately 
unconstrained for Java parity (and that the spec leaves it open). I would not 
add a `scale <= precision` check — that'd diverge from both Java and the 
suite's deliberate non-decision. Just want the acceptance to be on purpose.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to