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 75a787ee19 READY-253 Case 4 — correct maxDepth truncation Javadocs +
document @ParentProperty cyclic-graph contract (BUG-17); add recursion
behavior-pinning tests. No wire change.
75a787ee19 is described below
commit 75a787ee19aa7ab906912ec5579328819755cfb2
Author: James Bognar <[email protected]>
AuthorDate: Fri Jul 17 10:38:28 2026 -0400
READY-253 Case 4 — correct maxDepth truncation Javadocs + document
@ParentProperty cyclic-graph contract (BUG-17); add recursion behavior-pinning
tests. No wire change.
Co-authored-by: Cursor <[email protected]>
---
.../marshall/MarshallingTraverseContext.java | 31 +++++++----
.../marshall/MarshallingTraverseSession.java | 23 ++++++--
.../org/apache/juneau/marshall/ParentProperty.java | 19 +++++++
.../marshall/serializer/SerializerSession.java | 7 +++
.../juneau/ParentPropertyAnnotation_Test.java | 63 ++++++++++++++++++++++
5 files changed, 130 insertions(+), 13 deletions(-)
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseContext.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseContext.java
index 5f7c8753fe..17d3718184 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseContext.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseContext.java
@@ -101,12 +101,18 @@ public abstract class MarshallingTraverseContext extends
MarshallingContextable
*
* <p>
* Recursions can occur when traversing models that aren't true
trees but rather contain loops.
- * <br>In general, unchecked recursions cause
stack-overflow-errors.
- * <br>These show up as {@link MarshallingRecursionException
MarshallingRecursionException} with the message <js>"Depth too deep. Stack
overflow occurred."</js>.
+ * <br>When recursion detection is disabled, such loops are
instead bounded by {@link #maxDepth(int) maxDepth}
+ * (default <c>100</c>): the over-depth branch is silently
truncated, producing finite but semantically-incomplete output.
+ * <br>If the stack is exhausted before <c>maxDepth</c> is
reached, the resulting {@link StackOverflowError} is
+ * converted to a {@link MarshallingRecursionException
MarshallingRecursionException} with the message <js>"Depth too deep. Stack
overflow occurred."</js>.
*
* <h5 class='section'>Notes:</h5><ul>
* <li class='note'>
* Checking for recursion can cause a small
performance penalty.
+ * <li class='note'>
+ * This is the recommended way to fail-fast on
cyclic bean graphs such as parent/child
+ * {@link ParentProperty @ParentProperty}
references. Under the default config such cycles are instead
+ * silently truncated at {@link #maxDepth(int)
maxDepth} (see the {@link ParentProperty} Javadoc).
* </ul>
*
* <h5 class='section'>Example:</h5>
@@ -175,6 +181,9 @@ public abstract class MarshallingTraverseContext extends
MarshallingContextable
* <h5 class='section'>Notes:</h5><ul>
* <li class='note'>
* Checking for recursion can cause a small
performance penalty.
+ * <li class='note'>
+ * Use this to serialize cyclic bean graphs (such
as parent/child {@link ParentProperty @ParentProperty}
+ * references) by emitting the repeated node as
<jk>null</jk> so the output round-trips cleanly.
* </ul>
*
* <h5 class='section'>Example:</h5>
@@ -249,17 +258,18 @@ public abstract class MarshallingTraverseContext extends
MarshallingContextable
* Max traversal depth.
*
* <p>
- * When enabled, abort traversal if specified depth is reached
in the POJO tree.
- *
- * <p>
- * If this depth is exceeded, an exception is thrown.
+ * Specifies the maximum depth traversed in the POJO tree.
*
* <p>
- * This prevents stack overflows from occurring when trying to
traverse models with recursive references.
+ * When this depth is exceeded, the over-depth nodes are
silently dropped (truncated) from the output — no
+ * exception is thrown. This is a size guard that bounds the
output of deeply-nested (or cyclic) models; it is
+ * <b>not</b> a cycle detector. For genuine recursive
references, enable {@link #detectRecursions()} to fail-fast
+ * with a {@link MarshallingRecursionException}, or {@link
#ignoreRecursions()} to emit repeated nodes as
+ * <jk>null</jk> so the output round-trips cleanly.
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
- * <jc>// Create a serializer that throws an exception if
the depth reaches greater than 20.</jc>
+ * <jc>// Create a serializer that truncates output beyond
a depth of 20.</jc>
* WriterSerializer <jv>serializer</jv> = JsonSerializer
* .<jsm>create</jsm>()
* .maxDepth(20)
@@ -322,8 +332,9 @@ public abstract class MarshallingTraverseContext extends
MarshallingContextable
*
* @see Builder#maxDepth(int)
* @return
- * The depth at which traversal is aborted if depth is reached in
the POJO tree.
- * <br>If this depth is exceeded, an exception is thrown.
+ * The depth beyond which nodes in the POJO tree are dropped
(truncated) from the output.
+ * <br>Values deeper than this limit are silently omitted rather
than causing an exception to be thrown.
+ * <br>This is a size guard, not a cycle detector.
*/
public final int getMaxDepth() { return maxDepth; }
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 96bddd5b12..8841f5cb06 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
@@ -198,8 +198,9 @@ public class MarshallingTraverseSession extends
MarshallingSession {
*
* @see MarshallingTraverseContext.Builder#maxDepth(int)
* @return
- * The depth at which traversal is aborted if depth is reached in
the POJO tree.
- * <br>If this depth is exceeded, an exception is thrown.
+ * The depth beyond which nodes in the POJO tree are dropped
(truncated) from the output.
+ * <br>Values deeper than this limit are silently omitted rather
than causing an exception to be thrown.
+ * <br>This is a size guard, not a cycle detector.
*/
public final int getMaxDepth() { return ctx.getMaxDepth(); }
@@ -334,13 +335,25 @@ public class MarshallingTraverseSession extends
MarshallingSession {
/**
* Push the specified object onto the stack.
*
+ * <p>
+ * When the current depth exceeds {@link #getMaxDepth() maxDepth}, this
method returns <jk>null</jk> so that the
+ * over-depth node is dropped (truncated) from the output rather than
traversed further. Exceeding <c>maxDepth</c>
+ * does <b>not</b> throw an exception — it is a size guard that
truncates deeply-nested (or cyclic) models, not a
+ * cycle detector. To fail-fast on genuine cycles, enable
+ * {@link MarshallingTraverseContext.Builder#detectRecursions()
detectRecursions}; to omit repeated nodes as
+ * <jk>null</jk>, enable {@link
MarshallingTraverseContext.Builder#ignoreRecursions() ignoreRecursions}.
+ *
* @param attrName The attribute name.
* @param o The current object being traversed.
* @param eType The expected class type.
* @return
* The {@link ClassMeta} of the object so that <c>instanceof</c>
operations only need to be performed
* once (since they can be expensive).
- * @throws MarshallingRecursionException If recursion occurred.
+ * <br>Returns <jk>null</jk> when the object is <jk>null</jk>,
when <c>maxDepth</c> is exceeded (over-depth node
+ * dropped), or when a recursion is detected under {@link
MarshallingTraverseContext.Builder#ignoreRecursions() ignoreRecursions}.
+ * @throws MarshallingRecursionException If recursion occurred while
+ * {@link MarshallingTraverseContext.Builder#detectRecursions()
detectRecursions} is enabled without
+ * {@link MarshallingTraverseContext.Builder#ignoreRecursions()
ignoreRecursions}.
*/
protected final ClassMeta<?> push(String attrName, Object o,
ClassMeta<?> eType) throws MarshallingRecursionException {
indent++;
@@ -388,6 +401,10 @@ public class MarshallingTraverseSession extends
MarshallingSession {
/**
* Returns <jk>true</jk> if we're about to exceed the max depth for the
document.
*
+ * <p>
+ * When this returns <jk>true</jk>, the over-depth value is dropped
(truncated) from the output rather than
+ * causing an exception to be thrown — exceeding {@link #getMaxDepth()
maxDepth} is a size guard, not a cycle detector.
+ *
* @return <jk>true</jk> if we're about to exceed the max depth for the
document.
*/
protected final boolean willExceedDepth() {
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ParentProperty.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ParentProperty.java
index de84d7cd82..67a620f775 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ParentProperty.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ParentProperty.java
@@ -79,6 +79,25 @@ import java.lang.annotation.*;
* <li>This allows child objects to navigate back to their parent if needed
* </ul>
*
+ * <h5 class='section'>Cyclic graphs and serialization:</h5>
+ * <ul class='spaced-list'>
+ * <li>When a <ja>@ParentProperty</ja> back-reference is also a
normally-visible bean property (e.g. a <jk>public</jk>
+ * field or getter, as in the example above), it forms a
parent-to-child/child-to-parent <b>cycle</b>. Unlike
+ * Jackson's <c>@JsonBackReference</c>, Juneau does <b>not</b>
auto-omit the <ja>@ParentProperty</ja> member on the
+ * write (serialize) side — the annotation is a parse-time
convenience only. This is intentional.
+ * <li>Under the <b>default</b> serializer configuration
(<c>detectRecursions=<jk>false</jk></c>,
+ * <c>ignoreRecursions=<jk>false</jk></c>), serializing such a
cyclic graph produces <b>finite but
+ * semantically-incomplete</b> output: the traversal is silently
truncated at
+ * {@link MarshallingTraverseContext.Builder#maxDepth(int)
maxDepth} (default <c>100</c>). No exception is thrown —
+ * <c>maxDepth</c> is a size guard, not a cycle detector.
+ * <li>To fail-fast on cycles with a clear error, enable
+ * {@link MarshallingTraverseContext.Builder#detectRecursions()
detectRecursions} — a
+ * {@link MarshallingRecursionException} (surfaced as a serialize
exception) is thrown.
+ * <li>To omit the back-reference and round-trip cleanly, enable
+ * {@link MarshallingTraverseContext.Builder#ignoreRecursions()
ignoreRecursions} — the repeated node is emitted as
+ * <jk>null</jk>, and parsing re-injects the parent via this
annotation.
+ * </ul>
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/ParentPropertyAnnotation">@ParentProperty
Annotation</a>
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 7d6cb2921b..4ac518d85a 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
@@ -526,6 +526,13 @@ public class SerializerSession extends
MarshallingTraverseSession {
/**
* Returns <jk>true</jk> if the specified value should not be
serialized.
*
+ * <p>
+ * A value is also omitted (dropped) when it would exceed {@link
#getMaxDepth() maxDepth} (via
+ * {@link #willExceedDepth()}) — over-depth values are silently
truncated from the output rather than throwing.
+ * Exceeding <c>maxDepth</c> is a size guard, not a cycle detector; when
+ * {@link
org.apache.juneau.marshall.MarshallingTraverseContext.Builder#detectRecursions()
detectRecursions} is enabled, an actual recursion
+ * instead throws a {@link SerializeException}.
+ *
* @param cm The class type of the object being serialized.
* @param attrName The bean attribute name, or <jk>null</jk> if this
isn't a bean attribute.
* @param value The object being serialized.
diff --git
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParentPropertyAnnotation_Test.java
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParentPropertyAnnotation_Test.java
index 0997df1ed1..8b2dcd9a86 100644
---
a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParentPropertyAnnotation_Test.java
+++
b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParentPropertyAnnotation_Test.java
@@ -22,6 +22,8 @@ import static org.junit.jupiter.api.Assertions.*;
import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.serializer.*;
import org.junit.jupiter.api.*;
@SuppressWarnings({
@@ -185,4 +187,65 @@ class ParentPropertyAnnotation_Test extends TestBase {
var ex = assertThrows(ExecutableException.class, () ->
prop.set(bean, newParent));
assertTrue(ex.getMessage().contains("No setter defined"),
"Should throw exception when trying to set read-only property");
}
+
+
//------------------------------------------------------------------------------------------------------------------
+ // Cyclic @ParentProperty graph serialization contract
(behavior-pinning).
+ //
+ // A @ParentProperty back-reference that is also a normally-visible
bean property forms a parent/child cycle.
+ // Juneau does NOT auto-omit the back-reference on the write side, so
the serialization contract is:
+ // - default config: maxDepth=100 size guard silently truncates ->
finite (but semantically-incomplete) output, no throw.
+ // - detectRecursions(): fail-fast with a recursion
SerializeException.
+ // - ignoreRecursions(): omit the repeated node -> round-trips
cleanly (parent re-injected via @ParentProperty).
+
//------------------------------------------------------------------------------------------------------------------
+
+ public static class F_Parent {
+ public String name;
+ public F_Child child;
+ }
+
+ public static class F_Child {
+ public String name;
+
+ @ParentProperty
+ public F_Parent parent;
+ }
+
+ // Builds a parent/child cycle: Parent -> child (Child) -> parent
(@ParentProperty back-reference -> Parent).
+ static F_Parent f_cyclicGraph() {
+ var p = new F_Parent();
+ p.name = "p";
+ var c = new F_Child();
+ c.name = "c";
+ p.child = c;
+ c.parent = p;
+ return p;
+ }
+
+ @Test void f01_cyclicGraph_defaultConfig_truncatesFiniteNoThrow() {
+ var p = f_cyclicGraph();
+ // Default config does NOT throw: the maxDepth=100 size guard
silently truncates the cycle to finite output.
+ var json = assertDoesNotThrow(() ->
JsonSerializer.DEFAULT.serialize(p));
+ assertTrue(json.length() < 100_000, "Output should be finite
(truncated at maxDepth): length=" + json.length());
+ assertTrue(json.contains("\"name\":\"c\""), "Output should
contain the traversed graph: " + json);
+ }
+
+ @Test void f02_cyclicGraph_detectRecursions_throws() {
+ var p = f_cyclicGraph();
+ var s = JsonSerializer.create().detectRecursions().build();
+ assertThrowsWithMessage(SerializeException.class, "Recursion
occurred", () -> s.serialize(p));
+ }
+
+ @Test void f03_cyclicGraph_ignoreRecursions_roundTrips() throws
Exception {
+ var p = f_cyclicGraph();
+ var s = JsonSerializer.create().ignoreRecursions().build();
+ var json = assertDoesNotThrow(() -> s.serialize(p));
+ // The back-reference to the already-seen parent is omitted ->
output is finite and does not re-nest.
+ assertTrue(json.contains("\"name\":\"c\""), "Output should
serialize the child: " + json);
+ assertFalse(json.contains("\"child\":{\"child\""), "Cycle
should be broken (no re-nesting): " + json);
+ // Round-trips cleanly, and the parser re-injects the parent
via @ParentProperty.
+ var p2 = JsonParser.DEFAULT.parse(json, F_Parent.class);
+ assertEquals("p", p2.name);
+ assertEquals("c", p2.child.name);
+ assertSame(p2, p2.child.parent, "Parser should re-inject the
parent via @ParentProperty");
+ }
}