@@ -29,58 +29,122 @@ public class RecordVisitor
2929 protected final VisitorFormatWrapperImpl _visitorWrapper ;
3030
3131 /**
32- * Tracks if the schema for this record has been overridden (by an annotation or other means), and calls to the {@code property} and
33- * {@code optionalProperty} methods should be ignored.
32+ * Tracks if the schema for this record has been overridden (by an annotation or other means),
33+ * and calls to the {@code property} and {@code optionalProperty} methods should be ignored.
3434 */
3535 protected final boolean _overridden ;
3636
3737 protected final boolean _cfgAddNullDefaults ;
3838
39+ /**
40+ * When Avro schema for this JavaType ({@code _type}) results in UNION of multiple Avro types,
41+ * _typeSchema keeps track of which Avro type in the UNION represents this JavaType ({@code _type})
42+ * so that fields of this JavaType can be set to the right Avro type by {@code builtAvroSchema()}.
43+ *<br>
44+ * Example:
45+ * <pre>
46+ * @JsonSubTypes({
47+ * @JsonSubTypes.Type(value = Apple.class),
48+ * @JsonSubTypes.Type(value = Pear.class) })
49+ * class Fruit {}
50+ *
51+ * class Apple extends Fruit {}
52+ * class Orange extends Fruit {}
53+ * </pre>
54+ * When {@code _type = Fruit.class}
55+ * Then
56+ * _avroSchema if Fruit.class is union of Fruit record, Apple record and Orange record schemas: [
57+ * { name: Fruit, type: record, fields: [..] }, <--- _typeSchema points here
58+ * { name: Apple, type: record, fields: [..] },
59+ * { name: Orange, type: record, fields: [..]}
60+ * ]
61+ * _typeSchema points to Fruit.class without subtypes record schema
62+ *
63+ * FIXME: When _typeSchema is not null, then _overridden must be true, therefore (_overridden == true) can be replaced with (_typeSchema != null),
64+ * but it might be considered API change cause _overridden has protected access modifier.
65+ *
66+ * @since 2.19.1
67+ */
68+ private final Schema _typeSchema ;
69+
70+ // !!! 19-May-2025: TODO: make final in 2.20
3971 protected Schema _avroSchema ;
4072
73+ // !!! 19-May-2025: TODO: make final in 2.20
4174 protected List <Schema .Field > _fields = new ArrayList <>();
4275
43- public RecordVisitor (SerializationContext p , JavaType type , VisitorFormatWrapperImpl visitorWrapper )
76+ public RecordVisitor (SerializationContext ctxt , JavaType type ,
77+ VisitorFormatWrapperImpl visitorWrapper )
4478 {
45- super (p );
79+ super (ctxt );
4680 _type = type ;
4781 _visitorWrapper = visitorWrapper ;
4882
49- AvroFactory avroFactory = (AvroFactory ) p .tokenStreamFactory ();
83+ AvroFactory avroFactory = (AvroFactory ) ctxt .tokenStreamFactory ();
5084 _cfgAddNullDefaults = avroFactory .isEnabled (AvroWriteFeature .ADD_NULL_AS_DEFAULT_VALUE_IN_SCHEMA );
5185
5286 // Check if the schema for this record is overridden
53- SerializationConfig config = p .getConfig ();
87+ SerializationConfig config = ctxt .getConfig ();
5488
5589 // 12-Oct-2019, tatu: VERY important: only get direct annotations, not for supertypes --
5690 // otherwise there's infinite loop awaiting for... some reason. Other parts of code
5791 // should probably check for loops but bit hard for me to fix as I did not author
5892 // code in question (so may be unaware of some nuances)
59- final AnnotatedClass annotations = p .introspectDirectClassAnnotations (_type );
60- final AnnotationIntrospector intr = p .getAnnotationIntrospector ();
61- List <NamedType > subTypes = intr .findSubtypes (config , annotations );
93+ final AnnotatedClass annotations = ctxt .introspectDirectClassAnnotations (_type );
94+ final AnnotationIntrospector intr = ctxt .getAnnotationIntrospector ();
95+ // List<NamedType> subTypes = intr.findSubtypes(config, annotations);
6296 AvroSchema ann = annotations .getAnnotation (AvroSchema .class );
6397 if (ann != null ) {
6498 _avroSchema = AvroSchemaHelper .parseJsonSchema (ann .value ());
6599 _overridden = true ;
66- } else if (subTypes != null && !subTypes .isEmpty ()) {
67- List <Schema > unionSchemas = new ArrayList <>();
68- for (NamedType subType : subTypes ) {
69- final JavaType subTypeType = getContext ().getTypeFactory ().constructType (subType .getType ());
70- ValueSerializer <?> ser = getContext ().findValueSerializer (subTypeType );
71- VisitorFormatWrapperImpl visitor = _visitorWrapper .createChildWrapper ();
72- ser .acceptJsonFormatVisitor (visitor , subTypeType );
73- unionSchemas .add (visitor .getAvroSchema ());
74- }
75- _avroSchema = Schema .createUnion (unionSchemas );
76- _overridden = true ;
100+ _typeSchema = null ;
77101 } else {
78- _avroSchema = AvroSchemaHelper .initializeRecordSchema (p .getConfig (), _type , annotations );
102+ // If Avro schema for this _type results in UNION I want to know Avro type where to assign fields
103+ _avroSchema = AvroSchemaHelper .initializeRecordSchema (ctxt .getConfig (), _type , annotations );
104+ _typeSchema = _avroSchema ;
79105 _overridden = false ;
80106 AvroMeta meta = annotations .getAnnotation (AvroMeta .class );
81107 if (meta != null ) {
82108 _avroSchema .addProp (meta .key (), meta .value ());
83109 }
110+
111+ List <NamedType > subTypes = intr .findSubtypes (config , annotations );
112+ if (subTypes != null && !subTypes .isEmpty ()) {
113+ // alreadySeenClasses prevents subType processing in endless loop
114+ Set <Class <?>> alreadySeenClasses = new HashSet <>();
115+ alreadySeenClasses .add (_type .getRawClass ());
116+
117+ // At this point calculating hashCode for _typeSchema fails with
118+ // NPE because RecordSchema.fields is NULL
119+ // (see org.apache.avro.Schema.RecordSchema#computeHash).
120+ // Therefore, unionSchemas must not be HashSet (or any other type
121+ // using hashCode() for equality check).
122+ // Set ensures that each subType schema is once in resulting union.
123+ // IdentityHashMap is used because it is using reference-equality.
124+ final Set <Schema > unionSchemas = Collections .newSetFromMap (new IdentityHashMap <>());
125+ // Initialize with this schema
126+ if (_type .isConcrete ()) {
127+ unionSchemas .add (_typeSchema );
128+ }
129+
130+ for (NamedType subType : subTypes ) {
131+ if (!alreadySeenClasses .add (subType .getType ())) {
132+ continue ;
133+ }
134+ ValueSerializer <?> ser = ctxt .findValueSerializer (subType .getType ());
135+ VisitorFormatWrapperImpl visitor = _visitorWrapper .createChildWrapper ();
136+ ser .acceptJsonFormatVisitor (visitor ,ctxt .constructType (subType .getType ()));
137+ // Add subType schema into this union, unless it is already there.
138+ Schema subTypeSchema = visitor .getAvroSchema ();
139+ // When subType schema is union itself, include each its type into this union if not there already
140+ if (subTypeSchema .getType () == Type .UNION ) {
141+ unionSchemas .addAll (subTypeSchema .getTypes ());
142+ } else {
143+ unionSchemas .add (subTypeSchema );
144+ }
145+ }
146+ _avroSchema = Schema .createUnion (new ArrayList <>(unionSchemas ));
147+ }
84148 }
85149 _visitorWrapper .getSchemas ().addSchema (type , _avroSchema );
86150 }
@@ -89,7 +153,7 @@ public RecordVisitor(SerializationContext p, JavaType type, VisitorFormatWrapper
89153 public Schema builtAvroSchema () {
90154 if (!_overridden ) {
91155 // Assumption now is that we are done, so let's assign fields
92- _avroSchema .setFields (_fields );
156+ _typeSchema .setFields (_fields );
93157 }
94158 return _avroSchema ;
95159 }
0 commit comments