diff --git a/release-notes/VERSION b/release-notes/VERSION index 42f5e6160..6687b8122 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -69,6 +69,9 @@ Version: 3.x (for earlier see VERSION-2.x) #455: Can't deserialize list in JsonSubtype when type property is visible (reported by Jiri M) (fix by @cowtowncoder, w/ Claude code) +#484: Add `FromXmlParser.Feature.WRAP_ROOT_ELEMENT_NAME` to allow + lossless round-trip via Tree Model + (fix by @cowtowncoder, w/ Claude code) #496: Root name missing when root element has no attributes (add `FromXmlParser.getRootElementName()`) (reported by Sam K) diff --git a/src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java b/src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java index d6cb08cba..c1a9fd55d 100644 --- a/src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java +++ b/src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java @@ -91,6 +91,52 @@ public enum XmlReadFeature implements FormatFeature */ SKIP_UNKNOWN_XSI_ATTRIBUTES(false), + /** + * Feature that, when enabled, exposes the XML root element as an extra + * outer Object wrapper whose single property is named after the root + * element's local name. This preserves the root name in the resulting + * token stream (and therefore in {@code JsonNode}, {@code Map}, etc.), + * which is otherwise discarded. + *
+ * Example: with this feature enabled, + *
+ * <root><value>3</value></root> + *+ * is exposed as token stream equivalent to + *
+ * { "root" : { "value" : "3" } }
+ *
+ * instead of the default
+ *
+ * { "value" : "3" }
+ *
+ * The wrapper is purely a token-stream-level addition; the body is exposed
+ * exactly as it would be without wrap. Roots that the parser would otherwise
+ * expose as {@code null} ({@code xsi:nil} or, with
+ * {@link #EMPTY_ELEMENT_AS_NULL} enabled, empty elements) become
+ * {@code { "root" : null }}.
+ *+ * Designed to pair with {@link XmlWriteFeature#UNWRAP_ROOT_OBJECT_NODE} + * to allow lossless round-tripping of root element name via the Tree + * Model ({@code JsonNode}) and {@code Map} bindings. + *
+ * Notes: + *
+ * Default setting is {@code false} for backwards-compatibility.
+ *
+ * @since 3.2
+ */
+ WRAP_ROOT_ELEMENT_NAME(false),
+
;
private final boolean _defaultState;
diff --git a/src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java b/src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java
index 567148c03..16e7c97aa 100644
--- a/src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java
+++ b/src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java
@@ -121,6 +121,37 @@ public class FromXmlParser
*/
protected boolean _nextIsNullXsiNil;
+ /*
+ /**********************************************************************
+ /* Parsing state, optional root element wrapping (3.2)
+ /**********************************************************************
+ */
+
+ // [dataformat-xml#484] Root-element wrap state machine. When
+ // {@link XmlReadFeature#WRAP_ROOT_ELEMENT_NAME} is enabled, the parser
+ // synthesizes an extra outer Object whose single property is the root
+ // element local name. {@code _rootWrapStage} drives the synthetic tokens.
+
+ /** Wrap inactive: feature off, or wrapper already fully delivered. */
+ private static final int WRAP_INACTIVE = 0;
+ /** Next call emits the synthetic outer START_OBJECT. */
+ private static final int WRAP_PENDING_OUTER_START = 1;
+ /** Next call emits the synthetic PROPERTY_NAME (root local name). */
+ private static final int WRAP_PENDING_NAME = 2;
+ /** Wrapper open over an Object body: emit synthetic END_OBJECT when stream reaches XML_END. */
+ private static final int WRAP_OPEN_OBJECT = 3;
+ /** Wrapper open over a scalar/null body: queued in {@code _nextToken}; transition to PENDING_OUTER_END after delivery. */
+ private static final int WRAP_OPEN_SCALAR = 4;
+ /** Next call emits the synthetic outer END_OBJECT (scalar/null body case). */
+ private static final int WRAP_PENDING_OUTER_END = 5;
+
+ /**
+ * Stage of the root-element wrap state machine; see WRAP_* constants above.
+ *
+ * @since 3.2
+ */
+ protected int _rootWrapStage = WRAP_INACTIVE;
+
/*
/**********************************************************************
/* Parsing state, parsed values
@@ -207,6 +238,7 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
// 04-Jan-2019, tatu: Root-level nulls need slightly specific handling;
// changed in 2.10.2
+ final boolean wrapRoot = isEnabled(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
if (_xmlTokens.hasXsiNil()) {
_nextToken = JsonToken.VALUE_NULL;
// 21-Apr-2025, tatu: [dataformat-xml#714] Must "flush" the stream
@@ -216,7 +248,12 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
case XmlTokenStream.XML_START_ELEMENT:
// Removed from 2.14:
// case XmlTokenStream.XML_DELAYED_START_ELEMENT:
- _nextToken = JsonToken.START_OBJECT;
+ // [dataformat-xml#484]: in wrap mode, suppress queuing the
+ // (inner) START_OBJECT here; the wrap state machine queues it
+ // explicitly after delivering outer START + PROPERTY_NAME.
+ if (!wrapRoot) {
+ _nextToken = JsonToken.START_OBJECT;
+ }
break;
case XmlTokenStream.XML_ROOT_TEXT:
_currText = _xmlTokens.getText();
@@ -232,6 +269,10 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
_reportError("Internal problem: invalid starting state (%s)", _xmlTokens._currentStateDesc());
}
}
+ // [dataformat-xml#484]: activate wrap state machine if feature enabled
+ if (wrapRoot) {
+ _rootWrapStage = WRAP_PENDING_OUTER_START;
+ }
}
@Override
@@ -553,6 +594,14 @@ public JsonToken nextToken() throws JacksonException
_binaryValue = null;
_numTypesValid = NR_UNKNOWN;
//System.out.println("FromXmlParser.nextToken0: _nextToken = "+_nextToken);
+ // [dataformat-xml#484]: deliver synthetic root-wrap tokens before normal flow
+ if (_rootWrapStage != WRAP_INACTIVE) {
+ JsonToken wrap = _nextRootWrapToken();
+ if (wrap != null) {
+ return wrap;
+ }
+ // Otherwise: stage advanced to WRAP_OPEN_OBJECT/SCALAR; fall through to standard logic.
+ }
if (_nextToken != null) {
final JsonToken t = _updateToken(_nextToken);
_nextToken = null;
@@ -583,6 +632,10 @@ public JsonToken nextToken() throws JacksonException
// 13-May-2020, tatu: [dataformat-xml#397]: advance `index` anyway; not
// used for Object contexts, updated automatically by "createChildXxxContext"
_streamReadContext.valueStarted();
+ // [dataformat-xml#484]: scalar/null body delivered — queue closing END_OBJECT
+ if (_rootWrapStage == WRAP_OPEN_SCALAR) {
+ _rootWrapStage = WRAP_PENDING_OUTER_END;
+ }
}
return t;
}
@@ -774,6 +827,12 @@ public JsonToken nextToken() throws JacksonException
_nextToken = JsonToken.VALUE_STRING;
return _updateToken(JsonToken.PROPERTY_NAME);
case XmlTokenStream.XML_END:
+ // [dataformat-xml#484]: close the synthetic root wrapper before EOF
+ if (_rootWrapStage == WRAP_OPEN_OBJECT) {
+ _rootWrapStage = WRAP_INACTIVE;
+ _streamReadContext = _streamReadContext.getParent();
+ return _updateToken(JsonToken.END_OBJECT);
+ }
return _updateTokenToNull();
default:
return _internalErrorUnknownToken(token);
@@ -781,6 +840,41 @@ public JsonToken nextToken() throws JacksonException
}
}
+ /**
+ * [dataformat-xml#484]: deliver the next synthetic root-wrap token, or
+ * return {@code null} when the wrap state machine has nothing pending and
+ * the caller should fall through to standard token logic.
+ *
+ * @since 3.2
+ */
+ private JsonToken _nextRootWrapToken()
+ {
+ switch (_rootWrapStage) {
+ case WRAP_PENDING_OUTER_START:
+ _rootWrapStage = WRAP_PENDING_NAME;
+ _streamReadContext = _streamReadContext.createChildObjectContext(-1, -1);
+ return _updateToken(JsonToken.START_OBJECT);
+ case WRAP_PENDING_NAME:
+ // Object body case: queue inner START_OBJECT for normal flow to consume.
+ // Scalar/null body case: _nextToken already holds the value.
+ if (_nextToken == null) {
+ _nextToken = JsonToken.START_OBJECT;
+ _rootWrapStage = WRAP_OPEN_OBJECT;
+ } else {
+ _rootWrapStage = WRAP_OPEN_SCALAR;
+ }
+ _streamReadContext.setCurrentName(_xmlTokens.getRootName().getLocalPart());
+ return _updateToken(JsonToken.PROPERTY_NAME);
+ case WRAP_PENDING_OUTER_END:
+ _rootWrapStage = WRAP_INACTIVE;
+ _streamReadContext = _streamReadContext.getParent();
+ return _updateToken(JsonToken.END_OBJECT);
+ default:
+ // WRAP_OPEN_OBJECT / WRAP_OPEN_SCALAR — caller should fall through.
+ return null;
+ }
+ }
+
/*
/**********************************************************************
/* Overrides of specialized nextXxx() methods
@@ -804,6 +898,11 @@ public String nextName() throws JacksonException {
@Override
public String nextStringValue() throws JacksonException
{
+ // [dataformat-xml#484]: wrapper tokens are not String-valued; route
+ // through nextToken() to keep wrap-state advancement in one place.
+ if (_rootWrapStage != WRAP_INACTIVE) {
+ return (nextToken() == JsonToken.VALUE_STRING) ? _currText : null;
+ }
_binaryValue = null;
if (_nextToken != null) {
final JsonToken t = _updateToken(_nextToken);
diff --git a/src/test/java/tools/jackson/dataformat/xml/node/RootElementWrap484Test.java b/src/test/java/tools/jackson/dataformat/xml/node/RootElementWrap484Test.java
new file mode 100644
index 000000000..c253b262e
--- /dev/null
+++ b/src/test/java/tools/jackson/dataformat/xml/node/RootElementWrap484Test.java
@@ -0,0 +1,149 @@
+package tools.jackson.dataformat.xml.node;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectReader;
+import tools.jackson.databind.ObjectWriter;
+import tools.jackson.databind.node.ObjectNode;
+
+import tools.jackson.dataformat.xml.XmlMapper;
+import tools.jackson.dataformat.xml.XmlReadFeature;
+import tools.jackson.dataformat.xml.XmlTestUtil;
+import tools.jackson.dataformat.xml.XmlWriteFeature;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+// [dataformat-xml#484]: WRAP_ROOT_ELEMENT_NAME exposes root element as outer wrapper
+public class RootElementWrap484Test extends XmlTestUtil
+{
+ private final XmlMapper MAPPER = newMapper();
+
+ private final ObjectReader WRAP_READER = MAPPER.reader()
+ .with(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
+ private final ObjectReader PLAIN_READER = MAPPER.reader();
+
+ // Object root with single child element → {"root":{"value":"3"}}
+ @Test
+ public void testObjectRootWrapped() throws Exception
+ {
+ JsonNode tree = WRAP_READER.readTree("