Skip to content

Commit 0d7f007

Browse files
authored
[AVRO] #589: Fix schema not including base class for records with subclasses (#593)
1 parent 0922af7 commit 0d7f007

File tree

4 files changed

+390
-18
lines changed

4 files changed

+390
-18
lines changed

avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,46 @@ public class RecordVisitor
2626
protected final VisitorFormatWrapperImpl _visitorWrapper;
2727

2828
/**
29-
* Tracks if the schema for this record has been overridden (by an annotation or other means), and calls to the {@code property} and
30-
* {@code optionalProperty} methods should be ignored.
29+
* Tracks if the schema for this record has been overridden (by an annotation or other means),
30+
* and calls to the {@code property} and {@code optionalProperty} methods should be ignored.
3131
*/
3232
protected final boolean _overridden;
3333

34+
/**
35+
* When Avro schema for this JavaType ({@code _type}) results in UNION of multiple Avro types,
36+
* _typeSchema keeps track of which Avro type in the UNION represents this JavaType ({@code _type})
37+
* so that fields of this JavaType can be set to the right Avro type by {@code builtAvroSchema()}.
38+
*<br>
39+
* Example:
40+
* <pre>
41+
* @JsonSubTypes({
42+
* @JsonSubTypes.Type(value = Apple.class),
43+
* @JsonSubTypes.Type(value = Pear.class) })
44+
* class Fruit {}
45+
*
46+
* class Apple extends Fruit {}
47+
* class Orange extends Fruit {}
48+
* </pre>
49+
* When {@code _type = Fruit.class}
50+
* Then
51+
* _avroSchema if Fruit.class is union of Fruit record, Apple record and Orange record schemas: [
52+
* { name: Fruit, type: record, fields: [..] }, <--- _typeSchema points here
53+
* { name: Apple, type: record, fields: [..] },
54+
* { name: Orange, type: record, fields: [..]}
55+
* ]
56+
* _typeSchema points to Fruit.class without subtypes record schema
57+
*
58+
* FIXME: When _typeSchema is not null, then _overridden must be true, therefore (_overridden == true) can be replaced with (_typeSchema != null),
59+
* but it might be considered API change cause _overridden has protected access modifier.
60+
*
61+
* @since 2.19.1
62+
*/
63+
private final Schema _typeSchema;
64+
65+
// !!! 19-May-2025: TODO: make final in 2.20
3466
protected Schema _avroSchema;
3567

68+
// !!! 19-May-2025: TODO: make final in 2.20
3669
protected List<Schema.Field> _fields = new ArrayList<>();
3770

3871
public RecordVisitor(SerializerProvider p, JavaType type, VisitorFormatWrapperImpl visitorWrapper)
@@ -42,32 +75,62 @@ public RecordVisitor(SerializerProvider p, JavaType type, VisitorFormatWrapperIm
4275
_visitorWrapper = visitorWrapper;
4376
// Check if the schema for this record is overridden
4477
BeanDescription bean = getProvider().getConfig().introspectDirectClassAnnotations(_type);
45-
List<NamedType> subTypes = getProvider().getAnnotationIntrospector().findSubtypes(bean.getClassInfo());
4678
AvroSchema ann = bean.getClassInfo().getAnnotation(AvroSchema.class);
4779
if (ann != null) {
4880
_avroSchema = AvroSchemaHelper.parseJsonSchema(ann.value());
4981
_overridden = true;
50-
} else if (subTypes != null && !subTypes.isEmpty()) {
51-
List<Schema> unionSchemas = new ArrayList<>();
52-
try {
53-
for (NamedType subType : subTypes) {
54-
JsonSerializer<?> ser = getProvider().findValueSerializer(subType.getType());
55-
VisitorFormatWrapperImpl visitor = _visitorWrapper.createChildWrapper();
56-
ser.acceptJsonFormatVisitor(visitor, getProvider().getTypeFactory().constructType(subType.getType()));
57-
unionSchemas.add(visitor.getAvroSchema());
58-
}
59-
_avroSchema = Schema.createUnion(unionSchemas);
60-
_overridden = true;
61-
} catch (JsonMappingException jme) {
62-
throw new RuntimeException("Failed to build schema", jme);
63-
}
82+
_typeSchema = null;
6483
} else {
84+
// If Avro schema for this _type results in UNION I want to know Avro type where to assign fields
6585
_avroSchema = AvroSchemaHelper.initializeRecordSchema(bean);
86+
_typeSchema = _avroSchema;
6687
_overridden = false;
6788
AvroMeta meta = bean.getClassInfo().getAnnotation(AvroMeta.class);
6889
if (meta != null) {
6990
_avroSchema.addProp(meta.key(), meta.value());
7091
}
92+
93+
List<NamedType> subTypes = getProvider().getAnnotationIntrospector().findSubtypes(bean.getClassInfo());
94+
if (subTypes != null && !subTypes.isEmpty()) {
95+
// alreadySeenClasses prevents subType processing in endless loop
96+
Set<Class<?>> alreadySeenClasses = new HashSet<>();
97+
alreadySeenClasses.add(_type.getRawClass());
98+
99+
// At this point calculating hashCode for _typeSchema fails with
100+
// NPE because RecordSchema.fields is NULL
101+
// (see org.apache.avro.Schema.RecordSchema#computeHash).
102+
// Therefore, unionSchemas must not be HashSet (or any other type
103+
// using hashCode() for equality check).
104+
// Set ensures that each subType schema is once in resulting union.
105+
// IdentityHashMap is used because it is using reference-equality.
106+
final Set<Schema> unionSchemas = Collections.newSetFromMap(new IdentityHashMap<>());
107+
// Initialize with this schema
108+
if (_type.isConcrete()) {
109+
unionSchemas.add(_typeSchema);
110+
}
111+
112+
try {
113+
for (NamedType subType : subTypes) {
114+
if (!alreadySeenClasses.add(subType.getType())) {
115+
continue;
116+
}
117+
JsonSerializer<?> ser = getProvider().findValueSerializer(subType.getType());
118+
VisitorFormatWrapperImpl visitor = _visitorWrapper.createChildWrapper();
119+
ser.acceptJsonFormatVisitor(visitor, getProvider().getTypeFactory().constructType(subType.getType()));
120+
// Add subType schema into this union, unless it is already there.
121+
Schema subTypeSchema = visitor.getAvroSchema();
122+
// When subType schema is union itself, include each its type into this union if not there already
123+
if (subTypeSchema.getType() == Type.UNION) {
124+
unionSchemas.addAll(subTypeSchema.getTypes());
125+
} else {
126+
unionSchemas.add(subTypeSchema);
127+
}
128+
}
129+
_avroSchema = Schema.createUnion(new ArrayList<>(unionSchemas));
130+
} catch (JsonMappingException jme) {
131+
throw new RuntimeJsonMappingException("Failed to build schema", jme);
132+
}
133+
}
71134
}
72135
_visitorWrapper.getSchemas().addSchema(type, _avroSchema);
73136
}
@@ -76,7 +139,7 @@ public RecordVisitor(SerializerProvider p, JavaType type, VisitorFormatWrapperIm
76139
public Schema builtAvroSchema() {
77140
if (!_overridden) {
78141
// Assumption now is that we are done, so let's assign fields
79-
_avroSchema.setFields(_fields);
142+
_typeSchema.setFields(_fields);
80143
}
81144
return _avroSchema;
82145
}

0 commit comments

Comments
 (0)