diff --git a/doc/reference/annotations.rst b/doc/reference/annotations.rst index 651e7b5b6..0921a84bd 100644 --- a/doc/reference/annotations.rst +++ b/doc/reference/annotations.rst @@ -141,6 +141,94 @@ will need to wrap your custom strategy with the ``SerializedNameattributeStrateg ) ->build(); +XML Specific Usage +------------------ + +When serializing to or deserializing from XML, ``#[SerializedName]`` supports special +syntax to map PHP properties to XML attributes. This can be useful for more complex +XML structures where ``#[XmlAttribute]`` might not suffice. These syntaxes work for +both serialization and deserialization. + +1. **Attribute on the current element:** + To map a property as an attribute of the XML element that represents the current + class instance, prefix the attribute name with ``@``. + + .. code-block:: php + + element + + #[Serializer\SerializedName("name")] + #[Serializer\Type("string")] + private $name; // Becomes a child element + + public function __construct(int $id, string $name) + { + $this->id = $id; + $this->name = $name; + } + } + + // Example: $user = new User(1, 'John Doe'); + // Serializes to: John Doe + +2. **Attribute on a sibling element:** + To map a property as an attribute on a *sibling* XML element, use the + syntax ``"ElementName/@AttributeName"``. The property's value will + become an attribute named ``AttributeName`` on a sibling XML element named + ``ElementName``. Your PHP class should typically have one property that defines + the sibling element's value (e.g., ``ElementName``) and another property that + defines its attribute (e.g., ``ElementName/@AttributeName``). + + .. code-block:: php + + XML element. + */ + #[Serializer\SerializedName("identifier")] + #[Serializer\Type("string")] + #[Serializer\XmlElement(cdata: false)] + private $identifierValue; + + /** + * This property becomes the "scheme" attribute on the element. + */ + #[Serializer\SerializedName("identifier/@scheme")] + #[Serializer\Type("string")] + #[Serializer\XmlElement(cdata: false)] // Namespace can be specified here if needed via namespace: "http://..." + private $identifierScheme; + + public function __construct(string $identifierValue, string $identifierScheme) + { + $this->identifierValue = $identifierValue; + $this->identifierScheme = $identifierScheme; + } + } + + // Example: $item = new Item('ABC', 'product_sku'); + // Serializes to: ABC + + During serialization, if a property mapped to an attribute has a ``null`` value, + the attribute will not be rendered on the XML element. + The ``#[XmlElement]`` annotation can be used on properties mapped with these + syntaxes, for instance, to control the XML namespace of the attribute if it + differs from the element's namespace (though typically attributes inherit the + namespace of their element or have no namespace). + + #[Since] ~~~~~~~~ This attribute can be defined on a property to specify starting from which diff --git a/src/XmlDeserializationVisitor.php b/src/XmlDeserializationVisitor.php index 9972ee4c5..1645037ee 100644 --- a/src/XmlDeserializationVisitor.php +++ b/src/XmlDeserializationVisitor.php @@ -327,6 +327,70 @@ public function visitProperty(PropertyMetadata $metadata, $data) throw new NotAcceptableException(); } + if (0 === strpos($name, '@')) { + $attributeName = substr($name, 1); + $attributes = $data->attributes($metadata->xmlNamespace); + + if (isset($attributes[$attributeName])) { + if (!$metadata->type) { + throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name); + } + + return $this->navigator->accept($attributes[$attributeName], $metadata->type); + } + + 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())); + } + + if (false !== strpos($name, '/@')) { + [$elementName, $attributeName] = explode('/@', $name, 2); + + $childDataNode = null; + if ('' === $metadata->xmlNamespace) { + // Element explicitly in NO namespace + $xpathQuery = "./*[local-name()='" . $elementName . "' and (namespace-uri()='' or not(namespace-uri()))]"; + $matchingNodes = $data->xpath($xpathQuery); + if (!empty($matchingNodes)) { + $childDataNode = $matchingNodes[0]; + } + } elseif ($metadata->xmlNamespace) { + // Element in a specific namespace URI + $childrenInNs = $data->children($metadata->xmlNamespace); + if (isset($childrenInNs->$elementName)) { + $childDataNode = $childrenInNs->$elementName; + } + } else { + // xmlNamespace is null: element in default namespace (or no namespace if no default is active) + $childrenInDefaultOrNoNs = $data->children(null); + if (isset($childrenInDefaultOrNoNs->$elementName)) { + $childDataNode = $childrenInDefaultOrNoNs->$elementName; + } + } + + if (!$childDataNode || !$childDataNode->getName()) { + if (null === $metadata->xmlNamespace) { + $ns = '[default/none]'; + } else { + $ns = '' === $metadata->xmlNamespace ? '[none]' : $metadata->xmlNamespace; + } + + 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())); + } + + $attributeTargetNs = $metadata->xmlNamespace && '' !== $metadata->xmlNamespace ? $metadata->xmlNamespace : null; + $attributes = $childDataNode->attributes($attributeTargetNs); + + if (isset($attributes[$attributeName])) { + if (!$metadata->type) { + throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name); + } + + return $this->navigator->accept($attributes[$attributeName], $metadata->type); + } + + 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())); + } + if ($metadata->xmlValue) { if (!$metadata->type) { throw RuntimeException::noMetadataForProperty($metadata->class, $metadata->name); diff --git a/src/XmlSerializationVisitor.php b/src/XmlSerializationVisitor.php index 2017f4268..4e74e7ab5 100644 --- a/src/XmlSerializationVisitor.php +++ b/src/XmlSerializationVisitor.php @@ -318,6 +318,28 @@ public function visitProperty(PropertyMetadata $metadata, $v): void return; } + if (false !== strpos($metadata->serializedName, '/@') && $this->trySerializePropertyAsAttributeOnSiblingElement($metadata, $v)) { + return; + } + + if (0 === strpos($metadata->serializedName, '@')) { + [$attributeValue, $processedNode] = $this->processValueForXmlAttribute($v, $metadata->type, $metadata); + + if (null === $v && null === $processedNode) { + return; + } + + $attributeName = substr($metadata->serializedName, 1); + + if ($this->currentNode instanceof \DOMElement) { + $this->setAttributeOnNode($this->currentNode, $attributeName, $attributeValue, $metadata->xmlNamespace); + } else { + throw new RuntimeException('Cannot set attribute on a non-element node.'); + } + + return; + } + if ($addEnclosingElement = !$this->isInLineCollection($metadata) && !$metadata->inline) { $namespace = $metadata->xmlNamespace ?? $this->getClassDefaultNamespace($this->objectMetadataStack->top()); @@ -354,6 +376,75 @@ public function visitProperty(PropertyMetadata $metadata, $v): void $this->hasValue = false; } + private function trySerializePropertyAsAttributeOnSiblingElement(PropertyMetadata $metadata, $v): bool + { + [$elementName, $attributeName] = explode('/@', $metadata->serializedName, 2); + $namespace = $metadata->xmlNamespace ?? $this->getClassDefaultNamespace($this->objectMetadataStack->top()); + $targetElement = null; + + if ($this->currentNode instanceof \DOMElement) { + foreach ($this->currentNode->childNodes as $childNode) { + if ($childNode instanceof \DOMElement && $childNode->localName === $elementName) { + $isNamespaceMatch = false; + // Case 1: Expected a specific namespace, and child node has it. + // Case 2 (else): Expected no namespace + if (null !== $namespace && $childNode->namespaceURI === $namespace) { + $isNamespaceMatch = true; + } elseif ((null === $namespace || '' === $namespace) && (null === $childNode->namespaceURI || '' === $childNode->namespaceURI)) { + $isNamespaceMatch = true; + } + + if ($isNamespaceMatch) { + $targetElement = $childNode; + break; + } + } + } + } + + if (!$targetElement) { + return false; + } + + if (null === $v) { + return true; + } + + [$attributeStringValue, $attributeValueNode] = $this->processValueForXmlAttribute($v, $metadata->type, $metadata); + + if (null !== $attributeValueNode || is_scalar($v)) { + $this->setAttributeOnNode($targetElement, $attributeName, $attributeStringValue, $metadata->xmlNamespace); + } + + return true; + } + + /** + * @return array{0:string, 1:\DOMNode|null} string value for attribute, and the processed DOMNode/null. + * + * @throws RuntimeException If the value is unsuitable for an XML attribute. + */ + private function processValueForXmlAttribute($inputValue, ?array $valueType, PropertyMetadata $metadataForNavigatorContext): array + { + $this->setCurrentMetadata($metadataForNavigatorContext); + $processedNode = $this->navigator->accept($inputValue, $valueType); + $this->revertCurrentMetadata(); + + if ($processedNode instanceof \DOMCharacterData) { + $stringValue = $processedNode->nodeValue; + } elseif (null === $processedNode) { + $stringValue = is_scalar($inputValue) ? (string) $inputValue : ''; + } else { + throw new RuntimeException(sprintf( + 'Unsupported value for XML attribute for property "%s". Expected character data or scalar, but got %s.', + $metadataForNavigatorContext->name, + \is_object($processedNode) ? \get_class($processedNode) : \gettype($processedNode), + )); + } + + return [$stringValue, $processedNode]; + } + private function isInLineCollection(PropertyMetadata $metadata): bool { return $metadata->xmlCollection && $metadata->xmlCollectionInline; diff --git a/tests/Fixtures/ObjectWithAttributeSyntax.php b/tests/Fixtures/ObjectWithAttributeSyntax.php new file mode 100644 index 000000000..3470f36ab --- /dev/null +++ b/tests/Fixtures/ObjectWithAttributeSyntax.php @@ -0,0 +1,60 @@ +value = $value; + $this->testIdentifier = $testIdentifier; + $this->testIdentifierNs = $testIdentifierNs; + } +} diff --git a/tests/Fixtures/ObjectWithAttributeSyntaxWithoutNs.php b/tests/Fixtures/ObjectWithAttributeSyntaxWithoutNs.php new file mode 100644 index 000000000..5f25cc0bc --- /dev/null +++ b/tests/Fixtures/ObjectWithAttributeSyntaxWithoutNs.php @@ -0,0 +1,49 @@ +value = $value; + $this->testIdentifier = $testIdentifier; + } +} diff --git a/tests/Fixtures/ObjectWithElementAttributeSyntax.php b/tests/Fixtures/ObjectWithElementAttributeSyntax.php new file mode 100644 index 000000000..22488f7d2 --- /dev/null +++ b/tests/Fixtures/ObjectWithElementAttributeSyntax.php @@ -0,0 +1,99 @@ +identifierValue = $identifierValue; + $this->identifierScheme = $identifierScheme; + $this->customElementValue = $customElementValue; + $this->customElementType = $customElementType; + $this->dataPointValue = $dataPointValue; + } +} diff --git a/tests/Fixtures/ObjectWithElementAttributeSyntaxWithoutNs.php b/tests/Fixtures/ObjectWithElementAttributeSyntaxWithoutNs.php new file mode 100644 index 000000000..b11b25e6f --- /dev/null +++ b/tests/Fixtures/ObjectWithElementAttributeSyntaxWithoutNs.php @@ -0,0 +1,79 @@ +identifierValue = $identifierValue; + $this->identifierScheme = $identifierScheme; + $this->dataPointValue = $dataPointValue; + } +} diff --git a/tests/Serializer/XmlSerializationTest.php b/tests/Serializer/XmlSerializationTest.php index 3119d924f..ea9d51455 100644 --- a/tests/Serializer/XmlSerializationTest.php +++ b/tests/Serializer/XmlSerializationTest.php @@ -27,6 +27,10 @@ use JMS\Serializer\Tests\Fixtures\Discriminator\ObjectWithXmlNotCDataDiscriminatorParent; use JMS\Serializer\Tests\Fixtures\Input; use JMS\Serializer\Tests\Fixtures\InvalidUsageOfXmlValue; +use JMS\Serializer\Tests\Fixtures\ObjectWithAttributeSyntax; +use JMS\Serializer\Tests\Fixtures\ObjectWithAttributeSyntaxWithoutNs; +use JMS\Serializer\Tests\Fixtures\ObjectWithElementAttributeSyntax; +use JMS\Serializer\Tests\Fixtures\ObjectWithElementAttributeSyntaxWithoutNs; use JMS\Serializer\Tests\Fixtures\ObjectWithFloatProperty; use JMS\Serializer\Tests\Fixtures\ObjectWithNamespacesAndList; use JMS\Serializer\Tests\Fixtures\ObjectWithNamespacesAndNestedList; @@ -619,6 +623,175 @@ public function testThrowingExceptionWhenDeserializingUnionProperties() self::assertEquals($object, $this->deserialize(static::getContent('data_integer'), UnionTypedProperties::class)); } + public function testSerializeElementAttributeSyntax() + { + $object = new ObjectWithElementAttributeSyntax( + 'IDValue', + 'IDScheme', + 'CustomValue', + 'SpecialType', + '123', + ); + $object->descriptionValue = 'A description'; + $object->descriptionLang = 'en'; + $object->dataPointUnit = 'kg'; + $object->nullableIdentifierValue = 'NullableValue'; + $object->nullableIdentifierScheme = 'NullableScheme'; + + $expectedXml = self::getContent('object_with_element_attribute_syntax'); + $actualXml = $this->serialize($object); + + self::assertXmlStringEqualsXmlString($expectedXml, $actualXml); + $deserializedObject = $this->deserialize($actualXml, ObjectWithElementAttributeSyntax::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeElementAttributeSyntaxWithNulls() + { + $object = new ObjectWithElementAttributeSyntax( + 'IDValueOnly', + 'IDSchemeOnly', + 'CustomValueOnly', + 'SpecialTypeOnly', + '123NoUnit', + ); + $object->nullableIdentifierValue = 'ValueWithoutScheme'; + + $expectedXml = self::getContent('object_with_element_attribute_syntax_nulls'); + $actualXml = $this->serialize($object); + self::assertXmlStringEqualsXmlString($expectedXml, $actualXml); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithElementAttributeSyntax::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeElementAttributeSyntaxWithoutNs() + { + $object = new ObjectWithElementAttributeSyntaxWithoutNs( + 'IDValue', + 'IDScheme', + 'CustomValue', + ); + $object->descriptionValue = 'A description'; + $object->descriptionLang = 'en'; + $object->dataPointUnit = 'kg'; + $object->nullableIdentifierValue = 'NullableValue'; + $object->nullableIdentifierScheme = 'NullableScheme'; + + $actualXml = $this->serialize($object); + static::assertXmlStringEqualsXmlString( + ' + + IDValue + A description + CustomValue + NullableValue + ', + $actualXml, + ); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithElementAttributeSyntaxWithoutNs::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeElementAttributeSyntaxWithoutNsWithNulls() + { + $object = new ObjectWithElementAttributeSyntaxWithoutNs( + 'IDValueOnly', + 'IDSchemeOnly', + 'CustomValueOnly', + ); + $object->nullableIdentifierValue = 'ValueWithoutScheme'; + + $actualXml = $this->serialize($object); + static::assertXmlStringEqualsXmlString( + ' + + IDValueOnly + CustomValueOnly + ValueWithoutScheme + ', + $actualXml, + ); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithElementAttributeSyntaxWithoutNs::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeAttributeSyntax() + { + $object = new ObjectWithAttributeSyntax( + 'CustomValueOnly', + 'TestIdentifierValue', + 'NamespacedTestIdentifierValue', + ); + + $object->nullableIdentifierValue = 'NullableIdentifierValue'; + $object->nullableIdentifierScheme = 'NullableIdentifierScheme'; + + $expectedXml = self::getContent('object_with_attribute_syntax'); + $actualXml = $this->serialize($object); + self::assertXmlStringEqualsXmlString($expectedXml, $actualXml); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithAttributeSyntax::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeAttributeSyntaxWithNulls() + { + $object = new ObjectWithAttributeSyntax( + 'CustomValueOnly', + 'TestIdentifierValue', + 'NamespacedTestIdentifierValue', + ); + + $object->nullableIdentifierValue = 'NullableIdentifierValue'; + $object->nullableIdentifierScheme = null; + + $expectedXml = self::getContent('object_with_attribute_syntax_nulls'); + $actualXml = $this->serialize($object); + self::assertXmlStringEqualsXmlString($expectedXml, $actualXml); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithAttributeSyntax::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeAttributeSyntaxWithoutNs() + { + $object = new ObjectWithAttributeSyntaxWithoutNs( + 'CustomValueOnly', + 'TestIdentifierValue', + ); + + $object->nullableIdentifierValue = 'NullableIdentifierValue'; + $object->nullableIdentifierScheme = 'NonNullableIdentifierScheme'; + + $expectedXml = self::getContent('object_with_attribute_syntax_without_ns'); + $actualXml = $this->serialize($object); + self::assertXmlStringEqualsXmlString($expectedXml, $actualXml); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithAttributeSyntaxWithoutNs::class); + self::assertEquals($object, $deserializedObject); + } + + public function testSerializeAttributeSyntaxWithoutNsNulls() + { + $object = new ObjectWithAttributeSyntaxWithoutNs( + 'CustomValueOnly', + 'TestIdentifierValue', + ); + + $object->nullableIdentifierValue = 'NullableIdentifierValue'; + $object->nullableIdentifierScheme = null; + + $expectedXml = self::getContent('object_with_attribute_syntax_without_ns_nulls'); + $actualXml = $this->serialize($object); + self::assertXmlStringEqualsXmlString($expectedXml, $actualXml); + + $deserializedObject = $this->deserialize($actualXml, ObjectWithAttributeSyntaxWithoutNs::class); + self::assertEquals($object, $deserializedObject); + } + private function xpathFirstToString(\SimpleXMLElement $xml, $xpath) { $nodes = $xml->xpath($xpath); diff --git a/tests/Serializer/xml/object_with_attribute_syntax.xml b/tests/Serializer/xml/object_with_attribute_syntax.xml new file mode 100644 index 000000000..f7c042a74 --- /dev/null +++ b/tests/Serializer/xml/object_with_attribute_syntax.xml @@ -0,0 +1,4 @@ + + + CustomValueOnly + \ No newline at end of file diff --git a/tests/Serializer/xml/object_with_attribute_syntax_nulls.xml b/tests/Serializer/xml/object_with_attribute_syntax_nulls.xml new file mode 100644 index 000000000..ef2e8a377 --- /dev/null +++ b/tests/Serializer/xml/object_with_attribute_syntax_nulls.xml @@ -0,0 +1,4 @@ + + + CustomValueOnly + \ No newline at end of file diff --git a/tests/Serializer/xml/object_with_attribute_syntax_without_ns.xml b/tests/Serializer/xml/object_with_attribute_syntax_without_ns.xml new file mode 100644 index 000000000..abb75da31 --- /dev/null +++ b/tests/Serializer/xml/object_with_attribute_syntax_without_ns.xml @@ -0,0 +1,4 @@ + + + CustomValueOnly + \ No newline at end of file diff --git a/tests/Serializer/xml/object_with_attribute_syntax_without_ns_nulls.xml b/tests/Serializer/xml/object_with_attribute_syntax_without_ns_nulls.xml new file mode 100644 index 000000000..85fd52197 --- /dev/null +++ b/tests/Serializer/xml/object_with_attribute_syntax_without_ns_nulls.xml @@ -0,0 +1,4 @@ + + + CustomValueOnly + \ No newline at end of file diff --git a/tests/Serializer/xml/object_with_element_attribute_syntax.xml b/tests/Serializer/xml/object_with_element_attribute_syntax.xml new file mode 100644 index 000000000..c3273e547 --- /dev/null +++ b/tests/Serializer/xml/object_with_element_attribute_syntax.xml @@ -0,0 +1,8 @@ + + + IDValue + A description + CustomValue + 123 + NullableValue + diff --git a/tests/Serializer/xml/object_with_element_attribute_syntax_nulls.xml b/tests/Serializer/xml/object_with_element_attribute_syntax_nulls.xml new file mode 100644 index 000000000..c6280bc30 --- /dev/null +++ b/tests/Serializer/xml/object_with_element_attribute_syntax_nulls.xml @@ -0,0 +1,7 @@ + + + IDValueOnly + CustomValueOnly + 123NoUnit + ValueWithoutScheme +