diff --git a/src/Symfony/Component/Serializer/Context/Encoder/XmlEncoderContextBuilder.php b/src/Symfony/Component/Serializer/Context/Encoder/XmlEncoderContextBuilder.php index 0fd1f2f44c364..7bc43f28223fa 100644 --- a/src/Symfony/Component/Serializer/Context/Encoder/XmlEncoderContextBuilder.php +++ b/src/Symfony/Component/Serializer/Context/Encoder/XmlEncoderContextBuilder.php @@ -160,4 +160,14 @@ public function withCdataWrappingPattern(?string $cdataWrappingPattern): static { return $this->with(XmlEncoder::CDATA_WRAPPING_PATTERN, $cdataWrappingPattern); } + + /** + * Configures whether to force the output of a tag to be treated as an array. + * + * @param list|null $forceCollection + */ + public function withForceCollection(?array $forceCollection): static + { + return $this->with(XmlEncoder::FORCE_COLLECTION, $forceCollection); + } } diff --git a/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php b/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php index e1a816380a7b6..3a6c2d5b1d99c 100644 --- a/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php @@ -31,6 +31,11 @@ class XmlEncoder implements EncoderInterface, DecoderInterface, NormalizationAwa public const AS_COLLECTION = 'as_collection'; + /** + * An array of XML tags who should always be treated as a collection, even when it has only one child. + */ + public const FORCE_COLLECTION = 'force_collection'; + /** * An array of ignored XML node types while decoding, each one of the DOM Predefined XML_* constants. */ @@ -72,6 +77,7 @@ class XmlEncoder implements EncoderInterface, DecoderInterface, NormalizationAwa self::TYPE_CAST_ATTRIBUTES => true, self::CDATA_WRAPPING => true, self::CDATA_WRAPPING_PATTERN => '/[<>&]/', + self::FORCE_COLLECTION => [], ]; public function __construct(array $defaultContext = []) @@ -224,10 +230,20 @@ final protected function isElementNameValid(string $name): bool */ private function parseXml(\DOMNode $node, array $context = []): array|string { + $nodeName = $node->nodeName; + $data = $this->parseXmlAttributes($node, $context); $value = $this->parseXmlValue($node, $context); + if (\is_array($value) + && ($childNodeName = $node->firstChild?->nodeName) + && 1 === \count($value) + && \in_array($nodeName, $context[self::FORCE_COLLECTION] ?? $this->defaultContext[self::FORCE_COLLECTION], true) + ) { + return [$childNodeName => [$value[$childNodeName]]]; + } + if (!\count($data)) { return $value; } diff --git a/src/Symfony/Component/Serializer/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php b/src/Symfony/Component/Serializer/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php index 2f71c6012b222..52a3cb563284a 100644 --- a/src/Symfony/Component/Serializer/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php @@ -37,6 +37,7 @@ public function testWithers(array $values) ->withDecoderIgnoredNodeTypes($values[XmlEncoder::DECODER_IGNORED_NODE_TYPES]) ->withEncoderIgnoredNodeTypes($values[XmlEncoder::ENCODER_IGNORED_NODE_TYPES]) ->withEncoding($values[XmlEncoder::ENCODING]) + ->withForceCollection($values[XmlEncoder::FORCE_COLLECTION]) ->withFormatOutput($values[XmlEncoder::FORMAT_OUTPUT]) ->withLoadOptions($values[XmlEncoder::LOAD_OPTIONS]) ->withSaveOptions($values[XmlEncoder::SAVE_OPTIONS]) @@ -59,6 +60,7 @@ public static function withersDataProvider(): iterable XmlEncoder::DECODER_IGNORED_NODE_TYPES => [\XML_PI_NODE, \XML_COMMENT_NODE], XmlEncoder::ENCODER_IGNORED_NODE_TYPES => [\XML_TEXT_NODE], XmlEncoder::ENCODING => 'UTF-8', + XmlEncoder::FORCE_COLLECTION => ['order'], XmlEncoder::FORMAT_OUTPUT => false, XmlEncoder::LOAD_OPTIONS => \LIBXML_COMPACT, XmlEncoder::SAVE_OPTIONS => \LIBXML_NOERROR, @@ -76,6 +78,7 @@ public static function withersDataProvider(): iterable XmlEncoder::DECODER_IGNORED_NODE_TYPES => null, XmlEncoder::ENCODER_IGNORED_NODE_TYPES => null, XmlEncoder::ENCODING => null, + XmlEncoder::FORCE_COLLECTION => null, XmlEncoder::FORMAT_OUTPUT => null, XmlEncoder::LOAD_OPTIONS => null, XmlEncoder::SAVE_OPTIONS => null, diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php index 31d2ddfc69c41..0d74136fb6328 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php @@ -704,6 +704,25 @@ public function testDecodeAlwaysAsCollection() $this->assertEquals($expected, $this->encoder->decode($source, 'xml', ['as_collection' => true])); } + public function testDecodeGivenAttributeAlwaysAsCollection() + { + $source = <<<'XML' + + + 1 + 1200 + + +XML; + $expected = [ + 'order_row' => [ + ['id' => 1, 'price' => 1200], + ], + ]; + + $this->assertEquals($expected, $this->encoder->decode($source, 'xml', [XmlEncoder::FORCE_COLLECTION => ['order_rows']])); + } + public function testDecodeWithoutItemHash() { $obj = new ScalarDummy();