From 4d53e666ea9d38b9013e11f699368e260245572e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 8 Jan 2026 18:24:18 -0800 Subject: [PATCH 1/3] Implement #4708 --- release-notes/VERSION | 3 + .../databind/DeserializationFeature.java | 18 ++ .../impl/AsDeductionTypeDeserializer.java | 8 + .../DeductionWithAbstractSubtype4708Test.java | 214 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 src/test/java/tools/jackson/databind/jsontype/DeductionWithAbstractSubtype4708Test.java diff --git a/release-notes/VERSION b/release-notes/VERSION index ee2a8a7291..0516c9c08e 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -52,6 +52,9 @@ Versions: 3.x (for earlier see VERSION-2.x) creator property" when deserializing JSON with dup property to single-property Record (reported by @sseelmann) (fix contributed by @JacksonJang) +#4708: `JsonTypeInfo.Id.DEDUCTION` should blocks signatures for non-instantiable + types (abstract classes, interfaces) + (implemented by @cowtowncoder, w/ Claude code) #5115: `@JsonUnwrapped` Record deserialization can't handle name collision (reported by Viktor S) (fix contributed by @JacksonJang) diff --git a/src/main/java/tools/jackson/databind/DeserializationFeature.java b/src/main/java/tools/jackson/databind/DeserializationFeature.java index 018340c182..99b5d3a4ef 100644 --- a/src/main/java/tools/jackson/databind/DeserializationFeature.java +++ b/src/main/java/tools/jackson/databind/DeserializationFeature.java @@ -171,6 +171,24 @@ public enum DeserializationFeature implements ConfigFeature */ FAIL_ON_INVALID_SUBTYPE(true), + /** + * Feature that determines whether abstract types (abstract classes, interfaces) + * should be ignored when building the type fingerprints for polymorphic type + * deduction using {@link com.fasterxml.jackson.annotation.JsonTypeInfo.Id#DEDUCTION}. + * When enabled, non-concrete types are excluded from deduction since they cannot + * be instantiated; when disabled, they participate in deduction which may cause + * signature conflicts with their concrete subclasses. + *

+ * This feature only affects deduction-based polymorphic deserialization; other + * type resolution mechanisms (NAME, CLASS, etc.) are not affected. + *

+ * Feature is enabled by default (since 3.1) to exclude abstract types from + * deduction and avoid signature conflicts. + * + * @since 3.1 + */ + IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION(true), + /** * Feature that determines what happens when reading JSON content into tree * ({@link JsonNode} and a duplicate key diff --git a/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java b/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java index 5534233115..fa63f0177e 100644 --- a/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java +++ b/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java @@ -66,6 +66,14 @@ protected Map buildFingerprints(DeserializationContext ctxt, for (NamedType subtype : subtypes) { JavaType subtyped = ctxt.constructType(subtype.getType()); + // [databind#4708]: Skip non-concrete types (abstract classes, interfaces) + // since they cannot be instantiated and should not participate in deduction + if (ctxt.isEnabled(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION)) { + Class rawType = subtyped.getRawClass(); + if (!ClassUtil.isConcrete(rawType)) { + continue; + } + } List properties = ctxt.introspectBeanDescription(subtyped).findProperties(); BitSet fingerprint = new BitSet(nextProperty + properties.size()); diff --git a/src/test/java/tools/jackson/databind/jsontype/DeductionWithAbstractSubtype4708Test.java b/src/test/java/tools/jackson/databind/jsontype/DeductionWithAbstractSubtype4708Test.java new file mode 100644 index 0000000000..9d2981a4d2 --- /dev/null +++ b/src/test/java/tools/jackson/databind/jsontype/DeductionWithAbstractSubtype4708Test.java @@ -0,0 +1,214 @@ +package tools.jackson.databind.jsontype; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import tools.jackson.databind.*; +import tools.jackson.databind.exc.InvalidDefinitionException; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for [databind#4708]: DEDUCTION mode should ignore abstract classes + * and interfaces since they cannot be instantiated. + */ +public class DeductionWithAbstractSubtype4708Test extends DatabindTestUtil +{ + // Simulating Kotlin sealed class hierarchy with abstract intermediate class + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ + @JsonSubTypes.Type(Ingredient.AbstractItemById.class), // Abstract class registered! + @JsonSubTypes.Type(Ingredient.ItemById.class), + @JsonSubTypes.Type(Ingredient.ItemByTag.class) + }) + sealed interface Ingredient permits Ingredient.Item { + + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ + @JsonSubTypes.Type(Ingredient.AbstractItemById.class), // Abstract class registered! + @JsonSubTypes.Type(Ingredient.ItemById.class), + @JsonSubTypes.Type(Ingredient.ItemByTag.class) + }) + sealed interface Item extends Ingredient + permits Ingredient.AbstractItemById, Ingredient.ItemByTag { + } + + // Abstract class with properties - should be IGNORED during deduction + // Previously this would cause signature conflicts + non-sealed abstract class AbstractItemById implements Item { + @JsonProperty("item") + public String id; + public int count = 1; + + public AbstractItemById() {} + public AbstractItemById(String id, int count) { + this.id = id; + this.count = count; + } + } + + // Concrete implementation of the abstract class + final class ItemById extends AbstractItemById { + public ItemById() {} + public ItemById(String id, int count) { + super(id, count); + } + } + + // Another concrete class with different signature + final class ItemByTag implements Item { + @JsonProperty("tag") + public String tag; + public int count = 1; + + public ItemByTag() {} + public ItemByTag(String tag, int count) { + this.tag = tag; + this.count = count; + } + } + } + + private final ObjectMapper MAPPER = newJsonMapper(); + + @Test + public void testDeductionWithAbstractIntermediateClass() throws Exception + { + // Should deduce to ItemById, ignoring the abstract AbstractItemById + String json1 = a2q("{'item':'minecraft:stone','count':64}"); + Ingredient result1 = MAPPER.readValue(json1, Ingredient.class); + + assertNotNull(result1); + assertTrue(result1 instanceof Ingredient.ItemById, + "Should deserialize to concrete ItemById, not abstract class"); + Ingredient.ItemById item1 = (Ingredient.ItemById) result1; + assertEquals("minecraft:stone", item1.id); + assertEquals(64, item1.count); + + // Should deduce to ItemByTag + String json2 = a2q("{'tag':'minecraft:logs','count':32}"); + Ingredient result2 = MAPPER.readValue(json2, Ingredient.class); + + assertNotNull(result2); + assertTrue(result2 instanceof Ingredient.ItemByTag); + Ingredient.ItemByTag item2 = (Ingredient.ItemByTag) result2; + assertEquals("minecraft:logs", item2.tag); + assertEquals(32, item2.count); + } + + @Test + public void testDeductionWithItemInterface() throws Exception + { + // When deserializing as Item interface, should also work + String json = a2q("{'item':'test','count':1}"); + + JavaType itemType = MAPPER.constructType(Ingredient.Item.class); + Ingredient.Item result = MAPPER.readValue(json, itemType); + + assertNotNull(result); + assertTrue(result instanceof Ingredient.ItemById); + assertEquals("test", ((Ingredient.ItemById) result).id); + } + + // Simpler test case with just abstract class and concrete subclass + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ + @JsonSubTypes.Type(Animal.class), // Abstract class registered! + @JsonSubTypes.Type(ConcreteAnimal.class) + }) + abstract static class Animal { + public String name; + } + + static class ConcreteAnimal extends Animal { + public int age; + } + + @Test + public void testSimpleAbstractClassIgnored() throws Exception + { + // Abstract Animal should be ignored, only ConcreteAnimal should be considered + String json = a2q("{'name':'Fido','age':5}"); + Animal result = MAPPER.readValue(json, Animal.class); + + assertNotNull(result); + assertTrue(result instanceof ConcreteAnimal); + assertEquals("Fido", result.name); + assertEquals(5, ((ConcreteAnimal) result).age); + } + + // Test with interface in the mix + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ + @JsonSubTypes.Type(Dog.class), + @JsonSubTypes.Type(Cat.class) + }) + interface Pet { + // Interface should be ignored + } + + static class Dog implements Pet { + public String breed; + } + + static class Cat implements Pet { + public boolean indoor; + } + + @Test + public void testInterfaceIgnored() throws Exception + { + // Interface Pet should be ignored during fingerprinting + String json1 = a2q("{'breed':'Labrador'}"); + Pet result1 = MAPPER.readValue(json1, Pet.class); + + assertNotNull(result1); + assertTrue(result1 instanceof Dog); + assertEquals("Labrador", ((Dog) result1).breed); + + String json2 = a2q("{'indoor':true}"); + Pet result2 = MAPPER.readValue(json2, Pet.class); + + assertNotNull(result2); + assertTrue(result2 instanceof Cat); + assertTrue(((Cat) result2).indoor); + } + + // Test that the feature can be disabled to get old behavior + @Test + public void testFeatureCanBeDisabled() throws Exception + { + // When feature is disabled, abstract types participate in deduction + // which causes signature conflicts (old buggy behavior) + ObjectMapper mapper = jsonMapperBuilder() + .disable(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION) + .build(); + + String json = a2q("{'item':'minecraft:stone','count':64}"); + + try { + mapper.readValue(json, Ingredient.class); + fail("Should have failed with signature conflict"); + } catch (InvalidDefinitionException e) { + verifyException(e, "Subtypes"); + verifyException(e, "have the same signature"); + verifyException(e, "cannot be uniquely deduced"); + // Verify it mentions both the abstract class and concrete class + verifyException(e, "AbstractItemById"); + verifyException(e, "ItemById"); + } + } + + // Test that feature is enabled by default + @Test + public void testFeatureEnabledByDefault() throws Exception + { + ObjectMapper mapper = newJsonMapper(); + assertTrue(mapper.isEnabled(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION), + "IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION should be enabled by default"); + } +} From 847abfaeb2efa04c30424ef7b3150bf415d73c6e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 8 Jan 2026 18:26:58 -0800 Subject: [PATCH 2/3] ... --- .../jsontype/impl/AsDeductionTypeDeserializer.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java b/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java index fa63f0177e..ff0f2ee26a 100644 --- a/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java +++ b/src/main/java/tools/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java @@ -64,15 +64,13 @@ protected Map buildFingerprints(DeserializationContext ctxt, int nextProperty = 0; Map fingerprints = new HashMap<>(); + final boolean removeAbstract = ctxt.isEnabled(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION); for (NamedType subtype : subtypes) { JavaType subtyped = ctxt.constructType(subtype.getType()); // [databind#4708]: Skip non-concrete types (abstract classes, interfaces) // since they cannot be instantiated and should not participate in deduction - if (ctxt.isEnabled(DeserializationFeature.IGNORE_ABSTRACT_TYPES_FOR_DEDUCTION)) { - Class rawType = subtyped.getRawClass(); - if (!ClassUtil.isConcrete(rawType)) { - continue; - } + if (removeAbstract && !ClassUtil.isConcrete(subtyped.getRawClass())) { + continue; } List properties = ctxt.introspectBeanDescription(subtyped).findProperties(); From 0bde427fb41c3436f58c8ed898220e33e97d28dc Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Thu, 8 Jan 2026 18:35:07 -0800 Subject: [PATCH 3/3] ... --- release-notes/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/VERSION b/release-notes/VERSION index 0516c9c08e..580265073f 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -52,7 +52,7 @@ Versions: 3.x (for earlier see VERSION-2.x) creator property" when deserializing JSON with dup property to single-property Record (reported by @sseelmann) (fix contributed by @JacksonJang) -#4708: `JsonTypeInfo.Id.DEDUCTION` should blocks signatures for non-instantiable +#4708: `JsonTypeInfo.Id.DEDUCTION` should block signatures for non-instantiable types (abstract classes, interfaces) (implemented by @cowtowncoder, w/ Claude code) #5115: `@JsonUnwrapped` Record deserialization can't handle name collision