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("3"); + assertTrue(tree.isObject(), "expected outer Object, got: " + tree); + assertEquals(1, tree.size()); + JsonNode inner = tree.get("root"); + assertTrue(inner.isObject(), "expected inner Object, got: " + inner); + assertEquals("3", inner.get("value").asString()); + } + + // Object root with multiple children + attributes + @Test + public void testObjectRootWithAttributesAndChildren() throws Exception + { + JsonNode tree = WRAP_READER.readTree( + "xy"); + JsonNode inner = tree.get("root"); + assertEquals("1", inner.get("id").asString()); + assertEquals("x", inner.get("a").asString()); + assertEquals("y", inner.get("b").asString()); + } + + // Text-only root: wrap is purely token-level, so body matches what you + // would get without wrap (text becomes an empty-name property). + // 3 unwrapped → {"":"3"}; wrapped → {"root":{"":"3"}} + @Test + public void testTextOnlyRootWrapped() throws Exception + { + JsonNode tree = WRAP_READER.readTree("3"); + assertTrue(tree.isObject()); + JsonNode inner = tree.get("root"); + assertTrue(inner.isObject(), "inner expected to be Object, got: " + inner); + assertEquals("3", inner.get("").asString()); + } + + // xsi:nil root → {"root":null} + @Test + public void testXsiNilRootWrapped() throws Exception + { + JsonNode tree = WRAP_READER.readTree( + ""); + assertTrue(tree.isObject()); + assertTrue(tree.get("root").isNull()); + } + + // Empty element with EMPTY_ELEMENT_AS_NULL → {"root":null} + @Test + public void testEmptyElementAsNullRootWrapped() throws Exception + { + ObjectReader r = MAPPER.reader() + .with(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME) + .with(XmlReadFeature.EMPTY_ELEMENT_AS_NULL); + JsonNode tree = r.readTree(""); + assertTrue(tree.isObject()); + assertTrue(tree.get("root").isNull()); + } + + // Map binding works equivalently + @Test + public void testMapBindingWrapped() throws Exception + { + @SuppressWarnings("unchecked") + Map> result = WRAP_READER.forType(Map.class) + .readValue("3"); + assertEquals(1, result.size()); + assertEquals("3", result.get("root").get("value")); + } + + // Round-trip: parse-with-wrap → serialize-with-unwrap returns to original XML + @Test + public void testRoundTrip() throws Exception + { + final String INPUT = "3"; + JsonNode tree = WRAP_READER.readTree(INPUT); + // UNWRAP_ROOT_OBJECT_NODE is on by default in 3.x + ObjectWriter w = MAPPER.writer().with(XmlWriteFeature.UNWRAP_ROOT_OBJECT_NODE); + String xml = w.writeValueAsString(tree); + assertEquals(INPUT, xml); + } + + // Default off: existing behavior unchanged + @Test + public void testDefaultOffUnchanged() throws Exception + { + JsonNode tree = PLAIN_READER.readTree("3"); + // Without wrap, the root element name is dropped — body is exposed directly + assertTrue(tree.isObject()); + assertFalse(tree.has("root")); + assertEquals("3", tree.get("value").asString()); + } + + // Sanity: explicitly disabling the feature behaves like default + @Test + public void testFeatureExplicitlyDisabled() throws Exception + { + ObjectReader r = MAPPER.reader().without(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME); + JsonNode tree = r.readTree("3"); + assertEquals("3", tree.get("value").asString()); + assertFalse(tree.has("root")); + } + + // ObjectNode round-trip starting from constructed tree + @Test + public void testObjectNodeRoundTrip() throws Exception + { + ObjectNode wrapper = MAPPER.createObjectNode(); + ObjectNode inner = wrapper.putObject("root"); + inner.put("a", "1"); + inner.put("b", "2"); + ObjectWriter w = MAPPER.writer().with(XmlWriteFeature.UNWRAP_ROOT_OBJECT_NODE); + String xml = w.writeValueAsString(wrapper); + assertEquals("12", xml); + + JsonNode reparsed = WRAP_READER.readTree(xml); + assertEquals(wrapper, reparsed); + } +}