[Validator] Added support for cascade validation on typed properties

This commit is contained in:
Jules Pietri 2020-04-05 18:34:10 +02:00 committed by Fabien Potencier
parent f1dc422394
commit f4679ef08a
9 changed files with 356 additions and 6 deletions

View File

@ -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

View File

@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <jules@heahprod.com>
*/
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;
}
}

View File

@ -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)

View File

@ -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)));
}

View File

@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}

View File

@ -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());
}
}

View File

@ -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();

View File

@ -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;
}
}