From 0050bbb3450aacf58625a333735f8e5d2637e931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sun, 4 Jan 2015 14:47:41 +0100 Subject: [PATCH] [Serializer] Introduce ObjectNormalizer --- src/Symfony/Component/Serializer/CHANGELOG.md | 2 + .../Normalizer/ObjectNormalizer.php | 162 ++++++ .../Tests/Normalizer/ObjectNormalizerTest.php | 497 ++++++++++++++++++ .../Component/Serializer/composer.json | 4 +- 4 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php create mode 100644 src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 93645e8f57..ceeeeb1e7a 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -15,6 +15,8 @@ CHANGELOG `PropertyNormalizer::setCamelizedAttributes()` are replaced by `CamelCaseToSnakeCaseNameConverter` * [DEPRECATION] the `Exception` interface has been renamed to `ExceptionInterface` + * added `ObjectNormalizer` leveraging the `PropertyAccess` component to normalize + objects containing both properties and getters / setters / issers / hassers methods. 2.6.0 ----- diff --git a/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php new file mode 100644 index 0000000000..92628cb531 --- /dev/null +++ b/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Normalizer; + +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\CircularReferenceException; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts between objects and arrays using the PropertyAccess component. + * + * @author Kévin Dunglas + */ +class ObjectNormalizer extends AbstractNormalizer +{ + /** + * @var PropertyAccessorInterface + */ + protected $propertyAccessor; + + public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null) + { + parent::__construct($classMetadataFactory, $nameConverter); + + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return is_object($data); + } + + /** + * {@inheritdoc} + * + * @throws CircularReferenceException + */ + public function normalize($object, $format = null, array $context = array()) + { + if ($this->isCircularReference($object, $context)) { + return $this->handleCircularReference($object); + } + + $data = array(); + $attributes = $this->getAllowedAttributes($object, $context, true); + + // If not using groups, detect manually + if (false === $attributes) { + $attributes = array(); + + // methods + $reflClass = new \ReflectionClass($object); + foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) { + if ( + !$reflMethod->isConstructor() && + !$reflMethod->isDestructor() && + 0 === $reflMethod->getNumberOfRequiredParameters() + ) { + $name = $reflMethod->getName(); + + if (strpos($name, 'get') === 0 || strpos($name, 'has') === 0) { + // getters and hassers + $attributes[lcfirst(substr($name, 3))] = true; + } elseif (strpos($name, 'is') === 0) { + // issers + $attributes[lcfirst(substr($name, 2))] = true; + } + } + } + + // properties + foreach ($reflClass->getProperties(\ReflectionProperty::IS_PUBLIC) as $reflProperty) { + $attributes[$reflProperty->getName()] = true; + } + + $attributes = array_keys($attributes); + } + + foreach ($attributes as $attribute) { + if (in_array($attribute, $this->ignoredAttributes)) { + continue; + } + + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + + if (isset($this->callbacks[$attribute])) { + $attributeValue = call_user_func($this->callbacks[$attribute], $attributeValue); + } + + if (null !== $attributeValue && !is_scalar($attributeValue)) { + if (!$this->serializer instanceof NormalizerInterface) { + throw new LogicException(sprintf('Cannot normalize attribute "%s" because injected serializer is not a normalizer', $attribute)); + } + + $attributeValue = $this->serializer->normalize($attributeValue, $format, $context); + } + + if ($this->nameConverter) { + $attribute = $this->nameConverter->normalize($attribute); + } + + $data[$attribute] = $attributeValue; + } + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return class_exists($type); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = array()) + { + $allowedAttributes = $this->getAllowedAttributes($class, $context, true); + $normalizedData = $this->prepareForDenormalization($data); + + $reflectionClass = new \ReflectionClass($class); + $object = $this->instantiateObject($normalizedData, $class, $context, $reflectionClass, $allowedAttributes); + + foreach ($normalizedData as $attribute => $value) { + $allowed = $allowedAttributes === false || in_array($attribute, $allowedAttributes); + $ignored = in_array($attribute, $this->ignoredAttributes); + + if ($allowed && !$ignored) { + if ($this->nameConverter) { + $attribute = $this->nameConverter->normalize($attribute); + } + + try { + $this->propertyAccessor->setValue($object, $attribute, $value); + } catch (NoSuchPropertyException $exception) { + // Properties not found are ignored + } + } + } + + return $object; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php new file mode 100644 index 0000000000..e90209f357 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -0,0 +1,497 @@ + + * + * 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 Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy; +use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; +use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Tests\Fixtures\GroupDummy; + +/** + * @author Kévin Dunglas + */ +class ObjectNormalizerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var ObjectNormalizerTest + */ + private $normalizer; + /** + * @var SerializerInterface + */ + private $serializer; + + protected function setUp() + { + $this->serializer = $this->getMock(__NAMESPACE__.'\ObjectSerializerNormalizer'); + $this->normalizer = new ObjectNormalizer(); + $this->normalizer->setSerializer($this->serializer); + } + + public function testNormalize() + { + $obj = new ObjectDummy(); + $object = new \stdClass(); + $obj->setFoo('foo'); + $obj->bar = 'bar'; + $obj->setBaz(true); + $obj->setCamelCase('camelcase'); + $obj->setObject($object); + + $this->serializer + ->expects($this->once()) + ->method('normalize') + ->with($object, 'any') + ->will($this->returnValue('string_object')) + ; + + $this->assertEquals( + array( + 'foo' => 'foo', + 'bar' => 'bar', + 'baz' => true, + 'fooBar' => 'foobar', + 'camelCase' => 'camelcase', + 'object' => 'string_object', + ), + $this->normalizer->normalize($obj, 'any') + ); + } + + public function testDenormalize() + { + $obj = $this->normalizer->denormalize( + array('foo' => 'foo', 'bar' => 'bar', 'baz' => true, 'fooBar' => 'foobar'), + __NAMESPACE__.'\ObjectDummy', + 'any' + ); + $this->assertEquals('foo', $obj->getFoo()); + $this->assertEquals('bar', $obj->bar); + $this->assertTrue($obj->isBaz()); + } + + public function testDenormalizeWithObject() + { + $data = new \stdClass(); + $data->foo = 'foo'; + $data->bar = 'bar'; + $data->fooBar = 'foobar'; + $obj = $this->normalizer->denormalize($data, __NAMESPACE__.'\ObjectDummy', 'any'); + $this->assertEquals('foo', $obj->getFoo()); + $this->assertEquals('bar', $obj->bar); + } + + public function testLegacyDenormalizeOnCamelCaseFormat() + { + $this->iniSet('error_reporting', -1 & ~E_USER_DEPRECATED); + + $this->normalizer->setCamelizedAttributes(array('camel_case')); + $obj = $this->normalizer->denormalize( + array('camel_case' => 'camelCase'), + __NAMESPACE__.'\ObjectDummy' + ); + $this->assertEquals('camelCase', $obj->getCamelCase()); + } + + public function testDenormalizeNull() + { + $this->assertEquals(new ObjectDummy(), $this->normalizer->denormalize(null, __NAMESPACE__.'\ObjectDummy')); + } + + public function testConstructorDenormalize() + { + $obj = $this->normalizer->denormalize( + array('foo' => 'foo', 'bar' => 'bar', 'baz' => true, 'fooBar' => 'foobar'), + __NAMESPACE__.'\ObjectConstructorDummy', 'any'); + $this->assertEquals('foo', $obj->getFoo()); + $this->assertEquals('bar', $obj->bar); + $this->assertTrue($obj->isBaz()); + } + + public function testConstructorDenormalizeWithMissingOptionalArgument() + { + $obj = $this->normalizer->denormalize( + array('foo' => 'test', 'baz' => array(1, 2, 3)), + __NAMESPACE__.'\ObjectConstructorOptionalArgsDummy', 'any'); + $this->assertEquals('test', $obj->getFoo()); + $this->assertEquals(array(), $obj->bar); + $this->assertEquals(array(1, 2, 3), $obj->getBaz()); + } + + public function testConstructorWithObjectDenormalize() + { + $data = new \stdClass(); + $data->foo = 'foo'; + $data->bar = 'bar'; + $data->baz = true; + $data->fooBar = 'foobar'; + $obj = $this->normalizer->denormalize($data, __NAMESPACE__.'\ObjectConstructorDummy', 'any'); + $this->assertEquals('foo', $obj->getFoo()); + $this->assertEquals('bar', $obj->bar); + } + + public function testGroupsNormalize() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $this->normalizer = new ObjectNormalizer($classMetadataFactory); + $this->normalizer->setSerializer($this->serializer); + + $obj = new GroupDummy(); + $obj->setFoo('foo'); + $obj->setBar('bar'); + $obj->setFooBar('fooBar'); + $obj->setSymfony('symfony'); + $obj->setKevin('kevin'); + $obj->setCoopTilleuls('coopTilleuls'); + + $this->assertEquals(array( + 'bar' => 'bar', + ), $this->normalizer->normalize($obj, null, array('groups' => array('c')))); + + $this->assertEquals(array( + 'symfony' => 'symfony', + 'foo' => 'foo', + 'fooBar' => 'fooBar', + 'bar' => 'bar', + 'kevin' => 'kevin', + 'coopTilleuls' => 'coopTilleuls', + ), $this->normalizer->normalize($obj, null, array('groups' => array('a', 'c')))); + } + + public function testGroupsDenormalize() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $this->normalizer = new ObjectNormalizer($classMetadataFactory); + $this->normalizer->setSerializer($this->serializer); + + $obj = new GroupDummy(); + $obj->setFoo('foo'); + + $toNormalize = array('foo' => 'foo', 'bar' => 'bar'); + + $normalized = $this->normalizer->denormalize( + $toNormalize, + 'Symfony\Component\Serializer\Tests\Fixtures\GroupDummy', + null, + array('groups' => array('a')) + ); + $this->assertEquals($obj, $normalized); + + $obj->setBar('bar'); + + $normalized = $this->normalizer->denormalize( + $toNormalize, + 'Symfony\Component\Serializer\Tests\Fixtures\GroupDummy', + null, + array('groups' => array('a', 'b')) + ); + $this->assertEquals($obj, $normalized); + } + + /** + * @dataProvider provideCallbacks + */ + public function testCallbacks($callbacks, $value, $result, $message) + { + $this->normalizer->setCallbacks($callbacks); + + $obj = new ObjectConstructorDummy('', $value, true); + + $this->assertEquals( + $result, + $this->normalizer->normalize($obj, 'any'), + $message + ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testUncallableCallbacks() + { + $this->normalizer->setCallbacks(array('bar' => null)); + + $obj = new ObjectConstructorDummy('baz', 'quux', true); + + $this->normalizer->normalize($obj, 'any'); + } + + public function testIgnoredAttributes() + { + $this->normalizer->setIgnoredAttributes(array('foo', 'bar', 'baz', 'camelCase', 'object')); + + $obj = new ObjectDummy(); + $obj->setFoo('foo'); + $obj->bar = 'bar'; + $obj->setBaz(true); + + $this->assertEquals( + array('fooBar' => 'foobar'), + $this->normalizer->normalize($obj, 'any') + ); + } + + public function provideCallbacks() + { + return array( + array( + array( + 'bar' => function ($bar) { + return 'baz'; + }, + ), + 'baz', + array('foo' => '', 'bar' => 'baz', 'baz' => true), + 'Change a string', + ), + array( + array( + 'bar' => function ($bar) { + return; + }, + ), + 'baz', + array('foo' => '', 'bar' => null, 'baz' => true), + 'Null an item', + ), + array( + array( + 'bar' => function ($bar) { + return $bar->format('d-m-Y H:i:s'); + }, + ), + new \DateTime('2011-09-10 06:30:00'), + array('foo' => '', 'bar' => '10-09-2011 06:30:00', 'baz' => true), + 'Format a date', + ), + array( + array( + 'bar' => function ($bars) { + $foos = ''; + foreach ($bars as $bar) { + $foos .= $bar->getFoo(); + } + + return $foos; + }, + ), + array(new ObjectConstructorDummy('baz', '', false), new ObjectConstructorDummy('quux', '', false)), + array('foo' => '', 'bar' => 'bazquux', 'baz' => true), + 'Collect a property', + ), + array( + array( + 'bar' => function ($bars) { + return count($bars); + }, + ), + array(new ObjectConstructorDummy('baz', '', false), new ObjectConstructorDummy('quux', '', false)), + array('foo' => '', 'bar' => 2, 'baz' => true), + 'Count a property', + ), + ); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\LogicException + * @expectedExceptionMessage Cannot normalize attribute "object" because injected serializer is not a normalizer + */ + public function testUnableToNormalizeObjectAttribute() + { + $serializer = $this->getMock('Symfony\Component\Serializer\SerializerInterface'); + $this->normalizer->setSerializer($serializer); + + $obj = new ObjectDummy(); + $object = new \stdClass(); + $obj->setObject($object); + + $this->normalizer->normalize($obj, 'any'); + } + + /** + * @expectedException \Symfony\Component\Serializer\Exception\CircularReferenceException + */ + public function testUnableToNormalizeCircularReference() + { + $serializer = new Serializer(array($this->normalizer)); + $this->normalizer->setSerializer($serializer); + $this->normalizer->setCircularReferenceLimit(2); + + $obj = new CircularReferenceDummy(); + + $this->normalizer->normalize($obj); + } + + public function testSiblingReference() + { + $serializer = new Serializer(array($this->normalizer)); + $this->normalizer->setSerializer($serializer); + + $siblingHolder = new SiblingHolder(); + + $expected = array( + 'sibling0' => array('coopTilleuls' => 'Les-Tilleuls.coop'), + 'sibling1' => array('coopTilleuls' => 'Les-Tilleuls.coop'), + 'sibling2' => array('coopTilleuls' => 'Les-Tilleuls.coop'), + ); + $this->assertEquals($expected, $this->normalizer->normalize($siblingHolder)); + } + + public function testCircularReferenceHandler() + { + $serializer = new Serializer(array($this->normalizer)); + $this->normalizer->setSerializer($serializer); + $this->normalizer->setCircularReferenceHandler(function ($obj) { + return get_class($obj); + }); + + $obj = new CircularReferenceDummy(); + + $expected = array('me' => 'Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy'); + $this->assertEquals($expected, $this->normalizer->normalize($obj)); + } + + public function testDenormalizeNonExistingAttribute() + { + $this->assertEquals( + new ObjectDummy(), + $this->normalizer->denormalize(array('non_existing' => true), __NAMESPACE__.'\ObjectDummy') + ); + } +} + +class ObjectDummy +{ + protected $foo; + public $bar; + private $baz; + protected $camelCase; + protected $object; + + public function getFoo() + { + return $this->foo; + } + + public function setFoo($foo) + { + $this->foo = $foo; + } + + public function isBaz() + { + return $this->baz; + } + + public function setBaz($baz) + { + $this->baz = $baz; + } + + public function getFooBar() + { + return $this->foo.$this->bar; + } + + public function getCamelCase() + { + return $this->camelCase; + } + + public function setCamelCase($camelCase) + { + $this->camelCase = $camelCase; + } + + public function otherMethod() + { + throw new \RuntimeException("Dummy::otherMethod() should not be called"); + } + + public function setObject($object) + { + $this->object = $object; + } + + public function getObject() + { + return $this->object; + } +} + +class ObjectConstructorDummy +{ + protected $foo; + public $bar; + private $baz; + + public function __construct($foo, $bar, $baz) + { + $this->foo = $foo; + $this->bar = $bar; + $this->baz = $baz; + } + + public function getFoo() + { + return $this->foo; + } + + public function isBaz() + { + return $this->baz; + } + + public function otherMethod() + { + throw new \RuntimeException("Dummy::otherMethod() should not be called"); + } +} + +abstract class ObjectSerializerNormalizer implements SerializerInterface, NormalizerInterface +{ +} + +class ObjectConstructorOptionalArgsDummy +{ + protected $foo; + public $bar; + private $baz; + + public function __construct($foo, $bar = array(), $baz = array()) + { + $this->foo = $foo; + $this->bar = $bar; + $this->baz = $baz; + } + + public function getFoo() + { + return $this->foo; + } + + public function getBaz() + { + return $this->baz; + } + + public function otherMethod() + { + throw new \RuntimeException("Dummy::otherMethod() should not be called"); + } +} diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index cffa235e03..b88ea796d5 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -22,6 +22,7 @@ "symfony/phpunit-bridge": "~2.7|~3.0.0", "symfony/yaml": "~2.0|~3.0.0", "symfony/config": "~2.2|~3.0.0", + "symfony/property-access": "~2.3|~3.0.0", "doctrine/annotations": "~1.0", "doctrine/cache": "~1.0" }, @@ -29,7 +30,8 @@ "doctrine/annotations": "For using the annotation mapping. You will also need doctrine/cache.", "doctrine/cache": "For using the default cached annotation reader and metadata cache.", "symfony/yaml": "For using the default YAML mapping loader.", - "symfony/config": "For using the XML mapping loader." + "symfony/config": "For using the XML mapping loader.", + "symfony/property-access": "For using the ObjectNormalizer." }, "autoload": { "psr-0": { "Symfony\\Component\\Serializer\\": "" }