Skip to content

fix: Accept short decimal values when resolving against a decimal schema - #717

Merged
Kriskras99 merged 6 commits into
apache:mainfrom
wleeaf:decimal-resolve-small-values
Oct 30, 2026
Merged

Kriskras99 merged 6 commits into
apache:mainfrom
wleeaf:decimal-resolve-small-values

Conversation

@wleeaf

@wleeaf wleeaf commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Reading a bytes-backed decimal with a reader schema fails for most values. Writer and reader are both {"type": "bytes", "logicalType": "decimal", "precision": 5, "scale": 2}, and the datum is 2.56 (unscaled 256, encoded as the two bytes 01 00). Resolution fails:

from_avro_datum(&writer, &mut bytes, Some(&reader))
-> Precision 5 too small to hold decimal values with 2 bytes

The same happens through GenericDatumReader and Reader whenever a reader schema is given, and for any value whose byte length is small for the declared precision: 1.00 in a decimal(10, 2) (one byte) fails too. Reading without a reader schema works, and Java reads these values.

Value::resolve_decimal compared max_prec_for_len(value_length) with the schema's precision for every value. That bound says how many digits a fixed of that size can always hold, which is a constraint on the fixed size of a fixed-backed decimal. It says nothing about whether a particular value fits. A short value always has few digits, so the check rejected the values that are least likely to overflow. This removes the per-value check. The schema-level check, that a fixed size can hold the precision, stays. Decimal::len() had no other users and is removed with it.

Test: resolve_decimal_value_shorter_than_precision resolves a two-byte Value::Decimal and Value::Bytes against decimal(5, 2). It fails with the error above without the change and passes with it. cargo fmt --all --check, cargo clippy --all-features --all-targets --workspace and cargo test --all-features pass.

Resolving a value against a decimal schema failed unless the value's
byte length could hold `precision` digits, so 2.56 (two bytes) could
not be read as a `decimal(5, 2)` whenever a reader schema was given.
The byte length only bounds how large a value can be; it does not limit
the precision the value is read with. Remove the per-value check and the
now unused `Decimal::len()`. The check that a fixed size can hold the
precision stays.
@Kriskras99

Copy link
Copy Markdown
Contributor

I think allowing lower precision inputs is fine, but this now also allows higher precision inputs. The spec mentions the following: precision, a JSON integer representing the (maximum) precision of decimals stored in this type. So I don't think higher precision bytes should be allowed.

The precision is the maximum number of digits of a decimal, so a value
that is shorter than the precision allows still resolves, but one with
more digits (12345 for a precision of 4) is an error again. This uses
the number of digits of the unscaled value instead of the byte length.
@wleeaf

wleeaf commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Good point, thanks. Resolving now checks the number of decimal digits of the unscaled value against the precision, so a value with more digits than the precision is rejected again, and shorter values still pass (589b0b7). The new test covers 12345 with precision 4 (rejected) and 5 (accepted), and -99999 (accepted) and -100000 (rejected) with precision 5, for both Decimal and Bytes values.

Two existing tests changed with it: resolve_decimal_invalid_precision_for_length now expects an error for its 18-digit value with precision 1, which its name suggests was the intent, and avro_rs_342_decimal_fixed_and_bytes uses a 17-digit value instead of 22 digits for decimal(20, 0).

The rejection reuses ComparePrecisionAndSize, whose message still talks about bytes ("Precision 4 too small to hold decimal values with 2 bytes"). If you'd prefer, I can add a separate variant that reports the digit count instead.

@wleeaf

wleeaf commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Small update in dc9a8b6: the new test now uses decimal(5, 2) throughout, with 99999 and -99999 accepted and 123456 and -100000 rejected, for both Decimal and Bytes values.

Comment thread avro/src/types.rs Outdated
if num.digits() > precision.get() as u64 {
Err(Details::ComparePrecisionAndSize {
precision,
num_bytes: num.len(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why num.len() ? The condition above uses num.digits().

The rejection reuses ComparePrecisionAndSize, whose message still talks about bytes ("Precision 4 too small to hold decimal values with 2 bytes"). If you'd prefer, I can add a separate variant that reports the digit count instead.

I think it would be clearer, yes.

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.

The check and the error now use the same digit count: check_digits computes num.digits() once, compares it with the precision and reports it (ed394df). The rejection uses a new ComparePrecisionAndDigits { precision, digits } variant, e.g. "Precision 5 too small to hold a decimal value with 6 digits". ComparePrecisionAndSize isn't generated anymore, so it's marked deprecated like the other unused variants; I can drop that attribute if you'd rather keep it as is.

Comment thread avro/src/decimal.rs Outdated

/// The number of decimal digits of the unscaled value.
pub(crate) fn digits(&self) -> u64 {
BigDecimal::from_bigint(self.value.clone(), 0).digits()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would self.value.to_str_radix(10).len() work too ?
It would avoid the cloning+BigDecimal instantiation.
Please add unit tests with positive, negative, edge values.

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.

Yes. digits() is now self.value.magnitude().to_str_radix(10).len() (ed394df). It uses the magnitude because to_str_radix on a negative BigInt includes the -. The new decimal_digits test covers 0, ±1, ±99999, ±100000, the i64 and i128 bounds and sign-extended input, and the resolve test now also checks 0 and 100000 for decimal(5, 2). Decimal::len() had no callers left, so it's removed.

Resolving a decimal value with more digits than the precision now fails
with the new ComparePrecisionAndDigits error, which reports the number of
digits that was checked, instead of ComparePrecisionAndSize, whose
message gave the length in bytes. ComparePrecisionAndSize is no longer
generated and is deprecated.

Decimal::digits now takes the length of the magnitude in base 10, which
leaves out the sign of a negative value and avoids cloning the value into
a BigDecimal.
Comment thread avro/src/decimal.rs Resolved
Comment thread avro/src/types.rs Resolved
Comment thread avro/src/types.rs Resolved
Co-authored-by: Martin Grigorov <martin-g@users.noreply.github.com>
@martin-g martin-g added this to the 0.23.0 milestone Oct 30, 2026
@martin-g
martin-g requested a review from Kriskras99 October 30, 2026 11:47
@Kriskras99

Copy link
Copy Markdown
Contributor

The only thing I don't like about this is how we now calculate the amount of digits. But I also don't know a better way.

@Kriskras99
Kriskras99 merged commit a4e9e99 into apache:main Oct 30, 2026
16 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.

3 participants