diff --git a/src/Symfony/Component/PropertyAccess/CHANGELOG.md b/src/Symfony/Component/PropertyAccess/CHANGELOG.md index d733c4148187..7a545752b5e9 100644 --- a/src/Symfony/Component/PropertyAccess/CHANGELOG.md +++ b/src/Symfony/Component/PropertyAccess/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.1.0 +----- + +* Linking to PropertyInfo extractor to remove a lot of duplicate code + 4.4.0 ----- diff --git a/src/Symfony/Component/PropertyAccess/PropertyAccessor.php b/src/Symfony/Component/PropertyAccess/PropertyAccessor.php index e4626aa72fd0..a082dde2dbe0 100644 --- a/src/Symfony/Component/PropertyAccess/PropertyAccessor.php +++ b/src/Symfony/Component/PropertyAccess/PropertyAccessor.php @@ -17,12 +17,16 @@ use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\ApcuAdapter; use Symfony\Component\Cache\Adapter\NullAdapter; -use Symfony\Component\Inflector\Inflector; use Symfony\Component\PropertyAccess\Exception\AccessException; use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyReadInfo; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfo; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; /** * Default implementation of {@link PropertyAccessorInterface}. @@ -36,17 +40,6 @@ class PropertyAccessor implements PropertyAccessorInterface private const VALUE = 0; private const REF = 1; private const IS_REF_CHAINED = 2; - private const ACCESS_HAS_PROPERTY = 0; - private const ACCESS_TYPE = 1; - private const ACCESS_NAME = 2; - private const ACCESS_REF = 3; - private const ACCESS_ADDER = 4; - private const ACCESS_REMOVER = 5; - private const ACCESS_TYPE_METHOD = 0; - private const ACCESS_TYPE_PROPERTY = 1; - private const ACCESS_TYPE_MAGIC = 2; - private const ACCESS_TYPE_ADDER_AND_REMOVER = 3; - private const ACCESS_TYPE_NOT_FOUND = 4; private const CACHE_PREFIX_READ = 'r'; private const CACHE_PREFIX_WRITE = 'w'; private const CACHE_PREFIX_PROPERTY_PATH = 'p'; @@ -64,6 +57,17 @@ class PropertyAccessor implements PropertyAccessorInterface private $cacheItemPool; private $propertyPathCache = []; + + /** + * @var PropertyReadInfoExtractorInterface + */ + private $readInfoExtractor; + + /** + * @var PropertyWriteInfoExtractorInterface + */ + private $writeInfoExtractor; + private $readPropertyCache = []; private $writePropertyCache = []; private static $resultProto = [self::VALUE => null]; @@ -72,12 +76,14 @@ class PropertyAccessor implements PropertyAccessorInterface * Should not be used by application code. Use * {@link PropertyAccess::createPropertyAccessor()} instead. */ - public function __construct(bool $magicCall = false, bool $throwExceptionOnInvalidIndex = false, CacheItemPoolInterface $cacheItemPool = null, bool $throwExceptionOnInvalidPropertyPath = true) + public function __construct(bool $magicCall = false, bool $throwExceptionOnInvalidIndex = false, CacheItemPoolInterface $cacheItemPool = null, bool $throwExceptionOnInvalidPropertyPath = true, PropertyReadInfoExtractorInterface $readInfoExtractor = null, PropertyWriteInfoExtractorInterface $writeInfoExtractor = null) { $this->magicCall = $magicCall; $this->ignoreInvalidIndices = !$throwExceptionOnInvalidIndex; $this->cacheItemPool = $cacheItemPool instanceof NullAdapter ? null : $cacheItemPool; // Replace the NullAdapter by the null value $this->ignoreInvalidProperty = !$throwExceptionOnInvalidPropertyPath; + $this->readInfoExtractor = $readInfoExtractor ?? new ReflectionExtractor([], null, null, false); + $this->writeInfoExtractor = $writeInfoExtractor ?? new ReflectionExtractor(['set'], null, null, false); } /** @@ -376,32 +382,29 @@ private function readProperty(array $zval, string $property, bool $ignoreInvalid $result = self::$resultProto; $object = $zval[self::VALUE]; - $access = $this->getReadAccessInfo(\get_class($object), $property); + $class = \get_class($object); + $access = $this->getReadInfo($class, $property); - if (self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE]) { - $result[self::VALUE] = $object->{$access[self::ACCESS_NAME]}(); - } elseif (self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE]) { - $result[self::VALUE] = $object->{$access[self::ACCESS_NAME]}; + if (null !== $access) { + $name = $access->getName(); + $type = $access->getType(); - if ($access[self::ACCESS_REF] && isset($zval[self::REF])) { - $result[self::REF] = &$object->{$access[self::ACCESS_NAME]}; - } - } elseif (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) { - // Needed to support \stdClass instances. We need to explicitly - // exclude $access[self::ACCESS_HAS_PROPERTY], otherwise if - // a *protected* property was found on the class, property_exists() - // returns true, consequently the following line will result in a - // fatal error. + if (PropertyReadInfo::TYPE_METHOD === $type) { + $result[self::VALUE] = $object->$name(); + } elseif (PropertyReadInfo::TYPE_PROPERTY === $type) { + $result[self::VALUE] = $object->$name; + if (isset($zval[self::REF]) && $access->canBeReference()) { + $result[self::REF] = &$object->$name; + } + } + } elseif ($object instanceof \stdClass && property_exists($object, $property)) { $result[self::VALUE] = $object->$property; if (isset($zval[self::REF])) { $result[self::REF] = &$object->$property; } - } elseif (self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]) { - // we call the getter and hope the __call do the job - $result[self::VALUE] = $object->{$access[self::ACCESS_NAME]}(); } elseif (!$ignoreInvalidProperty) { - throw new NoSuchPropertyException($access[self::ACCESS_NAME]); + throw new NoSuchPropertyException(sprintf('Can\'t get a way to read the property "%s" in class "%s".', $property, $class)); } // Objects are always passed around by reference @@ -415,7 +418,7 @@ private function readProperty(array $zval, string $property, bool $ignoreInvalid /** * Guesses how to read the property value. */ - private function getReadAccessInfo(string $class, string $property): array + private function getReadInfo(string $class, string $property): ?PropertyReadInfo { $key = str_replace('\\', '.', $class).'..'.$property; @@ -430,65 +433,17 @@ private function getReadAccessInfo(string $class, string $property): array } } - $access = []; - - $reflClass = new \ReflectionClass($class); - $access[self::ACCESS_HAS_PROPERTY] = $reflClass->hasProperty($property); - $camelProp = $this->camelize($property); - $getter = 'get'.$camelProp; - $getsetter = lcfirst($camelProp); // jQuery style, e.g. read: last(), write: last($item) - $isser = 'is'.$camelProp; - $hasser = 'has'.$camelProp; - $canAccessor = 'can'.$camelProp; - - if ($reflClass->hasMethod($getter) && $reflClass->getMethod($getter)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $getter; - } elseif ($reflClass->hasMethod($getsetter) && $reflClass->getMethod($getsetter)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $getsetter; - } elseif ($reflClass->hasMethod($isser) && $reflClass->getMethod($isser)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $isser; - } elseif ($reflClass->hasMethod($hasser) && $reflClass->getMethod($hasser)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $hasser; - } elseif ($reflClass->hasMethod($canAccessor) && $reflClass->getMethod($canAccessor)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $canAccessor; - } elseif ($reflClass->hasMethod('__get') && $reflClass->getMethod('__get')->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; - $access[self::ACCESS_NAME] = $property; - $access[self::ACCESS_REF] = false; - } elseif ($access[self::ACCESS_HAS_PROPERTY] && $reflClass->getProperty($property)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; - $access[self::ACCESS_NAME] = $property; - $access[self::ACCESS_REF] = true; - } elseif ($this->magicCall && $reflClass->hasMethod('__call') && $reflClass->getMethod('__call')->isPublic()) { - // we call the getter and hope the __call do the job - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_MAGIC; - $access[self::ACCESS_NAME] = $getter; - } else { - $methods = [$getter, $getsetter, $isser, $hasser, '__get']; - if ($this->magicCall) { - $methods[] = '__call'; - } - - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_NOT_FOUND; - $access[self::ACCESS_NAME] = sprintf( - 'Neither the property "%s" nor one of the methods "%s()" '. - 'exist and have public access in class "%s".', - $property, - implode('()", "', $methods), - $reflClass->name - ); - } + $accessor = $this->readInfoExtractor->getReadInfo($class, $property, [ + 'enable_getter_setter_extraction' => true, + 'enable_magic_call_extraction' => $this->magicCall, + 'enable_constructor_extraction' => false, + ]); if (isset($item)) { - $this->cacheItemPool->save($item->set($access)); + $this->cacheItemPool->save($item->set($accessor)); } - return $this->readPropertyCache[$key] = $access; + return $this->readPropertyCache[$key] = $accessor; } /** @@ -522,40 +477,42 @@ private function writeProperty(array $zval, string $property, $value) } $object = $zval[self::VALUE]; - $access = $this->getWriteAccessInfo(\get_class($object), $property, $value); - - if (self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE]) { - $object->{$access[self::ACCESS_NAME]}($value); - } elseif (self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE]) { - $object->{$access[self::ACCESS_NAME]} = $value; - } elseif (self::ACCESS_TYPE_ADDER_AND_REMOVER === $access[self::ACCESS_TYPE]) { - $this->writeCollection($zval, $property, $value, $access[self::ACCESS_ADDER], $access[self::ACCESS_REMOVER]); - } elseif (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) { - // Needed to support \stdClass instances. We need to explicitly - // exclude $access[self::ACCESS_HAS_PROPERTY], otherwise if - // a *protected* property was found on the class, property_exists() - // returns true, consequently the following line will result in a - // fatal error. - + $class = \get_class($object); + $mutator = $this->getWriteInfo($class, $property, $value); + + if (PropertyWriteInfo::TYPE_NONE !== $mutator->getType()) { + $type = $mutator->getType(); + + if (PropertyWriteInfo::TYPE_METHOD === $type) { + $object->{$mutator->getName()}($value); + } elseif (PropertyWriteInfo::TYPE_PROPERTY === $type) { + $object->{$mutator->getName()} = $value; + } elseif (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $type) { + $this->writeCollection($zval, $property, $value, $mutator->getAdderInfo(), $mutator->getRemoverInfo()); + } + } elseif ($object instanceof \stdClass && property_exists($object, $property)) { $object->$property = $value; - } elseif (self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]) { - $object->{$access[self::ACCESS_NAME]}($value); - } elseif (self::ACCESS_TYPE_NOT_FOUND === $access[self::ACCESS_TYPE]) { - throw new NoSuchPropertyException(sprintf('Could not determine access type for property "%s" in class "%s"%s', $property, \get_class($object), isset($access[self::ACCESS_NAME]) ? ': '.$access[self::ACCESS_NAME] : '.')); - } else { - throw new NoSuchPropertyException($access[self::ACCESS_NAME]); + } elseif (!$this->ignoreInvalidProperty) { + if ($mutator->hasErrors()) { + throw new NoSuchPropertyException(implode('. ', $mutator->getErrors()).'.'); + } + + throw new NoSuchPropertyException(sprintf('Could not determine access type for property "%s" in class "%s".', $property, \get_class($object))); } } /** * Adjusts a collection-valued property by calling add*() and remove*() methods. */ - private function writeCollection(array $zval, string $property, iterable $collection, string $addMethod, string $removeMethod) + private function writeCollection(array $zval, string $property, iterable $collection, PropertyWriteInfo $addMethod, PropertyWriteInfo $removeMethod) { // At this point the add and remove methods have been found $previousValue = $this->readProperty($zval, $property); $previousValue = $previousValue[self::VALUE]; + $removeMethodName = $removeMethod->getName(); + $addMethodName = $addMethod->getName(); + if ($previousValue instanceof \Traversable) { $previousValue = iterator_to_array($previousValue); } @@ -566,7 +523,7 @@ private function writeCollection(array $zval, string $property, iterable $collec foreach ($previousValue as $key => $item) { if (!\in_array($item, $collection, true)) { unset($previousValue[$key]); - $zval[self::VALUE]->{$removeMethod}($item); + $zval[self::VALUE]->$removeMethodName($item); } } } else { @@ -575,17 +532,12 @@ private function writeCollection(array $zval, string $property, iterable $collec foreach ($collection as $item) { if (!$previousValue || !\in_array($item, $previousValue, true)) { - $zval[self::VALUE]->{$addMethod}($item); + $zval[self::VALUE]->$addMethodName($item); } } } - /** - * Guesses how to write the property value. - * - * @param mixed $value - */ - private function getWriteAccessInfo(string $class, string $property, $value): array + private function getWriteInfo(string $class, string $property, $value): PropertyWriteInfo { $useAdderAndRemover = \is_array($value) || $value instanceof \Traversable; $key = str_replace('\\', '.', $class).'..'.$property.'..'.(int) $useAdderAndRemover; @@ -601,124 +553,18 @@ private function getWriteAccessInfo(string $class, string $property, $value): ar } } - $access = []; - - $reflClass = new \ReflectionClass($class); - $access[self::ACCESS_HAS_PROPERTY] = $reflClass->hasProperty($property); - $camelized = $this->camelize($property); - $singulars = (array) Inflector::singularize($camelized); - $errors = []; - - if ($useAdderAndRemover) { - foreach ($this->findAdderAndRemover($reflClass, $singulars) as $methods) { - if (3 === \count($methods)) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_ADDER_AND_REMOVER; - $access[self::ACCESS_ADDER] = $methods[self::ACCESS_ADDER]; - $access[self::ACCESS_REMOVER] = $methods[self::ACCESS_REMOVER]; - break; - } - - if (isset($methods[self::ACCESS_ADDER])) { - $errors[] = sprintf('The add method "%s" in class "%s" was found, but the corresponding remove method "%s" was not found', $methods['methods'][self::ACCESS_ADDER], $reflClass->name, $methods['methods'][self::ACCESS_REMOVER]); - } - - if (isset($methods[self::ACCESS_REMOVER])) { - $errors[] = sprintf('The remove method "%s" in class "%s" was found, but the corresponding add method "%s" was not found', $methods['methods'][self::ACCESS_REMOVER], $reflClass->name, $methods['methods'][self::ACCESS_ADDER]); - } - } - } - - if (!isset($access[self::ACCESS_TYPE])) { - $setter = 'set'.$camelized; - $getsetter = lcfirst($camelized); // jQuery style, e.g. read: last(), write: last($item) - - if ($this->isMethodAccessible($reflClass, $setter, 1)) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $setter; - } elseif ($this->isMethodAccessible($reflClass, $getsetter, 1)) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_METHOD; - $access[self::ACCESS_NAME] = $getsetter; - } elseif ($this->isMethodAccessible($reflClass, '__set', 2)) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; - $access[self::ACCESS_NAME] = $property; - } elseif ($access[self::ACCESS_HAS_PROPERTY] && $reflClass->getProperty($property)->isPublic()) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_PROPERTY; - $access[self::ACCESS_NAME] = $property; - } elseif ($this->magicCall && $this->isMethodAccessible($reflClass, '__call', 2)) { - // we call the getter and hope the __call do the job - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_MAGIC; - $access[self::ACCESS_NAME] = $setter; - } else { - foreach ($this->findAdderAndRemover($reflClass, $singulars) as $methods) { - if (3 === \count($methods)) { - $errors[] = sprintf( - 'The property "%s" in class "%s" can be defined with the methods "%s()" but '. - 'the new value must be an array or an instance of \Traversable, '. - '"%s" given.', - $property, - $reflClass->name, - implode('()", "', [$methods[self::ACCESS_ADDER], $methods[self::ACCESS_REMOVER]]), - \is_object($value) ? \get_class($value) : \gettype($value) - ); - } - } - - if (!isset($access[self::ACCESS_NAME])) { - $access[self::ACCESS_TYPE] = self::ACCESS_TYPE_NOT_FOUND; - - $triedMethods = [ - $setter => 1, - $getsetter => 1, - '__set' => 2, - '__call' => 2, - ]; - - foreach ($singulars as $singular) { - $triedMethods['add'.$singular] = 1; - $triedMethods['remove'.$singular] = 1; - } - - foreach ($triedMethods as $methodName => $parameters) { - if (!$reflClass->hasMethod($methodName)) { - continue; - } - - $method = $reflClass->getMethod($methodName); - - if (!$method->isPublic()) { - $errors[] = sprintf('The method "%s" in class "%s" was found but does not have public access', $methodName, $reflClass->name); - continue; - } - - if ($method->getNumberOfRequiredParameters() > $parameters || $method->getNumberOfParameters() < $parameters) { - $errors[] = sprintf('The method "%s" in class "%s" requires %d arguments, but should accept only %d', $methodName, $reflClass->name, $method->getNumberOfRequiredParameters(), $parameters); - } - } - - if (\count($errors)) { - $access[self::ACCESS_NAME] = implode('. ', $errors).'.'; - } else { - $access[self::ACCESS_NAME] = sprintf( - 'Neither the property "%s" nor one of the methods %s"%s()", "%s()", '. - '"__set()" or "__call()" exist and have public access in class "%s".', - $property, - implode('', array_map(function ($singular) { - return '"add'.$singular.'()"/"remove'.$singular.'()", '; - }, $singulars)), - $setter, - $getsetter, - $reflClass->name - ); - } - } - } - } + $mutator = $this->writeInfoExtractor->getWriteInfo($class, $property, [ + 'enable_getter_setter_extraction' => true, + 'enable_magic_call_extraction' => $this->magicCall, + 'enable_constructor_extraction' => false, + 'enable_adder_remover_extraction' => $useAdderAndRemover, + ]); if (isset($item)) { - $this->cacheItemPool->save($item->set($access)); + $this->cacheItemPool->save($item->set($mutator)); } - return $this->writePropertyCache[$key] = $access; + return $this->writePropertyCache[$key] = $mutator; } /** @@ -732,79 +578,15 @@ private function isPropertyWritable($object, string $property): bool return false; } - $access = $this->getWriteAccessInfo(\get_class($object), $property, []); - - $isWritable = self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE] - || self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE] - || self::ACCESS_TYPE_ADDER_AND_REMOVER === $access[self::ACCESS_TYPE] - || (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) - || self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]; + $mutatorForArray = $this->getWriteInfo(\get_class($object), $property, []); - if ($isWritable) { + if (PropertyWriteInfo::TYPE_NONE !== $mutatorForArray->getType() || ($object instanceof \stdClass && property_exists($object, $property))) { return true; } - $access = $this->getWriteAccessInfo(\get_class($object), $property, ''); - - return self::ACCESS_TYPE_METHOD === $access[self::ACCESS_TYPE] - || self::ACCESS_TYPE_PROPERTY === $access[self::ACCESS_TYPE] - || self::ACCESS_TYPE_ADDER_AND_REMOVER === $access[self::ACCESS_TYPE] - || (!$access[self::ACCESS_HAS_PROPERTY] && property_exists($object, $property)) - || self::ACCESS_TYPE_MAGIC === $access[self::ACCESS_TYPE]; - } - - /** - * Camelizes a given string. - */ - private function camelize(string $string): string - { - return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); - } - - /** - * Searches for add and remove methods. - */ - private function findAdderAndRemover(\ReflectionClass $reflClass, array $singulars): iterable - { - foreach ($singulars as $singular) { - $addMethod = 'add'.$singular; - $removeMethod = 'remove'.$singular; - $result = ['methods' => [self::ACCESS_ADDER => $addMethod, self::ACCESS_REMOVER => $removeMethod]]; - - $addMethodFound = $this->isMethodAccessible($reflClass, $addMethod, 1); - - if ($addMethodFound) { - $result[self::ACCESS_ADDER] = $addMethod; - } - - $removeMethodFound = $this->isMethodAccessible($reflClass, $removeMethod, 1); - - if ($removeMethodFound) { - $result[self::ACCESS_REMOVER] = $removeMethod; - } - - yield $result; - } - - return null; - } - - /** - * Returns whether a method is public and has the number of required parameters. - */ - private function isMethodAccessible(\ReflectionClass $class, string $methodName, int $parameters): bool - { - if ($class->hasMethod($methodName)) { - $method = $class->getMethod($methodName); - - if ($method->isPublic() - && $method->getNumberOfRequiredParameters() <= $parameters - && $method->getNumberOfParameters() >= $parameters) { - return true; - } - } + $mutator = $this->getWriteInfo(\get_class($object), $property, ''); - return false; + return PropertyWriteInfo::TYPE_NONE !== $mutator->getType() || ($object instanceof \stdClass && property_exists($object, $property)); } /** diff --git a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php index 09aebab87b13..18e51f33f275 100644 --- a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php +++ b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php @@ -188,7 +188,7 @@ public function testIsWritableReturnsFalseIfNoAdderNorRemoverExists() public function testSetValueFailsIfAdderAndRemoverExistButValueIsNotTraversable() { $this->expectException('Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException'); - $this->expectExceptionMessageRegExp('/Could not determine access type for property "axes" in class "Symfony\\\\Component\\\\PropertyAccess\\\\Tests\\\\PropertyAccessorCollectionTest_Car[^"]*": The property "axes" in class "Symfony\\\\Component\\\\PropertyAccess\\\\Tests\\\\PropertyAccessorCollectionTest_Car[^"]*" can be defined with the methods "addAxis\(\)", "removeAxis\(\)" but the new value must be an array or an instance of \\\\Traversable, "string" given./'); + $this->expectExceptionMessageRegExp('/The property "axes" in class "Symfony\\\Component\\\PropertyAccess\\\Tests\\\PropertyAccessorCollectionTest_Car" can be defined with the methods "addAxis\(\)", "removeAxis\(\)" but the new value must be an array or an instance of \\\Traversable\./'); $car = new PropertyAccessorCollectionTest_Car(); $this->propertyAccessor->setValue($car, 'axes', 'Not an array or Traversable'); diff --git a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php index 218f18730f16..70c3b681b76a 100644 --- a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php +++ b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php @@ -760,7 +760,7 @@ public function testRemoverWithoutAdder() public function testAdderAndRemoveNeedsTheExactParametersDefined() { $this->expectException('Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException'); - $this->expectExceptionMessageRegExp('/.*The method "addFoo" in class "Symfony\\\Component\\\PropertyAccess\\\Tests\\\Fixtures\\\TestAdderRemoverInvalidArgumentLength" requires 0 arguments, but should accept only 1\. The method "removeFoo" in class "Symfony\\\Component\\\PropertyAccess\\\Tests\\\Fixtures\\\TestAdderRemoverInvalidArgumentLength" requires 2 arguments, but should accept only 1\./'); + $this->expectExceptionMessageRegExp('/.*The method "addFoo" in class "Symfony\\\Component\\\PropertyAccess\\\Tests\\\Fixtures\\\TestAdderRemoverInvalidArgumentLength" requires 0 arguments, but should accept only 1\./'); $object = new TestAdderRemoverInvalidArgumentLength(); $this->propertyAccessor->setValue($object, 'foo', [1, 2]); } diff --git a/src/Symfony/Component/PropertyAccess/composer.json b/src/Symfony/Component/PropertyAccess/composer.json index 91c41273a708..411f8121d5fe 100644 --- a/src/Symfony/Component/PropertyAccess/composer.json +++ b/src/Symfony/Component/PropertyAccess/composer.json @@ -17,7 +17,8 @@ ], "require": { "php": "^7.2.5", - "symfony/inflector": "^4.4|^5.0" + "symfony/inflector": "^4.4|^5.0", + "symfony/property-info": "^5.1" }, "require-dev": { "symfony/cache": "^4.4|^5.0" diff --git a/src/Symfony/Component/PropertyInfo/CHANGELOG.md b/src/Symfony/Component/PropertyInfo/CHANGELOG.md index 19120c9f603b..2925a37a9447 100644 --- a/src/Symfony/Component/PropertyInfo/CHANGELOG.md +++ b/src/Symfony/Component/PropertyInfo/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.1.0 +----- + +* Add support for extracting accessor and mutator via PHP Reflection + 4.3.0 ----- diff --git a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php index b62dd25a75d0..29e4327e82f5 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php @@ -15,7 +15,11 @@ use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyReadInfo; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfo; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; use Symfony\Component\PropertyInfo\Type; /** @@ -25,7 +29,7 @@ * * @final */ -class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTypeExtractorInterface, PropertyAccessExtractorInterface, PropertyInitializableExtractorInterface +class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTypeExtractorInterface, PropertyAccessExtractorInterface, PropertyInitializableExtractorInterface, PropertyReadInfoExtractorInterface, PropertyWriteInfoExtractorInterface { /** * @internal @@ -56,7 +60,8 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp private $accessorPrefixes; private $arrayMutatorPrefixes; private $enableConstructorExtraction; - private $accessFlags; + private $methodReflectionFlags; + private $propertyReflectionFlags; /** * @param string[]|null $mutatorPrefixes @@ -69,7 +74,8 @@ public function __construct(array $mutatorPrefixes = null, array $accessorPrefix $this->accessorPrefixes = null !== $accessorPrefixes ? $accessorPrefixes : self::$defaultAccessorPrefixes; $this->arrayMutatorPrefixes = null !== $arrayMutatorPrefixes ? $arrayMutatorPrefixes : self::$defaultArrayMutatorPrefixes; $this->enableConstructorExtraction = $enableConstructorExtraction; - $this->accessFlags = $accessFlags; + $this->methodReflectionFlags = $this->getMethodsFlags($accessFlags); + $this->propertyReflectionFlags = $this->getPropertyFlags($accessFlags); } /** @@ -83,34 +89,16 @@ public function getProperties(string $class, array $context = []): ?array return null; } - $propertyFlags = 0; - $methodFlags = 0; - - if ($this->accessFlags & self::ALLOW_PUBLIC) { - $propertyFlags = $propertyFlags | \ReflectionProperty::IS_PUBLIC; - $methodFlags = $methodFlags | \ReflectionMethod::IS_PUBLIC; - } - - if ($this->accessFlags & self::ALLOW_PRIVATE) { - $propertyFlags = $propertyFlags | \ReflectionProperty::IS_PRIVATE; - $methodFlags = $methodFlags | \ReflectionMethod::IS_PRIVATE; - } - - if ($this->accessFlags & self::ALLOW_PROTECTED) { - $propertyFlags = $propertyFlags | \ReflectionProperty::IS_PROTECTED; - $methodFlags = $methodFlags | \ReflectionMethod::IS_PROTECTED; - } - $reflectionProperties = $reflectionClass->getProperties(); $properties = []; foreach ($reflectionProperties as $reflectionProperty) { - if ($reflectionProperty->getModifiers() & $propertyFlags) { + if ($reflectionProperty->getModifiers() & $this->propertyReflectionFlags) { $properties[$reflectionProperty->name] = $reflectionProperty->name; } } - foreach ($reflectionClass->getMethods($methodFlags) as $reflectionMethod) { + foreach ($reflectionClass->getMethods($this->methodReflectionFlags) as $reflectionMethod) { if ($reflectionMethod->isStatic()) { continue; } @@ -176,9 +164,7 @@ public function isReadable(string $class, string $property, array $context = []) return true; } - list($reflectionMethod) = $this->getAccessorMethod($class, $property); - - return null !== $reflectionMethod; + return null !== $this->getReadInfo($class, $property, $context); } /** @@ -223,6 +209,163 @@ public function isInitializable(string $class, string $property, array $context return false; } + /** + * {@inheritdoc} + */ + public function getReadInfo(string $class, string $property, array $context = []): ?PropertyReadInfo + { + try { + $reflClass = new \ReflectionClass($class); + } catch (\ReflectionException $e) { + return null; + } + + $allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false; + $allowMagicCall = $context['enable_magic_call_extraction'] ?? false; + + $hasProperty = $reflClass->hasProperty($property); + $camelProp = $this->camelize($property); + $getsetter = lcfirst($camelProp); // jQuery style, e.g. read: last(), write: last($item) + + foreach ($this->accessorPrefixes as $prefix) { + $methodName = $prefix.$camelProp; + + if ($reflClass->hasMethod($methodName) && ($reflClass->getMethod($methodName)->getModifiers() & $this->methodReflectionFlags)) { + $method = $reflClass->getMethod($methodName); + + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $methodName, $this->getReadVisiblityForMethod($method), $method->isStatic(), false); + } + } + + if ($allowGetterSetter && $reflClass->hasMethod($getsetter) && ($reflClass->getMethod($getsetter)->getModifiers() & $this->methodReflectionFlags)) { + $method = $reflClass->getMethod($getsetter); + + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $getsetter, $this->getReadVisiblityForMethod($method), $method->isStatic(), false); + } + + if ($hasProperty && ($reflClass->getProperty($property)->getModifiers() & $this->propertyReflectionFlags)) { + $reflProperty = $reflClass->getProperty($property); + + return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, $this->getReadVisiblityForProperty($reflProperty), $reflProperty->isStatic(), true); + } + + if ($reflClass->hasMethod('__get') && ($reflClass->getMethod('__get')->getModifiers() & $this->methodReflectionFlags)) { + return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, PropertyReadInfo::VISIBILITY_PUBLIC, false, false); + } + + if ($allowMagicCall && $reflClass->hasMethod('__call') && ($reflClass->getMethod('__call')->getModifiers() & $this->methodReflectionFlags)) { + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, 'get'.$camelProp, PropertyReadInfo::VISIBILITY_PUBLIC, false, false); + } + + return null; + } + + /** + * {@inheritdoc} + */ + public function getWriteInfo(string $class, string $property, array $context = []): PropertyWriteInfo + { + try { + $reflClass = new \ReflectionClass($class); + } catch (\ReflectionException $e) { + return null; + } + + $allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false; + $allowMagicCall = $context['enable_magic_call_extraction'] ?? false; + $allowConstruct = $context['enable_constructor_extraction'] ?? $this->enableConstructorExtraction; + $allowAdderRemover = $context['enable_adder_remover_extraction'] ?? true; + + $camelized = $this->camelize($property); + $constructor = $reflClass->getConstructor(); + $singulars = (array) Inflector::singularize($camelized); + $errors = []; + + if (null !== $constructor && $allowConstruct) { + foreach ($constructor->getParameters() as $parameter) { + if ($parameter->getName() === $property) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_CONSTRUCTOR, $property); + } + } + } + + [$adderAccessName, $removerAccessName, $adderAndRemoverErrors] = $this->findAdderAndRemover($reflClass, $singulars); + if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { + $adderMethod = $reflClass->getMethod($adderAccessName); + $removerMethod = $reflClass->getMethod($removerAccessName); + + $mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER); + $mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisiblityForMethod($adderMethod), $adderMethod->isStatic())); + $mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisiblityForMethod($removerMethod), $removerMethod->isStatic())); + + return $mutator; + } else { + $errors = array_merge($errors, $adderAndRemoverErrors); + } + + foreach ($this->mutatorPrefixes as $mutatorPrefix) { + $methodName = $mutatorPrefix.$camelized; + + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, $methodName, 1); + if (!$accessible) { + $errors = array_merge($errors, $methodAccessibleErrors); + continue; + } + + $method = $reflClass->getMethod($methodName); + + if (!\in_array($mutatorPrefix, $this->arrayMutatorPrefixes, true)) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $methodName, $this->getWriteVisiblityForMethod($method), $method->isStatic()); + } + } + + $getsetter = lcfirst($camelized); + + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, $getsetter, 1); + if ($allowGetterSetter && $accessible) { + $method = $reflClass->getMethod($getsetter); + + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $getsetter, $this->getWriteVisiblityForMethod($method), $method->isStatic()); + } else { + $errors = array_merge($errors, $methodAccessibleErrors); + } + + if ($reflClass->hasProperty($property) && ($reflClass->getProperty($property)->getModifiers() & $this->propertyReflectionFlags)) { + $reflProperty = $reflClass->getProperty($property); + + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, $this->getWriteVisiblityForProperty($reflProperty), $reflProperty->isStatic()); + } + + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__set', 2); + if ($accessible) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, PropertyWriteInfo::VISIBILITY_PUBLIC, false); + } else { + $errors = array_merge($errors, $methodAccessibleErrors); + } + + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__call', 2); + if ($allowMagicCall && $accessible) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, 'set'.$camelized, PropertyWriteInfo::VISIBILITY_PUBLIC, false); + } else { + $errors = array_merge($errors, $methodAccessibleErrors); + } + + if (!$allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { + $errors = array_merge($errors, [sprintf( + 'The property "%s" in class "%s" can be defined with the methods "%s()" but '. + 'the new value must be an array or an instance of \Traversable', + $property, + $reflClass->getName(), + implode('()", "', [$adderAccessName, $removerAccessName]) + )]); + } + + $noneProperty = new PropertyWriteInfo(); + $noneProperty->setErrors($errors); + + return $noneProperty; + } + /** * @return Type[]|null */ @@ -360,19 +503,7 @@ private function isAllowedProperty(string $class, string $property): bool try { $reflectionProperty = new \ReflectionProperty($class, $property); - if ($this->accessFlags & self::ALLOW_PUBLIC && $reflectionProperty->isPublic()) { - return true; - } - - if ($this->accessFlags & self::ALLOW_PROTECTED && $reflectionProperty->isProtected()) { - return true; - } - - if ($this->accessFlags & self::ALLOW_PRIVATE && $reflectionProperty->isPrivate()) { - return true; - } - - return false; + return $reflectionProperty->getModifiers() & $this->propertyReflectionFlags; } catch (\ReflectionException $e) { // Return false if the property doesn't exist } @@ -465,4 +596,167 @@ private function getPropertyName(string $methodName, array $reflectionProperties return null; } + + /** + * 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 + { + if (!\is_array($this->arrayMutatorPrefixes) && 2 !== \count($this->arrayMutatorPrefixes)) { + return null; + } + + [$addPrefix, $removePrefix] = $this->arrayMutatorPrefixes; + $errors = []; + + foreach ($singulars as $singular) { + $addMethod = $addPrefix.$singular; + $removeMethod = $removePrefix.$singular; + + [$addMethodFound, $addMethodAccessibleErrors] = $this->isMethodAccessible($reflClass, $addMethod, 1); + [$removeMethodFound, $removeMethodAccessibleErrors] = $this->isMethodAccessible($reflClass, $removeMethod, 1); + $errors = array_merge($errors, $addMethodAccessibleErrors, $removeMethodAccessibleErrors); + + if ($addMethodFound && $removeMethodFound) { + return [$addMethod, $removeMethod, []]; + } elseif ($addMethodFound && !$removeMethodFound) { + $errors[] = sprintf('The add method "%s" in class "%s" was found, but the corresponding remove method "%s" was not found', $addMethod, $reflClass->getName(), $removeMethod); + } elseif (!$addMethodFound && $removeMethodFound) { + $errors[] = sprintf('The remove method "%s" in class "%s" was found, but the corresponding add method "%s" was not found', $removeMethod, $reflClass->getName(), $addMethod); + } + } + + return [null, null, $errors]; + } + + /** + * Returns whether a method is public and has the number of required parameters and errors. + */ + private function isMethodAccessible(\ReflectionClass $class, string $methodName, int $parameters): array + { + $errors = []; + + if ($class->hasMethod($methodName)) { + $method = $class->getMethod($methodName); + + if (\ReflectionMethod::IS_PUBLIC === $this->methodReflectionFlags && !$method->isPublic()) { + $errors[] = sprintf('The method "%s" in class "%s" was found but does not have public access.', $methodName, $class->getName()); + } elseif ($method->getNumberOfRequiredParameters() > $parameters || $method->getNumberOfParameters() < $parameters) { + $errors[] = sprintf('The method "%s" in class "%s" requires %d arguments, but should accept only %d.', $methodName, $class->getName(), $method->getNumberOfRequiredParameters(), $parameters); + } else { + return [true, $errors]; + } + } + + return [false, $errors]; + } + + /** + * Camelizes a given string. + */ + private function camelize(string $string): string + { + return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); + } + + /** + * Return allowed reflection method flags. + */ + private function getMethodsFlags(int $accessFlags): int + { + $methodFlags = 0; + + if ($accessFlags & self::ALLOW_PUBLIC) { + $methodFlags |= \ReflectionMethod::IS_PUBLIC; + } + + if ($accessFlags & self::ALLOW_PRIVATE) { + $methodFlags |= \ReflectionMethod::IS_PRIVATE; + } + + if ($accessFlags & self::ALLOW_PROTECTED) { + $methodFlags |= \ReflectionMethod::IS_PROTECTED; + } + + return $methodFlags; + } + + /** + * Return allowed reflection property flags. + */ + private function getPropertyFlags(int $accessFlags): int + { + $propertyFlags = 0; + + if ($accessFlags & self::ALLOW_PUBLIC) { + $propertyFlags |= \ReflectionProperty::IS_PUBLIC; + } + + if ($accessFlags & self::ALLOW_PRIVATE) { + $propertyFlags |= \ReflectionProperty::IS_PRIVATE; + } + + if ($accessFlags & self::ALLOW_PROTECTED) { + $propertyFlags |= \ReflectionProperty::IS_PROTECTED; + } + + return $propertyFlags; + } + + private function getReadVisiblityForProperty(\ReflectionProperty $reflectionProperty): string + { + if ($reflectionProperty->isPrivate()) { + return PropertyReadInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionProperty->isProtected()) { + return PropertyReadInfo::VISIBILITY_PROTECTED; + } + + return PropertyReadInfo::VISIBILITY_PUBLIC; + } + + private function getReadVisiblityForMethod(\ReflectionMethod $reflectionMethod): string + { + if ($reflectionMethod->isPrivate()) { + return PropertyReadInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionMethod->isProtected()) { + return PropertyReadInfo::VISIBILITY_PROTECTED; + } + + return PropertyReadInfo::VISIBILITY_PUBLIC; + } + + private function getWriteVisiblityForProperty(\ReflectionProperty $reflectionProperty): string + { + if ($reflectionProperty->isPrivate()) { + return PropertyWriteInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionProperty->isProtected()) { + return PropertyWriteInfo::VISIBILITY_PROTECTED; + } + + return PropertyWriteInfo::VISIBILITY_PUBLIC; + } + + private function getWriteVisiblityForMethod(\ReflectionMethod $reflectionMethod): string + { + if ($reflectionMethod->isPrivate()) { + return PropertyWriteInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionMethod->isProtected()) { + return PropertyWriteInfo::VISIBILITY_PROTECTED; + } + + return PropertyWriteInfo::VISIBILITY_PUBLIC; + } } diff --git a/src/Symfony/Component/PropertyInfo/PropertyReadInfo.php b/src/Symfony/Component/PropertyInfo/PropertyReadInfo.php new file mode 100644 index 000000000000..ae1035244479 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/PropertyReadInfo.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * The property read info tells how a property can be read. + * + * @author Joel Wurtz + * + * @internal + */ +final class PropertyReadInfo +{ + public const TYPE_METHOD = 'method'; + public const TYPE_PROPERTY = 'property'; + + public const VISIBILITY_PUBLIC = 'public'; + public const VISIBILITY_PROTECTED = 'protected'; + public const VISIBILITY_PRIVATE = 'private'; + + private $type; + + private $name; + + private $visibility; + + private $static; + + private $byRef; + + public function __construct(string $type, string $name, string $visibility, bool $static, bool $byRef) + { + $this->type = $type; + $this->name = $name; + $this->visibility = $visibility; + $this->static = $static; + $this->byRef = $byRef; + } + + /** + * Get type of access. + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get name of the access, which can be a method name or a property name, depending on the type. + */ + public function getName(): string + { + return $this->name; + } + + public function getVisibility(): string + { + return $this->visibility; + } + + public function isStatic(): bool + { + return $this->static; + } + + /** + * Whether this accessor can be accessed by reference. + */ + public function canBeReference(): bool + { + return $this->byRef; + } +} diff --git a/src/Symfony/Component/PropertyInfo/PropertyReadInfoExtractorInterface.php b/src/Symfony/Component/PropertyInfo/PropertyReadInfoExtractorInterface.php new file mode 100644 index 000000000000..816b2825d58b --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/PropertyReadInfoExtractorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Extract read information for the property of a class. + * + * @author Joel Wurtz + */ +interface PropertyReadInfoExtractorInterface +{ + /** + * Get read information object for a given property of a class. + */ + public function getReadInfo(string $class, string $property, array $context = []): ?PropertyReadInfo; +} diff --git a/src/Symfony/Component/PropertyInfo/PropertyWriteInfo.php b/src/Symfony/Component/PropertyInfo/PropertyWriteInfo.php new file mode 100644 index 000000000000..207003ea158b --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/PropertyWriteInfo.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * The write mutator defines how a property can be written. + * + * @author Joel Wurtz + * + * @internal + */ +final class PropertyWriteInfo +{ + public const TYPE_NONE = 'none'; + public const TYPE_METHOD = 'method'; + public const TYPE_PROPERTY = 'property'; + public const TYPE_ADDER_AND_REMOVER = 'adder_and_remover'; + public const TYPE_CONSTRUCTOR = 'constructor'; + + public const VISIBILITY_PUBLIC = 'public'; + public const VISIBILITY_PROTECTED = 'protected'; + public const VISIBILITY_PRIVATE = 'private'; + + private $type; + private $name; + private $visibility; + private $static; + private $adderInfo; + private $removerInfo; + private $errors = []; + + public function __construct(string $type = self::TYPE_NONE, string $name = null, string $visibility = null, bool $static = null) + { + $this->type = $type; + $this->name = $name; + $this->visibility = $visibility; + $this->static = $static; + } + + public function getType(): string + { + return $this->type; + } + + public function getName(): string + { + if (null === $this->name) { + throw new \LogicException("Calling getName() when having a mutator of type {$this->type} is not tolerated"); + } + + return $this->name; + } + + public function setAdderInfo(self $adderInfo): void + { + $this->adderInfo = $adderInfo; + } + + public function getAdderInfo(): self + { + if (null === $this->adderInfo) { + throw new \LogicException("Calling getAdderInfo() when having a mutator of type {$this->type} is not tolerated"); + } + + return $this->adderInfo; + } + + public function setRemoverInfo(self $removerInfo): void + { + $this->removerInfo = $removerInfo; + } + + public function getRemoverInfo(): self + { + if (null === $this->removerInfo) { + throw new \LogicException("Calling getRemoverInfo() when having a mutator of type {$this->type} is not tolerated"); + } + + return $this->removerInfo; + } + + public function getVisibility(): string + { + if (null === $this->visibility) { + throw new \LogicException("Calling getVisibility() when having a mutator of type {$this->type} is not tolerated"); + } + + return $this->visibility; + } + + public function isStatic(): bool + { + if (null === $this->static) { + throw new \LogicException("Calling isStatic() when having a mutator of type {$this->type} is not tolerated"); + } + + return $this->static; + } + + public function setErrors(array $errors): void + { + $this->errors = $errors; + } + + public function getErrors(): array + { + return $this->errors; + } + + public function hasErrors(): bool + { + return (bool) \count($this->errors); + } +} diff --git a/src/Symfony/Component/PropertyInfo/PropertyWriteInfoExtractorInterface.php b/src/Symfony/Component/PropertyInfo/PropertyWriteInfoExtractorInterface.php new file mode 100644 index 000000000000..f113463818e6 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/PropertyWriteInfoExtractorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Extract write information for the property of a class. + * + * @author Joel Wurtz + */ +interface PropertyWriteInfoExtractorInterface +{ + /** + * Get write information object for a given property of a class. + */ + public function getWriteInfo(string $class, string $property, array $context = []): ?PropertyWriteInfo; +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php index cf26b49b84e5..4f01159be28c 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php @@ -13,11 +13,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyReadInfo; +use Symfony\Component\PropertyInfo\PropertyWriteInfo; use Symfony\Component\PropertyInfo\Tests\Fixtures\AdderRemoverDummy; use Symfony\Component\PropertyInfo\Tests\Fixtures\DefaultValue; use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy; use Symfony\Component\PropertyInfo\Tests\Fixtures\NotInstantiable; use Symfony\Component\PropertyInfo\Tests\Fixtures\Php71Dummy; +use Symfony\Component\PropertyInfo\Tests\Fixtures\Php71DummyExtended; use Symfony\Component\PropertyInfo\Tests\Fixtures\Php71DummyExtended2; use Symfony\Component\PropertyInfo\Tests\Fixtures\Php74Dummy; use Symfony\Component\PropertyInfo\Type; @@ -272,7 +275,7 @@ public function testIsWritable($property, $expected) { $this->assertSame( $expected, - $this->extractor->isWritable('Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy', $property, []) + $this->extractor->isWritable(Dummy::class, $property, []) ); } @@ -367,6 +370,19 @@ public function constructorTypesProvider(): array ]; } + public function testNullOnPrivateProtectedAccessor() + { + $barAcessor = $this->extractor->getReadInfo(Dummy::class, 'bar'); + $barMutator = $this->extractor->getWriteInfo(Dummy::class, 'bar'); + $bazAcessor = $this->extractor->getReadInfo(Dummy::class, 'baz'); + $bazMutator = $this->extractor->getWriteInfo(Dummy::class, 'baz'); + + $this->assertNull($barAcessor); + $this->assertEquals(PropertyWriteInfo::TYPE_NONE, $barMutator->getType()); + $this->assertNull($bazAcessor); + $this->assertEquals(PropertyWriteInfo::TYPE_NONE, $bazMutator->getType()); + } + /** * @requires PHP 7.4 */ @@ -375,4 +391,107 @@ public function testTypedProperties(): void $this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)], $this->extractor->getTypes(Php74Dummy::class, 'dummy')); $this->assertEquals([new Type(Type::BUILTIN_TYPE_BOOL, true)], $this->extractor->getTypes(Php74Dummy::class, 'nullableBoolProp')); } + + /** + * @dataProvider readAccessorProvider + */ + public function testGetReadAccessor($class, $property, $found, $type, $name, $visibility, $static) + { + $extractor = new ReflectionExtractor(null, null, null, true, ReflectionExtractor::ALLOW_PUBLIC | ReflectionExtractor::ALLOW_PROTECTED | ReflectionExtractor::ALLOW_PRIVATE); + $readAcessor = $extractor->getReadInfo($class, $property); + + if (!$found) { + $this->assertNull($readAcessor); + + return; + } + + $this->assertNotNull($readAcessor); + $this->assertSame($type, $readAcessor->getType()); + $this->assertSame($name, $readAcessor->getName()); + $this->assertSame($visibility, $readAcessor->getVisibility()); + $this->assertSame($static, $readAcessor->isStatic()); + } + + public function readAccessorProvider(): array + { + return [ + [Dummy::class, 'bar', true, PropertyReadInfo::TYPE_PROPERTY, 'bar', PropertyReadInfo::VISIBILITY_PRIVATE, false], + [Dummy::class, 'baz', true, PropertyReadInfo::TYPE_PROPERTY, 'baz', PropertyReadInfo::VISIBILITY_PROTECTED, false], + [Dummy::class, 'bal', true, PropertyReadInfo::TYPE_PROPERTY, 'bal', PropertyReadInfo::VISIBILITY_PUBLIC, false], + [Dummy::class, 'parent', true, PropertyReadInfo::TYPE_PROPERTY, 'parent', PropertyReadInfo::VISIBILITY_PUBLIC, false], + [Dummy::class, 'static', true, PropertyReadInfo::TYPE_METHOD, 'getStatic', PropertyReadInfo::VISIBILITY_PUBLIC, true], + [Dummy::class, 'foo', true, PropertyReadInfo::TYPE_PROPERTY, 'foo', PropertyReadInfo::VISIBILITY_PUBLIC, false], + [Php71Dummy::class, 'foo', true, PropertyReadInfo::TYPE_METHOD, 'getFoo', PropertyReadInfo::VISIBILITY_PUBLIC, false], + [Php71Dummy::class, 'buz', true, PropertyReadInfo::TYPE_METHOD, 'getBuz', PropertyReadInfo::VISIBILITY_PUBLIC, false], + ]; + } + + /** + * @dataProvider writeMutatorProvider + */ + public function testGetWriteMutator($class, $property, $allowConstruct, $found, $type, $name, $addName, $removeName, $visibility, $static) + { + $extractor = new ReflectionExtractor(null, null, null, true, ReflectionExtractor::ALLOW_PUBLIC | ReflectionExtractor::ALLOW_PROTECTED | ReflectionExtractor::ALLOW_PRIVATE); + $writeMutator = $extractor->getWriteInfo($class, $property, [ + 'enable_constructor_extraction' => $allowConstruct, + 'enable_getter_setter_extraction' => true, + ]); + + if (!$found) { + $this->assertEquals(PropertyWriteInfo::TYPE_NONE, $writeMutator->getType()); + + return; + } + + $this->assertNotNull($writeMutator); + $this->assertSame($type, $writeMutator->getType()); + + if (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $writeMutator->getType()) { + $this->assertNotNull($writeMutator->getAdderInfo()); + $this->assertSame($addName, $writeMutator->getAdderInfo()->getName()); + $this->assertNotNull($writeMutator->getRemoverInfo()); + $this->assertSame($removeName, $writeMutator->getRemoverInfo()->getName()); + } + + if (PropertyWriteInfo::TYPE_CONSTRUCTOR === $writeMutator->getType()) { + $this->assertSame($name, $writeMutator->getName()); + } + + if (PropertyWriteInfo::TYPE_PROPERTY === $writeMutator->getType()) { + $this->assertSame($name, $writeMutator->getName()); + $this->assertSame($visibility, $writeMutator->getVisibility()); + $this->assertSame($static, $writeMutator->isStatic()); + } + + if (PropertyWriteInfo::TYPE_METHOD === $writeMutator->getType()) { + $this->assertSame($name, $writeMutator->getName()); + $this->assertSame($visibility, $writeMutator->getVisibility()); + $this->assertSame($static, $writeMutator->isStatic()); + } + } + + public function writeMutatorProvider(): array + { + return [ + [Dummy::class, 'bar', false, true, PropertyWriteInfo::TYPE_PROPERTY, 'bar', null, null, PropertyWriteInfo::VISIBILITY_PRIVATE, false], + [Dummy::class, 'baz', false, true, PropertyWriteInfo::TYPE_PROPERTY, 'baz', null, null, PropertyWriteInfo::VISIBILITY_PROTECTED, false], + [Dummy::class, 'bal', false, true, PropertyWriteInfo::TYPE_PROPERTY, 'bal', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Dummy::class, 'parent', false, true, PropertyWriteInfo::TYPE_PROPERTY, 'parent', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Dummy::class, 'staticSetter', false, true, PropertyWriteInfo::TYPE_METHOD, 'staticSetter', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, true], + [Dummy::class, 'foo', false, true, PropertyWriteInfo::TYPE_PROPERTY, 'foo', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71Dummy::class, 'bar', false, true, PropertyWriteInfo::TYPE_METHOD, 'setBar', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71Dummy::class, 'string', false, false, '', '', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71Dummy::class, 'string', true, true, PropertyWriteInfo::TYPE_CONSTRUCTOR, 'string', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71Dummy::class, 'baz', false, true, PropertyWriteInfo::TYPE_ADDER_AND_REMOVER, null, 'addBaz', 'removeBaz', PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended::class, 'bar', false, true, PropertyWriteInfo::TYPE_METHOD, 'setBar', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended::class, 'string', false, false, -1, '', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended::class, 'string', true, true, PropertyWriteInfo::TYPE_CONSTRUCTOR, 'string', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended::class, 'baz', false, true, PropertyWriteInfo::TYPE_ADDER_AND_REMOVER, null, 'addBaz', 'removeBaz', PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended2::class, 'bar', false, true, PropertyWriteInfo::TYPE_METHOD, 'setBar', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended2::class, 'string', false, false, '', '', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended2::class, 'string', true, false, '', '', null, null, PropertyWriteInfo::VISIBILITY_PUBLIC, false], + [Php71DummyExtended2::class, 'baz', false, true, PropertyWriteInfo::TYPE_ADDER_AND_REMOVER, null, 'addBaz', 'removeBaz', PropertyWriteInfo::VISIBILITY_PUBLIC, false], + ]; + } } diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Php71Dummy.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Php71Dummy.php index 80012f968d70..59d0dbcb8053 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Php71Dummy.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/Php71Dummy.php @@ -35,6 +35,10 @@ public function setBar(?int $bar) public function addBaz(string $baz) { } + + public function removeBaz(string $baz) + { + } } class Php71DummyExtended extends Php71Dummy