diff --git a/release-notes/VERSION b/release-notes/VERSION index 788647365b..6422f3a2b4 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -44,6 +44,10 @@ Versions: 3.x (for earlier see VERSION-2.x) #3964: Deserialization issue: MismatchedInputException, Bean not yet resolved (reported by @detomarco) +#4118: Deserialization of a certain kinds of parametrized properties fail to + resolve `?` into expected bounds, resulting in `LinkedHashMap` + (reported by @Mariusz) + (fix by @cowtowncoder, w/ Claude code) #4277: Deserialization `@JsonFormat(shape = JsonFormat.Shape.ARRAY)` POJO with `JsonTypeInfo.As.EXTERNAL_PROPERTY` does not work (reported by James M) diff --git a/src/main/java/tools/jackson/databind/type/TypeFactory.java b/src/main/java/tools/jackson/databind/type/TypeFactory.java index 71aa4737c9..c0e7d8e18f 100644 --- a/src/main/java/tools/jackson/databind/type/TypeFactory.java +++ b/src/main/java/tools/jackson/databind/type/TypeFactory.java @@ -1544,21 +1544,22 @@ protected JavaType _fromParamType(ClassStack context, ParameterizedType ptype, } newBindings = TypeBindings.create(rawType, pt); - // [databind#4118] Unbind any wildcards with a less specific upper bound than - // declared on the type variable - - // [databind#4147] Regressum Maximum, alas! Need to undo fix due to side effects; - // plan is to re-tackle in 2.17 - /* + // [databind#4118] Unbind wildcards in (direct) self-referential type parameters + // to allow deserializers to use the class definition's bounds instead of Object. + // [databind#4147] Only unbind for self-referential parameters to avoid + // breaking multi-parameter types like Either where only some are wildcards. for (int i = 0; i < paramCount; ++i) { if (args[i] instanceof WildcardType && !pt[i].hasGenericTypes()) { TypeVariable> typeVariable = rawType.getTypeParameters()[i]; - if (pt[i].getRawClass().isAssignableFrom(rawClass(typeVariable))) { - newBindings = newBindings.withoutVariable(typeVariable.getName()); + + // Only unbind if this is a (direct) self-referential type parameter + if (_isSelfReferentialTypeParameter(typeVariable, rawType)) { + if (pt[i].getRawClass().isAssignableFrom(rawClass(typeVariable))) { + newBindings = newBindings.withoutVariable(typeVariable.getName()); + } } } } - */ } return _fromClass(context, rawType, newBindings); } @@ -1608,4 +1609,37 @@ protected JavaType _fromWildcard(ClassStack context, WildcardType type, TypeBind */ return _fromAny(context, type.getUpperBounds()[0], bindings); } + + /** + * Helper method to determine if a type parameter is directly self-referential, + * meaning its bound references the declaring class itself. + * For example, in {@code class Foo>}, T is self-referential. + * This is used to handle recursive wildcard types correctly (see [databind#4118]). + *

+ * NOTE: does NOT check for indirect (nested) self-references: should be rare + * in practice but potential concern. + * + * @param typeVar Type variable to check + * @param declaringClass The class that declares this type variable + * @return {@code true} if the type variable's bound references the declaring class + * + * @since 3.1 + */ + protected boolean _isSelfReferentialTypeParameter(TypeVariable typeVar, Class declaringClass) { + Type[] bounds = typeVar.getBounds(); + if (bounds == null || bounds.length == 0) { + return false; + } + + // Check the first bound (typically the important one) + Type bound = bounds[0]; + + // May be directly the class (raw type bound) + if (bound == declaringClass) { + return true; + } + + // Or if bound is a ParameterizedType, check if its raw type is the declaring class + return (bound instanceof ParameterizedType pt && pt.getRawType() == declaringClass); + } } diff --git a/src/test/java/tools/jackson/databind/deser/MultiParamWildcard4147Test.java b/src/test/java/tools/jackson/databind/deser/MultiParamWildcard4147Test.java new file mode 100644 index 0000000000..b6b41671e6 --- /dev/null +++ b/src/test/java/tools/jackson/databind/deser/MultiParamWildcard4147Test.java @@ -0,0 +1,71 @@ +package tools.jackson.databind.deser; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.*; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test to ensure multi-parameter generic types with wildcards + * work correctly (regression test for #4147). + * + * The fix for #4118 initially broke Scala's Either type, which has + * two type parameters where only one might be a wildcard. This test + * ensures that such types continue to work correctly. + */ +class MultiParamWildcard4147Test extends DatabindTestUtil +{ + // Simulates Either pattern + static class Pair { + public L left; + public R right; + + public Pair() {} + public Pair(L left, R right) { + this.left = left; + this.right = right; + } + } + + static class Container { + public List> pairs; + + public Container() {} + public Container(List> pairs) { + this.pairs = pairs; + } + } + + private final ObjectMapper MAPPER = newJsonMapper(); + + // for [databind#4147] + @Test + void multiParamWithWildcard() throws Exception { + String json = a2q("{'pairs':[{'left':'test','right':123}]}"); + Container result = MAPPER.readValue(json, Container.class); + + assertNotNull(result.pairs); + assertEquals(1, result.pairs.size()); + assertEquals("test", result.pairs.get(0).left); + assertNotNull(result.pairs.get(0).right); + // Right side should be deserialized as Integer (not LinkedHashMap) + assertEquals(Integer.class, result.pairs.get(0).right.getClass()); + assertEquals(123, result.pairs.get(0).right); + } + + // Additional test: both parameters are wildcards + @Test + void multiParamWithBothWildcards() throws Exception { + String json = a2q("{'left':'hello','right':456}"); + + Pair result = MAPPER.readValue(json, Pair.class); + + assertNotNull(result); + assertEquals("hello", result.left); + assertEquals(456, result.right); + } +} diff --git a/src/test/java/tools/jackson/databind/tofix/RecursiveWildcard4118Test.java b/src/test/java/tools/jackson/databind/deser/RecursiveWildcard4118Test.java similarity index 93% rename from src/test/java/tools/jackson/databind/tofix/RecursiveWildcard4118Test.java rename to src/test/java/tools/jackson/databind/deser/RecursiveWildcard4118Test.java index b2335ce83e..d4158cc110 100644 --- a/src/test/java/tools/jackson/databind/tofix/RecursiveWildcard4118Test.java +++ b/src/test/java/tools/jackson/databind/deser/RecursiveWildcard4118Test.java @@ -1,4 +1,4 @@ -package tools.jackson.databind.tofix; +package tools.jackson.databind.deser; import java.util.ArrayList; import java.util.Arrays; @@ -11,7 +11,6 @@ import tools.jackson.core.type.TypeReference; import tools.jackson.databind.*; import tools.jackson.databind.testutil.DatabindTestUtil; -import tools.jackson.databind.testutil.failure.JacksonTestFailureExpected; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -57,7 +56,6 @@ public TestObject4118(List> attributes) { private final ObjectMapper MAPPER = newJsonMapper(); // for [databind#4118] - @JacksonTestFailureExpected @Test void recursiveWildcard4118() throws Exception { Tree tree = MAPPER.readValue("[[[]]]", new TypeReference>() { @@ -69,7 +67,6 @@ void recursiveWildcard4118() throws Exception { } // for [databind#4118] - @JacksonTestFailureExpected @Test void deserWildcard4118() throws Exception { // Given