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