This is an automated email from the ASF dual-hosted git repository.
Kriskras99 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 08cac64 fix: accept JSON string defaults for decimal fields in union
(#580)
08cac64 is described below
commit 08cac64f87ced7b0571620da60c6e5c052625871
Author: Vyacheslav Mayorov <[email protected]>
AuthorDate: Tue Jul 7 23:19:03 2026 +0300
fix: accept JSON string defaults for decimal fields in union (#580)
* fix: accept JSON string defaults for decimal fields in union
Per Avro 1.12.0 Specification, §"Complex Types / Records", the JSON
encoding of a `bytes` field's default value is a string whose codepoints
0-255 map to byte values 0-255 (e.g. `"\u00FF"`). The same section
specifies that a union-typed field's default must correspond to the
first schema that matches in the union.
`decimal` is defined as a logical type over `bytes`, so this rule
transitively applies: a nullable decimal field expressed as
`[{bytes, logicalType: decimal}, null]` with a JSON string default
requires `resolve_decimal` to accept `Value::String` when validating
defaults at schema parse time. Before this change the parser rejected
such schemas with `GetDefaultUnion(Decimal, String)`, even though
Java and Python Avro accept them.
The added arm walks the string's codepoints, rejecting any above
0xFF, and returns a `Value::Decimal`. The precision check is skipped
because the spec does not require a default's byte length to cover
the declared precision — it only requires a valid `bytes` value.
Wire-level decoded records always reach `resolve_decimal` as
`Value::Bytes`, so this arm is exclusively a default-validation path.
Tests cover `\u0000`, a full 0..=255 round-trip, codepoints > 0xFF
being rejected, and end-to-end parsing of a nullable decimal record
schema.
* fix: review comments
* chore: Fix test output
---------
Co-authored-by: Laurent Valdes <[email protected]>
Co-authored-by: Vyacheslav Mayorov <[email protected]>
Co-authored-by: Kriskras99 <[email protected]>
---
avro/src/error.rs | 2 +-
avro/src/types.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/avro/src/error.rs b/avro/src/error.rs
index 425bf4f..bd9cbcb 100644
--- a/avro/src/error.rs
+++ b/avro/src/error.rs
@@ -188,7 +188,7 @@ pub enum Details {
#[error("Expected Value::Duration or Value::Fixed(12), got: {0:?}")]
ResolveDuration(Value),
- #[error("Expected Value::Decimal, Value::Bytes or Value::Fixed, got:
{0:?}")]
+ #[error("Expected Value::Decimal, Value::Bytes, Value::Fixed or
Value::String, got: {0:?}")]
ResolveDecimal(Value),
#[error("Missing field in record: {0:?}")]
diff --git a/avro/src/types.rs b/avro/src/types.rs
index ad0258b..503f6cb 100644
--- a/avro/src/types.rs
+++ b/avro/src/types.rs
@@ -846,6 +846,26 @@ impl Value {
Ok(Value::Decimal(Decimal::from(bytes)))
}
}
+
+ // Per spec §Records, a decimal value can be encoded as a JSON
string
+ // whose codepoints (0-255) map directly to byte values. This
applies
+ // to defaults and any other String → Decimal resolution. No
precision
+ // check is performed — the bytes only need to be valid, not fit
the
+ // declared precision.
+ Value::String(s) => {
+ let bytes = s
+ .chars()
+ .map(|c| {
+ let cp = c as u32;
+ if cp > 0xFF {
+
Err(Details::ResolveDecimal(Value::String(s.clone())).into())
+ } else {
+ Ok(cp as u8)
+ }
+ })
+ .collect::<Result<Vec<u8>, Error>>()?;
+ Ok(Value::Decimal(Decimal::from(bytes)))
+ }
other => Err(Details::ResolveDecimal(other).into()),
}
}
@@ -1776,6 +1796,69 @@ Field with name '"b"' is not a member of the map items"#,
Ok(())
}
+ #[test]
+ fn avro_rs_580_resolve_decimal_from_string_default() -> TestResult {
+ let value = Value::String("\u{0000}".to_string());
+ let resolved = value.resolve(&Schema::Decimal(DecimalSchema {
+ precision: 10,
+ scale: 4,
+ inner: InnerDecimalSchema::Bytes,
+ }))?;
+ assert_eq!(resolved, Value::Decimal(Decimal::from(vec![0u8])));
+
+ let mut all_bytes_str = String::new();
+ for b in 0u8..=255u8 {
+ all_bytes_str.push(char::from_u32(b as u32).unwrap());
+ }
+ let resolved =
Value::String(all_bytes_str).resolve(&Schema::Decimal(DecimalSchema {
+ precision: 10,
+ scale: 0,
+ inner: InnerDecimalSchema::Bytes,
+ }))?;
+ assert_eq!(
+ resolved,
+ Value::Decimal(Decimal::from((0u8..=255u8).collect::<Vec<_>>()))
+ );
+
+ let value = Value::String("\u{0100}".to_string());
+ assert!(
+ value
+ .resolve(&Schema::Decimal(DecimalSchema {
+ precision: 10,
+ scale: 4,
+ inner: InnerDecimalSchema::Bytes,
+ }))
+ .is_err()
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn avro_rs_580_parse_schema_with_nullable_decimal_string_default() ->
TestResult {
+ let schema_json = r#"{
+ "type": "record",
+ "name": "NullableDecimal",
+ "fields": [
+ {
+ "name": "amount",
+ "type": [
+ {
+ "type": "bytes",
+ "scale": 4,
+ "precision": 10,
+ "logicalType": "decimal"
+ },
+ "null"
+ ],
+ "default": "\u0000"
+ }
+ ]
+ }"#;
+ Schema::parse_str(schema_json)?;
+ Ok(())
+ }
+
#[test]
fn resolve_decimal_invalid_scale() {
let value = Value::Decimal(Decimal::from(vec![1, 2]));
@@ -3326,7 +3409,7 @@ Field with name '"b"' is not a member of the map items"#,
(
r#"{ "name": "NAME", "type": "bytes", "logicalType":
"decimal", "precision": 4, "scale": 3 }"#,
Value::Float(123_f32),
- "Expected Value::Decimal, Value::Bytes or Value::Fixed, got:
Float(123.0)",
+ "Expected Value::Decimal, Value::Bytes, Value::Fixed or
Value::String, got: Float(123.0)",
),
(
r#"{ "name": "NAME", "type": "bytes" }"#,