From d6f490751dbb94ba9d4d9de756ad80f78a860f8a Mon Sep 17 00:00:00 2001 From: Benoit Lacelle Date: Sat, 28 Mar 2026 17:01:21 +0400 Subject: [PATCH 1/3] Add unit-tests and partial fix for @JsonTypeInfo interaction with @JsonValue --- .../jsontype/TypeResolverProvider.java | 1 + .../ser/jackson/JsonValueSerializer.java | 17 +- .../jsontype/TestJsonTypeInfoJsonValue.java | 221 ++++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java diff --git a/src/main/java/tools/jackson/databind/jsontype/TypeResolverProvider.java b/src/main/java/tools/jackson/databind/jsontype/TypeResolverProvider.java index fc8da3a1df..1370667cc1 100644 --- a/src/main/java/tools/jackson/databind/jsontype/TypeResolverProvider.java +++ b/src/main/java/tools/jackson/databind/jsontype/TypeResolverProvider.java @@ -280,6 +280,7 @@ protected TypeResolverBuilder _findTypeResolver(MapperConfig config, typeInfo = typeInfo.withInclusionType(JsonTypeInfo.As.PROPERTY); } + // [databind:4983] baseType comes from the annotated class/interface, not the serialized type detectedBaseType = ai.findPolymorphicBaseType(config, annotatedClass, typeInfo, baseType); } else { // when method/field annotated, declared type MUST be intended base type diff --git a/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java b/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java index 7d91917207..4b22adfc59 100644 --- a/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java @@ -180,12 +180,25 @@ public ValueSerializer createContextual(SerializationContext ctxt, * cases where "native" (aka "natural") type is being serialized, * using standard serializer */ - boolean forceTypeInformation = isNaturalTypeWithStdHandling(_valueType.getRawClass(), ser); + boolean forceTypeInformation; + if (_accessor == null) { + forceTypeInformation = isNaturalTypeWithStdHandling(_valueType.getRawClass(), ser); + } else { + // We came here due to a `@JsonValue`: the type of the accessed value is irrelevant as it is not the type of the original value + forceTypeInformation = false; + } return withResolved(property, vts, ser, forceTypeInformation); } // [databind#2822]: better hold on to "property", regardless if (property != _property) { - return withResolved(property, vts, ser, _forceTypeInformation); + boolean forceTypeInformation; + if (_accessor == null) { + forceTypeInformation = this._forceTypeInformation; + } else { + // We came here due to a `@JsonValue`: the type of the accessed value is irrelevant as it is not the type of the original value + forceTypeInformation = false; + } + return withResolved(property, vts, ser, forceTypeInformation); } } else { // 05-Sep-2013, tatu: I _think_ this can be considered a primary property... diff --git a/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java b/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java new file mode 100644 index 0000000000..42425a7445 --- /dev/null +++ b/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java @@ -0,0 +1,221 @@ +package tools.jackson.databind.jsontype; + +import java.util.Locale; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonValue; + +import tools.jackson.databind.ObjectMapper; + +// Investigate around AsPropertyTypeSerializer +// tools.jackson.databind.ser.jackson.JsonValueSerializer.serializeWithType(Object, JsonGenerator, SerializationContext, TypeSerializer) +// tools.jackson.databind.ser.BasicSerializerFactory.findSerializerByAnnotations(SerializationContext, JavaType, Supplier) +public class TestJsonTypeInfoJsonValue { + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", + defaultImpl = AroundString.class) + public interface AroundSomething { + Object getInner(); + } + + public static class AroundString implements AroundSomething { + @JsonValue + String inner; + + @JsonCreator + public AroundString(String inner) { + this.inner = inner; + } + + @Override + public Object getInner() { + return inner; + } + + public void setInner(String inner) { + this.inner = inner; + } + + } + + public static class AroundObject implements AroundSomething { + @JsonValue + Object inner; + + @JsonCreator + public AroundObject(Object inner) { + this.inner = inner; + } + + @Override + public Object getInner() { + return inner; + } + + public void setInner(String inner) { + this.inner = inner; + } + + } + + public static class AroundObject_NotJsonValue implements AroundSomething { + Object inner; + + @Override + public Object getInner() { + return inner; + } + + public void setInner(String inner) { + this.inner = inner; + } + + } + + public static class HasAround { + AroundSomething c; + + public AroundSomething getC() { + return c; + } + + public void setC(AroundSomething c) { + this.c = c; + } + } + + @Test + public void aroundString() { + AroundString matcher = new AroundString("foo"); + + HasAround wrapper = new HasAround(); + wrapper.setC(matcher); + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(wrapper); + Assertions.assertThat(asString).isEqualTo("{\"c\":\"foo\"}"); + + HasAround fromString = objectMapper.readValue(asString, HasAround.class); + Assertions.assertThat(fromString.getC().getInner()).isEqualTo("foo"); + } + + @Test + public void aroundObject() { + AroundObject matcher = new AroundObject("foo"); + + HasAround wrapper = new HasAround(); + wrapper.setC(matcher); + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(wrapper); + Assertions.assertThat(asString).isEqualTo("{\"c\":\"foo\"}"); + + HasAround fromString = objectMapper.readValue(asString, HasAround.class); + Assertions.assertThat(fromString.getC().getInner()).isEqualTo("foo"); + } + + @Test + public void aroundObjectNotJsonValue() { + AroundObject_NotJsonValue matcher = new AroundObject_NotJsonValue(); + matcher.setInner("foo"); + + HasAround wrapper = new HasAround(); + wrapper.setC(matcher); + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(wrapper); + Assertions.assertThat(asString) + .isEqualTo( + "{\"c\":{\"type\":\"TestJsonTypeInfoJsonValue$AroundObject_NotJsonValue\",\"inner\":\"foo\"}}"); + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type", + defaultImpl = NativeOption.class) + public interface SomeOption { + } + + public static enum NativeOption implements SomeOption { + A, B; + + @JsonValue + public String toString() { + return this.name(); + } + + @JsonCreator + public static NativeOption forValue(String value) { + return NativeOption.valueOf(value.toUpperCase(Locale.US)); + } + } + + public static enum CustomOption implements SomeOption { + C, D; + + @JsonCreator + public static CustomOption forValue(String value) { + return CustomOption.valueOf(value.toUpperCase(Locale.US)); + } + } + + public static enum OptionWithoutJsonTypeInfo { + E, F; + + @JsonCreator + public static OptionWithoutJsonTypeInfo forValue(String value) { + return OptionWithoutJsonTypeInfo.valueOf(value.toUpperCase(Locale.US)); + } + + } + + @Test + public void testEnum_Native_string() { + NativeOption matcher = NativeOption.A; + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(matcher); + Assertions.assertThat(asString).isEqualTo("\"A\""); + + SomeOption fromString = objectMapper.readValue(asString, SomeOption.class); + Assertions.assertThat(fromString).isSameAs(matcher); + } + + @Test + public void testEnum_Custom_string() { + CustomOption matcher = CustomOption.C; + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(matcher); + Assertions.assertThat(asString).isEqualTo("[\"TestJsonTypeInfoJsonValue$CustomOption\",\"C\"]"); + + SomeOption fromString = objectMapper.readValue(asString, SomeOption.class); + Assertions.assertThat(fromString).isSameAs(matcher); + } + + // To be removed, just to help debugging a standard scenario + @Test + public void testEnum_noJsonTypeInfo() { + OptionWithoutJsonTypeInfo matcher = OptionWithoutJsonTypeInfo.E; + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(matcher); + Assertions.assertThat(asString).isEqualTo("\"E\""); + + OptionWithoutJsonTypeInfo fromString = objectMapper.readValue(asString, OptionWithoutJsonTypeInfo.class); + Assertions.assertThat(fromString).isSameAs(matcher); + } +} \ No newline at end of file From a5f5e5aa06dec5d54e916cb6c6a51daa0f5677cd Mon Sep 17 00:00:00 2001 From: Benoit Lacelle Date: Sat, 28 Mar 2026 17:58:20 +0400 Subject: [PATCH 2/3] Add complexType case --- .../ser/jackson/JsonValueSerializer.java | 1 + .../jsontype/TestJsonTypeInfoJsonValue.java | 41 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java b/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java index 4b22adfc59..2868d670a0 100644 --- a/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java @@ -182,6 +182,7 @@ public ValueSerializer createContextual(SerializationContext ctxt, */ boolean forceTypeInformation; if (_accessor == null) { + // Why do we forceTypeInformation if the type is natural? Shouldn't it be the contrary? forceTypeInformation = isNaturalTypeWithStdHandling(_valueType.getRawClass(), ser); } else { // We came here due to a `@JsonValue`: the type of the accessed value is irrelevant as it is not the type of the original value diff --git a/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java b/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java index 42425a7445..d64489aff7 100644 --- a/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java +++ b/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java @@ -17,7 +17,7 @@ // tools.jackson.databind.ser.BasicSerializerFactory.findSerializerByAnnotations(SerializationContext, JavaType, Supplier) public class TestJsonTypeInfoJsonValue { - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, + @JsonTypeInfo(use = JsonTypeInfo.Id.MINIMAL_CLASS, include = JsonTypeInfo.As.PROPERTY, property = "type", defaultImpl = AroundString.class) @@ -80,14 +80,14 @@ public void setInner(String inner) { } public static class HasAround { - AroundSomething c; + AroundSomething wrapped; - public AroundSomething getC() { - return c; + public AroundSomething getWrapped() { + return wrapped; } - public void setC(AroundSomething c) { - this.c = c; + public void setC(AroundSomething wrapped) { + this.wrapped = wrapped; } } @@ -101,14 +101,14 @@ public void aroundString() { ObjectMapper objectMapper = new ObjectMapper(); String asString = objectMapper.writeValueAsString(wrapper); - Assertions.assertThat(asString).isEqualTo("{\"c\":\"foo\"}"); + Assertions.assertThat(asString).isEqualTo("{\"wrapped\":\"foo\"}"); HasAround fromString = objectMapper.readValue(asString, HasAround.class); - Assertions.assertThat(fromString.getC().getInner()).isEqualTo("foo"); + Assertions.assertThat(fromString.getWrapped().getInner()).isEqualTo("foo"); } @Test - public void aroundObject() { + public void aroundObject_simpleType() { AroundObject matcher = new AroundObject("foo"); HasAround wrapper = new HasAround(); @@ -117,10 +117,27 @@ public void aroundObject() { ObjectMapper objectMapper = new ObjectMapper(); String asString = objectMapper.writeValueAsString(wrapper); - Assertions.assertThat(asString).isEqualTo("{\"c\":\"foo\"}"); + Assertions.assertThat(asString).isEqualTo("{\"wrapped\":\"foo\"}"); + + HasAround fromString = objectMapper.readValue(asString, HasAround.class); + Assertions.assertThat(fromString.getWrapped().getInner()).isEqualTo("foo"); + } + + @Test + public void aroundObject_complexType() { + AroundObject matcher = new AroundObject(Map.of("foo", "bar")); + + HasAround wrapper = new HasAround(); + wrapper.setC(matcher); + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(wrapper); + Assertions.assertThat(asString) + .isEqualTo("{\"wrapped\":{\"type\":\".TestJsonTypeInfoJsonValue$AroundObject\",\"foo\":\"bar\"}}"); HasAround fromString = objectMapper.readValue(asString, HasAround.class); - Assertions.assertThat(fromString.getC().getInner()).isEqualTo("foo"); + Assertions.assertThat(fromString.getWrapped().getInner()).isEqualTo(Map.of("foo", "bar")); } @Test @@ -136,7 +153,7 @@ public void aroundObjectNotJsonValue() { String asString = objectMapper.writeValueAsString(wrapper); Assertions.assertThat(asString) .isEqualTo( - "{\"c\":{\"type\":\"TestJsonTypeInfoJsonValue$AroundObject_NotJsonValue\",\"inner\":\"foo\"}}"); + "{\"wrapped\":{\"type\":\".TestJsonTypeInfoJsonValue$AroundObject_NotJsonValue\",\"inner\":\"foo\"}}"); } @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, From 2e9ed284e9a152cd59c6062d1108fa00db80d0c6 Mon Sep 17 00:00:00 2001 From: Benoit Lacelle Date: Sat, 28 Mar 2026 23:16:10 +0400 Subject: [PATCH 3/3] Add enum with @JsonValue case --- .../jsontype/TestJsonTypeInfoJsonValue.java | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java b/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java index d64489aff7..2667010f74 100644 --- a/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java +++ b/src/test/java/tools/jackson/databind/jsontype/TestJsonTypeInfoJsonValue.java @@ -186,6 +186,21 @@ public static CustomOption forValue(String value) { } } + + public static enum CustomOption_WithJsonValue implements SomeOption { + C, D; + + @JsonValue + public String asString() { + return this.name(); + } + + @JsonCreator + public static CustomOption forValue(String value) { + return CustomOption.valueOf(value.toUpperCase(Locale.US)); + } + } + public static enum OptionWithoutJsonTypeInfo { E, F; @@ -197,7 +212,7 @@ public static OptionWithoutJsonTypeInfo forValue(String value) { } @Test - public void testEnum_Native_string() { + public void testEnum_Native() { NativeOption matcher = NativeOption.A; ObjectMapper objectMapper = new ObjectMapper(); @@ -210,7 +225,7 @@ public void testEnum_Native_string() { } @Test - public void testEnum_Custom_string() { + public void testEnum_Custom() { CustomOption matcher = CustomOption.C; ObjectMapper objectMapper = new ObjectMapper(); @@ -222,6 +237,19 @@ public void testEnum_Custom_string() { Assertions.assertThat(fromString).isSameAs(matcher); } + @Test + public void testEnum_Custom_jsonValue() { + CustomOption_WithJsonValue matcher = CustomOption_WithJsonValue.C; + + ObjectMapper objectMapper = new ObjectMapper(); + + String asString = objectMapper.writeValueAsString(matcher); + Assertions.assertThat(asString).isEqualTo("[\"TestJsonTypeInfoJsonValue$CustomOption\",\"C\"]"); + + SomeOption fromString = objectMapper.readValue(asString, SomeOption.class); + Assertions.assertThat(fromString).isSameAs(matcher); + } + // To be removed, just to help debugging a standard scenario @Test public void testEnum_noJsonTypeInfo() {