Skip to content

[Validator] Added support for cascade validation on typed properties #36352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ CHANGELOG
5.1.0
-----

* added a `Cascade` constraint to ease validating typed nested objects
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"[...] validating nested typed object properties"?

* 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
Expand Down
41 changes: 41 additions & 0 deletions src/Symfony/Component/Validator/Constraints/Cascade.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
34 changes: 31 additions & 3 deletions src/Symfony/Component/Validator/Mapping/ClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
{
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions src/Symfony/Component/Validator/Mapping/GenericMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
}

Expand Down
17 changes: 17 additions & 0 deletions src/Symfony/Component/Validator/Tests/Fixtures/CascadedChild.php
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions src/Symfony/Component/Validator/Tests/Fixtures/CascadingEntity.php
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
82 changes: 82 additions & 0 deletions src/Symfony/Component/Validator/Tests/Validator/AbstractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading