diff --git a/src/Symfony/Component/Form/Tests/Util/PropertyPathTest.php b/src/Symfony/Component/Form/Tests/Util/PropertyPathTest.php index 0b6326da72..299b93c274 100644 --- a/src/Symfony/Component/Form/Tests/Util/PropertyPathTest.php +++ b/src/Symfony/Component/Form/Tests/Util/PropertyPathTest.php @@ -21,25 +21,28 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase { $array = array('firstName' => 'Bernhard'); - $path = new PropertyPath('firstName'); + $path = new PropertyPath('[firstName]'); $this->assertEquals('Bernhard', $path->getValue($array)); } - public function testGetValueIgnoresSingular() + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyException + */ + public function testGetValueThrowsExceptionIfIndexNotationExpected() { - $array = array('children' => 'Many'); + $array = array('firstName' => 'Bernhard'); - $path = new PropertyPath('children|child'); + $path = new PropertyPath('firstName'); - $this->assertEquals('Many', $path->getValue($array)); + $path->getValue($array); } public function testGetValueReadsZeroIndex() { $array = array('Bernhard'); - $path = new PropertyPath('0'); + $path = new PropertyPath('[0]'); $this->assertEquals('Bernhard', $path->getValue($array)); } @@ -53,20 +56,11 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('Bernhard', $path->getValue($array)); } - public function testGetValueReadsElementWithSpecialCharsExceptDot() - { - $array = array('%!@$§' => 'Bernhard'); - - $path = new PropertyPath('%!@$§'); - - $this->assertEquals('Bernhard', $path->getValue($array)); - } - public function testGetValueReadsNestedIndexWithSpecialChars() { $array = array('root' => array('%!@$§.' => 'Bernhard')); - $path = new PropertyPath('root[%!@$§.]'); + $path = new PropertyPath('[root][%!@$§.]'); $this->assertEquals('Bernhard', $path->getValue($array)); } @@ -75,7 +69,7 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase { $array = array('child' => array('index' => array('firstName' => 'Bernhard'))); - $path = new PropertyPath('child[index].firstName'); + $path = new PropertyPath('[child][index][firstName]'); $this->assertEquals('Bernhard', $path->getValue($array)); } @@ -84,7 +78,7 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase { $array = array('child' => array('index' => array())); - $path = new PropertyPath('child[index].firstName'); + $path = new PropertyPath('[child][index][firstName]'); $this->assertNull($path->getValue($array)); } @@ -99,6 +93,24 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('Bernhard', $path->getValue($object)); } + public function testGetValueIgnoresSingular() + { + $object = (object) array('children' => 'Many'); + + $path = new PropertyPath('children|child'); + + $this->assertEquals('Many', $path->getValue($object)); + } + + public function testGetValueReadsPropertyWithSpecialCharsExceptDot() + { + $array = (object) array('%!@$§' => 'Bernhard'); + + $path = new PropertyPath('%!@$§'); + + $this->assertEquals('Bernhard', $path->getValue($array)); + } + public function testGetValueReadsPropertyWithCustomPropertyPath() { $object = new Author(); @@ -121,21 +133,23 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('Bernhard', $path->getValue($object)); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyException + */ public function testGetValueThrowsExceptionIfArrayAccessExpected() { $path = new PropertyPath('[firstName]'); - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyException'); - $path->getValue(new Author()); } + /** + * @expectedException Symfony\Component\Form\Exception\PropertyAccessDeniedException + */ public function testGetValueThrowsExceptionIfPropertyIsNotPublic() { $path = new PropertyPath('privateProperty'); - $this->setExpectedException('Symfony\Component\Form\Exception\PropertyAccessDeniedException'); - $path->getValue(new Author()); } @@ -159,12 +173,13 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('Schussek', $path->getValue($object)); } + /** + * @expectedException Symfony\Component\Form\Exception\PropertyAccessDeniedException + */ public function testGetValueThrowsExceptionIfGetterIsNotPublic() { $path = new PropertyPath('privateGetter'); - $this->setExpectedException('Symfony\Component\Form\Exception\PropertyAccessDeniedException'); - $path->getValue(new Author()); } @@ -198,48 +213,53 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertSame('foobar', $path->getValue($object)); } + /** + * @expectedException Symfony\Component\Form\Exception\PropertyAccessDeniedException + */ public function testGetValueThrowsExceptionIfIsserIsNotPublic() { $path = new PropertyPath('privateIsser'); - $this->setExpectedException('Symfony\Component\Form\Exception\PropertyAccessDeniedException'); - $path->getValue(new Author()); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyException + */ public function testGetValueThrowsExceptionIfPropertyDoesNotExist() { $path = new PropertyPath('foobar'); - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyException'); - $path->getValue(new Author()); } + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ public function testGetValueThrowsExceptionIfNotObjectOrArray() { $path = new PropertyPath('foobar'); - $this->setExpectedException('Symfony\Component\Form\Exception\UnexpectedTypeException'); - $path->getValue('baz'); } + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ public function testGetValueThrowsExceptionIfNull() { $path = new PropertyPath('foobar'); - $this->setExpectedException('Symfony\Component\Form\Exception\UnexpectedTypeException'); - $path->getValue(null); } + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ public function testGetValueThrowsExceptionIfEmpty() { $path = new PropertyPath('foobar'); - $this->setExpectedException('Symfony\Component\Form\Exception\UnexpectedTypeException'); - $path->getValue(''); } @@ -247,17 +267,28 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase { $array = array(); - $path = new PropertyPath('firstName'); + $path = new PropertyPath('[firstName]'); $path->setValue($array, 'Bernhard'); $this->assertEquals(array('firstName' => 'Bernhard'), $array); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyException + */ + public function testSetValueThrowsExceptionIfIndexNotationExpected() + { + $array = array(); + + $path = new PropertyPath('firstName'); + $path->setValue($array, 'Bernhard'); + } + public function testSetValueUpdatesArraysWithCustomPropertyPath() { $array = array(); - $path = new PropertyPath('child[index].firstName'); + $path = new PropertyPath('[child][index][firstName]'); $path->setValue($array, 'Bernhard'); $this->assertEquals(array('child' => array('index' => array('firstName' => 'Bernhard'))), $array); @@ -305,12 +336,13 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('foobar', $object->__get('magicProperty')); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyException + */ public function testSetValueThrowsExceptionIfArrayAccessExpected() { $path = new PropertyPath('[firstName]'); - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyException'); - $path->setValue(new Author(), 'Bernhard'); } @@ -334,42 +366,46 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('Schussek', $object->getLastName()); } + /** + * @expectedException Symfony\Component\Form\Exception\PropertyAccessDeniedException + */ public function testSetValueThrowsExceptionIfGetterIsNotPublic() { $path = new PropertyPath('privateSetter'); - $this->setExpectedException('Symfony\Component\Form\Exception\PropertyAccessDeniedException'); - $path->setValue(new Author(), 'foobar'); } + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ public function testSetValueThrowsExceptionIfNotObjectOrArray() { $path = new PropertyPath('foobar'); $value = 'baz'; - $this->setExpectedException('Symfony\Component\Form\Exception\UnexpectedTypeException'); - $path->setValue($value, 'bam'); } + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ public function testSetValueThrowsExceptionIfNull() { $path = new PropertyPath('foobar'); $value = null; - $this->setExpectedException('Symfony\Component\Form\Exception\UnexpectedTypeException'); - $path->setValue($value, 'bam'); } + /** + * @expectedException Symfony\Component\Form\Exception\UnexpectedTypeException + */ public function testSetValueThrowsExceptionIfEmpty() { $path = new PropertyPath('foobar'); $value = ''; - $this->setExpectedException('Symfony\Component\Form\Exception\UnexpectedTypeException'); - $path->setValue($value, 'bam'); } @@ -380,31 +416,35 @@ class PropertyPathTest extends \PHPUnit_Framework_TestCase $this->assertEquals('reference.traversable[index].property', $path->__toString()); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyPathException + */ public function testInvalidPropertyPath_noDotBeforeProperty() { - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyPathException'); - new PropertyPath('[index]property'); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyPathException + */ public function testInvalidPropertyPath_dotAtTheBeginning() { - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyPathException'); - new PropertyPath('.property'); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyPathException + */ public function testInvalidPropertyPath_unexpectedCharacters() { - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyPathException'); - new PropertyPath('property.$form'); } + /** + * @expectedException Symfony\Component\Form\Exception\InvalidPropertyPathException + */ public function testInvalidPropertyPath_null() { - $this->setExpectedException('Symfony\Component\Form\Exception\InvalidPropertyPathException'); - new PropertyPath(null); } diff --git a/src/Symfony/Component/Form/Util/PropertyPath.php b/src/Symfony/Component/Form/Util/PropertyPath.php index 1d052a0156..8371b6f873 100644 --- a/src/Symfony/Component/Form/Util/PropertyPath.php +++ b/src/Symfony/Component/Form/Util/PropertyPath.php @@ -69,9 +69,11 @@ class PropertyPath implements \IteratorAggregate private $positions; /** - * Parses the given property path + * Constructs a property path from a string. * - * @param string $propertyPath + * @param string $propertyPath The property path as string. + * + * @throws InvalidPropertyPathException If the syntax of the property path is not valid. */ public function __construct($propertyPath) { @@ -258,25 +260,7 @@ class PropertyPath implements \IteratorAggregate */ public function getValue($objectOrArray) { - for ($i = 0; $i < $this->length; ++$i) { - if (is_object($objectOrArray)) { - $value = $this->readProperty($objectOrArray, $i); - // arrays need to be treated separately (due to PHP bug?) - // http://bugs.php.net/bug.php?id=52133 - } elseif (is_array($objectOrArray)) { - $property = $this->elements[$i]; - if (!array_key_exists($property, $objectOrArray)) { - $objectOrArray[$property] = $i + 1 < $this->length ? array() : null; - } - $value =& $objectOrArray[$property]; - } else { - throw new UnexpectedTypeException($objectOrArray, 'object or array'); - } - - $objectOrArray =& $value; - } - - return $value; + return $this->readPropertyAt($objectOrArray, $this->length - 1); } /** @@ -299,63 +283,89 @@ class PropertyPath implements \IteratorAggregate * * If neither is found, an exception is thrown. * - * @param object|array $objectOrArray The object or array to traverse - * @param mixed $value The value at the end of the property path + * @param object|array $objectOrArray The object or array to modify. + * @param mixed $value The value to set at the end of the property path. * - * @throws InvalidPropertyException If the property/setter does not exist - * @throws PropertyAccessDeniedException If the property/setter exists but is not public + * @throws InvalidPropertyException If a property does not exist. + * @throws PropertyAccessDeniedException If a property cannot be accessed due to + * access restrictions (private or protected). + * @throws UnexpectedTypeException If a value within the path is neither object + * nor array. */ public function setValue(&$objectOrArray, $value) { - for ($i = 0, $l = $this->length - 1; $i < $l; ++$i) { - - if (is_object($objectOrArray)) { - $nestedObject = $this->readProperty($objectOrArray, $i); - // arrays need to be treated separately (due to PHP bug?) - // http://bugs.php.net/bug.php?id=52133 - } elseif (is_array($objectOrArray)) { - $property = $this->elements[$i]; - if (!array_key_exists($property, $objectOrArray)) { - $objectOrArray[$property] = array(); - } - $nestedObject =& $objectOrArray[$property]; - } else { - throw new UnexpectedTypeException($objectOrArray, 'object or array'); - } - - $objectOrArray =& $nestedObject; - } + $objectOrArray =& $this->readPropertyAt($objectOrArray, $this->length - 2); if (!is_object($objectOrArray) && !is_array($objectOrArray)) { throw new UnexpectedTypeException($objectOrArray, 'object or array'); } - $this->writeProperty($objectOrArray, $i, $value); + $property = $this->elements[$this->length - 1]; + $singular = $this->singulars[$this->length - 1]; + $isIndex = $this->isIndex[$this->length - 1]; + + $this->writeProperty($objectOrArray, $property, $singular, $isIndex, $value); } /** - * Reads the value of the property at the given index in the path + * Reads the path from an object up to a given path index. * - * @param object $object The object to read from - * @param integer $currentIndex The index of the read property in the path + * @param object|array $objectOrArray The object or array to read from. + * @param integer $index The integer up to which should be read. * - * @return mixed The value of the property + * @return mixed The value read at the end of the path. + * + * @throws UnexpectedTypeException If a value within the path is neither object nor array. */ - protected function readProperty($object, $currentIndex) + protected function &readPropertyAt(&$objectOrArray, $index) { - $property = $this->elements[$currentIndex]; - - if ($this->isIndex[$currentIndex]) { - if (!$object instanceof \ArrayAccess) { - throw new InvalidPropertyException(sprintf('Index "%s" cannot be read from object of type "%s" because it doesn\'t implement \ArrayAccess', $property, get_class($object))); + for ($i = 0; $i <= $index; ++$i) { + if (!is_object($objectOrArray) && !is_array($objectOrArray)) { + throw new UnexpectedTypeException($objectOrArray, 'object or array'); } - if (isset($object[$property])) { - return $object[$property]; + // Create missing nested arrays on demand + if (is_array($objectOrArray) && !array_key_exists($this->elements[$i], $objectOrArray)) { + $objectOrArray[$this->elements[$i]] = $i + 1 < $this->length ? array() : null; } - } else { + + $property = $this->elements[$i]; + $isIndex = $this->isIndex[$i]; + + $objectOrArray =& $this->readProperty($objectOrArray, $property, $isIndex); + } + + return $objectOrArray; + } + + /** + * Reads the a property from an object or array. + * + * @param object|array $objectOrArray The object or array to read from. + * @param string $property The property to read. + * @param integer $isIndex Whether to interpret the property as index. + * + * @return mixed The value of the read property + * + * @throws InvalidPropertyException If the property does not exist. + * @throws PropertyAccessDeniedException If the property cannot be accessed due to + * access restrictions (private or protected). + */ + protected function &readProperty(&$objectOrArray, $property, $isIndex) + { + $result = null; + + if ($isIndex) { + if (!$objectOrArray instanceof \ArrayAccess && !is_array($objectOrArray)) { + throw new InvalidPropertyException(sprintf('Index "%s" cannot be read from object of type "%s" because it doesn\'t implement \ArrayAccess', $property, get_class($objectOrArray))); + } + + if (isset($objectOrArray[$property])) { + $result =& $objectOrArray[$property]; + } + } elseif (is_object($objectOrArray)) { $camelProp = $this->camelize($property); - $reflClass = new ReflectionClass($object); + $reflClass = new ReflectionClass($objectOrArray); $getter = 'get'.$camelProp; $isser = 'is'.$camelProp; $hasser = 'has'.$camelProp; @@ -365,50 +375,58 @@ class PropertyPath implements \IteratorAggregate throw new PropertyAccessDeniedException(sprintf('Method "%s()" is not public in class "%s"', $getter, $reflClass->getName())); } - return $object->$getter(); + $result = $objectOrArray->$getter(); } elseif ($reflClass->hasMethod($isser)) { if (!$reflClass->getMethod($isser)->isPublic()) { throw new PropertyAccessDeniedException(sprintf('Method "%s()" is not public in class "%s"', $isser, $reflClass->getName())); } - return $object->$isser(); + $result = $objectOrArray->$isser(); } elseif ($reflClass->hasMethod($hasser)) { if (!$reflClass->getMethod($hasser)->isPublic()) { throw new PropertyAccessDeniedException(sprintf('Method "%s()" is not public in class "%s"', $hasser, $reflClass->getName())); } - return $object->$hasser(); + $result = $objectOrArray->$hasser(); } elseif ($reflClass->hasMethod('__get')) { // needed to support magic method __get - return $object->$property; + $result =& $objectOrArray->$property; } elseif ($reflClass->hasProperty($property)) { if (!$reflClass->getProperty($property)->isPublic()) { throw new PropertyAccessDeniedException(sprintf('Property "%s" is not public in class "%s". Maybe you should create the method "%s()" or "%s()"?', $property, $reflClass->getName(), $getter, $isser)); } - return $object->$property; - } elseif (property_exists($object, $property)) { + $result =& $objectOrArray->$property; + } elseif (property_exists($objectOrArray, $property)) { // needed to support \stdClass instances - return $object->$property; + $result =& $objectOrArray->$property; } else { throw new InvalidPropertyException(sprintf('Neither property "%s" nor method "%s()" nor method "%s()" exists in class "%s"', $property, $getter, $isser, $reflClass->getName())); } + } else { + throw new InvalidPropertyException(sprintf('Cannot read property "%s" from an array. Maybe you should write the property path as "[%s]" instead?', $property, $property)); } + + return $result; } /** * Sets the value of the property at the given index in the path * - * @param object $objectOrArray The object or array to traverse - * @param integer $currentIndex The index of the modified property in the path - * @param mixed $value The value to set + * @param object|array $objectOrArray The object or array to write to. + * @param string $property The property to write. + * @param string $singular The singular form of the property name or null. + * @param integer $isIndex Whether to interpret the property as index. + * @param mixed $value The value to write. + * + * @throws InvalidPropertyException If the property does not exist. + * @throws PropertyAccessDeniedException If the property cannot be accessed due to + * access restrictions (private or protected). */ - protected function writeProperty(&$objectOrArray, $currentIndex, $value) + protected function writeProperty(&$objectOrArray, $property, $singular, $isIndex, $value) { - $property = $this->elements[$currentIndex]; - - if (is_object($objectOrArray) && $this->isIndex[$currentIndex]) { - if (!$objectOrArray instanceof \ArrayAccess) { + if ($isIndex) { + if (!$objectOrArray instanceof \ArrayAccess && !is_array($objectOrArray)) { throw new InvalidPropertyException(sprintf('Index "%s" cannot be modified in object of type "%s" because it doesn\'t implement \ArrayAccess', $property, get_class($objectOrArray))); } @@ -422,7 +440,6 @@ class PropertyPath implements \IteratorAggregate // Check if the parent has matching methods to add/remove items if (is_array($value) || $value instanceof Traversable) { - $singular = $this->singulars[$currentIndex]; if (null !== $singular) { $addMethod = 'add' . ucfirst($singular); $removeMethod = 'remove' . ucfirst($singular); @@ -490,7 +507,7 @@ class PropertyPath implements \IteratorAggregate if ($addMethod && $removeMethod) { $itemsToAdd = is_object($value) ? clone $value : $value; $itemToRemove = array(); - $previousValue = $this->readProperty($objectOrArray, $currentIndex); + $previousValue = $this->readProperty($objectOrArray, $property, $isIndex); if (is_array($previousValue) || $previousValue instanceof Traversable) { foreach ($previousValue as $previousItem) { @@ -538,21 +555,38 @@ class PropertyPath implements \IteratorAggregate throw new InvalidPropertyException(sprintf('Neither element "%s" nor method "%s()" exists in class "%s"', $property, $setter, $reflClass->getName())); } } else { - $objectOrArray[$property] = $value; + throw new InvalidPropertyException(sprintf('Cannot write property "%s" in an array. Maybe you should write the property path as "[%s]" instead?', $property, $property)); } } - protected function camelize($property) + /** + * Camelizes a given string. + * + * @param string $string Some string. + * + * @return string The camelized version of the string. + */ + protected function camelize($string) { - return preg_replace_callback('/(^|_|\.)+(.)/', function ($match) { return ('.' === $match[1] ? '_' : '').strtoupper($match[2]); }, $property); + return preg_replace_callback('/(^|_|\.)+(.)/', function ($match) { return ('.' === $match[1] ? '_' : '').strtoupper($match[2]); }, $string); } - private function isAccessible(ReflectionClass $reflClass, $methodName, $numberOfRequiredParameters) + /** + * Returns whether a method is public and has a specific number of required parameters. + * + * @param \ReflectionClass $class The class of the method. + * @param string $methodName The method name. + * @param integer $parameters The number of parameters. + * + * @return Boolean Whether the method is public and has $parameters + * required parameters. + */ + private function isAccessible(ReflectionClass $class, $methodName, $parameters) { - if ($reflClass->hasMethod($methodName)) { - $method = $reflClass->getMethod($methodName); + if ($class->hasMethod($methodName)) { + $method = $class->getMethod($methodName); - if ($method->isPublic() && $method->getNumberOfRequiredParameters() === $numberOfRequiredParameters) { + if ($method->isPublic() && $method->getNumberOfRequiredParameters() === $parameters) { return true; } }