Skip to content

Commit cd6ac00

Browse files
committed
Omit empty XML elements without buffering
1 parent 1cf75cc commit cd6ac00

4 files changed

Lines changed: 253 additions & 45 deletions

File tree

src/Hl7.Fhir.Base/Serialization/BaseFhirXmlSerializer.cs

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,27 @@ public void Serialize(
5959

6060
writeComments(rootComments?.CommentsBefore, writer);
6161

62+
var deferredWriter = new DeferredXmlWriter(writer);
63+
6264
// Wrap the instance with a named element if either a root name is given,
6365
// or we are serializing a datatype (=a subtree).
6466
if (rootName is not null)
65-
writer.WriteStartElement(rootName, XmlNs.FHIR);
66-
else if(instance is not Resource)
67-
writer.WriteStartElement(instance.TypeName, XmlNs.FHIR);
68-
69-
serializeInternal(instance, writer, filter);
70-
71-
if (rootName is not null) writer.WriteEndElement();
67+
{
68+
using var root = deferredWriter.BeginElement(rootName, XmlNs.FHIR, required: true);
69+
serializeInternal(instance, deferredWriter, filter);
70+
}
71+
else if (instance is Resource)
72+
{
73+
serializeInternal(instance, deferredWriter, filter, required: true);
74+
}
75+
else
76+
{
77+
using var root = deferredWriter.BeginElement(instance.TypeName, XmlNs.FHIR, required: true);
78+
serializeInternal(instance, deferredWriter, filter);
79+
}
7280

73-
// Only write these once the root element is actually closed - for a datatype without a root name
74-
// the wrapping element above is left open for WriteEndDocument() to close.
81+
// Document-end comments belong after the root element and are only defined for resources
82+
// and explicitly named subtree documents.
7583
if (rootName is not null || instance is Resource)
7684
writeComments(rootComments?.DocumentEndComments, writer);
7785

@@ -80,27 +88,36 @@ public void Serialize(
8088

8189
private void serializeInternal(
8290
Base element,
83-
XmlWriter writer,
84-
SerializationFilter? filter)
91+
DeferredXmlWriter writer,
92+
SerializationFilter? filter,
93+
bool required = false)
8594
{
86-
if (element is Resource r)
87-
writer.WriteStartElement(r.TypeName, XmlNs.FHIR);
88-
8995
// Only throw if we don't have a mapping where we are expected to: when this is a subclass of Base.
9096
if (Inspector.FindOrImportClassMapping(element) is not {} mapping)
9197
throw new InvalidOperationException($"Encountered type {element.GetType()}, which is a support POCO for FHIR, but does not " +
9298
$"have sufficient metadata to be used by the serializer.");
9399

100+
if (element is Resource r)
101+
{
102+
using var resource = writer.BeginElement(r.TypeName, XmlNs.FHIR, required);
103+
serializeObject(element, writer, filter, mapping);
104+
}
105+
else
106+
{
107+
serializeObject(element, writer, filter, mapping);
108+
}
109+
}
110+
111+
private void serializeObject(Base element, DeferredXmlWriter writer, SerializationFilter? filter, ClassMapping mapping)
112+
{
94113
filter?.EnterObject(element, mapping);
95114

96115
serializeElement(element, writer, filter, mapping);
97116

98117
filter?.LeaveObject(element, mapping);
99-
100-
if (element is Resource) writer.WriteEndElement();
101118
}
102119

103-
private void serializeElement(Base element, XmlWriter writer, SerializationFilter? filter, ClassMapping? mapping)
120+
private void serializeElement(Base element, DeferredXmlWriter writer, SerializationFilter? filter, ClassMapping mapping)
104121
{
105122
static int attributeSorter(PropertyMapping? mapping, Base? value)
106123
{
@@ -153,7 +170,7 @@ static int attributeSorter(PropertyMapping? mapping, Base? value)
153170

154171
// Comments that were the last content of this element in the source data. Written after the children,
155172
// so they end up just before the closing tag written by our caller.
156-
writeComments(element.Annotation<SourceComments>()?.ClosingComments, writer);
173+
writer.WriteClosingComments(element.Annotation<SourceComments>()?.ClosingComments);
157174
}
158175

159176
/// <summary>
@@ -183,29 +200,28 @@ private static string addSuffixToElementName(string elementName, object? element
183200
}
184201

185202

186-
private void serializeMemberValue(string elementName, object? value, XmlWriter writer, SerializationFilter? filter)
203+
private void serializeMemberValue(string elementName, object? value, DeferredXmlWriter writer, SerializationFilter? filter)
187204
{
188205
try
189206
{
190-
191-
switch (value)
192-
{
193-
case null:
194-
break; // In error situations there may be a null in a list, just don't serialize it.
195-
case XHtml xhtml:
196-
writeComments(xhtml.Annotation<SourceComments>()?.CommentsBefore, writer);
197-
writer.WriteRaw(xhtml.Value ?? "");
198-
break;
199-
case Base complex:
200-
writeComments(complex.Annotation<SourceComments>()?.CommentsBefore, writer);
201-
writer.WriteStartElement(elementName, XmlNs.FHIR);
202-
serializeInternal(complex, writer, filter);
203-
writer.WriteEndElement();
204-
break;
205-
default:
206-
SerializePrimitiveValue(elementName, value, writer);
207-
break;
208-
}
207+
switch (value)
208+
{
209+
case null:
210+
break; // In error situations there may be a null in a list, just don't serialize it.
211+
case XHtml xhtml:
212+
writer.WriteRaw(xhtml.Value ?? "", xhtml.Annotation<SourceComments>()?.CommentsBefore);
213+
break;
214+
case Base complex:
215+
using (writer.BeginElement(elementName, XmlNs.FHIR,
216+
commentsBefore: complex.Annotation<SourceComments>()?.CommentsBefore))
217+
{
218+
serializeInternal(complex, writer, filter);
219+
}
220+
break;
221+
default:
222+
SerializePrimitiveValue(elementName, value, writer.PrepareAttribute());
223+
break;
224+
}
209225
}
210226
catch (Exception e)
211227
{
@@ -242,4 +258,4 @@ protected virtual void SerializePrimitiveValue(string elementName, object value,
242258
}
243259

244260
[Obsolete("This class has been replaced by the equivalent BaseFhirXmlSerializer class.")]
245-
public class BaseFhirXmlPocoSerializer(ModelInspector inspector) : BaseFhirXmlSerializer(inspector);
261+
public class BaseFhirXmlPocoSerializer(ModelInspector inspector) : BaseFhirXmlSerializer(inspector);
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright (c) 2021, Firely (info@fire.ly) and contributors
3+
* See the file CONTRIBUTORS for details.
4+
*
5+
* This file is licensed under the BSD 3-Clause license
6+
* available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE
7+
*/
8+
9+
#nullable enable
10+
11+
using System;
12+
using System.Xml;
13+
14+
namespace Hl7.Fhir.Serialization;
15+
16+
/// <summary>
17+
/// Delays writing XML elements until they contain an attribute, child element, or raw value.
18+
/// This allows the serializer to omit empty FHIR elements without buffering their serialized form.
19+
/// </summary>
20+
internal sealed class DeferredXmlWriter(XmlWriter destination)
21+
{
22+
private ElementState[] _elements = new ElementState[8];
23+
private int _depth;
24+
25+
public ElementScope BeginElement(
26+
string localName,
27+
string namespaceUri,
28+
bool required = false,
29+
string[]? commentsBefore = null)
30+
{
31+
if (_depth == _elements.Length)
32+
Array.Resize(ref _elements, _elements.Length * 2);
33+
34+
var index = _depth++;
35+
_elements[index] = new ElementState(localName, namespaceUri, commentsBefore);
36+
37+
if (required) commit(index);
38+
39+
return new ElementScope(this, index);
40+
}
41+
42+
/// <summary>
43+
/// Returns the underlying writer positioned to write an attribute on the current element.
44+
/// </summary>
45+
public XmlWriter PrepareAttribute()
46+
{
47+
var current = requireCurrent();
48+
commit(current);
49+
return destination;
50+
}
51+
52+
/// <summary>
53+
/// Writes raw element content and any comments that preceded it. Empty raw content does not
54+
/// materialize the current element.
55+
/// </summary>
56+
public void WriteRaw(string value, string[]? commentsBefore = null)
57+
{
58+
if (string.IsNullOrEmpty(value)) return;
59+
60+
commit(requireCurrent());
61+
writeComments(commentsBefore);
62+
destination.WriteRaw(value);
63+
}
64+
65+
/// <summary>
66+
/// Writes comments that followed the current element's last child. Comments are not FHIR
67+
/// content, so they are discarded when the element has not otherwise materialized.
68+
/// </summary>
69+
public void WriteClosingComments(string[]? comments)
70+
{
71+
var current = requireCurrent();
72+
if (!_elements[current].Committed) return;
73+
74+
writeComments(comments);
75+
}
76+
77+
private int requireCurrent()
78+
{
79+
if (_depth == 0)
80+
throw new InvalidOperationException("An XML element must be open for this operation.");
81+
82+
return _depth - 1;
83+
}
84+
85+
private void commit(int index)
86+
{
87+
if (_elements[index].Committed) return;
88+
89+
if (index > 0) commit(index - 1);
90+
91+
writeComments(_elements[index].CommentsBefore);
92+
destination.WriteStartElement(_elements[index].LocalName, _elements[index].NamespaceUri);
93+
_elements[index].Committed = true;
94+
}
95+
96+
private void endElement(int index)
97+
{
98+
if (index != _depth - 1)
99+
throw new InvalidOperationException("XML elements must be closed in reverse order.");
100+
101+
if (_elements[index].Committed)
102+
destination.WriteEndElement();
103+
104+
_elements[index] = default;
105+
_depth--;
106+
}
107+
108+
private void writeComments(string[]? comments)
109+
{
110+
if (comments is null) return;
111+
112+
foreach (var comment in comments)
113+
destination.WriteComment(comment);
114+
}
115+
116+
private struct ElementState(
117+
string localName,
118+
string namespaceUri,
119+
string[]? commentsBefore)
120+
{
121+
public string LocalName { get; } = localName;
122+
public string NamespaceUri { get; } = namespaceUri;
123+
public string[]? CommentsBefore { get; } = commentsBefore;
124+
public bool Committed { get; set; }
125+
}
126+
127+
internal struct ElementScope(DeferredXmlWriter owner, int index) : IDisposable
128+
{
129+
private DeferredXmlWriter? _owner = owner;
130+
private readonly int _index = index;
131+
132+
public void Dispose()
133+
{
134+
_owner?.endElement(_index);
135+
_owner = null;
136+
}
137+
}
138+
}

src/Hl7.Fhir.Support.Poco.Tests/Serialization/FhirXmlSerializationTests.cs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using Hl7.Fhir.Utility;
88
using Microsoft.VisualStudio.TestTools.UnitTesting;
99
using System.IO;
10-
using System.Linq;
1110
using System.Xml.Linq;
1211

1312
namespace Hl7.Fhir.Support.Poco.Tests
@@ -49,8 +48,63 @@ public void SerializesInvalidData()
4948
Patient p = new() { Contact = new() { new Patient.ContactComponent() } };
5049
xdoc = XDocument.Parse(SerializationUtil.WriteXmlToString(w => serializer.Serialize(p, w)));
5150
var contactArray = xdoc.Root.Elements(XName.Get("contact", XmlNs.FHIR));
52-
contactArray.Count().Should().Be(1);
53-
contactArray.First().Elements().Should().BeEmpty();
51+
contactArray.Should().BeEmpty();
52+
}
53+
54+
[TestMethod]
55+
public void OmitsComplexValuesMadeEmptyByFilter()
56+
{
57+
Patient patient = new()
58+
{
59+
MaritalStatus = new CodeableConcept("http://example.org", "married"),
60+
Contact = [new Patient.ContactComponent { Name = new HumanName { Family = "Doe" } }]
61+
};
62+
63+
static SerializationFilter filter() => new TopLevelFilter(
64+
new ElementMetadataFilter { IncludeNames = ["maritalStatus", "contact"] },
65+
new ElementMetadataFilter { IncludeNames = ["not-present"] });
66+
67+
var actual = new BaseFhirXmlSerializer(ModelInfo.ModelInspector)
68+
.SerializeToString(patient, filterFactory: filter);
69+
var root = XDocument.Parse(actual).Root!;
70+
71+
root.Elements(XName.Get("maritalStatus", XmlNs.FHIR)).Should().BeEmpty();
72+
root.Elements(XName.Get("contact", XmlNs.FHIR)).Should().BeEmpty();
73+
}
74+
75+
[TestMethod]
76+
public void KeepsPrimitiveElementsWithXmlContent()
77+
{
78+
Patient patient = new()
79+
{
80+
ActiveElement = new FhirBoolean(true),
81+
BirthDateElement = new Date { ElementId = "date-id" }
82+
};
83+
84+
var actual = new BaseFhirXmlSerializer(ModelInfo.ModelInspector).SerializeToString(patient);
85+
var root = XDocument.Parse(actual).Root!;
86+
87+
root.Element(XName.Get("active", XmlNs.FHIR))?.Attribute("value")?.Value.Should().Be("true");
88+
root.Element(XName.Get("birthDate", XmlNs.FHIR))?.Attribute("id")?.Value.Should().Be("date-id");
89+
}
90+
91+
[TestMethod]
92+
public void CommentsDoNotMaterializeAnEmptyElement()
93+
{
94+
Patient.ContactComponent contact = new();
95+
contact.AddAnnotation(new SourceComments
96+
{
97+
CommentsBefore = ["before empty contact"],
98+
ClosingComments = ["closing empty contact"]
99+
});
100+
101+
Patient patient = new() { Active = true, Contact = [contact] };
102+
103+
var actual = new BaseFhirXmlSerializer(ModelInfo.ModelInspector).SerializeToString(patient);
104+
var root = XDocument.Parse(actual, LoadOptions.PreserveWhitespace).Root!;
105+
106+
root.Elements(XName.Get("contact", XmlNs.FHIR)).Should().BeEmpty();
107+
actual.Should().NotContain("empty contact");
54108
}
55109

56110
[TestMethod]
@@ -119,4 +173,4 @@ public void FilterFactoryCreatesNewInstancesEachTime()
119173
filter1.Should().NotBeSameAs(filter2);
120174
}
121175
}
122-
}
176+
}

src/Hl7.Fhir.Support.Poco.Tests/Serialization/snapshots/FhirXmlDeserializationTests.SerializingErroneousResource_Should_ThrowExpectedErrors.verified.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,5 +541,5 @@
541541
Message: Element 'url' should have been encoded as an attribute. At Patient.gender.extension[3], line 146, position 8.
542542
}
543543
],
544-
Obj: <Patient xmlns="http://hl7.org/fhir"><contained><PatientX /></contained><contained><Patient><active value="true" /><active value="false" /></Patient></contained><contained><Patient><active value="not-checked" /></Patient></contained><contained><anotherElement value="incontained" /></contained><contained><Patient /></contained><contained><PatientId><id value="1" /></PatientId></contained><contained><Patient><id value="1" /><id value="2" /><id value="3" /><name /><name /></Patient></contained><contained><Patient><active value="false" anotherAttribute="true" /><birthDate value="February" /></Patient></contained><contained><Patient><id value="PrimitiveWhenComplex" /><active value="true"><name value="false" /><data value="testData" /></active><gender value="cat" /><address value="123 Main St" /></Patient></contained><contained><Patient no="yes"><id /><name value="Doe" /><unknown /></Patient></contained><contained><OperationOutcome><id value="shouldhaveissues" /></OperationOutcome></contained><contained><Observation valueString="not here"><status value="final" /><code><text value="Decimal Testing Observation" /></code><valueUnknown><highest value="1234" /></valueUnknown></Observation></contained><contained><Observation><code><text value="Decimal Testing Observation" /></code><component><code><text value="Component" /></code><valueQuantity><value value="10000000000000000" /><unit value="g" /></valueQuantity></component><component><code><text value="Component" /></code><valueQuantity><value value="0.0000000000000000000000010000" /><unit value="g" /></valueQuantity></component><component><code><text value="Component" /></code><valueQuantity><value value="-1.00000000000000000e245" /><unit value="g" /></valueQuantity></component></Observation></contained><identifier><use value="official" /><system value="urn:oid:9.0.1.2.3.4.5.6.7" /><value value="7654321" /></identifier><identifier><use value="official" /><system value="http://some.other/system" /><value value="11223344" /></identifier><active value="true" /><name><use value="official" /><family value="Donald" /><given value="Duck" /></name><telecom><system value="phone" /><value value="555-555-2003" /><use value="work" /><rank value="1" /></telecom><gender value="male"><extension url="http://example.org/StructureDefinition/real-gender"><valueCode value="metrosexual" /></extension><extension url="urn:oid:crap"><valueCode value="metrosexual" /></extension><extension url="http://then.nl" huh="hi!"><valueCode value="metrosexual" /></extension><extension url="http://nu.nl"><valueCode value="metrosexual" /></extension></gender></Patient>
545-
}
544+
Obj: <Patient xmlns="http://hl7.org/fhir"><contained><Patient><active value="true" /><active value="false" /></Patient></contained><contained><Patient><active value="not-checked" /></Patient></contained><contained><anotherElement value="incontained" /></contained><contained><PatientId><id value="1" /></PatientId></contained><contained><Patient><id value="1" /><id value="2" /><id value="3" /></Patient></contained><contained><Patient><active value="false" anotherAttribute="true" /><birthDate value="February" /></Patient></contained><contained><Patient><id value="PrimitiveWhenComplex" /><active value="true"><name value="false" /><data value="testData" /></active><gender value="cat" /><address value="123 Main St" /></Patient></contained><contained><Patient no="yes"><name value="Doe" /></Patient></contained><contained><OperationOutcome><id value="shouldhaveissues" /></OperationOutcome></contained><contained><Observation valueString="not here"><status value="final" /><code><text value="Decimal Testing Observation" /></code><valueUnknown><highest value="1234" /></valueUnknown></Observation></contained><contained><Observation><code><text value="Decimal Testing Observation" /></code><component><code><text value="Component" /></code><valueQuantity><value value="10000000000000000" /><unit value="g" /></valueQuantity></component><component><code><text value="Component" /></code><valueQuantity><value value="0.0000000000000000000000010000" /><unit value="g" /></valueQuantity></component><component><code><text value="Component" /></code><valueQuantity><value value="-1.00000000000000000e245" /><unit value="g" /></valueQuantity></component></Observation></contained><identifier><use value="official" /><system value="urn:oid:9.0.1.2.3.4.5.6.7" /><value value="7654321" /></identifier><identifier><use value="official" /><system value="http://some.other/system" /><value value="11223344" /></identifier><active value="true" /><name><use value="official" /><family value="Donald" /><given value="Duck" /></name><telecom><system value="phone" /><value value="555-555-2003" /><use value="work" /><rank value="1" /></telecom><gender value="male"><extension url="http://example.org/StructureDefinition/real-gender"><valueCode value="metrosexual" /></extension><extension url="urn:oid:crap"><valueCode value="metrosexual" /></extension><extension url="http://then.nl" huh="hi!"><valueCode value="metrosexual" /></extension><extension url="http://nu.nl"><valueCode value="metrosexual" /></extension></gender></Patient>
545+
}

0 commit comments

Comments
 (0)