From 6d2af217aafd03e3f1600ce0ebc9c30cf0a7fc70 Mon Sep 17 00:00:00 2001 From: Bernhard Schussek Date: Fri, 28 Mar 2014 17:21:37 +0100 Subject: [PATCH] [PropertyAccess] Added isReadable() and isWritable() --- UPGRADE-2.5.md | 43 +++++- .../Component/PropertyAccess/CHANGELOG.md | 1 + .../PropertyAccess/PropertyAccessor.php | 126 +++++++++++++++- .../PropertyAccessorInterface.php | 31 +++- .../Tests/PropertyAccessorCollectionTest.php | 54 ++++++- .../Tests/PropertyAccessorTest.php | 136 ++++++++++++++++++ 6 files changed, 382 insertions(+), 9 deletions(-) diff --git a/UPGRADE-2.5.md b/UPGRADE-2.5.md index e3b581b5b9..f1afa9495b 100644 --- a/UPGRADE-2.5.md +++ b/UPGRADE-2.5.md @@ -45,6 +45,47 @@ Form { ``` +PropertyAccess +-------------- + + * The methods `isReadable()` and `isWritable()` were added to + `PropertyAccessorInterface`. If you implemented this interface in your own + code, you should add these two methods. + + * The methods `getValue()` and `setValue()` now throw an + `NoSuchIndexException` instead of a `NoSuchPropertyException` when an index + is accessed on an object that does not implement `ArrayAccess`. If you catch + this exception in your code, you should adapt the catch statement: + + Before: + + ```php + $object = new \stdClass(); + + try { + $propertyAccessor->getValue($object, '[index]'); + $propertyAccessor->setValue($object, '[index]', 'New value'); + } catch (NoSuchPropertyException $e) { + // ... + } + ``` + + After: + + ```php + $object = new \stdClass(); + + try { + $propertyAccessor->getValue($object, '[index]'); + $propertyAccessor->setValue($object, '[index]', 'New value'); + } catch (NoSuchIndexException $e) { + // ... + } + ``` + + A `NoSuchPropertyException` is still thrown when a non-existing property is + accessed on an object or an array. + Validator --------- @@ -56,7 +97,7 @@ Validator After: - Default email validation is now done via a simple regex which may cause invalid emails (not RFC compilant) to be + Default email validation is now done via a simple regex which may cause invalid emails (not RFC compilant) to be valid. This is the default behaviour. Strict email validation has to be explicitly activated in the configuration file by adding diff --git a/src/Symfony/Component/PropertyAccess/CHANGELOG.md b/src/Symfony/Component/PropertyAccess/CHANGELOG.md index bfe3d51459..631c9d7256 100644 --- a/src/Symfony/Component/PropertyAccess/CHANGELOG.md +++ b/src/Symfony/Component/PropertyAccess/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * [BC BREAK] when accessing an index on an object that does not implement ArrayAccess, a NoSuchIndexException is now thrown instead of the semantically wrong NoSuchPropertyException + * [BC BREAK] added isReadable() and isWritable() to PropertyAccessorInterface 2.3.0 ------ diff --git a/src/Symfony/Component/PropertyAccess/PropertyAccessor.php b/src/Symfony/Component/PropertyAccess/PropertyAccessor.php index 66671c5be0..b21866bd68 100644 --- a/src/Symfony/Component/PropertyAccess/PropertyAccessor.php +++ b/src/Symfony/Component/PropertyAccess/PropertyAccessor.php @@ -105,6 +105,84 @@ class PropertyAccessor implements PropertyAccessorInterface } } + /** + * {@inheritdoc} + */ + public function isReadable($objectOrArray, $propertyPath) + { + if (is_string($propertyPath)) { + $propertyPath = new PropertyPath($propertyPath); + } elseif (!$propertyPath instanceof PropertyPathInterface) { + throw new UnexpectedTypeException($propertyPath, 'string or Symfony\Component\PropertyAccess\PropertyPathInterface'); + } + + try { + $this->readPropertiesUntil($objectOrArray, $propertyPath, $propertyPath->getLength(), $this->throwExceptionOnInvalidIndex); + + return true; + } catch (NoSuchIndexException $e) { + return false; + } catch (NoSuchPropertyException $e) { + return false; + } catch (UnexpectedTypeException $e) { + return false; + } + } + + /** + * {@inheritdoc} + */ + public function isWritable($objectOrArray, $propertyPath, $value) + { + if (is_string($propertyPath)) { + $propertyPath = new PropertyPath($propertyPath); + } elseif (!$propertyPath instanceof PropertyPathInterface) { + throw new UnexpectedTypeException($propertyPath, 'string or Symfony\Component\PropertyAccess\PropertyPathInterface'); + } + + try { + $propertyValues = $this->readPropertiesUntil($objectOrArray, $propertyPath, $propertyPath->getLength() - 1); + $overwrite = true; + + // Add the root object to the list + array_unshift($propertyValues, array( + self::VALUE => $objectOrArray, + self::IS_REF => true, + )); + + for ($i = count($propertyValues) - 1; $i >= 0; --$i) { + $objectOrArray = $propertyValues[$i][self::VALUE]; + + if ($overwrite) { + if (!is_object($objectOrArray) && !is_array($objectOrArray)) { + return false; + } + + $property = $propertyPath->getElement($i); + + if ($propertyPath->isIndex($i)) { + if (!$objectOrArray instanceof \ArrayAccess && !is_array($objectOrArray)) { + return false; + } + } else { + if (!$this->isPropertyWritable($objectOrArray, $property, $value)) { + return false; + } + } + } + + $value = $objectOrArray; + $overwrite = !$propertyValues[$i][self::IS_REF]; + } + + return true; + } catch (NoSuchIndexException $e) { + return false; + } catch (NoSuchPropertyException $e) { + return false; + } + } + /** * Reads the path from an object up to a given path index. * @@ -357,9 +435,9 @@ class PropertyAccessor implements PropertyAccessorInterface $setter = 'set'.$this->camelize($property); $classHasProperty = $reflClass->hasProperty($property); - if ($reflClass->hasMethod($setter) && $reflClass->getMethod($setter)->isPublic()) { + if ($this->isMethodAccessible($reflClass, $setter, 1)) { $object->$setter($value); - } elseif ($reflClass->hasMethod('__set') && $reflClass->getMethod('__set')->isPublic()) { + } elseif ($this->isMethodAccessible($reflClass, '__set', 2)) { $object->$property = $value; } elseif ($classHasProperty && $reflClass->getProperty($property)->isPublic()) { $object->$property = $value; @@ -370,7 +448,7 @@ class PropertyAccessor implements PropertyAccessorInterface // returns true, consequently the following line will result in a // fatal error. $object->$property = $value; - } elseif ($this->magicCall && $reflClass->hasMethod('__call') && $reflClass->getMethod('__call')->isPublic()) { + } elseif ($this->magicCall && $this->isMethodAccessible($reflClass, '__call', 2)) { // we call the getter and hope the __call do the job $object->$setter($value); } else { @@ -385,6 +463,38 @@ class PropertyAccessor implements PropertyAccessorInterface } } + private function isPropertyWritable($object, $property, $value) + { + if (!is_object($object)) { + throw new NoSuchPropertyException(sprintf('Cannot write property "%s" to an array. Maybe you should write the property path as "[%s]" instead?', $property, $property)); + } + + $reflClass = new \ReflectionClass($object); + $plural = $this->camelize($property); + + // Any of the two methods is required, but not yet known + $singulars = (array) StringUtil::singularify($plural); + + if (is_array($value) || $value instanceof \Traversable) { + try { + if (null !== $this->findAdderAndRemover($reflClass, $singulars)) { + return true; + } + } catch (NoSuchPropertyException $e) { + return false; + } + } + + $setter = 'set'.$this->camelize($property); + $classHasProperty = $reflClass->hasProperty($property); + + return $this->isMethodAccessible($reflClass, $setter, 1) + || $this->isMethodAccessible($reflClass, '__set', 2) + || ($classHasProperty && $reflClass->getProperty($property)->isPublic()) + || (!$classHasProperty && property_exists($object, $property)) + || ($this->magicCall && $this->isMethodAccessible($reflClass, '__call', 2)); + } + /** * Camelizes a given string. * @@ -409,6 +519,8 @@ class PropertyAccessor implements PropertyAccessorInterface */ private function findAdderAndRemover(\ReflectionClass $reflClass, array $singulars) { + $exception = null; + foreach ($singulars as $singular) { $addMethod = 'add'.$singular; $removeMethod = 'remove'.$singular; @@ -420,8 +532,8 @@ class PropertyAccessor implements PropertyAccessorInterface return array($addMethod, $removeMethod); } - if ($addMethodFound xor $removeMethodFound) { - throw new NoSuchPropertyException(sprintf( + if ($addMethodFound xor $removeMethodFound && null === $exception) { + $exception = new NoSuchPropertyException(sprintf( 'Found the public method "%s()", but did not find a public "%s()" on class %s', $addMethodFound ? $addMethod : $removeMethod, $addMethodFound ? $removeMethod : $addMethod, @@ -430,6 +542,10 @@ class PropertyAccessor implements PropertyAccessorInterface } } + if (null !== $exception) { + throw $exception; + } + return null; } diff --git a/src/Symfony/Component/PropertyAccess/PropertyAccessorInterface.php b/src/Symfony/Component/PropertyAccess/PropertyAccessorInterface.php index 1eed7c7b07..b9da0e4937 100644 --- a/src/Symfony/Component/PropertyAccess/PropertyAccessorInterface.php +++ b/src/Symfony/Component/PropertyAccess/PropertyAccessorInterface.php @@ -19,7 +19,7 @@ namespace Symfony\Component\PropertyAccess; interface PropertyAccessorInterface { /** - * Sets the value at the end of the property path of the object + * Sets the value at the end of the property path of the object graph. * * Example: * @@ -50,7 +50,7 @@ interface PropertyAccessorInterface public function setValue(&$objectOrArray, $propertyPath, $value); /** - * Returns the value at the end of the property path of the object + * Returns the value at the end of the property path of the object graph. * * Example: * @@ -78,4 +78,31 @@ interface PropertyAccessorInterface * @throws Exception\NoSuchPropertyException If a property does not exist or is not public. */ public function getValue($objectOrArray, $propertyPath); + + /** + * Returns whether a value can be written at a given property path. + * + * Whenever this method returns true, {@link setValue()} is guaranteed not + * to throw an exception when called with the same arguments. + * + * @param object|array $objectOrArray The object or array to check + * @param string|PropertyPathInterface $propertyPath The property path to check + * @param mixed $value The value to set at the end of the property path + * + * @return Boolean Whether the value can be set + */ + public function isWritable($objectOrArray, $propertyPath, $value); + + /** + * Returns whether a property path can be read from an object graph. + * + * Whenever this method returns true, {@link getValue()} is guaranteed not + * to throw an exception when called with the same arguments. + * + * @param object|array $objectOrArray The object or array to check + * @param string|PropertyPathInterface $propertyPath The property path to check + * + * @return Boolean Whether the property path can be read + */ + public function isReadable($objectOrArray, $propertyPath); } diff --git a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php index 808c9e1449..cd51f2601c 100644 --- a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php +++ b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorCollectionTest.php @@ -210,11 +210,63 @@ abstract class PropertyAccessorCollectionTest extends \PHPUnit_Framework_TestCas * @expectedException \Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException * @expectedExceptionMessage Neither the property "axes" nor one of the methods "addAx()", "addAxe()", "addAxis()", "setAxes()", "__set()" or "__call()" exist and have public access in class "Mock_PropertyAccessorCollectionTest_CarNoAdderAndRemover */ - public function testSetValueFailsIfNoAdderAndNoRemoverFound() + public function testSetValueFailsIfNoAdderNorRemoverFound() { $car = $this->getMock(__CLASS__.'_CarNoAdderAndRemover'); $axes = $this->getCollection(array(0 => 'first', 1 => 'second', 2 => 'third')); $this->propertyAccessor->setValue($car, 'axes', $axes); } + + /** + * @dataProvider getValidPropertyPaths + */ + public function testIsReadable(array $array, $path) + { + $collection = $this->getCollection($array); + + $this->assertTrue($this->propertyAccessor->isReadable($collection, $path)); + } + + /** + * @dataProvider getValidPropertyPaths + */ + public function testIsWritable(array $array, $path) + { + $collection = $this->getCollection($array); + + $this->assertTrue($this->propertyAccessor->isWritable($collection, $path, 'Updated')); + } + + public function testIsWritableReturnsTrueIfAdderAndRemoverExists() + { + $car = $this->getMock(__CLASS__.'_Car'); + $axes = $this->getCollection(array(1 => 'first', 2 => 'second', 3 => 'third')); + + $this->assertTrue($this->propertyAccessor->isWritable($car, 'axes', $axes)); + } + + public function testIsWritableReturnsFalseIfOnlyAdderExists() + { + $car = $this->getMock(__CLASS__.'_CarOnlyAdder'); + $axes = $this->getCollection(array(1 => 'first', 2 => 'second', 3 => 'third')); + + $this->assertFalse($this->propertyAccessor->isWritable($car, 'axes', $axes)); + } + + public function testIsWritableReturnsFalseIfOnlyRemoverExists() + { + $car = $this->getMock(__CLASS__.'_CarOnlyRemover'); + $axes = $this->getCollection(array(1 => 'first', 2 => 'second', 3 => 'third')); + + $this->assertFalse($this->propertyAccessor->isWritable($car, 'axes', $axes)); + } + + public function testIsWritableReturnsFalseIfNoAdderNorRemoverExists() + { + $car = $this->getMock(__CLASS__.'_CarNoAdderAndRemover'); + $axes = $this->getCollection(array(1 => 'first', 2 => 'second', 3 => 'third')); + + $this->assertFalse($this->propertyAccessor->isWritable($car, 'axes', $axes)); + } } diff --git a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php index 2d8b97dc57..a40c2d9ffa 100644 --- a/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php +++ b/src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php @@ -301,4 +301,140 @@ class PropertyAccessorTest extends \PHPUnit_Framework_TestCase $this->propertyAccessor->setValue($value, 'foobar', 'bam'); } + + /** + * @dataProvider getValidPropertyPaths + */ + public function testIsReadable($objectOrArray, $path) + { + $this->assertTrue($this->propertyAccessor->isReadable($objectOrArray, $path)); + } + + /** + * @dataProvider getPathsWithMissingProperty + */ + public function testIsReadableReturnsFalseIfPropertyNotFound($objectOrArray, $path) + { + $this->assertFalse($this->propertyAccessor->isReadable($objectOrArray, $path)); + } + + /** + * @dataProvider getPathsWithMissingIndex + */ + public function testIsReadableReturnsTrueIfIndexNotFound($objectOrArray, $path) + { + // Non-existing indices can be read. In this case, null is returned + $this->assertTrue($this->propertyAccessor->isReadable($objectOrArray, $path)); + } + + /** + * @dataProvider getPathsWithMissingIndex + */ + public function testIsReadableReturnsFalseIfIndexNotFoundAndIndexExceptionsEnabled($objectOrArray, $path) + { + $this->propertyAccessor = new PropertyAccessor(false, true); + + // When exceptions are enabled, non-existing indices cannot be read + $this->assertFalse($this->propertyAccessor->isReadable($objectOrArray, $path)); + } + + public function testIsReadableRecognizesMagicGet() + { + $this->assertTrue($this->propertyAccessor->isReadable(new TestClassMagicGet('Bernhard'), 'magicProperty')); + } + + public function testIsReadableDoesNotRecognizeMagicCallByDefault() + { + $this->assertFalse($this->propertyAccessor->isReadable(new TestClassMagicCall('Bernhard'), 'magicCallProperty')); + } + + public function testIsReadableRecognizesMagicCallIfEnabled() + { + $this->propertyAccessor = new PropertyAccessor(true); + + $this->assertTrue($this->propertyAccessor->isReadable(new TestClassMagicCall('Bernhard'), 'magicCallProperty')); + } + + public function testIsReadableThrowsExceptionIfNotObjectOrArray() + { + $this->assertFalse($this->propertyAccessor->isReadable('baz', 'foobar')); + } + + public function testIsReadableThrowsExceptionIfNull() + { + $this->assertFalse($this->propertyAccessor->isReadable(null, 'foobar')); + } + + public function testIsReadableThrowsExceptionIfEmpty() + { + $this->assertFalse($this->propertyAccessor->isReadable('', 'foobar')); + } + + /** + * @dataProvider getValidPropertyPaths + */ + public function testIsWritable($objectOrArray, $path) + { + $this->assertTrue($this->propertyAccessor->isWritable($objectOrArray, $path, 'Updated')); + } + + /** + * @dataProvider getPathsWithMissingProperty + */ + public function testIsWritableReturnsFalseIfPropertyNotFound($objectOrArray, $path) + { + $this->assertFalse($this->propertyAccessor->isWritable($objectOrArray, $path, 'Updated')); + } + + /** + * @dataProvider getPathsWithMissingIndex + */ + public function testIsWritableReturnsTrueIfIndexNotFound($objectOrArray, $path) + { + // Non-existing indices can be written. Arrays are created on-demand. + $this->assertTrue($this->propertyAccessor->isWritable($objectOrArray, $path, 'Updated')); + } + + /** + * @dataProvider getPathsWithMissingIndex + */ + public function testIsWritableReturnsTrueIfIndexNotFoundAndIndexExceptionsEnabled($objectOrArray, $path) + { + $this->propertyAccessor = new PropertyAccessor(false, true); + + // Non-existing indices can be written even if exceptions are enabled + $this->assertTrue($this->propertyAccessor->isWritable($objectOrArray, $path, 'Updated')); + } + + public function testIsWritableRecognizesMagicSet() + { + $this->assertTrue($this->propertyAccessor->isWritable(new TestClassMagicGet('Bernhard'), 'magicProperty', 'Updated')); + } + + public function testIsWritableDoesNotRecognizeMagicCallByDefault() + { + $this->assertFalse($this->propertyAccessor->isWritable(new TestClassMagicCall('Bernhard'), 'magicCallProperty', 'Updated')); + } + + public function testIsWritableRecognizesMagicCallIfEnabled() + { + $this->propertyAccessor = new PropertyAccessor(true); + + $this->assertTrue($this->propertyAccessor->isWritable(new TestClassMagicCall('Bernhard'), 'magicCallProperty', 'Updated')); + } + + public function testIsWritableThrowsExceptionIfNotObjectOrArray() + { + $this->assertFalse($this->propertyAccessor->isWritable('baz', 'foobar', 'Updated')); + } + + public function testIsWritableThrowsExceptionIfNull() + { + $this->assertFalse($this->propertyAccessor->isWritable(null, 'foobar', 'Updated')); + } + + public function testIsWritableThrowsExceptionIfEmpty() + { + $this->assertFalse($this->propertyAccessor->isWritable('', 'foobar', 'Updated')); + } }