Skip to content

Commit

Permalink
bug #51907 [Serializer] Fix collecting only first missing constructor…
Browse files Browse the repository at this point in the history
… argument (HypeMC)

This PR was squashed before being merged into the 5.4 branch.

Discussion
----------

[Serializer] Fix collecting only first missing constructor argument

| Q             | A
| ------------- | ---
| Branch?       | 5.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | -
| License       | MIT

Alternative to #51866, sort of followup to #49832

Currently on 5.4 only the first exception is added to the `not_normalizable_value_exceptions` array when `COLLECT_DENORMALIZATION_ERRORS` is `true` or only the first argument is mentioned in the `MissingConstructorArgumentsException` when it is `false`.
On 6.3 however, the part with the `MissingConstructorArgumentsException` was fix with #49832, but the part with the `not_normalizable_value_exceptions` was overlooked.
IMO this is inconsistent behavior as the two cases are actually the same thing with the only difference being that in one case an exception is thrown while in the other the errors are collected.

I'm not sure if #51866 really qualifies as a bug or is actually more a feature, but the reason #49832 was merged onto 6.3 was because of the changes originally done in #49013, which itself was a feature.

If #51866 does qualify as a bug then it would make sense to backport #49832 to 5.4 for consistency, which is what this PR does.

The PR contains two commits:
1) backport of #49832
2) alternative to #51866

If #51866 does not qualify as a bug, the first commit can be drooped and the second one can be rebased with 6.4.

PS I think it's easier to review the changes commit by commit.

Commits
-------

0f398ce [Serializer] Fix collecting only first missing constructor argument
  • Loading branch information
nicolas-grekas committed Oct 17, 2023
2 parents d28330f + 0f398ce commit bccdccc
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 19 deletions.
31 changes: 17 additions & 14 deletions src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex
}

$constructorParameters = $constructor->getParameters();

$missingConstructorArguments = [];
$params = [];
foreach ($constructorParameters as $constructorParameter) {
$paramName = $constructorParameter->name;
Expand Down Expand Up @@ -401,7 +401,8 @@ protected function instantiateObject(array &$data, string $class, array &$contex
$params[] = null;
} else {
if (!isset($context['not_normalizable_value_exceptions'])) {
throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name), 0, null, [$constructorParameter->name]);
$missingConstructorArguments[] = $constructorParameter->name;
continue;
}

$exception = NotNormalizableValueException::createForUnexpectedDataType(
Expand All @@ -412,24 +413,26 @@ protected function instantiateObject(array &$data, string $class, array &$contex
true
);
$context['not_normalizable_value_exceptions'][] = $exception;

return $reflectionClass->newInstanceWithoutConstructor();
}
}

if ($constructor->isConstructor()) {
try {
return $reflectionClass->newInstanceArgs($params);
} catch (\TypeError $th) {
if (!isset($context['not_normalizable_value_exceptions'])) {
throw $th;
}
if ($missingConstructorArguments) {
throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$%s".', $class, implode('", "$', $missingConstructorArguments)), 0, null, $missingConstructorArguments);
}

return $reflectionClass->newInstanceWithoutConstructor();
}
} else {
if (!$constructor->isConstructor()) {
return $constructor->invokeArgs(null, $params);
}

try {
return $reflectionClass->newInstanceArgs($params);
} catch (\TypeError $e) {
if (!isset($context['not_normalizable_value_exceptions'])) {
throw $e;
}

return $reflectionClass->newInstanceWithoutConstructor();
}
}

unset($context['has_constructor']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@

final class Php80WithPromotedTypedConstructor
{
public function __construct(public bool $bool)
{
public function __construct(
public bool $bool,
public string $string,
public int $int,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\Fixtures;

final class WithTypedConstructor
{
/**
* @var string
*/
public $string;
/**
* @var bool
*/
public $bool;
/**
* @var int
*/
public $int;

public function __construct(string $string, bool $bool, int $int)
{
$this->string = $string;
$this->bool = $bool;
$this->int = $int;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,30 @@ public function testConstructorWithMissingData()
];

$normalizer = $this->getDenormalizerForConstructArguments();
try {
$normalizer->denormalize($data, ConstructorArgumentsObject::class);
self::fail(sprintf('Failed asserting that exception of type "%s" is thrown.', MissingConstructorArgumentsException::class));
} catch (MissingConstructorArgumentsException $e) {
self::assertSame(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$bar", "$baz".', ConstructorArgumentsObject::class), $e->getMessage());
self::assertSame(['bar', 'baz'], $e->getMissingConstructorArguments());
}
}

public function testExceptionsAreCollectedForConstructorWithMissingData()
{
$data = [
'foo' => 10,
];

$exceptions = [];

$normalizer = $this->getDenormalizerForConstructArguments();
$normalizer->denormalize($data, ConstructorArgumentsObject::class, null, [
'not_normalizable_value_exceptions' => &$exceptions,
]);

$this->expectException(MissingConstructorArgumentsException::class);
$this->expectExceptionMessage('Cannot create an instance of "'.ConstructorArgumentsObject::class.'" from serialized data because its constructor requires parameter "bar" to be present.');
$normalizer->denormalize($data, ConstructorArgumentsObject::class);
self::assertCount(2, $exceptions);
self::assertSame('Failed to create object because the class misses the "bar" property.', $exceptions[0]->getMessage());
self::assertSame('Failed to create object because the class misses the "baz" property.', $exceptions[1]->getMessage());
}
}
80 changes: 80 additions & 0 deletions src/Symfony/Component/Serializer/Tests/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
use Symfony\Component\Serializer\Tests\Fixtures\Php74Full;
use Symfony\Component\Serializer\Tests\Fixtures\Php80WithPromotedTypedConstructor;
use Symfony\Component\Serializer\Tests\Fixtures\TraversableDummy;
use Symfony\Component\Serializer\Tests\Fixtures\WithTypedConstructor;
use Symfony\Component\Serializer\Tests\Normalizer\TestDenormalizer;
use Symfony\Component\Serializer\Tests\Normalizer\TestNormalizer;

Expand Down Expand Up @@ -1196,6 +1197,85 @@ public function testCollectDenormalizationErrorsWithConstructor(?ClassMetadataFa
'useMessageForUser' => false,
'message' => 'The type of the "bool" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php80WithPromotedTypedConstructor" must be one of "bool" ("string" given).',
],
[
'currentType' => 'array',
'expectedTypes' => [
'unknown',
],
'path' => null,
'useMessageForUser' => true,
'message' => 'Failed to create object because the class misses the "string" property.',
],
[
'currentType' => 'array',
'expectedTypes' => [
'unknown',
],
'path' => null,
'useMessageForUser' => true,
'message' => 'Failed to create object because the class misses the "int" property.',
],
];

$this->assertSame($expected, $exceptionsAsArray);
}

public function testCollectDenormalizationErrorsWithInvalidConstructorTypes()
{
$json = '{"string": "some string", "bool": "bool", "int": true}';

$extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]);

$serializer = new Serializer(
[new ObjectNormalizer(null, null, null, $extractor)],
['json' => new JsonEncoder()]
);

try {
$serializer->deserialize($json, WithTypedConstructor::class, 'json', [
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
]);

$this->fail();
} catch (\Throwable $th) {
$this->assertInstanceOf(PartialDenormalizationException::class, $th);
}

$this->assertInstanceOf(WithTypedConstructor::class, $object = $th->getData());

$this->assertSame('some string', $object->string);
$this->assertTrue($object->bool);
$this->assertSame(1, $object->int);

$exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array {
return [
'currentType' => $e->getCurrentType(),
'expectedTypes' => $e->getExpectedTypes(),
'path' => $e->getPath(),
'useMessageForUser' => $e->canUseMessageForUser(),
'message' => $e->getMessage(),
];
}, $th->getErrors());

$expected = [
[
'currentType' => 'string',
'expectedTypes' => [
0 => 'bool',
],
'path' => 'bool',
'useMessageForUser' => false,
'message' => 'The type of the "bool" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\WithTypedConstructor" must be one of "bool" ("string" given).',
],
[
'currentType' => 'bool',
'expectedTypes' => [
0 => 'int',
],
'path' => 'int',
'useMessageForUser' => false,
'message' => 'The type of the "int" attribute for class "Symfony\Component\Serializer\Tests\Fixtures\WithTypedConstructor" must be one of "int" ("bool" given).',
],
];

$this->assertSame($expected, $exceptionsAsArray);
Expand Down

0 comments on commit bccdccc

Please sign in to comment.