From bb0f56d57928148b02923f6050b8c759512b659c Mon Sep 17 00:00:00 2001 From: HypeMC Date: Thu, 16 Jan 2025 19:53:09 +0100 Subject: [PATCH] [PropertyInfo][FrameworkBundle] Allow defining accessors and mutators via an attribute --- .../FrameworkExtension.php | 11 ++ .../Resources/config/property_info.php | 15 +++ .../PropertyInfo/Attribute/WithAccessors.php | 32 +++++ .../Component/PropertyInfo/CHANGELOG.md | 1 + .../Exception/ExceptionInterface.php | 16 +++ .../PropertyInfo/Exception/LogicException.php | 16 +++ .../Exception/MappingException.php | 27 ++++ .../Exception/RuntimeException.php | 16 +++ .../Extractor/ReflectionExtractor.php | 118 ++++++++++++++---- .../PropertyInfo/Mapping/ClassMetadata.php | 77 ++++++++++++ .../Factory/CachedClassMetadataFactory.php | 54 ++++++++ .../Mapping/Factory/ClassMetadataFactory.php | 52 ++++++++ .../Factory/ClassMetadataFactoryInterface.php | 27 ++++ .../Mapping/Loader/AttributeLoader.php | 65 ++++++++++ .../Mapping/Loader/LoaderInterface.php | 22 ++++ .../PropertyInfo/Mapping/PropertyMetadata.php | 24 ++++ .../Tests/Attribute/WithAccessorsTest.php | 39 ++++++ .../Extractor/ReflectionExtractorTest.php | 115 +++++++++++++++++ .../Tests/Fixtures/WithAccessors/Bar.php | 46 +++++++ .../Tests/Fixtures/WithAccessors/Baz.php | 21 ++++ .../Fixtures/WithAccessors/BazInterface.php | 22 ++++ .../Tests/Fixtures/WithAccessors/Foo.php | 28 +++++ .../Fixtures/WithAccessors/InvalidMethods.php | 20 +++ .../WithAccessors/JustAdderAndRemover.php | 28 +++++ .../WithAccessors/JustGetterOrSetter.php | 30 +++++ .../Tests/Mapping/ClassMetadataTest.php | 62 +++++++++ .../CachedClassMetadataFactoryTest.php | 85 +++++++++++++ .../Factory/ClassMetadataFactoryTest.php | 75 +++++++++++ .../Mapping/Loader/AttributeLoaderTest.php | 50 ++++++++ 29 files changed, 1169 insertions(+), 25 deletions(-) create mode 100644 src/Symfony/Component/PropertyInfo/Attribute/WithAccessors.php create mode 100644 src/Symfony/Component/PropertyInfo/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/PropertyInfo/Exception/LogicException.php create mode 100644 src/Symfony/Component/PropertyInfo/Exception/MappingException.php create mode 100644 src/Symfony/Component/PropertyInfo/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/ClassMetadata.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/Factory/CachedClassMetadataFactory.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactory.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactoryInterface.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/Loader/AttributeLoader.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/Loader/LoaderInterface.php create mode 100644 src/Symfony/Component/PropertyInfo/Mapping/PropertyMetadata.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Attribute/WithAccessorsTest.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Bar.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Baz.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/BazInterface.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Foo.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/InvalidMethods.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustAdderAndRemover.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustGetterOrSetter.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Mapping/ClassMetadataTest.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/CachedClassMetadataFactoryTest.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/ClassMetadataFactoryTest.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Mapping/Loader/AttributeLoaderTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 88fe69ec32756..6e497fe6fadb9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -140,6 +140,7 @@ use Symfony\Component\PropertyInfo\Extractor\ConstructorArgumentTypeExtractorInterface; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; +use Symfony\Component\PropertyInfo\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -2076,6 +2077,16 @@ private function registerPropertyInfoConfiguration(array $config, ContainerBuild if ($container->getParameter('kernel.debug')) { $container->removeDefinition('property_info.cache'); + $container->removeDefinition('property_info.mapping.cached_class_metadata_factory'); + } + + if (!interface_exists(ClassMetadataFactoryInterface::class)) { + $container->removeDefinition('property_info.mapping.attribute_loader'); + $container->removeDefinition('property_info.mapping.class_metadata_factory'); + $container->removeDefinition('property_info.mapping.cached_class_metadata_factory'); + } else { + $container->getDefinition('property_info.reflection_extractor') + ->setArgument('$classMetadataFactory', new Reference('property_info.mapping.class_metadata_factory')); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php index 505dda6f4fd75..6d8bf86ba3d81 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php @@ -13,6 +13,9 @@ use Symfony\Component\PropertyInfo\Extractor\ConstructorExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\Mapping\Factory\CachedClassMetadataFactory; +use Symfony\Component\PropertyInfo\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\PropertyInfo\Mapping\Loader\AttributeLoader; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoCacheExtractor; @@ -40,6 +43,18 @@ ->decorate('property_info') ->args([service('property_info.cache.inner'), service('cache.property_info')]) + ->set('property_info.mapping.attribute_loader', AttributeLoader::class) + + ->set('property_info.mapping.class_metadata_factory', ClassMetadataFactory::class) + ->args([service('property_info.mapping.attribute_loader')]) + + ->set('property_info.mapping.cached_class_metadata_factory', CachedClassMetadataFactory::class) + ->decorate('property_info.mapping.class_metadata_factory') + ->args([ + service('property_info.mapping.cached_class_metadata_factory.inner'), + service('cache.property_info'), + ]) + // Extractor ->set('property_info.reflection_extractor', ReflectionExtractor::class) ->tag('property_info.list_extractor', ['priority' => -1000]) diff --git a/src/Symfony/Component/PropertyInfo/Attribute/WithAccessors.php b/src/Symfony/Component/PropertyInfo/Attribute/WithAccessors.php new file mode 100644 index 0000000000000..9f8623fa2d290 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Attribute/WithAccessors.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Attribute; + +use Symfony\Component\PropertyInfo\Exception\LogicException; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class WithAccessors +{ + public function __construct( + public ?string $getter = null, + public ?string $setter = null, + public ?string $adder = null, + public ?string $remover = null, + ) { + if (!($this->getter || $this->setter || $this->adder || $this->remover)) { + throw new LogicException('You need to have at least one method name set.'); + } + if ($this->adder xor $this->remover) { + throw new LogicException('You need to have both an adder and remover set.'); + } + } +} diff --git a/src/Symfony/Component/PropertyInfo/CHANGELOG.md b/src/Symfony/Component/PropertyInfo/CHANGELOG.md index 78803e270751f..0af7d1f01cac0 100644 --- a/src/Symfony/Component/PropertyInfo/CHANGELOG.md +++ b/src/Symfony/Component/PropertyInfo/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add support for `non-positive-int`, `non-negative-int` and `non-zero-int` PHPStan types to `PhpStanExtractor` * Add `PropertyDescriptionExtractorInterface` to `PhpStanExtractor` + * Allow defining accessors and mutators via the `#[WithAccessors]` attribute 7.1 --- diff --git a/src/Symfony/Component/PropertyInfo/Exception/ExceptionInterface.php b/src/Symfony/Component/PropertyInfo/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..5605c19d559d7 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Exception/ExceptionInterface.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Exception; + +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/PropertyInfo/Exception/LogicException.php b/src/Symfony/Component/PropertyInfo/Exception/LogicException.php new file mode 100644 index 0000000000000..9eee374296601 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Exception/LogicException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Exception; + +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/PropertyInfo/Exception/MappingException.php b/src/Symfony/Component/PropertyInfo/Exception/MappingException.php new file mode 100644 index 0000000000000..97c27d4ad3e47 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Exception/MappingException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Exception; + +class MappingException extends RuntimeException +{ + /** + * @param list $invalidMethods + */ + public function __construct( + string $message, + public readonly string $forClass, + public readonly array $invalidMethods, + ?\Throwable $previous = null, + ) { + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Symfony/Component/PropertyInfo/Exception/RuntimeException.php b/src/Symfony/Component/PropertyInfo/Exception/RuntimeException.php new file mode 100644 index 0000000000000..1163b29f0006c --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Exception/RuntimeException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Exception; + +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php index 506a5fa24e02e..2fb3c351e9f14 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php @@ -11,6 +11,8 @@ namespace Symfony\Component\PropertyInfo\Extractor; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; +use Symfony\Component\PropertyInfo\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; @@ -100,6 +102,7 @@ public function __construct( int $accessFlags = self::ALLOW_PUBLIC, ?InflectorInterface $inflector = null, private int $magicMethodsFlags = self::ALLOW_MAGIC_GET | self::ALLOW_MAGIC_SET, + private readonly ?ClassMetadataFactoryInterface $classMetadataFactory = null, ) { $this->mutatorPrefixes = $mutatorPrefixes ?? self::$defaultMutatorPrefixes; $this->accessorPrefixes = $accessorPrefixes ?? self::$defaultAccessorPrefixes; @@ -142,7 +145,7 @@ public function getProperties(string $class, array $context = []): ?array continue; } - $propertyName = $this->getPropertyName($reflectionMethod->name, $reflectionProperties); + $propertyName = $this->getPropertyName($class, $reflectionMethod->name, $reflectionProperties); if (!$propertyName || isset($properties[$propertyName])) { continue; } @@ -222,7 +225,7 @@ public function getType(string $class, string $property, array $context = []): ? } } - [$accessorReflection, $prefix] = $this->getAccessorMethod($class, $property); + [$accessorReflection] = $this->getAccessorMethod($class, $property); if ($accessorReflection) { try { return $this->typeResolver->resolve($accessorReflection); @@ -359,6 +362,12 @@ public function getReadInfo(string $class, string $property, array $context = [] return null; } + if (null !== $methodName = $this->getClassMetadata($class)?->getPropertyMetadataFor($property)?->getter) { + $method = new \ReflectionMethod($class, $methodName); + + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $methodName, $this->getReadVisibilityForMethod($method), $method->isStatic(), false); + } + $allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false; $magicMethods = $context['enable_magic_methods_extraction'] ?? $this->magicMethodsFlags; $allowMagicCall = (bool) ($magicMethods & self::ALLOW_MAGIC_CALL); @@ -406,16 +415,35 @@ public function getWriteInfo(string $class, string $property, array $context = [ return null; } + $allowAdderRemover = $context['enable_adder_remover_extraction'] ?? true; + $propertyMetadata = $this->getClassMetadata($class)?->getPropertyMetadataFor($property); + $adderAccessName = $propertyMetadata?->adder; + $removerAccessName = $propertyMetadata?->remover; + + if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { + $adderMethod = new \ReflectionMethod($class, $adderAccessName); + $removerMethod = new \ReflectionMethod($class, $removerAccessName); + + $mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER); + $mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisibilityForMethod($adderMethod), $adderMethod->isStatic())); + $mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisibilityForMethod($removerMethod), $removerMethod->isStatic())); + + return $mutator; + } + + if (null !== $methodName = $propertyMetadata?->setter) { + $method = new \ReflectionMethod($class, $methodName); + + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $methodName, $this->getWriteVisibilityForMethod($method), $method->isStatic()); + } + $allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false; $magicMethods = $context['enable_magic_methods_extraction'] ?? $this->magicMethodsFlags; $allowMagicCall = (bool) ($magicMethods & self::ALLOW_MAGIC_CALL); $allowMagicSet = (bool) ($magicMethods & self::ALLOW_MAGIC_SET); $allowConstruct = $context['enable_constructor_extraction'] ?? $this->enableConstructorExtraction; - $allowAdderRemover = $context['enable_adder_remover_extraction'] ?? true; - $camelized = $this->camelize($property); $constructor = $reflClass->getConstructor(); - $singulars = $this->inflector->singularize($camelized); $errors = []; if (null !== $constructor && $allowConstruct) { @@ -426,19 +454,23 @@ public function getWriteInfo(string $class, string $property, array $context = [ } } - [$adderAccessName, $removerAccessName, $adderAndRemoverErrors] = $this->findAdderAndRemover($reflClass, $singulars); - if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { - $adderMethod = $reflClass->getMethod($adderAccessName); - $removerMethod = $reflClass->getMethod($removerAccessName); + $camelized = $this->camelize($property); - $mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER); - $mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisibilityForMethod($adderMethod), $adderMethod->isStatic())); - $mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisibilityForMethod($removerMethod), $removerMethod->isStatic())); + if (null === $adderAccessName || null === $removerAccessName) { + [$adderAccessName, $removerAccessName, $adderAndRemoverErrors] = $this->findAdderAndRemover($property, $camelized, $reflClass); + if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { + $adderMethod = $reflClass->getMethod($adderAccessName); + $removerMethod = $reflClass->getMethod($removerAccessName); - return $mutator; - } + $mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER); + $mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisibilityForMethod($adderMethod), $adderMethod->isStatic())); + $mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisibilityForMethod($removerMethod), $removerMethod->isStatic())); - $errors[] = $adderAndRemoverErrors; + return $mutator; + } + + $errors[] = $adderAndRemoverErrors; + } foreach ($this->mutatorPrefixes as $mutatorPrefix) { $methodName = $mutatorPrefix.$camelized; @@ -743,6 +775,10 @@ private function isAllowedProperty(string $class, string $property, bool $writeA */ private function getAccessorMethod(string $class, string $property): ?array { + if (null !== $getter = $this->getClassMetadata($class)?->getPropertyMetadataFor($property)?->getter) { + return [new \ReflectionMethod($class, $getter), $this->extractPrefix($getter)[0] ?? '']; + } + $ucProperty = ucfirst($property); foreach ($this->accessorPrefixes as $prefix) { @@ -769,6 +805,10 @@ private function getAccessorMethod(string $class, string $property): ?array */ private function getMutatorMethod(string $class, string $property): ?array { + if (null !== $setter = $this->getClassMetadata($class)?->getPropertyMetadataFor($property)?->setter) { + return [new \ReflectionMethod($class, $setter), $this->extractPrefix($setter)[0] ?? '']; + } + $ucProperty = ucfirst($property); $ucSingulars = $this->inflector->singularize($ucProperty); @@ -800,24 +840,28 @@ private function getMutatorMethod(string $class, string $property): ?array return null; } - private function getPropertyName(string $methodName, array $reflectionProperties): ?string + private function getPropertyName(string $class, string $methodName, array $reflectionProperties): ?string { - $pattern = implode('|', array_merge($this->accessorPrefixes, $this->mutatorPrefixes)); + if (null !== $propertyName = $this->getClassMetadata($class)?->getPropertyMetadataByMethod($methodName)?->name) { + return $propertyName; + } - if ('' !== $pattern && preg_match('/^('.$pattern.')(.+)$/i', $methodName, $matches)) { - if (!\in_array($matches[1], $this->arrayMutatorPrefixes, true)) { - return $matches[2]; + if (null !== $prefixAndPropertyName = $this->extractPrefix($methodName)) { + [$prefix, $propertyName] = $prefixAndPropertyName; + + if (!\in_array($prefix, $this->arrayMutatorPrefixes, true)) { + return $propertyName; } foreach ($reflectionProperties as $reflectionProperty) { foreach ($this->inflector->singularize($reflectionProperty->name) as $name) { - if (strtolower($name) === strtolower($matches[2])) { + if (strtolower($name) === strtolower($propertyName)) { return $reflectionProperty->name; } } } - return $matches[2]; + return $propertyName; } return null; @@ -827,12 +871,16 @@ private function getPropertyName(string $methodName, array $reflectionProperties * Searches for add and remove methods. * * @param \ReflectionClass $reflClass The reflection class for the given object - * @param array $singulars The singular form of the property name or null * * @return array An array containing the adder and remover when found and errors */ - private function findAdderAndRemover(\ReflectionClass $reflClass, array $singulars): array + private function findAdderAndRemover(string $property, string $camelProp, \ReflectionClass $reflClass): array { + $propertyMetadata = $this->getClassMetadata($reflClass->getName())?->getPropertyMetadataFor($property); + if (($addMethod = $propertyMetadata?->adder) && ($removeMethod = $propertyMetadata?->remover)) { + return [$addMethod, $removeMethod, []]; + } + if (2 !== \count($this->arrayMutatorPrefixes)) { return [null, null, []]; } @@ -840,7 +888,7 @@ private function findAdderAndRemover(\ReflectionClass $reflClass, array $singula [$addPrefix, $removePrefix] = $this->arrayMutatorPrefixes; $errors = []; - foreach ($singulars as $singular) { + foreach ($this->inflector->singularize($camelProp) as $singular) { $addMethod = $addPrefix.$singular; $removeMethod = $removePrefix.$singular; @@ -893,6 +941,21 @@ private function camelize(string $string): string return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); } + /** + * Returns an array with the prefix of the method as the first key + * and the remainder as the second, or null if not found. + */ + private function extractPrefix(string $methodName): ?array + { + $pattern = implode('|', [...$this->accessorPrefixes, ...$this->mutatorPrefixes]); + + if ('' !== $pattern && preg_match('/^('.$pattern.')(.+)$/i', $methodName, $matches)) { + return [$matches[1], $matches[2]]; + } + + return null; + } + /** * Return allowed reflection method flags. */ @@ -1002,4 +1065,9 @@ private function getWriteVisibilityForMethod(\ReflectionMethod $reflectionMethod return PropertyWriteInfo::VISIBILITY_PUBLIC; } + + private function getClassMetadata(string $class): ?ClassMetadata + { + return $this->classMetadataFactory?->hasMetadataFor($class) ? $this->classMetadataFactory->getMetadataFor($class) : null; + } } diff --git a/src/Symfony/Component/PropertyInfo/Mapping/ClassMetadata.php b/src/Symfony/Component/PropertyInfo/Mapping/ClassMetadata.php new file mode 100644 index 0000000000000..3d94936d08299 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/ClassMetadata.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping; + +final class ClassMetadata +{ + /** + * @var array + */ + private array $properties = []; + /** + * @var ?array + */ + private ?array $methods = null; + + /** + * @param class-string $name + */ + public function __construct( + public readonly string $name, + ) { + } + + public function addPropertyMetadata(PropertyMetadata $propertyMetadata): void + { + $this->properties[$propertyMetadata->name] = $propertyMetadata; + $this->methods = null; + } + + public function getPropertyMetadataFor(string $property): ?PropertyMetadata + { + return $this->properties[$property] ?? null; + } + + public function getPropertyMetadataByMethod(string $method): ?PropertyMetadata + { + if (null === $this->methods) { + $this->methods = []; + + foreach ($this->properties as $propertyMetadata) { + foreach (['getter', 'setter', 'adder', 'remover'] as $accessor) { + if (null !== $propertyMetadata->$accessor) { + $this->methods[$propertyMetadata->$accessor] = $propertyMetadata; + } + } + } + } + + return $this->methods[$method] ?? null; + } + + public function merge(self $classMetadata): void + { + foreach ($classMetadata->properties as $property) { + $this->addPropertyMetadata($property); + } + } + + public function __serialize(): array + { + return $this->properties; + } + + public function __unserialize(array $properties): void + { + $this->properties = $properties; + } +} diff --git a/src/Symfony/Component/PropertyInfo/Mapping/Factory/CachedClassMetadataFactory.php b/src/Symfony/Component/PropertyInfo/Mapping/Factory/CachedClassMetadataFactory.php new file mode 100644 index 0000000000000..e9d14a7b26ce8 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/Factory/CachedClassMetadataFactory.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping\Factory; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; + +final class CachedClassMetadataFactory implements ClassMetadataFactoryInterface +{ + /** + * @var array + */ + private array $loadedClasses = []; + + public function __construct( + private readonly ClassMetadataFactoryInterface $decorated, + private readonly CacheItemPoolInterface $cacheItemPool, + ) { + } + + public function getMetadataFor(string $className): ClassMetadata + { + if (isset($this->loadedClasses[$className])) { + return $this->loadedClasses[$className]; + } + + $key = rawurlencode(strtr($className, '\\', '_')); + + $item = $this->cacheItemPool->getItem($key); + if ($item->isHit()) { + return $this->loadedClasses[$className] = $item->get(); + } + + $metadata = $this->decorated->getMetadataFor($className); + + $this->cacheItemPool->save($item->set($metadata)); + + return $this->loadedClasses[$className] = $metadata; + } + + public function hasMetadataFor(string $className): bool + { + return $this->decorated->hasMetadataFor($className); + } +} diff --git a/src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactory.php b/src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactory.php new file mode 100644 index 0000000000000..4a4db3d05e91a --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactory.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping\Factory; + +use Symfony\Component\PropertyInfo\Exception\RuntimeException; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; +use Symfony\Component\PropertyInfo\Mapping\Loader\LoaderInterface; + +final class ClassMetadataFactory implements ClassMetadataFactoryInterface +{ + public function __construct( + private readonly LoaderInterface $loader, + ) { + } + + public function getMetadataFor(string $className): ClassMetadata + { + if (!$this->hasMetadataFor($className)) { + throw new RuntimeException(\sprintf('The class or interface "%s" does not exist.', $className)); + } + + $this->loader->loadClassMetadata($metadata = new ClassMetadata($className)); + + $reflectionClass = new \ReflectionClass($className); + + // Include metadata from the parent class + if ($parent = $reflectionClass->getParentClass()) { + $metadata->merge($this->getMetadataFor($parent->name)); + } + + // Include metadata from all implemented interfaces + foreach ($reflectionClass->getInterfaces() as $interface) { + $metadata->merge($this->getMetadataFor($interface->name)); + } + + return $metadata; + } + + public function hasMetadataFor(string $className): bool + { + return class_exists($className) || interface_exists($className, false); + } +} diff --git a/src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactoryInterface.php b/src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactoryInterface.php new file mode 100644 index 0000000000000..6da13d632655c --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/Factory/ClassMetadataFactoryInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping\Factory; + +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; + +interface ClassMetadataFactoryInterface +{ + /** + * @param class-string $className + */ + public function getMetadataFor(string $className): ClassMetadata; + + /** + * @param class-string $className + */ + public function hasMetadataFor(string $className): bool; +} diff --git a/src/Symfony/Component/PropertyInfo/Mapping/Loader/AttributeLoader.php b/src/Symfony/Component/PropertyInfo/Mapping/Loader/AttributeLoader.php new file mode 100644 index 0000000000000..ed3c31ca09874 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/Loader/AttributeLoader.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping\Loader; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; +use Symfony\Component\PropertyInfo\Exception\MappingException; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; +use Symfony\Component\PropertyInfo\Mapping\PropertyMetadata; + +final class AttributeLoader implements LoaderInterface +{ + public function loadClassMetadata(ClassMetadata $metadata): bool + { + $success = false; + + $reflectionClass = new \ReflectionClass($metadata->name); + foreach ($reflectionClass->getProperties() as $refProperty) { + if ($refProperty->getDeclaringClass()->name !== $metadata->name) { + continue; + } + + /** @var \ReflectionAttribute $refAttribute */ + foreach ($refProperty->getAttributes(WithAccessors::class) as $refAttribute) { + $attribute = $refAttribute->newInstance(); + + $methods = [ + $attribute->getter, + $attribute->setter, + $attribute->adder, + $attribute->remover, + ]; + $this->validateMethods($methods, $reflectionClass); + + $metadata->addPropertyMetadata(new PropertyMetadata($refProperty->name, ...$methods)); + + $success = true; + } + } + + return $success; + } + + private function validateMethods(array $methods, \ReflectionClass $refClass): void + { + $invalidMethods = []; + foreach ($methods as $method) { + if (null !== $method && !$refClass->hasMethod($method)) { + $invalidMethods[] = $method; + } + } + + if ($invalidMethods) { + throw new MappingException(\sprintf('The class "%s" does not have the following methods: "%s". Check its #[WithAccessors] attributes.', $refClass->name, implode('", "', $invalidMethods)), $refClass->name, $invalidMethods); + } + } +} diff --git a/src/Symfony/Component/PropertyInfo/Mapping/Loader/LoaderInterface.php b/src/Symfony/Component/PropertyInfo/Mapping/Loader/LoaderInterface.php new file mode 100644 index 0000000000000..6bab4797d02ec --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/Loader/LoaderInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping\Loader; + +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; + +/** + * Loads validation metadata into {@link ClassMetadata} instances. + */ +interface LoaderInterface +{ + public function loadClassMetadata(ClassMetadata $metadata): bool; +} diff --git a/src/Symfony/Component/PropertyInfo/Mapping/PropertyMetadata.php b/src/Symfony/Component/PropertyInfo/Mapping/PropertyMetadata.php new file mode 100644 index 0000000000000..05ea669beb3f9 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Mapping/PropertyMetadata.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Mapping; + +final readonly class PropertyMetadata +{ + public function __construct( + public string $name, + public ?string $getter, + public ?string $setter, + public ?string $adder, + public ?string $remover, + ) { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Attribute/WithAccessorsTest.php b/src/Symfony/Component/PropertyInfo/Tests/Attribute/WithAccessorsTest.php new file mode 100644 index 0000000000000..7aec95eb1432c --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Attribute/WithAccessorsTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; +use Symfony\Component\PropertyInfo\Exception\LogicException; + +class WithAccessorsTest extends TestCase +{ + public function testExceptionIsThrownWhenNoAccessorsAreDefined() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You need to have at least one method name set.'); + + new WithAccessors(); + } + + /** + * @testWith ["foo", null] + * [null, "foo"] + */ + public function testExceptionIsThrownWhenOnlyAdderOrRemoverAreDefined(?string $adder, ?string $remover) + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You need to have both an adder and remover set.'); + + new WithAccessors(adder: $adder, remover: $remover); + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php index 1372d25c785ef..6ebff093d2fa6 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php @@ -13,6 +13,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\PropertyInfo\Mapping\Loader\AttributeLoader; use Symfony\Component\PropertyInfo\PropertyReadInfo; use Symfony\Component\PropertyInfo\PropertyWriteInfo; use Symfony\Component\PropertyInfo\Tests\Fixtures\AdderRemoverDummy; @@ -33,6 +35,9 @@ use Symfony\Component\PropertyInfo\Tests\Fixtures\Php82Dummy; use Symfony\Component\PropertyInfo\Tests\Fixtures\SnakeCaseDummy; use Symfony\Component\PropertyInfo\Tests\Fixtures\VirtualProperties; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\Bar; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\JustAdderAndRemover; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\JustGetterOrSetter; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\NullableType; @@ -220,6 +225,13 @@ public function testGetPropertiesWithNoPrefixes() ); } + public function testGetPropertiesWithMetadata() + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + + self::assertSame(['prop2', 'prop3', 'prop1'], $extractor->getProperties(Bar::class)); + } + /** * @group legacy * @@ -382,6 +394,24 @@ public static function provideLegacyDefaultValue() ]; } + /** + * @group legacy + * + * @dataProvider provideLegacyTypesWithMetadata + */ + public function testExtractTypeWithMetadataLegacy(string $property, array $type) + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + + self::assertEquals($type, $extractor->getTypes(JustGetterOrSetter::class, $property)); + } + + public static function provideLegacyTypesWithMetadata(): iterable + { + yield ['justGetter', [new LegacyType(LegacyType::BUILTIN_TYPE_BOOL, false)]]; + yield ['justSetter', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true)]]; + } + /** * @dataProvider getReadableProperties */ @@ -448,6 +478,14 @@ public function testIsReadableSnakeCase() $this->assertTrue($this->extractor->isReadable(SnakeCaseDummy::class, 'snake_readonly')); } + public function testIsReadableWithMetadata() + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + + self::assertTrue($extractor->isReadable(JustGetterOrSetter::class, 'justGetter')); + self::assertFalse($extractor->isReadable(JustGetterOrSetter::class, 'justSetter')); + } + public function testIsWriteableSnakeCase() { $this->assertTrue($this->extractor->isWritable(SnakeCaseDummy::class, 'snake_property')); @@ -456,6 +494,14 @@ public function testIsWriteableSnakeCase() $this->assertTrue($this->extractor->isWritable(SnakeCaseDummy::class, 'snake_method')); } + public function testIsWritableWithMetadata() + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + + self::assertFalse($extractor->isWritable(JustGetterOrSetter::class, 'justGetter')); + self::assertTrue($extractor->isWritable(JustGetterOrSetter::class, 'justSetter')); + } + public function testSingularize() { $this->assertTrue($this->extractor->isWritable(AdderRemoverDummy::class, 'analyses')); @@ -592,6 +638,18 @@ public static function readAccessorProvider(): array ]; } + public function testGetReadAccessorWithMetadata() + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + $readAccessor = $extractor->getReadInfo(Bar::class, 'prop2'); + + self::assertNotNull($readAccessor); + self::assertSame(PropertyReadInfo::TYPE_METHOD, $readAccessor->getType()); + self::assertSame('fetchProp2', $readAccessor->getName()); + self::assertSame(PropertyReadInfo::VISIBILITY_PUBLIC, $readAccessor->getVisibility()); + self::assertFalse($readAccessor->isStatic()); + } + /** * @dataProvider writeMutatorProvider */ @@ -671,6 +729,47 @@ public function testDisabledAdderAndRemoverReturnsError() self::assertSame([\sprintf('The property "baz" in class "%s" can be defined with the methods "addBaz()", "removeBaz()" but the new value must be an array or an instance of \Traversable', Php71Dummy::class)], $writeMutator->getErrors()); } + /** + * @dataProvider writeMutatorWithMetadataProvider + */ + public function testGetWriteMutatorWithMetadata(string $property, string $type, string $name, ?string $addName, ?string $removeName) + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + $writeMutator = $extractor->getWriteInfo(Bar::class, $property); + + self::assertNotNull($writeMutator); + self::assertSame($type, $writeMutator->getType()); + + if (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $writeMutator->getType()) { + self::assertNotNull($writeMutator->getAdderInfo()); + self::assertSame($addName, $writeMutator->getAdderInfo()->getName()); + self::assertNotNull($writeMutator->getRemoverInfo()); + self::assertSame($removeName, $writeMutator->getRemoverInfo()->getName()); + } elseif (PropertyWriteInfo::TYPE_METHOD === $writeMutator->getType()) { + self::assertSame($name, $writeMutator->getName()); + self::assertSame(PropertyWriteInfo::VISIBILITY_PUBLIC, $writeMutator->getVisibility()); + self::assertFalse($writeMutator->isStatic()); + } + } + + public static function writeMutatorWithMetadataProvider(): iterable + { + yield ['prop2', PropertyWriteInfo::TYPE_METHOD, 'takeProp2', null, null]; + yield ['prop3', PropertyWriteInfo::TYPE_ADDER_AND_REMOVER, 'takeProp3', 'pushProp3', 'popProp3']; + } + + public function testDisabledAdderAndRemoverWithMetadataReturnsError() + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + $writeMutator = $extractor->getWriteInfo(JustAdderAndRemover::class, 'prop', [ + 'enable_adder_remover_extraction' => false, + ]); + + self::assertNotNull($writeMutator); + self::assertSame(PropertyWriteInfo::TYPE_NONE, $writeMutator->getType()); + self::assertSame([\sprintf('The property "prop" in class "%s" can be defined with the methods "pushProp()", "popProp()" but the new value must be an array or an instance of \Traversable', JustAdderAndRemover::class)], $writeMutator->getErrors()); + } + public function testGetWriteInfoReadonlyProperties() { $writeMutatorConstructor = $this->extractor->getWriteInfo(Php81Dummy::class, 'foo', ['enable_constructor_extraction' => true]); @@ -978,6 +1077,22 @@ public static function defaultValueProvider(): iterable yield ['defaultNull', null]; } + /** + * @dataProvider provideTypesWithMetadata + */ + public function testExtractTypeWithMetadata(string $property, Type $type) + { + $extractor = new ReflectionExtractor(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader())); + + self::assertEquals($type, $extractor->getType(JustGetterOrSetter::class, $property)); + } + + public static function provideTypesWithMetadata(): iterable + { + yield ['justGetter', Type::bool()]; + yield ['justSetter', Type::nullable(Type::string())]; + } + /** * @dataProvider constructorTypesProvider */ diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Bar.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Bar.php new file mode 100644 index 0000000000000..e71314d202ad7 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Bar.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; + +class Bar extends Foo +{ + #[WithAccessors(getter: 'fetchProp2', setter: 'takeProp2')] + private int $prop2; + #[WithAccessors(getter: 'fetchProp3', setter: 'takeProp3', adder: 'pushProp3', remover: 'popProp3')] + private array $prop3; + + public function fetchProp2(): int + { + } + + public function takeProp2(int $prop2): void + { + } + + public function fetchProp3(): array + { + } + + public function takeProp3(array $prop3): void + { + } + + public function pushProp3(int $prop3Item): void + { + } + + public function popProp3(): int + { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Baz.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Baz.php new file mode 100644 index 0000000000000..02ebe344f8968 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Baz.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +class Baz extends Foo implements BazInterface +{ + public private(set) string $fullName; + + public function assignFullName(string $fullName): void + { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/BazInterface.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/BazInterface.php new file mode 100644 index 0000000000000..08ad4d32362ca --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/BazInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; + +interface BazInterface +{ + #[WithAccessors(setter: 'assignFullName')] + public string $fullName { get; } + + public function assignFullName(string $fullName): void; +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Foo.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Foo.php new file mode 100644 index 0000000000000..fb1164c9b8ac5 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/Foo.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; + +class Foo +{ + #[WithAccessors(getter: 'giveProp1', setter: 'receiveProp1')] + private string $prop1; + + public function giveProp1(): string + { + } + + public function receiveProp1(string $prop1): void + { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/InvalidMethods.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/InvalidMethods.php new file mode 100644 index 0000000000000..b4e87e758d098 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/InvalidMethods.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; + +class InvalidMethods +{ + #[WithAccessors(getter: 'bar', setter: 'baz')] + private $foo; +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustAdderAndRemover.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustAdderAndRemover.php new file mode 100644 index 0000000000000..aac3b824cda9d --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustAdderAndRemover.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; + +class JustAdderAndRemover +{ + #[WithAccessors(adder: 'pushProp', remover: 'popProp')] + private array $prop = []; + + public function pushProp(int $prop): void + { + } + + public function popProp(int $prop): array + { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustGetterOrSetter.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustGetterOrSetter.php new file mode 100644 index 0000000000000..aef28ee29ebc8 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/WithAccessors/JustGetterOrSetter.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors; + +use Symfony\Component\PropertyInfo\Attribute\WithAccessors; + +class JustGetterOrSetter +{ + #[WithAccessors(getter: 'giveJustGetter')] + private int $justGetter = 1; + #[WithAccessors(setter: 'receiveJustSetter')] + private int $justSetter; + + public function giveJustGetter(): bool + { + } + + public function receiveJustSetter(?string $justSetter): void + { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Mapping/ClassMetadataTest.php b/src/Symfony/Component/PropertyInfo/Tests/Mapping/ClassMetadataTest.php new file mode 100644 index 0000000000000..e4f529f9cf436 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Mapping/ClassMetadataTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; +use Symfony\Component\PropertyInfo\Mapping\PropertyMetadata; + +class ClassMetadataTest extends TestCase +{ + public function testGetPropertyMetadataFor() + { + $classMetadata = new ClassMetadata('Foo'); + $classMetadata->addPropertyMetadata($propertyMetadata = new PropertyMetadata('bar', 'getBar', 'setBar', null, null)); + + self::assertSame($propertyMetadata, $classMetadata->getPropertyMetadataFor('bar')); + self::assertNull($classMetadata->getPropertyMetadataFor('baz')); + } + + public function testGetPropertyMetadataByMethod() + { + $classMetadata = new ClassMetadata('Foo'); + $classMetadata->addPropertyMetadata($propertyMetadata = new PropertyMetadata('bar', 'getBar', 'setBar', 'addBar', 'removeBar')); + + self::assertSame($propertyMetadata, $classMetadata->getPropertyMetadataByMethod('getBar')); + self::assertSame($propertyMetadata, $classMetadata->getPropertyMetadataByMethod('addBar')); + self::assertNull($classMetadata->getPropertyMetadataByMethod('getBaz')); + + $classMetadata->addPropertyMetadata($propertyMetadata = new PropertyMetadata('baz', 'getBaz', 'setBaz', null, null)); + + self::assertSame($propertyMetadata, $classMetadata->getPropertyMetadataByMethod('getBaz')); + } + + public function testMerge() + { + $classMetadata1 = new ClassMetadata('Foo'); + $classMetadata1->addPropertyMetadata($propertyMetadata1 = new PropertyMetadata('bar', 'getBar', 'setBar', null, null)); + + self::assertSame($propertyMetadata1, $classMetadata1->getPropertyMetadataFor('bar')); + self::assertNull($classMetadata1->getPropertyMetadataFor('qux')); + + self::assertSame($propertyMetadata1, $classMetadata1->getPropertyMetadataByMethod('getBar')); + self::assertNull($classMetadata1->getPropertyMetadataByMethod('addQux')); + + $classMetadata2 = new ClassMetadata('Baz'); + $classMetadata2->addPropertyMetadata($propertyMetadata2 = new PropertyMetadata('qux', 'getQux', 'setQux', 'addQux', 'removeQux')); + + $classMetadata1->merge($classMetadata2); + + self::assertSame($propertyMetadata2, $classMetadata1->getPropertyMetadataFor('qux')); + self::assertSame($propertyMetadata2, $classMetadata1->getPropertyMetadataByMethod('addQux')); + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/CachedClassMetadataFactoryTest.php b/src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/CachedClassMetadataFactoryTest.php new file mode 100644 index 0000000000000..8167d0e23050b --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/CachedClassMetadataFactoryTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Mapping\Factory; + +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; +use Symfony\Component\PropertyInfo\Mapping\Factory\CachedClassMetadataFactory; +use Symfony\Component\PropertyInfo\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\Foo; + +class CachedClassMetadataFactoryTest extends TestCase +{ + private const CACHE_KEY = 'Symfony_Component_PropertyInfo_Tests_Fixtures_WithAccessors_Foo'; + + public function testGetMetadataForWhenNotInCache() + { + $metadata = new ClassMetadata(Foo::class); + + $decorated = $this->createMock(ClassMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('getMetadataFor')->with(Foo::class)->willReturn($metadata); + + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->expects($this->once())->method('isHit')->willReturn(false); + $cacheItem->expects($this->never())->method('get'); + $cacheItem->expects($this->once())->method('set')->with($metadata)->willReturnSelf(); + + $cacheItemPool = $this->createMock(CacheItemPoolInterface::class); + $cacheItemPool->expects($this->once())->method('getItem')->with(self::CACHE_KEY)->willReturn($cacheItem); + $cacheItemPool->expects($this->once())->method('save')->with($cacheItem); + + $factory = new CachedClassMetadataFactory($decorated, $cacheItemPool); + + self::assertSame($metadata, $factory->getMetadataFor(Foo::class)); + + // Ensure $loadedClasses is used and no call to getItem() or getMetadataFor() occurs + self::assertSame($metadata, $factory->getMetadataFor(Foo::class)); + } + + public function testGetMetadataForWhenInCache() + { + $metadata = new ClassMetadata(Foo::class); + + $decorated = $this->createMock(ClassMetadataFactoryInterface::class); + $decorated->expects($this->never())->method('getMetadataFor'); + + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->expects($this->once())->method('isHit')->willReturn(true); + $cacheItem->expects($this->once())->method('get')->willReturn($metadata); + $cacheItem->expects($this->never())->method('set'); + + $cacheItemPool = $this->createMock(CacheItemPoolInterface::class); + $cacheItemPool->expects($this->once())->method('getItem')->with(self::CACHE_KEY)->willReturn($cacheItem); + $cacheItemPool->expects($this->never())->method('save'); + + $factory = new CachedClassMetadataFactory($decorated, $cacheItemPool); + + self::assertSame($metadata, $factory->getMetadataFor(Foo::class)); + + // Ensure $loadedClasses is used and no call to getItem() or getMetadataFor() occurs + self::assertSame($metadata, $factory->getMetadataFor(Foo::class)); + } + + public function testHasMetadataFor() + { + $decorated = $this->createMock(ClassMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('hasMetadataFor')->with(Foo::class)->willReturn(true); + + $cacheItemPool = $this->createMock(CacheItemPoolInterface::class); + + $factory = new CachedClassMetadataFactory($decorated, $cacheItemPool); + + self::assertTrue($factory->hasMetadataFor(Foo::class)); + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/ClassMetadataFactoryTest.php b/src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/ClassMetadataFactoryTest.php new file mode 100644 index 0000000000000..45ff31d8e525d --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Mapping/Factory/ClassMetadataFactoryTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Mapping\Factory; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Exception\RuntimeException; +use Symfony\Component\PropertyInfo\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\PropertyInfo\Mapping\Loader\AttributeLoader; +use Symfony\Component\PropertyInfo\Mapping\PropertyMetadata; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\Bar; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\Baz; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\Foo; + +class ClassMetadataFactoryTest extends TestCase +{ + /** + * @dataProvider provideMetadata + * + * @param class-string $class + * @param array $expectedProperties + */ + public function testGetMetadataFor(string $class, array $expectedProperties) + { + $factory = new ClassMetadataFactory(new AttributeLoader()); + $classMetadata = $factory->getMetadataFor($class); + + foreach ($expectedProperties as $property => $propertyMetadata) { + self::assertEquals($propertyMetadata, $classMetadata->getPropertyMetadataFor($property)); + } + } + + public function testGetMetadataForThrowsExceptionForNonExistentClass() + { + $factory = new ClassMetadataFactory(new AttributeLoader()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The class or interface "Non\Existent\Class" does not exist.'); + + $factory->getMetadataFor('Non\Existent\Class'); + } + + public function testHasMetadataFor() + { + $factory = new ClassMetadataFactory(new AttributeLoader()); + + $this->assertTrue($factory->hasMetadataFor(Foo::class)); + $this->assertTrue($factory->hasMetadataFor(Bar::class)); + $this->assertFalse($factory->hasMetadataFor('Non\Existent\Class')); + } + + public static function provideMetadata(): iterable + { + yield [Foo::class, $foo = [ + 'prop1' => new PropertyMetadata('prop1', 'giveProp1', 'receiveProp1', null, null), + ]]; + yield [Bar::class, $foo + [ + 'prop2' => new PropertyMetadata('prop2', 'fetchProp2', 'takeProp2', null, null), + 'prop3' => new PropertyMetadata('prop3', 'fetchProp3', 'takeProp3', 'pushProp3', 'popProp3'), + ]]; + if (\PHP_VERSION_ID >= 80400) { + yield [Baz::class, $foo + [ + 'fullName' => new PropertyMetadata('fullName', null, 'assignFullName', null, null), + ]]; + } + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Mapping/Loader/AttributeLoaderTest.php b/src/Symfony/Component/PropertyInfo/Tests/Mapping/Loader/AttributeLoaderTest.php new file mode 100644 index 0000000000000..0d0f96a0b22b5 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Mapping\Loader; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Exception\MappingException; +use Symfony\Component\PropertyInfo\Mapping\ClassMetadata; +use Symfony\Component\PropertyInfo\Mapping\Loader\AttributeLoader; +use Symfony\Component\PropertyInfo\Mapping\PropertyMetadata; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\Bar; +use Symfony\Component\PropertyInfo\Tests\Fixtures\WithAccessors\InvalidMethods; + +class AttributeLoaderTest extends TestCase +{ + public function testLoadClassMetadataIsSuccessful() + { + $classMetadata = new ClassMetadata(Bar::class); + + self::assertTrue((new AttributeLoader())->loadClassMetadata($classMetadata)); + self::assertEquals(new PropertyMetadata('prop2', 'fetchProp2', 'takeProp2', null, null), $classMetadata->getPropertyMetadataFor('prop2')); + self::assertEquals(new PropertyMetadata('prop3', 'fetchProp3', 'takeProp3', 'pushProp3', 'popProp3'), $classMetadata->getPropertyMetadataFor('prop3')); + self::assertNull($classMetadata->getPropertyMetadataFor('prop1')); + } + + public function testLoadClassMetadataIsNotSuccessful() + { + $classMetadata = new ClassMetadata(\stdClass::class); + + self::assertFalse((new AttributeLoader())->loadClassMetadata($classMetadata)); + } + + public function testExceptionIsThrownOnInvalidMethods() + { + $classMetadata = new ClassMetadata(InvalidMethods::class); + + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('The class "%s" does not have the following methods: "bar", "baz"', InvalidMethods::class)); + + (new AttributeLoader())->loadClassMetadata($classMetadata); + } +}