Prune empty objects via token-deferring writers - #3569
Conversation
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>
Two review points on #3569: - Simplify the DynamicResource guard. An unnamed dynamic resource now writes "resourceType":"DynamicResource" rather than omitting the property, as agreed. That is the type the instance actually has, and it matches the element name the Xml serializer has always used for one. - Fix the two indented-output assertions, which normalised newlines on the actual value but not on the expected one. Since ".cs" is a "text" file, the raw string literals holding the expectations carry LF in the index and CRLF in a checkout under core.autocrlf - so they passed locally and failed on the build agent. Both sides are now normalised. Verified against the CI configuration (Release, net10.0, the pipeline's test filter) with the test file converted to CRLF to mirror a fresh checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PrepareContent() replaces the Value property on both pruning writers: getting the underlying writer flushes the pending opening tokens, and a property with that side effect would fire whenever a debugger evaluates it, corrupting the output mid-session. New tests pin behaviour that was implemented but not yet covered: Xml elements emptied by a filter are pruned, an attribute alone keeps its element alive, retained comments keep (or outlive) their element, and a '_elementName' array whose entries are all emptied by a filter is omitted entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the FHIR JSON and XML serializers to omit empty objects/elements by introducing token-deferring “pruning” writer wrappers. This prevents the serializers from emitting invalid {} in JSON or empty elements (e.g. <contact/>) in XML when a POCO (or a SerializationFilter) produces no content, improving round-tripping with the SDK’s own deserializers.
Changes:
- Add
PruningJsonWriter/PruningXmlWriterto defer structure-opening tokens until real content is written, dropping structures that remain empty. - Refactor
BaseFhirJsonSerializerandBaseFhirXmlSerializerto use the pruning writers, removing the prior buffer-and-splice approach and ensuring mapping resolution happens before any output is written. - Add
EmptyStructurePruningTestsand update existing serialization tests/snapshots to reflect pruning behavior and corrected indentation/output.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/Hl7.Fhir.Base/Serialization/PruningJsonWriter.cs | New writer wrapper that defers JSON object/array openings and drops empty structures (with optional null placeholders). |
| src/Hl7.Fhir.Base/Serialization/PruningXmlWriter.cs | New writer wrapper that defers XML start tags and drops elements that remain empty. |
| src/Hl7.Fhir.Base/Serialization/BaseFhirJsonSerializer.cs | Switch JSON serialization to pruning writer; simplify empty-structure handling and remove buffering code paths. |
| src/Hl7.Fhir.Base/Serialization/BaseFhirXmlSerializer.cs | Switch XML serialization to pruning writer; prune empty elements and remove debugging console output. |
| src/Hl7.Fhir.Support.Poco.Tests/Serialization/EmptyStructurePruningTests.cs | New targeted test suite covering pruning behavior across JSON/XML and filter interactions. |
| src/Hl7.Fhir.Support.Poco.Tests/Serialization/FhirJsonSerializationTests.cs | Update invalid-data test expectations to reflect omission of empty objects. |
| src/Hl7.Fhir.Support.Poco.Tests/Serialization/FhirXmlSerializationTests.cs | Update invalid-data test expectations to reflect omission of empty XML elements. |
| src/Hl7.Fhir.Support.Poco.Tests/Serialization/snapshots/FhirJsonDeserializationTests.SerializingErroneousResource_Should_ThrowExpectedErrors.verified.txt | Snapshot updated for pruned empty structures and formatting/indentation changes. |
| src/Hl7.Fhir.Support.Poco.Tests/Serialization/snapshots/FhirXmlDeserializationTests.SerializingErroneousResource_Should_ThrowExpectedErrors.verified.txt | Snapshot updated to reflect pruning of empty contained resources/elements in XML. |
| src/Hl7.Fhir.Support.Poco.Tests/Serialization/snapshots/FhirJsonDeserializationTests.JsonDeserializerHandleUnexpectedObject.verified.txt | Snapshot updated for formatting/indentation changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
andrzejskowronski
left a comment
There was a problem hiding this comment.
Had some remarks at first, but after viewing rest of the code it's clear why certain choices are made, so I removed them
- Thought a Queue would fit nicely for the Pending writes - but not with LIFO checks
- JSON passing null PropertyName - while not intuitive, keeps the code overall cleaner
So approving and merging
Description
Alternative to #3557 — same bug, different mechanism. Opening this alongside rather than on top of that branch, since it replaces the approach rather than amending it. @andrzejskowronski's diagnosis and test expectations are what this builds on.
The FHIR spec does not allow empty objects or elements, but the serializers open a structure before knowing whether any members will follow. So an empty POCO — or a
SerializationFilterthat removes everything inside — leaves behind a{}or a<x/>. Our own deserializer rejects that output (OBJECTS_CANNOT_BE_EMPTY,ELEMENT_CANNOT_BE_EMPTY), so summarized resources did not round-trip.The mechanism
#3557 solves it two ways at once: a predicate (
hasContent/hasElementContent/memberHasContent) that re-implements the serializer's emptiness rules for the unfiltered case, and — because a stateful filter can't be dry-run — a buffer-and-splice for the filtered case, serializing each subtree into anArrayBufferWriter<byte>and inspecting the bytes.It turns out no buffering is needed at all. The only thing that ever has to be undone is the token that opens a structure: the property name plus the
{/[, or the XML start tag. Nothing written after that point ever needs taking back — FHIR JSON has no "write content, then discover it was wrong" case.So two new
internal sealedclasses,PruningJsonWriterandPruningXmlWriter, hold the opening tokens back and hand them to the real writer the moment content arrives, discarding them when the matching close comes first. Because the tokens still reach the underlying writer in document order, it keeps track of depth, separators and indentation itself.What that buys over the buffering approach:
hasContenthad to mirror it exactly, which is why it deleted theand not DynamicResource { DynamicTypeName: null }guard to keep itsResource => truearm honest (see Copilot's open thread there). Here nothing forced that call — but review settled it the same way: the guard is gone as a deliberate decision of its own (6711377), so an unnamedDynamicResourcenow writes"resourceType": "DynamicResource", matching the element name Xml has always used for one.if (filter is null) … else …fork disappears from all three places.[-prologue written into a buffer to coax the right indent, no slicing it back off by scanning for the first{, noWriteRawValue(…, skipInputValidation: true), nowriter.CurrentDepth + (wroteStartArray ? 0 : 1)in callers. The_elementNameindentation bug fixes itself as a consequence rather than by construction.wroteStartArray+numNullsMissed, twice) that keptelementName/_elementNamearrays index-aligned, via the same deferral applied tonullplaceholders.hasContentpre-check skipsEnterObject/LeaveObjectfor pruned subtrees; deferral preserves today's exactEnter/TryEnter/Leavesequence, so there is no filter-state behaviour to reason about.ArrayBufferWriter+Utf8JsonWriterallocation per complex member.Net effect on
BaseFhirJsonSerializer: 163 lines changed, −36 net, andserializeInternalgoes back to straight-line writing.Also included: the class-mapping lookup is hoisted above the first write in both serializers (so a failure can't leave a half-open structure), and a leftover
Console.WriteLine(e)is removed fromBaseFhirXmlSerializer.serializeMemberValue. The writers hand out the underlying writer through aPrepareContent()method: getting it flushes the pending tokens, and a property with that side effect would fire whenever a debugger evaluates it.Observable changes
All of these were invalid FHIR before:
_elementNameobjects is now correct (same snapshot result as Refactor JSON serializer to handle optional and empty objects #3557).<contact/>and friends are pruned."language": {}and"link": [{}]disappear from the snapshot. The error list still reports what was wrong; only the echo is gone.DynamicResourcewrites"resourceType": "DynamicResource"rather than{}— settled in review: Json now matches the element name Xml has always used.Two asymmetries worth a reviewer's eye:
resourceType, so it is never empty; in XML the type name is the element name, so<contained><PatientX/></contained>has genuinely nothing in it. Pinned by a test — say the word if you'd rather force-keep resources in XML.Pruning deliberately stops at the document root:
SerializeToString(new HumanName())still yields{}, and in XML the root is always written becauseXmlWriter.WriteEndDocument()throws on a document without one.Public API
No new public surface — both writers are
internal sealed. BothSerializePrimitiveValueoverloads keep their signatures on both serializers; they areprotected virtualpublic API and SDK6 is released, so they receive the real writer after pending tokens have been flushed.Related issues
Alternative implementation of #3557.
Testing
New
EmptyStructurePruningTests(16 tests) covering: single and nested empty members collapsing in one pass; separators intact when a member in the middle is dropped (compact and pretty);_elementNameomitted when a filter empties it, including when it empties every entry of a_elementNamearray;_elementNameindented at its own depth; primitive-array alignment with leading, trailing and all-empty placeholders; alignment preserved when a filter empties an entry before and after the array opens; root written when empty, including the lone-primitive wrapper;resourceTypealways written, including for an unnamedDynamicResource;nullplaceholders kept in complex arrays; empty contained resource pruned in XML but not JSON; XML elements emptied by a filter pruned; XML elements kept alive by nothing but an attribute; retained comments keeping (or outliving) their element.Updated:
FhirJsonSerializationTests.SerializesInvalidDataand its XML counterpart; three Verify snapshots (the JSON one is byte-identical to what #3557 produces).Full runs, all green:
The example round-trips are the load-bearing ones here — they exercise both formats over every spec example, including the leading/trailing
nullplaceholders injson-edge-cases.jsonand narrative/contained-resource comment retention. Full solution builds clean.FirelyTeam Checklist
Not marked breaking: every output change is output that was invalid FHIR and that our own parser rejected. Worth a maintainer's call though — anyone diffing round-tripped invalid input will see a difference, and empty-sibling removal shifts array indices.
🤖 Generated with Claude Code