diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 1f19bd9d865..727f41d1507 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -88,14 +88,19 @@ protected function extractAttributes(object $object, ?string $format = null, arr $name = $reflMethod->name; $attributeName = null; - if (str_starts_with($name, 'get') || str_starts_with($name, 'has') || str_starts_with($name, 'can')) { + if (3 < \strlen($name) && match ($name[0]) { + 'g' => str_starts_with($name, 'get'), + 'h' => str_starts_with($name, 'has'), + 'c' => str_starts_with($name, 'can'), + default => false, + }) { // getters, hassers and canners $attributeName = substr($name, 3); if (!$reflClass->hasProperty($attributeName)) { $attributeName = lcfirst($attributeName); } - } elseif (str_starts_with($name, 'is')) { + } elseif ('is' !== $name && str_starts_with($name, 'is')) { // issers $attributeName = substr($name, 2); diff --git a/Normalizer/UidNormalizer.php b/Normalizer/UidNormalizer.php index a6cc190a97a..843d7a5509a 100644 --- a/Normalizer/UidNormalizer.php +++ b/Normalizer/UidNormalizer.php @@ -22,7 +22,7 @@ final class UidNormalizer implements NormalizerInterface, DenormalizerInterface public const NORMALIZATION_FORMAT_CANONICAL = 'canonical'; public const NORMALIZATION_FORMAT_BASE58 = 'base58'; public const NORMALIZATION_FORMAT_BASE32 = 'base32'; - public const NORMALIZATION_FORMAT_RFC4122 = 'rfc4122'; + public const NORMALIZATION_FORMAT_RFC4122 = 'rfc4122'; // RFC 9562 obsoleted RFC 4122 but the format is the same public const NORMALIZATION_FORMATS = [ self::NORMALIZATION_FORMAT_CANONICAL, self::NORMALIZATION_FORMAT_BASE58, diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index a666185dd6a..b4f5c103ca7 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -13,7 +13,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyDocBlockExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Context; @@ -37,6 +39,7 @@ use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\CustomNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -1189,7 +1192,7 @@ public function testDenormalizeBooleanTypesWithNotMatchingData(array $data, stri $normalizer->denormalize($data, $type); } - public function provideBooleanTypesData() + public static function provideBooleanTypesData() { return [ [['foo' => true], FalsePropertyDummy::class], @@ -1209,7 +1212,7 @@ public function testDenormalizeBooleanTypeWithFilterBool(array $data, ?bool $exp $this->assertSame($expectedFoo, $dummy->foo); } - public function provideDenormalizeWithFilterBoolData(): array + public static function provideDenormalizeWithFilterBoolData(): array { return [ [['foo' => 'true'], true], @@ -1247,6 +1250,56 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string $this->assertInstanceOf(\ArrayObject::class, $actual->foo); $this->assertSame(1, $actual->foo->count()); } + + public function testTemplateTypeWhenAnObjectIsPassedToDenormalize() + { + $normalizer = new class ( + classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), + propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new PhpStanExtractor(), new ReflectionExtractor()]) + ) extends AbstractObjectNormalizerDummy { + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return true; + } + }; + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + + $denormalizedData = $normalizer->denormalize(['value' => new DummyGenericsValue()], DummyGenericsValueWrapper::class); + + $this->assertInstanceOf(DummyGenericsValueWrapper::class, $denormalizedData); + $this->assertInstanceOf(DummyGenericsValue::class, $denormalizedData->value); + + $this->assertSame('dummy', $denormalizedData->value->type); + } + + public function testDenormalizeTemplateType() + { + if (!interface_exists(PropertyDocBlockExtractorInterface::class)) { + $this->markTestSkipped('The PropertyInfo component before Symfony 7.1 does not support template types.'); + } + + $normalizer = new class ( + classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), + propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new PhpStanExtractor(), new ReflectionExtractor()]) + ) extends AbstractObjectNormalizerDummy { + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return true; + } + }; + $serializer = new Serializer([new ArrayDenormalizer(), $normalizer]); + $normalizer->setSerializer($serializer); + + $denormalizedData = $normalizer->denormalize(['value' => ['type' => 'dummy'], 'values' => [['type' => 'dummy']]], DummyGenericsValueWrapper::class); + + $this->assertInstanceOf(DummyGenericsValueWrapper::class, $denormalizedData); + $this->assertInstanceOf(DummyGenericsValue::class, $denormalizedData->value); + $this->assertContainsOnlyInstancesOf(DummyGenericsValue::class, $denormalizedData->values); + $this->assertCount(1, $denormalizedData->values); + $this->assertSame('dummy', $denormalizedData->value->type); + $this->assertSame('dummy', $denormalizedData->values[0]->type); + } } class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer @@ -1753,3 +1806,31 @@ public function getSupportedTypes(?string $format): array ]; } } + +#[DiscriminatorMap('type', ['dummy' => DummyGenericsValue::class])] +abstract class AbstractDummyGenericsValue +{ + public function __construct( + public string $type, + ) { + } +} + +class DummyGenericsValue extends AbstractDummyGenericsValue +{ + public function __construct() + { + parent::__construct('dummy'); + } +} + +/** + * @template T of AbstractDummyGenericsValue + */ +class DummyGenericsValueWrapper +{ + /** @var T */ + public mixed $value; + /** @var T[] */ + public array $values; +} diff --git a/composer.json b/composer.json index 0092a9643af..948fa36fa0d 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ }, "require-dev": { "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "phpstan/phpdoc-parser": "^1.0", "seld/jsonlint": "^1.10", "symfony/cache": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", @@ -37,7 +38,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.5", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -50,6 +51,7 @@ "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", + "symfony/type-info": "<7.1.5", "symfony/uid": "<6.4", "symfony/validator": "<6.4", "symfony/yaml": "<6.4"