Skip to content

Commit 2ecfc93

Browse files
ewoutkramerclaude
andcommitted
Prune empty objects/elements via token-deferring writers
The FHIR spec does not allow empty objects or elements, but the serializers opened a structure before knowing whether any members would follow, so an empty POCO - or a SerializationFilter that removed everything inside - left behind a "{}" or a "<x/>". Our own deserializer rejects that output (OBJECTS_CANNOT_BE_EMPTY), so summarized resources did not round-trip. Rather than predicting emptiness up front, or serializing into a side buffer and inspecting the bytes, postpone only the tokens that *open* a structure - the property name plus the brace, or the start tag. They are handed to the real writer as soon as content arrives, and discarded when the matching close comes first. Nothing that has been written ever needs taking back. Because the tokens still reach the underlying writer in document order, it keeps track of depth, separators and indentation itself. That removes the buffer-and-splice in deferSerializeForFilter, along with the indentation bug it caused for '_elementName' objects, and makes the filtered and unfiltered paths one and the same. It also subsumes the two hand-rolled wroteStartArray/numNullsMissed loops that kept 'elementName'/'_elementName' arrays aligned. Observable changes, all of them output that was invalid FHIR before: - empty complex members and array items are omitted (array items shift up); - indentation of '_elementName' objects is now correct; - Xml prunes empty elements too, including empty contained resources - in Json a resource always writes 'resourceType', so it is never empty there. Also hoists the class-mapping lookup above the first write in both serializers, so a failure cannot leave a half-open structure behind, and drops a leftover Console.WriteLine from BaseFhirXmlSerializer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3ccb4d3 commit 2ecfc93

10 files changed

Lines changed: 798 additions & 199 deletions

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

Lines changed: 64 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,25 @@ public void Serialize(Base instance, Utf8JsonWriter writer, Func<SerializationFi
4747
if (filter is not null)
4848
instance = SerializationUtil.MakeSubsettedClone(instance);
4949

50+
// Structures that turn out to be empty must not be written at all, but we only know that after we
51+
// have walked their members. The PruningJsonWriter postpones the opening tokens for us, so the
52+
// serializer below can simply write, and never has to take an empty object back.
53+
// Note that the root is written even when it is empty: our public API promises Json output, and
54+
// callers like SerializeToDocument() would choke on an empty payload.
55+
var pruningWriter = new PruningJsonWriter(writer);
56+
5057
// This handles an edge-case where we are asked to serialize just a primitive value.
5158
// For compatibility with SDK5 logic, we emit object with pseudo-property 'value' and value of the fhir primitive.
5259
// Issue for context: https://github.com/FirelyTeam/firely-net-sdk/issues/3286
5360
if (instance is not PrimitiveType val)
5461
{
55-
serializeInternal(instance, writer, filter);
62+
serializeInternal(instance, null, pruningWriter, filter, PruningJsonWriter.OnEmpty.Keep);
5663
}
5764
else
5865
{
59-
writer.WriteStartObject();
60-
serializeFhirPrimitive("value", val, writer, filter);
61-
writer.WriteEndObject();
66+
pruningWriter.WriteStartObject(onEmpty: PruningJsonWriter.OnEmpty.Keep);
67+
serializeFhirPrimitive("value", val, pruningWriter, filter);
68+
pruningWriter.WriteEndObject();
6269
}
6370
}
6471

@@ -67,38 +74,48 @@ public void Serialize(Base instance, Utf8JsonWriter writer, Func<SerializationFi
6774
/// </summary>
6875
/// <remarks>Not serializing the "value" element is useful when serializing FHIR primitives into two properties, one
6976
/// with just the value, and one with the id/extensions.</remarks>
77+
/// <param name="element">The element to serialize, or <c>null</c> to write a placeholder.</param>
78+
/// <param name="propertyName">The name of the property the element is the value of, or <c>null</c> when it
79+
/// is an item of an array. It is passed to the writer along with the opening brace, so that it can be
80+
/// dropped together with it when the element turns out to be empty.</param>
81+
/// <param name="writer">The writer to serialize into.</param>
82+
/// <param name="filter">An optional filter determining which members to serialize.</param>
83+
/// <param name="onEmpty">What the writer should do when the element produces no output at all.</param>
7084
private void serializeInternal(
7185
Base? element,
72-
Utf8JsonWriter writer,
73-
SerializationFilter? filter)
86+
string? propertyName,
87+
PruningJsonWriter writer,
88+
SerializationFilter? filter,
89+
PruningJsonWriter.OnEmpty onEmpty = PruningJsonWriter.OnEmpty.Omit)
7490
{
7591
if (element is null)
7692
{
7793
// empty objects in arrays may occur in error situations.
78-
writer.WriteNullValue();
94+
writer.Value.WriteNullValue();
7995
return;
8096
}
8197

82-
writer.WriteStartObject();
83-
84-
if (element is Resource r and not DynamicResource { DynamicTypeName: null })
85-
writer.WriteString("resourceType", r.TypeName);
86-
8798
// Only throw if we don't have a mapping where we are expected to: when this is a subclass of Base.
99+
// Resolved before any output is written, so a failure does not leave the writer in a broken state.
88100
if (Inspector.FindOrImportClassMapping(element) is not {} mapping)
89101
throw new InvalidOperationException($"Encountered type {element.GetType()}, which is a support POCO for FHIR, but does not " +
90102
$"have sufficient metadata to be used by the serializer.");
91103

104+
writer.WriteStartObject(propertyName, onEmpty);
105+
106+
if (element is Resource r and not DynamicResource { DynamicTypeName: null })
107+
writer.WriteString("resourceType", r.TypeName);
108+
92109
filter?.EnterObject(element, mapping);
93110

94111
foreach (var member in element.EnumerateElements())
95112
{
96-
var propertyMapping = mapping?.FindMappedElementByName(member.Key);
113+
var propertyMapping = mapping.FindMappedElementByName(member.Key);
97114

98115
if (filter?.TryEnterMember(member.Key, member.Value, propertyMapping) == false)
99116
continue;
100117

101-
var propertyName = propertyMapping switch
118+
var memberPropertyName = propertyMapping switch
102119
{
103120
{ Choice: ChoiceType.DatatypeChoice } => addSuffixToElementName(member.Key, member.Value),
104121
null when member.Value is DataType annotatable && annotatable.HasAnnotation<ChoiceElementAnnotation>()
@@ -109,26 +126,24 @@ private void serializeInternal(
109126
switch (member.Value)
110127
{
111128
case PrimitiveType pt:
112-
serializeFhirPrimitive(propertyName, pt, writer, filter);
129+
serializeFhirPrimitive(memberPropertyName, pt, writer, filter);
113130
break;
114131
case IReadOnlyList<PrimitiveType?> pts:
115-
serializeFhirPrimitiveList(propertyName, pts, writer, filter);
132+
serializeFhirPrimitiveList(memberPropertyName, pts, writer, filter);
116133
break;
117134
case IReadOnlyList<Base?> children: // Not List<Base>, since that is an invariant type.
118135
{
119-
writer.WritePropertyName(propertyName);
120-
writer.WriteStartArray();
136+
writer.WriteStartArray(memberPropertyName);
121137

122138
foreach (var child in children)
123-
serializeInternal(child, writer, filter);
139+
serializeInternal(child, null, writer, filter);
124140

125141
writer.WriteEndArray();
126142
break;
127143
}
128144
case Base b:
129145
{
130-
writer.WritePropertyName(propertyName);
131-
serializeInternal(b, writer, filter);
146+
serializeInternal(b, memberPropertyName, writer, filter);
132147
break;
133148
}
134149
default:
@@ -163,81 +178,44 @@ private static string addSuffixToElementName(string elementName, object elementV
163178
private void serializeFhirPrimitiveList(
164179
string elementName,
165180
IReadOnlyList<PrimitiveType?> values,
166-
Utf8JsonWriter writer,
181+
PruningJsonWriter writer,
167182
SerializationFilter? filter)
168183
{
169-
if(values is null) throw new ArgumentNullException(nameof(values));
184+
if (values is null) throw new ArgumentNullException(nameof(values));
170185

171186
// Don't serialize empty collections.
172187
if (values.Count == 0) return;
173188

174-
// We should not write a "elementName" property until we encounter an actual
175-
// value. If we do, we should "catch up", by creating the property starting
176-
// with a json array that contains 'null' for each of the elements we encountered
177-
// until now that did not have a value id/extensions.
178-
bool wroteStartArray = false;
179-
int numNullsMissed = 0;
189+
// The "elementName" and "_elementName" arrays must have the same length, so that each id/extension
190+
// lines up with the value it belongs to. Entries without content therefore become placeholders
191+
// rather than being left out. Neither array should be written at all, however, if none of the
192+
// entries has content - which is what the writer's placeholders take care of: it only writes them
193+
// once the array turns out to hold something.
194+
writer.WriteStartArray(elementName);
180195

181196
foreach (var value in values)
182197
{
183198
if (value?.JsonValue is not null)
184-
{
185-
if (!wroteStartArray)
186-
{
187-
wroteStartArray = true;
188-
writeStartArray(elementName, numNullsMissed, writer);
189-
}
190-
191-
SerializePrimitiveValue(value, writer);
192-
}
199+
SerializePrimitiveValue(value, writer.Value);
193200
else
194-
{
195-
if (wroteStartArray)
196-
writer.WriteNullValue();
197-
else
198-
numNullsMissed += 1;
199-
}
201+
writer.WriteNullPlaceholder();
200202
}
201203

202-
if (wroteStartArray) writer.WriteEndArray();
204+
writer.WriteEndArray();
203205

204-
// We should not write a "_elementName" property until we encounter an actual
205-
// id/extension. If we do, we should "catch up", by creating the property starting
206-
// with a json array that contains 'null' for each of the elements we encountered
207-
// until now that did not have id/extensions etc.
208-
wroteStartArray = false;
209-
numNullsMissed = 0;
206+
writer.WriteStartArray("_" + elementName);
210207

211208
foreach (var value in values)
212209
{
213-
if (value?.EnumerateElements().Any() == true)
214-
{
215-
if (!wroteStartArray)
216-
{
217-
wroteStartArray = true;
218-
writeStartArray("_" + elementName, numNullsMissed, writer);
219-
}
220-
221-
serializeInternal(value, writer, filter);
222-
}
210+
// As in serializeFhirPrimitive: a value without id/extensions can only ever become a
211+
// placeholder, so there is no point descending into it.
212+
if (value?.EnumerateElements().Any() != true)
213+
writer.WriteNullPlaceholder();
223214
else
224-
{
225-
if (wroteStartArray)
226-
writer.WriteNullValue();
227-
else
228-
numNullsMissed += 1;
229-
}
215+
serializeInternal(value, null, writer, filter, PruningJsonWriter.OnEmpty.NullPlaceholder);
230216
}
231217

232-
if (wroteStartArray) writer.WriteEndArray();
233-
}
234-
235-
private static void writeStartArray(string propName, int numNulls, Utf8JsonWriter writer)
236-
{
237-
writer.WriteStartArray(propName);
238-
239-
for (int i = 0; i < numNulls; i++)
240-
writer.WriteNullValue();
218+
writer.WriteEndArray();
241219
}
242220

243221

@@ -246,22 +224,27 @@ private static void writeStartArray(string propName, int numNulls, Utf8JsonWrite
246224
/// </summary>
247225
/// <remarks>FHIR primitives are handled separately here since they may require
248226
/// serialization into two Json properties called "elementName" and "_elementName".</remarks>
249-
private void serializeFhirPrimitive(string elementName, PrimitiveType value, Utf8JsonWriter writer, SerializationFilter? filter)
227+
private void serializeFhirPrimitive(string elementName, PrimitiveType value, PruningJsonWriter writer, SerializationFilter? filter)
250228
{
251229
if (value is null) throw new ArgumentNullException(nameof(value));
252230

253231
if (value.JsonValue is not null)
254232
{
255233
// Write a property with 'elementName'
256234
writer.WritePropertyName(elementName);
257-
SerializePrimitiveValue(value, writer);
235+
SerializePrimitiveValue(value, writer.Value);
258236
}
259237

238+
// A primitive without id/extensions has nothing to put in '_elementName', so don't descend into it.
239+
// This is just a shortcut for the most common case - the writer would drop the resulting empty
240+
// object anyway - but it also keeps the filter callbacks for such a primitive as they were.
260241
if (!value.EnumerateElements().Any()) return;
261-
262-
deferSerializeForFilter(elementName, value, writer, filter);
242+
243+
// Write a property with '_elementName' for the id/extensions - which the writer leaves out
244+
// altogether when the filter turns out to have removed all of them.
245+
serializeInternal(value, "_" + elementName, writer, filter);
263246
}
264-
247+
265248
private static void tryWriteBase64(Utf8JsonWriter writer, string text)
266249
{
267250
var maxSize = Base64.GetMaxDecodedFromUtf8Length(text.Length);
@@ -272,24 +255,6 @@ private static void tryWriteBase64(Utf8JsonWriter writer, string text)
272255
writer.WriteStringValue(text);
273256
}
274257

275-
private void deferSerializeForFilter(string elementName, PrimitiveType value, Utf8JsonWriter writer, SerializationFilter? filter)
276-
{
277-
var buffer = new ArrayBufferWriter<byte>();
278-
using (var defer = new Utf8JsonWriter(buffer, writer.Options))
279-
{
280-
serializeInternal(value, defer, filter);
281-
}
282-
283-
// brackets only, so either object was empty, or we filtered everything out
284-
const int expectedLength = 3;
285-
if (buffer.WrittenCount < expectedLength) return;
286-
287-
// Write a property with '_elementName'
288-
writer.WritePropertyName("_" + elementName);
289-
// write the deferred data
290-
writer.WriteRawValue(buffer.WrittenSpan, skipInputValidation: true);
291-
}
292-
293258
/// <summary>
294259
/// Serialize a primitive POCO into Json.
295260
/// </summary>

0 commit comments

Comments
 (0)