diff --git a/src/main/java/tools/jackson/databind/deser/jdk/CollectionDeserializer.java b/src/main/java/tools/jackson/databind/deser/jdk/CollectionDeserializer.java index 5db454e973..230adddf78 100644 --- a/src/main/java/tools/jackson/databind/deser/jdk/CollectionDeserializer.java +++ b/src/main/java/tools/jackson/databind/deser/jdk/CollectionDeserializer.java @@ -219,10 +219,13 @@ public CollectionDeserializer createContextual(DeserializationContext ctxt, // May have a content converter valueDeser = findConvertingContentDeserializer(ctxt, property, valueDeser); final JavaType vt = _containerType.getContentType(); + // [databind#5742]: strip annotation-level contentNulls to prevent propagation + // into nested container deserializers + BeanProperty contentProp = _contentProperty(ctxt, property, vt); if (valueDeser == null) { - valueDeser = ctxt.findContextualValueDeserializer(vt, property); + valueDeser = ctxt.findContextualValueDeserializer(vt, contentProp); } else { // if directly assigned, probably not yet contextual, so: - valueDeser = ctxt.handleSecondaryContextualization(valueDeser, property, vt); + valueDeser = ctxt.handleSecondaryContextualization(valueDeser, contentProp, vt); } // and finally, type deserializer needs context as well TypeDeserializer valueTypeDeser = _valueTypeDeserializer; diff --git a/src/main/java/tools/jackson/databind/deser/jdk/EnumMapDeserializer.java b/src/main/java/tools/jackson/databind/deser/jdk/EnumMapDeserializer.java index 69a7c69da6..05832993b7 100644 --- a/src/main/java/tools/jackson/databind/deser/jdk/EnumMapDeserializer.java +++ b/src/main/java/tools/jackson/databind/deser/jdk/EnumMapDeserializer.java @@ -158,10 +158,13 @@ public ValueDeserializer createContextual(DeserializationContext ctxt, BeanPr // [databind#5870]: May have a content converter valueDeser = findConvertingContentDeserializer(ctxt, property, valueDeser); final JavaType vt = _containerType.getContentType(); + // [databind#5742]: strip annotation-level contentNulls to prevent propagation + // into nested container deserializers + BeanProperty contentProp = _contentProperty(ctxt, property, vt); if (valueDeser == null) { - valueDeser = ctxt.findContextualValueDeserializer(vt, property); + valueDeser = ctxt.findContextualValueDeserializer(vt, contentProp); } else { // if directly assigned, probably not yet contextual, so: - valueDeser = ctxt.handleSecondaryContextualization(valueDeser, property, vt); + valueDeser = ctxt.handleSecondaryContextualization(valueDeser, contentProp, vt); } TypeDeserializer vtd = _valueTypeDeserializer; if (vtd != null) { diff --git a/src/main/java/tools/jackson/databind/deser/jdk/MapDeserializer.java b/src/main/java/tools/jackson/databind/deser/jdk/MapDeserializer.java index ced9b092e9..8471f6a334 100644 --- a/src/main/java/tools/jackson/databind/deser/jdk/MapDeserializer.java +++ b/src/main/java/tools/jackson/databind/deser/jdk/MapDeserializer.java @@ -347,10 +347,13 @@ public ValueDeserializer createContextual(DeserializationContext ctxt, valueDeser = findConvertingContentDeserializer(ctxt, property, valueDeser); } final JavaType vt = _containerType.getContentType(); + // [databind#5742]: strip annotation-level contentNulls to prevent propagation + // into nested container deserializers + BeanProperty contentProp = _contentProperty(ctxt, property, vt); if (valueDeser == null) { - valueDeser = ctxt.findContextualValueDeserializer(vt, property); + valueDeser = ctxt.findContextualValueDeserializer(vt, contentProp); } else { // if directly assigned, probably not yet contextual, so: - valueDeser = ctxt.handleSecondaryContextualization(valueDeser, property, vt); + valueDeser = ctxt.handleSecondaryContextualization(valueDeser, contentProp, vt); } TypeDeserializer vtd = _valueTypeDeserializer; if (vtd != null) { diff --git a/src/main/java/tools/jackson/databind/deser/jdk/ObjectArrayDeserializer.java b/src/main/java/tools/jackson/databind/deser/jdk/ObjectArrayDeserializer.java index e11d1d3671..dedaa479ab 100644 --- a/src/main/java/tools/jackson/databind/deser/jdk/ObjectArrayDeserializer.java +++ b/src/main/java/tools/jackson/databind/deser/jdk/ObjectArrayDeserializer.java @@ -147,10 +147,13 @@ public ValueDeserializer createContextual(DeserializationContext ctxt, // May have a content converter valueDeser = findConvertingContentDeserializer(ctxt, property, valueDeser); final JavaType vt = _containerType.getContentType(); + // [databind#5742]: strip annotation-level contentNulls to prevent propagation + // into nested container deserializers + BeanProperty contentProp = _contentProperty(ctxt, property, vt); if (valueDeser == null) { - valueDeser = ctxt.findContextualValueDeserializer(vt, property); + valueDeser = ctxt.findContextualValueDeserializer(vt, contentProp); } else { // if directly assigned, probably not yet contextual, so: - valueDeser = ctxt.handleSecondaryContextualization(valueDeser, property, vt); + valueDeser = ctxt.handleSecondaryContextualization(valueDeser, contentProp, vt); } TypeDeserializer elemTypeDeser = _elementTypeDeserializer; if (elemTypeDeser != null) { diff --git a/src/main/java/tools/jackson/databind/deser/std/StdDeserializer.java b/src/main/java/tools/jackson/databind/deser/std/StdDeserializer.java index 09cb738d6e..7001eca634 100644 --- a/src/main/java/tools/jackson/databind/deser/std/StdDeserializer.java +++ b/src/main/java/tools/jackson/databind/deser/std/StdDeserializer.java @@ -1,9 +1,12 @@ package tools.jackson.databind.deser.std; import java.io.IOException; +import java.lang.annotation.Annotation; import java.util.*; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.Nulls; import tools.jackson.core.*; @@ -15,6 +18,8 @@ import tools.jackson.databind.annotation.JacksonStdImpl; import tools.jackson.databind.cfg.CoercionAction; import tools.jackson.databind.cfg.CoercionInputShape; +import tools.jackson.databind.cfg.ConfigOverride; +import tools.jackson.databind.cfg.MapperConfig; import tools.jackson.databind.deser.*; import tools.jackson.databind.deser.bean.BeanDeserializerBase; import tools.jackson.databind.deser.impl.NullsAsEmptyProvider; @@ -22,6 +27,7 @@ import tools.jackson.databind.deser.impl.NullsFailProvider; import tools.jackson.databind.introspect.AnnotatedClass; import tools.jackson.databind.introspect.AnnotatedMember; +import tools.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor; import tools.jackson.databind.jsontype.TypeDeserializer; import tools.jackson.databind.type.LogicalType; import tools.jackson.databind.util.AccessPattern; @@ -2000,6 +2006,113 @@ protected Nulls findContentNullStyle(DeserializationContext ctxt, BeanProperty p return ctxt.getConfig().getDefaultNullHandling().getContentNulls(); } + /** + * For container deserializers: creates a property view for content deserialization + * that strips the outer property's annotation-level contentNulls, preventing it + * from propagating into nested container deserializers. + * Annotation-free paths are unchanged; annotation-present paths are re-evaluated + * for the next nested content type using ConfigOverride and global defaults. + * + * @since 3.2 + */ + protected BeanProperty _contentProperty(DeserializationContext ctxt, + BeanProperty prop, JavaType contentType) + { + if (prop == null) { + return null; + } + // Gate: only strip if the property has an EXPLICIT @JsonSetter(contentNulls=...) + AnnotatedMember member = prop.getMember(); + if (member == null) { + return prop; + } + AnnotationIntrospector intr = ctxt.getAnnotationIntrospector(); + if (intr == null) { + return prop; + } + JsonSetter.Value setterInfo = intr.findSetterInfo(ctxt.getConfig(), member); + if (setterInfo == null || setterInfo.nonDefaultContentNulls() == null) { + return prop; // no explicit annotation -> reuse original property + } + // Recalculate contentNulls from inner content type's ConfigOverride + global default + // (excludes the outer property's annotation) + Nulls innerContentNulls = null; + DeserializationConfig config = ctxt.getConfig(); + ConfigOverride co = config.getConfigOverride(contentType.getRawClass()); + JsonSetter.Value coInfo = co.getNullHandling(); + if (coInfo != null) { + innerContentNulls = coInfo.nonDefaultContentNulls(); + } + if (innerContentNulls == null) { + innerContentNulls = config.getDefaultNullHandling().nonDefaultContentNulls(); + } + PropertyMetadata md = prop.getMetadata(); + if (innerContentNulls == md.getContentNulls()) { + return prop; // same value after recalculation -> no wrapper needed + } + return new _ContentBeanProperty(prop, md.withNulls(md.getValueNulls(), innerContentNulls)); + } + + /** + * Delegating {@link BeanProperty} wrapper that only overrides {@link #getMetadata()}. + * Used to prevent contentNulls propagation into nested container deserializers. + * + * @since 3.2 + */ + protected static class _ContentBeanProperty implements BeanProperty { + private final BeanProperty _delegate; + private final PropertyMetadata _metadata; + + _ContentBeanProperty(BeanProperty delegate, PropertyMetadata metadata) { + _delegate = delegate; + _metadata = metadata; + } + + @Override public PropertyMetadata getMetadata() { return _metadata; } + @Override public boolean isRequired() { return _metadata.isRequired(); } + + // All other methods delegate to _delegate + @Override public String getName() { return _delegate.getName(); } + @Override public PropertyName getFullName() { return _delegate.getFullName(); } + @Override public JavaType getType() { return _delegate.getType(); } + @Override public PropertyName getWrapperName() { return _delegate.getWrapperName(); } + @Override public boolean isVirtual() { return _delegate.isVirtual(); } + @Override + public A getAnnotation(Class acls) { + return _delegate.getAnnotation(acls); + } + @Override + public A getContextAnnotation(Class acls) { + return _delegate.getContextAnnotation(acls); + } + @Override public AnnotatedMember getMember() { + return _delegate.getMember(); + } + @Override + public JsonFormat.Value findPropertyFormat(MapperConfig config, + Class baseType) { + return _delegate.findPropertyFormat(config, baseType); + } + @Override + public JsonFormat.Value findFormatOverrides(MapperConfig config) { + return _delegate.findFormatOverrides(config); + } + @Override + public JsonInclude.Value findPropertyInclusion(MapperConfig config, + Class baseType) { + return _delegate.findPropertyInclusion(config, baseType); + } + @Override + public List findAliases(MapperConfig config) { + return _delegate.findAliases(config); + } + @Override + public void depositSchemaProperty(JsonObjectFormatVisitor v, + SerializationContext c) { + _delegate.depositSchemaProperty(v, c); + } + } + protected final NullValueProvider _findNullProvider(DeserializationContext ctxt, BeanProperty prop, Nulls nulls, ValueDeserializer valueDeser) { diff --git a/src/test/java/tools/jackson/databind/deser/filter/NullConversionsForContentTest.java b/src/test/java/tools/jackson/databind/deser/filter/NullConversionsForContentTest.java index f8e1299745..e3fc42af6d 100644 --- a/src/test/java/tools/jackson/databind/deser/filter/NullConversionsForContentTest.java +++ b/src/test/java/tools/jackson/databind/deser/filter/NullConversionsForContentTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.Nulls; @@ -42,6 +43,44 @@ static class NullContentUndefined { public T values; } + // [databind#5742]: nested List + static class NestedListHolder { + @JsonSetter(contentNulls = Nulls.FAIL) + public List> nested; + } + + // [databind#5742]: record-like creator form + static class NestedListRecord5742 { + public final List> foo; + + @JsonCreator + NestedListRecord5742(@JsonSetter(contentNulls = Nulls.FAIL) + @JsonProperty("foo") List> foo) { + this.foo = foo; + } + } + + // [databind#5742]: nested Map + static class NestedMapHolder { + @JsonSetter(contentNulls = Nulls.FAIL) + public Map> nested; + } + + // [databind#5742]: nested 2D array + static class Nested2DArrayHolder { + @JsonSetter(contentNulls = Nulls.FAIL) + public String[][] nested; + } + + // [databind#5742]: actual Java record (original issue reproduction) + record NestedListRecord(@JsonSetter(contentNulls = Nulls.FAIL) List> foo) { } + + // [databind#5742]: nested EnumMap + static class NestedEnumMapHolder { + @JsonSetter(contentNulls = Nulls.FAIL) + public EnumMap> nested; + } + // [databind#4200] static class DelegatingWrapper4200 { private final Map value; @@ -532,4 +571,209 @@ public void testNullsSkipWithMaps() throws Exception assertEquals("bar", result.values.get(ABC.C)); } } + + /* + /********************************************************** + /* Test methods, [databind#5742] nested contentNulls propagation + /********************************************************** + */ + + // [databind#5742]: record/creator form (original issue reproduction) + @Test + public void testNestedListNullsDoNotPropagateCreator5742() throws Exception + { + // [null] -> outer content is null -> should FAIL + try { + MAPPER.readValue(a2q("{'foo':[null]}"), NestedListRecord5742.class); + fail("Should fail for null content in outer list"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // [[null]] -> outer content is [null] (non-null List), inner content has null -> should PASS + NestedListRecord5742 result = MAPPER.readValue(a2q("{'foo':[[null]]}"), NestedListRecord5742.class); + assertEquals(1, result.foo.size()); + assertEquals(1, result.foo.get(0).size()); + assertNull(result.foo.get(0).get(0)); + } + + // [databind#5742]: actual Java record (exact issue reproduction) + @Test + public void testNestedListNullsDoNotPropagateRecord5742() throws Exception + { + // [null] -> outer content is null -> should FAIL + try { + MAPPER.readValue(a2q("{'foo':[null]}"), NestedListRecord.class); + fail("Should fail for null content in outer list"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // [[null]] -> outer content is [null] (non-null List), inner content has null -> should PASS + NestedListRecord result = MAPPER.readValue(a2q("{'foo':[[null]]}"), NestedListRecord.class); + assertEquals(1, result.foo().size()); + assertEquals(1, result.foo().get(0).size()); + assertNull(result.foo().get(0).get(0)); + } + + // [databind#5742]: field-based nested List + @Test + public void testNestedListNullsDoNotPropagate5742() throws Exception + { + // [null] -> outer content is null -> FAIL + try { + MAPPER.readValue(a2q("{'nested':[null]}"), NestedListHolder.class); + fail("Should fail for null content in outer list"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // [[null]] -> inner list has null -> should PASS + NestedListHolder result = MAPPER.readValue(a2q("{'nested':[[null]]}"), NestedListHolder.class); + assertEquals(1, result.nested.size()); + assertEquals(1, result.nested.get(0).size()); + assertNull(result.nested.get(0).get(0)); + } + + // [databind#5742]: nested Map> + @Test + public void testNestedMapNullsDoNotPropagate5742() throws Exception + { + // {"nested":{"a":null}} -> outer map value is null -> FAIL + try { + MAPPER.readValue(a2q("{'nested':{'a':null}}"), NestedMapHolder.class); + fail("Should fail for null value in outer map"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // {"nested":{"a":[null]}} -> inner list has null -> should PASS + NestedMapHolder result = MAPPER.readValue(a2q("{'nested':{'a':[null]}}"), NestedMapHolder.class); + assertEquals(1, result.nested.size()); + assertEquals(1, result.nested.get("a").size()); + assertNull(result.nested.get("a").get(0)); + } + + // [databind#5742]: nested 2D array String[][] + @Test + public void testNested2DArrayNullsDoNotPropagate5742() throws Exception + { + // [null] -> outer content is null -> FAIL + try { + MAPPER.readValue(a2q("{'nested':[null]}"), Nested2DArrayHolder.class); + fail("Should fail for null content in outer array"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // [[null]] -> inner array has null -> should PASS + Nested2DArrayHolder result = MAPPER.readValue(a2q("{'nested':[[null]]}"), Nested2DArrayHolder.class); + assertEquals(1, result.nested.length); + assertEquals(1, result.nested[0].length); + assertNull(result.nested[0][0]); + } + + // [databind#5742]: nested EnumMap> + @Test + public void testNestedEnumMapNullsDoNotPropagate5742() throws Exception + { + // {"nested":{"A":null}} -> outer map value is null -> FAIL + try { + MAPPER.readValue(a2q("{'nested':{'A':null}}"), NestedEnumMapHolder.class); + fail("Should fail for null value in outer EnumMap"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // {"nested":{"A":[null]}} -> inner list has null -> should PASS + NestedEnumMapHolder result = MAPPER.readValue(a2q("{'nested':{'A':[null]}}"), NestedEnumMapHolder.class); + assertEquals(1, result.nested.size()); + assertEquals(1, result.nested.get(ABC.A).size()); + assertNull(result.nested.get(ABC.A).get(0)); + } + + // [databind#5742]: regression check - global default contentNulls=FAIL should preserve existing behavior + @Test + public void testGlobalDefaultNullsNotAffected5742() throws Exception + { + ObjectMapper mapper = jsonMapperBuilder() + .changeDefaultNullHandling(n -> n.withContentNulls(Nulls.FAIL)) + .build(); + // No annotation -> annotation gate not triggered -> strip does not happen + // Both [null] and [[null]] should FAIL for List> with global default + try { + mapper.readValue(a2q("{'values':[null]}"), + new TypeReference>>>() { }); + fail("Should fail for [null] with global default"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + try { + mapper.readValue(a2q("{'values':[[null]]}"), + new TypeReference>>>() { }); + fail("Should fail for [[null]] with global default"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + } + + // [databind#5742]: regression check - ConfigOverride only should preserve existing behavior + @Test + public void testConfigOverrideOnlyNotAffected5742() throws Exception + { + ObjectMapper mapper = jsonMapperBuilder() + .withConfigOverride(List.class, + o -> o.setNullHandling(JsonSetter.Value.forContentNulls(Nulls.FAIL))) + .build(); + // No annotation -> annotation gate not triggered -> strip does not happen + try { + mapper.readValue(a2q("{'values':[null]}"), + new TypeReference>>>() { }); + fail("Should fail for [null] with ConfigOverride"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + try { + mapper.readValue(a2q("{'values':[[null]]}"), + new TypeReference>>>() { }); + fail("Should fail for [[null]] with ConfigOverride"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + } + + // [databind#5742]: annotation FAIL + global SKIP -> inner should use global SKIP + @Test + public void testAnnotationPlusGlobalDefault5742() throws Exception + { + ObjectMapper mapper = jsonMapperBuilder() + .changeDefaultNullHandling(n -> n.withContentNulls(Nulls.SKIP)) + .build(); + // outer [null] -> FAIL (annotation) + try { + mapper.readValue(a2q("{'nested':[null]}"), NestedListHolder.class); + fail("Should fail for null content in outer list"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // outer [[null]] -> inner SKIP applied: null is skipped + NestedListHolder result = mapper.readValue(a2q("{'nested':[[null]]}"), NestedListHolder.class); + assertEquals(1, result.nested.size()); + assertEquals(0, result.nested.get(0).size()); // null was skipped + } + + // [databind#5742]: annotation FAIL on Map + ConfigOverride(List.class) SKIP -> inner list uses SKIP + @Test + public void testAnnotationPlusConfigOverride5742() throws Exception + { + ObjectMapper mapper = jsonMapperBuilder() + .withConfigOverride(List.class, + o -> o.setNullHandling(JsonSetter.Value.forContentNulls(Nulls.SKIP))) + .build(); + // {"nested":{"a":null}} -> outer map value null -> FAIL + try { + mapper.readValue(a2q("{'nested':{'a':null}}"), NestedMapHolder.class); + fail("Should fail for null value in outer map"); + } catch (InvalidNullException e) { + verifyException(e, "Invalid `null` value"); + } + // {"nested":{"a":[null]}} -> inner list uses ConfigOverride(List.class) SKIP + NestedMapHolder result = mapper.readValue(a2q("{'nested':{'a':[null]}}"), NestedMapHolder.class); + assertEquals(1, result.nested.size()); + assertEquals(0, result.nested.get("a").size()); // null was skipped + } }