Skip to content

Commit f96af3d

Browse files
authored
Fix #629 (partially), invalid XML for any getter (#822)
1 parent 1f23004 commit f96af3d

5 files changed

Lines changed: 191 additions & 1 deletion

File tree

release-notes/CREDITS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ Severin Kistler (@kistlers)
7474
`java.util.Map`) fails
7575
(3.2.0)
7676

77+
James Dudley (@dudleycodes)
78+
* Reported #629: `@JsonAnySetter` mangles nested xml Elements and Attributes
79+
during serialization
80+
(3.2.0)
81+
7782
Alex Olson (@alexkolson)
7883
* Reported #665: `@JacksonXmlProperty` appears to behave differently than
7984
`@JsonProperty` when used on java records

release-notes/VERSION

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ Version: 3.x (for earlier see VERSION-2.x)
5555
`java.util.Map`) fails
5656
(reported by Severin K)
5757
(fix by Christopher M)
58+
#629: `@JsonAnySetter` mangles nested xml Elements and Attributes during serialization
59+
(reported by James D)
60+
(fix by @cowtowncoder, w/ Claude code)
5861
#665: `@JacksonXmlProperty` appears to behave differently than
5962
`@JsonProperty` when used on java records
6063
(reported by Alex O)

src/main/java/tools/jackson/dataformat/xml/XmlFactory.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,8 @@ protected ToXmlGenerator _toXmlGenerator(ObjectWriteContext writeCtxt, IOContext
423423
writeCtxt.getFormatWriteFeatures(_formatWriteFeatures),
424424
sw,
425425
_xmlPrettyPrinter(writeCtxt),
426-
_nameProcessor);
426+
_nameProcessor,
427+
_cfgNameForTextElement);
427428
}
428429

429430
/*

src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ public class ToXmlGenerator
7272
*/
7373
protected final boolean _stax2Emulation;
7474

75+
/**
76+
* Name used for pseudo-properties used to represent XML text segments
77+
* (which may occur within elements that also have attributes or child
78+
* elements): default value is empty String ({@code ""}).
79+
*<p>
80+
* Needed to recognize and handle such properties during serialization:
81+
* see [dataformat-xml#629] for details.
82+
*
83+
* @since 3.2
84+
*/
85+
protected final String _cfgNameForTextElement;
86+
87+
/**
88+
* Pre-computed {@link QName} for {@link #_cfgNameForTextElement}, used
89+
* as a placeholder when the text element name is encountered in
90+
* {@link #writeName(String)} to ensure {@link #_nextName} is non-null.
91+
*
92+
* @since 3.2
93+
*/
94+
protected final QName _textElementQName;
95+
7596
/*
7697
/**********************************************************************
7798
/* Logical output state
@@ -145,9 +166,24 @@ public class ToXmlGenerator
145166
/**********************************************************************
146167
*/
147168

169+
/**
170+
* @deprecated Since 3.2
171+
*/
172+
@Deprecated // @since 3.2
148173
public ToXmlGenerator(ObjectWriteContext writeCtxt, IOContext ioCtxt,
149174
int streamWriteFeatures, int xmlFeatures,
150175
XMLStreamWriter sw, XmlPrettyPrinter pp, XmlNameProcessor nameProcessor)
176+
{
177+
this(writeCtxt, ioCtxt, streamWriteFeatures, xmlFeatures, sw, pp, nameProcessor, "");
178+
}
179+
180+
/**
181+
* @since 3.2
182+
*/
183+
public ToXmlGenerator(ObjectWriteContext writeCtxt, IOContext ioCtxt,
184+
int streamWriteFeatures, int xmlFeatures,
185+
XMLStreamWriter sw, XmlPrettyPrinter pp, XmlNameProcessor nameProcessor,
186+
String nameForTextElement)
151187
{
152188
super(writeCtxt, ioCtxt, streamWriteFeatures);
153189
_formatFeatures = xmlFeatures;
@@ -159,6 +195,8 @@ public ToXmlGenerator(ObjectWriteContext writeCtxt, IOContext ioCtxt,
159195
? DupDetector.rootDetector(this) : null;
160196
_streamWriteContext = SimpleStreamWriteContext.createRootContext(dups);
161197
_nameProcessor = nameProcessor;
198+
_cfgNameForTextElement = nameForTextElement;
199+
_textElementQName = new QName(nameForTextElement);
162200
}
163201

164202
/**
@@ -438,6 +476,18 @@ public JsonGenerator writeName(String name) throws JacksonException
438476
setNextName(new QName(XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI,
439477
"type", "xsi"));
440478
setNextIsAttribute(true);
479+
} else if (name.equals(_cfgNameForTextElement)) {
480+
// [dataformat-xml#629]: Name matching the "unnamed text property" marker
481+
// (FromXmlParser.DEFAULT_UNNAMED_TEXT_PROPERTY, default "") represents
482+
// XML text content within elements that also have attributes or child
483+
// elements. Write as unwrapped text, not as an element (which would
484+
// produce invalid XML like "<>...</>" for the default empty name).
485+
_nextIsUnwrapped = true;
486+
// Must still ensure _nextName is non-null so value-write methods
487+
// don't throw (they check _nextName == null before checkNextIsUnwrapped)
488+
if (_nextName == null) {
489+
_nextName = _textElementQName;
490+
}
441491
} else {
442492
// Should this ever get called?
443493
ns = (_nextName == null) ? "" : _nextName.getNamespaceURI();
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package tools.jackson.dataformat.xml.ser;
2+
3+
import java.util.*;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import com.fasterxml.jackson.annotation.*;
8+
9+
import tools.jackson.dataformat.xml.XmlMapper;
10+
import tools.jackson.dataformat.xml.XmlTestUtil;
11+
12+
import static org.junit.jupiter.api.Assertions.*;
13+
14+
// [dataformat-xml#629] @JsonAnySetter mangles nested XML elements and attributes
15+
public class AnySetterNestedXml629Test extends XmlTestUtil
16+
{
17+
static class PojoWith629 {
18+
@JsonProperty("id")
19+
public String id;
20+
21+
@JsonAnySetter
22+
@JsonAnyGetter
23+
public Map<String, Object> others = new LinkedHashMap<>();
24+
}
25+
26+
private final XmlMapper MAPPER = newMapper();
27+
28+
// Simple unmapped elements round-trip correctly
29+
@Test
30+
public void testAnySetterSimpleRoundTrip() throws Exception
31+
{
32+
String input = "<PojoWith629><id>123</id><simple>hello</simple></PojoWith629>";
33+
PojoWith629 pojo = MAPPER.readValue(input, PojoWith629.class);
34+
assertEquals("123", pojo.id);
35+
assertEquals("hello", pojo.others.get("simple"));
36+
37+
String xml = MAPPER.writeValueAsString(pojo);
38+
assertTrue(xml.contains("<simple>hello</simple>"), "Got: " + xml);
39+
}
40+
41+
// Nested elements without attributes round-trip correctly
42+
@Test
43+
public void testAnySetterNestedElementsRoundTrip() throws Exception
44+
{
45+
String input =
46+
"<PojoWith629>" +
47+
"<id>123</id>" +
48+
"<unmapped>" +
49+
"<a>1</a>" +
50+
"<b>2</b>" +
51+
"</unmapped>" +
52+
"</PojoWith629>";
53+
54+
PojoWith629 pojo = MAPPER.readValue(input, PojoWith629.class);
55+
assertEquals("123", pojo.id);
56+
57+
Object unmapped = pojo.others.get("unmapped");
58+
assertNotNull(unmapped, "unmapped should be captured by @JsonAnySetter");
59+
assertTrue(unmapped instanceof Map, "Expected Map but got: " + unmapped.getClass());
60+
@SuppressWarnings("unchecked")
61+
Map<String, Object> nested = (Map<String, Object>) unmapped;
62+
assertEquals("1", nested.get("a"));
63+
assertEquals("2", nested.get("b"));
64+
65+
String xml = MAPPER.writeValueAsString(pojo);
66+
assertFalse(xml.contains("<>"), "Output contains empty tags: " + xml);
67+
assertFalse(xml.contains("</>"), "Output contains empty closing tags: " + xml);
68+
}
69+
70+
// [dataformat-xml#629]: Elements with attributes should not produce empty tags
71+
@Test
72+
public void testAnySetterAttributeElementRoundTrip() throws Exception
73+
{
74+
String input =
75+
"<PojoWith629>" +
76+
"<id>123</id>" +
77+
"<elem uid=\"1\">text</elem>" +
78+
"</PojoWith629>";
79+
80+
PojoWith629 pojo = MAPPER.readValue(input, PojoWith629.class);
81+
assertEquals("123", pojo.id);
82+
83+
String xml = MAPPER.writeValueAsString(pojo);
84+
assertFalse(xml.contains("<>"), "Output contains empty tags: " + xml);
85+
assertFalse(xml.contains("</>"), "Output contains empty closing tags: " + xml);
86+
// text content should be present (even if attribute-ness is lost)
87+
assertTrue(xml.contains("text"), "Got: " + xml);
88+
}
89+
90+
// [dataformat-xml#629]: The reporter's original case
91+
@Test
92+
public void testAnySetterNestedElementsWithAttributesRoundTrip() throws Exception
93+
{
94+
String input =
95+
"<PojoWith629>" +
96+
"<id>123</id>" +
97+
"<unmapped-element>" +
98+
"<e uid=\"1\">one</e>" +
99+
"<e uid=\"2\">TWO</e>" +
100+
"<e uid=\"3\">3</e>" +
101+
"</unmapped-element>" +
102+
"</PojoWith629>";
103+
104+
PojoWith629 pojo = MAPPER.readValue(input, PojoWith629.class);
105+
assertEquals("123", pojo.id);
106+
107+
String xml = MAPPER.writeValueAsString(pojo);
108+
assertFalse(xml.contains("<>"), "Output contains empty tags: " + xml);
109+
assertFalse(xml.contains("</>"), "Output contains empty closing tags: " + xml);
110+
}
111+
112+
// [dataformat-xml#629]: Verify text-element key works when it appears first
113+
// in the Map (edge case: _nextName could be null at that point)
114+
@Test
115+
public void testAnySetterTextKeyFirstInMap() throws Exception
116+
{
117+
PojoWith629 pojo = new PojoWith629();
118+
pojo.id = "1";
119+
// Construct a Map where the empty key (text element) appears first
120+
LinkedHashMap<String, Object> inner = new LinkedHashMap<>();
121+
inner.put("", "textval");
122+
inner.put("attr", "aval");
123+
pojo.others.put("elem", inner);
124+
125+
String xml = MAPPER.writeValueAsString(pojo);
126+
assertFalse(xml.contains("<>"), "Output contains empty tags: " + xml);
127+
assertFalse(xml.contains("</>"), "Output contains empty closing tags: " + xml);
128+
assertTrue(xml.contains("textval"), "Text content missing: " + xml);
129+
assertTrue(xml.contains("<attr>aval</attr>"), "Attribute-turned-element missing: " + xml);
130+
}
131+
}

0 commit comments

Comments
 (0)