Skip to content

Commit 3ce0dae

Browse files
committed
Merge branch '3.0' into 3.x
2 parents f5664cf + 7a628de commit 3ce0dae

File tree

2 files changed

+217
-1
lines changed

2 files changed

+217
-1
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ protected static class Employee {
3333
public Gender gender;
3434
}
3535

36-
private final AvroMapper MAPPER = new AvroMapper();
36+
private final AvroMapper MAPPER = newMapper();
3737

3838
@Test
3939
public void testSimple() throws Exception
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package tools.jackson.dataformat.avro.schemaev;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import com.fasterxml.jackson.annotation.JsonInclude;
6+
7+
import tools.jackson.core.exc.StreamReadException;
8+
9+
import tools.jackson.dataformat.avro.*;
10+
11+
import static org.junit.jupiter.api.Assertions.*;
12+
13+
/**
14+
* Test for issue #275: Avro backward compatibility when adding fields with default values.
15+
* <p>
16+
* This test demonstrates that Jackson Avro DOES support backward compatibility correctly,
17+
* but users MUST use the {@code withReaderSchema()} method to enable schema resolution.
18+
* <p>
19+
* The key insight is that Avro binary format does not include schema metadata in the
20+
* serialized data. Therefore, when reading data that was written with schema A using
21+
* schema B, the library needs to be explicitly told about both schemas through the
22+
* {@code withReaderSchema()} API.
23+
* <p>
24+
* Common mistake: Trying to read old data with a new schema directly leads to
25+
* "Unexpected end-of-input" errors because the parser tries to read fields that
26+
* don't exist in the binary data.
27+
* <p>
28+
* Correct usage pattern:
29+
* <pre>
30+
* // Write with old schema
31+
* AvroSchema writerSchema = mapper.schemaFrom(OLD_SCHEMA_JSON);
32+
* byte[] data = mapper.writer(writerSchema).writeValueAsBytes(object);
33+
*
34+
* // Read with new schema (that has additional fields with defaults)
35+
* AvroSchema readerSchema = mapper.schemaFrom(NEW_SCHEMA_JSON);
36+
* AvroSchema resolved = writerSchema.withReaderSchema(readerSchema);
37+
* MyObject result = mapper.readerFor(MyObject.class)
38+
* .with(resolved) // Use resolved schema, not readerSchema directly!
39+
* .readValue(data);
40+
* </pre>
41+
*/
42+
public class Evolution275Test extends AvroTestBase
43+
{
44+
// Original schema with 8 fields (simulating the issue scenario)
45+
static String SCHEMA_V1_JSON = aposToQuotes("{\n"+
46+
" 'type':'record',\n"+
47+
" 'name':'Employee',\n"+
48+
" 'fields':[\n"+
49+
" { 'name':'code', 'type':'string' },\n"+
50+
" { 'name':'countryCode', 'type':'string' },\n"+
51+
" { 'name':'createdBy', 'type':'string' },\n"+
52+
" { 'name':'createdDate', 'type':'string' },\n"+
53+
" { 'name':'id', 'type':'long' },\n"+
54+
" { 'name':'lastModifiedBy', 'type':'string' },\n"+
55+
" { 'name':'lastModifiedDate', 'type':'string' },\n"+
56+
" { 'name':'name', 'type':'string' }\n"+
57+
" ]\n"+
58+
"}\n");
59+
60+
// Updated schema adding a 9th field with null default at the end
61+
static String SCHEMA_V2_JSON = aposToQuotes("{\n"+
62+
" 'type':'record',\n"+
63+
" 'name':'Employee',\n"+
64+
" 'fields':[\n"+
65+
" { 'name':'code', 'type':'string' },\n"+
66+
" { 'name':'countryCode', 'type':'string' },\n"+
67+
" { 'name':'createdBy', 'type':'string' },\n"+
68+
" { 'name':'createdDate', 'type':'string' },\n"+
69+
" { 'name':'id', 'type':'long' },\n"+
70+
" { 'name':'lastModifiedBy', 'type':'string' },\n"+
71+
" { 'name':'lastModifiedDate', 'type':'string' },\n"+
72+
" { 'name':'name', 'type':'string' },\n"+
73+
" { 'name':'phone', 'type':['null', 'string'], 'default':null }\n"+
74+
" ]\n"+
75+
"}\n");
76+
77+
// Simpler test with just 2 fields + new field with null default
78+
static String SCHEMA_SIMPLE_V1_JSON = aposToQuotes("{\n"+
79+
" 'type':'record',\n"+
80+
" 'name':'SimpleRecord',\n"+
81+
" 'fields':[\n"+
82+
" { 'name':'id', 'type':'int' },\n"+
83+
" { 'name':'name', 'type':'string' }\n"+
84+
" ]\n"+
85+
"}\n");
86+
87+
static String SCHEMA_SIMPLE_V2_JSON = aposToQuotes("{\n"+
88+
" 'type':'record',\n"+
89+
" 'name':'SimpleRecord',\n"+
90+
" 'fields':[\n"+
91+
" { 'name':'id', 'type':'int' },\n"+
92+
" { 'name':'name', 'type':'string' },\n"+
93+
" { 'name':'phone', 'type':['null', 'string'], 'default':null }\n"+
94+
" ]\n"+
95+
"}\n");
96+
97+
@JsonInclude(JsonInclude.Include.NON_NULL)
98+
public static class Employee {
99+
public String code;
100+
public String countryCode;
101+
public String createdBy;
102+
public String createdDate;
103+
public long id;
104+
public String lastModifiedBy;
105+
public String lastModifiedDate;
106+
public String name;
107+
public String phone;
108+
109+
protected Employee() { }
110+
111+
public Employee(String code, String countryCode, String createdBy,
112+
String createdDate, long id, String lastModifiedBy,
113+
String lastModifiedDate, String name) {
114+
this.code = code;
115+
this.countryCode = countryCode;
116+
this.createdBy = createdBy;
117+
this.createdDate = createdDate;
118+
this.id = id;
119+
this.lastModifiedBy = lastModifiedBy;
120+
this.lastModifiedDate = lastModifiedDate;
121+
this.name = name;
122+
}
123+
}
124+
125+
@JsonInclude(JsonInclude.Include.NON_NULL)
126+
public static class SimpleRecord {
127+
public int id;
128+
public String name;
129+
public String phone;
130+
131+
protected SimpleRecord() { }
132+
133+
public SimpleRecord(int id, String name) {
134+
this.id = id;
135+
this.name = name;
136+
}
137+
}
138+
139+
private final AvroMapper MAPPER = newMapper();
140+
141+
@Test
142+
public void testSimpleAddNullableFieldWithDefault() throws Exception
143+
{
144+
final AvroSchema srcSchema = MAPPER.schemaFrom(SCHEMA_SIMPLE_V1_JSON);
145+
final AvroSchema dstSchema = MAPPER.schemaFrom(SCHEMA_SIMPLE_V2_JSON);
146+
final AvroSchema xlate = srcSchema.withReaderSchema(dstSchema);
147+
148+
// Write data using old schema (without phone field)
149+
byte[] avro = MAPPER.writer(srcSchema).writeValueAsBytes(new SimpleRecord(1, "Alice"));
150+
151+
// Read using new schema (with phone field defaulting to null)
152+
// This should NOT throw "Unexpected end-of-input in FIELD_NAME"
153+
SimpleRecord result = MAPPER.readerFor(SimpleRecord.class)
154+
.with(xlate)
155+
.readValue(avro);
156+
157+
assertEquals(1, result.id);
158+
assertEquals("Alice", result.name);
159+
assertNull(result.phone); // Should use default value
160+
}
161+
162+
// This test demonstrates INCORRECT usage: trying to read data serialized with an old schema
163+
// using a new schema directly, without calling withReaderSchema().
164+
// This is expected to fail because Avro binary format doesn't include schema metadata,
165+
// so the reader can't know the data was written with a different schema.
166+
// Users MUST call withReaderSchema() when reading data written with a different schema.
167+
@Test
168+
public void testSimpleAddNullableFieldWithDefaultWrongUsage() throws Exception
169+
{
170+
final AvroSchema srcSchema = MAPPER.schemaFrom(SCHEMA_SIMPLE_V1_JSON);
171+
final AvroSchema dstSchema = MAPPER.schemaFrom(SCHEMA_SIMPLE_V2_JSON);
172+
173+
// Write data using old schema (without phone field)
174+
byte[] avro = MAPPER.writer(srcSchema).writeValueAsBytes(new SimpleRecord(1, "Alice"));
175+
176+
// INCORRECT: Try to read with new schema directly without using withReaderSchema
177+
// This triggers EOF error because the reader expects to find the phone field in binary data
178+
// but the data doesn't contain it.
179+
StreamReadException thrown = assertThrows(StreamReadException.class, () -> {
180+
MAPPER.readerFor(SimpleRecord.class)
181+
.with(dstSchema) // Using dstSchema directly instead of xlate
182+
.readValue(avro);
183+
});
184+
185+
verifyException(thrown, "Unexpected end-of-input in PROPERTY_NAME");
186+
}
187+
188+
@Test
189+
public void testAddNullableFieldWithDefault() throws Exception
190+
{
191+
final AvroSchema srcSchema = MAPPER.schemaFrom(SCHEMA_V1_JSON);
192+
final AvroSchema dstSchema = MAPPER.schemaFrom(SCHEMA_V2_JSON);
193+
final AvroSchema xlate = srcSchema.withReaderSchema(dstSchema);
194+
195+
// Write data using old schema (without phone field)
196+
Employee emp = new Employee("EMP001", "US", "admin", "2024-01-01",
197+
123L, "admin", "2024-01-01", "John Doe");
198+
byte[] avro = MAPPER.writer(srcSchema).writeValueAsBytes(emp);
199+
200+
// Read using new schema (with phone field defaulting to null)
201+
// This should NOT throw "Unexpected end-of-input in FIELD_NAME"
202+
Employee result = MAPPER.readerFor(Employee.class)
203+
.with(xlate)
204+
.readValue(avro);
205+
206+
assertEquals("EMP001", result.code);
207+
assertEquals("US", result.countryCode);
208+
assertEquals("admin", result.createdBy);
209+
assertEquals("2024-01-01", result.createdDate);
210+
assertEquals(123L, result.id);
211+
assertEquals("admin", result.lastModifiedBy);
212+
assertEquals("2024-01-01", result.lastModifiedDate);
213+
assertEquals("John Doe", result.name);
214+
assertNull(result.phone); // Should use default value
215+
}
216+
}

0 commit comments

Comments
 (0)