diff --git a/src/DeserializationContext.php b/src/DeserializationContext.php index 5c16a24db..ed54d5898 100644 --- a/src/DeserializationContext.php +++ b/src/DeserializationContext.php @@ -13,6 +13,11 @@ class DeserializationContext extends Context */ private $depth = 0; + /** + * @var bool + */ + private $deserializeNull = true; + public static function create(): self { return new self(); @@ -33,6 +38,25 @@ public function increaseDepth(): void $this->depth += 1; } + /** + * Set if NULLs should be deserialized (TRUE) ot not (FALSE) + */ + public function setDeserializeNull(bool $bool): self + { + $this->deserializeNull = $bool; + + return $this; + } + + /** + * Returns TRUE when NULLs should be deserialized + * Returns FALSE when NULLs should not be deserialized + */ + public function shouldDeserializeNull(): bool + { + return $this->deserializeNull; + } + public function decreaseDepth(): void { if ($this->depth <= 0) { diff --git a/src/GraphNavigator/DeserializationGraphNavigator.php b/src/GraphNavigator/DeserializationGraphNavigator.php index d25da8111..3ff5c65dc 100644 --- a/src/GraphNavigator/DeserializationGraphNavigator.php +++ b/src/GraphNavigator/DeserializationGraphNavigator.php @@ -6,6 +6,7 @@ use JMS\Serializer\Accessor\AccessorStrategyInterface; use JMS\Serializer\Construction\ObjectConstructorInterface; +use JMS\Serializer\Context; use JMS\Serializer\DeserializationContext; use JMS\Serializer\EventDispatcher\EventDispatcher; use JMS\Serializer\EventDispatcher\EventDispatcherInterface; @@ -23,6 +24,7 @@ use JMS\Serializer\Metadata\ClassMetadata; use JMS\Serializer\NullAwareVisitorInterface; use JMS\Serializer\Visitor\DeserializationVisitorInterface; +use JMS\Serializer\VisitorInterface; use Metadata\MetadataFactoryInterface; /** @@ -74,6 +76,11 @@ final class DeserializationGraphNavigator extends GraphNavigator implements Grap */ private $accessor; + /** + * @var bool + */ + private $shouldDeserializeNull; + public function __construct( MetadataFactoryInterface $metadataFactory, HandlerRegistryInterface $handlerRegistry, @@ -92,6 +99,12 @@ public function __construct( } } + public function initialize(VisitorInterface $visitor, Context $context): void + { + parent::initialize($visitor, $context); + $this->shouldDeserializeNull = $context->shouldDeserializeNull(); + } + /** * Called for each node of the graph that is being traversed. * @@ -109,10 +122,13 @@ public function accept($data, ?array $type = null) } // Sometimes data can convey null but is not of a null type. // Visitors can have the power to add this custom null evaluation - if ($this->visitor instanceof NullAwareVisitorInterface && true === $this->visitor->isNull($data)) { + // If null is explicitly allowed we should skip this + if ($this->visitor instanceof NullAwareVisitorInterface + && true === $this->visitor->isNull($data) + && true === $this->shouldDeserializeNull + ) { $type = ['name' => 'NULL', 'params' => []]; } - switch ($type['name']) { case 'NULL': return $this->visitor->visitNull($data, $type); @@ -153,9 +169,11 @@ public function accept($data, ?array $type = null) // before loading metadata because the type name might not be a class, but // could also simply be an artifical type. if (null !== $handler = $this->handlerRegistry->getHandler(GraphNavigatorInterface::DIRECTION_DESERIALIZATION, $type['name'], $this->format)) { - $rs = \call_user_func($handler, $this->visitor, $data, $type, $this->context); - $this->context->decreaseDepth(); - + try { + $rs = \call_user_func($handler, $this->visitor, $data, $type, $this->context); + } finally { + $this->context->decreaseDepth(); + } return $rs; } @@ -201,6 +219,7 @@ public function accept($data, ?array $type = null) $this->context->pushPropertyMetadata($propertyMetadata); try { $v = $this->visitor->visitProperty($propertyMetadata, $data); + $this->accessor->setValue($object, $v, $propertyMetadata, $this->context); } catch (NotAcceptableException $e) { } diff --git a/src/JsonDeserializationVisitor.php b/src/JsonDeserializationVisitor.php index ca91ff1e7..5b2696610 100644 --- a/src/JsonDeserializationVisitor.php +++ b/src/JsonDeserializationVisitor.php @@ -11,7 +11,7 @@ use JMS\Serializer\Metadata\PropertyMetadata; use JMS\Serializer\Visitor\DeserializationVisitorInterface; -final class JsonDeserializationVisitor extends AbstractVisitor implements DeserializationVisitorInterface +final class JsonDeserializationVisitor extends AbstractVisitor implements NullAwareVisitorInterface, DeserializationVisitorInterface { /** * @var int @@ -169,7 +169,7 @@ public function visitProperty(PropertyMetadata $metadata, $data) throw new NotAcceptableException(); } - return null !== $data[$name] ? $this->navigator->accept($data[$name], $metadata->type) : null; + return $this->navigator->accept($data[$name], $metadata->type); } /** @@ -237,4 +237,12 @@ public function prepare($str) throw new RuntimeException('Could not decode JSON.'); } } + + /** + * {@inheritdoc} + */ + public function isNull($value): bool + { + return null === $value; + } } diff --git a/tests/Fixtures/InitializedBlogPostConstructor.php b/tests/Fixtures/InitializedBlogPostConstructor.php index 078978b67..6a514832e 100644 --- a/tests/Fixtures/InitializedBlogPostConstructor.php +++ b/tests/Fixtures/InitializedBlogPostConstructor.php @@ -14,9 +14,15 @@ class InitializedBlogPostConstructor implements ObjectConstructorInterface { private $fallback; - public function __construct() + /** + * @var BlogPost + */ + private $blogPost; + + public function __construct(BlogPost $blogPost) { $this->fallback = new UnserializeObjectConstructor(); + $this->blogPost = $blogPost; } public function construct(DeserializationVisitorInterface $visitor, ClassMetadata $metadata, $data, array $type, DeserializationContext $context): ?object @@ -25,6 +31,6 @@ public function construct(DeserializationVisitorInterface $visitor, ClassMetadat return $this->fallback->construct($visitor, $metadata, $data, $type, $context); } - return new BlogPost('This is a nice title.', new Author('Foo Bar'), new \DateTime('2011-07-30 00:00', new \DateTimeZone('UTC')), new Publisher('Bar Foo')); + return $this->blogPost; } } diff --git a/tests/Fixtures/ObjectWithNullObject.php b/tests/Fixtures/ObjectWithNullObject.php new file mode 100644 index 000000000..718ca6ef0 --- /dev/null +++ b/tests/Fixtures/ObjectWithNullObject.php @@ -0,0 +1,24 @@ +nullProperty; + } +} diff --git a/tests/Serializer/BaseSerializationTest.php b/tests/Serializer/BaseSerializationTest.php index 4075349a0..69c015a74 100644 --- a/tests/Serializer/BaseSerializationTest.php +++ b/tests/Serializer/BaseSerializationTest.php @@ -76,6 +76,7 @@ use JMS\Serializer\Tests\Fixtures\ObjectWithIntListAndIntMap; use JMS\Serializer\Tests\Fixtures\ObjectWithIterator; use JMS\Serializer\Tests\Fixtures\ObjectWithLifecycleCallbacks; +use JMS\Serializer\Tests\Fixtures\ObjectWithNullObject; use JMS\Serializer\Tests\Fixtures\ObjectWithNullProperty; use JMS\Serializer\Tests\Fixtures\ObjectWithToString; use JMS\Serializer\Tests\Fixtures\ObjectWithTypedArraySetter; @@ -209,6 +210,25 @@ public function testDeserializeNullObject() self::assertNull($dObj->getNullProperty()); } + public function testDeserializeNullObjectWithHandler() + { + if (!$this->hasDeserializer()) { + $this->markTestSkipped(sprintf('No deserializer available for format `%s`', $this->getFormat())); + } + $ctx = DeserializationContext::create() + ->setDeserializeNull(false); + + /** @var ObjectWithNullObject $dObj */ + $dObj = $this->serializer->deserialize( + $this->getContent('simple_object_nullable'), + ObjectWithNullObject::class, + $this->getFormat(), + $ctx + ); + + self::assertSame('nullObject', $dObj->getNullProperty()); + } + /** * @expectedException \JMS\Serializer\Exception\NotAcceptableException * @dataProvider getTypes @@ -730,14 +750,13 @@ public function testBlogPost() public function testDeserializingNull() { - $objectConstructor = new InitializedBlogPostConstructor(); + $post = new BlogPost('This is a nice title.', $author = new Author('Foo Bar'), new \DateTime('2011-07-30 00:00', new \DateTimeZone('UTC')), new Publisher('Bar Foo')); + $objectConstructor = new InitializedBlogPostConstructor($post); $builder = SerializerBuilder::create(); $builder->setObjectConstructor($objectConstructor); $this->serializer = $builder->build(); - $post = new BlogPost('This is a nice title.', $author = new Author('Foo Bar'), new \DateTime('2011-07-30 00:00', new \DateTimeZone('UTC')), new Publisher('Bar Foo')); - $this->setField($post, 'author', null); $this->setField($post, 'publisher', null); @@ -751,7 +770,42 @@ public function testDeserializingNull() self::assertAttributeSame(false, 'published', $deserialized); self::assertAttributeSame(false, 'reviewed', $deserialized); self::assertAttributeEquals(new ArrayCollection(), 'comments', $deserialized); - self::assertEquals(null, $this->getField($deserialized, 'author')); + self::assertAttributeSame(null, 'author', $deserialized); + self::assertAttributeSame(null, 'tag', $deserialized); + } + } + + public function testDeserializingNullAllowed() + { + $savedPost = new BlogPost('This is a nice title.', $author = new Author('Foo Bar'), new \DateTime('2011-07-30 00:00', new \DateTimeZone('UTC')), $publisher = new Publisher('Bar Foo')); + $savedPost->addTag(new Tag('foo')); + $initialTag = $this->getField($savedPost, 'tag'); + + $objectConstructor = new InitializedBlogPostConstructor($savedPost); + + $builder = SerializerBuilder::create(); + $builder->setObjectConstructor($objectConstructor); + $this->serializer = $builder->build(); + + $post = clone $savedPost; + $this->setField($post, 'author', null); + $this->setField($post, 'publisher', null); + $this->setField($post, 'tag', null); + + if ($this->hasDeserializer()) { + $ctx = DeserializationContext::create(); + $ctx->setDeserializeNull(false); + + $deserialized = $this->deserialize($this->serialize($post), get_class($post), $ctx); + + self::assertEquals('2011-07-30T00:00:00+00:00', $this->getField($deserialized, 'createdAt')->format(\DateTime::ATOM)); + self::assertAttributeEquals('This is a nice title.', 'title', $deserialized); + self::assertAttributeSame(false, 'published', $deserialized); + self::assertAttributeSame(false, 'reviewed', $deserialized); + self::assertAttributeEquals(new ArrayCollection(), 'comments', $deserialized); + self::assertAttributeEquals($author, 'author', $deserialized); + self::assertAttributeEquals($publisher, 'publisher', $deserialized); + self::assertAttributeEquals($initialTag, 'tag', $deserialized); } } @@ -1668,7 +1722,14 @@ static function (DeserializationVisitorInterface $visitor, $data, $type, Context return $list; } ); - + $this->handlerRegistry->registerHandler( + GraphNavigatorInterface::DIRECTION_DESERIALIZATION, + 'NullObject', + $this->getFormat(), + static function (DeserializationVisitorInterface $visitor, $data, $type, Context $context) { + return 'nullObject'; + } + ); $this->dispatcher = new EventDispatcher(); $this->dispatcher->addSubscriber(new DoctrineProxySubscriber()); diff --git a/tests/Serializer/GraphNavigatorTest.php b/tests/Serializer/GraphNavigatorTest.php index 27286522a..7afc5e0cf 100644 --- a/tests/Serializer/GraphNavigatorTest.php +++ b/tests/Serializer/GraphNavigatorTest.php @@ -141,7 +141,6 @@ protected function setUp() ->enableOriginalConstructor() ->setMethodsExcept(['getExclusionStrategy']) ->getMock(); - $this->deserializationContext = $this->getMockBuilder(DeserializationContext::class) ->enableOriginalConstructor() ->setMethodsExcept(['getExclusionStrategy']) diff --git a/tests/Serializer/XmlSerializationTest.php b/tests/Serializer/XmlSerializationTest.php index 08254e6cb..817df32ba 100644 --- a/tests/Serializer/XmlSerializationTest.php +++ b/tests/Serializer/XmlSerializationTest.php @@ -368,6 +368,11 @@ public function testDeserializingNull() $this->markTestSkipped('Not supported in XML.'); } + public function testDeserializingNullAllowed() + { + $this->markTestSkipped('Not supported in XML.'); + } + public function testObjectWithXmlNamespaces() { $object = new ObjectWithXmlNamespaces('This is a nice title.', 'Foo Bar', new \DateTime('2011-07-30 00:00', new \DateTimeZone('UTC')), 'en');