diff --git a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java index a28cebc37..812b2af41 100644 --- a/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java +++ b/avro/src/main/java/com/fasterxml/jackson/dataformat/avro/schema/RecordVisitor.java @@ -101,9 +101,8 @@ public RecordVisitor(SerializerProvider p, JavaType type, VisitorFormatWrapperIm // (see org.apache.avro.Schema.RecordSchema#computeHash). // Therefore, unionSchemas must not be HashSet (or any other type // using hashCode() for equality check). - // Set ensures that each subType schema is once in resulting union. - // IdentityHashMap is used because it is using reference-equality. - final Set unionSchemas = Collections.newSetFromMap(new IdentityHashMap<>()); + // ArrayList ensures that ordering of subTypes is preserved. + final List unionSchemas = new ArrayList<>(); // Initialize with this schema if (_type.isConcrete()) { unionSchemas.add(_typeSchema); @@ -126,7 +125,7 @@ public RecordVisitor(SerializerProvider p, JavaType type, VisitorFormatWrapperIm unionSchemas.add(subTypeSchema); } } - _avroSchema = Schema.createUnion(new ArrayList<>(unionSchemas)); + _avroSchema = Schema.createUnion(deduplicateByReference(unionSchemas)); } catch (JsonMappingException jme) { throw new RuntimeJsonMappingException("Failed to build schema", jme); } @@ -135,6 +134,19 @@ public RecordVisitor(SerializerProvider p, JavaType type, VisitorFormatWrapperIm _visitorWrapper.getSchemas().addSchema(type, _avroSchema); } + private static List deduplicateByReference(List schemas) { + final List result = new ArrayList<>(); + // Set based on IdentityHashMap is used because we need to deduplicate by reference. + final Set seenSchemas = Collections.newSetFromMap(new IdentityHashMap<>()); + + for(Schema s : schemas) { + if(seenSchemas.add(s)) { + result.add(s); // preserve order + } + } + return result; + } + @Override public Schema builtAvroSchema() { if (!_overridden) { diff --git a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/schema/PolymorphicTypeAnnotationsTest.java b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/schema/PolymorphicTypeAnnotationsTest.java index f21675478..f6a82d64e 100644 --- a/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/schema/PolymorphicTypeAnnotationsTest.java +++ b/avro/src/test/java/com/fasterxml/jackson/dataformat/avro/schema/PolymorphicTypeAnnotationsTest.java @@ -264,9 +264,17 @@ public void base_class_explicitly_in_Union_annotation_test() throws Exception { } @Union({ - // Interface being explicitly in @Union led to StackOverflowError exception. - DocumentInterface.class, - Word.class, Excel.class}) + // Interface being explicitly in @Union led to StackOverflowError exception. + DocumentInterface.class, + // We added a bunch of implementations to test deterministic ordering of the schemas' subtypes ordering. + Word.class, + Excel.class, + Pdf.class, + PowerPoint.class, + TextDocument.class, + Markdown.class, + HtmlDocument.class + }) interface DocumentInterface { } @@ -276,11 +284,32 @@ static class Word implements DocumentInterface { static class Excel implements DocumentInterface { } + static class Pdf implements DocumentInterface { + } + + static class PowerPoint implements DocumentInterface { + } + + static class TextDocument implements DocumentInterface { + } + + static class Markdown implements DocumentInterface { + } + + static class HtmlDocument implements DocumentInterface { + } + + @Test public void interface_explicitly_in_Union_annotation_test() throws Exception { // GIVEN final Schema wordSchema = MAPPER.schemaFor(Word.class).getAvroSchema(); final Schema excelSchema = MAPPER.schemaFor(Excel.class).getAvroSchema(); + final Schema pdfSchema = MAPPER.schemaFor(Pdf.class).getAvroSchema(); + final Schema powerPointSchema = MAPPER.schemaFor(PowerPoint.class).getAvroSchema(); + final Schema textSchema = MAPPER.schemaFor(TextDocument.class).getAvroSchema(); + final Schema markdownSchema = MAPPER.schemaFor(Markdown.class).getAvroSchema(); + final Schema htmlSchema = MAPPER.schemaFor(HtmlDocument.class).getAvroSchema(); // WHEN Schema actualSchema = MAPPER.schemaFor(DocumentInterface.class).getAvroSchema(); @@ -289,6 +318,16 @@ public void interface_explicitly_in_Union_annotation_test() throws Exception { // THEN assertThat(actualSchema.getType()).isEqualTo(Schema.Type.UNION); - assertThat(actualSchema.getTypes()).containsExactlyInAnyOrder(wordSchema, excelSchema); + + // Deterministic order: exactly as declared in @Union (excluding the interface). + assertThat(actualSchema.getTypes()).containsExactly( + wordSchema, + excelSchema, + pdfSchema, + powerPointSchema, + textSchema, + markdownSchema, + htmlSchema + ); } } diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 23f1883a7..f7ddf864c 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -18,6 +18,13 @@ Active maintainers: No changes since 2.19 +2.19.3 (not yet released) + +#601: Jackson subtype Avro schema unions are non-deterministic and therefore + incompatible with each other + (reported by Raphael W) + (fix by Vincent E) + 2.19.2 (18-Jul-2025) No changes since 2.19.1