You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/06/16 21:38:18 UTC
(juneau) branch master updated: feat(marshall): null-inclusion knobs + first-class Optional*/BitSet datatypes
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new b0b0671f97 feat(marshall): null-inclusion knobs + first-class Optional*/BitSet datatypes
b0b0671f97 is described below
commit b0b0671f97d69f9cae126c89619d00523eb985d0
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Jun 16 17:38:12 2026 -0400
feat(marshall): null-inclusion knobs + first-class Optional*/BitSet datatypes
---
.../juneau/assertions/VersionAssertion_Test.java | 31 +-
.../juneau/commons/bean/BeanPropertyMeta.java | 20 +
.../apache/juneau/commons/bean/BeanSession.java | 2 +-
.../juneau/commons/conversion/BasicConverter.java | 53 ++
.../commons/conversion/CachingConverter.java | 6 +
.../juneau/marshall/jena/RdfSerializerSession.java | 2 +-
.../marshall/jena/RdfStreamSerializerSession.java | 2 +-
.../org/apache/juneau/marshall/BitSetFormat.java | 210 ++++++++
.../java/org/apache/juneau/marshall/ClassMeta.java | 41 ++
.../org/apache/juneau/marshall/Marshalled.java | 7 +
.../juneau/marshall/MarshalledAnnotation.java | 19 +
.../apache/juneau/marshall/MarshalledConfig.java | 7 +
.../marshall/MarshalledConfigAnnotation.java | 1 +
.../org/apache/juneau/marshall/MarshalledProp.java | 39 ++
.../juneau/marshall/MarshalledPropAnnotation.java | 39 ++
.../marshall/MarshalledPropertyPostProcessor.java | 223 +++++++-
.../apache/juneau/marshall/MarshallingContext.java | 36 +-
.../juneau/marshall/MarshallingContextable.java | 11 +
.../marshall/MarshallingTraverseSession.java | 6 +
.../java/org/apache/juneau/marshall/Nulls.java | 98 ++++
.../marshall/hjson/HjsonSerializerSession.java | 2 +-
.../marshall/hocon/HoconSerializerSession.java | 2 +-
.../marshall/html/HtmlSerializerSession.java | 2 +-
.../juneau/marshall/ini/IniSerializerSession.java | 2 +-
.../juneau/marshall/jcs/JcsSerializerSession.java | 3 +-
.../marshall/json/JsonSerializerSession.java | 2 +-
.../org/apache/juneau/marshall/parser/Parser.java | 58 ++-
.../juneau/marshall/parser/ParserConfig.java | 30 ++
.../marshall/parser/ParserConfigAnnotation.java | 1 +
.../juneau/marshall/parser/ParserSession.java | 42 +-
.../marshall/proto/ProtoSerializerSession.java | 2 +-
.../juneau/marshall/serializer/Serializer.java | 100 ++++
.../marshall/serializer/SerializerConfig.java | 31 ++
.../serializer/SerializerConfigAnnotation.java | 1 +
.../marshall/serializer/SerializerSession.java | 182 +++++++
.../marshall/toml/TomlSerializerSession.java | 2 +-
.../juneau/marshall/uon/UonSerializerSession.java | 2 +-
.../urlencoding/UrlEncodingSerializerSession.java | 2 +-
.../juneau/marshall/xml/XmlSerializerSession.java | 8 +-
.../marshall/yaml/YamlSerializerSession.java | 2 +-
.../marshall/NullInclusionAndCoercion_Test.java | 569 +++++++++++++++++++++
scripts/coverage.py | 184 ++++++-
42 files changed, 2011 insertions(+), 71 deletions(-)
diff --git a/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java b/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java
index db98993ad8..2116cc2388 100644
--- a/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java
+++ b/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java
@@ -68,7 +68,8 @@ class VersionAssertion_Test extends TestBase {
var x = of("1");
var nil = no(Version.class);
var s = Json5Serializer.DEFAULT;
- test(x).asString(s).is("{maintenance:null,major:1,minor:null}");
+ // 10.0: Optional.empty() ≡ null under the shared Optional contract — omitted unless keepNullProperties().
+ test(x).asString(s).is("{major:1}");
test(nil).asString(s).is("null");
}
@@ -80,14 +81,14 @@ class VersionAssertion_Test extends TestBase {
@Test void ba02_asJson() {
var x = of("1");
var nil = no(Version.class);
- test(x).asJson().is("{maintenance:null,major:1,minor:null}");
+ test(x).asJson().is("{major:1}");
test(nil).asJson().is("null");
}
@Test void ba03_asJsonSorted() {
var x = of("1");
var nil = no(Version.class);
- test(x).asJsonSorted().is("{maintenance:null,major:1,minor:null}");
+ test(x).asJsonSorted().is("{major:1}");
test(nil).asJsonSorted().is("null");
}
@@ -230,9 +231,9 @@ class VersionAssertion_Test extends TestBase {
var nil = no(Version.class);
test(x1).isSameJsonAs(x1a);
test(nil).isSameJsonAs(nil);
- assertThrown(()->test(x1a).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{maintenance:null,major:2,minor:null}'. Actual='{maintenance:null,major:1,minor:null}'.");
- assertThrown(()->test(nil).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{maintenance:null,major:2,minor:null}'. Actual='null'.");
- assertThrown(()->test(x1).isSameJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison. Expect='null'. Actual='{maintenance:null,major:1,minor:null}'.");
+ assertThrown(()->test(x1a).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{major:2}'. Actual='{major:1}'.");
+ assertThrown(()->test(nil).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{major:2}'. Actual='null'.");
+ assertThrown(()->test(x1).isSameJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison. Expect='null'. Actual='{major:1}'.");
}
@Test void ca10_isSameSortedJsonAs() {
@@ -242,9 +243,9 @@ class VersionAssertion_Test extends TestBase {
var nil = no(Version.class);
test(x1).isSameSortedJsonAs(x1a);
test(nil).isSameSortedJsonAs(nil);
- assertThrown(()->test(x1a).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{maintenance:null,major:2,minor:null}'. Actual='{maintenance:null,major:1,minor:null}'.");
- assertThrown(()->test(nil).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{maintenance:null,major:2,minor:null}'. Actual='null'.");
- assertThrown(()->test(x1).isSameSortedJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison. Expect='null'. Actual='{maintenance:null,major:1,minor:null}'.");
+ assertThrown(()->test(x1a).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{major:2}'. Actual='{major:1}'.");
+ assertThrown(()->test(nil).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison. Expect='{major:2}'. Actual='null'.");
+ assertThrown(()->test(x1).isSameSortedJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison. Expect='null'. Actual='{major:1}'.");
}
@Test void ca11_isSameSerializedAs() {
@@ -255,9 +256,9 @@ class VersionAssertion_Test extends TestBase {
var s = Json5Serializer.DEFAULT;
test(x1).isSameSerializedAs(x1a, s);
test(nil).isSameSerializedAs(nil, s);
- assertThrown(()->test(x1a).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison. Expect='{maintenance:null,major:2,minor:null}'. Actual='{maintenance:null,major:1,minor:null}'.");
- assertThrown(()->test(nil).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison. Expect='{maintenance:null,major:2,minor:null}'. Actual='null'.");
- assertThrown(()->test(x1).isSameSerializedAs(nil, s)).asMessage().asOneLine().is("Unexpected comparison. Expect='null'. Actual='{maintenance:null,major:1,minor:null}'.");
+ assertThrown(()->test(x1a).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison. Expect='{major:2}'. Actual='{major:1}'.");
+ assertThrown(()->test(nil).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison. Expect='{major:2}'. Actual='null'.");
+ assertThrown(()->test(x1).isSameSerializedAs(nil, s)).asMessage().asOneLine().is("Unexpected comparison. Expect='null'. Actual='{major:1}'.");
}
@Test void ca12_isType() {
@@ -293,10 +294,10 @@ class VersionAssertion_Test extends TestBase {
@Test void ca15_isJson() {
var x = of("1");
var nil = no(Version.class);
- test(x).isJson("{maintenance:null,major:1,minor:null}");
+ test(x).isJson("{major:1}");
test(nil).isJson("null");
- assertThrown(()->test(x).isJson("bad")).asMessage().asOneLine().is("String differed at position 0. Expect='bad'. Actual='{maintenance:null,major:1,minor:null}'.");
- assertThrown(()->test(x).isJson(null)).asMessage().asOneLine().is("String differed at position 0. Expect='null'. Actual='{maintenance:null,major:1,minor:null}'.");
+ assertThrown(()->test(x).isJson("bad")).asMessage().asOneLine().is("String differed at position 0. Expect='bad'. Actual='{major:1}'.");
+ assertThrown(()->test(x).isJson(null)).asMessage().asOneLine().is("String differed at position 0. Expect='null'. Actual='{major:1}'.");
assertThrown(()->test(nil).isJson("bad")).asMessage().asOneLine().is("String differed at position 0. Expect='bad'. Actual='null'.");
}
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
index 78e37cd0cd..459d393120 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
@@ -60,6 +60,20 @@ import org.apache.juneau.commons.reflect.*;
})
public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
+ /**
+ * Sentinel value that, when returned from {@link BeanPropertyMeta.Builder#writeTransform} during
+ * {@link #set(BeanMap, String, Object)}, causes the set to be skipped entirely — neither the configured setter
+ * is invoked nor the property cache updated.
+ *
+ * <p>
+ * Provided as a generic SPI hook so the marshalling layer (or any other transform-installing extension) can
+ * implement "leave the bean's pre-existing value in place" semantics — used by the per-property null-coercion
+ * {@code SKIP} mode added to the marshalling layer in 10.0.0.
+ *
+ * @since 10.0.0
+ */
+ public static final Object SKIP_VALUE = new Object();
+
// Property name constants
private static final String PROP_field = "field";
private static final String PROP_getter = "getter";
@@ -1093,6 +1107,12 @@ public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
// Apply the install-time write transform (identity by default; swap-aware in the marshalling layer).
value1 = writeTransform.apply(session, value1);
+ // Generic SPI hook: a transform that returns SKIP_VALUE causes the set to be skipped entirely so the
+ // bean's pre-existing value (typically a field-initializer) is preserved. Used by the marshalling
+ // layer's per-property null-coercion SKIP mode.
+ if (value1 == SKIP_VALUE)
+ return null;
+
if (m.bean == null) {
// Read-only beans get their properties stored in a cache.
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java
index e949e4dc65..cb38da1d0b 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java
@@ -143,7 +143,7 @@ public interface BeanSession {
* <p>
* Default-view-inclusion policy (configurable): an untagged property — one whose view set is empty — is
* considered in-view under every active view unless the
- * {@link org.apache.juneau.marshall.MarshallingContext.Builder#disableDefaultViewInclusion()} flag is set.
+ * {@code MarshallingContext.Builder.disableDefaultViewInclusion()} flag is set.
*
* @param pMeta The property metadata. Must not be <jk>null</jk>.
* @return <jk>true</jk> if the property should be included under the current active view.
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
index b8809a6585..a86833d07e 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
@@ -208,6 +208,9 @@ public class BasicConverter extends CachingConverter {
if (out == Optional.class && (c = findOptionalConversion()) != null) return c;
+ if ((out == OptionalInt.class || out == OptionalLong.class || out == OptionalDouble.class)
+ && (c = findPrimitiveOptionalConversion(out)) != null) return c;
+
if (Collection.class.isAssignableFrom(out) && (c = findCollectionConversion(inType, out)) != null) return c;
if (Map.class.isAssignableFrom(out) && (c = findMapConversion(inType, out)) != null) return c;
@@ -355,6 +358,56 @@ public class BasicConverter extends CachingConverter {
};
}
+ /**
+ * Conversion to a primitive {@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble}.
+ *
+ * <p>
+ * Mirrors the {@link Optional} contract: a {@code null} input (or an empty optional of any flavor)
+ * yields the matching empty primitive optional; any other value is unwrapped (if it is itself an
+ * optional) and coerced to the matching boxed number before wrapping.
+ *
+ * @param out The target primitive-optional class.
+ * @return The conversion, never <jk>null</jk>.
+ */
+ private <I, O> Conversion<I, O> findPrimitiveOptionalConversion(Class<O> out) {
+ final Class<? extends Number> elementType;
+ if (out == OptionalInt.class)
+ elementType = Integer.class;
+ else if (out == OptionalLong.class)
+ elementType = Long.class;
+ else
+ elementType = Double.class;
+ return (Conversion<I, O>) (Conversion<Object, Object>) (in, memberOf, session, args) -> {
+ Object v = in;
+ if (v instanceof Optional<?> opt)
+ v = opt.orElse(null);
+ else if (v instanceof OptionalInt oi)
+ v = oi.isPresent() ? Integer.valueOf(oi.getAsInt()) : null;
+ else if (v instanceof OptionalLong ol)
+ v = ol.isPresent() ? Long.valueOf(ol.getAsLong()) : null;
+ else if (v instanceof OptionalDouble od)
+ v = od.isPresent() ? Double.valueOf(od.getAsDouble()) : null;
+ if (v == null)
+ return emptyPrimitiveOptional(out);
+ var n = (Number) to(v, memberOf, session, elementType);
+ if (n == null)
+ return emptyPrimitiveOptional(out);
+ if (out == OptionalInt.class)
+ return OptionalInt.of(n.intValue());
+ if (out == OptionalLong.class)
+ return OptionalLong.of(n.longValue());
+ return OptionalDouble.of(n.doubleValue());
+ };
+ }
+
+ private static Object emptyPrimitiveOptional(Class<?> out) {
+ if (out == OptionalInt.class)
+ return OptionalInt.empty();
+ if (out == OptionalLong.class)
+ return OptionalLong.empty();
+ return OptionalDouble.empty();
+ }
+
//-----------------------------------------------------------------------------------------------------------------
// Collection conversions
//-----------------------------------------------------------------------------------------------------------------
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java
index 825f51a889..dc76227469 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java
@@ -83,6 +83,12 @@ public abstract class CachingConverter implements Converter {
return (T) opt(nullDefault(args[0], Arrays.copyOfRange(args, 1, args.length)));
return (T) opte();
}
+ if (type == OptionalInt.class)
+ return (T) OptionalInt.empty();
+ if (type == OptionalLong.class)
+ return (T) OptionalLong.empty();
+ if (type == OptionalDouble.class)
+ return (T) OptionalDouble.empty();
return type.isPrimitive() ? primitiveDefault(type) : null;
}
diff --git a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java
index 16b50a19a5..b1f77fe6f8 100644
--- a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java
+++ b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java
@@ -365,7 +365,7 @@ public class RdfSerializerSession extends WriterSerializerSession {
if (nn(t))
onBeanGetterException(bpMeta, t);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(bpMeta, key, value))
return;
var ns = bpRdf.getNamespace();
diff --git a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java
index 4f57bfd4f7..705fcce26d 100644
--- a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java
+++ b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java
@@ -409,7 +409,7 @@ public class RdfStreamSerializerSession extends OutputStreamSerializerSession {
var t = x.getThrown();
if (nn(t))
onBeanGetterException(bpMeta, t);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(bpMeta, key, value))
return;
var ns = bpRdf.getNamespace();
if (ns == null && ctx.isUseXmlNamespaces())
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java
new file mode 100644
index 0000000000..9ba4c3f454
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshall;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.util.*;
+import java.util.stream.*;
+
+/**
+ * Supported wire formats for {@link BitSet} values.
+ *
+ * <p>
+ * Used by {@link MarshallingContext.Builder#bitSetFormat(BitSetFormat)},
+ * {@link org.apache.juneau.marshall.Marshalled#bitSetFormat()},
+ * {@link org.apache.juneau.marshall.MarshalledProp#bitSetFormat()}, and
+ * {@link org.apache.juneau.marshall.MarshalledConfig#bitSetFormat()} to control how {@link BitSet} values
+ * are written to text-based wire formats.
+ *
+ * <p>
+ * The default is {@link #INDICES} which emits the ascending set-bit indices as a comma-delimited token.
+ *
+ * <h5 class='topic'>Precedence (highest to lowest)</h5>
+ * <ol>
+ * <li>{@link org.apache.juneau.marshall.MarshalledProp#bitSetFormat() @MarshalledProp(bitSetFormat=…)} on the bean property.
+ * <li>{@link org.apache.juneau.marshall.Marshalled#bitSetFormat() @Marshalled(bitSetFormat=…)} on the bean class.
+ * <li>{@link org.apache.juneau.marshall.MarshalledConfig#bitSetFormat() @MarshalledConfig(bitSetFormat=…)} on
+ * <code><ja>@Rest</ja></code>-annotated classes / methods.
+ * <li>Programmatic {@link MarshallingContext.Builder#bitSetFormat(BitSetFormat)}.
+ * <li>Environment variable <c>MarshallingContext.bitSetFormat</c>.
+ * <li>The default constant ({@link #INDICES}).
+ * </ol>
+ *
+ * <h5 class='topic'>Parsing</h5>
+ *
+ * <p>
+ * Parsing is directed by the configured constant — a serializer and parser sharing the same
+ * {@code BitSetFormat} round-trip cleanly. The three textual shapes are ambiguous with each other
+ * (a bare digit string is a valid index list, bit string, and hex token), so the configured format is
+ * honored rather than auto-detected.
+ */
+public enum BitSetFormat {
+
+ /** Sentinel meaning "no value configured" — falls through to the next-higher precedence level. */
+ NOT_SET,
+
+ /**
+ * Ascending set-bit indices as a comma-delimited token (the default).
+ *
+ * <h5 class='figure'>Example:</h5>
+ * <p class='bcode'>
+ * 0,2,5
+ * </p>
+ *
+ * <p>
+ * An empty {@link BitSet} emits the empty string.
+ */
+ INDICES,
+
+ /**
+ * Little-endian bit string — character at position <c>i</c> is <c>'1'</c> when bit <c>i</c> is set.
+ *
+ * <h5 class='figure'>Example:</h5>
+ * <p class='bcode'>
+ * 101001
+ * </p>
+ *
+ * <p>
+ * The string length equals {@link BitSet#length()}; an empty {@link BitSet} emits the empty string.
+ */
+ BITS,
+
+ /**
+ * Hex-encoded little-endian byte array ({@link BitSet#toByteArray()}).
+ *
+ * <h5 class='figure'>Example:</h5>
+ * <p class='bcode'>
+ * 25
+ * </p>
+ *
+ * <p>
+ * An empty {@link BitSet} emits the empty string.
+ */
+ HEX;
+
+ private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray();
+
+ /**
+ * Formats the specified {@link BitSet} using this format.
+ *
+ * <p>
+ * {@link #NOT_SET} falls through to {@link #INDICES}.
+ *
+ * @param value The value to format. Can be <jk>null</jk>.
+ * @param format The format to use. Can be <jk>null</jk> (treated as {@link #INDICES}).
+ * @return The formatted wire representation, or <jk>null</jk> if {@code value} is <jk>null</jk>.
+ */
+ public static String format(BitSet value, BitSetFormat format) {
+ if (value == null)
+ return null;
+ var fmt = format == null ? INDICES : format;
+ return switch (fmt) {
+ case NOT_SET, INDICES -> value.stream().mapToObj(Integer::toString).collect(Collectors.joining(","));
+ case BITS -> formatBits(value);
+ case HEX -> formatHex(value.toByteArray());
+ };
+ }
+
+ private static String formatBits(BitSet value) {
+ var len = value.length();
+ var sb = new StringBuilder(len);
+ for (var i = 0; i < len; i++)
+ sb.append(value.get(i) ? '1' : '0');
+ return sb.toString();
+ }
+
+ private static String formatHex(byte[] bytes) {
+ var sb = new StringBuilder(bytes.length * 2);
+ for (var b : bytes) {
+ sb.append(HEX_CHARS[(b >> 4) & 0xF]);
+ sb.append(HEX_CHARS[b & 0xF]);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Parses the specified wire value into a {@link BitSet}.
+ *
+ * <p>
+ * Parsing is directed by the {@code format} hint (defaulting to {@link #INDICES}); the three textual
+ * shapes are mutually ambiguous, so the configured format is honored rather than auto-detected.
+ *
+ * @param value The wire value. Can be <jk>null</jk> or blank.
+ * @param format The configured format hint. Can be <jk>null</jk> (treated as {@link #INDICES}).
+ * @return The parsed {@link BitSet}, or <jk>null</jk> if {@code value} is <jk>null</jk>.
+ * @throws IllegalArgumentException If the value does not match the configured textual shape.
+ */
+ public static BitSet parse(String value, BitSetFormat format) {
+ if (value == null)
+ return null;
+ var s = value.trim();
+ var fmt = format == null ? INDICES : format;
+ var bs = new BitSet();
+ if (s.isEmpty())
+ return bs;
+ try {
+ switch (fmt) {
+ case NOT_SET, INDICES -> {
+ for (var tok : s.split(","))
+ bs.set(Integer.parseInt(tok.trim()));
+ }
+ case BITS -> {
+ for (var i = 0; i < s.length(); i++)
+ if (s.charAt(i) == '1')
+ bs.set(i);
+ }
+ case HEX -> {
+ return BitSet.valueOf(parseHex(s));
+ }
+ }
+ } catch (IllegalArgumentException e) {
+ throw illegalArg("Invalid BitSet value ''{0}'' for format {1}: {2}", value, fmt, e.getMessage());
+ }
+ return bs;
+ }
+
+ private static byte[] parseHex(String s) {
+ if ((s.length() & 1) != 0)
+ throw illegalArg("Hex string must have an even number of characters");
+ var out = new byte[s.length() / 2];
+ for (var i = 0; i < out.length; i++) {
+ var hi = Character.digit(s.charAt(i * 2), 16);
+ var lo = Character.digit(s.charAt(i * 2 + 1), 16);
+ if (hi < 0 || lo < 0)
+ throw illegalArg("Invalid hex character");
+ out[i] = (byte) ((hi << 4) | lo);
+ }
+ return out;
+ }
+
+ /**
+ * Returns <jk>true</jk> if this format is numeric on the wire.
+ *
+ * <p>
+ * Always <jk>false</jk> — every constant emits a textual representation.
+ *
+ * @return <jk>false</jk>.
+ */
+ @SuppressWarnings({
+ "static-method", // Kept as an instance method for polymorphic-by-convention symmetry with the other Format classes (BigNumberFormat, FloatFormat, DurationFormat, etc.) where isNumeric() depends on the enum constant.
+ "java:S3400" // Same rationale — must remain an instance method, not a constant, to match the cross-Format API contract.
+ })
+ public boolean isNumeric() {
+ return false;
+ }
+}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java
index a5aa88ef76..c39c271bb2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java
@@ -675,11 +675,44 @@ public final class ClassMeta<T> extends BeanInfo<T> {
})
@Override
public Optional<?> getOptionalDefault() {
+ if (isPrimitiveOptional())
+ return Optional.empty();
if (isOptional())
return opt(getElementType().getOptionalDefault());
return null;
}
+ /**
+ * Returns <jk>true</jk> if this is one of the primitive {@link Optional} cousins
+ * ({@link OptionalInt}, {@link OptionalLong}, or {@link OptionalDouble}).
+ *
+ * <p>
+ * These types carry no generic element type but are treated as first-class optional types by the
+ * marshallers (mirroring the {@link Optional} contract — empty ≡ null/absent, present unwraps to the
+ * primitive number). See {@link #getElementType()} which synthesizes the matching boxed element type.
+ *
+ * @return <jk>true</jk> if this is {@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble}.
+ */
+ public boolean isPrimitiveOptional() {
+ return is(OptionalInt.class) || is(OptionalLong.class) || is(OptionalDouble.class);
+ }
+
+ /**
+ * Returns <jk>true</jk> if this class is {@link Optional} or one of its primitive cousins
+ * ({@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble}).
+ *
+ * <p>
+ * Overrides {@link org.apache.juneau.commons.reflect.ClassInfo#isOptional()} so the marshalling layer
+ * treats the three primitive optional types as first-class optionals (synthetic boxed element type,
+ * empty-as-null contract) without registering swaps for them.
+ *
+ * @return <jk>true</jk> if this is an optional-like type.
+ */
+ @Override
+ public boolean isOptional() {
+ return super.isOptional() || isPrimitiveOptional();
+ }
+
/**
* Returns the method or field annotated with {@link ParentProperty @ParentProperty}.
*
@@ -1495,6 +1528,14 @@ public final class ClassMeta<T> extends BeanInfo<T> {
private ClassMeta<?> findElementType() {
if (marshallingContext == null)
return null;
+ // Primitive optional types carry no generic parameter — synthesize the matching boxed element type so
+ // the first-class optional path (serialize unwrap / parse element-typed wrap) has a target type.
+ if (is(OptionalInt.class))
+ return marshallingContext.getClassMeta(Integer.class);
+ if (is(OptionalLong.class))
+ return marshallingContext.getClassMeta(Long.class);
+ if (is(OptionalDouble.class))
+ return marshallingContext.getClassMeta(Double.class);
if (cat.is(ARRAY)) {
return marshallingContext.getClassMeta(inner().getComponentType());
} else if (cat.is(COLLECTION) || cat.is(ITERABLE) || cat.is(ITERATOR) || cat.is(STREAM) || is(Optional.class)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java
index 2663ce0711..7859da2f2b 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java
@@ -170,6 +170,13 @@ public @interface Marshalled {
*/
UuidFormat uuidFormat() default UuidFormat.NOT_SET;
+ /**
+ * BitSet wire format override.
+ *
+ * @return The annotation value.
+ */
+ BitSetFormat bitSetFormat() default BitSetFormat.NOT_SET;
+
/**
* Big-number wire format override for {@link BigInteger} / {@link BigDecimal} values.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java
index 7fae59ca04..aad4d1f8d8 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java
@@ -62,6 +62,7 @@ public class MarshalledAnnotation {
private BinaryFormat binaryFormat = BinaryFormat.NOT_SET;
private EnumFormat enumFormat = EnumFormat.NOT_SET;
private UuidFormat uuidFormat = UuidFormat.NOT_SET;
+ private BitSetFormat bitSetFormat = BitSetFormat.NOT_SET;
private BigNumberFormat bigNumberFormat = BigNumberFormat.NOT_SET;
private BooleanFormat booleanFormat = BooleanFormat.NOT_SET;
private FloatFormat floatFormat = FloatFormat.NOT_SET;
@@ -238,6 +239,17 @@ public class MarshalledAnnotation {
return this;
}
+ /**
+ * Sets the {@link Marshalled#bitSetFormat()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder bitSetFormat(BitSetFormat value) {
+ bitSetFormat = value;
+ return this;
+ }
+
/**
* Sets the {@link Marshalled#bigNumberFormat()} property on this annotation.
*
@@ -374,6 +386,7 @@ public class MarshalledAnnotation {
private final BinaryFormat binaryFormat;
private final EnumFormat enumFormat;
private final UuidFormat uuidFormat;
+ private final BitSetFormat bitSetFormat;
private final BigNumberFormat bigNumberFormat;
private final BooleanFormat booleanFormat;
private final FloatFormat floatFormat;
@@ -401,6 +414,7 @@ public class MarshalledAnnotation {
binaryFormat = b.binaryFormat;
enumFormat = b.enumFormat;
uuidFormat = b.uuidFormat;
+ bitSetFormat = b.bitSetFormat;
bigNumberFormat = b.bigNumberFormat;
booleanFormat = b.booleanFormat;
floatFormat = b.floatFormat;
@@ -473,6 +487,11 @@ public class MarshalledAnnotation {
return uuidFormat;
}
+ @Override /* Overridden from Marshalled */
+ public BitSetFormat bitSetFormat() {
+ return bitSetFormat;
+ }
+
@Override /* Overridden from Marshalled */
public BigNumberFormat bigNumberFormat() {
return bigNumberFormat;
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java
index 89ca24d1e3..9e885ec2a5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java
@@ -296,6 +296,13 @@ public @interface MarshalledConfig {
*/
UuidFormat uuidFormat() default UuidFormat.NOT_SET;
+ /**
+ * BitSet wire format.
+ *
+ * @return The annotation value.
+ */
+ BitSetFormat bitSetFormat() default BitSetFormat.NOT_SET;
+
/**
* Big-number wire format for {@link BigInteger} / {@link BigDecimal} values.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java
index 3dacc653be..18b802042d 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java
@@ -67,6 +67,7 @@ public class MarshalledConfigAnnotation {
if (a.binaryFormat() != BinaryFormat.NOT_SET) b.binaryFormat(a.binaryFormat());
if (a.enumFormat() != EnumFormat.NOT_SET) b.enumFormat(a.enumFormat());
if (a.uuidFormat() != UuidFormat.NOT_SET) b.uuidFormat(a.uuidFormat());
+ if (a.bitSetFormat() != BitSetFormat.NOT_SET) b.bitSetFormat(a.bitSetFormat());
if (a.bigNumberFormat() != BigNumberFormat.NOT_SET) b.bigNumberFormat(a.bigNumberFormat());
if (a.booleanFormat() != BooleanFormat.NOT_SET) b.booleanFormat(a.booleanFormat());
if (a.floatFormat() != FloatFormat.NOT_SET) b.floatFormat(a.floatFormat());
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java
index 186975d3e8..608c1dd293 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java
@@ -233,6 +233,13 @@ public @interface MarshalledProp {
*/
UuidFormat uuidFormat() default UuidFormat.NOT_SET;
+ /**
+ * BitSet wire format override for {@link BitSet} property values.
+ *
+ * @return The annotation value.
+ */
+ BitSetFormat bitSetFormat() default BitSetFormat.NOT_SET;
+
/**
* Big-number wire format override for {@link BigInteger} / {@link BigDecimal} property values.
*
@@ -267,4 +274,36 @@ public @interface MarshalledProp {
* @return The annotation value.
*/
ClassFormat classFormat() default ClassFormat.NOT_SET;
+
+ /**
+ * Per-property null-coercion policy applied when a {@code null} JSON-equivalent value reaches this property during
+ * parsing.
+ *
+ * <p>
+ * Juneau's analog of Jackson's {@code @JsonSetter(nulls=…)}. When the parser encounters an explicit {@code null}
+ * (or an absent {@link Optional}) for this property, the configured policy decides whether to:
+ * <ul>
+ * <li>{@link Nulls#LEAVE LEAVE} (default) — set the property to {@code null} (or, for an {@link Optional}-typed
+ * property, to {@link Optional#empty()}).
+ * <li>{@link Nulls#EMPTY EMPTY} — substitute the type's "empty" value (empty {@link String}/{@link java.util.Collection}/
+ * {@link java.util.Map}, primitive default for primitives, {@code Optional.empty()} for {@link Optional}).
+ * <li>{@link Nulls#DEFAULT DEFAULT} — substitute the bean-constructed default for the property (i.e. the value the
+ * property holds on a fresh no-arg-constructed instance of the bean). When no reference instance can be built,
+ * falls back to {@link Nulls#LEAVE LEAVE}.
+ * <li>{@link Nulls#SKIP SKIP} — do not call the setter at all, leaving any pre-existing value in place.
+ * </ul>
+ *
+ * <p>
+ * For an {@link Optional}-typed property, {@code EMPTY}/{@code DEFAULT} resolve to {@link Optional#empty()} —
+ * never a bare {@code null} inside an {@link Optional}. The same contract applies to {@link java.util.OptionalInt},
+ * {@link java.util.OptionalLong}, and {@link java.util.OptionalDouble}.
+ *
+ * <p>
+ * When this member is {@link Nulls#NOT_SET NOT_SET} (the default), the context-level default configured on
+ * {@link org.apache.juneau.marshall.parser.Parser.Builder#nulls(Nulls)} applies.
+ *
+ * @return The annotation value.
+ * @since 10.0.0
+ */
+ Nulls nulls() default Nulls.NOT_SET;
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java
index 7fba4e6a60..c6d8487bc3 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java
@@ -56,11 +56,13 @@ public class MarshalledPropAnnotation {
private BinaryFormat binaryFormat = BinaryFormat.NOT_SET;
private EnumFormat enumFormat = EnumFormat.NOT_SET;
private UuidFormat uuidFormat = UuidFormat.NOT_SET;
+ private BitSetFormat bitSetFormat = BitSetFormat.NOT_SET;
private BigNumberFormat bigNumberFormat = BigNumberFormat.NOT_SET;
private BooleanFormat booleanFormat = BooleanFormat.NOT_SET;
private FloatFormat floatFormat = FloatFormat.NOT_SET;
private CurrencyFormat currencyFormat = CurrencyFormat.NOT_SET;
private ClassFormat classFormat = ClassFormat.NOT_SET;
+ private Nulls nulls = Nulls.NOT_SET;
/**
* Constructor.
@@ -221,6 +223,17 @@ public class MarshalledPropAnnotation {
return this;
}
+ /**
+ * Sets the {@link MarshalledProp#bitSetFormat()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder bitSetFormat(BitSetFormat value) {
+ bitSetFormat = value;
+ return this;
+ }
+
/**
* Sets the {@link MarshalledProp#bigNumberFormat()} property on this annotation.
*
@@ -287,6 +300,18 @@ public class MarshalledPropAnnotation {
return this;
}
+ /**
+ * Sets the {@link MarshalledProp#nulls()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ * @since 10.0.0
+ */
+ public Builder nulls(Nulls value) {
+ nulls = value;
+ return this;
+ }
+
}
@SuppressWarnings({
@@ -308,11 +333,13 @@ public class MarshalledPropAnnotation {
private final BinaryFormat binaryFormat;
private final EnumFormat enumFormat;
private final UuidFormat uuidFormat;
+ private final BitSetFormat bitSetFormat;
private final BigNumberFormat bigNumberFormat;
private final BooleanFormat booleanFormat;
private final FloatFormat floatFormat;
private final CurrencyFormat currencyFormat;
private final ClassFormat classFormat;
+ private final Nulls nulls;
Object(MarshalledPropAnnotation.Builder b) {
super(b);
@@ -330,11 +357,13 @@ public class MarshalledPropAnnotation {
binaryFormat = b.binaryFormat;
enumFormat = b.enumFormat;
uuidFormat = b.uuidFormat;
+ bitSetFormat = b.bitSetFormat;
bigNumberFormat = b.bigNumberFormat;
booleanFormat = b.booleanFormat;
floatFormat = b.floatFormat;
currencyFormat = b.currencyFormat;
classFormat = b.classFormat;
+ nulls = b.nulls;
}
@Override /* Overridden from MarshalledProp */
@@ -397,6 +426,11 @@ public class MarshalledPropAnnotation {
return uuidFormat;
}
+ @Override /* Overridden from MarshalledProp */
+ public BitSetFormat bitSetFormat() {
+ return bitSetFormat;
+ }
+
@Override /* Overridden from MarshalledProp */
public BigNumberFormat bigNumberFormat() {
return bigNumberFormat;
@@ -431,6 +465,11 @@ public class MarshalledPropAnnotation {
public String[] view() {
return view;
}
+
+ @Override /* Overridden from MarshalledProp */
+ public Nulls nulls() {
+ return nulls;
+ }
}
/** Default value */
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java
index 662750cafe..6e038440d4 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java
@@ -107,6 +107,10 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
var bdClasses = new ArrayList<Class<?>>();
Set<String> viewNames = null;
var propertyClass = propertyClass(b);
+ // Per-property null-coercion mode discovered across innerField/getter/setter (last-seen-wins, matching the
+ // existing format-override loop semantics). Stays Nulls.NOT_SET when no @MarshalledProp annotation
+ // declares it, in which case only the context-level Parser.Builder#nulls(Nulls) default applies.
+ var perPropertyNulls = Nulls.NOT_SET;
// XMLGregorianCalendar always uses XML format regardless of any CalendarFormat setting.
if (b.swap == null && XMLGregorianCalendar.class.equals(propertyClass))
@@ -123,8 +127,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
});
ap.find(Swap.class, b.innerField).stream().findFirst().ifPresent(x -> b.swap = swapSwap(x));
b.isUri |= ap.has(Uri.class, b.innerField);
- for (var ai : ap.find(MarshalledProp.class, b.innerField))
+ for (var ai : ap.find(MarshalledProp.class, b.innerField)) {
viewNames = addViews(viewNames, ai.inner().view());
+ if (ai.inner().nulls() != Nulls.NOT_SET)
+ perPropertyNulls = ai.inner().nulls();
+ }
}
if (nn(b.getter)) {
@@ -137,8 +144,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
});
ap.find(Swap.class, b.getter).forEach(x -> b.swap = swapSwap(x));
b.isUri |= ap.has(Uri.class, b.getter);
- for (var ai : ap.find(MarshalledProp.class, b.getter))
+ for (var ai : ap.find(MarshalledProp.class, b.getter)) {
viewNames = addViews(viewNames, ai.inner().view());
+ if (ai.inner().nulls() != Nulls.NOT_SET)
+ perPropertyNulls = ai.inner().nulls();
+ }
}
if (nn(b.setter)) {
@@ -151,8 +161,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
});
ap.find(Swap.class, b.setter).forEach(x -> b.swap = swapSwap(x));
b.isUri |= ap.has(Uri.class, b.setter);
- for (var ai : ap.find(MarshalledProp.class, b.setter))
+ for (var ai : ap.find(MarshalledProp.class, b.setter)) {
viewNames = addViews(viewNames, ai.inner().view());
+ if (ai.inner().nulls() != Nulls.NOT_SET)
+ perPropertyNulls = ai.inner().nulls();
+ }
}
if (viewNames != null)
@@ -191,6 +204,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
// Install schema-validation transforms (wraps any existing readTransform/writeTransform) when a
// PropertyValidatorFactory is on the classpath and the property carries at least one @Schema annotation.
installSchemaValidationTransforms(bc, b);
+
+ // Install null-coercion writeTransform (wraps any existing writeTransform). Always installed so the
+ // context-level Parser.Builder#nulls(Nulls) default can take effect at runtime even when no per-property
+ // annotation is configured. The wrap is a no-op for non-null values and the LEAVE policy.
+ installNullCoercionTransform(b, perPropertyNulls, propertyClass);
}
/**
@@ -269,6 +287,182 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
}
}
+ /** Sentinel marker used to cache "no reference instance could be built" for the {@link Nulls#DEFAULT} mode. */
+ private static final Object NO_DEFAULT_REFERENCE = new Object();
+ /** Cache key under which the per-{@link ClassMeta} reference instance is memoized for {@link Nulls#DEFAULT}. */
+ private static final String NULLS_DEFAULT_REFERENCE_CACHE_KEY = "MarshalledPropertyPostProcessor.nullsDefault.referenceInstance";
+
+ /**
+ * Installs a null-coercion {@code writeTransform} on a {@link BeanPropertyMeta.Builder}.
+ *
+ * <p>
+ * The installed wrap is invoked on every {@code BeanPropertyMeta.set} call but is a near-zero-cost no-op when the
+ * post-swap value is non-null (the common case). When the value is {@code null} (or {@link Optional#empty()} /
+ * {@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble} in their empty form), the wrap consults the
+ * effective {@link Nulls} mode (per-property > session-level > context-level > {@link Nulls#LEAVE
+ * LEAVE}) and substitutes one of the documented coerced values, or returns {@link BeanPropertyMeta#SKIP_VALUE} to
+ * signal the bean-modeling layer to skip the set entirely.
+ *
+ * @param b The bean-property builder.
+ * @param perPropertyNulls The per-property {@link Nulls} mode discovered on {@code @MarshalledProp}.
+ * {@link Nulls#NOT_SET} means "no per-property override; fall through to the session/context default".
+ * @param propertyClass The raw property class — used to compute {@link Nulls#EMPTY} / {@link Nulls#DEFAULT}
+ * substitutes.
+ */
+ @SuppressWarnings({
+ "java:S3776" // Cognitive complexity is acceptable; switch-over-modes is the clearest expression of the contract.
+ })
+ static void installNullCoercionTransform(BeanPropertyMeta.Builder b, Nulls perPropertyNulls, Class<?> propertyClass) {
+ var inner = b.writeTransform;
+ // Capture the full property ClassMeta so the Optional empty-default is nesting-aware (e.g.
+ // Optional<Optional<X>> → Optional.of(Optional.empty()) rather than a collapsed single-level
+ // Optional.empty()). The raw propertyClass alone cannot express the nesting depth.
+ var propertyMeta = (b.rawTypeMeta instanceof ClassMeta<?> cm) ? cm : null;
+ b.writeTransform = (session, value) -> {
+ Object resolved = nn(inner) ? inner.apply(session, value) : value;
+ // Fast path — non-null, non-empty-Optional values bypass the policy lookup entirely.
+ if (resolved != null && ! isEmptyOptional(resolved))
+ return resolved;
+ var effective = resolveEffectiveNulls(perPropertyNulls, session);
+ return switch (effective) {
+ case LEAVE, NOT_SET -> coerceLeave(resolved, propertyClass, propertyMeta);
+ case EMPTY -> emptyValueFor(propertyClass, propertyMeta);
+ case DEFAULT -> defaultValueFor(b, propertyClass, propertyMeta, session);
+ case SKIP -> BeanPropertyMeta.SKIP_VALUE;
+ };
+ };
+ }
+
+ /**
+ * Resolves the effective {@link Nulls} mode for a null-valued set.
+ *
+ * <p>
+ * Precedence: per-property annotation > session-level override > parser-context default >
+ * {@link Nulls#LEAVE}.
+ *
+ * @param perPropertyNulls The per-property mode (may be {@link Nulls#NOT_SET}).
+ * @param session The bean session — narrowed to {@link ParserSession} to read the session/context defaults.
+ * @return The effective mode (never {@code null}; falls back to {@link Nulls#LEAVE}).
+ */
+ private static Nulls resolveEffectiveNulls(Nulls perPropertyNulls, BeanSession session) {
+ if (perPropertyNulls != null && perPropertyNulls != Nulls.NOT_SET)
+ return perPropertyNulls;
+ if (session instanceof ParserSession ps) {
+ var ctx = ps.getNulls();
+ if (ctx != null && ctx != Nulls.NOT_SET)
+ return ctx;
+ }
+ return Nulls.LEAVE;
+ }
+
+ /**
+ * {@link Nulls#LEAVE} coercion — passes through unchanged for non-Optional properties, but resolves
+ * {@code null}/empty-Optional to {@link Optional#empty()} (or the matching primitive-Optional empty sentinel)
+ * for {@link Optional}-typed properties so the shared Optional contract holds.
+ */
+ private static Object coerceLeave(Object resolved, Class<?> propertyClass, ClassMeta<?> propertyMeta) {
+ var empty = emptyOptional(propertyClass, propertyMeta);
+ return empty != null ? empty : resolved;
+ }
+
+ /**
+ * Returns the empty form for an {@link Optional}-flavored property, or {@code null} if the property is not an
+ * optional type.
+ *
+ * <p>
+ * For a regular {@link Optional}, this is nesting-aware: it delegates to {@link ClassMeta#getOptionalDefault()}
+ * so {@code Optional<Optional<X>>} resolves to {@code Optional.of(Optional.empty())} (the shared contract:
+ * present outer, empty inner) rather than a structure-collapsing single-level {@code Optional.empty()}. When
+ * the property metadata is unavailable, falls back to a single-level {@code Optional.empty()}.
+ */
+ private static Object emptyOptional(Class<?> propertyClass, ClassMeta<?> propertyMeta) {
+ if (Optional.class.equals(propertyClass))
+ return (propertyMeta != null && propertyMeta.isOptional()) ? propertyMeta.getOptionalDefault() : Optional.empty();
+ if (OptionalInt.class.equals(propertyClass))
+ return OptionalInt.empty();
+ if (OptionalLong.class.equals(propertyClass))
+ return OptionalLong.empty();
+ if (OptionalDouble.class.equals(propertyClass))
+ return OptionalDouble.empty();
+ return null;
+ }
+
+ @SuppressWarnings({
+ "java:S3776" // Type-dispatch chain; splitting into per-type helpers would obscure the single-purpose intent.
+ })
+ private static Object emptyValueFor(Class<?> propertyClass, ClassMeta<?> propertyMeta) {
+ if (propertyClass == null || propertyClass == Object.class)
+ return null;
+ var emptyOpt = emptyOptional(propertyClass, propertyMeta);
+ if (emptyOpt != null)
+ return emptyOpt;
+ if (String.class.equals(propertyClass) || CharSequence.class.equals(propertyClass))
+ return "";
+ if (propertyClass.isArray())
+ return java.lang.reflect.Array.newInstance(propertyClass.getComponentType(), 0);
+ if (List.class.isAssignableFrom(propertyClass) || Collection.class.equals(propertyClass)
+ || Iterable.class.equals(propertyClass))
+ return new ArrayList<>();
+ if (Set.class.isAssignableFrom(propertyClass))
+ return new LinkedHashSet<>();
+ if (Map.class.isAssignableFrom(propertyClass))
+ return new LinkedHashMap<>();
+ // Primitive defaults — Java auto-unbox handles boxed-type assignment.
+ if (propertyClass.isPrimitive()) {
+ if (boolean.class.equals(propertyClass)) return Boolean.FALSE;
+ if (byte.class.equals(propertyClass)) return Byte.valueOf((byte) 0);
+ if (short.class.equals(propertyClass)) return Short.valueOf((short) 0);
+ if (char.class.equals(propertyClass)) return Character.valueOf((char) 0);
+ if (int.class.equals(propertyClass)) return Integer.valueOf(0);
+ if (long.class.equals(propertyClass)) return Long.valueOf(0L);
+ if (float.class.equals(propertyClass)) return Float.valueOf(0f);
+ if (double.class.equals(propertyClass)) return Double.valueOf(0d);
+ }
+ // Other reference types — no canonical empty sentinel; fall back to null (the LEAVE behavior).
+ return null;
+ }
+
+ /**
+ * {@link Nulls#DEFAULT} coercion — returns the property's value on a fresh no-arg-constructed reference
+ * instance of the owning bean. When no reference instance can be built (no accessible no-arg ctor, ctor
+ * throws), falls back to {@link Nulls#LEAVE} behavior.
+ */
+ @SuppressWarnings({
+ "java:S1166" // RuntimeException intentionally swallowed; the documented contract is "fall back to LEAVE".
+ })
+ private static Object defaultValueFor(BeanPropertyMeta.Builder b, Class<?> propertyClass, ClassMeta<?> propertyMeta, BeanSession session) {
+ if (! (session instanceof MarshallingSession ms))
+ return coerceLeave(null, propertyClass, propertyMeta);
+ var ownerInfo = owningClass(b);
+ if (ownerInfo == null)
+ return coerceLeave(null, propertyClass, propertyMeta);
+ var ownerMeta = ms.getClassMeta(ownerInfo.inner());
+ Optional<?> ref = ownerMeta.getProperty(NULLS_DEFAULT_REFERENCE_CACHE_KEY, cm -> {
+ var instance = BeanInstantiator.createOrNull(cm.inner());
+ return instance == null ? NO_DEFAULT_REFERENCE : instance;
+ });
+ var owner = ref.orElse(null);
+ if (owner == null || owner == NO_DEFAULT_REFERENCE)
+ return coerceLeave(null, propertyClass, propertyMeta);
+ try {
+ var beanMap = ms.toBeanMap(owner);
+ var prop = beanMap.getMeta().getPropertyMeta(b.name);
+ if (prop == null)
+ return coerceLeave(null, propertyClass, propertyMeta);
+ return prop.get(beanMap, b.name);
+ } catch (RuntimeException e) {
+ return coerceLeave(null, propertyClass, propertyMeta);
+ }
+ }
+
+ private static boolean isEmptyOptional(Object o) {
+ if (o instanceof Optional<?> opt) return opt.isEmpty();
+ if (o instanceof OptionalInt oi) return oi.isEmpty();
+ if (o instanceof OptionalLong ol) return ol.isEmpty();
+ if (o instanceof OptionalDouble od) return od.isEmpty();
+ return false;
+ }
+
/**
* Installs {@code @Schema}-driven validation transforms on a {@link BeanPropertyMeta.Builder}.
*
@@ -453,6 +647,8 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
b.swap = enumSwap(mp.enumFormat(), propertyClass);
if (UUID.class.equals(propertyClass) && mp.uuidFormat() != UuidFormat.NOT_SET)
b.swap = uuidSwap(mp.uuidFormat());
+ if (BitSet.class.equals(propertyClass) && mp.bitSetFormat() != BitSetFormat.NOT_SET)
+ b.swap = bitSetSwap(mp.bitSetFormat());
if (isBigNumberType(propertyClass) && mp.bigNumberFormat() != BigNumberFormat.NOT_SET)
b.swap = bigNumberSwap(mp.bigNumberFormat(), propertyClass);
if (isBooleanType(propertyClass) && mp.booleanFormat() != BooleanFormat.NOT_SET)
@@ -493,6 +689,8 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
b.swap = enumSwap(m.enumFormat(), propertyClass);
else if (UUID.class.equals(propertyClass) && m.uuidFormat() != UuidFormat.NOT_SET)
b.swap = uuidSwap(m.uuidFormat());
+ else if (BitSet.class.equals(propertyClass) && m.bitSetFormat() != BitSetFormat.NOT_SET)
+ b.swap = bitSetSwap(m.bitSetFormat());
else if (isBigNumberType(propertyClass) && m.bigNumberFormat() != BigNumberFormat.NOT_SET)
b.swap = bigNumberSwap(m.bigNumberFormat(), propertyClass);
else if (isBooleanType(propertyClass) && m.booleanFormat() != BooleanFormat.NOT_SET)
@@ -542,6 +740,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
// default — UUID.toString()). At STANDARD / NOT_SET, the serializer's natural String-conversion
// path already produces the right wire form.
b.swap = uuidSwap(bc.getUuidFormat());
+ else if (BitSet.class.equals(propertyClass))
+ // BitSet has no natural String round-trip (BitSet.toString() is not parseable), so install the
+ // context-level swap unconditionally — every configured format (INDICES default included)
+ // produces a parseable textual wire form.
+ b.swap = bitSetSwap(bc.getBitSetFormat());
else if (isBigNumberType(propertyClass) && bc.getBigNumberFormat() != BigNumberFormat.NUMBER && bc.getBigNumberFormat() != BigNumberFormat.NOT_SET)
// Install context-level swap only for STRING / AUTO. At NUMBER / NOT_SET, BigInteger /
// BigDecimal are already emitted as bare numeric tokens by every text serializer.
@@ -877,6 +1080,20 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
};
}
+ private static ObjectSwap<?,?> bitSetSwap(BitSetFormat format) {
+ return new ObjectSwap<BitSet,String>(BitSet.class, String.class) {
+ @Override /* ObjectSwap */
+ public String swap(MarshallingSession session, BitSet o) {
+ return BitSetFormat.format(o, format);
+ }
+
+ @Override /* ObjectSwap */
+ public BitSet unswap(MarshallingSession session, String o, ClassMeta<?> hint) {
+ return BitSetFormat.parse(o, format);
+ }
+ };
+ }
+
@SuppressWarnings({
"java:S3776" // Cognitive-complexity threshold is advisory; consolidated BigInteger/BigDecimal dispatch keeps JIT inlining effective per AGENTS.md policy.
})
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java
index 8c063de659..dc450d5eb0 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java
@@ -213,6 +213,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
private static final String PROP_binaryFormat = "binaryFormat";
private static final String PROP_enumFormat = "enumFormat";
private static final String PROP_uuidFormat = "uuidFormat";
+ private static final String PROP_bitSetFormat = "bitSetFormat";
private static final String PROP_bigNumberFormat = "bigNumberFormat";
private static final String PROP_booleanFormat = "booleanFormat";
private static final String PROP_floatFormat = "floatFormat";
@@ -290,6 +291,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
private BinaryFormat binaryFormat;
private EnumFormat enumFormat;
private UuidFormat uuidFormat;
+ private BitSetFormat bitSetFormat;
private BigNumberFormat bigNumberFormat;
private BooleanFormat booleanFormat;
private FloatFormat floatFormat;
@@ -348,6 +350,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
binaryFormat = env("MarshallingContext.binaryFormat", BinaryFormat.NOT_SET);
enumFormat = env("MarshallingContext.enumFormat", EnumFormat.TO_STRING);
uuidFormat = env("MarshallingContext.uuidFormat", UuidFormat.STANDARD);
+ bitSetFormat = env("MarshallingContext.bitSetFormat", BitSetFormat.INDICES);
bigNumberFormat = env("MarshallingContext.bigNumberFormat", BigNumberFormat.NUMBER);
booleanFormat = env("MarshallingContext.booleanFormat", BooleanFormat.TRUE_FALSE);
floatFormat = env("MarshallingContext.floatFormat", FloatFormat.NaN_AS_NULL);
@@ -403,6 +406,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
binaryFormat = copyFrom.binaryFormat;
enumFormat = copyFrom.enumFormat;
uuidFormat = copyFrom.uuidFormat;
+ bitSetFormat = copyFrom.bitSetFormat;
bigNumberFormat = copyFrom.bigNumberFormat;
booleanFormat = copyFrom.booleanFormat;
floatFormat = copyFrom.floatFormat;
@@ -461,6 +465,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
binaryFormat = copyFrom.binaryFormat;
enumFormat = copyFrom.enumFormat;
uuidFormat = copyFrom.uuidFormat;
+ bitSetFormat = copyFrom.bitSetFormat;
bigNumberFormat = copyFrom.bigNumberFormat;
booleanFormat = copyFrom.booleanFormat;
floatFormat = copyFrom.floatFormat;
@@ -2355,6 +2360,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
binaryFormat,
enumFormat,
uuidFormat,
+ bitSetFormat,
bigNumberFormat,
booleanFormat,
floatFormat,
@@ -3635,6 +3641,20 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
return this;
}
+ /**
+ * BitSet wire format.
+ *
+ * <p>
+ * Controls how {@link BitSet} values are written to text-based wire formats.
+ *
+ * @param value The value for this setting.
+ * @return This object.
+ */
+ public Builder bitSetFormat(BitSetFormat value) {
+ bitSetFormat = value == null ? BitSetFormat.INDICES : value;
+ return this;
+ }
+
/**
* Big-number wire format.
*
@@ -4045,6 +4065,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
private final BinaryFormat binaryFormat;
private final EnumFormat enumFormat;
private final UuidFormat uuidFormat;
+ private final BitSetFormat bitSetFormat;
private final BigNumberFormat bigNumberFormat;
private final BooleanFormat booleanFormat;
private final FloatFormat floatFormat;
@@ -4103,6 +4124,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
binaryFormat = builder.binaryFormat;
enumFormat = builder.enumFormat;
uuidFormat = builder.uuidFormat;
+ bitSetFormat = builder.bitSetFormat;
bigNumberFormat = builder.bigNumberFormat;
booleanFormat = builder.booleanFormat;
floatFormat = builder.floatFormat;
@@ -5309,6 +5331,13 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
*/
public final UuidFormat getUuidFormat() { return uuidFormat; }
+ /**
+ * BitSet wire format.
+ *
+ * @return The BitSet wire format.
+ */
+ public final BitSetFormat getBitSetFormat() { return bitSetFormat; }
+
/**
* Big-number wire format.
*
@@ -5457,13 +5486,14 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
.a(PROP_binaryFormat, binaryFormat)
.a(PROP_enumFormat, enumFormat)
.a(PROP_uuidFormat, uuidFormat)
+ .a(PROP_bitSetFormat, bitSetFormat)
.a(PROP_bigNumberFormat, bigNumberFormat)
.a(PROP_booleanFormat, booleanFormat)
.a(PROP_floatFormat, floatFormat)
.a(PROP_currencyFormat, currencyFormat)
- .a(PROP_classFormat, classFormat)
- .a(PROP_classLoader, classLoader)
- .a(PROP_useInterfaceProxies, useInterfaceProxies)
+ .a(PROP_classFormat, classFormat)
+ .a(PROP_classLoader, classLoader)
+ .a(PROP_useInterfaceProxies, useInterfaceProxies)
.a(PROP_useJavaBeanIntrospector, useJavaBeanIntrospector)
.a(PROP_validateSchema, validateSchema);
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java
index bb103a06c4..5c4faa2270 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java
@@ -2960,6 +2960,17 @@ public abstract class MarshallingContextable extends Context {
return self();
}
+ /**
+ * BitSet wire format.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public SELF bitSetFormat(BitSetFormat value) {
+ bcBuilder.bitSetFormat(value);
+ return self();
+ }
+
/**
* Big-number wire format.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java
index cef33ade8a..615419cebf 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java
@@ -245,6 +245,12 @@ public class MarshallingTraverseSession extends MarshallingSession {
return null;
if (o instanceof Optional<?> o2)
return getOptionalValue(o2.orElse(null));
+ if (o instanceof OptionalInt o2)
+ return o2.isPresent() ? Integer.valueOf(o2.getAsInt()) : null;
+ if (o instanceof OptionalLong o2)
+ return o2.isPresent() ? Long.valueOf(o2.getAsLong()) : null;
+ if (o instanceof OptionalDouble o2)
+ return o2.isPresent() ? Double.valueOf(o2.getAsDouble()) : null;
return o;
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Nulls.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Nulls.java
new file mode 100644
index 0000000000..fce8a3bba2
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Nulls.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshall;
+
+import java.util.*;
+
+/**
+ * Per-property null-coercion modes consulted by parsers when an explicit {@code null} (or absent
+ * {@link Optional}) is encountered on the wire.
+ *
+ * <p>
+ * Juneau's analog of Jackson's {@code com.fasterxml.jackson.annotation.Nulls}. The selected mode
+ * decides what the parser does <em>at the moment of setting</em> the bean property — it does not
+ * affect serialization.
+ *
+ * <p>
+ * Precedence:
+ * <ol>
+ * <li>Per-property {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)}.
+ * <li>Context-level default on {@link org.apache.juneau.marshall.parser.Parser.Builder#nulls(Nulls)}.
+ * <li>{@link #LEAVE} when neither is set.
+ * </ol>
+ *
+ * <h5 class='section'>Optional contract:</h5>
+ * <p>
+ * For an {@link Optional}-typed property, {@link #EMPTY} and {@link #DEFAULT} both resolve to
+ * {@link Optional#empty()} — never a bare {@code null} inside the {@link Optional}. The same rule
+ * applies to {@link OptionalInt}, {@link OptionalLong}, and {@link OptionalDouble}.
+ *
+ * @since 10.0.0
+ */
+public enum Nulls {
+
+ /** Sentinel meaning "no value configured" — falls through to the next-higher precedence level. */
+ NOT_SET,
+
+ /**
+ * Pass the {@code null} value through to the setter unchanged.
+ *
+ * <p>
+ * For an {@link Optional}-typed property this resolves to {@link Optional#empty()} per the shared
+ * Optional contract — never a bare {@code null} inside the {@link Optional}.
+ *
+ * <p>
+ * This is the default behavior when no policy is configured.
+ */
+ LEAVE,
+
+ /**
+ * Substitute the type's "empty" value:
+ * <ul>
+ * <li>{@link String} → {@code ""}
+ * <li>{@link java.util.Collection} → empty collection (preserves declared type when possible)
+ * <li>{@link java.util.Map} → empty map
+ * <li>Array → zero-length array of the declared element type
+ * <li>{@link Optional} / {@link OptionalInt} / {@link OptionalLong} / {@link OptionalDouble} →
+ * the corresponding {@code empty()} sentinel
+ * <li>Primitive — the Java primitive default ({@code 0}, {@code false}, {@code (char)0})
+ * <li>Other reference types — falls back to {@link #LEAVE}
+ * </ul>
+ */
+ EMPTY,
+
+ /**
+ * Substitute the bean-constructed default for this property — the value the property holds on a
+ * fresh no-arg-constructed instance of the bean.
+ *
+ * <p>
+ * The reference instance is built once per bean class (and cached on its {@code ClassMeta}). When
+ * no reference instance can be built (no accessible no-arg ctor / constructor throws), this mode
+ * silently falls back to {@link #LEAVE}.
+ */
+ DEFAULT,
+
+ /**
+ * Do not call the setter at all.
+ *
+ * <p>
+ * Any value already present on the bean (typically the bean's own field-initializer value) is
+ * preserved. This is useful for properties whose absence on the wire should be treated as "no
+ * change" rather than "reset to null".
+ */
+ SKIP;
+}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java
index 3e964feaa3..954a6ff264 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java
@@ -144,7 +144,7 @@ public class HjsonSerializerSession extends WriterSerializerSession implements R
var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
writeKey(out, key, i, first);
serializeAnything(out, value, cMeta, key, pMeta);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java
index 5fcbe6abe9..f16212fee5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java
@@ -146,7 +146,7 @@ public class HoconSerializerSession extends WriterSerializerSession implements R
var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
var isObject = cMeta.isBean() || cMeta.isMap();
writeKeyPrefix(out, key, i, first);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
index 1f5d358437..1d05f251c1 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
@@ -456,7 +456,7 @@ public class HtmlSerializerSession extends XmlSerializerSession {
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
String link = null;
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java
index ec3c5bcc58..a63f56c3ac 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java
@@ -134,7 +134,7 @@ public class IniSerializerSession extends WriterSerializerSession implements Rec
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
var aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java
index a759a56ca5..a5ca84d632 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java
@@ -333,10 +333,9 @@ public class JcsSerializerSession extends JsonSerializerSession {
Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
m.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
- var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
entries.add(new BeanProp(pMeta, key, value));
});
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java
index 7944394e74..39beff3ab5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java
@@ -168,7 +168,7 @@ public class JsonSerializerSession extends WriterSerializerSession implements To
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
addComma.ifSet(() -> out.append(',').smi(i)).set();
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java
index f83ad48cd0..784cbd31e6 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java
@@ -148,6 +148,7 @@ public class Parser extends MarshallingContextable {
private static final String PROP_autoCloseStreams = "autoCloseStreams";
private static final String PROP_debugOutputLines = "debugOutputLines";
private static final String PROP_listener = "listener";
+ private static final String PROP_nulls = "nulls";
private static final String PROP_trimStrings = "trimStrings";
private static final String PROP_unbuffered = "unbuffered";
@@ -165,6 +166,7 @@ public class Parser extends MarshallingContextable {
private Class<? extends ParserListener> listener;
private int debugOutputLines;
private String consumes;
+ private Nulls nulls;
/**
* Constructor, default settings.
@@ -176,6 +178,7 @@ public class Parser extends MarshallingContextable {
debugOutputLines = env("Parser.debugOutputLines", 5);
listener = null;
consumes = null;
+ nulls = Nulls.NOT_SET;
}
/**
@@ -191,6 +194,7 @@ public class Parser extends MarshallingContextable {
debugOutputLines = copyFrom.debugOutputLines;
listener = copyFrom.listener;
consumes = copyFrom.consumes;
+ nulls = copyFrom.nulls;
}
/**
@@ -206,6 +210,7 @@ public class Parser extends MarshallingContextable {
debugOutputLines = copyFrom.debugOutputLines;
listener = copyFrom.listener;
consumes = copyFrom.consumes;
+ nulls = copyFrom.nulls;
}
/**
@@ -326,7 +331,8 @@ public class Parser extends MarshallingContextable {
unbuffered,
debugOutputLines,
listener,
- consumes
+ consumes,
+ nulls
);
// @formatter:on
}
@@ -387,6 +393,43 @@ public class Parser extends MarshallingContextable {
return self();
}
+ /**
+ * Sets the default per-property null-coercion policy applied during parsing.
+ *
+ * <p>
+ * Juneau's analog of Jackson's
+ * <c>ObjectMapper.setDefaultSetterInfo(JsonSetter.Value.forValueNulls(...))</c>.
+ * Applied as the fallback when no per-property
+ * {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)} is configured.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Substitute the empty value for any property whose wire value is null.</jc>
+ * ReaderParser <jv>parser</jv> = JsonParser
+ * .<jsm>create</jsm>()
+ * .nulls(Nulls.<jsf>EMPTY</jsf>)
+ * .build();
+ * </p>
+ *
+ * @param value The new value for this property.
+ * <br>{@code null} or {@link Nulls#NOT_SET} disables the default (each property falls back to
+ * {@link Nulls#LEAVE}).
+ * @return This object.
+ * @since 10.0.0
+ */
+ public SELF nulls(Nulls value) {
+ nulls = value == null ? Nulls.NOT_SET : value;
+ return self();
+ }
+
+ /**
+ * Returns the current context-level {@link Nulls} default configured on this builder.
+ *
+ * @return The current value (never <jk>null</jk>; {@link Nulls#NOT_SET} when unconfigured).
+ * @since 10.0.0
+ */
+ public Nulls getNulls() { return nulls == null ? Nulls.NOT_SET : nulls; }
+
/**
* Trim parsed strings.
*
@@ -556,6 +599,7 @@ public class Parser extends MarshallingContextable {
protected final int debugOutputLines;
protected final Class<? extends ParserListener> listener;
protected final String consumes;
+ protected final Nulls nulls;
private final List<MediaType> consumesArray;
/**
@@ -572,6 +616,7 @@ public class Parser extends MarshallingContextable {
listener = builder.listener;
trimStrings = builder.trimStrings;
unbuffered = builder.unbuffered;
+ nulls = builder.nulls == null ? Nulls.NOT_SET : builder.nulls;
String[] consumesParts = splita(nn(consumes) ? consumes : "");
List<MediaType> consumesList = new ArrayList<>();
@@ -980,6 +1025,16 @@ public class Parser extends MarshallingContextable {
*/
protected final boolean isTrimStrings() { return trimStrings; }
+ /**
+ * Returns the context-level {@link Nulls} default consulted at parse-time when no per-property
+ * {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)} is configured.
+ *
+ * @return The current value (never <jk>null</jk>; {@link Nulls#NOT_SET} when unconfigured).
+ * @see Parser.Builder#nulls(Nulls)
+ * @since 10.0.0
+ */
+ public final Nulls getNulls() { return nulls; }
+
/**
* Unbuffered.
*
@@ -995,6 +1050,7 @@ public class Parser extends MarshallingContextable {
.a(PROP_autoCloseStreams, autoCloseStreams)
.a(PROP_debugOutputLines, debugOutputLines)
.a(PROP_listener, listener)
+ .a(PROP_nulls, nulls)
.a(PROP_trimStrings, trimStrings)
.a(PROP_unbuffered, unbuffered);
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java
index f50ae97b68..c87b3d6971 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java
@@ -217,6 +217,36 @@ public @interface ParserConfig {
*/
String trimStrings() default "";
+ /**
+ * Default per-property null-coercion policy applied during parsing.
+ *
+ * <p>
+ * Juneau's analog of Jackson's <c>JsonSetter.Value.forValueNulls(...)</c> at the
+ * <c>ObjectMapper</c> level. Applied as the fallback when no per-property
+ * {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)} is configured.
+ *
+ * <ul class='values'>
+ * <li><js>"LEAVE"</js> (default) — pass {@code null} through unchanged.
+ * <li><js>"EMPTY"</js> — substitute the type's empty value (empty {@link String}/{@link java.util.Collection}/
+ * {@link java.util.Map}, primitive default for primitives, {@code Optional.empty()} for {@link java.util.Optional}).
+ * <li><js>"DEFAULT"</js> — substitute the bean-constructed default for the property.
+ * <li><js>"SKIP"</js> — do not call the setter at all.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/DefaultVarResolver">VarResolver.DEFAULT</a> (e.g. <js>"$C{myConfigVar}"</js>).
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jm'>{@link org.apache.juneau.marshall.parser.Parser.Builder#nulls(org.apache.juneau.marshall.Nulls)}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 10.0.0
+ */
+ String nulls() default "";
+
/**
* Unbuffered.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java
index 32f4631d0c..e0787f1c69 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java
@@ -86,6 +86,7 @@ public class ParserConfigAnnotation {
type(a.listener()).ifPresent(b::listener);
bool(a.trimStrings()).ifPresent(b::trimStrings);
bool(a.unbuffered()).ifPresent(b::unbuffered);
+ string(a.nulls()).map(Nulls::valueOf).ifPresent(b::nulls);
}
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java
index a491bac3a6..147be6ed1d 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java
@@ -69,10 +69,12 @@ public class ParserSession extends MarshallingSession {
private static final String PROP_outer = "outer";
private static final String PROP_schema = "schema";
private static final String PROP_trimStrings = "trimStrings";
+ private static final String PROP_nulls = "nulls";
private static final String PROP_ParserSession_javaMethod = "ParserSession.javaMethod";
private static final String PROP_ParserSession_outer = "ParserSession.outer";
private static final String PROP_ParserSession_schema = "ParserSession.schema";
private static final String PROP_ParserSession_trimStrings = "ParserSession.trimStrings";
+ private static final String PROP_ParserSession_nulls = "ParserSession.nulls";
// Argument name constants for assertArgNotNull
private static final String ARG_ctx = "ctx";
@@ -89,6 +91,7 @@ public class ParserSession extends MarshallingSession {
private Method javaMethod;
private Object outer;
private boolean trimStrings;
+ private Nulls nulls;
private Parser ctx;
/**
@@ -102,6 +105,7 @@ public class ParserSession extends MarshallingSession {
this.ctx = ctx;
mediaTypeDefault(ctx.getPrimaryMediaType());
trimStrings = ctx.isTrimStrings();
+ nulls = ctx.getNulls();
}
@Override
@@ -151,6 +155,8 @@ public class ParserSession extends MarshallingSession {
return schema(cvt(value, HttpPartSchema.class));
case PROP_trimStrings, PROP_ParserSession_trimStrings:
return trimStrings(cvt(value, Boolean.class));
+ case PROP_nulls, PROP_ParserSession_nulls:
+ return nulls(cvt(value, Nulls.class));
default:
super.property(key, value);
return self();
@@ -168,6 +174,19 @@ public class ParserSession extends MarshallingSession {
return self();
}
+ /**
+ * Per-session override for the parser's null-coercion policy.
+ *
+ * @param value The new value for this setting. {@code null} is treated as {@link Nulls#NOT_SET}.
+ * @return This object.
+ * @see Parser.Builder#nulls(Nulls)
+ * @since 10.0.0
+ */
+ public SELF nulls(Nulls value) {
+ nulls = value == null ? Nulls.NOT_SET : value;
+ return self();
+ }
+
/**
* HTTP-part schema.
*
@@ -262,6 +281,7 @@ public class ParserSession extends MarshallingSession {
private final Method javaMethod;
private final Object outer;
private final boolean trimStrings;
+ private final Nulls nulls;
private final Parser ctx;
private final ParserListener listener;
private final Deque<StringBuilder> sbStack;
@@ -282,6 +302,7 @@ public class ParserSession extends MarshallingSession {
outer = builder.outer;
schema = builder.schema;
trimStrings = builder.trimStrings;
+ nulls = builder.nulls == null ? Nulls.NOT_SET : builder.nulls;
listener = BeanInstantiator.createOrNull(ctx.getListener());
sbStack = new ArrayDeque<>();
}
@@ -737,7 +758,13 @@ public class ParserSession extends MarshallingSession {
if (BeanSupplier.class.isAssignableFrom(type.inner()) && !BeanChannel.class.isAssignableFrom(type.inner()))
throw new ParseException(this, "BeanSupplier cannot be used as a parser target. Use BeanConsumer or BeanChannel for round-trip support.");
try {
- return doParse(pipe, type);
+ var r = doParse(pipe, type);
+ // Primitive optionals (OptionalInt/Long/Double) are parsed as first-class optionals — the format
+ // parsers wrap the element in a generic Optional, so coerce the top-level result to the matching
+ // primitive-optional type (mirrors the Optional<T> contract; absent/null already resolved to empty).
+ if (type.isPrimitiveOptional() && ! type.inner().isInstance(r))
+ return convertToType(r, type);
+ return r;
} catch (ParseException | IOException e) {
throw e;
} catch (@SuppressWarnings("unused") StackOverflowError e) {
@@ -1145,6 +1172,16 @@ public class ParserSession extends MarshallingSession {
*/
protected final boolean isTrimStrings() { return trimStrings; }
+ /**
+ * Returns the effective {@link Nulls} default for this session — the per-session override (set via
+ * {@link ParserSession.Builder#nulls(Nulls)}) when present, otherwise the parser context's
+ * {@link Parser#getNulls()} value.
+ *
+ * @return The current session-level value (never <jk>null</jk>; {@link Nulls#NOT_SET} when unconfigured).
+ * @since 10.0.0
+ */
+ public final Nulls getNulls() { return nulls; }
+
/**
* Unbuffered.
*
@@ -1322,7 +1359,8 @@ public class ParserSession extends MarshallingSession {
.a(PROP_javaMethod, javaMethod)
.a(PROP_listener, listener)
.a(PROP_outer, outer)
- .a(PROP_trimStrings, trimStrings);
+ .a(PROP_trimStrings, trimStrings)
+ .a(PROP_nulls, nulls);
}
/**
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java
index 7d3aea39ff..4cb03de5c8 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java
@@ -261,7 +261,7 @@ public class ProtoSerializerSession extends WriterSerializerSession implements R
if (value == null)
return;
var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
var protoPMeta = ctx.getProtoBeanPropertyMeta(pMeta);
if (nn(protoPMeta) && !protoPMeta.getComment().isEmpty())
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java
index c6580e72ce..a1fe0ed5b0 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java
@@ -82,6 +82,7 @@ public class Serializer extends MarshallingTraverseContext {
private static final String PROP_addRootType = "addRootType";
private static final String PROP_keepNullProperties = "keepNullProperties";
private static final String PROP_listener = "listener";
+ private static final String PROP_nonDefault = "nonDefault";
private static final String PROP_sortCollections = "sortCollections";
private static final String PROP_sortMaps = "sortMaps";
private static final String PROP_trimEmptyCollections = "trimEmptyCollections";
@@ -105,6 +106,7 @@ public class Serializer extends MarshallingTraverseContext {
private boolean addBeanTypes;
private boolean addRootType;
private boolean keepNullProperties;
+ private boolean nonDefault;
private boolean sortCollections;
private boolean sortMaps;
private boolean trimEmptyCollections;
@@ -126,6 +128,7 @@ public class Serializer extends MarshallingTraverseContext {
addBeanTypes = env("Serializer.addBeanTypes", false);
addRootType = env("Serializer.addRootType", false);
keepNullProperties = env("Serializer.keepNullProperties", false);
+ nonDefault = env("Serializer.nonDefault", false);
sortCollections = env("Serializer.sortCollections", false);
sortMaps = env("Serializer.sortMaps", false);
trimEmptyCollections = env("Serializer.trimEmptyCollections", false);
@@ -150,6 +153,7 @@ public class Serializer extends MarshallingTraverseContext {
addBeanTypes = copyFrom.addBeanTypes;
addRootType = copyFrom.addRootType;
keepNullProperties = copyFrom.keepNullProperties;
+ nonDefault = copyFrom.nonDefault;
sortCollections = copyFrom.sortCollections;
sortMaps = copyFrom.sortMaps;
trimEmptyCollections = copyFrom.trimEmptyCollections;
@@ -174,6 +178,7 @@ public class Serializer extends MarshallingTraverseContext {
addBeanTypes = copyFrom.addBeanTypes;
addRootType = copyFrom.addRootType;
keepNullProperties = copyFrom.keepNullProperties;
+ nonDefault = copyFrom.nonDefault;
sortCollections = copyFrom.sortCollections;
sortMaps = copyFrom.sortMaps;
trimEmptyCollections = copyFrom.trimEmptyCollections;
@@ -366,6 +371,7 @@ public class Serializer extends MarshallingTraverseContext {
addBeanTypes,
addRootType,
keepNullProperties,
+ nonDefault,
sortCollections,
sortMaps,
trimEmptyCollections,
@@ -429,6 +435,87 @@ public class Serializer extends MarshallingTraverseContext {
return self();
}
+ /**
+ * Don't serialize bean properties whose values equal the default for their type.
+ *
+ * <p>
+ * When enabled, bean properties whose current value equals the type's natural default are omitted from
+ * the serialized output. This is the Juneau analog of Jackson's <c>JsonInclude.Include.NON_DEFAULT</c>
+ * inclusion mode.
+ *
+ * <p>
+ * The notion of "default" depends on the property type:
+ * <ul class='spaced-list'>
+ * <li>Primitives and primitive wrappers (<code><jk>int</jk></code>, <code>Integer</code>,
+ * <code><jk>boolean</jk></code>, <code>Boolean</code>, etc.) use the Java type default
+ * (<c>0</c>, <c>false</c>, <code>'\0'</code>, <jk>null</jk>).
+ * <li>Bean properties on bean-typed property values use a <i>reference instance</i> of the bean type —
+ * instantiated once via the no-arg constructor and cached on the {@link ClassMeta} — and the
+ * property is omitted when its current value equals that reference's value.
+ * <li>When no reference instance can be built (no accessible no-arg constructor, or instantiation
+ * throws), <c>nonDefault</c> is silently skipped for that bean — the property is emitted normally
+ * rather than failing the serialize.
+ * </ul>
+ *
+ * <p>
+ * <b>Equality semantics:</b> value-equals-default uses {@link Objects#equals}, with numeric values
+ * compared by mathematical value (BigDecimal.compareTo-style) so <c>1.0</c> equals <c>1</c>,
+ * <c>0.0</c> equals <c>-0.0</c>, and <code>BigDecimal(<js>"1.0"</js>)</code> equals
+ * <code>BigDecimal(<js>"1"</js>)</code>.
+ *
+ * <p>
+ * <b>Precedence:</b> the serialize-side inclusion knobs short-circuit in a fixed order — first "omit" wins:
+ * <ol>
+ * <li><jk>null</jk> values (including {@link java.util.Optional#empty()}) are governed by
+ * {@link #keepNullProperties()}.
+ * <li>Non-<jk>null</jk> string values are normalized by {@link #trimStrings()}.
+ * <li>Empty collections / arrays / maps are omitted by {@link #trimEmptyCollections()} /
+ * {@link #trimEmptyMaps()}.
+ * <li>Surviving values that equal their type's default are omitted by <c>nonDefault</c>.
+ * </ol>
+ * <jk>null</jk> is resolved by <c>keepNullProperties</c>, not treated as a <c>nonDefault</c> case.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Create a serializer that omits default-valued properties.</jc>
+ * WriterSerializer <jv>serializer</jv> = JsonSerializer
+ * .<jsm>create</jsm>()
+ * .nonDefault()
+ * .build();
+ *
+ * <jc>// Our bean to serialize.</jc>
+ * <jk>public class</jk> MyBean {
+ * <jk>public int</jk> <jf>count</jf>; <jc>// Default 0; omitted unless set to non-zero.</jc>
+ * <jk>public</jk> String <jf>name</jf> = <js>"x"</js>; <jc>// Bean-default is "x"; only omitted when value equals "x".</jc>
+ * }
+ *
+ * <jc>// Produces "{}" because both fields equal their defaults.</jc>
+ * String <jv>json</jv> = <jv>serializer</jv>.serialize(<jk>new</jk> MyBean());
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/InclusionAndNullHandling">Inclusion and null handling</a>
+ * </ul>
+ *
+ * @return This object.
+ * @since 10.0.0
+ */
+ public SELF nonDefault() {
+ return nonDefault(true);
+ }
+
+ /**
+ * Same as {@link #nonDefault()} but allows you to explicitly specify the value.
+ *
+ * @param value The value for this setting.
+ * @return This object.
+ * @since 10.0.0
+ */
+ public SELF nonDefault(boolean value) {
+ nonDefault = value;
+ return self();
+ }
+
/**
* Serializer listener.
*
@@ -934,6 +1021,7 @@ public class Serializer extends MarshallingTraverseContext {
protected final boolean addBeanTypes;
protected final boolean addRootType;
protected final boolean keepNullProperties;
+ protected final boolean nonDefault;
protected final boolean sortCollections;
protected final boolean sortMaps;
protected final boolean trimEmptyCollections;
@@ -962,6 +1050,7 @@ public class Serializer extends MarshallingTraverseContext {
addRootType = builder.addRootType;
keepNullProperties = builder.keepNullProperties;
listener = builder.listener;
+ nonDefault = builder.nonDefault;
produces = builder.produces;
sortCollections = builder.sortCollections;
sortMaps = builder.sortMaps;
@@ -1205,6 +1294,16 @@ public class Serializer extends MarshallingTraverseContext {
*/
protected final boolean isKeepNullProperties() { return keepNullProperties; }
+ /**
+ * Don't serialize bean properties whose values equal the default for their type.
+ *
+ * @see Serializer.Builder#nonDefault()
+ * @return
+ * <jk>true</jk> if bean properties whose value equals the type / bean-constructed default are omitted.
+ * @since 10.0.0
+ */
+ protected final boolean isNonDefault() { return nonDefault; }
+
/**
* Sort arrays and collections alphabetically.
*
@@ -1257,6 +1356,7 @@ public class Serializer extends MarshallingTraverseContext {
.a(PROP_addRootType, addRootType)
.a(PROP_keepNullProperties, keepNullProperties)
.a(PROP_listener, listener)
+ .a(PROP_nonDefault, nonDefault)
.a(PROP_sortCollections, sortCollections)
.a(PROP_sortMaps, sortMaps)
.a(PROP_trimEmptyCollections, trimEmptyCollections)
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java
index 62e1b7f9f4..57d3f88b57 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java
@@ -360,6 +360,37 @@ public @interface SerializerConfig {
*/
String maxIndent() default "";
+ /**
+ * Don't serialize properties whose value equals the type default.
+ *
+ * <p>
+ * If <js>"true"</js>, bean properties whose current value equals the corresponding default — the Java type default
+ * for primitives/boxed types, or the value returned by the bean's own no-arg constructor for bean properties — will
+ * be omitted from the output. This is Juneau's analog of Jackson's
+ * <c>JsonInclude.Include.NON_DEFAULT</c>.
+ *
+ * <p>
+ * Numeric values are compared by value (<c>1.0</c> equals <c>1</c>; <c>0.0</c> equals <c>-0.0</c>).
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js>
+ * <li><js>"false"</js> (default)
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/DefaultVarResolver">VarResolver.DEFAULT</a> (e.g. <js>"$C{myConfigVar}"</js>).
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jm'>{@link org.apache.juneau.marshall.serializer.Serializer.Builder#nonDefault()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 10.0.0
+ */
+ String nonDefault() default "";
+
/**
* Quote character.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java
index 51cf56e403..612f7cb458 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java
@@ -84,6 +84,7 @@ public class SerializerConfigAnnotation {
bool(a.addBeanTypes()).ifPresent(b::addBeanTypes);
bool(a.addRootType()).ifPresent(b::addRootType);
bool(a.keepNullProperties()).ifPresent(b::keepNullProperties);
+ bool(a.nonDefault()).ifPresent(b::nonDefault);
type(a.listener()).ifPresent(b::listener);
bool(a.sortCollections()).ifPresent(b::sortCollections);
bool(a.sortMaps()).ifPresent(b::sortMaps);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java
index 221169a56a..592158229a 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java
@@ -24,6 +24,7 @@ import static org.apache.juneau.commons.utils.Utils.*;
import java.io.*;
import java.lang.reflect.*;
+import java.math.*;
import java.net.*;
import java.text.*;
import java.time.*;
@@ -89,6 +90,7 @@ public class SerializerSession extends MarshallingTraverseSession {
private static final String PROP_uriContext = "uriContext";
private static final String PROP_uriResolver = "uriResolver";
private static final String PROP_keepNullProperties = "keepNullProperties";
+ private static final String PROP_nonDefault = "nonDefault";
private static final String PROP_trimStrings = "trimStrings";
private static final String PROP_addBeanTypes = "addBeanTypes";
private static final String PROP_addRootType = "addRootType";
@@ -101,6 +103,7 @@ public class SerializerSession extends MarshallingTraverseSession {
private static final String PROP_SerializerSession_schema = "SerializerSession.schema";
private static final String PROP_SerializerSession_uriContext = "SerializerSession.uriContext";
private static final String PROP_SerializerSession_keepNullProperties = "SerializerSession.keepNullProperties";
+ private static final String PROP_SerializerSession_nonDefault = "SerializerSession.nonDefault";
private static final String PROP_SerializerSession_trimStrings = "SerializerSession.trimStrings";
private static final String PROP_SerializerSession_addBeanTypes = "SerializerSession.addBeanTypes";
private static final String PROP_SerializerSession_addRootType = "SerializerSession.addRootType";
@@ -125,6 +128,7 @@ public class SerializerSession extends MarshallingTraverseSession {
private UriContext uriContext;
private VarResolverSession resolver;
private boolean keepNullProperties;
+ private boolean nonDefault;
private boolean trimStrings;
private boolean addBeanTypes;
private boolean addRootType;
@@ -145,6 +149,7 @@ public class SerializerSession extends MarshallingTraverseSession {
mediaTypeDefault(ctx.getResponseContentType());
uriContext = ctx.getUriContext();
keepNullProperties = ctx.isKeepNullProperties();
+ nonDefault = ctx.isNonDefault();
trimStrings = ctx.isTrimStrings();
addBeanTypes = ctx.isAddBeanTypes();
addRootType = ctx.isAddRootType();
@@ -190,6 +195,8 @@ public class SerializerSession extends MarshallingTraverseSession {
return uriContext(cvt(value, UriContext.class));
case PROP_keepNullProperties, PROP_SerializerSession_keepNullProperties:
return keepNullProperties(cvt(value, Boolean.class));
+ case PROP_nonDefault, PROP_SerializerSession_nonDefault:
+ return nonDefault(cvt(value, Boolean.class));
case PROP_trimStrings, PROP_SerializerSession_trimStrings:
return trimStrings(cvt(value, Boolean.class));
case PROP_addBeanTypes, PROP_SerializerSession_addBeanTypes:
@@ -289,6 +296,18 @@ public class SerializerSession extends MarshallingTraverseSession {
return self();
}
+ /**
+ * Non-default inclusion.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ * @since 10.0.0
+ */
+ public SELF nonDefault(boolean value) {
+ nonDefault = value;
+ return self();
+ }
+
/**
* Trim strings.
*
@@ -456,6 +475,7 @@ public class SerializerSession extends MarshallingTraverseSession {
private final UriResolver uriResolver;
private VarResolverSession vrs;
private final boolean keepNullProperties;
+ private final boolean nonDefault;
private final boolean trimStrings;
private final boolean addBeanTypes;
private final boolean addRootType;
@@ -479,6 +499,7 @@ public class SerializerSession extends MarshallingTraverseSession {
vrs = builder.resolver;
listener = BeanInstantiator.createOrNull(ctx.getListener());
keepNullProperties = builder.keepNullProperties;
+ nonDefault = builder.nonDefault;
trimStrings = builder.trimStrings;
addBeanTypes = builder.addBeanTypes;
addRootType = builder.addRootType;
@@ -544,6 +565,157 @@ public class SerializerSession extends MarshallingTraverseSession {
return false;
}
+ /**
+ * Per-bean-property variant of {@link #canIgnoreValue(ClassMeta, String, Object)}.
+ *
+ * <p>
+ * Applies the same standard inclusion rules ({@link Serializer.Builder#keepNullProperties() keepNullProperties},
+ * {@link Serializer.Builder#trimEmptyCollections() trimEmptyCollections} /
+ * {@link Serializer.Builder#trimEmptyMaps() trimEmptyMaps},
+ * recursion detection), then additionally:
+ * <ul>
+ * <li>Treats {@link java.util.Optional#empty()} (and its primitive cousins
+ * {@link java.util.OptionalInt}/{@link java.util.OptionalLong}/{@link java.util.OptionalDouble}) as a
+ * <jk>null</jk> property value for inclusion purposes — the shared Optional/absent contract.
+ * <li>Applies {@link Serializer.Builder#nonDefault() nonDefault} by comparing the surviving value against a
+ * cached <i>reference instance</i> of the property's containing bean. When no reference instance can be
+ * built (no accessible no-arg ctor / instantiation throws), {@code nonDefault} is silently skipped for
+ * that bean — the property is emitted normally rather than failing.
+ * </ul>
+ *
+ * <p>
+ * Precedence (first "omit" wins): (1) <jk>null</jk> / empty Optional, (2) trimEmpty*, (3) value-equals-default.
+ *
+ * @param pMeta The bean property meta for the value being considered. Must not be <jk>null</jk>.
+ * @param attrName The bean attribute name. Must not be <jk>null</jk>.
+ * @param value The current property value.
+ * @return <jk>true</jk> if the property should be omitted from the output.
+ * @throws SerializeException If recursion occurred.
+ * @since 10.0.0
+ */
+ public final boolean canIgnoreValue(BeanPropertyMeta pMeta, String attrName, Object value) throws SerializeException {
+ var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
+ // Resolve Optional<empty> / OptionalInt/Long/Double.isPresent()==false to null for the inclusion check.
+ var resolved = unwrapEmptyOptional(value);
+ if (canIgnoreValue(cMeta, attrName, resolved))
+ return true;
+ if (isNonDefault() && resolved != null)
+ return equalsBeanPropertyDefault(pMeta, attrName, resolved);
+ return false;
+ }
+
+ /**
+ * Resolves an {@link Optional#empty()} (or the empty form of an {@link OptionalInt}/{@link OptionalLong}/
+ * {@link OptionalDouble}) to <jk>null</jk>, leaving all other values unchanged.
+ *
+ * <p>
+ * Nested {@code Optional<Optional<X>>} values are unwrapped recursively.
+ *
+ * @param value The value to resolve.
+ * @return The resolved value (may be <jk>null</jk>).
+ */
+ private static Object unwrapEmptyOptional(Object value) {
+ if (value == null)
+ return null;
+ if (value instanceof Optional<?> o)
+ return o.isEmpty() ? null : unwrapEmptyOptional(o.orElse(null));
+ if (value instanceof OptionalInt oi)
+ return oi.isEmpty() ? null : Integer.valueOf(oi.getAsInt());
+ if (value instanceof OptionalLong ol)
+ return ol.isEmpty() ? null : Long.valueOf(ol.getAsLong());
+ if (value instanceof OptionalDouble od)
+ return od.isEmpty() ? null : Double.valueOf(od.getAsDouble());
+ return value;
+ }
+
+ /**
+ * Returns <jk>true</jk> when {@code value} equals the property's bean-constructed default per the
+ * {@code nonDefault} contract.
+ *
+ * <p>
+ * Resolves (and caches per-{@link ClassMeta}) the containing bean's reference instance. Returns
+ * <jk>false</jk> when no reference instance can be built (which signals "skip nonDefault for this bean" per
+ * the documented contract).
+ *
+ * @param pMeta The bean-property meta.
+ * @param attrName The bean attribute name.
+ * @param value The surviving property value (non-null).
+ * @return <jk>true</jk> if the value equals the property's default.
+ */
+ private boolean equalsBeanPropertyDefault(BeanPropertyMeta pMeta, String attrName, Object value) {
+ var owning = pMeta.getBeanMeta();
+ if (owning == null)
+ return false;
+ var ownerClass = owning.getClassInfo();
+ if (ownerClass == null)
+ return false;
+ var ownerMeta = getClassMeta(ownerClass.inner());
+ var refValueHolder = ownerMeta.getProperty(NON_DEFAULT_PROPERTY_CACHE_KEY, cm -> {
+ var instance = BeanInstantiator.createOrNull(cm.inner());
+ return instance == null ? NO_REFERENCE_INSTANCE : instance;
+ });
+ var ref = refValueHolder.orElse(null);
+ if (ref == null || ref == NO_REFERENCE_INSTANCE)
+ return false;
+ // Resolve the default value via the bean's accessor surface so it composes with @MarshalledProp
+ // transforms. Wrap any throw in a guard — a bean whose getter throws on a fresh instance is just
+ // treated as "no default available", which is safer than aborting the serialize.
+ Object defaultValue;
+ try {
+ defaultValue = pMeta.get(toBeanMap(ref), attrName);
+ } catch (RuntimeException e) { // HTT — defensive: getter on reference instance throws.
+ return false;
+ }
+ var resolvedDefault = unwrapEmptyOptional(defaultValue);
+ return defaultEquals(value, resolvedDefault);
+ }
+
+ /**
+ * {@code Objects.equals} with a numeric-value shortcut so {@code 1.0} equals {@code 1},
+ * {@code BigDecimal("1.0")} equals {@code BigDecimal("1")}, and {@code 0.0}/{@code -0.0} equal each other.
+ *
+ * @param a First value.
+ * @param b Second value.
+ * @return <jk>true</jk> if the values are equal under the {@code nonDefault} contract.
+ */
+ @SuppressWarnings({
+ "java:S1244" // Float equality is intentional: caller has already narrowed to numeric short-circuit; BigDecimal compareTo handles the value-equality semantics.
+ })
+ private static boolean defaultEquals(Object a, Object b) {
+ if (a == b)
+ return true;
+ if (a == null || b == null)
+ return false;
+ if (a instanceof Number an && b instanceof Number bn) {
+ // Treat NaN as equal to NaN (Objects.equals semantics) so two NaN-typed defaults match.
+ var anan = (an instanceof Double d && Double.isNaN(d)) || (an instanceof Float f && Float.isNaN(f));
+ var bnan = (bn instanceof Double d && Double.isNaN(d)) || (bn instanceof Float f && Float.isNaN(f));
+ if (anan || bnan)
+ return anan && bnan;
+ try {
+ return toBigDecimal(an).compareTo(toBigDecimal(bn)) == 0;
+ } catch (NumberFormatException e) { // HTT — defensive: a Number subclass whose toString() isn't a valid decimal.
+ return Objects.equals(a, b);
+ }
+ }
+ return Objects.equals(a, b);
+ }
+
+ private static BigDecimal toBigDecimal(Number n) {
+ if (n instanceof BigDecimal bd)
+ return bd;
+ if (n instanceof BigInteger bi)
+ return new BigDecimal(bi);
+ if (n instanceof Float || n instanceof Double)
+ return BigDecimal.valueOf(n.doubleValue());
+ return BigDecimal.valueOf(n.longValue());
+ }
+
+ /** Cache key under which the per-{@link ClassMeta} reference instance is memoized for {@code nonDefault}. */
+ private static final String NON_DEFAULT_PROPERTY_CACHE_KEY = "SerializerSession.nonDefault.referenceInstance";
+ /** Sentinel stored when a reference instance could not be built (cached so we don't retry on every call). */
+ private static final Object NO_REFERENCE_INSTANCE = new Object();
+
/**
* Consumes each entry in the list.
*
@@ -1281,6 +1453,16 @@ public class SerializerSession extends MarshallingTraverseSession {
*/
protected final boolean isKeepNullProperties() { return keepNullProperties; }
+ /**
+ * Don't serialize bean properties whose values equal the default for their type.
+ *
+ * @see Serializer.Builder#nonDefault()
+ * @return
+ * <jk>true</jk> if bean properties whose value equals the type / bean-constructed default are omitted.
+ * @since 10.0.0
+ */
+ protected final boolean isNonDefault() { return nonDefault; }
+
/**
* Sort arrays and collections alphabetically.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
index 2c395bce5b..73e7071c49 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
@@ -170,7 +170,7 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
ClassMeta<?> cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
ClassMeta<?> aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
if (isSimpleOrInlineTable(aType, value, pMeta)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java
index 2618f25175..7d3f2dd518 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java
@@ -226,7 +226,7 @@ public class UonSerializerSession extends WriterSerializerSession implements Htt
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
addComma.ifSet(() -> out.append(',')).set();
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java
index e3ec43ce63..b8c8614d2a 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java
@@ -230,7 +230,7 @@ public class UrlEncodingSerializerSession extends UonSerializerSession {
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(sMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
if (nn(value) && shouldUseExpandedParams(pMeta)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
index 85232fff40..e19efc0ef2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
@@ -375,12 +375,12 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
return xcm.getFormat() == XMLTEXT;
}
- private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta, ClassMeta<?> cMeta) {
+ private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta) {
var key = p.getName();
var value = p.getValue();
if (nn(p.getThrown()))
onBeanGetterException(pMeta, p.getThrown());
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return opte();
return opt(new SimpleEntry<>(key, value));
}
@@ -418,7 +418,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
if (attrs.contains(n) || attrs.contains("*") || n.equals(attrsProperty)) {
var pMeta = p.getMeta();
if (pMeta.canRead()) {
- var kv = getPropertyKeyValueIfNotIgnored(p, pMeta, (ClassMeta<?>) p.getBeanInfo());
+ var kv = getPropertyKeyValueIfNotIgnored(p, pMeta);
if (kv.isEmpty())
continue;
var key = kv.get().getKey();
@@ -469,7 +469,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
if ((contentType.isCollection() && ((Collection)content).isEmpty()) || (contentType.isArray() && Array.getLength(content) == 0))
hasContent = false;
} else if (elements.contains(n) || collapsedElements.contains(n) || elements.contains("*") || collapsedElements.contains("*")) {
- var kv = getPropertyKeyValueIfNotIgnored(p, pMeta, cMeta);
+ var kv = getPropertyKeyValueIfNotIgnored(p, pMeta);
if (kv.isEmpty())
continue;
var key = kv.get().getKey();
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java
index 4336c678e8..c6021b1d49 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java
@@ -277,7 +277,7 @@ public class YamlSerializerSession extends WriterSerializerSession implements Re
if (nn(thrown))
onBeanGetterException(pMeta, thrown);
- if (canIgnoreValue(cMeta, key, value))
+ if (canIgnoreValue(pMeta, key, value))
return;
addAtt.ifSet(() -> out.yamlIndent(i)).set();
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/NullInclusionAndCoercion_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/NullInclusionAndCoercion_Test.java
new file mode 100644
index 0000000000..c7620c34e3
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/NullInclusionAndCoercion_Test.java
@@ -0,0 +1,569 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshall;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.hjson.*;
+import org.apache.juneau.marshall.hocon.*;
+import org.apache.juneau.marshall.ini.*;
+import org.apache.juneau.marshall.jcs.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.proto.*;
+import org.apache.juneau.marshall.serializer.*;
+import org.apache.juneau.marshall.toml.*;
+import org.apache.juneau.marshall.uon.*;
+import org.apache.juneau.marshall.xml.*;
+import org.apache.juneau.marshall.yaml.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for the 10.0.0 null-inclusion / null-coercion knobs and the first-class {@link BitSetFormat} /
+ * primitive-{@link Optional} handling.
+ *
+ * <p>
+ * Exercises {@code nonDefault} on the serializer side, the {@link Nulls} per-property / context-level policy on the
+ * parser side, the shared {@link Optional} contract both sides honor, the {@code MarshalledPropertyPostProcessor}
+ * null-coercion transforms, the {@link BitSetFormat} format/parse paths, and the primitive-{@link Optional}
+ * converter / {@link ClassMeta} plumbing.
+ *
+ * <p>
+ * All formats touched here ({@code json}, plus the {@code hjson/hocon/ini/jcs/proto/toml/uon/xml/yaml} families used
+ * by the per-format inclusion-branch test) live in this module, so the full matrix runs as a unit test with no
+ * dependency on the integration-tests surface.
+ */
+class NullInclusionAndCoercion_Test extends TestBase {
+
+ //====================================================================================================
+ // a. Serializer nonDefault — inclusion knob + numeric-default equality (defaultEquals / toBigDecimal)
+ //====================================================================================================
+
+ public static class A1 {
+ public int i;
+ public Integer boxed;
+ public String s;
+ public double d;
+ }
+
+ @Test void a01_nonDefault_omitsPrimitiveDefaults() throws Exception {
+ assertEquals("{}", JsonSerializer.create().nonDefault().build().serializeToString(new A1()));
+ }
+
+ @Test void a02_nonDefault_emitsNonDefaultPrimitives() throws Exception {
+ var b = new A1();
+ b.i = 5;
+ assertEquals("{\"i\":5}", JsonSerializer.create().nonDefault().build().serializeToString(b));
+ }
+
+ @Test void a03_nonDefault_disabledByDefault() throws Exception {
+ assertEquals("{\"d\":0.0,\"i\":0}", JsonSerializer.create().build().serializeToString(new A1()));
+ }
+
+ @Test void a04_nonDefault_keepNullPropertiesFirst() throws Exception {
+ assertEquals("{\"boxed\":null,\"s\":null}", JsonSerializer.create().nonDefault().keepNullProperties().build().serializeToString(new A1()));
+ }
+
+ public static class A2 {
+ public String name = "hello";
+ public int count = 7;
+ }
+
+ @Test void a05_nonDefault_usesBeanConstructedDefaults_cached() throws Exception {
+ var s = JsonSerializer.create().nonDefault().build();
+ // Serialize twice so the per-ClassMeta reference instance is resolved once then read from cache.
+ assertEquals("{}", s.serializeToString(new A2()));
+ var b = new A2();
+ b.name = "world";
+ assertEquals("{\"name\":\"world\"}", s.serializeToString(b));
+ }
+
+ // Numeric-default equality across Number subtypes — exercises defaultEquals() + toBigDecimal() branches.
+ public static class A3 {
+ public long l = 5L;
+ public float f = 1.5f;
+ public BigInteger bi = BigInteger.TEN;
+ public BigDecimal bd = new BigDecimal("1.0");
+ public double nan = Double.NaN;
+ public float fnan = Float.NaN;
+ }
+
+ @Test void a06_nonDefault_numericEquality_allMatchDefaults() throws Exception {
+ // Every field equals its bean-constructed default (Double + Float NaN both match) → all omitted.
+ assertEquals("{}", JsonSerializer.create().nonDefault().build().serializeToString(new A3()));
+ }
+
+ // Value-equal-but-not-reference-equal + String + null-default → defaultEquals non-Number/null/BigInteger paths.
+ public static class A6 {
+ public String name = "x";
+ public String nul; // reference default is null
+ public BigInteger big = new BigInteger("10"); // distinct instance from the value below
+ }
+
+ @Test void a09_nonDefault_defaultEquals_nonNumericAndNullAndBigInteger() throws Exception {
+ var b = new A6();
+ b.name = "y"; // diverges from "x" (both non-null, non-Number) → emitted
+ b.nul = "z"; // reference default null, value non-null → emitted
+ b.big = new BigInteger("10"); // value-equal but != reference → BigDecimal compare → omitted
+ var json = JsonSerializer.create().nonDefault().build().serializeToString(b);
+ assertTrue(json.contains("\"name\":\"y\""), json);
+ assertTrue(json.contains("\"nul\":\"z\""), json);
+ assertFalse(json.contains("\"big\""), json);
+ }
+
+ @Test void a07_nonDefault_numericEquality_divergent() throws Exception {
+ var b = new A3();
+ b.l = 6L;
+ b.bd = new BigDecimal("2.0");
+ b.nan = 1.0; // NaN default vs non-NaN value → emitted
+ var json = JsonSerializer.create().nonDefault().build().serializeToString(b);
+ assertTrue(json.contains("\"l\":6"), json);
+ assertTrue(json.contains("\"bd\":2.0"), json);
+ assertTrue(json.contains("\"nan\":1.0"), json);
+ assertFalse(json.contains("\"f\":"), json);
+ assertFalse(json.contains("\"bi\":"), json);
+ }
+
+ // Bean with no accessible no-arg constructor — nonDefault cannot build a reference instance, so the
+ // "no reference instance" fallback fires and every property is emitted.
+ public static class A4 {
+ public int x;
+ public A4(int x) { this.x = x; }
+ }
+
+ @Test void a08_nonDefault_noNoArgCtor_emitsEverything() throws Exception {
+ assertEquals("{\"x\":5}", JsonSerializer.create().nonDefault().build().serializeToString(new A4(5)));
+ }
+
+ //====================================================================================================
+ // b. Shared Optional contract on serialize (Optional<T> + OptionalInt/Long/Double)
+ //====================================================================================================
+
+ public static class B1 {
+ public Optional<String> a = Optional.empty();
+ public Optional<String> b = Optional.of("x");
+ public OptionalInt c = OptionalInt.empty();
+ public OptionalInt d = OptionalInt.of(7);
+ public OptionalLong e = OptionalLong.of(9L);
+ public OptionalDouble f = OptionalDouble.of(2.5);
+ }
+
+ @Test void b01_emptyOptionals_omittedByDefault() throws Exception {
+ var json = JsonSerializer.DEFAULT.serializeToString(new B1());
+ assertFalse(json.contains("\"a\":"), json);
+ assertFalse(json.contains("\"c\":"), json);
+ }
+
+ @Test void b02_presentOptionals_emittedUnwrapped() throws Exception {
+ var json = JsonSerializer.DEFAULT.serializeToString(new B1());
+ assertTrue(json.contains("\"b\":\"x\""), json);
+ assertTrue(json.contains("\"d\":7"), json);
+ assertTrue(json.contains("\"e\":9"), json);
+ assertTrue(json.contains("\"f\":2.5"), json);
+ }
+
+ @Test void b03_emptyOptionals_emittedAsNullWhenKept() throws Exception {
+ var json = JsonSerializer.create().keepNullProperties().build().serializeToString(new B1());
+ assertTrue(json.contains("\"a\":null"), json);
+ assertTrue(json.contains("\"c\":null"), json);
+ }
+
+ public static class B2 {
+ public OptionalLong e = OptionalLong.empty();
+ public OptionalDouble f = OptionalDouble.empty();
+ }
+
+ @Test void b04_emptyPrimitiveLongDouble_unwrapToNull() throws Exception {
+ // keepNullProperties forces traversal of the empty OptionalLong/OptionalDouble — exercises both the
+ // canIgnoreValue unwrap and the traverse-session getOptionalValue empty branches.
+ var json = JsonSerializer.create().keepNullProperties().build().serializeToString(new B2());
+ assertTrue(json.contains("\"e\":null"), json);
+ assertTrue(json.contains("\"f\":null"), json);
+ }
+
+ //====================================================================================================
+ // c. Shared Optional contract on parse + top-level primitive-optional coercion
+ //====================================================================================================
+
+ @Test void c01_absentAndNullOptional_resolveToEmpty() throws Exception {
+ var b = JsonParser.DEFAULT.parse("{\"a\":null,\"c\":null,\"e\":null,\"f\":null}", B1.class);
+ assertTrue(b.a.isEmpty());
+ assertTrue(b.c.isEmpty());
+ assertTrue(b.e.isEmpty());
+ assertTrue(b.f.isEmpty());
+ }
+
+ @Test void c02_presentPrimitiveOptionals_parse() throws Exception {
+ var b = JsonParser.DEFAULT.parse("{\"d\":42,\"e\":43,\"f\":4.5}", B1.class);
+ assertEquals(42, b.d.getAsInt());
+ assertEquals(43L, b.e.getAsLong());
+ assertEquals(4.5, b.f.getAsDouble());
+ }
+
+ @Test void c03_topLevelPrimitiveOptional_parse() throws Exception {
+ assertEquals(5, JsonParser.DEFAULT.parse("5", OptionalInt.class).getAsInt());
+ assertEquals(6L, JsonParser.DEFAULT.parse("6", OptionalLong.class).getAsLong());
+ assertEquals(7.5, JsonParser.DEFAULT.parse("7.5", OptionalDouble.class).getAsDouble());
+ assertTrue(JsonParser.DEFAULT.parse("null", OptionalInt.class).isEmpty());
+ }
+
+ //====================================================================================================
+ // cb. BitSet — first-class handling via BitSetFormat (round-trip, no swap registered)
+ //====================================================================================================
+
+ public static class CB1 {
+ public BitSet bits;
+ }
+
+ private static BitSet bitSet(int... indices) {
+ var b = new BitSet();
+ for (var i : indices)
+ b.set(i);
+ return b;
+ }
+
+ @Test void cb01_indices_default_roundTrip() throws Exception {
+ var bean = new CB1();
+ bean.bits = bitSet(0, 2, 5);
+ var json = JsonSerializer.DEFAULT.serializeToString(bean);
+ assertTrue(json.contains("\"bits\":\"0,2,5\""), json);
+ assertEquals(bean.bits, JsonParser.DEFAULT.parse(json, CB1.class).bits);
+ }
+
+ @Test void cb02_bits_format_roundTrip() throws Exception {
+ var s = JsonSerializer.create().bitSetFormat(BitSetFormat.BITS).build();
+ var p = JsonParser.create().bitSetFormat(BitSetFormat.BITS).build();
+ var bean = new CB1();
+ bean.bits = bitSet(0, 2, 5);
+ var json = s.serializeToString(bean);
+ assertTrue(json.contains("\"bits\":\"101001\""), json);
+ assertEquals(bean.bits, p.parse(json, CB1.class).bits);
+ }
+
+ @Test void cb03_hex_format_roundTrip() throws Exception {
+ var s = JsonSerializer.create().bitSetFormat(BitSetFormat.HEX).build();
+ var p = JsonParser.create().bitSetFormat(BitSetFormat.HEX).build();
+ var bean = new CB1();
+ bean.bits = bitSet(0, 2, 5);
+ var json = s.serializeToString(bean);
+ assertTrue(json.contains("\"bits\":\"25\""), json);
+ assertEquals(bean.bits, p.parse(json, CB1.class).bits);
+ }
+
+ public static class CB2 {
+ @MarshalledProp(bitSetFormat = BitSetFormat.BITS)
+ public BitSet bits;
+ }
+
+ @Test void cb04_perProperty_overridesContext() throws Exception {
+ var bean = new CB2();
+ bean.bits = bitSet(0, 2, 5);
+ var json = JsonSerializer.DEFAULT.serializeToString(bean);
+ assertTrue(json.contains("\"bits\":\"101001\""), json);
+ assertEquals(bean.bits, JsonParser.DEFAULT.parse(json, CB2.class).bits);
+ }
+
+ @Marshalled(bitSetFormat = BitSetFormat.HEX)
+ public static class CB3 {
+ public BitSet bits;
+ }
+
+ @Test void cb05_perClass_overridesContext() throws Exception {
+ var bean = new CB3();
+ bean.bits = bitSet(0, 2, 5);
+ var json = JsonSerializer.DEFAULT.serializeToString(bean);
+ assertTrue(json.contains("\"bits\":\"25\""), json);
+ assertEquals(bean.bits, JsonParser.DEFAULT.parse(json, CB3.class).bits);
+ }
+
+ @Test void cb06_emptyBitSet_roundTrip() throws Exception {
+ var bean = new CB1();
+ bean.bits = new BitSet();
+ var json = JsonSerializer.DEFAULT.serializeToString(bean);
+ assertEquals(new BitSet(), JsonParser.DEFAULT.parse(json, CB1.class).bits);
+ }
+
+ //====================================================================================================
+ // cc. BitSetFormat — direct unit coverage of format()/parse()/isNumeric()
+ //====================================================================================================
+
+ @Test void cc01_format_allConstants() {
+ var bs = bitSet(0, 2, 5);
+ assertEquals("0,2,5", BitSetFormat.format(bs, BitSetFormat.INDICES));
+ assertEquals("0,2,5", BitSetFormat.format(bs, BitSetFormat.NOT_SET)); // NOT_SET → INDICES
+ assertEquals("0,2,5", BitSetFormat.format(bs, null)); // null → INDICES
+ assertEquals("101001", BitSetFormat.format(bs, BitSetFormat.BITS));
+ assertEquals("25", BitSetFormat.format(bs, BitSetFormat.HEX));
+ assertEquals("", BitSetFormat.format(new BitSet(), BitSetFormat.INDICES));
+ assertEquals("", BitSetFormat.format(new BitSet(), BitSetFormat.BITS));
+ assertEquals("", BitSetFormat.format(new BitSet(), BitSetFormat.HEX));
+ assertNull(BitSetFormat.format(null, BitSetFormat.INDICES)); // null value → null
+ }
+
+ @Test void cc02_parse_allConstants() {
+ var expected = bitSet(0, 2, 5);
+ assertEquals(expected, BitSetFormat.parse("0,2,5", BitSetFormat.INDICES));
+ assertEquals(expected, BitSetFormat.parse(" 0 , 2 , 5 ", BitSetFormat.NOT_SET)); // NOT_SET → INDICES, trims
+ assertEquals(expected, BitSetFormat.parse("0,2,5", null)); // null → INDICES
+ assertEquals(expected, BitSetFormat.parse("101001", BitSetFormat.BITS));
+ assertEquals(expected, BitSetFormat.parse("25", BitSetFormat.HEX));
+ assertEquals(new BitSet(), BitSetFormat.parse("", BitSetFormat.INDICES)); // empty → empty BitSet
+ assertEquals(new BitSet(), BitSetFormat.parse(" ", BitSetFormat.HEX)); // blank → empty BitSet
+ assertNull(BitSetFormat.parse(null, BitSetFormat.INDICES)); // null → null
+ }
+
+ @Test void cc03_parse_invalidIndices_throws() {
+ assertThrows(IllegalArgumentException.class, () -> BitSetFormat.parse("0,x,5", BitSetFormat.INDICES));
+ }
+
+ @Test void cc04_parse_invalidHex_throws() {
+ assertThrows(IllegalArgumentException.class, () -> BitSetFormat.parse("2", BitSetFormat.HEX)); // odd length
+ assertThrows(IllegalArgumentException.class, () -> BitSetFormat.parse("zz", BitSetFormat.HEX)); // bad nibble
+ }
+
+ @Test void cc05_isNumeric_alwaysFalse() {
+ for (var f : BitSetFormat.values())
+ assertFalse(f.isNumeric());
+ }
+
+ //====================================================================================================
+ // d. Parser context-level nulls modes (LEAVE / EMPTY / DEFAULT / SKIP)
+ //====================================================================================================
+
+ public static class D1 {
+ public String s;
+ public Integer i;
+ public List<String> list;
+ }
+
+ @Test void d01_nulls_LEAVE_default() throws Exception {
+ var b = JsonParser.DEFAULT.parse("{\"s\":null,\"i\":null,\"list\":null}", D1.class);
+ assertNull(b.s);
+ assertNull(b.i);
+ assertNull(b.list);
+ }
+
+ @Test void d02_nulls_EMPTY() throws Exception {
+ var b = JsonParser.create().nulls(Nulls.EMPTY).build().parse("{\"s\":null,\"i\":null,\"list\":null}", D1.class);
+ assertEquals("", b.s);
+ assertNull(b.i); // boxed Integer has no empty sentinel → LEAVE fallback
+ assertNotNull(b.list);
+ assertTrue(b.list.isEmpty());
+ }
+
+ public static class D2 {
+ public String s = "hello";
+ public int i = 5;
+ }
+
+ @Test void d03_nulls_DEFAULT() throws Exception {
+ var b = JsonParser.create().nulls(Nulls.DEFAULT).build().parse("{\"s\":null,\"i\":null}", D2.class);
+ assertEquals("hello", b.s);
+ assertEquals(5, b.i);
+ }
+
+ @Test void d04_nulls_SKIP() throws Exception {
+ var b = JsonParser.create().nulls(Nulls.SKIP).build().parse("{\"s\":null,\"i\":null}", D2.class);
+ assertEquals("hello", b.s);
+ assertEquals(5, b.i);
+ }
+
+ @Test void d05_nulls_nullArg_treatedAsNotSet() throws Exception {
+ // nulls(null) → NOT_SET → behaves as LEAVE.
+ var b = JsonParser.create().nulls(null).build().parse("{\"s\":null}", D1.class);
+ assertNull(b.s);
+ }
+
+ //====================================================================================================
+ // e. EMPTY-mode emptyValueFor — exhaustive per-type coverage
+ //====================================================================================================
+
+ public static class E1 {
+ public String s;
+ public CharSequence cs;
+ public int[] arr;
+ public List<String> list;
+ public Collection<String> coll;
+ public Set<String> set;
+ public Map<String,String> map;
+ public Optional<String> opt;
+ public OptionalInt oi;
+ public OptionalLong ol;
+ public OptionalDouble od;
+ public int pi;
+ public long pl;
+ public double pd;
+ public float pf;
+ public short psh;
+ public byte pby;
+ public boolean pbo;
+ public char pch;
+ public Object obj; // Object/unknown → no empty sentinel → null (LEAVE fallback)
+ public StringBuilder sb; // reference type with no canonical empty → null (LEAVE fallback)
+ }
+
+ @Test void e01_empty_substitutesPerType() throws Exception {
+ var json = "{\"s\":null,\"cs\":null,\"arr\":null,\"list\":null,\"coll\":null,\"set\":null,\"map\":null,"
+ + "\"opt\":null,\"oi\":null,\"ol\":null,\"od\":null,\"pi\":null,\"pl\":null,\"pd\":null,\"pf\":null,"
+ + "\"psh\":null,\"pby\":null,\"pbo\":null,\"pch\":null,\"obj\":null,\"sb\":null}";
+ var b = JsonParser.create().nulls(Nulls.EMPTY).build().parse(json, E1.class);
+ assertEquals("", b.s);
+ assertEquals("", b.cs);
+ assertNull(b.obj);
+ assertNull(b.sb);
+ assertNotNull(b.arr);
+ assertEquals(0, b.arr.length);
+ assertTrue(b.list.isEmpty());
+ assertTrue(b.coll.isEmpty());
+ assertTrue(b.set.isEmpty());
+ assertTrue(b.map.isEmpty());
+ assertTrue(b.opt.isEmpty());
+ assertTrue(b.oi.isEmpty());
+ assertTrue(b.ol.isEmpty());
+ assertTrue(b.od.isEmpty());
+ assertEquals(0, b.pi);
+ assertEquals(0L, b.pl);
+ assertEquals(0d, b.pd);
+ assertEquals(0f, b.pf);
+ assertEquals((short) 0, b.psh);
+ assertEquals((byte) 0, b.pby);
+ assertFalse(b.pbo);
+ assertEquals((char) 0, b.pch);
+ }
+
+ //====================================================================================================
+ // f. @MarshalledProp(nulls=...) per-property override + Optional contract under coercion
+ //====================================================================================================
+
+ public static class F1 {
+ @MarshalledProp(nulls = Nulls.EMPTY)
+ public String s;
+ @MarshalledProp(nulls = Nulls.SKIP)
+ public String t = "initial";
+ public String u; // inherits context default
+ }
+
+ @Test void f01_perProperty_overridesContext() throws Exception {
+ var b = JsonParser.DEFAULT.parse("{\"s\":null,\"t\":null,\"u\":null}", F1.class);
+ assertEquals("", b.s);
+ assertEquals("initial", b.t);
+ assertNull(b.u);
+ }
+
+ public static class F2 {
+ public Optional<String> a;
+ @MarshalledProp(nulls = Nulls.EMPTY)
+ public Optional<String> b;
+ @MarshalledProp(nulls = Nulls.DEFAULT)
+ public Optional<String> c = Optional.of("seed");
+ public OptionalInt d;
+ public OptionalLong e;
+ public OptionalDouble f;
+ }
+
+ @Test void f02_optionalContract_underCoercion() throws Exception {
+ var b = JsonParser.create().nulls(Nulls.EMPTY).build().parse("{\"a\":null,\"b\":null,\"d\":null,\"e\":null,\"f\":null}", F2.class);
+ assertTrue(b.a.isEmpty());
+ assertTrue(b.b.isEmpty());
+ assertTrue(b.d.isEmpty());
+ assertTrue(b.e.isEmpty());
+ assertTrue(b.f.isEmpty());
+ }
+
+ @Test void f03_optional_DEFAULT_usesBeanDefault() throws Exception {
+ var b = JsonParser.DEFAULT.parse("{\"c\":null}", F2.class);
+ assertTrue(b.c.isPresent());
+ assertEquals("seed", b.c.orElse(null));
+ }
+
+ // @MarshalledProp(nulls=...) on the getter and setter surfaces (not just the field).
+ public static class F4 {
+ private String g;
+ private String s;
+ @MarshalledProp(nulls = Nulls.EMPTY)
+ public String getG() { return g; }
+ public void setG(String v) { g = v; }
+ public String getS() { return s; }
+ @MarshalledProp(nulls = Nulls.EMPTY)
+ public void setS(String v) { s = v; }
+ }
+
+ @Test void f04_perProperty_nullsOnGetterAndSetter() throws Exception {
+ var b = JsonParser.DEFAULT.parse("{\"g\":null,\"s\":null}", F4.class);
+ assertEquals("", b.g);
+ assertEquals("", b.s);
+ }
+
+ //====================================================================================================
+ // g. Per-format inclusion-branch coverage (canIgnoreValue true-path on every serializer family)
+ //====================================================================================================
+
+ public static class G1 {
+ public Optional<String> a = Optional.empty(); // non-null but ignorable → reaches canIgnoreValue
+ public String b = "keep";
+ }
+
+ @Test void g01_perFormat_ignoresEmptyOptional() {
+ // An empty Optional is a non-null value that survives the null pre-filter and reaches canIgnoreValue,
+ // whose ignore-branch then fires on every serializer family. serialize(Object) traverses the bean
+ // regardless of char/stream output shape.
+ var bean = new G1();
+ List<Serializer> serializers = List.of(
+ HjsonSerializer.DEFAULT, HoconSerializer.DEFAULT, IniSerializer.DEFAULT, JcsSerializer.DEFAULT,
+ ProtoSerializer.DEFAULT, TomlSerializer.DEFAULT, UonSerializer.DEFAULT, XmlSerializer.DEFAULT,
+ YamlSerializer.DEFAULT);
+ for (var s : serializers)
+ assertDoesNotThrow(() -> s.serialize(bean), () -> s.getClass().getSimpleName());
+ }
+
+ //====================================================================================================
+ // h. Primitive-optional conversions (BasicConverter / CachingConverter)
+ //====================================================================================================
+
+ @Test void h01_convert_nullAndNumber() throws Exception {
+ var bc = MarshallingContext.DEFAULT;
+ assertEquals(OptionalInt.empty(), bc.convertToType(null, OptionalInt.class));
+ assertEquals(OptionalLong.empty(), bc.convertToType(null, OptionalLong.class));
+ assertEquals(OptionalDouble.empty(), bc.convertToType(null, OptionalDouble.class));
+ assertEquals(OptionalInt.of(5), bc.convertToType(5, OptionalInt.class));
+ assertEquals(OptionalLong.of(6L), bc.convertToType("6", OptionalLong.class));
+ assertEquals(OptionalDouble.of(7.5), bc.convertToType(7.5, OptionalDouble.class));
+ }
+
+ @Test void h02_convert_fromOptionalInputs() throws Exception {
+ var bc = MarshallingContext.DEFAULT;
+ assertEquals(OptionalInt.of(9), bc.convertToType(Optional.of(9), OptionalInt.class));
+ assertEquals(OptionalLong.empty(), bc.convertToType(Optional.empty(), OptionalLong.class));
+ assertEquals(OptionalLong.of(4L), bc.convertToType(OptionalInt.of(4), OptionalLong.class));
+ assertEquals(OptionalInt.of(3), bc.convertToType(OptionalLong.of(3L), OptionalInt.class));
+ assertEquals(OptionalInt.of(2), bc.convertToType(OptionalDouble.of(2.0), OptionalInt.class));
+ // Cross-type empties (different out-type avoids the identity short-circuit) → exercise each empty-unwrap
+ // branch plus the OptionalDouble empty sentinel.
+ assertEquals(OptionalLong.empty(), bc.convertToType(OptionalInt.empty(), OptionalLong.class));
+ assertEquals(OptionalInt.empty(), bc.convertToType(OptionalLong.empty(), OptionalInt.class));
+ assertEquals(OptionalInt.empty(), bc.convertToType(OptionalDouble.empty(), OptionalInt.class));
+ assertEquals(OptionalDouble.empty(), bc.convertToType(Optional.empty(), OptionalDouble.class));
+ }
+
+ @Test void h03_convert_unwrappedToNull_yieldsEmpty() throws Exception {
+ // Optional containing a blank string → unwraps then coerces to a null number → empty primitive optional.
+ assertEquals(OptionalInt.empty(), MarshallingContext.DEFAULT.convertToType(Optional.of(""), OptionalInt.class));
+ }
+}
diff --git a/scripts/coverage.py b/scripts/coverage.py
index 577bd29518..f6a10563c1 100755
--- a/scripts/coverage.py
+++ b/scripts/coverage.py
@@ -16,6 +16,15 @@ Coverage reporter for Apache Juneau.
Shows JaCoCo branch and instruction coverage for a source file or folder.
+Coverage data is aggregated across ALL modules' unit tests (plus the
+integration-test suite), not just juneau-integration-tests. JaCoCo writes a
+separate target/jacoco.exec per Maven module, so a class exercised only by
+unit tests in its own module (e.g. juneau-core/juneau-marshall) used to read
+0% because only the integration exec was consulted. This script discovers
+every module's target/jacoco.exec and feeds them all -- together with each
+owning module's target/classes -- to the JaCoCo CLI to produce one combined,
+repo-wide report.
+
Usage:
./scripts/coverage.py <path> [options]
@@ -24,27 +33,48 @@ Arguments:
Paths can be absolute or relative to the repo root.
Options:
- --run, -r Re-run tests before reporting (updates the .exec data).
+ --run, -r Re-run the owning module's tests before reporting
+ (refreshes that module's .exec; other modules' existing
+ execs are still merged in).
--branches, -b Show only lines with missed branches (default: show all uncovered).
--help, -h Show this help message.
Examples:
./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/
- ./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
- ./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/ --run
+ ./scripts/coverage.py juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java
+ ./scripts/coverage.py juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java --run
"""
+import os
+import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
-INTEGRATION_MODULE = REPO_ROOT / "juneau-integration-tests"
-INTEGRATION_EXEC = INTEGRATION_MODULE / "target" / "jacoco.exec"
SRC_MARKERS = ["src/main/java", "src/test/java"]
+# Combined, repo-wide JaCoCo XML report assembled from every module's exec file.
+# Lives under target/ (git-ignored); regenerated on every run.
+COMBINED_XML = REPO_ROOT / "target" / "coverage" / "jacoco.xml"
+
+# Modules excluded from the combined report:
+# - juneau-shaded / juneau-distrib repackage classes from other modules, which would
+# introduce duplicate-class entries into the JaCoCo report.
+# - examples / petstore / microservice / sc / test-utils are outside the coverage
+# scope (mirrors <sonar.coverage.exclusions> in the root pom.xml).
+EXCLUDED_MODULE_MARKERS = (
+ "juneau-shaded",
+ "juneau-distrib",
+ "juneau-examples",
+ "juneau-petstore",
+ "juneau-microservice",
+ "juneau-sc",
+ "juneau-test-utils",
+)
+
def die(msg):
print(f"ERROR: {msg}", file=sys.stderr)
@@ -83,11 +113,102 @@ def path_to_jacoco_package(path: Path) -> tuple[str | None, str | None]:
return None, None
-def run_tests():
- """Re-run tests with upstream modules to refresh the .exec file."""
- print("Running tests to refresh coverage data...")
+def discover_modules() -> list[Path]:
+ """Return all build modules (dirs with a pom.xml) excluding the reactor root and excluded markers."""
+ poms = list(REPO_ROOT.glob("*/pom.xml")) + list(REPO_ROOT.glob("*/*/pom.xml"))
+ modules = []
+ for pom in poms:
+ m = pom.parent
+ if m == REPO_ROOT:
+ continue
+ rel = str(m.relative_to(REPO_ROOT))
+ if any(marker in rel for marker in EXCLUDED_MODULE_MARKERS):
+ continue
+ modules.append(m)
+ return sorted(modules)
+
+
+def collect_jacoco_inputs() -> tuple[list[Path], list[Path], list[Path]]:
+ """
+ Scan every active module and collect:
+ - exec files (target/jacoco.exec) -- execution data to merge
+ - class dirs (target/classes) -- bytecode to analyze
+ - source dirs (src/main/java) -- for source attribution
+ A module contributes its classes/sources whenever it has been compiled, and its
+ exec whenever its tests have run. Coverage of a class is the union of every exec
+ that touched it (its own module's unit tests + the integration suite + any other).
+ """
+ execfiles, classdirs, srcdirs = [], [], []
+ for m in discover_modules():
+ classes = m / "target" / "classes"
+ execf = m / "target" / "jacoco.exec"
+ src = m / "src" / "main" / "java"
+ if classes.is_dir() and any(classes.rglob("*.class")):
+ classdirs.append(classes)
+ if src.is_dir():
+ srcdirs.append(src)
+ if execf.is_file():
+ execfiles.append(execf)
+ return execfiles, classdirs, srcdirs
+
+
+def jacoco_version() -> str:
+ """Read <jacoco.plugin.version> from the root pom (fallback to a known-good default)."""
+ pom = (REPO_ROOT / "pom.xml").read_text(encoding="utf-8")
+ m = re.search(r"<jacoco\.plugin\.version>([^<]+)</jacoco\.plugin\.version>", pom)
+ return m.group(1).strip() if m else "0.8.14"
+
+
+def maven_local_repo() -> Path:
+ """Best-effort resolution of the Maven local repository."""
+ for env in ("MAVEN_REPO", "M2_REPO"):
+ v = os.environ.get(env)
+ if v and Path(v).is_dir():
+ return Path(v)
+ return Path.home() / ".m2" / "repository"
+
+
+def java_binary() -> str:
+ """Resolve a Java launcher, honoring JAVA_HOME, then ~/jdk/default, then PATH."""
+ jh = os.environ.get("JAVA_HOME")
+ if jh and (Path(jh) / "bin" / "java").exists():
+ return str(Path(jh) / "bin" / "java")
+ default_jdk = Path.home() / "jdk" / "default" / "bin" / "java"
+ if default_jdk.exists():
+ return str(default_jdk)
+ return "java"
+
+
+def jacoco_cli_jar() -> Path:
+ """Locate (resolving via Maven if needed) the JaCoCo CLI 'nodeps' jar."""
+ ver = jacoco_version()
+ jar = maven_local_repo() / "org" / "jacoco" / "org.jacoco.cli" / ver / f"org.jacoco.cli-{ver}-nodeps.jar"
+ if jar.exists():
+ return jar
+ print(f"Fetching JaCoCo CLI {ver}...")
+ result = subprocess.run(
+ ["mvn", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:get",
+ f"-Dartifact=org.jacoco:org.jacoco.cli:{ver}:jar:nodeps"],
+ cwd=REPO_ROOT, capture_output=True, text=True
+ )
+ if result.returncode != 0 or not jar.exists():
+ print(result.stderr[-2000:], file=sys.stderr)
+ die(f"Could not resolve JaCoCo CLI jar ({jar}).")
+ return jar
+
+
+def run_tests(module: Path):
+ """Re-run a single module's tests to refresh its .exec file.
+
+ Scoped to the owning module so single-file/class queries stay fast. Other
+ modules' existing execs are still merged in when the report is generated.
+ Requires upstream module artifacts to be available in the local repo (from a
+ prior full build, e.g. `mvn install -DskipTests`).
+ """
+ rel = module.relative_to(REPO_ROOT)
+ print(f"Running tests for module {rel} to refresh coverage data...")
result = subprocess.run(
- ["mvn", "-pl", "juneau-integration-tests", "-am", "test", "-Drat.skip=true", "-q"],
+ ["mvn", "-pl", str(rel), "test", "-Drat.skip=true", "-q"],
cwd=REPO_ROOT,
capture_output=True,
text=True
@@ -98,19 +219,30 @@ def run_tests():
print("Tests passed.\n")
-def generate_report(module: Path) -> Path:
- """Generate JaCoCo XML report for the given module using the utest exec file."""
- xml_path = module / "target" / "site" / "jacoco" / "jacoco.xml"
- result = subprocess.run(
- ["mvn", "jacoco:report", f"-Djacoco.dataFile={INTEGRATION_EXEC}", "-q"],
- cwd=module,
- capture_output=True,
- text=True
- )
+def generate_combined_report() -> Path:
+ """Build the combined, repo-wide JaCoCo XML report from all module execs + classes."""
+ execfiles, classdirs, srcdirs = collect_jacoco_inputs()
+ if not execfiles:
+ die("No jacoco.exec files found in any module. Run with --run, or build the "
+ "project first (e.g. `mvn -pl juneau-integration-tests -am test`).")
+ if not classdirs:
+ die("No compiled classes (target/classes) found. Build the project first.")
+
+ COMBINED_XML.parent.mkdir(parents=True, exist_ok=True)
+ cmd = [java_binary(), "-jar", str(jacoco_cli_jar()), "report"]
+ cmd += [str(e) for e in execfiles]
+ for c in classdirs:
+ cmd += ["--classfiles", str(c)]
+ for s in srcdirs:
+ cmd += ["--sourcefiles", str(s)]
+ cmd += ["--xml", str(COMBINED_XML), "--name", "Juneau combined coverage", "--quiet"]
+
+ print(f"Aggregating coverage from {len(execfiles)} module exec file(s)...")
+ result = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True)
if result.returncode != 0:
- print(result.stderr[-2000:], file=sys.stderr)
- die(f"Failed to generate JaCoCo report for {module}.")
- return xml_path
+ print(result.stderr[-3000:], file=sys.stderr)
+ die("Failed to generate combined JaCoCo report.")
+ return COMBINED_XML
def bar(covered, total, width=20):
@@ -145,7 +277,7 @@ def report(xml_path: Path, pkg_filter: str, file_filter: str | None, branches_on
if not matched_packages:
die(f"No JaCoCo data found for package '{pkg_filter}'.\n"
- "Make sure the module is built and the exec file is up to date.")
+ "Make sure the module is built and the exec file is up to date (use --run).")
# Collect per-file data
files_data = [] # list of (pkg_name, fname, lines_with_issues)
@@ -265,13 +397,9 @@ def main(): # NOSONAR: always returns 0 by design — standard POSIX exit code
die(f"Path does not appear to be under src/main/java or src/test/java: {path}")
if do_run:
- run_tests()
-
- if not INTEGRATION_EXEC.exists():
- die(f"No exec file found at {INTEGRATION_EXEC}. Run with --run first.")
+ run_tests(module)
- print(f"Generating JaCoCo report for module: {module.relative_to(REPO_ROOT)}")
- xml_path = generate_report(module)
+ xml_path = generate_combined_report()
report(xml_path, pkg_filter, file_filter, branches_only)
return 0