diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 951eb9d4a59b8..f381919d8b1dd 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -350,6 +350,10 @@ public function denormalize($data, string $type, string $format = null, array $c $this->validateCallbackContext($context); + if (null === $data && isset($context['value_type']) && $context['value_type'] instanceof Type && $context['value_type']->isNullable()) { + return null; + } + $allowedAttributes = $this->getAllowedAttributes($type, $context, true); $normalizedData = $this->prepareForDenormalization($data); $extraAttributes = []; @@ -519,6 +523,8 @@ private function validateAndDenormalize(array $types, string $currentClass, stri if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) { [$context['key_type']] = $collectionKeyType; } + + $context['value_type'] = $collectionValueType; } elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) { // get inner type for any nested array [$innerType] = $collectionValueType; diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/MapDenormalizationTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/MapDenormalizationTest.php new file mode 100644 index 0000000000000..6c32fb925b0ff --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/MapDenormalizationTest.php @@ -0,0 +1,304 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Normalizer; + +use Doctrine\Common\Annotations\AnnotationReader; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; +use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +class MapDenormalizationTest extends TestCase +{ + public function testMapOfStringToNullableObject() + { + $normalizedData = $this->getSerializer()->denormalize([ + 'map' => [ + 'assertDummyMapValue' => [ + 'value' => 'foo', + ], + 'assertNull' => null, + ], + ], DummyMapOfStringToNullableObject::class); + + $this->assertInstanceOf(DummyMapOfStringToNullableObject::class, $normalizedData); + + // check nullable map value + $this->assertIsArray($normalizedData->map); + + $this->assertArrayHasKey('assertDummyMapValue', $normalizedData->map); + $this->assertInstanceOf(DummyValue::class, $normalizedData->map['assertDummyMapValue']); + + $this->assertArrayHasKey('assertNull', $normalizedData->map); + + $this->assertNull($normalizedData->map['assertNull']); + } + + public function testMapOfStringToAbstractNullableObject() + { + $normalizedData = $this->getSerializer()->denormalize( + [ + 'map' => [ + 'assertNull' => null, + ], + ], DummyMapOfStringToNullableAbstractObject::class); + + $this->assertInstanceOf(DummyMapOfStringToNullableAbstractObject::class, $normalizedData); + + $this->assertIsArray($normalizedData->map); + $this->assertArrayHasKey('assertNull', $normalizedData->map); + $this->assertNull($normalizedData->map['assertNull']); + } + + public function testMapOfStringToObject() + { + $normalizedData = $this->getSerializer()->denormalize( + [ + 'map' => [ + 'assertDummyMapValue' => [ + 'value' => 'foo', + ], + 'assertEmptyDummyMapValue' => null, + ], + ], DummyMapOfStringToObject::class); + + $this->assertInstanceOf(DummyMapOfStringToObject::class, $normalizedData); + + // check nullable map value + $this->assertIsArray($normalizedData->map); + + $this->assertArrayHasKey('assertDummyMapValue', $normalizedData->map); + $this->assertInstanceOf(DummyValue::class, $normalizedData->map['assertDummyMapValue']); + $this->assertEquals('foo', $normalizedData->map['assertDummyMapValue']->value); + + $this->assertArrayHasKey('assertEmptyDummyMapValue', $normalizedData->map); + $this->assertInstanceOf(DummyValue::class, $normalizedData->map['assertEmptyDummyMapValue']); // correct since to attribute is not nullable + $this->assertNull($normalizedData->map['assertEmptyDummyMapValue']->value); + } + + public function testMapOfStringToAbstractObject() + { + $normalizedData = $this->getSerializer()->denormalize( + [ + 'map' => [ + 'assertDummyMapValue' => [ + 'type' => 'dummy', + 'value' => 'foo', + ], + ], + ], DummyMapOfStringToNotNullableAbstractObject::class); + + $this->assertInstanceOf(DummyMapOfStringToNotNullableAbstractObject::class, $normalizedData); + + // check nullable map value + $this->assertIsArray($normalizedData->map); + + $this->assertArrayHasKey('assertDummyMapValue', $normalizedData->map); + $this->assertInstanceOf(DummyValue::class, $normalizedData->map['assertDummyMapValue']); + $this->assertEquals('foo', $normalizedData->map['assertDummyMapValue']->value); + } + + public function testMapOfStringToAbstractObjectMissingTypeAttribute() + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Type property "type" not found for the abstract object "Symfony\Component\Serializer\Tests\Normalizer\AbstractDummyValue".'); + + $this->getSerializer()->denormalize( + [ + 'map' => [ + 'assertEmptyDummyMapValue' => null, + ], + ], DummyMapOfStringToNotNullableAbstractObject::class); + } + + public function testNullableObject() + { + $normalizedData = $this->getSerializer()->denormalize( + [ + 'object' => [ + 'value' => 'foo', + ], + 'nullObject' => null, + ], DummyNullableObjectValue::class); + + $this->assertInstanceOf(DummyNullableObjectValue::class, $normalizedData); + + $this->assertInstanceOf(DummyValue::class, $normalizedData->object); + $this->assertEquals('foo', $normalizedData->object->value); + + $this->assertNull($normalizedData->nullObject); + } + + public function testNotNullableObject() + { + $normalizedData = $this->getSerializer()->denormalize( + [ + 'object' => [ + 'value' => 'foo', + ], + 'nullObject' => null, + ], DummyNotNullableObjectValue::class); + + $this->assertInstanceOf(DummyNotNullableObjectValue::class, $normalizedData); + + $this->assertInstanceOf(DummyValue::class, $normalizedData->object); + $this->assertEquals('foo', $normalizedData->object->value); + + $this->assertInstanceOf(DummyValue::class, $normalizedData->nullObject); + $this->assertNull($normalizedData->nullObject->value); + } + + public function testNullableAbstractObject() + { + $normalizedData = $this->getSerializer()->denormalize( + [ + 'object' => [ + 'type' => 'another-dummy', + 'value' => 'foo', + ], + 'nullObject' => null, + ], DummyNullableAbstractObjectValue::class); + + $this->assertInstanceOf(DummyNullableAbstractObjectValue::class, $normalizedData); + + $this->assertInstanceOf(AnotherDummyValue::class, $normalizedData->object); + $this->assertEquals('foo', $normalizedData->object->value); + + $this->assertNull($normalizedData->nullObject); + } + + private function getSerializer() + { + $loaderMock = new class() implements ClassMetadataFactoryInterface { + public function getMetadataFor($value): ClassMetadataInterface + { + if (AbstractDummyValue::class === $value) { + return new ClassMetadata( + AbstractDummyValue::class, + new ClassDiscriminatorMapping('type', [ + 'dummy' => DummyValue::class, + 'another-dummy' => AnotherDummyValue::class, + ]) + ); + } + + throw new InvalidArgumentException(); + } + + public function hasMetadataFor($value): bool + { + return AbstractDummyValue::class === $value; + } + }; + + $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($factory, null, null, new PhpDocExtractor(), new ClassDiscriminatorFromClassMetadata($loaderMock)); + $serializer = new Serializer([$normalizer, new ArrayDenormalizer()]); + $normalizer->setSerializer($serializer); + + return $serializer; + } +} + +abstract class AbstractDummyValue +{ + public $value; +} + +class DummyValue extends AbstractDummyValue +{ +} + +class AnotherDummyValue extends AbstractDummyValue +{ +} + +class DummyNotNullableObjectValue +{ + /** + * @var DummyValue + */ + public $object; + + /** + * @var DummyValue + */ + public $nullObject; +} + +class DummyNullableObjectValue +{ + /** + * @var DummyValue|null + */ + public $object; + + /** + * @var DummyValue|null + */ + public $nullObject; +} + +class DummyNullableAbstractObjectValue +{ + /** + * @var AbstractDummyValue|null + */ + public $object; + + /** + * @var AbstractDummyValue|null + */ + public $nullObject; +} + +class DummyMapOfStringToNullableObject +{ + /** + * @var array + */ + public $map; +} + +class DummyMapOfStringToObject +{ + /** + * @var array + */ + public $map; +} + +class DummyMapOfStringToNullableAbstractObject +{ + /** + * @var array + */ + public $map; +} + +class DummyMapOfStringToNotNullableAbstractObject +{ + /** + * @var array + */ + public $map; +}