Skip to content

Commit bea6c99

Browse files
committed
feature #36352 [Validator] Added support for cascade validation on typed properties (HeahDude)
This PR was merged into the 5.2-dev branch. Discussion ---------- [Validator] Added support for cascade validation on typed properties | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | ~ | License | MIT | Doc PR | TODO This PR leverages PHP 7.4 property types to "guess" typed object members and enable default cascade validation on nested objects. Before: ```php use Symfony\Component\Validator\Constraints as Assert; class Composite { /** * @var self[] * * @Assert\Valid */ public array $children; /** * @Assert\Valid */ public ?self $parent; /** * @Assert\Valid */ public static ?self $root; } ``` After: ```php use Symfony\Component\Validator\Constraints as Assert; /** * @Assert\Cascade */ class Composite { /* * @var self[] */ public array $children; public ?self $parent; public static ?self $root; } ``` The constraint can also be used in xml, yaml, and of course raw PHP. ___________ Question: is the naming ok, maybe we could use `CascadeValid` to be more explicit? Commits ------- f4679ef [Validator] Added support for cascade validation on typed properties
2 parents f1dc422 + f4679ef commit bea6c99

File tree

9 files changed

+356
-6
lines changed

9 files changed

+356
-6
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ CHANGELOG
3232
5.1.0
3333
-----
3434

35+
* added a `Cascade` constraint to ease validating typed nested objects
3536
* added the `Hostname` constraint and validator
3637
* added the `alpha3` option to the `Country` and `Language` constraints
3738
* allow to define a reusable set of constraints by extending the `Compound` constraint
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
16+
17+
/**
18+
* @Annotation
19+
* @Target({"CLASS"})
20+
*
21+
* @author Jules Pietri <jules@heahprod.com>
22+
*/
23+
class Cascade extends Constraint
24+
{
25+
public function __construct($options = null)
26+
{
27+
if (\is_array($options) && \array_key_exists('groups', $options)) {
28+
throw new ConstraintDefinitionException(sprintf('The option "groups" is not supported by the constraint "%s".', __CLASS__));
29+
}
30+
31+
parent::__construct($options);
32+
}
33+
34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public function getTargets()
38+
{
39+
return self::CLASS_CONSTRAINT;
40+
}
41+
}

src/Symfony/Component/Validator/Mapping/ClassMetadata.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
namespace Symfony\Component\Validator\Mapping;
1313

1414
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\Constraints\Cascade;
1516
use Symfony\Component\Validator\Constraints\GroupSequence;
1617
use Symfony\Component\Validator\Constraints\Traverse;
18+
use Symfony\Component\Validator\Constraints\Valid;
1719
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
1820
use Symfony\Component\Validator\Exception\GroupDefinitionException;
1921

@@ -170,6 +172,17 @@ public function getDefaultGroup()
170172

171173
/**
172174
* {@inheritdoc}
175+
*
176+
* If the constraint {@link Cascade} is added, the cascading strategy will be
177+
* changed to {@link CascadingStrategy::CASCADE}.
178+
*
179+
* If the constraint {@link Traverse} is added, the traversal strategy will be
180+
* changed. Depending on the $traverse property of that constraint,
181+
* the traversal strategy will be set to one of the following:
182+
*
183+
* - {@link TraversalStrategy::IMPLICIT} by default
184+
* - {@link TraversalStrategy::NONE} if $traverse is disabled
185+
* - {@link TraversalStrategy::TRAVERSE} if $traverse is enabled
173186
*/
174187
public function addConstraint(Constraint $constraint)
175188
{
@@ -190,6 +203,23 @@ public function addConstraint(Constraint $constraint)
190203
return $this;
191204
}
192205

206+
if ($constraint instanceof Cascade) {
207+
if (\PHP_VERSION_ID < 70400) {
208+
throw new ConstraintDefinitionException(sprintf('The constraint "%s" requires PHP 7.4.', Cascade::class));
209+
}
210+
211+
$this->cascadingStrategy = CascadingStrategy::CASCADE;
212+
213+
foreach ($this->getReflectionClass()->getProperties() as $property) {
214+
if ($property->hasType() && (('array' === $type = $property->getType()->getName()) || class_exists(($type)))) {
215+
$this->addPropertyConstraint($property->getName(), new Valid());
216+
}
217+
}
218+
219+
// The constraint is not added
220+
return $this;
221+
}
222+
193223
$constraint->addImplicitGroupName($this->getDefaultGroup());
194224

195225
parent::addConstraint($constraint);
@@ -459,13 +489,11 @@ public function isGroupSequenceProvider()
459489
}
460490

461491
/**
462-
* Class nodes are never cascaded.
463-
*
464492
* {@inheritdoc}
465493
*/
466494
public function getCascadingStrategy()
467495
{
468-
return CascadingStrategy::NONE;
496+
return $this->cascadingStrategy;
469497
}
470498

471499
private function addPropertyMetadata(PropertyMetadataInterface $metadata)

src/Symfony/Component/Validator/Mapping/GenericMetadata.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Validator\Mapping;
1313

1414
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\Constraints\Cascade;
1516
use Symfony\Component\Validator\Constraints\DisableAutoMapping;
1617
use Symfony\Component\Validator\Constraints\EnableAutoMapping;
1718
use Symfony\Component\Validator\Constraints\Traverse;
@@ -132,12 +133,12 @@ public function __clone()
132133
*
133134
* @return $this
134135
*
135-
* @throws ConstraintDefinitionException When trying to add the
136-
* {@link Traverse} constraint
136+
* @throws ConstraintDefinitionException When trying to add the {@link Cascade}
137+
* or {@link Traverse} constraint
137138
*/
138139
public function addConstraint(Constraint $constraint)
139140
{
140-
if ($constraint instanceof Traverse) {
141+
if ($constraint instanceof Traverse || $constraint instanceof Cascade) {
141142
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)));
142143
}
143144

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Fixtures;
13+
14+
class CascadedChild
15+
{
16+
public $name;
17+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Fixtures;
13+
14+
class CascadingEntity
15+
{
16+
public string $scalar;
17+
18+
public CascadedChild $requiredChild;
19+
20+
public ?CascadedChild $optionalChild;
21+
22+
public static ?CascadedChild $staticChild;
23+
24+
/**
25+
* @var CascadedChild[]
26+
*/
27+
public array $children;
28+
}

src/Symfony/Component/Validator/Tests/Mapping/ClassMetadataTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Validator\Constraint;
16+
use Symfony\Component\Validator\Constraints\Cascade;
1617
use Symfony\Component\Validator\Constraints\Valid;
18+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
19+
use Symfony\Component\Validator\Mapping\CascadingStrategy;
1720
use Symfony\Component\Validator\Mapping\ClassMetadata;
21+
use Symfony\Component\Validator\Tests\Fixtures\CascadingEntity;
1822
use Symfony\Component\Validator\Tests\Fixtures\ConstraintA;
1923
use Symfony\Component\Validator\Tests\Fixtures\ConstraintB;
2024
use Symfony\Component\Validator\Tests\Fixtures\PropertyConstraint;
@@ -310,4 +314,36 @@ public function testGetPropertyMetadataReturnsEmptyArrayWithoutConfiguredMetadat
310314
{
311315
$this->assertCount(0, $this->metadata->getPropertyMetadata('foo'), '->getPropertyMetadata() returns an empty collection if no metadata is configured for the given property');
312316
}
317+
318+
/**
319+
* @requires PHP < 7.4
320+
*/
321+
public function testCascadeConstraintIsNotAvailable()
322+
{
323+
$metadata = new ClassMetadata(CascadingEntity::class);
324+
325+
$this->expectException(ConstraintDefinitionException::class);
326+
$this->expectExceptionMessage('The constraint "Symfony\Component\Validator\Constraints\Cascade" requires PHP 7.4.');
327+
328+
$metadata->addConstraint(new Cascade());
329+
}
330+
331+
/**
332+
* @requires PHP 7.4
333+
*/
334+
public function testCascadeConstraint()
335+
{
336+
$metadata = new ClassMetadata(CascadingEntity::class);
337+
338+
$metadata->addConstraint(new Cascade());
339+
340+
$this->assertSame(CascadingStrategy::CASCADE, $metadata->getCascadingStrategy());
341+
$this->assertCount(4, $metadata->properties);
342+
$this->assertSame([
343+
'requiredChild',
344+
'optionalChild',
345+
'staticChild',
346+
'children',
347+
], $metadata->getConstrainedProperties());
348+
}
313349
}

src/Symfony/Component/Validator/Tests/Validator/AbstractTest.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Validator\Tests\Validator;
1313

1414
use Symfony\Component\Validator\Constraints\Callback;
15+
use Symfony\Component\Validator\Constraints\Cascade;
1516
use Symfony\Component\Validator\Constraints\Collection;
1617
use Symfony\Component\Validator\Constraints\Expression;
1718
use Symfony\Component\Validator\Constraints\GroupSequence;
@@ -23,6 +24,8 @@
2324
use Symfony\Component\Validator\Context\ExecutionContextInterface;
2425
use Symfony\Component\Validator\Mapping\ClassMetadata;
2526
use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface;
27+
use Symfony\Component\Validator\Tests\Fixtures\CascadedChild;
28+
use Symfony\Component\Validator\Tests\Fixtures\CascadingEntity;
2629
use Symfony\Component\Validator\Tests\Fixtures\Entity;
2730
use Symfony\Component\Validator\Tests\Fixtures\FailingConstraint;
2831
use Symfony\Component\Validator\Tests\Fixtures\Reference;
@@ -497,6 +500,85 @@ public function testReferenceTraversalDisabledOnReferenceEnabledOnClass()
497500
$this->assertCount(0, $violations);
498501
}
499502

503+
public function testReferenceCascadeDisabledByDefault()
504+
{
505+
$entity = new Entity();
506+
$entity->reference = new Reference();
507+
508+
$callback = function ($value, ExecutionContextInterface $context) {
509+
$this->fail('Should not be called');
510+
};
511+
512+
$this->referenceMetadata->addConstraint(new Callback([
513+
'callback' => $callback,
514+
'groups' => 'Group',
515+
]));
516+
517+
$violations = $this->validate($entity, new Valid(), 'Group');
518+
519+
/* @var ConstraintViolationInterface[] $violations */
520+
$this->assertCount(0, $violations);
521+
}
522+
523+
/**
524+
* @requires PHP 7.4
525+
*/
526+
public function testReferenceCascadeEnabledIgnoresUntyped()
527+
{
528+
$entity = new Entity();
529+
$entity->reference = new Reference();
530+
531+
$this->metadata->addConstraint(new Cascade());
532+
533+
$callback = function ($value, ExecutionContextInterface $context) {
534+
$this->fail('Should not be called');
535+
};
536+
537+
$this->referenceMetadata->addConstraint(new Callback([
538+
'callback' => $callback,
539+
'groups' => 'Group',
540+
]));
541+
542+
$violations = $this->validate($entity, new Valid(), 'Group');
543+
544+
/* @var ConstraintViolationInterface[] $violations */
545+
$this->assertCount(0, $violations);
546+
}
547+
548+
/**
549+
* @requires PHP 7.4
550+
*/
551+
public function testTypedReferenceCascadeEnabled()
552+
{
553+
$entity = new CascadingEntity();
554+
$entity->requiredChild = new CascadedChild();
555+
556+
$callback = function ($value, ExecutionContextInterface $context) {
557+
$context->buildViolation('Invalid child')
558+
->atPath('name')
559+
->addViolation()
560+
;
561+
};
562+
563+
$cascadingMetadata = new ClassMetadata(CascadingEntity::class);
564+
$cascadingMetadata->addConstraint(new Cascade());
565+
566+
$cascadedMetadata = new ClassMetadata(CascadedChild::class);
567+
$cascadedMetadata->addConstraint(new Callback([
568+
'callback' => $callback,
569+
'groups' => 'Group',
570+
]));
571+
572+
$this->metadataFactory->addMetadata($cascadingMetadata);
573+
$this->metadataFactory->addMetadata($cascadedMetadata);
574+
575+
$violations = $this->validate($entity, new Valid(), 'Group');
576+
577+
/* @var ConstraintViolationInterface[] $violations */
578+
$this->assertCount(1, $violations);
579+
$this->assertInstanceOf(Callback::class, $violations->get(0)->getConstraint());
580+
}
581+
500582
public function testAddCustomizedViolation()
501583
{
502584
$entity = new Entity();

0 commit comments

Comments
 (0)