Skip to content

Commit e078795

Browse files
authored
Fix #496: root name missing if no attributes (#831)
1 parent 386c5df commit e078795

5 files changed

Lines changed: 186 additions & 0 deletions

File tree

release-notes/CREDITS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ Jiri Mikulasek (@jimirocks)
6161
* Reported #455: Can't deserialize list in JsonSubtype when type property is visible
6262
(3.2.0)
6363

64+
Sam Kruglov (@Sam-Kruglov)
65+
* Reported #496: Root name missing when root element has no attributes (add
66+
`FromXmlParser.getRootElementName()`)
67+
(3.2.0)
68+
6469
Josip Antoliš (@Antolius)
6570
* Reported #517: XML wrapper doesn't work with java records
6671
(3.2.0)

release-notes/VERSION

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Version: 3.x (for earlier see VERSION-2.x)
4444
#455: Can't deserialize list in JsonSubtype when type property is visible
4545
(reported by Jiri M)
4646
(fix by @cowtowncoder, w/ Claude code)
47+
#496: Root name missing when root element has no attributes (add
48+
`FromXmlParser.getRootElementName()`)
49+
(reported by Sam K)
50+
(fix by @cowtowncoder, w/ Claude code)
4751
#517: XML wrapper doesn't work with java records
4852
(reported by Josip A)
4953
(fix by Christopher M)

src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.util.Objects;
88
import java.util.Set;
99

10+
import javax.xml.namespace.QName;
1011
import javax.xml.stream.XMLStreamException;
1112
import javax.xml.stream.XMLStreamReader;
1213
import javax.xml.stream.XMLStreamWriter;
@@ -280,6 +281,24 @@ public XMLStreamReader getStaxReader() {
280281
return _xmlTokens.getXmlReader();
281282
}
282283

284+
/**
285+
* Accessor for the qualified name ({@link QName}) of the root XML element,
286+
* including local name, namespace URI and prefix. Unlike accessing
287+
* the underlying Stax reader directly, this value is stable regardless
288+
* of how far parsing has advanced.
289+
*<p>
290+
* NOTE: the local name and namespace URI are post-{@link XmlNameProcessor}
291+
* decoding (matching what Jackson databind sees), while the prefix is the
292+
* raw value from the XML source.
293+
*
294+
* @return Qualified name of the root element
295+
*
296+
* @since 3.2
297+
*/
298+
public QName getRootElementName() {
299+
return _xmlTokens.getRootName();
300+
}
301+
283302
/*
284303
/**********************************************************************
285304
/* ElementWrappable implementation

src/main/java/tools/jackson/dataformat/xml/deser/XmlTokenStream.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.io.IOException;
44

55
import javax.xml.XMLConstants;
6+
import javax.xml.namespace.QName;
67
import javax.xml.stream.*;
78

89
import org.codehaus.stax2.XMLStreamLocation2;
@@ -115,6 +116,15 @@ public class XmlTokenStream
115116

116117
protected String _namespaceURI;
117118

119+
/**
120+
* Root element's qualified name (namespace URI, local name, prefix),
121+
* saved during {@link #initialize()} so it remains accessible even
122+
* after the stream has advanced past it.
123+
*
124+
* @since 3.2
125+
*/
126+
protected QName _rootName;
127+
118128
/**
119129
* Current text value for TEXT_VALUE returned
120130
*/
@@ -191,7 +201,11 @@ public int initialize() throws XMLStreamException
191201
+XMLStreamConstants.START_ELEMENT+"), instead got "+_xmlReader.getEventType());
192202
}
193203
_checkXsiAttributes(); // sets _attributeCount, _nextAttributeIndex
204+
// [dataformat-xml#496] Save root element name (with prefix) before stream advances
205+
String rootPrefix = _xmlReader.getPrefix();
194206
_decodeElementName(_xmlReader.getNamespaceURI(), _xmlReader.getLocalName());
207+
_rootName = new QName(_namespaceURI, _localName,
208+
(rootPrefix == null) ? "" : rootPrefix);
195209

196210
// 02-Jul-2020, tatu: Two choices: if child elements OR attributes, expose
197211
// as Object value; otherwise expose as Text
@@ -325,6 +339,18 @@ public void skipEndElement() throws IOException, XMLStreamException
325339

326340
public String getNamespaceURI() { return _namespaceURI; }
327341

342+
/**
343+
* Accessor for the qualified name of the root XML element (local name,
344+
* namespace URI, prefix), as determined during stream initialization.
345+
* Unlike {@link #getLocalName()}, this value does not change as the
346+
* stream advances.
347+
*
348+
* @return Qualified name of the root element
349+
*
350+
* @since 3.2
351+
*/
352+
public QName getRootName() { return _rootName; }
353+
328354
public boolean hasXsiNil() {
329355
return _xsiNilFound;
330356
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package tools.jackson.dataformat.xml.stream;
2+
3+
import javax.xml.namespace.QName;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import tools.jackson.core.*;
8+
import tools.jackson.databind.*;
9+
import tools.jackson.databind.annotation.JsonDeserialize;
10+
11+
import tools.jackson.dataformat.xml.XmlMapper;
12+
import tools.jackson.dataformat.xml.XmlTestUtil;
13+
import tools.jackson.dataformat.xml.deser.FromXmlParser;
14+
15+
import static org.junit.jupiter.api.Assertions.*;
16+
17+
// [dataformat-xml#496] Root element name not accessible from custom deserializer
18+
// when root has no attributes
19+
public class RootElementName496Test extends XmlTestUtil
20+
{
21+
@JsonDeserialize(using = RootNameDeserializer.class)
22+
static class RootNameHolder {
23+
public QName rootName;
24+
25+
RootNameHolder(QName rootName) {
26+
this.rootName = rootName;
27+
}
28+
}
29+
30+
static class RootNameDeserializer extends ValueDeserializer<RootNameHolder> {
31+
@Override
32+
public RootNameHolder deserialize(JsonParser p, DeserializationContext ctxt)
33+
{
34+
QName rootName = ((FromXmlParser) p).getRootElementName();
35+
// consume the rest
36+
while (p.nextToken() != null) { }
37+
return new RootNameHolder(rootName);
38+
}
39+
}
40+
41+
private final XmlMapper MAPPER = newMapper();
42+
43+
// [dataformat-xml#496]: root name accessible without attributes
44+
@Test
45+
public void testRootNameWithoutAttributes() throws Exception
46+
{
47+
RootNameHolder result = MAPPER.readValue(
48+
"<root><field>value</field></root>", RootNameHolder.class);
49+
assertEquals("root", result.rootName.getLocalPart());
50+
}
51+
52+
// [dataformat-xml#496]: root name accessible with attributes
53+
@Test
54+
public void testRootNameWithAttributes() throws Exception
55+
{
56+
RootNameHolder result = MAPPER.readValue(
57+
"<root foo='bar'><field>value</field></root>", RootNameHolder.class);
58+
assertEquals("root", result.rootName.getLocalPart());
59+
}
60+
61+
// [dataformat-xml#496]: verify via parser directly, stable across full parse
62+
@Test
63+
public void testRootNameViaParser() throws Exception
64+
{
65+
try (JsonParser p = MAPPER.createParser("<myRoot><child>text</child></myRoot>")) {
66+
FromXmlParser xp = (FromXmlParser) p;
67+
QName rootName = xp.getRootElementName();
68+
assertEquals("myRoot", rootName.getLocalPart());
69+
// Advance past all tokens
70+
while (p.nextToken() != null) { }
71+
// Still accessible after parsing
72+
assertEquals("myRoot", xp.getRootElementName().getLocalPart());
73+
}
74+
}
75+
76+
// [dataformat-xml#496]: empty root element
77+
@Test
78+
public void testRootNameEmptyElement() throws Exception
79+
{
80+
try (JsonParser p = MAPPER.createParser("<emptyRoot/>")) {
81+
FromXmlParser xp = (FromXmlParser) p;
82+
assertEquals("emptyRoot", xp.getRootElementName().getLocalPart());
83+
}
84+
}
85+
86+
// [dataformat-xml#496]: root with text-only content (scalar root value)
87+
@Test
88+
public void testRootNameTextOnly() throws Exception
89+
{
90+
try (JsonParser p = MAPPER.createParser("<textRoot>hello</textRoot>")) {
91+
FromXmlParser xp = (FromXmlParser) p;
92+
assertEquals("textRoot", xp.getRootElementName().getLocalPart());
93+
}
94+
}
95+
96+
// [dataformat-xml#496]: root with namespace — verify all QName components
97+
@Test
98+
public void testRootNameWithNamespace() throws Exception
99+
{
100+
try (JsonParser p = MAPPER.createParser(
101+
"<ns:root xmlns:ns='http://example.com'><ns:child>val</ns:child></ns:root>")) {
102+
FromXmlParser xp = (FromXmlParser) p;
103+
QName rootName = xp.getRootElementName();
104+
assertEquals("root", rootName.getLocalPart());
105+
assertEquals("http://example.com", rootName.getNamespaceURI());
106+
assertEquals("ns", rootName.getPrefix());
107+
}
108+
}
109+
110+
// [dataformat-xml#496]: root with default namespace (no prefix)
111+
@Test
112+
public void testRootNameWithDefaultNamespace() throws Exception
113+
{
114+
try (JsonParser p = MAPPER.createParser(
115+
"<root xmlns='http://example.com'><child>val</child></root>")) {
116+
FromXmlParser xp = (FromXmlParser) p;
117+
QName rootName = xp.getRootElementName();
118+
assertEquals("root", rootName.getLocalPart());
119+
assertEquals("http://example.com", rootName.getNamespaceURI());
120+
assertEquals("", rootName.getPrefix());
121+
}
122+
}
123+
124+
// [dataformat-xml#496]: root with multiple children (no attributes)
125+
@Test
126+
public void testRootNameMultipleChildren() throws Exception
127+
{
128+
RootNameHolder result = MAPPER.readValue(
129+
"<document><a>1</a><b>2</b><c>3</c></document>", RootNameHolder.class);
130+
assertEquals("document", result.rootName.getLocalPart());
131+
}
132+
}

0 commit comments

Comments
 (0)