From 2b509904c836fc773299e7b51a63c0cd7d720777 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 16 May 2019 14:24:54 +0200 Subject: [PATCH] [Validator] Allow to use property paths to get limits in range constraint --- src/Symfony/Component/Validator/CHANGELOG.md | 6 + .../Component/Validator/Constraints/Range.php | 23 +- .../Validator/Constraints/RangeValidator.php | 75 +++- .../Validator/Tests/Constraints/RangeTest.php | 51 +++ .../Tests/Constraints/RangeValidatorTest.php | 404 ++++++++++++++++++ 5 files changed, 547 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/RangeTest.php diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 8a85ee35efcf..9bc25d9f391c 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -8,6 +8,12 @@ CHANGELOG * added the `compared_value_path` parameter in violations when using any comparison constraint with the `propertyPath` option. * added support for checking an array of types in `TypeValidator` + * Added new `minPropertyPath` and `maxPropertyPath` options + to `Range` constraint in order to get the value to compare + from an array or object + * added the `limit_path` parameter in violations when using + `Range` constraint with the `minPropertyPath` or + `maxPropertyPath` options. 4.3.0 ----- diff --git a/src/Symfony/Component/Validator/Constraints/Range.php b/src/Symfony/Component/Validator/Constraints/Range.php index 65ece5d83200..115b9014d5aa 100644 --- a/src/Symfony/Component/Validator/Constraints/Range.php +++ b/src/Symfony/Component/Validator/Constraints/Range.php @@ -11,7 +11,10 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\LogicException; use Symfony\Component\Validator\Exception\MissingOptionsException; /** @@ -36,14 +39,30 @@ class Range extends Constraint public $maxMessage = 'This value should be {{ limit }} or less.'; public $invalidMessage = 'This value should be a valid number.'; public $min; + public $minPropertyPath; public $max; + public $maxPropertyPath; public function __construct($options = null) { + if (\is_array($options)) { + if (isset($options['min']) && isset($options['minPropertyPath'])) { + throw new ConstraintDefinitionException(sprintf('The "%s" constraint requires only one of the "min" or "minPropertyPath" options to be set, not both.', \get_class($this))); + } + + if (isset($options['max']) && isset($options['maxPropertyPath'])) { + throw new ConstraintDefinitionException(sprintf('The "%s" constraint requires only one of the "max" or "maxPropertyPath" options to be set, not both.', \get_class($this))); + } + + if ((isset($options['minPropertyPath']) || isset($options['maxPropertyPath'])) && !class_exists(PropertyAccess::class)) { + throw new LogicException(sprintf('The "%s" constraint requires the Symfony PropertyAccess component to use the "minPropertyPath" or "maxPropertyPath" option.', \get_class($this))); + } + } + parent::__construct($options); - if (null === $this->min && null === $this->max) { - throw new MissingOptionsException(sprintf('Either option "min" or "max" must be given for constraint %s', __CLASS__), ['min', 'max']); + if (null === $this->min && null === $this->minPropertyPath && null === $this->max && null === $this->maxPropertyPath) { + throw new MissingOptionsException(sprintf('Either option "min", "minPropertyPath", "max" or "maxPropertyPath" must be given for constraint %s', __CLASS__), ['min', 'max']); } } } diff --git a/src/Symfony/Component/Validator/Constraints/RangeValidator.php b/src/Symfony/Component/Validator/Constraints/RangeValidator.php index e0cb92a93e9e..3837b0d6fa60 100644 --- a/src/Symfony/Component/Validator/Constraints/RangeValidator.php +++ b/src/Symfony/Component/Validator/Constraints/RangeValidator.php @@ -11,8 +11,12 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\UnexpectedTypeException; /** @@ -20,6 +24,13 @@ */ class RangeValidator extends ConstraintValidator { + private $propertyAccessor; + + public function __construct(PropertyAccessorInterface $propertyAccessor = null) + { + $this->propertyAccessor = $propertyAccessor; + } + /** * {@inheritdoc} */ @@ -42,8 +53,8 @@ public function validate($value, Constraint $constraint) return; } - $min = $constraint->min; - $max = $constraint->max; + $min = $this->getLimit($constraint->minPropertyPath, $constraint->min, $constraint); + $max = $this->getLimit($constraint->maxPropertyPath, $constraint->max, $constraint); // Convert strings to DateTimes if comparing another DateTime // This allows to compare with any date/time value supported by @@ -59,22 +70,66 @@ public function validate($value, Constraint $constraint) } } - if (null !== $constraint->max && $value > $max) { - $this->context->buildViolation($constraint->maxMessage) + if (null !== $max && $value > $max) { + $violationBuilder = $this->context->buildViolation($constraint->maxMessage) ->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE)) ->setParameter('{{ limit }}', $this->formatValue($max, self::PRETTY_DATE)) - ->setCode(Range::TOO_HIGH_ERROR) - ->addViolation(); + ->setCode(Range::TOO_HIGH_ERROR); + + if (null !== $constraint->maxPropertyPath) { + $violationBuilder->setParameter('{{ max_limit_path }}', $constraint->maxPropertyPath); + } + + if (null !== $constraint->minPropertyPath) { + $violationBuilder->setParameter('{{ min_limit_path }}', $constraint->minPropertyPath); + } + + $violationBuilder->addViolation(); return; } - if (null !== $constraint->min && $value < $min) { - $this->context->buildViolation($constraint->minMessage) + if (null !== $min && $value < $min) { + $violationBuilder = $this->context->buildViolation($constraint->minMessage) ->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE)) ->setParameter('{{ limit }}', $this->formatValue($min, self::PRETTY_DATE)) - ->setCode(Range::TOO_LOW_ERROR) - ->addViolation(); + ->setCode(Range::TOO_LOW_ERROR); + + if (null !== $constraint->maxPropertyPath) { + $violationBuilder->setParameter('{{ max_limit_path }}', $constraint->maxPropertyPath); + } + + if (null !== $constraint->minPropertyPath) { + $violationBuilder->setParameter('{{ min_limit_path }}', $constraint->minPropertyPath); + } + + $violationBuilder->addViolation(); + } + } + + private function getLimit($propertyPath, $default, Constraint $constraint) + { + if (null === $propertyPath) { + return $default; + } + + if (null === $object = $this->context->getObject()) { + return $default; } + + try { + return $this->getPropertyAccessor()->getValue($object, $propertyPath); + } catch (NoSuchPropertyException $e) { + throw new ConstraintDefinitionException(sprintf('Invalid property path "%s" provided to "%s" constraint: %s', $propertyPath, \get_class($constraint), $e->getMessage()), 0, $e); + } + } + + private function getPropertyAccessor(): PropertyAccessorInterface + { + if (null === $this->propertyAccessor) { + $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + } + + return $this->propertyAccessor; } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/RangeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/RangeTest.php new file mode 100644 index 000000000000..b860cb5778f9 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/RangeTest.php @@ -0,0 +1,51 @@ + 'min', + 'minPropertyPath' => 'minPropertyPath', + ]); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\ConstraintDefinitionException + * @expectedExceptionMessage requires only one of the "max" or "maxPropertyPath" options to be set, not both. + */ + public function testThrowsConstraintExceptionIfBothMaxLimitAndPropertyPath() + { + new Range([ + 'max' => 'min', + 'maxPropertyPath' => 'maxPropertyPath', + ]); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\MissingOptionsException + * @expectedExceptionMessage Either option "min", "minPropertyPath", "max" or "maxPropertyPath" must be given + */ + public function testThrowsConstraintExceptionIfNoLimitNorPropertyPath() + { + new Range([]); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\ConstraintDefinitionException + * @expectedExceptionMessage No default option is configured + */ + public function testThrowsNoDefaultOptionConfiguredException() + { + new Range('value'); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php index 661161d886a2..f57428cafe68 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/RangeValidatorTest.php @@ -389,4 +389,408 @@ public function testNonNumeric() ->setCode(Range::INVALID_CHARACTERS_ERROR) ->assertRaised(); } + + public function testNoViolationOnNullObjectWithPropertyPaths() + { + $this->setObject(null); + + $this->validator->validate(1, new Range([ + 'minPropertyPath' => 'minPropertyPath', + 'maxPropertyPath' => 'maxPropertyPath', + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenToTwenty + */ + public function testValidValuesMinPropertyPath($value) + { + $this->setObject(new Limit(10)); + + $this->validator->validate($value, new Range([ + 'minPropertyPath' => 'value', + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenToTwenty + */ + public function testValidValuesMinPropertyPathOnArray($value) + { + $this->setObject(['root' => ['value' => 10]]); + + $this->validator->validate($value, new Range([ + 'minPropertyPath' => '[root][value]', + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenToTwenty + */ + public function testValidValuesMaxPropertyPath($value) + { + $this->setObject(new Limit(20)); + + $this->validator->validate($value, new Range([ + 'maxPropertyPath' => 'value', + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenToTwenty + */ + public function testValidValuesMaxPropertyPathOnArray($value) + { + $this->setObject(['root' => ['value' => 20]]); + + $this->validator->validate($value, new Range([ + 'maxPropertyPath' => '[root][value]', + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenToTwenty + */ + public function testValidValuesMinMaxPropertyPath($value) + { + $this->setObject(new MinMax(10, 20)); + + $this->validator->validate($value, new Range([ + 'minPropertyPath' => 'min', + 'maxPropertyPath' => 'max', + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getLessThanTen + */ + public function testInvalidValuesMinPropertyPath($value, $formattedValue) + { + $this->setObject(new Limit(10)); + + $constraint = new Range([ + 'minPropertyPath' => 'value', + 'minMessage' => 'myMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', $formattedValue) + ->setParameter('{{ limit }}', 10) + ->setParameter('{{ min_limit_path }}', 'value') + ->setCode(Range::TOO_LOW_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getMoreThanTwenty + */ + public function testInvalidValuesMaxPropertyPath($value, $formattedValue) + { + $this->setObject(new Limit(20)); + + $constraint = new Range([ + 'maxPropertyPath' => 'value', + 'maxMessage' => 'myMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', $formattedValue) + ->setParameter('{{ limit }}', 20) + ->setParameter('{{ max_limit_path }}', 'value') + ->setCode(Range::TOO_HIGH_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getMoreThanTwenty + */ + public function testInvalidValuesCombinedMaxPropertyPath($value, $formattedValue) + { + $this->setObject(new MinMax(10, 20)); + + $constraint = new Range([ + 'minPropertyPath' => 'min', + 'maxPropertyPath' => 'max', + 'minMessage' => 'myMinMessage', + 'maxMessage' => 'myMaxMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMaxMessage') + ->setParameter('{{ value }}', $formattedValue) + ->setParameter('{{ limit }}', 20) + ->setParameter('{{ max_limit_path }}', 'max') + ->setParameter('{{ min_limit_path }}', 'min') + ->setCode(Range::TOO_HIGH_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getLessThanTen + */ + public function testInvalidValuesCombinedMinPropertyPath($value, $formattedValue) + { + $this->setObject(new MinMax(10, 20)); + + $constraint = new Range([ + 'minPropertyPath' => 'min', + 'maxPropertyPath' => 'max', + 'minMessage' => 'myMinMessage', + 'maxMessage' => 'myMaxMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMinMessage') + ->setParameter('{{ value }}', $formattedValue) + ->setParameter('{{ limit }}', 10) + ->setParameter('{{ max_limit_path }}', 'max') + ->setParameter('{{ min_limit_path }}', 'min') + ->setCode(Range::TOO_LOW_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getLessThanTen + */ + public function testViolationOnNullObjectWithDefinedMin($value, $formattedValue) + { + $this->setObject(null); + + $this->validator->validate($value, new Range([ + 'min' => 10, + 'maxPropertyPath' => 'max', + 'minMessage' => 'myMessage', + ])); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', $formattedValue) + ->setParameter('{{ limit }}', 10) + ->setParameter('{{ max_limit_path }}', 'max') + ->setCode(Range::TOO_LOW_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getMoreThanTwenty + */ + public function testViolationOnNullObjectWithDefinedMax($value, $formattedValue) + { + $this->setObject(null); + + $this->validator->validate($value, new Range([ + 'minPropertyPath' => 'min', + 'max' => 20, + 'maxMessage' => 'myMessage', + ])); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', $formattedValue) + ->setParameter('{{ limit }}', 20) + ->setParameter('{{ min_limit_path }}', 'min') + ->setCode(Range::TOO_HIGH_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getTenthToTwentiethMarch2014 + */ + public function testValidDatesMinPropertyPath($value) + { + $this->setObject(new Limit('March 10, 2014')); + + $this->validator->validate($value, new Range(['minPropertyPath' => 'value'])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenthToTwentiethMarch2014 + */ + public function testValidDatesMaxPropertyPath($value) + { + $this->setObject(new Limit('March 20, 2014')); + + $constraint = new Range(['maxPropertyPath' => 'value']); + $this->validator->validate($value, $constraint); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getTenthToTwentiethMarch2014 + */ + public function testValidDatesMinMaxPropertyPath($value) + { + $this->setObject(new MinMax('March 10, 2014', 'March 20, 2014')); + + $constraint = new Range(['minPropertyPath' => 'min', 'maxPropertyPath' => 'max']); + $this->validator->validate($value, $constraint); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getSoonerThanTenthMarch2014 + */ + public function testInvalidDatesMinPropertyPath($value, $dateTimeAsString) + { + // Conversion of dates to string differs between ICU versions + // Make sure we have the correct version loaded + IntlTestHelper::requireIntl($this, '57.1'); + + $this->setObject(new Limit('March 10, 2014')); + + $constraint = new Range([ + 'minPropertyPath' => 'value', + 'minMessage' => 'myMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', $dateTimeAsString) + ->setParameter('{{ limit }}', 'Mar 10, 2014, 12:00 AM') + ->setParameter('{{ min_limit_path }}', 'value') + ->setCode(Range::TOO_LOW_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getLaterThanTwentiethMarch2014 + */ + public function testInvalidDatesMaxPropertyPath($value, $dateTimeAsString) + { + // Conversion of dates to string differs between ICU versions + // Make sure we have the correct version loaded + IntlTestHelper::requireIntl($this, '57.1'); + + $this->setObject(new Limit('March 20, 2014')); + + $constraint = new Range([ + 'maxPropertyPath' => 'value', + 'maxMessage' => 'myMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', $dateTimeAsString) + ->setParameter('{{ limit }}', 'Mar 20, 2014, 12:00 AM') + ->setParameter('{{ max_limit_path }}', 'value') + ->setCode(Range::TOO_HIGH_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getLaterThanTwentiethMarch2014 + */ + public function testInvalidDatesCombinedMaxPropertyPath($value, $dateTimeAsString) + { + // Conversion of dates to string differs between ICU versions + // Make sure we have the correct version loaded + IntlTestHelper::requireIntl($this, '57.1'); + + $this->setObject(new MinMax('March 10, 2014', 'March 20, 2014')); + + $constraint = new Range([ + 'minPropertyPath' => 'min', + 'maxPropertyPath' => 'max', + 'minMessage' => 'myMinMessage', + 'maxMessage' => 'myMaxMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMaxMessage') + ->setParameter('{{ value }}', $dateTimeAsString) + ->setParameter('{{ limit }}', 'Mar 20, 2014, 12:00 AM') + ->setParameter('{{ max_limit_path }}', 'max') + ->setParameter('{{ min_limit_path }}', 'min') + ->setCode(Range::TOO_HIGH_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getSoonerThanTenthMarch2014 + */ + public function testInvalidDatesCombinedMinPropertyPath($value, $dateTimeAsString) + { + // Conversion of dates to string differs between ICU versions + // Make sure we have the correct version loaded + IntlTestHelper::requireIntl($this, '57.1'); + + $this->setObject(new MinMax('March 10, 2014', 'March 20, 2014')); + + $constraint = new Range([ + 'minPropertyPath' => 'min', + 'maxPropertyPath' => 'max', + 'minMessage' => 'myMinMessage', + 'maxMessage' => 'myMaxMessage', + ]); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMinMessage') + ->setParameter('{{ value }}', $dateTimeAsString) + ->setParameter('{{ limit }}', 'Mar 10, 2014, 12:00 AM') + ->setParameter('{{ max_limit_path }}', 'max') + ->setParameter('{{ min_limit_path }}', 'min') + ->setCode(Range::TOO_LOW_ERROR) + ->assertRaised(); + } +} + +final class Limit +{ + private $value; + + public function __construct($value) + { + $this->value = $value; + } + + public function getValue() + { + return $this->value; + } +} + +final class MinMax +{ + private $min; + private $max; + + public function __construct($min, $max) + { + $this->min = $min; + $this->max = $max; + } + + public function getMin() + { + return $this->min; + } + + public function getMax() + { + return $this->max; + } }