Skip to content

Commit 3fc6c70

Browse files
committed
feat(serialization): support metadata-driven ordered emit for transparent XML choice groups (Milestone 7)
- Enhanced `FHIRProperty` to define 'choiceGroup' metadata, supporting transparent XML choice groups without wrapper elements. - Introduced `ChoiceGroupItem` type for heterogeneous child elements emitted in document order. - Updated `FHIRComplexTypeXmlNormalizer` to preserve interleaved document order for choice groups by consuming source DOM elements during denormalization. - Added serialization support for nested choice groups at any depth, ensuring round-trip byte stability (XML to object to XML). - Implemented comprehensive tests validating ordered emit, namespace handling, and fidelity for choice group serialization and deserialization processes.
1 parent 9f6d7c2 commit 3fc6c70

9 files changed

Lines changed: 791 additions & 10 deletions

src/Component/Metadata/src/Attribute/FhirProperty.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
* 'extension' — Extension array (named 'extension')
2121
* 'modifierExtension' — ModifierExtension array (named 'modifierExtension')
2222
* 'choice' — Polymorphic value[x] / deceased[x] — must set isChoice: true and variants
23+
* 'choiceGroup' — Transparent (wrapper-less) XML choice group (FHIR tooling extension
24+
* xml-choice-group). The property is an ordered list<ChoiceGroupItem> whose
25+
* heterogeneous children emit directly under the parent, in document order
26+
* (e.g. CDA AD: streetAddressLine, city, streetAddressLine). Set
27+
* isArray: true, phpType: ChoiceGroupItem::class, and variants keyed by
28+
* child element name (jsonKey = element name, phpType = the item value's
29+
* FQCN or 'string'). Unlike 'choice', isChoice stays FALSE — every variant
30+
* maps to the one list property and appends, rather than selecting a single
31+
* value[x] slot.
2332
* 'enum' — Backed enum (CDA coded property bound to a generated enum, e.g. NullFlavor);
2433
* the property type IS the enum and its ->value is the code string
2534
*
@@ -30,7 +39,9 @@ final class FhirProperty
3039
{
3140
/**
3241
* @param list<array{fhirType: string, propertyKind: string, phpType: string, jsonKey: string}>|null $variants
33-
* Populated only when isChoice is true. Each variant describes one concrete type in the union.
42+
* Per-variant metadata. Populated when isChoice is true (one entry per value[x] type) OR
43+
* when propertyKind is 'choiceGroup' (one entry per allowed child element name, jsonKey =
44+
* element name, phpType = the item value's FQCN or 'string'). Null otherwise.
3445
*/
3546
public function __construct(
3647
/** FHIR type code: 'date', 'HumanName', 'BackboneElement', 'choice', etc. */
@@ -43,7 +54,7 @@ public function __construct(
4354
public readonly bool $isRequired = false,
4455
/** True for choice elements (value[x], deceased[x]). Requires variants to be set. */
4556
public readonly bool $isChoice = false,
46-
/** Per-variant metadata for choice elements; null for non-choice properties. */
57+
/** Per-variant metadata for 'choice' (value[x]) and 'choiceGroup' properties; null otherwise. */
4758
public readonly ?array $variants = null,
4859
/**
4960
* JSON/XML key override. Null means use the PHP property name as-is.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ardenexal\FHIRTools\Component\Metadata;
6+
7+
/**
8+
* One member of a transparent (wrapper-less) XML choice group.
9+
*
10+
* Some elements carry the FHIR tooling extension
11+
* `http://hl7.org/fhir/tools/StructureDefinition/xml-choice-group`, meaning their heterogeneous
12+
* child elements appear directly under the parent — with no intervening wrapper element — and in
13+
* significant document order (e.g. a CDA `AD` postal address: `streetAddressLine`, `city`,
14+
* `streetAddressLine`, …). Such a property is modelled as an ordered `list<ChoiceGroupItem>`, where
15+
* each item pairs the child's XML element name (the discriminator) with its value, and the list
16+
* order IS the document order.
17+
*
18+
* The type is intentionally generic — not tied to any particular FHIR/CDA datatype — so the
19+
* serializer can drive any `xml-choice-group` element from metadata alone:
20+
* - `$elementName` is the local XML element name, e.g. `streetAddressLine`. It is the discriminator
21+
* for the choice (NOT a class name and NOT an attribute such as `@partType`).
22+
* - `$value` is the child's value: a complex datatype object (e.g. an ADXP) or a bare string for a
23+
* primitive/text part.
24+
*
25+
* Round-trip note: the serializer reads `$value` reflectively/via the registered variant `phpType`,
26+
* so this type carries no compile-time dependency on the concrete datatype classes it wraps.
27+
*
28+
* Boundary (see M7 plan, task 6): a pure-text choice-group member that has NO element name (e.g. the
29+
* `xmlText` string slice of CDA `AD`) is not yet representable here — `$elementName` is required.
30+
* The mixed element-and-text case must be designed deliberately before that slice is supported.
31+
*
32+
* @author Ardenexal
33+
*/
34+
final readonly class ChoiceGroupItem
35+
{
36+
/**
37+
* @param string $elementName Local XML element name of the child (the choice discriminator)
38+
* @param object|string $value The child's value: a complex datatype object, or a string for a primitive/text part
39+
*/
40+
public function __construct(
41+
public string $elementName,
42+
public object|string $value,
43+
) {
44+
}
45+
}

src/Component/Serialization/src/FHIRSerializationService.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ public function deserializeFromXml(string $xmlData, string $targetClass, array $
216216
// "2002") are not cast to float/int, which would lose precision on round-trip.
217217
$xmlContext[XmlEncoder::TYPE_CAST_ATTRIBUTES] = false;
218218

219+
// Stash the source document element so the denormalizer can recover document order for
220+
// transparent xml-choice-group properties — Symfony's XmlEncoder decode regroups
221+
// same-named siblings and loses the interleaving (CDA M7). LIBXML_NONET disables network
222+
// access; DOCTYPE entities are not expanded. The denormalizer threads the element down to
223+
// each complex child, so a choice group nested at any depth recovers its order.
224+
$sourceDocument = new \DOMDocument();
225+
if (@$sourceDocument->loadXML($xmlData, \LIBXML_NONET) && $sourceDocument->documentElement !== null) {
226+
$xmlContext[FHIRComplexTypeXmlNormalizer::SOURCE_ELEMENT_CONTEXT_KEY] = $sourceDocument->documentElement;
227+
}
228+
219229
$result = $this->serializer->deserialize($xmlData, $targetClass, 'xml', $xmlContext);
220230

221231
if (!is_object($result)) {

src/Component/Serialization/src/Metadata/PropertyMetadata.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ final class PropertyMetadata
2121
* @param bool $isArray True when the property holds a list
2222
* @param bool $isRequired True when cardinality is 1..*
2323
* @param bool $isChoice True for choice elements (value[x])
24-
* @param list<PropertyVariantMetadata>|null $variants Non-null only when isChoice is true
24+
* @param list<PropertyVariantMetadata>|null $variants Non-null for 'choice' (value[x]) and 'choiceGroup' properties; null otherwise
2525
* @param string|null $jsonKey Key override; null = use PHP property name
2626
* @param string|null $phpItemClass FQCN for complex/backbone array item class (e.g. HumanName::class);
2727
* null for primitives, scalars, and choice elements

src/Component/Serialization/src/Metadata/PropertyMetadataProvider.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,12 @@ private function resolveFromAttributes(string $className): array
122122
/** @var FhirProperty $attr */
123123
$attr = $attributes[0]->newInstance();
124124

125+
// Build variants for value[x] choices (isChoice) AND for transparent
126+
// xml-choice-group properties (propertyKind 'choiceGroup'), which reuse the
127+
// same per-variant shape keyed by child element name. value[x] semantics are
128+
// unchanged; choiceGroup keeps isChoice false (see FhirProperty propertyKind doc).
125129
$variants = null;
126-
if ($attr->isChoice && $attr->variants !== null) {
130+
if ($attr->variants !== null && ($attr->isChoice || $attr->propertyKind === 'choiceGroup')) {
127131
$variants = array_map(
128132
static fn (array $v): PropertyVariantMetadata => PropertyVariantMetadata::fromArray(
129133
$v['fhirType'],

0 commit comments

Comments
 (0)