Skip to content

fix(spec): Harden decimal and fixed type string parsing - #3256

Merged
laskoviymishka merged 5 commits into
apache:mainfrom
shoemoney:fix/harden-decimal-fixed-type-parsing
Oct 2, 2026
Merged

laskoviymishka merged 5 commits into
apache:mainfrom
shoemoney:fix/harden-decimal-fixed-type-parsing

Conversation

@shoemoney

@shoemoney shoemoney commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #3229.

What changes are included in this PR?

Require exactly one pair of delimiters in decimal and fixed type strings. The previous repeated trimming accepted malformed inputs such as decimal(5, 2))))), decimal(decimal(5, 2))), and fixed[fixed[16]]], as well as missing closing delimiters.

Require a nonempty ASCII-digit token after trimming, rejecting empty tokens and a leading + in decimal precision, decimal scale, or fixed length. Integer overflow retains the original ParseIntError diagnostic.

Share the existing 1 <= precision <= 38 validation between Type::decimal and decimal JSON deserialization. Preserve the constructor's accepted scale values, including Type::decimal(5, 8), so constructor-produced values round-trip through JSON. The earlier version of this PR incorrectly imposed scale <= precision only in deserialization. Arrow conversion has a stricter scale check, but that is not a shared constructor or metadata invariant; this PR leaves the existing constructor acceptance unchanged.

No regex dependency or public API is added.

Are these changes tested?

The two new review regression tests failed on the previous PR head: leading-plus inputs were accepted and the constructor-created (5, 8) decimal failed to round-trip. Both now pass, with independent checks through Type and PrimitiveType. Existing malformed-input and zero-precision diagnostic tests remain.

The latest review follow-up adds empty-token rejection coverage and explicit canonical JSON serialization assertions through both types. Its overflow diagnostic regression failed on the previous head and passes with the restored parse errors. The shared precision validator documents the unchanged scale policy.

On the pinned nightly-2026-04-16 toolchain:

  • cargo test -p iceberg --lib --all-features: 1,763 passed.
  • cargo fmt --all -- --check: passed.
  • cargo clippy -p iceberg --all-targets --all-features -- -D warnings: passed.

AI Disclosure

Written in conjunction with my pair programmer Claude. Review follow-ups were prepared with AI assistance, including Codex.

deserialize_decimal and deserialize_fixed used trim_start_matches and
trim_end_matches, which strip repeated occurrences and never require the
prefix, so "decimal(decimal(5, 2)))))" and "fixed[16" both parsed
successfully. Precision was also unbounded, so "decimal(50, 2)" was
accepted into Schema and TableMetadata even though the crate's own
MAX_DECIMAL_PRECISION is 38, and the failure only surfaced much later in
Arrow or Avro conversion.

Parse strictly with strip_prefix and strip_suffix, and reject precision
outside 1..=38 and scale greater than precision.

Signed-off-by: Jeremy Schoemaker <jeremy@shoemoney.com>
Copilot AI lite review requested due to automatic review settings September 21, 2026 03:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The changes are small, targeted, and well-tested, and the parsing hardening aligns with existing invariants while reducing downstream schema incompatibilities.

Review effort: Lite
Findings: 2 Low severity

Open (2)
What changed in this PR

This PR hardens Iceberg primitive type string parsing for decimal(p, s) and fixed[n] during JSON schema deserialization to reject malformed inputs and enforce decimal invariants earlier in the pipeline.

Changes:

  • Switch decimal and fixed parsing to strict strip_prefix/strip_suffix parsing instead of trim_*_matches.
  • Enforce decimal constraints at deserialization time (0 < precision <= MAX_DECIMAL_PRECISION, scale <= precision) with clearer errors for malformed inputs.
  • Add unit tests covering rejected malformed strings and accepted valid strings.
File Description
crates/​iceberg/​src/​spec/​datatypes.rs Tightens decimal/fixed string parsing and adds regression tests for malformed inputs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/iceberg/src/spec/datatypes.rs Outdated
Comment thread crates/iceberg/src/spec/datatypes.rs Outdated

@mkroll-db mkroll-db left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It matches mostly the go implementation and the difference in how negative numbers are handled is covered by tests.
Added some smaller suggestions to add more tests.

Comment thread crates/iceberg/src/spec/datatypes.rs
Comment thread crates/iceberg/src/spec/datatypes.rs

@dannycjones dannycjones left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for picking this up!

@mkroll-db

Copy link
Copy Markdown
Contributor

Thanks a lot.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @shoemoney for picking this up! This also closes a gap I ran into while reviewing #3241. A manifest whose stored schema declares decimal(39, 10) parses today, and the error only shows up later when a bound is converted back to bytes.

Comment thread crates/iceberg/src/spec/datatypes.rs Outdated
Comment on lines +362 to +363
let precision: u32 = precision.trim().parse().map_err(|_| malformed())?;
let scale: u32 = scale.trim().parse().map_err(|_| malformed())?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also reject a leading + here? u32::from_str accepts it, so on the head commit "decimal(+5, +2)" parses to Decimal { precision: 5, scale: 2 } and "fixed[+16]" (line 401) parses to Fixed(16). The type-string patterns in Java (Types.java#L66-L73), iceberg-go (types.go#L36-L37), and PyIceberg (types.py#L60) only allow digits there. If we reject it, could both strings go into test_reject_malformed_decimal_and_fixed_type_strings?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 93a06c0. The parser now checks that each trimmed numeric token contains ASCII digits before parsing. Added rejection cases for decimal(+5, 2), decimal(5, +2), decimal(+5, +2), and fixed[+16] through both Type and PrimitiveType.

The new test failed on the previous head and passes with the fix. All 1,761 library tests, the pinned formatting check, and strict all-target/all-feature Clippy passed.

Comment thread crates/iceberg/src/spec/datatypes.rs Outdated
Comment on lines +365 to +379
if precision == 0 {
return Err(D::Error::custom(
"Decimal precision must be greater than zero",
));
}
if precision > MAX_DECIMAL_PRECISION {
return Err(D::Error::custom(format!(
"Decimals with precision larger than {MAX_DECIMAL_PRECISION} are not supported: {precision}"
)));
}
if scale > precision {
return Err(D::Error::custom(format!(
"Decimal scale {scale} must not be larger than precision {precision}"
)));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type::decimal (datatypes.rs#L193-L199) checks precision but not scale. That means Type::decimal(5, 8) succeeds and serializes as "decimal(5, 8)", which this function now rejects. On the head commit, serde_json::from_str::<Type>(&serde_json::to_string(&Type::decimal(5, 8).unwrap()).unwrap()) returns data did not match any variant of untagged enum SerdeType. So a schema built through the public API can end up in table metadata that iceberg-rust can't read back.

What do you think about moving these checks into one private function that both Type::decimal and deserialize_decimal call? The two construction paths couldn't disagree, and the precision message wouldn't be written twice. A test that round-trips a Type::decimal result through JSON would cover it.

The description says the crate already requires scale <= precision elsewhere. Could you point me to where? I couldn't find it outside this PR. The implementations don't agree on the rule either. The spec only requires precision to be 38 or less (spec.md#L273), and Java's DecimalType checks only that (Types.java#L525-L533). PyIceberg (types.py#L331-L347), iceberg-go (types.go#L680-L694), and arrow-rs (types.rs#L1412-L1442) all reject precision 0 and a scale larger than the precision. With the stricter rule, iceberg-rust would fail to load table metadata that Java accepts, the same way PyIceberg does today. Whichever rule we pick, it would help to note the choice in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right: the scale <= precision statement in the description was too broad. arrow/schema.rs applies Arrow's stricter validation, but Type::decimal and the metadata parser did not share that restriction.

In 93a06c0, the constructor and deserializer share a private precision validator. I removed this PR's added scale restriction to preserve existing constructor behavior, including (5, 8), and corrected the description. Constructor-built roundtrip tests cover (1, 0), (5, 5), (5, 8), and (38, 38), plus rejection of precision 0 and 39. The (5, 8) roundtrip failed on the previous head and now passes.

All 1,761 library tests, the pinned formatting check, and strict all-target/all-feature Clippy passed. Tightening the public constructor's scale policy can be considered separately.

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The strip_prefix / strip_suffix change looks right. It catches the #3241 case during parsing instead of later when the value is serialized, and the reject cases line up with the primitive-type fixtures in apache/iceberg-verification#9.

A couple of small things:

  • the digit check is true for an empty string, so decimal(, 2) and fixed[] only fail at .parse(). Behavior is fine, but !is_empty() plus tests would make this clearer.
  • parse overflow now gets turned into a generic error. I’d keep the original ParseIntError.

One thing worth documenting: (5, 8) makes scale > precision explicitly valid here. The spec doesn’t really pin that case, so this matches Java, but iceberg-go and PyIceberg reject it. I’d add a comment so the choice isn’t accidental.

I'll keep this PR open for couple days before merge.

Comment thread crates/iceberg/src/spec/datatypes.rs Outdated
let (precision, scale) = (precision.trim(), scale.trim());
if ![precision, scale]
.iter()
.all(|token| token.bytes().all(|byte| byte.is_ascii_digit()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added explicit nonempty-token checks in 0c6d0d5. The rejection tests now cover decimal(, 2), decimal(5,), whitespace-only tokens and fixed[], through both Type and PrimitiveType. All 1,763 library tests, formatting and strict all-target/all-feature Clippy passed on the pinned nightly.

Comment thread crates/iceberg/src/spec/datatypes.rs Outdated
{
return Err(malformed());
}
let precision: u32 = precision.parse().map_err(|_| malformed())?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored map_err(D::Error::custom) for decimal precision, decimal scale and fixed length in 0c6d0d5, so overflow retains the ParseIntError diagnostic. The new regression failed on the previous head and now passes for overflowing u32 precision/scale and u64 fixed length.


#[test]
fn test_decimal_constructor_json_roundtrip() {
for (precision, scale) in [(1, 0), (5, 5), (5, 8), (38, 38)] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the comment beside validate_decimal_precision in 0c6d0d5. Scale remains deliberately unconstrained for Java parity; the existing (5, 8) constructor/JSON roundtrip still passes.

Comment thread crates/iceberg/src/spec/datatypes.rs Outdated
}
}

fn check_type_serde_roundtrip_value(json: &str, expected_type: Type) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strengthened the test in 0c6d0d5 to compare serialization of the parsed value with explicit canonical JSON, through both Type and PrimitiveType. The compact decimal(5,2) input must serialize as decimal(5, 2), and the canonical output is reparsed as well.

@laskoviymishka
laskoviymishka added this pull request to the merge queue Oct 2, 2026
Merged via the queue into apache:main with commit fc682d7 Oct 2, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Schema data type parsing needs hardening

6 participants