Skip to content

Commit e81ea87

Browse files
committed
feature #51348 [FrameworkBundle][Validator] Allow implementing validation groups provider outside DTOs (Yonel Ceruto)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [FrameworkBundle][Validator] Allow implementing validation groups provider outside DTOs | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | symfony/symfony-docs#18744 Alternative to symfony/symfony#51233 Inspiration: symfony/symfony#51012 Currently, you can determine the sequence of groups to apply dynamically based on the state of your DTO by implementing the `GroupSequenceProviderInterface` in your DTO class. https://symfony.com/doc/current/validation/sequence_provider.html#group-sequence-providers ```php use Symfony\Component\Validator\GroupSequenceProviderInterface; #[Assert\GroupSequenceProvider] class UserDto implements GroupSequenceProviderInterface { // ... public function getGroupSequence(): array|GroupSequence { if ($this->isCompanyType()) { return ['User', 'Company']; } return ['User']; } } ``` It covers most of the common scenarios, but for more advanced ones, it may not be sufficient. Suppose now you need to provide the sequence of groups from an external configuration (or service) which can change its value dynamically: ```php #[Assert\GroupSequenceProvider] class UserDto implements GroupSequenceProviderInterface { // ... public __constructor(private readonly ConfigService $config) { } public function getGroupSequence(): array|GroupSequence { if ($this->config->isEnabled()) { return ['User', $this->config->getGroup()]; } return ['User']; } } ``` This issue cannot be resolved at present without managing the DTO initialization and manually setting its dependencies. On the other hand, since the state of the DTO is not used at all, the implementation of the `GroupSequenceProviderInterface` becomes less fitting to the DTO responsibility. Further, stricter programming may raise a complaint about a violation of SOLID principles here. So, the proposal of this PR is to allow configuring the validation groups provider outside of the DTO, while simultaneously enabling the registration of this provider as a service if necessary. To achieve this, you'll need to implement a new `GroupProviderInterface` in a separate class, and configure it using the new `provider` option within the `GroupSequenceProvider` attribute: ```php #[Assert\GroupSequenceProvider(provider: UserGroupProvider::class)] class UserDto { // ... } class UserGroupProvider implements GroupProviderInterface { public __constructor(private readonly ConfigService $config) { } public function getGroups(object $object): array|GroupSequence { if ($this->config->isEnabled()) { return ['User', $this->config->getGroup()]; } return ['User']; } } ``` That's all you'll need to do if autowiring is enabled under your custom provider. Otherwise, you can manually tag your service with `validator.group_provider` to collect it and utilize it as a provider service during the validation process. In conclusion, no more messing with the DTO structure, just use the new `class` option for more advanced use cases. --- TODO: - [x] Add tests - [x] Create doc PR Commits ------- a3a089a15b8 [FrameworkBundle][Validator] Allow implementing validation groups provider outside DTOs
2 parents 4d7ab48 + 0fb9e1f commit e81ea87

20 files changed

+265
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ CHANGELOG
1515
* Deprecate `ValidatorBuilder::enableAnnotationMapping()`, use `ValidatorBuilder::enableAttributeMapping()` instead
1616
* Deprecate `ValidatorBuilder::disableAnnotationMapping()`, use `ValidatorBuilder::disableAttributeMapping()` instead
1717
* Deprecate `AnnotationLoader`, use `AttributeLoader` instead
18+
* Add `GroupProviderInterface` to implement validation group providers outside the underlying class
1819

1920
6.3
2021
---

Constraints/GroupSequenceProvider.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,25 @@
1111

1212
namespace Symfony\Component\Validator\Constraints;
1313

14+
use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor;
15+
use Symfony\Component\Validator\Attribute\HasNamedArguments;
16+
1417
/**
1518
* Attribute to define a group sequence provider.
1619
*
1720
* @Annotation
1821
*
22+
* @NamedArgumentConstructor
23+
*
1924
* @Target({"CLASS", "ANNOTATION"})
2025
*
2126
* @author Bernhard Schussek <bschussek@gmail.com>
2227
*/
2328
#[\Attribute(\Attribute::TARGET_CLASS)]
2429
class GroupSequenceProvider
2530
{
31+
#[HasNamedArguments]
32+
public function __construct(public ?string $provider = null)
33+
{
34+
}
2635
}

GroupProviderInterface.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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;
13+
14+
use Symfony\Component\Validator\Constraints\GroupSequence;
15+
16+
/**
17+
* Defines the interface for a validation group provider.
18+
*
19+
* @author Yonel Ceruto <yonelceruto@gmail.com>
20+
*/
21+
interface GroupProviderInterface
22+
{
23+
/**
24+
* Returns which validation groups should be used for a certain state
25+
* of the object.
26+
*
27+
* @return string[]|string[][]|GroupSequence
28+
*/
29+
public function getGroups(object $object): array|GroupSequence;
30+
}

Mapping/ClassMetadata.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ class ClassMetadata extends GenericMetadata implements ClassMetadataInterface
8686
*/
8787
public bool $groupSequenceProvider = false;
8888

89+
/**
90+
* @internal This property is public in order to reduce the size of the
91+
* class' serialized representation. Do not access it. Use
92+
* {@link getGroupProvider()} instead.
93+
*/
94+
public ?string $groupProvider = null;
95+
8996
/**
9097
* The strategy for traversing traversable objects.
9198
*
@@ -123,6 +130,7 @@ public function __sleep(): array
123130
'getters',
124131
'groupSequence',
125132
'groupSequenceProvider',
133+
'groupProvider',
126134
'members',
127135
'name',
128136
'properties',
@@ -319,6 +327,7 @@ public function addGetterMethodConstraints(string $property, string $method, arr
319327
public function mergeConstraints(self $source)
320328
{
321329
if ($source->isGroupSequenceProvider()) {
330+
$this->setGroupProvider($source->getGroupProvider());
322331
$this->setGroupSequenceProvider(true);
323332
}
324333

@@ -432,7 +441,7 @@ public function setGroupSequenceProvider(bool $active)
432441
throw new GroupDefinitionException('Defining a group sequence provider is not allowed with a static group sequence.');
433442
}
434443

435-
if (!$this->getReflectionClass()->implementsInterface(GroupSequenceProviderInterface::class)) {
444+
if (null === $this->groupProvider && !$this->getReflectionClass()->implementsInterface(GroupSequenceProviderInterface::class)) {
436445
throw new GroupDefinitionException(sprintf('Class "%s" must implement GroupSequenceProviderInterface.', $this->name));
437446
}
438447

@@ -444,6 +453,16 @@ public function isGroupSequenceProvider(): bool
444453
return $this->groupSequenceProvider;
445454
}
446455

456+
public function setGroupProvider(?string $provider): void
457+
{
458+
$this->groupProvider = $provider;
459+
}
460+
461+
public function getGroupProvider(): ?string
462+
{
463+
return $this->groupProvider;
464+
}
465+
447466
public function getCascadingStrategy(): int
448467
{
449468
return $this->cascadingStrategy;

Mapping/ClassMetadataInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
* @see GroupSequence
3131
* @see GroupSequenceProviderInterface
3232
* @see TraversalStrategy
33+
*
34+
* @method string|null getGroupProvider()
3335
*/
3436
interface ClassMetadataInterface extends MetadataInterface
3537
{

Mapping/Loader/AnnotationLoader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool
5151
if ($constraint instanceof GroupSequence) {
5252
$metadata->setGroupSequence($constraint->groups);
5353
} elseif ($constraint instanceof GroupSequenceProvider) {
54+
$metadata->setGroupProvider($constraint->provider);
5455
$metadata->setGroupSequenceProvider(true);
5556
} elseif ($constraint instanceof Constraint) {
5657
$metadata->addConstraint($constraint);

Mapping/Loader/XmlFileLoader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ private function loadClassesFromXml(): void
201201
private function loadClassMetadataFromXml(ClassMetadata $metadata, \SimpleXMLElement $classDescription): void
202202
{
203203
if (\count($classDescription->{'group-sequence-provider'}) > 0) {
204+
$metadata->setGroupProvider($classDescription->{'group-sequence-provider'}[0]->value ?: null);
204205
$metadata->setGroupSequenceProvider(true);
205206
}
206207

Mapping/Loader/YamlFileLoader.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ private function loadClassesFromYaml(): void
150150
private function loadClassMetadataFromYaml(ClassMetadata $metadata, array $classDescription): void
151151
{
152152
if (isset($classDescription['group_sequence_provider'])) {
153+
if (\is_string($classDescription['group_sequence_provider'])) {
154+
$metadata->setGroupProvider($classDescription['group_sequence_provider']);
155+
}
153156
$metadata->setGroupSequenceProvider(
154157
(bool) $classDescription['group_sequence_provider']
155158
);

Mapping/Loader/schema/dic/constraint-mapping/constraint-mapping-1.0.xsd

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
validation constraints.
1515
]]></xsd:documentation>
1616
</xsd:annotation>
17-
17+
1818
<xsd:element name="constraint-mapping" type="constraint-mapping" />
19-
19+
2020
<xsd:complexType name="constraint-mapping">
2121
<xsd:annotation>
2222
<xsd:documentation><![CDATA[
@@ -28,7 +28,7 @@
2828
<xsd:element name="class" type="class" maxOccurs="unbounded" />
2929
</xsd:sequence>
3030
</xsd:complexType>
31-
31+
3232
<xsd:complexType name="namespace">
3333
<xsd:annotation>
3434
<xsd:documentation><![CDATA[
@@ -41,13 +41,13 @@
4141
</xsd:extension>
4242
</xsd:simpleContent>
4343
</xsd:complexType>
44-
44+
4545
<xsd:complexType name="class">
4646
<xsd:annotation>
4747
<xsd:documentation><![CDATA[
4848
Contains constraints for a single class.
49-
50-
Nested elements may be class constraints, property and/or getter
49+
50+
Nested elements may be class constraints, property and/or getter
5151
definitions.
5252
]]></xsd:documentation>
5353
</xsd:annotation>
@@ -72,15 +72,18 @@
7272
<xsd:element name="value" type="value" minOccurs="1" maxOccurs="unbounded" />
7373
</xsd:sequence>
7474
</xsd:complexType>
75-
75+
7676
<xsd:complexType name="group-sequence-provider">
7777
<xsd:annotation>
7878
<xsd:documentation><![CDATA[
7979
Defines the name of the group sequence provider for a class.
8080
]]></xsd:documentation>
8181
</xsd:annotation>
82+
<xsd:sequence>
83+
<xsd:element name="value" type="value" minOccurs="0" maxOccurs="unbounded" />
84+
</xsd:sequence>
8285
</xsd:complexType>
83-
86+
8487
<xsd:complexType name="property">
8588
<xsd:annotation>
8689
<xsd:documentation><![CDATA[
@@ -93,7 +96,7 @@
9396
</xsd:sequence>
9497
<xsd:attribute name="name" type="xsd:string" use="required" />
9598
</xsd:complexType>
96-
99+
97100
<xsd:complexType name="getter">
98101
<xsd:annotation>
99102
<xsd:documentation><![CDATA[
@@ -106,14 +109,14 @@
106109
</xsd:sequence>
107110
<xsd:attribute name="property" type="xsd:string" use="required" />
108111
</xsd:complexType>
109-
112+
110113
<xsd:complexType name="constraint" mixed="true">
111114
<xsd:annotation>
112115
<xsd:documentation><![CDATA[
113116
Contains a constraint definition. The name of the constraint should be
114117
given in the "name" option.
115-
116-
May contain a single value, multiple "constraint" elements,
118+
119+
May contain a single value, multiple "constraint" elements,
117120
multiple "value" elements or multiple "option" elements.
118121
]]></xsd:documentation>
119122
</xsd:annotation>
@@ -122,15 +125,15 @@
122125
<xsd:element name="option" type="option" minOccurs="1" maxOccurs="unbounded" />
123126
<xsd:element name="value" type="value" minOccurs="1" maxOccurs="unbounded" />
124127
</xsd:choice>
125-
<xsd:attribute name="name" type="xsd:string" use="required" />
128+
<xsd:attribute name="name" type="xsd:string" use="required" />
126129
</xsd:complexType>
127-
130+
128131
<xsd:complexType name="option" mixed="true">
129132
<xsd:annotation>
130133
<xsd:documentation><![CDATA[
131134
Contains a constraint option definition. The name of the option
132135
should be given in the "name" option.
133-
136+
134137
May contain a single value, multiple "value" elements or multiple
135138
"constraint" elements.
136139
]]></xsd:documentation>
@@ -139,14 +142,14 @@
139142
<xsd:element name="constraint" type="constraint" minOccurs="1" maxOccurs="unbounded" />
140143
<xsd:element name="value" type="value" minOccurs="1" maxOccurs="unbounded" />
141144
</xsd:choice>
142-
<xsd:attribute name="name" type="xsd:string" use="required" />
145+
<xsd:attribute name="name" type="xsd:string" use="required" />
143146
</xsd:complexType>
144-
147+
145148
<xsd:complexType name="value" mixed="true">
146149
<xsd:annotation>
147150
<xsd:documentation><![CDATA[
148151
A value of an element.
149-
152+
150153
May contain a single value, multiple "value" elements or multiple
151154
"constraint" elements.
152155
]]></xsd:documentation>
@@ -155,6 +158,6 @@
155158
<xsd:element name="constraint" type="constraint" minOccurs="1" maxOccurs="unbounded" />
156159
<xsd:element name="value" type="value" minOccurs="1" maxOccurs="unbounded" />
157160
</xsd:choice>
158-
<xsd:attribute name="key" type="xsd:string" use="optional" />
161+
<xsd:attribute name="key" type="xsd:string" use="optional" />
159162
</xsd:complexType>
160163
</xsd:schema>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Constraints\GroupSequenceProvider;
16+
use Symfony\Component\Validator\Tests\Dummy\DummyGroupProvider;
17+
18+
class GroupSequenceProviderTest extends TestCase
19+
{
20+
public function testCreateAttributeStyle()
21+
{
22+
$sequence = new GroupSequenceProvider(provider: DummyGroupProvider::class);
23+
24+
$this->assertSame(DummyGroupProvider::class, $sequence->provider);
25+
}
26+
}

Tests/Dummy/DummyGroupProvider.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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\Dummy;
13+
14+
use Symfony\Component\Validator\Constraints\GroupSequence;
15+
use Symfony\Component\Validator\GroupProviderInterface;
16+
17+
class DummyGroupProvider implements GroupProviderInterface
18+
{
19+
public function getGroups(object $object): array|GroupSequence
20+
{
21+
return ['foo', 'bar'];
22+
}
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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\Attribute;
13+
14+
use Symfony\Component\Validator\Constraints as Assert;
15+
use Symfony\Component\Validator\Tests\Dummy\DummyGroupProvider;
16+
17+
#[Assert\GroupSequenceProvider(provider: DummyGroupProvider::class)]
18+
class GroupProviderDto
19+
{
20+
public string $firstName = '';
21+
public string $lastName = '';
22+
}

Tests/Mapping/Loader/XmlFileLoaderTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
use Symfony\Component\Validator\Exception\MappingException;
2525
use Symfony\Component\Validator\Mapping\ClassMetadata;
2626
use Symfony\Component\Validator\Mapping\Loader\XmlFileLoader;
27+
use Symfony\Component\Validator\Tests\Dummy\DummyGroupProvider;
28+
use Symfony\Component\Validator\Tests\Fixtures\Attribute\GroupProviderDto;
2729
use Symfony\Component\Validator\Tests\Fixtures\ConstraintA;
2830
use Symfony\Component\Validator\Tests\Fixtures\ConstraintB;
2931
use Symfony\Component\Validator\Tests\Fixtures\ConstraintWithRequiredArgument;
@@ -126,6 +128,20 @@ public function testLoadGroupSequenceProvider()
126128
$this->assertEquals($expected, $metadata);
127129
}
128130

131+
public function testLoadGroupProvider()
132+
{
133+
$loader = new XmlFileLoader(__DIR__.'/constraint-mapping.xml');
134+
$metadata = new ClassMetadata(GroupProviderDto::class);
135+
136+
$loader->loadClassMetadata($metadata);
137+
138+
$expected = new ClassMetadata(GroupProviderDto::class);
139+
$expected->setGroupProvider(DummyGroupProvider::class);
140+
$expected->setGroupSequenceProvider(true);
141+
142+
$this->assertEquals($expected, $metadata);
143+
}
144+
129145
public function testThrowExceptionIfDocTypeIsSet()
130146
{
131147
$loader = new XmlFileLoader(__DIR__.'/withdoctype.xml');

0 commit comments

Comments
 (0)