Skip to content

Serializer improvements #146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Bundle/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

<service id="dunglas_doctrine_json_odm.normalizer.datetime" class="Symfony\Component\Serializer\Normalizer\DateTimeNormalizer" public="false" />

<service id="dunglas_doctrine_json_odm.normalizer.traversable" class="Dunglas\DoctrineJsonOdm\Normalizer\TraversableNormalizer" public="false" />

<service id="dunglas_doctrine_json_odm.normalizer.array" class="Symfony\Component\Serializer\Normalizer\ArrayDenormalizer" public="false" />

<service id="dunglas_doctrine_json_odm.type_mapper" class="Dunglas\DoctrineJsonOdm\TypeMapper" public="false" />
Expand All @@ -29,6 +31,7 @@
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.backed_enum" on-invalid="ignore" />
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.uid" on-invalid="ignore" />
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.datetime" />
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.traversable" />
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.array" />
<argument type="service" id="dunglas_doctrine_json_odm.normalizer.object" />
</argument>
Expand Down
52 changes: 52 additions & 0 deletions src/Normalizer/TraversableNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Dunglas\DoctrineJsonOdm\Normalizer;

use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Traversable;

final class TraversableNormalizer implements NormalizerInterface, DenormalizerInterface, NormalizerAwareInterface, DenormalizerAwareInterface
{
use NormalizerAwareTrait;
use DenormalizerAwareTrait;

public function getSupportedTypes(?string $format): array
{
return [
Traversable::class => true,
];
}

public function normalize(mixed $object, ?string $format = null, array $context = []): array
{
$result = [];
foreach (iterator_to_array($object) as $key => $item) {
$result[$key] = $this->normalizer->normalize($item, $format, $context);
}
return $result;
}

public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Traversable;
}

public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): object
{
$result = [];
foreach ($data as $key => $item) {
$result[$key] = $this->denormalizer->denormalize($item, $type, $format, $context);
}
return new $type($result);
}

public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return is_array($data) && is_subclass_of($type, Traversable::class);
}
}
2 changes: 2 additions & 0 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class Serializer extends BaseSerializer
{
use SerializerTrait;

private const CONTEXT_SERIALIZER = '#serializer';
private const KEY_TYPE = '#type';
private const KEY_SCALAR = '#scalar';
}
Expand All @@ -27,6 +28,7 @@ final class Serializer extends BaseSerializer
{
use TypedSerializerTrait;

private const CONTEXT_SERIALIZER = '#serializer';
private const KEY_TYPE = '#type';
private const KEY_SCALAR = '#scalar';
}
Expand Down
54 changes: 39 additions & 15 deletions src/SerializerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,53 +41,77 @@ public function __construct(array $normalizers = [], array $encoders = [], ?Type
* @param mixed $data
* @param string|null $format
*
* @return array|\ArrayObject|bool|float|int|string|null
* @return array|\ArrayObject|scalar|null
*/
public function normalize($data, $format = null, array $context = [])
{
$normalizedData = parent::normalize($data, $format, $context);
if (\is_array($data)) {
$normData = [];
foreach ($data as $idx => $datum) {
$normData[$idx] = $this->normalize($datum, $format, $context);
}

return $normData;
}

if (\is_object($data)) {
$typeName = \get_class($data);

$normData = parent::normalize($data, $format, $context + [self::CONTEXT_SERIALIZER => $this]);

if ($this->typeMapper) {
$typeName = $this->typeMapper->getTypeByClass($typeName);
}

$typeData = [self::KEY_TYPE => $typeName];
$valueData = is_scalar($normalizedData) ? [self::KEY_SCALAR => $normalizedData] : $normalizedData;
$normalizedData = array_merge($typeData, $valueData);

if (\is_array($normData) && !isset($normData[self::KEY_TYPE])) {
$normData = $this->normalize($normData, $format, $context);
}
if (\is_scalar($normData)) {
$normData = [self::KEY_SCALAR => $normData];
}

return \array_merge($typeData, $normData);
}

return $normalizedData;
return $data;
}

/**
* @param $data
* @param null|scalar|array $data
* @param string $type
* @param string|null $format
*
* @return mixed
*/
public function denormalize($data, $type, $format = null, array $context = [])
{
if (\is_array($data) && (isset($data[self::KEY_TYPE]))) {
if (!\is_array($data)) {
return $data;
}

if (isset($data[self::KEY_TYPE])) {
$keyType = $data[self::KEY_TYPE];
unset($data[self::KEY_TYPE]);

if ($this->typeMapper) {
$keyType = $this->typeMapper->getClassByType($keyType);
}

unset($data[self::KEY_TYPE]);

$data = $data[self::KEY_SCALAR] ?? $data;
$data = $this->denormalize($data, $keyType, $format, $context);

return parent::denormalize($data, $keyType, $format, $context);
}
if (\is_array($data)) {
foreach ($data as $idx => $datum) {
$data[$idx] = $this->denormalize($datum, $keyType, $format, $context);
}
}

if (is_iterable($data)) {
$type = ('' === $type) ? 'stdClass' : $type;
return parent::denormalize($data, $keyType, $format, $context + [self::CONTEXT_SERIALIZER => $this]);
}

return parent::denormalize($data, $type.'[]', $format, $context);
foreach ($data as $idx => $datum) {
$data[$idx] = $this->denormalize($datum, '', $format, $context);
}

return $data;
Expand Down
52 changes: 52 additions & 0 deletions tests/Fixtures/Normalizer/VectorNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Dunglas\DoctrineJsonOdm\Tests\Fixtures\Normalizer;

use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\Vector;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
* Notice that normally you have to call (de-)normalize() for all nested objects graph (like array items in this case).
* But, this package ships its own serializer that will do this for you :)
* Vanilla normalizers that DOES call to normalize() for nested objects are fine too, they won't break.
*/
final class VectorNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function getSupportedTypes(?string $format): array
{
return [
Vector::class => true,
];
}

public function normalize(mixed $object, ?string $format = null, array $context = []): array
{
if (!$this->supportsNormalization($object)) {
throw new InvalidArgumentException(sprintf('The object must be an instance of "%s".', Vector::class));
}

return ['position' => $object->key(), '[]' => $object->getArray()];
}

public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Vector;
}

public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): object
{
if (!$this->supportsDenormalization($data, $type)) {
throw NotNormalizableValueException::createForUnexpectedDataType('Data expected to be a array of shape {"position": int, "[]": array}.', $data, ['array'], $context['deserialization_path'] ?? null);
}

return new Vector($data['[]'], $data['position']);
}

public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return is_array($data) && is_a($type, Vector::class, true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\DependencyInjection;

use Dunglas\DoctrineJsonOdm\Tests\Fixtures\Normalizer\VectorNormalizer;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
Expand All @@ -21,9 +22,16 @@ public function process(ContainerBuilder $container): void
{
$container->setDefinition('dunglas_doctrine_json_odm.normalizer.custom', new Definition(CustomNormalizer::class));

$vectorDefinition = new Definition(VectorNormalizer::class);
$vectorDefinition->addTag('serializer.normalizer');
$container->setDefinition(VectorNormalizer::class, $vectorDefinition);

$serializerDefinition = $container->getDefinition('dunglas_doctrine_json_odm.serializer');
$arguments = $serializerDefinition->getArguments();
$arguments[0] = array_merge([new Reference('dunglas_doctrine_json_odm.normalizer.custom')], $arguments[0]);
$arguments[0] = array_merge([
new Reference(VectorNormalizer::class),
new Reference('dunglas_doctrine_json_odm.normalizer.custom'),
], $arguments[0]);
$serializerDefinition->setArguments($arguments);
}
}
43 changes: 43 additions & 0 deletions tests/Fixtures/TestBundle/Document/TraversableValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document;

use ArrayAccess;
use ArrayIterator;
use IteratorAggregate;
use Traversable;

final class TraversableValue implements ArrayAccess, IteratorAggregate
{
private array $array;

public function __construct(array $array = [])
{
$this->array = $array;
}

public function offsetExists(mixed $offset): bool
{
return array_key_exists($offset, $this->array);
}

public function offsetGet(mixed $offset): mixed
{
return $this->array[$offset];
}

public function offsetSet(mixed $offset, mixed $value): void
{
$this->array[$offset] = $value;
}

public function offsetUnset(mixed $offset): void
{
unset($this->array[$offset]);
}

public function getIterator(): Traversable
{
return new ArrayIterator($this->array);
}
}
48 changes: 48 additions & 0 deletions tests/Fixtures/TestBundle/Document/Vector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document;

use Iterator;

class Vector implements Iterator
{
private int $position;

private array $array;

public function __construct(array $array = [], int $position = 0)
{
$this->array = $array;
$this->position = $position;
}

public function getArray(): array
{
return $this->array;
}

public function current(): mixed
{
return $this->array[$this->key()];
}

public function key(): mixed
{
return $this->position;
}

public function next(): void
{
++$this->position;
}

public function rewind(): void
{
$this->position = 0;
}

public function valid(): bool
{
return isset($this->array[$this->key()]);
}
}
37 changes: 37 additions & 0 deletions tests/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\Bar;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\Baz;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\ScalarValue;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\TraversableValue;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\Vector;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Document\WithMappedType;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Entity\Foo;
use Dunglas\DoctrineJsonOdm\Tests\Fixtures\TestBundle\Enum\InputMode;
Expand Down Expand Up @@ -241,4 +243,39 @@ public function testSerializeUid(): void

$this->assertEquals($value, $restoredValue);
}

/** Uses {@link VectorNormalizer} to normalize Vector, otherwise it will be treated as Traversable and fail */
public function testSerializeObjectWithConfiguredNormalizer(): void
{
$serializer = self::$kernel->getContainer()->get('dunglas_doctrine_json_odm.serializer');

$attribute = new Attribute();
$attribute->key = 'foo';
$attribute->value = 'bar';

$vector = new Vector([$attribute, 2, 3, 4, 5]);
$vector->next();

$data = $serializer->serialize($vector, 'json');
$restoredVector = $serializer->deserialize($data, '', 'json');

$this->assertEquals($vector, $restoredVector);
}

/** {@see \Dunglas\DoctrineJsonOdm\Normalizer\TraversableNormalizer} */
public function testTraversableNormalizer(): void
{
$serializer = self::$kernel->getContainer()->get('dunglas_doctrine_json_odm.serializer');

$attribute = new Attribute();
$attribute->key = 'foo';
$attribute->value = 'bar';

$vector = new TraversableValue([$attribute, 'x' => 2, 'y'=>[3], 'z'=>'4', 5, ['' => null]]);

$data = $serializer->serialize($vector, 'json');
$restoredVector = $serializer->deserialize($data, '', 'json');

$this->assertEquals($vector, $restoredVector);
}
}