You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cxf.apache.org by co...@apache.org on 2026/10/19 08:28:32 UTC

(cxf-xjc-utils) 01/01: Print shared objects only once in JAXBToStringStyle

This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch coheigea/JAXBToStringStyle
in repository https://gitbox.apache.org/repos/asf/cxf-xjc-utils.git

commit 6f8042c91b4a4935cb87a6aced642dbfec20a104
Author: Colm O hEigeartaigh <co...@apache.org>
AuthorDate: Mon Oct 19 09:28:05 2026 +0100

    Print shared objects only once in JAXBToStringStyle
---
 .../apache/cxf/xjc/runtime/JAXBToStringStyle.java  |  78 +++++++++++-
 .../JAXBToStringStyleSharedReferenceTest.java      | 132 +++++++++++++++++++++
 2 files changed, 209 insertions(+), 1 deletion(-)

diff --git a/runtime/src/main/java/org/apache/cxf/xjc/runtime/JAXBToStringStyle.java b/runtime/src/main/java/org/apache/cxf/xjc/runtime/JAXBToStringStyle.java
index 02f3b25..12f7f0c 100644
--- a/runtime/src/main/java/org/apache/cxf/xjc/runtime/JAXBToStringStyle.java
+++ b/runtime/src/main/java/org/apache/cxf/xjc/runtime/JAXBToStringStyle.java
@@ -20,6 +20,7 @@
 package org.apache.cxf.xjc.runtime;
 
 import java.util.Collection;
+import java.util.IdentityHashMap;
 import java.util.Map;
 
 import javax.xml.namespace.QName;
@@ -51,6 +52,21 @@ public final class JAXBToStringStyle {
 class JAXBToStringStyleImpl extends ToStringStyle {
     private static final long serialVersionUID = -584903238590491314L;
 
+    /*
+     * The state of the toString() call in progress on this thread, removed once it completes or
+     * fails. commons-lang3 only detects cycles (objects on the current path), so an object
+     * referenced from several places, e.g. through xs:IDREF, would otherwise be printed once per
+     * path, which grows exponentially with the depth of such references.
+     */
+    private static final ThreadLocal<CallState> STATE = new ThreadLocal<>();
+
+    private static final class CallState {
+        // objects already printed in detail during the current top-level call
+        final Map<Object, Object> rendered = new IdentityHashMap<>();
+        // how many field values are being appended, 0 between the fields of the top-level object
+        int depth;
+    }
+
     JAXBToStringStyleImpl() {
         super();
     }
@@ -72,6 +88,63 @@ class JAXBToStringStyleImpl extends ToStringStyle {
         }
     }
         
+    private static CallState callState() {
+        CallState state = STATE.get();
+        if (state == null) {
+            state = new CallState();
+            STATE.set(state);
+        }
+        return state;
+    }
+
+    // for tests: whether this thread holds any state, which it should only during a call
+    static boolean hasCallState() {
+        return STATE.get() != null;
+    }
+
+    @Override
+    public void appendStart(StringBuffer buffer, Object object) {
+        CallState state = callState();
+        if (state.depth == 0) {
+            // a new top-level call
+            state.rendered.clear();
+        }
+        if (object != null) {
+            state.rendered.put(object, null);
+        }
+        super.appendStart(buffer, object);
+    }
+
+    @Override
+    public void appendEnd(StringBuffer buffer, Object object) {
+        try {
+            super.appendEnd(buffer, object);
+        } finally {
+            CallState state = STATE.get();
+            if (state == null || state.depth == 0) {
+                STATE.remove();
+            }
+        }
+    }
+
+    // nested toString() calls happen within this, so it tracks whether a call is top-level
+    @Override
+    protected void appendInternal(StringBuffer buffer, String fieldName, Object value, boolean detail) {
+        CallState state = callState();
+        state.depth++;
+        try {
+            super.appendInternal(buffer, fieldName, value, detail);
+        } catch (RuntimeException | Error e) {
+            if (state.depth == 1) {
+                // the top-level toString() call fails, so don't keep its state
+                STATE.remove();
+            }
+            throw e;
+        } finally {
+            state.depth--;
+        }
+    }
+
     /*
      * Introspect into JAXBElement as a special case as it does not have a
      * toString() and we loose the content
@@ -80,7 +153,10 @@ class JAXBToStringStyleImpl extends ToStringStyle {
      */
     @Override
     protected void appendDetail(StringBuffer buffer, String fieldName, Object value) {
-        if (value instanceof JAXBElement) {
+        if (value != null && callState().rendered.containsKey(value)) {
+            // already printed in detail, so just identify it as for a cycle
+            appendCyclicObject(buffer, fieldName, value);
+        } else if (value instanceof JAXBElement) {
             buffer.append(ToStringBuilder.reflectionToString(value, this));
         } else {
             appendValue(buffer, value);
diff --git a/runtime/src/test/java/org/apache/cxf/xjc/runtime/JAXBToStringStyleSharedReferenceTest.java b/runtime/src/test/java/org/apache/cxf/xjc/runtime/JAXBToStringStyleSharedReferenceTest.java
new file mode 100644
index 0000000..31a763f
--- /dev/null
+++ b/runtime/src/test/java/org/apache/cxf/xjc/runtime/JAXBToStringStyleSharedReferenceTest.java
@@ -0,0 +1,132 @@
+/**
+ * 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.cxf.xjc.runtime;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * An object referenced from several places (e.g. through xs:IDREF) is printed in detail once
+ * per toString() call, so output stays linear in the size of the object graph.
+ */
+public class JAXBToStringStyleSharedReferenceTest {
+
+    static class Node {
+        String id;
+        Object left;
+        Object right;
+
+        Node(String id) {
+            this.id = id;
+        }
+
+        @Override
+        public String toString() {
+            return ToStringBuilder.reflectionToString(this, JAXBToStringStyle.DEFAULT_STYLE);
+        }
+    }
+
+    static class Failing {
+        @Override
+        public String toString() {
+            throw new IllegalStateException("toString failed");
+        }
+    }
+
+    @Test
+    public void testSharedReferenceIsPrintedOnce() {
+        Node shared = new Node("shared");
+        Node parent = new Node("parent");
+        parent.left = shared;
+        parent.right = shared;
+        String ts = parent.toString();
+        Assert.assertEquals(ts, 1, count(ts, "id=shared"));
+        // the second reference only identifies the object, as for a cycle
+        Assert.assertTrue(ts, ts.contains("right=" + Node.class.getName() + "@"));
+        // a separate call prints it in detail again
+        Assert.assertTrue(shared.toString().contains("id=shared"));
+    }
+
+    @Test
+    public void testDeeplySharedReferences() {
+        // each level references the level below twice: 2^40 paths without sharing
+        Node node = new Node("leaf");
+        for (int i = 0; i < 40; i++) {
+            Node parent = new Node("n" + i);
+            parent.left = node;
+            parent.right = node;
+            node = parent;
+        }
+        String ts = node.toString();
+        Assert.assertEquals(1, count(ts, "id=leaf"));
+        Assert.assertTrue(ts.length() < 10000);
+    }
+
+    @Test
+    public void testAllStyles() {
+        for (ToStringStyle style : new ToStringStyle[] {JAXBToStringStyle.DEFAULT_STYLE,
+            JAXBToStringStyle.SIMPLE_STYLE, JAXBToStringStyle.MULTI_LINE_STYLE}) {
+            Node shared = new Node("shared");
+            Node parent = new Node("parent");
+            parent.left = shared;
+            parent.right = shared;
+            String ts = ToStringBuilder.reflectionToString(parent, style);
+            Assert.assertEquals(ts, 1, count(ts, "shared"));
+        }
+    }
+
+    @Test
+    public void testFailedToStringDoesNotAffectLaterCalls() {
+        Node shared = new Node("shared");
+        Node failing = new Node("failing");
+        failing.left = shared;
+        failing.right = new Failing();
+        try {
+            Assert.fail("toString() should have failed: " + failing.toString());
+        } catch (IllegalStateException e) {
+            // expected
+        }
+        Node parent = new Node("parent");
+        parent.left = shared;
+        Assert.assertTrue(parent.toString().contains("id=shared"));
+    }
+
+    @Test
+    public void testNoStateKeptAfterCalls() {
+        Node node = new Node("n");
+        node.left = new Failing();
+        try {
+            Assert.fail(node.toString());
+        } catch (IllegalStateException e) {
+            // expected
+        }
+        Assert.assertFalse(JAXBToStringStyleImpl.hasCallState());
+        node.left = new Node("ok");
+        Assert.assertTrue(node.toString().contains("id=ok"));
+        Assert.assertFalse(JAXBToStringStyleImpl.hasCallState());
+    }
+
+    private static int count(String s, String part) {
+        return s.split(java.util.regex.Pattern.quote(part), -1).length - 1;
+    }
+}