diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 4fcae7d99cfcc..5c88e4455e09c 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -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; @@ -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( @@ -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']); diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Php80WithPromotedTypedConstructor.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Php80WithPromotedTypedConstructor.php index be3247450ba79..a7b79aa47dcae 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/Php80WithPromotedTypedConstructor.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Php80WithPromotedTypedConstructor.php @@ -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, + ) { } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/WithTypedConstructor.php b/src/Symfony/Component/Serializer/Tests/Fixtures/WithTypedConstructor.php new file mode 100644 index 0000000000000..734d1c83b24d9 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/WithTypedConstructor.php @@ -0,0 +1,35 @@ + + * + * 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; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php index 306c571f9c59d..f7e18241c7210 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php @@ -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()); } } diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index 2141c0cf6d334..fdd98d0be5b5a 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -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; @@ -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);