Skip to content

Commit 7c88b1b

Browse files
authored
Merge pull request #1602 from NikoGrano/issue/1601
XML Serialization: fix #1601
2 parents 3c2d00a + 42fcf87 commit 7c88b1b

14 files changed

+734
-0
lines changed

doc/reference/annotations.rst

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,94 @@ will need to wrap your custom strategy with the ``SerializedNameattributeStrateg
141141
)
142142
->build();
143143
144+
XML Specific Usage
145+
------------------
146+
147+
When serializing to or deserializing from XML, ``#[SerializedName]`` supports special
148+
syntax to map PHP properties to XML attributes. This can be useful for more complex
149+
XML structures where ``#[XmlAttribute]`` might not suffice. These syntaxes work for
150+
both serialization and deserialization.
151+
152+
1. **Attribute on the current element:**
153+
To map a property as an attribute of the XML element that represents the current
154+
class instance, prefix the attribute name with ``@``.
155+
156+
.. code-block:: php
157+
158+
<?php
159+
use JMS\Serializer\Annotation as Serializer;
160+
161+
#[Serializer\XmlRoot("user")]
162+
class User
163+
{
164+
#[Serializer\SerializedName("@id")]
165+
#[Serializer\Type("integer")]
166+
private $id; // Becomes an attribute "id" on the <user> element
167+
168+
#[Serializer\SerializedName("name")]
169+
#[Serializer\Type("string")]
170+
private $name; // Becomes a child element <name>
171+
172+
public function __construct(int $id, string $name)
173+
{
174+
$this->id = $id;
175+
$this->name = $name;
176+
}
177+
}
178+
179+
// Example: $user = new User(1, 'John Doe');
180+
// Serializes to: <user id="1"><name>John Doe</name></user>
181+
182+
2. **Attribute on a sibling element:**
183+
To map a property as an attribute on a *sibling* XML element, use the
184+
syntax ``"ElementName/@AttributeName"``. The property's value will
185+
become an attribute named ``AttributeName`` on a sibling XML element named
186+
``ElementName``. Your PHP class should typically have one property that defines
187+
the sibling element's value (e.g., ``ElementName``) and another property that
188+
defines its attribute (e.g., ``ElementName/@AttributeName``).
189+
190+
.. code-block:: php
191+
192+
<?php
193+
use JMS\Serializer\Annotation as Serializer;
194+
195+
#[Serializer\XmlRoot("item")]
196+
class Item
197+
{
198+
/**
199+
* This property defines the <identifier> XML element.
200+
*/
201+
#[Serializer\SerializedName("identifier")]
202+
#[Serializer\Type("string")]
203+
#[Serializer\XmlElement(cdata: false)]
204+
private $identifierValue;
205+
206+
/**
207+
* This property becomes the "scheme" attribute on the <identifier> element.
208+
*/
209+
#[Serializer\SerializedName("identifier/@scheme")]
210+
#[Serializer\Type("string")]
211+
#[Serializer\XmlElement(cdata: false)] // Namespace can be specified here if needed via namespace: "http://..."
212+
private $identifierScheme;
213+
214+
public function __construct(string $identifierValue, string $identifierScheme)
215+
{
216+
$this->identifierValue = $identifierValue;
217+
$this->identifierScheme = $identifierScheme;
218+
}
219+
}
220+
221+
// Example: $item = new Item('ABC', 'product_sku');
222+
// Serializes to: <item><identifier scheme="product_sku">ABC</identifier></item>
223+
224+
During serialization, if a property mapped to an attribute has a ``null`` value,
225+
the attribute will not be rendered on the XML element.
226+
The ``#[XmlElement]`` annotation can be used on properties mapped with these
227+
syntaxes, for instance, to control the XML namespace of the attribute if it
228+
differs from the element's namespace (though typically attributes inherit the
229+
namespace of their element or have no namespace).
230+
231+
144232
#[Since]
145233
~~~~~~~~
146234
This attribute can be defined on a property to specify starting from which

src/XmlDeserializationVisitor.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,70 @@ public function visitProperty(PropertyMetadata $metadata, $data)
327327
throw new NotAcceptableException();
328328
}
329329

330+
if (0 === strpos($name, '@')) {
331+
$attributeName = substr($name, 1);
332+
$attributes = $data->attributes($metadata->xmlNamespace);
333+
334+
if (isset($attributes[$attributeName])) {
335+
if (!$metadata->type) {
336+
throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
337+
}
338+
339+
return $this->navigator->accept($attributes[$attributeName], $metadata->type);
340+
}
341+
342+
throw new NotAcceptableException(sprintf('Attribute "%s" (derived from serializedName "%s") in namespace "%s" not found for property %s::$%s. XML: %s', $attributeName, $name, $metadata->xmlNamespace ?? '[none]', $metadata->class, $metadata->name, $data->asXML()));
343+
}
344+
345+
if (false !== strpos($name, '/@')) {
346+
[$elementName, $attributeName] = explode('/@', $name, 2);
347+
348+
$childDataNode = null;
349+
if ('' === $metadata->xmlNamespace) {
350+
// Element explicitly in NO namespace
351+
$xpathQuery = "./*[local-name()='" . $elementName . "' and (namespace-uri()='' or not(namespace-uri()))]";
352+
$matchingNodes = $data->xpath($xpathQuery);
353+
if (!empty($matchingNodes)) {
354+
$childDataNode = $matchingNodes[0];
355+
}
356+
} elseif ($metadata->xmlNamespace) {
357+
// Element in a specific namespace URI
358+
$childrenInNs = $data->children($metadata->xmlNamespace);
359+
if (isset($childrenInNs->$elementName)) {
360+
$childDataNode = $childrenInNs->$elementName;
361+
}
362+
} else {
363+
// xmlNamespace is null: element in default namespace (or no namespace if no default is active)
364+
$childrenInDefaultOrNoNs = $data->children(null);
365+
if (isset($childrenInDefaultOrNoNs->$elementName)) {
366+
$childDataNode = $childrenInDefaultOrNoNs->$elementName;
367+
}
368+
}
369+
370+
if (!$childDataNode || !$childDataNode->getName()) {
371+
if (null === $metadata->xmlNamespace) {
372+
$ns = '[default/none]';
373+
} else {
374+
$ns = '' === $metadata->xmlNamespace ? '[none]' : $metadata->xmlNamespace;
375+
}
376+
377+
throw new NotAcceptableException(sprintf('Child element "%s" for attribute access not found (element namespace: %s). Property %s::$%s. XML: %s', $elementName, $ns, $metadata->class, $metadata->name, $data->asXML()));
378+
}
379+
380+
$attributeTargetNs = $metadata->xmlNamespace && '' !== $metadata->xmlNamespace ? $metadata->xmlNamespace : null;
381+
$attributes = $childDataNode->attributes($attributeTargetNs);
382+
383+
if (isset($attributes[$attributeName])) {
384+
if (!$metadata->type) {
385+
throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);
386+
}
387+
388+
return $this->navigator->accept($attributes[$attributeName], $metadata->type);
389+
}
390+
391+
throw new NotAcceptableException(sprintf('Attribute "%s" on element "%s" not found (attribute namespace: %s). Property %s::$%s. XML: %s', $attributeName, $elementName, $attributeTargetNs ?? '[none]', $metadata->class, $metadata->name, $data->asXML()));
392+
}
393+
330394
if ($metadata->xmlValue) {
331395
if (!$metadata->type) {
332396
throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name);

src/XmlSerializationVisitor.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,28 @@ public function visitProperty(PropertyMetadata $metadata, $v): void
318318
return;
319319
}
320320

321+
if (false !== strpos($metadata->serializedName, '/@') && $this->trySerializePropertyAsAttributeOnSiblingElement($metadata, $v)) {
322+
return;
323+
}
324+
325+
if (0 === strpos($metadata->serializedName, '@')) {
326+
[$attributeValue, $processedNode] = $this->processValueForXmlAttribute($v, $metadata->type, $metadata);
327+
328+
if (null === $v && null === $processedNode) {
329+
return;
330+
}
331+
332+
$attributeName = substr($metadata->serializedName, 1);
333+
334+
if ($this->currentNode instanceof \DOMElement) {
335+
$this->setAttributeOnNode($this->currentNode, $attributeName, $attributeValue, $metadata->xmlNamespace);
336+
} else {
337+
throw new RuntimeException('Cannot set attribute on a non-element node.');
338+
}
339+
340+
return;
341+
}
342+
321343
if ($addEnclosingElement = !$this->isInLineCollection($metadata) && !$metadata->inline) {
322344
$namespace = $metadata->xmlNamespace ?? $this->getClassDefaultNamespace($this->objectMetadataStack->top());
323345

@@ -354,6 +376,75 @@ public function visitProperty(PropertyMetadata $metadata, $v): void
354376
$this->hasValue = false;
355377
}
356378

379+
private function trySerializePropertyAsAttributeOnSiblingElement(PropertyMetadata $metadata, $v): bool
380+
{
381+
[$elementName, $attributeName] = explode('/@', $metadata->serializedName, 2);
382+
$namespace = $metadata->xmlNamespace ?? $this->getClassDefaultNamespace($this->objectMetadataStack->top());
383+
$targetElement = null;
384+
385+
if ($this->currentNode instanceof \DOMElement) {
386+
foreach ($this->currentNode->childNodes as $childNode) {
387+
if ($childNode instanceof \DOMElement && $childNode->localName === $elementName) {
388+
$isNamespaceMatch = false;
389+
// Case 1: Expected a specific namespace, and child node has it.
390+
// Case 2 (else): Expected no namespace
391+
if (null !== $namespace && $childNode->namespaceURI === $namespace) {
392+
$isNamespaceMatch = true;
393+
} elseif ((null === $namespace || '' === $namespace) && (null === $childNode->namespaceURI || '' === $childNode->namespaceURI)) {
394+
$isNamespaceMatch = true;
395+
}
396+
397+
if ($isNamespaceMatch) {
398+
$targetElement = $childNode;
399+
break;
400+
}
401+
}
402+
}
403+
}
404+
405+
if (!$targetElement) {
406+
return false;
407+
}
408+
409+
if (null === $v) {
410+
return true;
411+
}
412+
413+
[$attributeStringValue, $attributeValueNode] = $this->processValueForXmlAttribute($v, $metadata->type, $metadata);
414+
415+
if (null !== $attributeValueNode || is_scalar($v)) {
416+
$this->setAttributeOnNode($targetElement, $attributeName, $attributeStringValue, $metadata->xmlNamespace);
417+
}
418+
419+
return true;
420+
}
421+
422+
/**
423+
* @return array{0:string, 1:\DOMNode|null} string value for attribute, and the processed DOMNode/null.
424+
*
425+
* @throws RuntimeException If the value is unsuitable for an XML attribute.
426+
*/
427+
private function processValueForXmlAttribute($inputValue, ?array $valueType, PropertyMetadata $metadataForNavigatorContext): array
428+
{
429+
$this->setCurrentMetadata($metadataForNavigatorContext);
430+
$processedNode = $this->navigator->accept($inputValue, $valueType);
431+
$this->revertCurrentMetadata();
432+
433+
if ($processedNode instanceof \DOMCharacterData) {
434+
$stringValue = $processedNode->nodeValue;
435+
} elseif (null === $processedNode) {
436+
$stringValue = is_scalar($inputValue) ? (string) $inputValue : '';
437+
} else {
438+
throw new RuntimeException(sprintf(
439+
'Unsupported value for XML attribute for property "%s". Expected character data or scalar, but got %s.',
440+
$metadataForNavigatorContext->name,
441+
\is_object($processedNode) ? \get_class($processedNode) : \gettype($processedNode),
442+
));
443+
}
444+
445+
return [$stringValue, $processedNode];
446+
}
447+
357448
private function isInLineCollection(PropertyMetadata $metadata): bool
358449
{
359450
return $metadata->xmlCollection && $metadata->xmlCollectionInline;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JMS\Serializer\Tests\Fixtures;
6+
7+
use JMS\Serializer\Annotation as Serializer;
8+
9+
/**
10+
* @Serializer\XmlRoot("test-object")
11+
* @Serializer\XmlNamespace(uri="http://example.com/default", prefix="")
12+
* @Serializer\XmlNamespace(uri="http://example.com/ns1", prefix="ns1")
13+
*/
14+
class ObjectWithAttributeSyntax
15+
{
16+
/**
17+
* @Serializer\SerializedName("Value")
18+
* @Serializer\Type("string")
19+
* @Serializer\XmlElement(cdata=false)
20+
*/
21+
public $value;
22+
23+
/**
24+
* @Serializer\SerializedName("@Identifier")
25+
* @Serializer\Type("string")
26+
* @Serializer\XmlElement(cdata=false)
27+
*/
28+
public $testIdentifier;
29+
30+
/**
31+
* @Serializer\SerializedName("@NamespacedIdentifier")
32+
* @Serializer\Type("string")
33+
* @Serializer\XmlElement(cdata=false, namespace="http://example.com/ns1")
34+
*/
35+
public $testIdentifierNs;
36+
37+
/**
38+
* @Serializer\SerializedName("@NullableIdentifier")
39+
* @Serializer\Type("string")
40+
* @Serializer\XmlElement(cdata=false)
41+
*/
42+
public $nullableIdentifierValue = null;
43+
44+
/**
45+
* @Serializer\SerializedName("@NamespacedNullableIdentifier")
46+
* @Serializer\Type("string")
47+
* @Serializer\XmlElement(cdata=false, namespace="http://example.com/ns1")
48+
*/
49+
public $nullableIdentifierScheme = null;
50+
51+
public function __construct(
52+
string $value,
53+
string $testIdentifier,
54+
string $testIdentifierNs
55+
) {
56+
$this->value = $value;
57+
$this->testIdentifier = $testIdentifier;
58+
$this->testIdentifierNs = $testIdentifierNs;
59+
}
60+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JMS\Serializer\Tests\Fixtures;
6+
7+
use JMS\Serializer\Annotation as Serializer;
8+
9+
/**
10+
* @Serializer\XmlRoot("test-object")
11+
*/
12+
class ObjectWithAttributeSyntaxWithoutNs
13+
{
14+
/**
15+
* @Serializer\SerializedName("Value")
16+
* @Serializer\Type("string")
17+
* @Serializer\XmlElement(cdata=false)
18+
*/
19+
public $value;
20+
21+
/**
22+
* @Serializer\SerializedName("@Identifier")
23+
* @Serializer\Type("string")
24+
* @Serializer\XmlElement(cdata=false)
25+
*/
26+
public $testIdentifier;
27+
28+
/**
29+
* @Serializer\SerializedName("@NullableIdentifier")
30+
* @Serializer\Type("string")
31+
* @Serializer\XmlElement(cdata=false)
32+
*/
33+
public $nullableIdentifierValue = null;
34+
35+
/**
36+
* @Serializer\SerializedName("@NullableIdentifierScheme")
37+
* @Serializer\Type("string")
38+
* @Serializer\XmlElement(cdata=false)
39+
*/
40+
public $nullableIdentifierScheme = null;
41+
42+
public function __construct(
43+
string $value,
44+
string $testIdentifier
45+
) {
46+
$this->value = $value;
47+
$this->testIdentifier = $testIdentifier;
48+
}
49+
}

0 commit comments

Comments
 (0)