From f4679ef08a745ddc2e57ba0c2911758be621d425 Mon Sep 17 00:00:00 2001 From: Jules Pietri Date: Sun, 5 Apr 2020 18:34:10 +0200 Subject: [PATCH] [Validator] Added support for cascade validation on typed properties --- src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Validator/Constraints/Cascade.php | 41 +++++++ .../Validator/Mapping/ClassMetadata.php | 34 ++++- .../Validator/Mapping/GenericMetadata.php | 7 +- .../Tests/Fixtures/CascadedChild.php | 17 +++ .../Tests/Fixtures/CascadingEntity.php | 28 +++++ .../Tests/Mapping/ClassMetadataTest.php | 36 ++++++ .../Tests/Validator/AbstractTest.php | 82 +++++++++++++ .../Validator/RecursiveValidatorTest.php | 116 ++++++++++++++++++ 9 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 src/Symfony/Component/Validator/Constraints/Cascade.php create mode 100644 src/Symfony/Component/Validator/Tests/Fixtures/CascadedChild.php create mode 100644 src/Symfony/Component/Validator/Tests/Fixtures/CascadingEntity.php diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index df48d15b3ffe4..32d22c4220194 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -32,6 +32,7 @@ CHANGELOG 5.1.0 ----- + * added a `Cascade` constraint to ease validating typed nested objects * added the `Hostname` constraint and validator * added the `alpha3` option to the `Country` and `Language` constraints * allow to define a reusable set of constraints by extending the `Compound` constraint diff --git a/src/Symfony/Component/Validator/Constraints/Cascade.php b/src/Symfony/Component/Validator/Constraints/Cascade.php new file mode 100644 index 0000000000000..a5566eaa4e418 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Cascade.php @@ -0,0 +1,41 @@ + + * + * 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\Exception\ConstraintDefinitionException; + +/** + * @Annotation + * @Target({"CLASS"}) + * + * @author Jules Pietri + */ +class Cascade extends Constraint +{ + public function __construct($options = null) + { + if (\is_array($options) && \array_key_exists('groups', $options)) { + throw new ConstraintDefinitionException(sprintf('The option "groups" is not supported by the constraint "%s".', __CLASS__)); + } + + parent::__construct($options); + } + + /** + * {@inheritdoc} + */ + public function getTargets() + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/Symfony/Component/Validator/Mapping/ClassMetadata.php b/src/Symfony/Component/Validator/Mapping/ClassMetadata.php index a5418e189671f..41520ccb19985 100644 --- a/src/Symfony/Component/Validator/Mapping/ClassMetadata.php +++ b/src/Symfony/Component/Validator/Mapping/ClassMetadata.php @@ -12,8 +12,10 @@ namespace Symfony\Component\Validator\Mapping; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Cascade; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\Traverse; +use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\GroupDefinitionException; @@ -170,6 +172,17 @@ public function getDefaultGroup() /** * {@inheritdoc} + * + * If the constraint {@link Cascade} is added, the cascading strategy will be + * changed to {@link CascadingStrategy::CASCADE}. + * + * If the constraint {@link Traverse} is added, the traversal strategy will be + * changed. Depending on the $traverse property of that constraint, + * the traversal strategy will be set to one of the following: + * + * - {@link TraversalStrategy::IMPLICIT} by default + * - {@link TraversalStrategy::NONE} if $traverse is disabled + * - {@link TraversalStrategy::TRAVERSE} if $traverse is enabled */ public function addConstraint(Constraint $constraint) { @@ -190,6 +203,23 @@ public function addConstraint(Constraint $constraint) return $this; } + if ($constraint instanceof Cascade) { + if (\PHP_VERSION_ID < 70400) { + throw new ConstraintDefinitionException(sprintf('The constraint "%s" requires PHP 7.4.', Cascade::class)); + } + + $this->cascadingStrategy = CascadingStrategy::CASCADE; + + foreach ($this->getReflectionClass()->getProperties() as $property) { + if ($property->hasType() && (('array' === $type = $property->getType()->getName()) || class_exists(($type)))) { + $this->addPropertyConstraint($property->getName(), new Valid()); + } + } + + // The constraint is not added + return $this; + } + $constraint->addImplicitGroupName($this->getDefaultGroup()); parent::addConstraint($constraint); @@ -459,13 +489,11 @@ public function isGroupSequenceProvider() } /** - * Class nodes are never cascaded. - * * {@inheritdoc} */ public function getCascadingStrategy() { - return CascadingStrategy::NONE; + return $this->cascadingStrategy; } private function addPropertyMetadata(PropertyMetadataInterface $metadata) diff --git a/src/Symfony/Component/Validator/Mapping/GenericMetadata.php b/src/Symfony/Component/Validator/Mapping/GenericMetadata.php index f470f1d98d9eb..06971e8f92514 100644 --- a/src/Symfony/Component/Validator/Mapping/GenericMetadata.php +++ b/src/Symfony/Component/Validator/Mapping/GenericMetadata.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Mapping; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Cascade; use Symfony\Component\Validator\Constraints\DisableAutoMapping; use Symfony\Component\Validator\Constraints\EnableAutoMapping; use Symfony\Component\Validator\Constraints\Traverse; @@ -132,12 +133,12 @@ public function __clone() * * @return $this * - * @throws ConstraintDefinitionException When trying to add the - * {@link Traverse} constraint + * @throws ConstraintDefinitionException When trying to add the {@link Cascade} + * or {@link Traverse} constraint */ public function addConstraint(Constraint $constraint) { - if ($constraint instanceof Traverse) { + if ($constraint instanceof Traverse || $constraint instanceof Cascade) { throw new ConstraintDefinitionException(sprintf('The constraint "%s" can only be put on classes. Please use "Symfony\Component\Validator\Constraints\Valid" instead.', get_debug_type($constraint))); } diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/CascadedChild.php b/src/Symfony/Component/Validator/Tests/Fixtures/CascadedChild.php new file mode 100644 index 0000000000000..e4911279d94da --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/CascadedChild.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Fixtures; + +class CascadedChild +{ + public $name; +} diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/CascadingEntity.php b/src/Symfony/Component/Validator/Tests/Fixtures/CascadingEntity.php new file mode 100644 index 0000000000000..88ea02d81fbd6 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/CascadingEntity.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Fixtures; + +class CascadingEntity +{ + public string $scalar; + + public CascadedChild $requiredChild; + + public ?CascadedChild $optionalChild; + + public static ?CascadedChild $staticChild; + + /** + * @var CascadedChild[] + */ + public array $children; +} diff --git a/src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php b/src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php index bbe3475ebdb4a..9f0ab71b62add 100644 --- a/src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php +++ b/src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php @@ -13,8 +13,12 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Cascade; use Symfony\Component\Validator\Constraints\Valid; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Mapping\CascadingStrategy; use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Tests\Fixtures\CascadingEntity; use Symfony\Component\Validator\Tests\Fixtures\ConstraintA; use Symfony\Component\Validator\Tests\Fixtures\ConstraintB; use Symfony\Component\Validator\Tests\Fixtures\PropertyConstraint; @@ -310,4 +314,36 @@ public function testGetPropertyMetadataReturnsEmptyArrayWithoutConfiguredMetadat { $this->assertCount(0, $this->metadata->getPropertyMetadata('foo'), '->getPropertyMetadata() returns an empty collection if no metadata is configured for the given property'); } + + /** + * @requires PHP < 7.4 + */ + public function testCascadeConstraintIsNotAvailable() + { + $metadata = new ClassMetadata(CascadingEntity::class); + + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The constraint "Symfony\Component\Validator\Constraints\Cascade" requires PHP 7.4.'); + + $metadata->addConstraint(new Cascade()); + } + + /** + * @requires PHP 7.4 + */ + public function testCascadeConstraint() + { + $metadata = new ClassMetadata(CascadingEntity::class); + + $metadata->addConstraint(new Cascade()); + + $this->assertSame(CascadingStrategy::CASCADE, $metadata->getCascadingStrategy()); + $this->assertCount(4, $metadata->properties); + $this->assertSame([ + 'requiredChild', + 'optionalChild', + 'staticChild', + 'children', + ], $metadata->getConstrainedProperties()); + } } diff --git a/src/Symfony/Component/Validator/Tests/Validator/AbstractTest.php b/src/Symfony/Component/Validator/Tests/Validator/AbstractTest.php index 06f7e85775276..5d82a2ba34794 100644 --- a/src/Symfony/Component/Validator/Tests/Validator/AbstractTest.php +++ b/src/Symfony/Component/Validator/Tests/Validator/AbstractTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Validator; use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\Cascade; use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\Expression; use Symfony\Component\Validator\Constraints\GroupSequence; @@ -23,6 +24,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; +use Symfony\Component\Validator\Tests\Fixtures\CascadedChild; +use Symfony\Component\Validator\Tests\Fixtures\CascadingEntity; use Symfony\Component\Validator\Tests\Fixtures\Entity; use Symfony\Component\Validator\Tests\Fixtures\FailingConstraint; use Symfony\Component\Validator\Tests\Fixtures\Reference; @@ -497,6 +500,85 @@ public function testReferenceTraversalDisabledOnReferenceEnabledOnClass() $this->assertCount(0, $violations); } + public function testReferenceCascadeDisabledByDefault() + { + $entity = new Entity(); + $entity->reference = new Reference(); + + $callback = function ($value, ExecutionContextInterface $context) { + $this->fail('Should not be called'); + }; + + $this->referenceMetadata->addConstraint(new Callback([ + 'callback' => $callback, + 'groups' => 'Group', + ])); + + $violations = $this->validate($entity, new Valid(), 'Group'); + + /* @var ConstraintViolationInterface[] $violations */ + $this->assertCount(0, $violations); + } + + /** + * @requires PHP 7.4 + */ + public function testReferenceCascadeEnabledIgnoresUntyped() + { + $entity = new Entity(); + $entity->reference = new Reference(); + + $this->metadata->addConstraint(new Cascade()); + + $callback = function ($value, ExecutionContextInterface $context) { + $this->fail('Should not be called'); + }; + + $this->referenceMetadata->addConstraint(new Callback([ + 'callback' => $callback, + 'groups' => 'Group', + ])); + + $violations = $this->validate($entity, new Valid(), 'Group'); + + /* @var ConstraintViolationInterface[] $violations */ + $this->assertCount(0, $violations); + } + + /** + * @requires PHP 7.4 + */ + public function testTypedReferenceCascadeEnabled() + { + $entity = new CascadingEntity(); + $entity->requiredChild = new CascadedChild(); + + $callback = function ($value, ExecutionContextInterface $context) { + $context->buildViolation('Invalid child') + ->atPath('name') + ->addViolation() + ; + }; + + $cascadingMetadata = new ClassMetadata(CascadingEntity::class); + $cascadingMetadata->addConstraint(new Cascade()); + + $cascadedMetadata = new ClassMetadata(CascadedChild::class); + $cascadedMetadata->addConstraint(new Callback([ + 'callback' => $callback, + 'groups' => 'Group', + ])); + + $this->metadataFactory->addMetadata($cascadingMetadata); + $this->metadataFactory->addMetadata($cascadedMetadata); + + $violations = $this->validate($entity, new Valid(), 'Group'); + + /* @var ConstraintViolationInterface[] $violations */ + $this->assertCount(1, $violations); + $this->assertInstanceOf(Callback::class, $violations->get(0)->getConstraint()); + } + public function testAddCustomizedViolation() { $entity = new Entity(); diff --git a/src/Symfony/Component/Validator/Tests/Validator/RecursiveValidatorTest.php b/src/Symfony/Component/Validator/Tests/Validator/RecursiveValidatorTest.php index 1ebe1534abe23..ec2d8f1eec670 100644 --- a/src/Symfony/Component/Validator/Tests/Validator/RecursiveValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Validator/RecursiveValidatorTest.php @@ -13,6 +13,7 @@ use Symfony\Component\Translation\IdentityTranslator; use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\Cascade; use Symfony\Component\Validator\Constraints\Collection; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\IsTrue; @@ -21,6 +22,7 @@ use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\Optional; use Symfony\Component\Validator\Constraints\Required; +use Symfony\Component\Validator\Constraints\Type; use Symfony\Component\Validator\Constraints\Valid; use Symfony\Component\Validator\ConstraintValidatorFactory; use Symfony\Component\Validator\Context\ExecutionContextFactory; @@ -28,6 +30,8 @@ use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; use Symfony\Component\Validator\Tests\Constraints\Fixtures\ChildA; use Symfony\Component\Validator\Tests\Constraints\Fixtures\ChildB; +use Symfony\Component\Validator\Tests\Fixtures\CascadedChild; +use Symfony\Component\Validator\Tests\Fixtures\CascadingEntity; use Symfony\Component\Validator\Tests\Fixtures\Entity; use Symfony\Component\Validator\Tests\Fixtures\EntityParent; use Symfony\Component\Validator\Tests\Fixtures\EntityWithGroupedConstraintOnMethods; @@ -202,4 +206,116 @@ public function testOptionalConstraintIsIgnored() $this->assertCount(0, $violations); } + + /** + * @requires PHP 7.4 + */ + public function testValidateDoNotCascadeNestedObjectsAndArraysByDefault() + { + $this->metadataFactory->addMetadata(new ClassMetadata(CascadingEntity::class)); + $this->metadataFactory->addMetadata((new ClassMetadata(CascadedChild::class)) + ->addPropertyConstraint('name', new NotNull()) + ); + + $entity = new CascadingEntity(); + $entity->requiredChild = new CascadedChild(); + $entity->optionalChild = new CascadedChild(); + $entity->children[] = new CascadedChild(); + CascadingEntity::$staticChild = new CascadedChild(); + + $violations = $this->validator->validate($entity); + + $this->assertCount(0, $violations); + + CascadingEntity::$staticChild = null; + } + + /** + * @requires PHP 7.4 + */ + public function testValidateTraverseNestedArrayByDefaultIfConstrainedWithoutCascading() + { + $this->metadataFactory->addMetadata((new ClassMetadata(CascadingEntity::class)) + ->addPropertyConstraint('children', new All([ + new Type(CascadedChild::class), + ])) + ); + $this->metadataFactory->addMetadata((new ClassMetadata(CascadedChild::class)) + ->addPropertyConstraint('name', new NotNull()) + ); + + $entity = new CascadingEntity(); + $entity->children[] = new \stdClass(); + $entity->children[] = new CascadedChild(); + + $violations = $this->validator->validate($entity); + + $this->assertCount(1, $violations); + $this->assertInstanceOf(Type::class, $violations->get(0)->getConstraint()); + } + + /** + * @requires PHP 7.4 + */ + public function testValidateCascadeWithValid() + { + $this->metadataFactory->addMetadata((new ClassMetadata(CascadingEntity::class)) + ->addPropertyConstraint('requiredChild', new Valid()) + ->addPropertyConstraint('optionalChild', new Valid()) + ->addPropertyConstraint('staticChild', new Valid()) + ->addPropertyConstraint('children', new Valid()) + ); + $this->metadataFactory->addMetadata((new ClassMetadata(CascadedChild::class)) + ->addPropertyConstraint('name', new NotNull()) + ); + + $entity = new CascadingEntity(); + $entity->requiredChild = new CascadedChild(); + $entity->children[] = new CascadedChild(); + $entity->children[] = null; + CascadingEntity::$staticChild = new CascadedChild(); + + $violations = $this->validator->validate($entity); + + $this->assertCount(3, $violations); + $this->assertInstanceOf(NotNull::class, $violations->get(0)->getConstraint()); + $this->assertInstanceOf(NotNull::class, $violations->get(1)->getConstraint()); + $this->assertInstanceOf(NotNull::class, $violations->get(2)->getConstraint()); + $this->assertSame('requiredChild.name', $violations->get(0)->getPropertyPath()); + $this->assertSame('staticChild.name', $violations->get(1)->getPropertyPath()); + $this->assertSame('children[0].name', $violations->get(2)->getPropertyPath()); + + CascadingEntity::$staticChild = null; + } + + /** + * @requires PHP 7.4 + */ + public function testValidateWithExplicitCascade() + { + $this->metadataFactory->addMetadata((new ClassMetadata(CascadingEntity::class)) + ->addConstraint(new Cascade()) + ); + $this->metadataFactory->addMetadata((new ClassMetadata(CascadedChild::class)) + ->addPropertyConstraint('name', new NotNull()) + ); + + $entity = new CascadingEntity(); + $entity->requiredChild = new CascadedChild(); + $entity->children[] = new CascadedChild(); + $entity->children[] = null; + CascadingEntity::$staticChild = new CascadedChild(); + + $violations = $this->validator->validate($entity); + + $this->assertCount(3, $violations); + $this->assertInstanceOf(NotNull::class, $violations->get(0)->getConstraint()); + $this->assertInstanceOf(NotNull::class, $violations->get(1)->getConstraint()); + $this->assertInstanceOf(NotNull::class, $violations->get(2)->getConstraint()); + $this->assertSame('requiredChild.name', $violations->get(0)->getPropertyPath()); + $this->assertSame('staticChild.name', $violations->get(1)->getPropertyPath()); + $this->assertSame('children[0].name', $violations->get(2)->getPropertyPath()); + + CascadingEntity::$staticChild = null; + } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy