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
+ }
}