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

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