From 590dbff5e6cbcee52aff45ca9604ba2bf44dcc11 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 10 Jul 2026 08:45:06 -0700 Subject: [PATCH 1/2] Fix #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully remove empty collection during serialization --- release-notes/CREDITS | 8 + release-notes/VERSION | 4 + .../ser/jdk/CollectionSerializer.java | 10 +- .../databind/ser/jdk/EnumSetSerializer.java | 10 +- .../ser/jdk/IndexedListSerializer.java | 11 +- .../databind/ser/jdk/IterableSerializer.java | 16 +- .../databind/ser/jdk/IteratorSerializer.java | 4 + .../databind/ser/jdk/JDKArraySerializers.java | 90 ++++- .../ser/jdk/ObjectArraySerializer.java | 53 ++- .../ser/jdk/StaticListSerializerBase.java | 21 +- .../ser/jdk/StringArraySerializer.java | 21 +- .../ser/std/AsArraySerializerBase.java | 45 +++ .../JsonIncludeContainerEmpty6065Test.java | 335 ++++++++++++++++++ 13 files changed, 614 insertions(+), 14 deletions(-) create mode 100644 src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java diff --git a/release-notes/CREDITS b/release-notes/CREDITS index e4f980bdd7..d82e788ff3 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -593,3 +593,11 @@ Théo Szanto (@indyteo) * Fixed #6043: `FAIL_ON_UNKNOWN_PROPERTIES` has no effect with `JsonFormat.Shape.ARRAY` when using creator-based instantiation (such as `record`) [3.2.1] + * Fixed #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully + remove empty collection during serialization + [3.2.1] + +@doeiqts + * Reported #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully + remove empty collection during serialization + [3.2.1] diff --git a/release-notes/VERSION b/release-notes/VERSION index e1358e57bf..300c16eada 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -35,6 +35,10 @@ No changes since 3.2 (reported by @sebastien-leveque) (fix by @cowtowncoder, w/ Claude code) #6060: `@JsonView` by-passed for `@JsonUnwrapped` Field/Setter properties [CVE-2026-59889] +#6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully + remove empty collection during serialization + (reported by @doeiqts) + (fix by @seonwooj0810) #6077: `FAIL_ON_UNEXPECTED_VIEW_PROPERTIES` not being respected with constructor-backed properties (nor with Builders, `@JsonUnwrapped`, external type ids or `Shape.ARRAY`) (reported by João G) diff --git a/src/main/java/tools/jackson/databind/ser/jdk/CollectionSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/CollectionSerializer.java index 3dd6566351..3d5628bb3b 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/CollectionSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/CollectionSerializer.java @@ -86,7 +86,15 @@ protected CollectionSerializer withResolved(BeanProperty property, @Override public boolean isEmpty(SerializationContext prov, Collection value) { - return value.isEmpty(); + if (value.isEmpty()) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // a collection whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + return _allElementsSuppressed(prov, value.iterator()); + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/EnumSetSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/EnumSetSerializer.java index 6a1463843d..ac7e3bd334 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/EnumSetSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/EnumSetSerializer.java @@ -46,7 +46,15 @@ public EnumSetSerializer withResolved(BeanProperty property, @Override public boolean isEmpty(SerializationContext prov, EnumSet> value) { - return value.isEmpty(); + if (value.isEmpty()) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an EnumSet whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + return _allElementsSuppressed(prov, value.iterator()); + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/IndexedListSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/IndexedListSerializer.java index d3a7a94835..6639f70859 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/IndexedListSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/IndexedListSerializer.java @@ -66,7 +66,16 @@ public IndexedListSerializer withResolved(BeanProperty property, @Override public boolean isEmpty(SerializationContext prov, Object value) { - return ((List)value).isEmpty(); + List list = (List) value; + if (list.isEmpty()) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // a list whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + return _allElementsSuppressed(prov, list.iterator()); + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java index d7914b20db..e427272a50 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java @@ -55,8 +55,20 @@ public IterableSerializer withResolved(BeanProperty property, @Override public boolean isEmpty(SerializationContext ctxt, Iterable value) { - // Not really good way to implement this, but has to do for now: - return !value.iterator().hasNext(); + Iterator it = value.iterator(); + if (!it.hasNext()) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an Iterable whose every element is suppressed is considered empty. + // NOTE: this walks elements until one survives filtering, so `serialize()` + // (which calls `value.iterator()` again) must re-traverse from the start -- + // fine for re-iterable sources, but doubles traversal cost and cannot be + // supported for single-use Iterables (those were already unsupported here). + if (_needToCheckFiltering(ctxt)) { + return _allElementsSuppressed(ctxt, it); + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/IteratorSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/IteratorSerializer.java index 7136e29dcd..ce85c39f37 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/IteratorSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/IteratorSerializer.java @@ -57,6 +57,10 @@ public IteratorSerializer withResolved(BeanProperty property, @Override public boolean isEmpty(SerializationContext ctxt, Iterator value) { + // [databind#6065]: unlike other containers, cannot apply content @JsonInclude + // filtering here: inspecting elements would consume the (single-use) Iterator, + // leaving nothing for `serialize()`. So an Iterator that becomes empty after + // content filtering is still emitted as `[]` rather than dropped. return !value.hasNext(); } diff --git a/src/main/java/tools/jackson/databind/ser/jdk/JDKArraySerializers.java b/src/main/java/tools/jackson/databind/ser/jdk/JDKArraySerializers.java index 395574d2a8..17ec9dac05 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/JDKArraySerializers.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/JDKArraySerializers.java @@ -150,7 +150,20 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, boolean[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (boolean v : value) { + if (_shouldSerializeElement(prov, Boolean.valueOf(v))) { + return false; + } + } + return true; + } + return false; } @Override @@ -226,7 +239,20 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, short[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (short v : value) { + if (_shouldSerializeElement(prov, Short.valueOf(v))) { + return false; + } + } + return true; + } + return false; } @Override @@ -378,7 +404,20 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, int[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (int v : value) { + if (_shouldSerializeElement(prov, Integer.valueOf(v))) { + return false; + } + } + return true; + } + return false; } @Override @@ -460,7 +499,20 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, long[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (long v : value) { + if (_shouldSerializeElement(prov, Long.valueOf(v))) { + return false; + } + } + return true; + } + return false; } @Override @@ -547,7 +599,20 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, float[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (float v : value) { + if (_shouldSerializeElement(prov, Float.valueOf(v))) { + return false; + } + } + return true; + } + return false; } @Override @@ -649,7 +714,20 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, double[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (double v : value) { + if (_shouldSerializeElement(prov, Double.valueOf(v))) { + return false; + } + } + return true; + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/ObjectArraySerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/ObjectArraySerializer.java index f0c9b7a756..5be4e8eea3 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/ObjectArraySerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/ObjectArraySerializer.java @@ -241,7 +241,15 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, Object[] value) { - return value.length == 0; + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + return _allElementsSuppressed(prov, value); + } + return false; } @Override @@ -480,6 +488,49 @@ protected boolean _shouldSerializeElement(SerializationContext ctxt, return !_suppressableValue.equals(elem); } + /** + * Helper method for content-aware emptiness checks (used by {@link #isEmpty}): + * when content {@code @JsonInclude} is applied to containers, an array is + * considered empty if every one of its elements would be suppressed during + * serialization (so the array would render as an empty array). Callers should + * only invoke this when {@link #_needToCheckFiltering} returns true. + * + * @since 3.2.1 + */ + protected boolean _allElementsSuppressed(SerializationContext ctxt, Object[] value) + { + for (Object elem : value) { + if (elem == null) { + if (_suppressNulls) { + continue; + } + return false; + } + // Only the "empty" check needs an element serializer; other suppression + // mechanisms compare the element directly, so avoid resolving one + ValueSerializer serializer = null; + if (_suppressableValue == MARKER_FOR_EMPTY) { + serializer = _elementSerializer; + if (serializer == null) { + Class cc = elem.getClass(); + serializer = _dynamicValueSerializers.serializerFor(cc); + if (serializer == null) { + if (_elementType.hasGenericTypes()) { + serializer = _findAndAddDynamic(ctxt, + ctxt.constructSpecializedType(_elementType, cc)); + } else { + serializer = _findAndAddDynamic(ctxt, cc); + } + } + } + } + if (_shouldSerializeElement(ctxt, elem, serializer)) { + return false; + } + } + return true; + } + @Override public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) { diff --git a/src/main/java/tools/jackson/databind/ser/jdk/StaticListSerializerBase.java b/src/main/java/tools/jackson/databind/ser/jdk/StaticListSerializerBase.java index 8648c06ec1..ecd74eded9 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/StaticListSerializerBase.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/StaticListSerializerBase.java @@ -199,7 +199,26 @@ public ValueSerializer createContextual(SerializationContext ctxt, @Override public boolean isEmpty(SerializationContext provider, T value) { - return (value == null) || (value.isEmpty()); + if ((value == null) || value.isEmpty()) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // a collection whose every element is suppressed is considered empty + if (_needToCheckFiltering(provider)) { + for (Object elem : value) { + if (elem == null) { + if (!_suppressNulls) { + return false; + } + // Elements are "natural" types (Strings), never have content serializer + // (see `createContextual()`), so pass `null`, same as `serializeContents()` + } else if (_shouldSerializeElement(elem, null, provider)) { + return false; + } + } + return true; + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/jdk/StringArraySerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/StringArraySerializer.java index fe7a83bac0..259e25fb5e 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/StringArraySerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/StringArraySerializer.java @@ -185,7 +185,26 @@ public ValueSerializer getContentSerializer() { @Override public boolean isEmpty(SerializationContext prov, String[] value) { - return (value.length == 0); + if (value.length == 0) { + return true; + } + // [databind#6065]: with content @JsonInclude applied to containers, + // an array whose every element is suppressed is considered empty + if (_needToCheckFiltering(prov)) { + for (String str : value) { + if (str == null) { + if (_suppressNulls) { + continue; + } + return false; + } + if (_shouldSerializeElement(prov, str, _elementSerializer)) { + return false; + } + } + return true; + } + return false; } @Override diff --git a/src/main/java/tools/jackson/databind/ser/std/AsArraySerializerBase.java b/src/main/java/tools/jackson/databind/ser/std/AsArraySerializerBase.java index 64d2ae8ab3..a59e31f49e 100644 --- a/src/main/java/tools/jackson/databind/ser/std/AsArraySerializerBase.java +++ b/src/main/java/tools/jackson/databind/ser/std/AsArraySerializerBase.java @@ -1,5 +1,6 @@ package tools.jackson.databind.ser.std; +import java.util.Iterator; import java.util.Objects; import com.fasterxml.jackson.annotation.JsonFormat; @@ -424,4 +425,48 @@ protected boolean _shouldSerializeElement(SerializationContext ctxt, } return !_suppressableValue.equals(elem); } + + /** + * Helper method for content-aware emptiness checks (used by {@code isEmpty}): + * when content {@code @JsonInclude} is applied to containers, a container is + * considered empty if every one of its elements would be suppressed during + * serialization (so the container would render as an empty array). Callers + * should only invoke this when {@link #_needToCheckFiltering} returns true. + * + * @since 3.2.1 + */ + protected boolean _allElementsSuppressed(SerializationContext ctxt, Iterator it) + { + while (it.hasNext()) { + Object elem = it.next(); + if (elem == null) { + if (_suppressNulls) { + continue; + } + return false; + } + // Only the "empty" check needs an element serializer; other suppression + // mechanisms compare the element directly, so avoid resolving one + ValueSerializer serializer = null; + if (_suppressableValue == MARKER_FOR_EMPTY) { + serializer = _elementSerializer; + if (serializer == null) { + Class cc = elem.getClass(); + serializer = _dynamicValueSerializers.serializerFor(cc); + if (serializer == null) { + if (_elementType.hasGenericTypes()) { + serializer = _findAndAddDynamic(ctxt, + ctxt.constructSpecializedType(_elementType, cc)); + } else { + serializer = _findAndAddDynamic(ctxt, cc); + } + } + } + } + if (_shouldSerializeElement(ctxt, elem, serializer)) { + return false; + } + } + return true; + } } diff --git a/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java b/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java new file mode 100644 index 0000000000..dae4a417ba --- /dev/null +++ b/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java @@ -0,0 +1,335 @@ +package tools.jackson.databind.ser.filter; + +import java.util.*; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +// [databind#6065]: SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS should +// drop containers that become empty once content @JsonInclude filtering is applied, +// matching the behavior already implemented for Maps (#1649). +public class JsonIncludeContainerEmpty6065Test extends DatabindTestUtil +{ + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class Bean { + public String myString; + public List myList; + public Map myMap; + } + + // List of non-String elements (routed through IndexedListSerializer) + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class IntListBean { + public List values; + } + + // Set (routed through CollectionSerializer) + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class StringSetBean { + public Set values; + } + + // String array (routed through StringArraySerializer) + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class StringArrayBean { + public String[] values; + } + + // Object array (routed through ObjectArraySerializer) + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class IntArrayBean { + public Integer[] values; + } + + // Iterable (routed through IterableSerializer): must be a genuine non-Collection + // Iterable, otherwise runtime-type resolution routes it through CollectionSerializer + static class StringIterable implements Iterable { + private final List _values; + StringIterable(String... values) { _values = Arrays.asList(values); } + @Override public Iterator iterator() { return _values.iterator(); } + } + + // Primitive arrays (routed through JDKArraySerializers.*ArraySerializer): content + // NON_DEFAULT suppresses default-valued elements (0 / false), which is the only + // content inclusion that can suppress primitives (they are never "empty"/null). + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_DEFAULT) + static class PrimArraysBean { + public int[] ints; + public long[] longs; + public short[] shorts; + public double[] doubles; + public float[] floats; + public boolean[] bools; + } + + // EnumSet (routed through EnumSetSerializer): enums are never null/empty, so only a + // CUSTOM content filter can suppress elements. + enum Size { SMALL, LARGE } + + // Custom @JsonInclude content filter: its equals() returns true for values to suppress + static class SuppressSmallFilter { + @Override public boolean equals(Object other) { return other == Size.SMALL; } + @Override public int hashCode() { return 0; } + } + + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, + content = JsonInclude.Include.CUSTOM, contentFilter = SuppressSmallFilter.class) + static class EnumSetBean { + public EnumSet values; + } + + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class IterableBean { + public Iterable values; + } + + // Nested containers: content filtering recurses, so an inner container that becomes + // empty is itself suppressed as an element of the outer container + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class NestedListBean { + public List> values; + } + + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_EMPTY) + static class MapOfListsBean { + public Map> values; + } + + // content = NON_NULL: only nulls are suppressed, empty Strings are retained + @JsonInclude(value = JsonInclude.Include.NON_EMPTY, content = JsonInclude.Include.NON_NULL) + static class NonNullContentBean { + public List strings; + public List numbers; + public String[] stringArray; + public Integer[] numberArray; + } + + private final ObjectMapper MAPPER = JsonMapper.builder() + .enable(SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS) + .build(); + + private final ObjectMapper NO_FEATURE = JsonMapper.builder() + .disable(SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS) + .build(); + + @Test + public void testAllNull() throws Exception { + assertEquals("{}", MAPPER.writeValueAsString(new Bean())); + } + + @Test + public void testEmptyContainers() throws Exception { + Bean bean = new Bean(); + bean.myList = new ArrayList<>(); + bean.myMap = new HashMap<>(); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testContainersEmptyAfterContentFilter() throws Exception { + Bean bean = new Bean(); + bean.myList = new ArrayList<>(Collections.singletonList(null)); + bean.myMap = new HashMap<>(); + bean.myMap.put("1", null); + // Both list and map become empty once null content is suppressed + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testStringListWithEmptyStringOnly() throws Exception { + Bean bean = new Bean(); + bean.myList = new ArrayList<>(Arrays.asList("", "")); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testStringListKeepsNonEmpty() throws Exception { + Bean bean = new Bean(); + bean.myList = new ArrayList<>(Arrays.asList(null, "keep", "")); + assertEquals("{\"myList\":[\"keep\"]}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testIntListWithNullsOnly() throws Exception { + IntListBean bean = new IntListBean(); + bean.values = new ArrayList<>(Arrays.asList(null, null)); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testIntListKeepsValues() throws Exception { + IntListBean bean = new IntListBean(); + bean.values = new ArrayList<>(Arrays.asList(null, 42)); + assertEquals("{\"values\":[42]}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testStringSetWithNullsOnly() throws Exception { + StringSetBean bean = new StringSetBean(); + bean.values = new LinkedHashSet<>(Arrays.asList((String) null)); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: same handling for String arrays + @Test + public void testStringArrayEmptyAfterContentFilter() throws Exception { + StringArrayBean bean = new StringArrayBean(); + bean.values = new String[] { null, "" }; + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testStringArrayKeepsNonEmpty() throws Exception { + StringArrayBean bean = new StringArrayBean(); + bean.values = new String[] { null, "keep", "" }; + assertEquals("{\"values\":[\"keep\"]}", MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: same handling for Object arrays + @Test + public void testObjectArrayNullsOnly() throws Exception { + IntArrayBean bean = new IntArrayBean(); + bean.values = new Integer[] { null, null }; + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testObjectArrayKeepsValues() throws Exception { + IntArrayBean bean = new IntArrayBean(); + bean.values = new Integer[] { null, 42 }; + assertEquals("{\"values\":[42]}", MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: same handling for Iterable + @Test + public void testIterableEmptyAfterContentFilter() throws Exception { + IterableBean bean = new IterableBean(); + bean.values = new StringIterable(null, ""); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testIterableKeepsNonEmpty() throws Exception { + IterableBean bean = new IterableBean(); + bean.values = new StringIterable(null, "keep", ""); + assertEquals("{\"values\":[\"keep\"]}", MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: primitive arrays, all-default content suppressed -> property dropped + @Test + public void testPrimitiveArraysAllDefault() throws Exception { + PrimArraysBean bean = new PrimArraysBean(); + bean.ints = new int[] { 0, 0 }; + bean.longs = new long[] { 0L }; + bean.shorts = new short[] { 0, 0 }; + bean.doubles = new double[] { 0.0, 0.0 }; + bean.floats = new float[] { 0.0f }; + bean.bools = new boolean[] { false, false }; + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testPrimitiveArraysKeepNonDefault() throws Exception { + PrimArraysBean bean = new PrimArraysBean(); + bean.ints = new int[] { 0, 42 }; + bean.longs = new long[] { 0L, 7L }; + bean.doubles = new double[] { 0.0, 1.5 }; + bean.bools = new boolean[] { false, true }; + assertEquals("{\"bools\":[true],\"doubles\":[1.5],\"ints\":[42],\"longs\":[7]}", + MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: EnumSet, all elements suppressed by CUSTOM filter -> property dropped + @Test + public void testEnumSetAllSuppressed() throws Exception { + EnumSetBean bean = new EnumSetBean(); + bean.values = EnumSet.of(Size.SMALL); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testEnumSetKeepsNonSuppressed() throws Exception { + EnumSetBean bean = new EnumSetBean(); + bean.values = EnumSet.of(Size.SMALL, Size.LARGE); + assertEquals("{\"values\":[\"LARGE\"]}", MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: nested containers -- inner List that is empty after content + // filtering is itself suppressed as an element of the outer List + @Test + public void testNestedListsAllEmptyAfterContentFilter() throws Exception { + NestedListBean bean = new NestedListBean(); + bean.values = new ArrayList<>(Arrays.asList( + new ArrayList<>(Arrays.asList("", "")), + new ArrayList<>(Collections.singletonList((String) null)), + new ArrayList<>())); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testNestedListsKeepNonEmpty() throws Exception { + NestedListBean bean = new NestedListBean(); + bean.values = new ArrayList<>(Arrays.asList( + new ArrayList<>(Arrays.asList("", "")), + new ArrayList<>(Arrays.asList(null, "keep")))); + assertEquals("{\"values\":[[\"keep\"]]}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testMapOfListsAllEmptyAfterContentFilter() throws Exception { + MapOfListsBean bean = new MapOfListsBean(); + bean.values = new LinkedHashMap<>(); + bean.values.put("a", new ArrayList<>(Arrays.asList("", ""))); + bean.values.put("b", new ArrayList<>()); + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testMapOfListsKeepsNonEmpty() throws Exception { + MapOfListsBean bean = new MapOfListsBean(); + bean.values = new LinkedHashMap<>(); + bean.values.put("a", new ArrayList<>(Collections.singletonList(""))); + bean.values.put("b", new ArrayList<>(Arrays.asList(null, "keep"))); + assertEquals("{\"values\":{\"b\":[\"keep\"]}}", MAPPER.writeValueAsString(bean)); + } + + // [databind#6065]: content = NON_NULL suppresses nulls only; container with + // nothing but nulls becomes empty, but empty Strings/zeroes are retained + @Test + public void testNonNullContentAllNulls() throws Exception { + NonNullContentBean bean = new NonNullContentBean(); + bean.strings = new ArrayList<>(Arrays.asList(null, null)); + bean.numbers = new ArrayList<>(Collections.singletonList((Integer) null)); + bean.stringArray = new String[] { null }; + bean.numberArray = new Integer[] { null, null }; + assertEquals("{}", MAPPER.writeValueAsString(bean)); + } + + @Test + public void testNonNullContentRetainsEmptyValues() throws Exception { + NonNullContentBean bean = new NonNullContentBean(); + bean.strings = new ArrayList<>(Arrays.asList(null, "")); + bean.numbers = new ArrayList<>(Arrays.asList(null, 0)); + bean.stringArray = new String[] { null, "" }; + bean.numberArray = new Integer[] { null, 0 }; + assertEquals("{\"numberArray\":[0],\"numbers\":[0]," + +"\"stringArray\":[\"\"],\"strings\":[\"\"]}", + MAPPER.writeValueAsString(bean)); + } + + // Without the feature, containers are left intact (only null containers dropped) + @Test + public void testFeatureDisabledLeavesContainers() throws Exception { + Bean bean = new Bean(); + bean.myList = new ArrayList<>(Collections.singletonList(null)); + assertEquals("{\"myList\":[null]}", NO_FEATURE.writeValueAsString(bean)); + } +} From fd4c9af7398331c37bb61b7376ae3f59ff9ae4ab Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 10 Jul 2026 08:56:07 -0700 Subject: [PATCH 2/2] Minor tweaking --- .../databind/ser/jdk/IterableSerializer.java | 10 +- .../JsonIncludeContainerEmpty6065Test.java | 93 +++++++++++-------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java b/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java index e427272a50..cacb4a2f96 100644 --- a/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java +++ b/src/main/java/tools/jackson/databind/ser/jdk/IterableSerializer.java @@ -61,10 +61,12 @@ public boolean isEmpty(SerializationContext ctxt, Iterable value) { } // [databind#6065]: with content @JsonInclude applied to containers, // an Iterable whose every element is suppressed is considered empty. - // NOTE: this walks elements until one survives filtering, so `serialize()` - // (which calls `value.iterator()` again) must re-traverse from the start -- - // fine for re-iterable sources, but doubles traversal cost and cannot be - // supported for single-use Iterables (those were already unsupported here). + // NOTE: unlike the old peek-only check, this consumes elements from the + // iterator; serialize()/serializeContents() obtain a fresh one via + // value.iterator(), so this relies on the Iterable being re-iterable -- + // an assumption this serializer already makes (see hasSingleElement(), + // called from serialize() before serializeContents()). The cost is one + // extra traversal, only when content filtering is active. if (_needToCheckFiltering(ctxt)) { return _allElementsSuppressed(ctxt, it); } diff --git a/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java b/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java index dae4a417ba..aabbf4fd49 100644 --- a/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java +++ b/src/test/java/tools/jackson/databind/ser/filter/JsonIncludeContainerEmpty6065Test.java @@ -121,12 +121,12 @@ static class NonNullContentBean { .build(); @Test - public void testAllNull() throws Exception { + public void allNull() throws Exception { assertEquals("{}", MAPPER.writeValueAsString(new Bean())); } @Test - public void testEmptyContainers() throws Exception { + public void emptyContainers() throws Exception { Bean bean = new Bean(); bean.myList = new ArrayList<>(); bean.myMap = new HashMap<>(); @@ -134,7 +134,7 @@ public void testEmptyContainers() throws Exception { } @Test - public void testContainersEmptyAfterContentFilter() throws Exception { + public void containersEmptyAfterContentFilter() throws Exception { Bean bean = new Bean(); bean.myList = new ArrayList<>(Collections.singletonList(null)); bean.myMap = new HashMap<>(); @@ -144,35 +144,39 @@ public void testContainersEmptyAfterContentFilter() throws Exception { } @Test - public void testStringListWithEmptyStringOnly() throws Exception { + public void stringListWithEmptyStringOnly() throws Exception { Bean bean = new Bean(); bean.myList = new ArrayList<>(Arrays.asList("", "")); assertEquals("{}", MAPPER.writeValueAsString(bean)); } @Test - public void testStringListKeepsNonEmpty() throws Exception { + public void stringListKeepsNonEmpty() throws Exception { Bean bean = new Bean(); bean.myList = new ArrayList<>(Arrays.asList(null, "keep", "")); - assertEquals("{\"myList\":[\"keep\"]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"myList":["keep"]}""", + MAPPER.writeValueAsString(bean)); } @Test - public void testIntListWithNullsOnly() throws Exception { + public void intListWithNullsOnly() throws Exception { IntListBean bean = new IntListBean(); bean.values = new ArrayList<>(Arrays.asList(null, null)); assertEquals("{}", MAPPER.writeValueAsString(bean)); } @Test - public void testIntListKeepsValues() throws Exception { + public void intListKeepsValues() throws Exception { IntListBean bean = new IntListBean(); bean.values = new ArrayList<>(Arrays.asList(null, 42)); - assertEquals("{\"values\":[42]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":[42]}""", + MAPPER.writeValueAsString(bean)); } @Test - public void testStringSetWithNullsOnly() throws Exception { + public void stringSetWithNullsOnly() throws Exception { StringSetBean bean = new StringSetBean(); bean.values = new LinkedHashSet<>(Arrays.asList((String) null)); assertEquals("{}", MAPPER.writeValueAsString(bean)); @@ -180,52 +184,58 @@ public void testStringSetWithNullsOnly() throws Exception { // [databind#6065]: same handling for String arrays @Test - public void testStringArrayEmptyAfterContentFilter() throws Exception { + public void stringArrayEmptyAfterContentFilter() throws Exception { StringArrayBean bean = new StringArrayBean(); bean.values = new String[] { null, "" }; assertEquals("{}", MAPPER.writeValueAsString(bean)); } @Test - public void testStringArrayKeepsNonEmpty() throws Exception { + public void stringArrayKeepsNonEmpty() throws Exception { StringArrayBean bean = new StringArrayBean(); bean.values = new String[] { null, "keep", "" }; - assertEquals("{\"values\":[\"keep\"]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":["keep"]}""", + MAPPER.writeValueAsString(bean)); } // [databind#6065]: same handling for Object arrays @Test - public void testObjectArrayNullsOnly() throws Exception { + public void objectArrayNullsOnly() throws Exception { IntArrayBean bean = new IntArrayBean(); bean.values = new Integer[] { null, null }; assertEquals("{}", MAPPER.writeValueAsString(bean)); } @Test - public void testObjectArrayKeepsValues() throws Exception { + public void objectArrayKeepsValues() throws Exception { IntArrayBean bean = new IntArrayBean(); bean.values = new Integer[] { null, 42 }; - assertEquals("{\"values\":[42]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":[42]}""", + MAPPER.writeValueAsString(bean)); } // [databind#6065]: same handling for Iterable @Test - public void testIterableEmptyAfterContentFilter() throws Exception { + public void iterableEmptyAfterContentFilter() throws Exception { IterableBean bean = new IterableBean(); bean.values = new StringIterable(null, ""); assertEquals("{}", MAPPER.writeValueAsString(bean)); } @Test - public void testIterableKeepsNonEmpty() throws Exception { + public void iterableKeepsNonEmpty() throws Exception { IterableBean bean = new IterableBean(); bean.values = new StringIterable(null, "keep", ""); - assertEquals("{\"values\":[\"keep\"]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":["keep"]}""", + MAPPER.writeValueAsString(bean)); } // [databind#6065]: primitive arrays, all-default content suppressed -> property dropped @Test - public void testPrimitiveArraysAllDefault() throws Exception { + public void primitiveArraysAllDefault() throws Exception { PrimArraysBean bean = new PrimArraysBean(); bean.ints = new int[] { 0, 0 }; bean.longs = new long[] { 0L }; @@ -237,35 +247,38 @@ public void testPrimitiveArraysAllDefault() throws Exception { } @Test - public void testPrimitiveArraysKeepNonDefault() throws Exception { + public void primitiveArraysKeepNonDefault() throws Exception { PrimArraysBean bean = new PrimArraysBean(); bean.ints = new int[] { 0, 42 }; bean.longs = new long[] { 0L, 7L }; bean.doubles = new double[] { 0.0, 1.5 }; bean.bools = new boolean[] { false, true }; - assertEquals("{\"bools\":[true],\"doubles\":[1.5],\"ints\":[42],\"longs\":[7]}", + assertEquals(""" + {"bools":[true],"doubles":[1.5],"ints":[42],"longs":[7]}""", MAPPER.writeValueAsString(bean)); } // [databind#6065]: EnumSet, all elements suppressed by CUSTOM filter -> property dropped @Test - public void testEnumSetAllSuppressed() throws Exception { + public void enumSetAllSuppressed() throws Exception { EnumSetBean bean = new EnumSetBean(); bean.values = EnumSet.of(Size.SMALL); assertEquals("{}", MAPPER.writeValueAsString(bean)); } @Test - public void testEnumSetKeepsNonSuppressed() throws Exception { + public void enumSetKeepsNonSuppressed() throws Exception { EnumSetBean bean = new EnumSetBean(); bean.values = EnumSet.of(Size.SMALL, Size.LARGE); - assertEquals("{\"values\":[\"LARGE\"]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":["LARGE"]}""", + MAPPER.writeValueAsString(bean)); } // [databind#6065]: nested containers -- inner List that is empty after content // filtering is itself suppressed as an element of the outer List @Test - public void testNestedListsAllEmptyAfterContentFilter() throws Exception { + public void nestedListsAllEmptyAfterContentFilter() throws Exception { NestedListBean bean = new NestedListBean(); bean.values = new ArrayList<>(Arrays.asList( new ArrayList<>(Arrays.asList("", "")), @@ -275,16 +288,18 @@ public void testNestedListsAllEmptyAfterContentFilter() throws Exception { } @Test - public void testNestedListsKeepNonEmpty() throws Exception { + public void nestedListsKeepNonEmpty() throws Exception { NestedListBean bean = new NestedListBean(); bean.values = new ArrayList<>(Arrays.asList( new ArrayList<>(Arrays.asList("", "")), new ArrayList<>(Arrays.asList(null, "keep")))); - assertEquals("{\"values\":[[\"keep\"]]}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":[["keep"]]}""", + MAPPER.writeValueAsString(bean)); } @Test - public void testMapOfListsAllEmptyAfterContentFilter() throws Exception { + public void mapOfListsAllEmptyAfterContentFilter() throws Exception { MapOfListsBean bean = new MapOfListsBean(); bean.values = new LinkedHashMap<>(); bean.values.put("a", new ArrayList<>(Arrays.asList("", ""))); @@ -293,18 +308,20 @@ public void testMapOfListsAllEmptyAfterContentFilter() throws Exception { } @Test - public void testMapOfListsKeepsNonEmpty() throws Exception { + public void mapOfListsKeepsNonEmpty() throws Exception { MapOfListsBean bean = new MapOfListsBean(); bean.values = new LinkedHashMap<>(); bean.values.put("a", new ArrayList<>(Collections.singletonList(""))); bean.values.put("b", new ArrayList<>(Arrays.asList(null, "keep"))); - assertEquals("{\"values\":{\"b\":[\"keep\"]}}", MAPPER.writeValueAsString(bean)); + assertEquals(""" + {"values":{"b":["keep"]}}""", + MAPPER.writeValueAsString(bean)); } // [databind#6065]: content = NON_NULL suppresses nulls only; container with // nothing but nulls becomes empty, but empty Strings/zeroes are retained @Test - public void testNonNullContentAllNulls() throws Exception { + public void nonNullContentAllNulls() throws Exception { NonNullContentBean bean = new NonNullContentBean(); bean.strings = new ArrayList<>(Arrays.asList(null, null)); bean.numbers = new ArrayList<>(Collections.singletonList((Integer) null)); @@ -314,22 +331,24 @@ public void testNonNullContentAllNulls() throws Exception { } @Test - public void testNonNullContentRetainsEmptyValues() throws Exception { + public void nonNullContentRetainsEmptyValues() throws Exception { NonNullContentBean bean = new NonNullContentBean(); bean.strings = new ArrayList<>(Arrays.asList(null, "")); bean.numbers = new ArrayList<>(Arrays.asList(null, 0)); bean.stringArray = new String[] { null, "" }; bean.numberArray = new Integer[] { null, 0 }; - assertEquals("{\"numberArray\":[0],\"numbers\":[0]," - +"\"stringArray\":[\"\"],\"strings\":[\"\"]}", + assertEquals(""" + {"numberArray":[0],"numbers":[0],"stringArray":[""],"strings":[""]}""", MAPPER.writeValueAsString(bean)); } // Without the feature, containers are left intact (only null containers dropped) @Test - public void testFeatureDisabledLeavesContainers() throws Exception { + public void featureDisabledLeavesContainers() throws Exception { Bean bean = new Bean(); bean.myList = new ArrayList<>(Collections.singletonList(null)); - assertEquals("{\"myList\":[null]}", NO_FEATURE.writeValueAsString(bean)); + assertEquals(""" + {"myList":[null]}""", + NO_FEATURE.writeValueAsString(bean)); } }