Skip to content

Commit afdb026

Browse files
authored
Fix avro bugs (#745)
1 parent 1b6c6f0 commit afdb026

6 files changed

Lines changed: 182 additions & 6 deletions

File tree

avro/src/main/java/tools/jackson/dataformat/avro/deser/AvroFieldDefaulters.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ public static AvroFieldReader createDefaulter(String name,
3131
case VALUE_NUMBER_INT:
3232
switch (defaultAsNode.numberType()) {
3333
case INT:
34-
return new ScalarDefaults.FloatDefaults(name, defaultAsNode.asInt());
34+
return new ScalarDefaults.IntDefaults(name, defaultAsNode.asInt());
3535
case BIG_INTEGER: // TODO: maybe support separately?
3636
case LONG:
3737
default:
38-
return new ScalarDefaults.FloatDefaults(name, defaultAsNode.asLong());
38+
return new ScalarDefaults.LongDefaults(name, defaultAsNode.asLong());
3939
}
4040
case VALUE_STRING:
4141
return new ScalarDefaults.StringDefaults(name, defaultAsNode.asString());

avro/src/main/java/tools/jackson/dataformat/avro/ser/AvroWriteContext.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ protected GenericRecord _createRecord(Schema schema, Object currValue)
164164
// couldn't find an exact match
165165
schema = _recordOrMapFromUnion(schema);
166166
}
167+
type = schema.getType();
167168
}
168169
if (type == Schema.Type.MAP) {
169170
throw new IllegalStateException("_createRecord should never be called for elements of type MAP");
@@ -182,6 +183,7 @@ protected GenericRecord _createRecord(Schema schema)
182183
Type type = schema.getType();
183184
if (type == Schema.Type.UNION) {
184185
schema = _recordOrMapFromUnion(schema);
186+
type = schema.getType();
185187
}
186188
if (type == Schema.Type.MAP) {
187189
throw new IllegalStateException("_createRecord should never be called for elements of type MAP");
@@ -438,10 +440,11 @@ private static int _resolveBigDecimalIndex(Schema unionSchema, List<Schema> type
438440
Schema schema = types.get(i);
439441
Schema.Type t = schema.getType();
440442

441-
if (t == Type.DOUBLE) {
443+
// Prefer String or Bytes with logical type info for BigDecimal;
444+
// fall back to DOUBLE if nothing better is found
445+
if (t == Type.STRING || t == Type.BYTES) {
442446
return i;
443447
}
444-
// BigDecimals can be shoved into a double, but optimally would be a String or byte[] with logical type information
445448
if (t == Type.DOUBLE) {
446449
match = i;
447450
continue;

avro/src/test/java/tools/jackson/dataformat/avro/BigDecimal_serialization_and_deserializationTest.java renamed to avro/src/test/java/tools/jackson/dataformat/avro/BigDecimalSerializationAndDeserializationTest.java

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tools.jackson.dataformat.avro;
22

33
import java.math.BigDecimal;
4+
import java.util.Map;
45

56
import org.junit.jupiter.api.Test;
67

@@ -9,7 +10,7 @@
910

1011
import static org.assertj.core.api.Assertions.assertThat;
1112

12-
public class BigDecimal_serialization_and_deserializationTest extends AvroTestBase {
13+
public class BigDecimalSerializationAndDeserializationTest extends AvroTestBase {
1314
private static final AvroMapper MAPPER = new AvroMapper();
1415

1516
static class BigDecimalAndName {
@@ -177,4 +178,59 @@ public void testSerialization_toFixedWithLogicalTypeDecimal() throws Exception {
177178
assertThat(result.name).isEqualTo("peter");
178179
}
179180

181+
// Verify BigDecimal in a union with STRING and DOUBLE selects STRING (not DOUBLE)
182+
@Test
183+
public void testBigDecimalUnionPrefersStringOverDouble() throws Exception
184+
{
185+
// Union with string first, double second
186+
String schemaJson = a2q("{" +
187+
"'type':'record'," +
188+
"'name':'Test'," +
189+
"'fields':[" +
190+
" {'name':'value', 'type':['null','string','double']}" +
191+
"]" +
192+
"}");
193+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
194+
195+
Map<String, Object> input = Map.of("value", BigDecimal.valueOf(123456789, 6));
196+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(input);
197+
198+
Map<String, Object> result = MAPPER.readerFor(Map.class)
199+
.with(schema)
200+
.readValue(bytes);
201+
// The BigDecimal should have been serialized as a string (index 1),
202+
// not as a double (index 2), preserving full precision
203+
assertThat(result.get("value")).isNotNull();
204+
// If it went through STRING, the value round-trips as a String
205+
assertThat(result.get("value")).isInstanceOf(String.class);
206+
}
207+
208+
// Verify BigDecimal in a union with BYTES (decimal logical type) and DOUBLE selects BYTES
209+
@Test
210+
public void testBigDecimalUnionPrefersBytesOverDouble() throws Exception
211+
{
212+
String schemaJson = a2q("{" +
213+
"'type':'record'," +
214+
"'name':'Test'," +
215+
"'fields':[" +
216+
" {'name':'value', 'type':['null'," +
217+
" {'type':'bytes','logicalType':'decimal','precision':20,'scale':6}," +
218+
" 'double']}" +
219+
"]" +
220+
"}");
221+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
222+
223+
BigDecimal input = BigDecimal.valueOf(123456789, 6);
224+
Map<String, Object> data = Map.of("value", input);
225+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(data);
226+
227+
Map<String, Object> result = MAPPER.readerFor(Map.class)
228+
.with(schema)
229+
.readValue(bytes);
230+
assertThat(result.get("value")).isNotNull();
231+
// Should round-trip as BigDecimal via bytes, not lose precision via double
232+
assertThat(result.get("value")).isInstanceOf(BigDecimal.class);
233+
assertThat((BigDecimal) result.get("value")).isEqualByComparingTo(input);
234+
}
235+
180236
}

avro/src/test/java/tools/jackson/dataformat/avro/MapWithUnionTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,41 @@ public void testMapContainerWithNested() throws Exception
131131
assertEquals("bing", m.get("zap"));
132132
}
133133

134+
// Verify that a union resolving to MAP is handled correctly in _createRecord
135+
@SuppressWarnings("unchecked")
136+
@Test
137+
public void testUnionResolvingToMapType() throws Exception
138+
{
139+
// Schema where the union resolves to a map
140+
String schemaJson = a2q("{" +
141+
"'type':'record'," +
142+
"'name':'Test'," +
143+
"'fields':[" +
144+
" {'name':'data', 'type':['null'," +
145+
" {'type':'record','name':'Nested','fields':[" +
146+
" {'name':'x','type':'int'}" +
147+
" ]}," +
148+
" {'type':'map','values':'string'}" +
149+
" ]}" +
150+
"]" +
151+
"}");
152+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
153+
154+
// Write a record where 'data' is a map (union index 2)
155+
Map<String, Object> input = Map.of("data", Map.of("key1", "val1", "key2", "val2"));
156+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(input);
157+
158+
Map<String, Object> result = MAPPER.readerFor(Map.class)
159+
.with(schema)
160+
.readValue(bytes);
161+
assertNotNull(result.get("data"));
162+
assertTrue(result.get("data") instanceof Map,
163+
"Expected Map but got " + result.get("data").getClass().getSimpleName());
164+
Map<String, String> dataMap = (Map<String, String>) result.get("data");
165+
assertEquals("val1", dataMap.get("key1"));
166+
assertEquals("val2", dataMap.get("key2"));
167+
}
168+
134169
private Map<String,Object> _map(Object... args) {
135170
Map<String,Object> m = new LinkedHashMap<>();
136171
for (int i = 0; i < args.length; i += 2) {

avro/src/test/java/tools/jackson/dataformat/avro/schemaev/ComplexDefaultsTest.java

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,85 @@ public void testListDefaults() throws Exception
194194
assertEquals("Fo", result.data.get(0));
195195
assertEquals("obar", result.data.get(1));
196196
}
197+
198+
/*
199+
/**********************************************************************
200+
/* Tests for integer default precision (not stored as float)
201+
/**********************************************************************
202+
*/
203+
204+
// Verify that int default values preserve precision (not stored as float)
205+
@Test
206+
public void testIntDefaultValuePrecision() throws Exception
207+
{
208+
// V1 has x and y; V2 adds 'largeInt' with a default that exceeds float precision
209+
String v1Json = a2q("{" +
210+
"'type':'record','name':'RootType'," +
211+
"'fields':[" +
212+
" {'name':'x','type':'int'}," +
213+
" {'name':'y','type':'int'}" +
214+
"]" +
215+
"}");
216+
// Default value 20000001 exceeds float's 24-bit mantissa (max exact int: 16777216)
217+
String v2Json = a2q("{" +
218+
"'type':'record','name':'RootType'," +
219+
"'fields':[" +
220+
" {'name':'x','type':'int'}," +
221+
" {'name':'largeInt','type':'int','default':20000001}," +
222+
" {'name':'y','type':'int'}" +
223+
"]" +
224+
"}");
225+
226+
AvroSchema v1Schema = MAPPER.schemaFrom(v1Json);
227+
AvroSchema v2Schema = MAPPER.schemaFrom(v2Json);
228+
229+
// Write with V1 (no largeInt field)
230+
Map<String, Object> input = Map.of("x", 1, "y", 2);
231+
byte[] bytes = MAPPER.writer(v1Schema).writeValueAsBytes(input);
232+
233+
// Read with V2 — should get the default value for largeInt
234+
AvroSchema xlate = v1Schema.withReaderSchema(v2Schema);
235+
Map<String, Object> result = MAPPER.readerFor(Map.class)
236+
.with(xlate)
237+
.readValue(bytes);
238+
assertEquals(1, result.get("x"));
239+
assertEquals(2, result.get("y"));
240+
// The critical assertion: default 20000001 must survive float truncation
241+
assertEquals(20000001, ((Number) result.get("largeInt")).intValue(),
242+
"Integer default lost precision — likely stored as float");
243+
}
244+
245+
// Verify that long default values preserve precision
246+
@Test
247+
public void testLongDefaultValuePrecision() throws Exception
248+
{
249+
String v1Json = a2q("{" +
250+
"'type':'record','name':'RootType'," +
251+
"'fields':[" +
252+
" {'name':'x','type':'int'}" +
253+
"]" +
254+
"}");
255+
// Default value 9007199254740993 exceeds float AND double integer precision
256+
String v2Json = a2q("{" +
257+
"'type':'record','name':'RootType'," +
258+
"'fields':[" +
259+
" {'name':'x','type':'int'}," +
260+
" {'name':'bigLong','type':'long','default':9007199254740993}" +
261+
"]" +
262+
"}");
263+
264+
AvroSchema v1Schema = MAPPER.schemaFrom(v1Json);
265+
AvroSchema v2Schema = MAPPER.schemaFrom(v2Json);
266+
267+
Map<String, Object> input = Map.of("x", 1);
268+
byte[] bytes = MAPPER.writer(v1Schema).writeValueAsBytes(input);
269+
270+
AvroSchema xlate = v1Schema.withReaderSchema(v2Schema);
271+
Map<String, Object> result = MAPPER.readerFor(Map.class)
272+
.with(xlate)
273+
.readValue(bytes);
274+
assertEquals(1, result.get("x"));
275+
assertEquals(9007199254740993L, ((Number) result.get("bigLong")).longValue(),
276+
"Long default lost precision — likely stored as float");
277+
}
197278
}

release-notes/VERSION

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ implementations)
1616

1717
3.3.0 (not yet released)
1818

19-
No changes since 3.2
19+
#745: (avro) Fix BigDecimal union resolution, stale type variable in `_createRecord`
20+
and integer default precision loss
2021

2122
3.2.2 (14-Aug-2026)
2223

0 commit comments

Comments
 (0)