From 78ceed193f4b2b2283c7283f8a8871e0bb28333f Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Thu, 5 Dec 2013 18:15:45 +0100 Subject: [PATCH] [Serializer] Added PropertyNormalizer, a new normalizer that maps an object's properties to an array --- src/Symfony/Component/Serializer/CHANGELOG.md | 6 + .../Normalizer/PropertyNormalizer.php | 217 +++++++++++++++ .../Normalizer/PropertyNormalizerTest.php | 247 ++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php create mode 100644 src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 5b859623fef0..49ca9065de1d 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +2.5.0 +----- + + * added a new serializer: `PropertyNormalizer`. Like `GetSetMethodNormalizer`, + this normalizer will map an object's properties to an array. + 2.4.0 ----- diff --git a/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php new file mode 100644 index 000000000000..9eefff3ca81b --- /dev/null +++ b/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php @@ -0,0 +1,217 @@ + + * + * 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\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\RuntimeException; + +/** + * Converts between objects and arrays by mapping properties. + * + * The normalization process looks for all the object's properties (public and private). + * The result is a map from property names to property values. Property values + * are normalized through the serializer. + * + * The denormalization first looks at the constructor of the given class to see + * if any of the parameters have the same name as one of the properties. The + * constructor is then called with all parameters or an exception is thrown if + * any required parameters were not present as properties. Then the denormalizer + * walks through the given map of property names to property values to see if a + * property with the corresponding name exists. If found, the property gets the value. + * + * @author Matthieu Napoli + */ +class PropertyNormalizer extends SerializerAwareNormalizer implements NormalizerInterface, DenormalizerInterface +{ + private $callbacks = array(); + private $ignoredAttributes = array(); + private $camelizedAttributes = array(); + + /** + * Set normalization callbacks + * + * @param array $callbacks help normalize the result + * + * @throws InvalidArgumentException if a non-callable callback is set + */ + public function setCallbacks(array $callbacks) + { + foreach ($callbacks as $attribute => $callback) { + if (!is_callable($callback)) { + throw new InvalidArgumentException(sprintf( + 'The given callback for attribute "%s" is not callable.', + $attribute + )); + } + } + $this->callbacks = $callbacks; + } + + /** + * Set ignored attributes for normalization + * + * @param array $ignoredAttributes + */ + public function setIgnoredAttributes(array $ignoredAttributes) + { + $this->ignoredAttributes = $ignoredAttributes; + } + + /** + * Set attributes to be camelized on denormalize + * + * @param array $camelizedAttributes + */ + public function setCamelizedAttributes(array $camelizedAttributes) + { + $this->camelizedAttributes = $camelizedAttributes; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = array()) + { + $reflectionObject = new \ReflectionObject($object); + $attributes = array(); + + foreach ($reflectionObject->getProperties() as $property) { + if (in_array($property->name, $this->ignoredAttributes)) { + continue; + } + + // Override visibility + if (! $property->isPublic()) { + $property->setAccessible(true); + } + + $attributeValue = $property->getValue($object); + + if (array_key_exists($property->name, $this->callbacks)) { + $attributeValue = call_user_func($this->callbacks[$property->name], $attributeValue); + } + if (null !== $attributeValue && !is_scalar($attributeValue)) { + $attributeValue = $this->serializer->normalize($attributeValue, $format); + } + + $attributes[$property->name] = $attributeValue; + } + + return $attributes; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = array()) + { + $reflectionClass = new \ReflectionClass($class); + $constructor = $reflectionClass->getConstructor(); + + if ($constructor) { + $constructorParameters = $constructor->getParameters(); + + $params = array(); + foreach ($constructorParameters as $constructorParameter) { + $paramName = lcfirst($this->formatAttribute($constructorParameter->name)); + + if (isset($data[$paramName])) { + $params[] = $data[$paramName]; + // don't run set for a parameter passed to the constructor + unset($data[$paramName]); + } elseif (!$constructorParameter->isOptional()) { + throw new RuntimeException(sprintf( + 'Cannot create an instance of %s from serialized data because ' . + 'its constructor requires parameter "%s" to be present.', + $class, + $constructorParameter->name + )); + } + } + + $object = $reflectionClass->newInstanceArgs($params); + } else { + $object = new $class; + } + + foreach ($data as $propertyName => $value) { + $propertyName = lcfirst($this->formatAttribute($propertyName)); + + if ($reflectionClass->hasProperty($propertyName)) { + $property = $reflectionClass->getProperty($propertyName); + + // Override visibility + if (! $property->isPublic()) { + $property->setAccessible(true); + } + + $property->setValue($object, $value); + } + } + + return $object; + } + + /** + * {@inheritDoc} + */ + public function supportsNormalization($data, $format = null) + { + return is_object($data) && $this->supports(get_class($data)); + } + + /** + * {@inheritDoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return $this->supports($type); + } + + /** + * Format an attribute name, for example to convert a snake_case name to camelCase. + * + * @param string $attributeName + * @return string + */ + protected function formatAttribute($attributeName) + { + if (in_array($attributeName, $this->camelizedAttributes)) { + return preg_replace_callback('/(^|_|\.)+(.)/', function ($match) { + return ('.' === $match[1] ? '_' : '').strtoupper($match[2]); + }, $attributeName); + } + + return $attributeName; + } + + /** + * Checks if the given class has any non-static property. + * + * @param string $class + * + * @return Boolean + */ + private function supports($class) + { + $class = new \ReflectionClass($class); + + // We look for at least one non-static property + foreach ($class->getProperties() as $property) { + if (! $property->isStatic()) { + return true; + } + } + + return false; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php new file mode 100644 index 000000000000..b398b75efb0a --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php @@ -0,0 +1,247 @@ + + * + * 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 Symfony\Component\Serializer\Normalizer\PropertyNormalizer; + +class PropertyNormalizerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var PropertyNormalizer + */ + private $normalizer; + + protected function setUp() + { + $this->normalizer = new PropertyNormalizer(); + $this->normalizer->setSerializer($this->getMock('Symfony\Component\Serializer\Serializer')); + } + + public function testNormalize() + { + $obj = new PropertyDummy(); + $obj->foo = 'foo'; + $obj->setBar('bar'); + $obj->setCamelCase('camelcase'); + $this->assertEquals( + array('foo' => 'foo', 'bar' => 'bar', 'camelCase' => 'camelcase'), + $this->normalizer->normalize($obj, 'any') + ); + } + + public function testDenormalize() + { + $obj = $this->normalizer->denormalize( + array('foo' => 'foo', 'bar' => 'bar'), + __NAMESPACE__.'\PropertyDummy', + 'any' + ); + $this->assertEquals('foo', $obj->foo); + $this->assertEquals('bar', $obj->getBar()); + } + + public function testDenormalizeOnCamelCaseFormat() + { + $this->normalizer->setCamelizedAttributes(array('camel_case')); + $obj = $this->normalizer->denormalize( + array('camel_case' => 'value'), + __NAMESPACE__.'\PropertyDummy' + ); + $this->assertEquals('value', $obj->getCamelCase()); + } + + /** + * @dataProvider attributeProvider + */ + public function testFormatAttribute($attribute, $camelizedAttributes, $result) + { + $r = new \ReflectionObject($this->normalizer); + $m = $r->getMethod('formatAttribute'); + $m->setAccessible(true); + + $this->normalizer->setCamelizedAttributes($camelizedAttributes); + $this->assertEquals($m->invoke($this->normalizer, $attribute, $camelizedAttributes), $result); + } + + public function attributeProvider() + { + return array( + array('attribute_test', array('attribute_test'),'AttributeTest'), + array('attribute_test', array('any'),'attribute_test'), + array('attribute', array('attribute'),'Attribute'), + array('attribute', array(), 'attribute'), + ); + } + + public function testConstructorDenormalize() + { + $obj = $this->normalizer->denormalize( + array('foo' => 'foo', 'bar' => 'bar'), + __NAMESPACE__.'\PropertyConstructorDummy', + 'any' + ); + $this->assertEquals('foo', $obj->getFoo()); + $this->assertEquals('bar', $obj->getBar()); + } + + /** + * @dataProvider provideCallbacks + */ + public function testCallbacks($callbacks, $value, $result, $message) + { + $this->normalizer->setCallbacks($callbacks); + + $obj = new PropertyConstructorDummy('', $value); + + $this->assertEquals( + $result, + $this->normalizer->normalize($obj, 'any'), + $message + ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testUncallableCallbacks() + { + $this->normalizer->setCallbacks(array('bar' => null)); + + $obj = new PropertyConstructorDummy('baz', 'quux'); + + $this->normalizer->normalize($obj, 'any'); + } + + public function testIgnoredAttributes() + { + $this->normalizer->setIgnoredAttributes(array('foo', 'bar', 'camelCase')); + + $obj = new PropertyDummy(); + $obj->foo = 'foo'; + $obj->setBar('bar'); + + $this->assertEquals( + array(), + $this->normalizer->normalize($obj, 'any') + ); + } + + public function provideCallbacks() + { + return array( + array( + array( + 'bar' => function ($bar) { + return 'baz'; + }, + ), + 'baz', + array('foo' => '', 'bar' => 'baz'), + 'Change a string', + ), + array( + array( + 'bar' => function ($bar) { + return null; + }, + ), + 'baz', + array('foo' => '', 'bar' => null), + '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'), + 'Format a date', + ), + array( + array( + 'bar' => function ($bars) { + $foos = ''; + foreach ($bars as $bar) { + $foos .= $bar->getFoo(); + } + + return $foos; + }, + ), + array(new PropertyConstructorDummy('baz', ''), new PropertyConstructorDummy('quux', '')), + array('foo' => '', 'bar' => 'bazquux'), + 'Collect a property', + ), + array( + array( + 'bar' => function ($bars) { + return count($bars); + }, + ), + array(new PropertyConstructorDummy('baz', ''), new PropertyConstructorDummy('quux', '')), + array('foo' => '', 'bar' => 2), + 'Count a property', + ), + ); + } +} + +class PropertyDummy +{ + public $foo; + private $bar; + protected $camelCase; + + public function getBar() + { + return $this->bar; + } + + public function setBar($bar) + { + $this->bar = $bar; + } + + public function getCamelCase() + { + return $this->camelCase; + } + + public function setCamelCase($camelCase) + { + $this->camelCase = $camelCase; + } +} + +class PropertyConstructorDummy +{ + protected $foo; + private $bar; + + public function __construct($foo, $bar) + { + $this->foo = $foo; + $this->bar = $bar; + } + + public function getFoo() + { + return $this->foo; + } + + public function getBar() + { + return $this->bar; + } +}