Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 43 additions & 9 deletions src/main/java/tools/jackson/databind/type/TypeFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<L, R> where only some are wildcards.
for (int i = 0; i < paramCount; ++i) {
if (args[i] instanceof WildcardType && !pt[i].hasGenericTypes()) {
TypeVariable<? extends Class<?>> 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);
}
Expand Down Expand Up @@ -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 extends Foo<?>>}, T is self-referential.
* This is used to handle recursive wildcard types correctly (see [databind#4118]).
*<p>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<L, R> pattern
static class Pair<L, R> {
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<Pair<String, ?>> pairs;

public Container() {}
public Container(List<Pair<String, ?>> 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);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package tools.jackson.databind.tofix;
package tools.jackson.databind.deser;

import java.util.ArrayList;
import java.util.Arrays;
Expand All @@ -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;

Expand Down Expand Up @@ -57,7 +56,6 @@ public TestObject4118(List<TestAttribute4118<?>> attributes) {
private final ObjectMapper MAPPER = newJsonMapper();

// for [databind#4118]
@JacksonTestFailureExpected
@Test
void recursiveWildcard4118() throws Exception {
Tree<?> tree = MAPPER.readValue("[[[]]]", new TypeReference<Tree<?>>() {
Expand All @@ -69,7 +67,6 @@ void recursiveWildcard4118() throws Exception {
}

// for [databind#4118]
@JacksonTestFailureExpected
@Test
void deserWildcard4118() throws Exception {
// Given
Expand Down