Skip to content

Commit 1b57d68

Browse files
authored
Follow-up to #745: fix BigDecimal union regression, root union-to-map (#753)
1 parent 95731a5 commit 1b57d68

4 files changed

Lines changed: 160 additions & 14 deletions

File tree

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import java.math.BigDecimal;
55
import java.util.*;
66

7+
import org.apache.avro.LogicalTypes;
78
import org.apache.avro.Schema;
89
import org.apache.avro.Schema.Type;
910
import org.apache.avro.UnresolvedUnionException;
@@ -434,26 +435,41 @@ private static int _findNotNullIndex(List<Schema> types)
434435

435436
private static int _resolveBigDecimalIndex(Schema unionSchema, List<Schema> types,
436437
BigDecimal value) {
437-
int match = -1;
438+
// Branches are considered in order of how well they retain the value,
439+
// regardless of declaration order: "decimal" first, then String, then Double
440+
int stringMatch = -1;
441+
int doubleMatch = -1;
438442

439443
for (int i = 0, size = types.size(); i < size; ++i) {
440444
Schema schema = types.get(i);
441445
Schema.Type t = schema.getType();
442446

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) {
446-
return i;
447-
}
448-
if (t == Type.DOUBLE) {
449-
match = i;
450-
continue;
447+
if (t == Type.BYTES || t == Type.FIXED) {
448+
// Best match: retains both scale and type.
449+
// NOTE: plain `bytes`/`fixed` must NOT be chosen, since conversion
450+
// requires the "decimal" logical type to exist
451+
if (schema.getLogicalType() instanceof LogicalTypes.Decimal) {
452+
return i;
453+
}
454+
} else if (t == Type.STRING) {
455+
// Second best: retains all digits, but reads back as String
456+
if (stringMatch < 0) {
457+
stringMatch = i;
458+
}
459+
} else if (t == Type.DOUBLE) {
460+
// Last resort: lossy
461+
if (doubleMatch < 0) {
462+
doubleMatch = i;
463+
}
451464
}
452465
}
453-
if (match < 0) {
454-
match = ReflectData.get().resolveUnion(unionSchema, value);
466+
if (stringMatch >= 0) {
467+
return stringMatch;
455468
}
456-
return match;
469+
if (doubleMatch >= 0) {
470+
return doubleMatch;
471+
}
472+
return ReflectData.get().resolveUnion(unionSchema, value);
457473
}
458474

459475
private static int _resolveMapIndex(Schema unionSchema, List<Schema> types,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,17 @@ public final AvroWriteContext createChildObjectContext(Object currValue) {
5959
// verify that root type is record (or compatible)
6060
switch (_schema.getType()) {
6161
case RECORD:
62-
case UNION: // maybe
6362
{
6463
GenericRecord rec = _createRecord(_schema, currValue);
6564
_rootValue = rec;
6665
return new ObjectWriteContext(this, _generator, rec, currValue);
6766
}
67+
case UNION: // maybe: may resolve to either Record or Map
68+
{
69+
AvroWriteContext child = _createObjectContext(_schema, currValue);
70+
_rootValue = child.rawValue();
71+
return child;
72+
}
6873
case MAP: // used to not be supported
6974
{
7075
MapWriteContext ctxt = new MapWriteContext(this, _generator, _schema, currValue);

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,110 @@ public void testBigDecimalUnionPrefersBytesOverDouble() throws Exception
233233
assertThat((BigDecimal) result.get("value")).isEqualByComparingTo(input);
234234
}
235235

236+
// Verify that plain BYTES (without "decimal" logical type) is NOT chosen for
237+
// BigDecimal: conversion would fail, so DOUBLE must be used instead
238+
@Test
239+
public void testBigDecimalUnionSkipsPlainBytes() throws Exception
240+
{
241+
String schemaJson = a2q("{" +
242+
"'type':'record'," +
243+
"'name':'Test'," +
244+
"'fields':[" +
245+
" {'name':'value', 'type':['null','bytes','double']}" +
246+
"]" +
247+
"}");
248+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
249+
250+
BigDecimal input = BigDecimal.valueOf(123456789, 6);
251+
Map<String, Object> data = Map.of("value", input);
252+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(data);
253+
254+
Map<String, Object> result = MAPPER.readerFor(Map.class)
255+
.with(schema)
256+
.readValue(bytes);
257+
// Written as DOUBLE (index 2) since plain `bytes` cannot hold a BigDecimal
258+
assertThat(result.get("value")).isInstanceOf(Double.class);
259+
assertThat((Double) result.get("value")).isEqualTo(input.doubleValue());
260+
}
261+
262+
// Verify that "decimal" BYTES wins over STRING even when declared later:
263+
// preference is by type fidelity, not by declaration order
264+
@Test
265+
public void testBigDecimalUnionPrefersBytesOverString() throws Exception
266+
{
267+
String schemaJson = a2q("{" +
268+
"'type':'record'," +
269+
"'name':'Test'," +
270+
"'fields':[" +
271+
" {'name':'value', 'type':['null','string'," +
272+
" {'type':'bytes','logicalType':'decimal','precision':20,'scale':6}," +
273+
" 'double']}" +
274+
"]" +
275+
"}");
276+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
277+
278+
BigDecimal input = BigDecimal.valueOf(123456789, 6);
279+
Map<String, Object> data = Map.of("value", input);
280+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(data);
281+
282+
Map<String, Object> result = MAPPER.readerFor(Map.class)
283+
.with(schema)
284+
.readValue(bytes);
285+
assertThat(result.get("value")).isInstanceOf(BigDecimal.class);
286+
assertThat((BigDecimal) result.get("value")).isEqualByComparingTo(input);
287+
}
288+
289+
// Verify that FIXED with "decimal" logical type is also usable for BigDecimal
290+
@Test
291+
public void testBigDecimalUnionPrefersFixedOverString() throws Exception
292+
{
293+
String schemaJson = a2q("{" +
294+
"'type':'record'," +
295+
"'name':'Test'," +
296+
"'fields':[" +
297+
" {'name':'value', 'type':['null','string'," +
298+
" {'type':'fixed','name':'Dec','size':16," +
299+
" 'logicalType':'decimal','precision':20,'scale':6}," +
300+
" 'double']}" +
301+
"]" +
302+
"}");
303+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
304+
305+
BigDecimal input = BigDecimal.valueOf(123456789, 6);
306+
Map<String, Object> data = Map.of("value", input);
307+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(data);
308+
309+
Map<String, Object> result = MAPPER.readerFor(Map.class)
310+
.with(schema)
311+
.readValue(bytes);
312+
assertThat(result.get("value")).isInstanceOf(BigDecimal.class);
313+
assertThat((BigDecimal) result.get("value")).isEqualByComparingTo(input);
314+
}
315+
316+
// Verify that plain FIXED (without "decimal" logical type) is not chosen either
317+
@Test
318+
public void testBigDecimalUnionSkipsPlainFixed() throws Exception
319+
{
320+
String schemaJson = a2q("{" +
321+
"'type':'record'," +
322+
"'name':'Test'," +
323+
"'fields':[" +
324+
" {'name':'value', 'type':['null'," +
325+
" {'type':'fixed','name':'Raw','size':16}," +
326+
" 'double']}" +
327+
"]" +
328+
"}");
329+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
330+
331+
BigDecimal input = BigDecimal.valueOf(123456789, 6);
332+
Map<String, Object> data = Map.of("value", input);
333+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(data);
334+
335+
Map<String, Object> result = MAPPER.readerFor(Map.class)
336+
.with(schema)
337+
.readValue(bytes);
338+
assertThat(result.get("value")).isInstanceOf(Double.class);
339+
assertThat((Double) result.get("value")).isEqualTo(input.doubleValue());
340+
}
341+
236342
}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,26 @@ 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
134+
// Verify that a root-level union resolving to MAP is written as a Map,
135+
// and not passed to Record handling
136+
@SuppressWarnings("unchecked")
137+
@Test
138+
public void testRootUnionResolvingToMapType() throws Exception
139+
{
140+
String schemaJson = a2q("['null',{'type':'map','values':'string'}]");
141+
AvroSchema schema = MAPPER.schemaFrom(schemaJson);
142+
143+
Map<String, Object> input = Map.of("key1", "val1", "key2", "val2");
144+
byte[] bytes = MAPPER.writer(schema).writeValueAsBytes(input);
145+
146+
Map<String, String> result = MAPPER.readerFor(Map.class)
147+
.with(schema)
148+
.readValue(bytes);
149+
assertEquals("val1", result.get("key1"));
150+
assertEquals("val2", result.get("key2"));
151+
}
152+
153+
// Verify that a union resolving to MAP is handled correctly for Record properties
135154
@SuppressWarnings("unchecked")
136155
@Test
137156
public void testUnionResolvingToMapType() throws Exception

0 commit comments

Comments
 (0)