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");
+       }
 }

Reply via email to