feature #14756 [Serializer] Support for array denormalization (derrabus)

This PR was merged into the 2.8 branch.

Discussion
----------

[Serializer] Support for array denormalization

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | none
| License       | MIT
| Doc PR        | none (yet)

This is a rebase of #14343 against the 2.8 branch.

I had to implement a couple of API endpoints that receive data as JSON-serialized arrays of objects. The current implementation of the Serializer component is capable of serializing such arrays, but so far this operation is a one-way-road. This PR is an attempt to change this.

## Demo
```php
class Dummy
{
    public $foo;
    public $bar;

    public function __construct($foo = null, $bar = null)
    {
        $this->foo = $foo;
        $this->bar = $bar;
    }
}

$serializer = new \Symfony\Component\Serializer\Serializer(
    array(
        new \Symfony\Component\Serializer\Normalizer\PropertyNormalizer(),
        new \Symfony\Component\Serializer\Normalizer\ArrayDenormalizer()
    ),
    array(
        new \Symfony\Component\Serializer\Encoder\JsonEncoder()
    )
);

$json = $serializer->serialize(
    array(
        new Dummy('one', 'two'),
        new Dummy('three', 'four')
    ),
    'json'
);

echo $json . "\n\n";

// Deserialize the JSON string, so we get back to where we started from.
$data = $serializer->deserialize($json, 'Dummy[]', 'json');

var_dump($data);
```

By appending `[]` to the type parameter, you indicate that you expect to deserialize an array of objects of the given type. This is the same notation that phpDocumentor uses to indicate collections. The denormalization of the array is implemented recursively: The denormalizer simply calls `Serializer::denormalize()` on each element of the array. This way, the ArrayDenormalizer can be combined with any other denormalizer.

## Side effects

For this implementation, I had to touch `GetSetMethodNormalizer`, `PropertyNormalizer` and `CustomNormalizer`. Those classes expected `supportsDenormalization` to be called with a valid class in the `$type` parameter. Instead of throwing a reflection exception, they now simply return false. I'm not exactly sure, if this is should be considered to be a BC break.

This implementation violates the `SerializerInterface` which declared `deserialize()` to always return an object. But imho, the assumption that serialized data always represents an object is too restrictive anyway. Also, this declaration is not consistent with the `serialize()` method which accepts `mixed` as input type for the data to serialize.

## Other PRs

I've found an older PR adressing this issue, #12066. That PR was closed because the contributor did not reply to feedback.

Commits
-------

0573f28d Support for array denormalization.
This commit is contained in:
Kévin Dunglas 2015-05-27 13:35:50 +02:00
commit 2983ecbf3d
7 changed files with 268 additions and 2 deletions

View File

@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Serializer\Exception;
class BadMethodCallException extends \BadMethodCallException implements ExceptionInterface
{
}

View File

@ -0,0 +1,77 @@
<?php
namespace Symfony\Component\Serializer\Normalizer;
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Denormalizes arrays of objects.
*
* @author Alexander M. Turek <me@derrabus.de>
*/
class ArrayDenormalizer implements DenormalizerInterface, SerializerAwareInterface
{
/**
* @var SerializerInterface|DenormalizerInterface
*/
private $serializer;
/**
* {@inheritdoc}
*/
public function denormalize($data, $class, $format = null, array $context = array())
{
if ($this->serializer === null) {
throw new BadMethodCallException('Please set a serializer before calling denormalize()!');
}
if (!is_array($data)) {
throw new InvalidArgumentException('Data expected to be an array, '.gettype($data).' given.');
}
if (substr($class, -2) !== '[]') {
throw new InvalidArgumentException('Unsupported class: '.$class);
}
$serializer = $this->serializer;
$class = substr($class, 0, -2);
return array_map(
function ($data) use ($serializer, $class, $format, $context) {
return $serializer->denormalize($data, $class, $format, $context);
},
$data
);
}
/**
* {@inheritdoc}
*/
public function supportsDenormalization($data, $type, $format = null)
{
return substr($type, -2) === '[]'
&& $this->serializer->supportsDenormalization($data, substr($type, 0, -2), $format);
}
/**
* {@inheritdoc}
*/
public function setSerializer(SerializerInterface $serializer)
{
if (!$serializer instanceof DenormalizerInterface) {
throw new InvalidArgumentException('Expected a serializer that also implements DenormalizerInterface.');
}
$this->serializer = $serializer;
}
}

View File

@ -59,6 +59,10 @@ class CustomNormalizer extends SerializerAwareNormalizer implements NormalizerIn
*/
public function supportsDenormalization($data, $type, $format = null)
{
if (!class_exists($type)) {
return false;
}
$class = new \ReflectionClass($type);
return $class->isSubclassOf('Symfony\Component\Serializer\Normalizer\DenormalizableInterface');

View File

@ -135,7 +135,7 @@ class GetSetMethodNormalizer extends AbstractNormalizer
*/
public function supportsDenormalization($data, $type, $format = null)
{
return $this->supports($type);
return class_exists($type) && $this->supports($type);
}
/**

View File

@ -135,7 +135,7 @@ class PropertyNormalizer extends AbstractNormalizer
*/
public function supportsDenormalization($data, $type, $format = null)
{
return $this->supports($type);
return class_exists($type) && $this->supports($type);
}
/**

View File

@ -0,0 +1,121 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\ArrayDenormalizer;
use Symfony\Component\Serializer\SerializerInterface;
class ArrayDenormalizerTest extends \PHPUnit_Framework_TestCase
{
/**
* @var ArrayDenormalizer
*/
private $denormalizer;
/**
* @var SerializerInterface|\PHPUnit_Framework_MockObject_MockObject
*/
private $serializer;
protected function setUp()
{
$this->serializer = $this->getMock('Symfony\Component\Serializer\Serializer');
$this->denormalizer = new ArrayDenormalizer();
$this->denormalizer->setSerializer($this->serializer);
}
public function testDenormalize()
{
$this->serializer->expects($this->at(0))
->method('denormalize')
->with(array('foo' => 'one', 'bar' => 'two'))
->will($this->returnValue(new ArrayDummy('one', 'two')));
$this->serializer->expects($this->at(1))
->method('denormalize')
->with(array('foo' => 'three', 'bar' => 'four'))
->will($this->returnValue(new ArrayDummy('three', 'four')));
$result = $this->denormalizer->denormalize(
array(
array('foo' => 'one', 'bar' => 'two'),
array('foo' => 'three', 'bar' => 'four'),
),
__NAMESPACE__.'\ArrayDummy[]'
);
$this->assertEquals(
array(
new ArrayDummy('one', 'two'),
new ArrayDummy('three', 'four'),
),
$result
);
}
public function testSupportsValidArray()
{
$this->serializer->expects($this->once())
->method('supportsDenormalization')
->with($this->anything(), __NAMESPACE__.'\ArrayDummy', $this->anything())
->will($this->returnValue(true));
$this->assertTrue(
$this->denormalizer->supportsDenormalization(
array(
array('foo' => 'one', 'bar' => 'two'),
array('foo' => 'three', 'bar' => 'four'),
),
__NAMESPACE__.'\ArrayDummy[]'
)
);
}
public function testSupportsInvalidArray()
{
$this->serializer->expects($this->any())
->method('supportsDenormalization')
->will($this->returnValue(false));
$this->assertFalse(
$this->denormalizer->supportsDenormalization(
array(
array('foo' => 'one', 'bar' => 'two'),
array('foo' => 'three', 'bar' => 'four'),
),
__NAMESPACE__.'\InvalidClass[]'
)
);
}
public function testSupportsNoArray()
{
$this->assertFalse(
$this->denormalizer->supportsDenormalization(
array('foo' => 'one', 'bar' => 'two'),
__NAMESPACE__.'\ArrayDummy'
)
);
}
}
class ArrayDummy
{
public $foo;
public $bar;
public function __construct($foo, $bar)
{
$this->foo = $foo;
$this->bar = $bar;
}
}

View File

@ -11,6 +11,9 @@
namespace Symfony\Component\Serializer\Tests;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\PropertyNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer;
@ -220,6 +223,51 @@ class SerializerTest extends \PHPUnit_Framework_TestCase
$result = $this->serializer->decode(json_encode($data), 'json');
$this->assertEquals($data, $result);
}
public function testSupportsArrayDeserialization()
{
$serializer = new Serializer(
array(
new GetSetMethodNormalizer(),
new PropertyNormalizer(),
new ObjectNormalizer(),
new CustomNormalizer(),
new ArrayDenormalizer(),
),
array(
'json' => new JsonEncoder(),
)
);
$this->assertTrue(
$serializer->supportsDenormalization(array(), __NAMESPACE__.'\Model[]', 'json')
);
}
public function testDeserializeArray()
{
$jsonData = '[{"title":"foo","numbers":[5,3]},{"title":"bar","numbers":[2,8]}]';
$expectedData = array(
Model::fromArray(array('title' => 'foo', 'numbers' => array(5, 3))),
Model::fromArray(array('title' => 'bar', 'numbers' => array(2, 8))),
);
$serializer = new Serializer(
array(
new GetSetMethodNormalizer(),
new ArrayDenormalizer(),
),
array(
'json' => new JsonEncoder(),
)
);
$this->assertEquals(
$expectedData,
$serializer->deserialize($jsonData, __NAMESPACE__.'\Model[]', 'json')
);
}
}
class Model