[PropertyAccess] Added isReadable() and isWritable()

This commit is contained in:
Bernhard Schussek 2014-03-28 17:21:37 +01:00
parent 20e6bf8f49
commit 6d2af217aa
6 changed files with 382 additions and 9 deletions

View File

@ -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
---------

View File

@ -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
------

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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));
}
}

View File

@ -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'));
}
}