From d4ebbfd02d416504ebfed262d656941062905b76 Mon Sep 17 00:00:00 2001 From: Bernhard Schussek Date: Tue, 10 Sep 2013 17:14:04 +0200 Subject: [PATCH] [Validator] Renamed Condition to Expression and added possibility to set it onto properties --- .../FrameworkExtension.php | 3 + .../FrameworkBundle/Resources/config/form.xml | 4 - .../Resources/config/property_access.xml | 14 ++ .../Resources/config/validator.xml | 6 + .../Validator/ConstraintValidatorFactory.php | 32 ++- .../Constraints/ConditionValidator.php | 50 ----- .../{Condition.php => Expression.php} | 20 +- .../Constraints/ExpressionValidator.php | 82 ++++++++ .../Validator/Exception/RuntimeException.php | 21 ++ .../Constraints/ExpressionValidatorTest.php | 197 ++++++++++++++++++ .../Component/Validator/ValidatorBuilder.php | 28 ++- .../Validator/ValidatorBuilderInterface.php | 10 + src/Symfony/Component/Validator/composer.json | 3 +- 13 files changed, 405 insertions(+), 65 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml delete mode 100644 src/Symfony/Component/Validator/Constraints/ConditionValidator.php rename src/Symfony/Component/Validator/Constraints/{Condition.php => Expression.php} (66%) create mode 100644 src/Symfony/Component/Validator/Constraints/ExpressionValidator.php create mode 100644 src/Symfony/Component/Validator/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index f0460c1a85ff..3b2f85aa2d25 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -48,6 +48,9 @@ public function load(array $configs, ContainerBuilder $container) // will be used and everything will still work as expected. $loader->load('translation.xml'); + // Property access is used by both the Form and the Validator component + $loader->load('property_access.xml'); + $loader->load('debug_prod.xml'); if ($container->getParameter('kernel.debug')) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml index 569100fce75f..93fa5235871c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.xml @@ -10,7 +10,6 @@ Symfony\Component\Form\FormFactory Symfony\Component\Form\Extension\DependencyInjection\DependencyInjectionExtension Symfony\Component\Form\Extension\Validator\ValidatorTypeGuesser - Symfony\Component\PropertyAccess\PropertyAccessor @@ -54,9 +53,6 @@ - - - diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml new file mode 100644 index 000000000000..18026144e457 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.xml @@ -0,0 +1,14 @@ + + + + + + Symfony\Component\PropertyAccess\PropertyAccessor + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml index 0bc70040a206..5f8fba2b2dd1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml @@ -17,6 +17,7 @@ Symfony\Bundle\FrameworkBundle\Validator\ConstraintValidatorFactory + Symfony\Component\Validator\Constraints\ExpressionValidator @@ -63,5 +64,10 @@ %validator.mapping.loader.yaml_files_loader.mapping_files% + + + + + diff --git a/src/Symfony/Component/Validator/ConstraintValidatorFactory.php b/src/Symfony/Component/Validator/ConstraintValidatorFactory.php index 88b5cefdc2c7..88868e533635 100644 --- a/src/Symfony/Component/Validator/ConstraintValidatorFactory.php +++ b/src/Symfony/Component/Validator/ConstraintValidatorFactory.php @@ -11,8 +11,9 @@ namespace Symfony\Component\Validator; -use Symfony\Component\Validator\ConstraintValidatorFactoryInterface; -use Symfony\Component\Validator\Constraint; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Validator\Constraints\ExpressionValidator; /** * Default implementation of the ConstraintValidatorFactoryInterface. @@ -20,11 +21,23 @@ * This enforces the convention that the validatedBy() method on any * Constrain will return the class name of the ConstraintValidator that * should validate the Constraint. + * + * @author Bernhard Schussek */ class ConstraintValidatorFactory implements ConstraintValidatorFactoryInterface { protected $validators = array(); + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + public function __construct(PropertyAccessorInterface $propertyAccessor = null) + { + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + /** * {@inheritDoc} */ @@ -32,8 +45,19 @@ public function getInstance(Constraint $constraint) { $className = $constraint->validatedBy(); - if (!isset($this->validators[$className]) || $className === 'Symfony\Component\Validator\Constraints\CollectionValidator') { - $this->validators[$className] = new $className(); + // The second condition is a hack that is needed when CollectionValidator + // calls itself recursively (Collection constraints can be nested). + // Since the context of the validator is overwritten when initialize() + // is called for the nested constraint, the outer validator is + // acting on the wrong context when the nested validation terminates. + // + // A better solution - which should be approached in Symfony 3.0 - is to + // remove the initialize() method and pass the context as last argument + // to validate() instead. + if (!isset($this->validators[$className]) || 'Symfony\Component\Validator\Constraints\CollectionValidator' === $className) { + $this->validators[$className] = 'validator.expression' === $className + ? new ExpressionValidator($this->propertyAccessor) + : new $className(); } return $this->validators[$className]; diff --git a/src/Symfony/Component/Validator/Constraints/ConditionValidator.php b/src/Symfony/Component/Validator/Constraints/ConditionValidator.php deleted file mode 100644 index 6f7f572a5cc3..000000000000 --- a/src/Symfony/Component/Validator/Constraints/ConditionValidator.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Validator\Constraints; - -use Symfony\Component\Validator\Constraint; -use Symfony\Component\Validator\ConstraintValidator; -use Symfony\Component\ExpressionLanguage\ExpressionLanguage; - -/** - * @author Fabien Potencier - */ -class ConditionValidator extends ConstraintValidator -{ - private $expressionLanguage; - - /** - * {@inheritDoc} - */ - public function validate($object, Constraint $constraint) - { - if (null === $object) { - return; - } - - if (!$this->getExpressionLanguage()->evaluate($constraint->condition, array('this' => $object))) { - $this->context->addViolation($constraint->message); - } - } - - private function getExpressionLanguage() - { - if (null === $this->expressionLanguage) { - if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { - throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); - } - $this->expressionLanguage = new ExpressionLanguage(); - } - - return $this->expressionLanguage; - } -} diff --git a/src/Symfony/Component/Validator/Constraints/Condition.php b/src/Symfony/Component/Validator/Constraints/Expression.php similarity index 66% rename from src/Symfony/Component/Validator/Constraints/Condition.php rename to src/Symfony/Component/Validator/Constraints/Expression.php index 88c1a96279bc..b845a32392b2 100644 --- a/src/Symfony/Component/Validator/Constraints/Condition.php +++ b/src/Symfony/Component/Validator/Constraints/Expression.php @@ -17,18 +17,19 @@ * @Annotation * * @author Fabien Potencier + * @author Bernhard Schussek */ -class Condition extends Constraint +class Expression extends Constraint { public $message = 'This value is not valid.'; - public $condition; + public $expression; /** * {@inheritDoc} */ public function getDefaultOption() { - return 'condition'; + return 'expression'; } /** @@ -36,13 +37,22 @@ public function getDefaultOption() */ public function getRequiredOptions() { - return array('condition'); + return array('expression'); } + /** * {@inheritDoc} */ public function getTargets() { - return self::CLASS_CONSTRAINT; + return array(self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT); + } + + /** + * {@inheritDoc} + */ + public function validatedBy() + { + return 'validator.expression'; } } diff --git a/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php b/src/Symfony/Component/Validator/Constraints/ExpressionValidator.php new file mode 100644 index 000000000000..e27859b08c19 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/ExpressionValidator.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\Validator\Constraints; + +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Validator\Exception\RuntimeException; + +/** + * @author Fabien Potencier + * @author Bernhard Schussek + */ +class ExpressionValidator extends ConstraintValidator +{ + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + /** + * @var ExpressionLanguage + */ + private $expressionLanguage; + + public function __construct(PropertyAccessorInterface $propertyAccessor) + { + $this->propertyAccessor = $propertyAccessor; + } + + /** + * {@inheritDoc} + */ + public function validate($value, Constraint $constraint) + { + if (null === $value || '' === $value) { + return; + } + + $variables = array(); + + if (null === $this->context->getPropertyName()) { + $variables['this'] = $value; + } else { + // Extract the object that the property belongs to from the object + // graph + $path = new PropertyPath($this->context->getPropertyPath()); + $parentPath = $path->getParent(); + $root = $this->context->getRoot(); + + $variables['value'] = $value; + $variables['this'] = $parentPath ? $this->propertyAccessor->getValue($root, $parentPath) : $root; + } + + if (!$this->getExpressionLanguage()->evaluate($constraint->expression, $variables)) { + $this->context->addViolation($constraint->message); + } + } + + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage) { + if (!class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + throw new RuntimeException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed.'); + } + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } +} diff --git a/src/Symfony/Component/Validator/Exception/RuntimeException.php b/src/Symfony/Component/Validator/Exception/RuntimeException.php new file mode 100644 index 000000000000..df4a50c47433 --- /dev/null +++ b/src/Symfony/Component/Validator/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Exception; + +/** + * Base RuntimeException for the Validator component. + * + * @author Bernhard Schussek + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php new file mode 100644 index 000000000000..b71138e5f6bb --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/ExpressionValidatorTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\Validator\Constraints\Expression; +use Symfony\Component\Validator\Constraints\ExpressionValidator; + +class ExpressionValidatorTest extends \PHPUnit_Framework_TestCase +{ + protected $context; + protected $validator; + + protected function setUp() + { + $this->context = $this->getMock('Symfony\Component\Validator\ExecutionContext', array(), array(), '', false); + $this->validator = new ExpressionValidator(PropertyAccess::createPropertyAccessor()); + $this->validator->initialize($this->context); + + $this->context->expects($this->any()) + ->method('getClassName') + ->will($this->returnValue(__CLASS__)); + } + + protected function tearDown() + { + $this->context = null; + $this->validator = null; + } + + public function testNullIsValid() + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate(null, new Expression('value == 1')); + } + + public function testEmptyStringIsValid() + { + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('', new Expression('value == 1')); + } + + public function testSucceedingExpressionAtObjectLevel() + { + $constraint = new Expression('this.property == 1'); + + $object = (object) array('property' => '1'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue(null)); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate($object, $constraint); + } + + public function testFailingExpressionAtObjectLevel() + { + $constraint = new Expression(array( + 'expression' => 'this.property == 1', + 'message' => 'myMessage', + )); + + $object = (object) array('property' => '2'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue(null)); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('myMessage'); + + $this->validator->validate($object, $constraint); + } + + public function testSucceedingExpressionAtPropertyLevel() + { + $constraint = new Expression('value == this.expected'); + + $object = (object) array('expected' => '1'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($object)); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('1', $constraint); + } + + public function testFailingExpressionAtPropertyLevel() + { + $constraint = new Expression(array( + 'expression' => 'value == this.expected', + 'message' => 'myMessage', + )); + + $object = (object) array('expected' => '1'); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($object)); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('myMessage'); + + $this->validator->validate('2', $constraint); + } + + public function testSucceedingExpressionAtNestedPropertyLevel() + { + $constraint = new Expression('value == this.expected'); + + $object = (object) array('expected' => '1'); + $root = (object) array('nested' => $object); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('nested.property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($root)); + + $this->context->expects($this->never()) + ->method('addViolation'); + + $this->validator->validate('1', $constraint); + } + + public function testFailingExpressionAtNestedPropertyLevel() + { + $constraint = new Expression(array( + 'expression' => 'value == this.expected', + 'message' => 'myMessage', + )); + + $object = (object) array('expected' => '1'); + $root = (object) array('nested' => $object); + + $this->context->expects($this->any()) + ->method('getPropertyName') + ->will($this->returnValue('property')); + + $this->context->expects($this->any()) + ->method('getPropertyPath') + ->will($this->returnValue('nested.property')); + + $this->context->expects($this->any()) + ->method('getRoot') + ->will($this->returnValue($root)); + + $this->context->expects($this->once()) + ->method('addViolation') + ->with('myMessage'); + + $this->validator->validate('2', $constraint); + } +} diff --git a/src/Symfony/Component/Validator/ValidatorBuilder.php b/src/Symfony/Component/Validator/ValidatorBuilder.php index a5bfc1fdf63c..159070be41c2 100644 --- a/src/Symfony/Component/Validator/ValidatorBuilder.php +++ b/src/Symfony/Component/Validator/ValidatorBuilder.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Validator; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\Mapping\ClassMetadataFactory; use Symfony\Component\Validator\Exception\ValidatorException; use Symfony\Component\Validator\Mapping\Loader\LoaderChain; @@ -84,6 +86,11 @@ class ValidatorBuilder implements ValidatorBuilderInterface */ private $translationDomain; + /** + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + /** * {@inheritdoc} */ @@ -253,6 +260,10 @@ public function setMetadataCache(CacheInterface $cache) */ public function setConstraintValidatorFactory(ConstraintValidatorFactoryInterface $validatorFactory) { + if (null !== $this->propertyAccessor) { + throw new ValidatorException('You cannot set a validator factory after setting a custom property accessor. Remove the call to setPropertyAccessor() if you want to call setConstraintValidatorFactory().'); + } + $this->validatorFactory = $validatorFactory; return $this; @@ -278,6 +289,20 @@ public function setTranslationDomain($translationDomain) return $this; } + /** + * {@inheritdoc} + */ + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor) + { + if (null !== $this->validatorFactory) { + throw new ValidatorException('You cannot set a property accessor after setting a custom validator factory. Configure your validator factory instead.'); + } + + $this->propertyAccessor = $propertyAccessor; + + return $this; + } + /** * {@inheritdoc} */ @@ -319,7 +344,8 @@ public function getValidator() $metadataFactory = new ClassMetadataFactory($loader, $this->metadataCache); } - $validatorFactory = $this->validatorFactory ?: new ConstraintValidatorFactory(); + $propertyAccessor = $this->propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + $validatorFactory = $this->validatorFactory ?: new ConstraintValidatorFactory($propertyAccessor); $translator = $this->translator ?: new DefaultTranslator(); return new Validator($metadataFactory, $validatorFactory, $translator, $this->translationDomain, $this->initializers); diff --git a/src/Symfony/Component/Validator/ValidatorBuilderInterface.php b/src/Symfony/Component/Validator/ValidatorBuilderInterface.php index 99f367b6aa1a..92aaca756a3b 100644 --- a/src/Symfony/Component/Validator/ValidatorBuilderInterface.php +++ b/src/Symfony/Component/Validator/ValidatorBuilderInterface.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Validator; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\Mapping\Cache\CacheInterface; use Symfony\Component\Translation\TranslatorInterface; use Doctrine\Common\Annotations\Reader; @@ -159,6 +160,15 @@ public function setTranslator(TranslatorInterface $translator); */ public function setTranslationDomain($translationDomain); + /** + * Sets the property accessor for resolving property paths. + * + * @param PropertyAccessorInterface $propertyAccessor The property accessor. + * + * @return ValidatorBuilderInterface The builder object. + */ + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor); + /** * Builds and returns a new validator object. * diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index de06e2326d1d..d6f619582576 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -17,7 +17,8 @@ ], "require": { "php": ">=5.3.3", - "symfony/translation": "~2.0" + "symfony/translation": "~2.0", + "symfony/property-access": "~2.2" }, "require-dev": { "symfony/http-foundation": "~2.1",