diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index df48d15b3f..32d22c4220 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 0000000000..a5566eaa4e --- /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 a5418e1896..41520ccb19 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 @@ class ClassMetadata extends GenericMetadata implements ClassMetadataInterface /** * {@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 @@ class ClassMetadata extends GenericMetadata implements ClassMetadataInterface 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 @@ class ClassMetadata extends GenericMetadata implements ClassMetadataInterface } /** - * 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 f470f1d98d..06971e8f92 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 @@ class GenericMetadata implements MetadataInterface * * @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 0000000000..e4911279d9 --- /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 0000000000..88ea02d81f --- /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 bbe3475ebd..9f0ab71b62 100644 --- a/src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php +++ b/src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php @@ -13,8 +13,12 @@ namespace Symfony\Component\Validator\Tests\Mapping; 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 @@ class ClassMetadataTest extends TestCase { $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 06f7e85775..5d82a2ba34 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\ConstraintViolationInterface; 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 @@ abstract class AbstractTest extends AbstractValidatorTest $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 1ebe1534ab..ec2d8f1eec 100644 --- a/src/Symfony/Component/Validator/Tests/Validator/RecursiveValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Validator/RecursiveValidatorTest.php @@ -13,6 +13,7 @@ namespace Symfony\Component\Validator\Tests\Validator; 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\NotBlank; 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\ClassMetadata; 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 @@ class RecursiveValidatorTest extends AbstractTest $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; + } }