8000 [Serializer] Fix denormalizing union types by T-bond · Pull Request #45838 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Serializer] Fix denormalizing union types #45838

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

Merged
merged 1 commit into from
Mar 24, 2022
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ private function validateAndDenormalize(string $currentClass, string $attribute,
}

$expectedTypes = [];
$isUnionType = \count($types) > 1;
foreach ($types as $type) {
if (null === $data && $type->isNullable()) {
return null;
Expand Down Expand Up @@ -455,29 +456,40 @@ private function validateAndDenormalize(string $currentClass, string $attribute,

$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;

if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
if (!$this->serializer instanceof DenormalizerInterface) {
throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class));
// This try-catch should cover all NotNormalizableValueException (and all return branches after the first
// exception) so we could try denormalizing all types of an union type. If the target type is not an union
// type, we will just re-throw the catched exception.
// In the case of no denormalization succeeds with an union type, it will fall back to the default exception
// with the acceptable types list.
try {
if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
if (!$this->serializer instanceof DenormalizerInterface) {
throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class));
}

$childContext = $this->createChildContext($context, $attribute, $format);
if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) {
return $this->serializer->denormalize($data, $class, $format, $childContext);
}
}

$childContext = $this->createChildContext($context, $attribute, $format);
if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) {
return $this->serializer->denormalize($data, $class, $format, $childContext);
// JSON only has a Number type corresponding to both int and float PHP types.
// PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert
// floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible).
// PHP's json_decode automatically converts Numbers without a decimal part to integers.
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
// a float is expected.
if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) {
return (float) $data;
}
}

// JSON only has a Number type corresponding to both int and float PHP types.
// PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert
// floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible).
// PHP's json_decode automatically converts Numbers without a decimal part to integers.
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
// a float is expected.
if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) {
return (float) $data;
}

if (('is_'.$builtinType)($data)) {
return $data;
if (('is_'.$builtinType)($data)) {
return $data;
}
} catch (NotNormalizableValueException $e) {
if (!$isUnionType) {
throw $e;
}
}
}

Expand Down Expand Up @@ -639,7 +651,7 @@ private function getCacheKey(?string $format, array $context)
'ignored' => $this->ignoredAttributes,
8000 'camelized' => $this->camelizedAttributes,
]));
} catch (\Exception $exception) {
} catch (\Exception $e) {
// The context cannot be serialized, skip the cache
return false;
}
Expand Down
54 changes: 54 additions & 0 deletions src/Symfony/Component/Serializer/Tests/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
Expand All @@ -33,6 +34,7 @@
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\CustomNormalizer;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer;
Expand Down Expand Up @@ -538,6 +540,38 @@ public function testNormalizePreserveEmptyArrayObject()
$this->assertEquals('{"foo":{},"bar":["notempty"],"baz":{"nested":{}}}', $serializer->serialize($object, 'json', [AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true]));
}

public function testUnionTypeDeserializable()
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
$serializer = new Serializer(
[
new DateTimeNormalizer(),
new ObjectNormalizer($classMetadataFactory, null, null, $extractor, new ClassDiscriminatorFromClassMetadata($classMetadataFactory)),
],
['json' => new JsonEncoder()]
);

$actual = $serializer->deserialize('{ "changed": null }', DummyUnionType::class, 'json', [
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
]);

$this->assertEquals((new DummyUnionType())->setChanged(null), $actual, 'Union type denormalization first case failed.');

$actual = $serializer->deserialize('{ "changed": "2022-03-22T16:15:05+0000" }', DummyUnionType::class, 'json', [
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
]);

$expectedDateTime = \DateTime::createFromFormat(\DateTime::ISO8601, '2022-03-22T16:15:05+0000');
$this->assertEquals((new DummyUnionType())->setChanged($expectedDateTime), $actual, 'Union type denormalization second case failed.');

$actual = $serializer->deserialize('{ "changed": false }', DummyUnionType::class, 'json', [
DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601,
]);

$this->assertEquals(new DummyUnionType(), $actual, 'Union type denormalization third case failed.');
}

private function serializerWithClassDiscriminator()
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
Expand Down Expand Up @@ -610,6 +644,26 @@ public function __construct($value)
}
}

class DummyUnionType
{
/**
* @var \DateTime|bool|null
*/
public $changed = false;

/**
* @param \DateTime|bool|null
*
* @return $this
*/
public function setChanged($changed): self
{
$this->changed = $changed;

return $this;
}
}

interface NormalizerAwareNormalizer extends NormalizerInterface, NormalizerAwareInterface
{
}
Expand Down
0