Skip to content

Commit f603472

Browse files
committed
feature #61545 [Validator] Add #[ExtendsValidationFor] to declare new constraints for a class (nicolas-grekas)
This PR was merged into the 7.4 branch. Discussion ---------- [Validator] Add `#[ExtendsValidationFor]` to declare new constraints for a class | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT This PR builds on #61528 I propose to add a `#[ExtendsValidationFor]` attribute that allows adding validation constraints to another class. This is typically needed for third party classes. For context, Sylius has a nice doc about this: https://docs.sylius.com/the-customization-guide/customizing-validation At the moment, the only way to achieve this is by declaring the new constraints in the (hardcoded) `config/validation/` folder, using either xml or yaml. No attributes. With this PR, one will be able to define those extra constraints using PHP attributes, set on classes that'd mirror the properties/getters of the targeted class. The compiler pass will ensure that all properties/getters declared in these source classes also exist in the target class. (source = the app's class that declares the new constraints; target = the existing class to add constraints to.) ```php #[ExtendsValidationFor(TargetClass::class)] abstract class SourceClass { #[Assert\NotBlank(groups: ['my_app'])] #[Assert\Length(min: 3, groups: ['my_app'])] public string $name = ''; #[Assert\Email(groups: ['my_app'])] public string $email = ''; #[Assert\Range(min: 18, groups: ['my_app'])] public int $age = 0; } ``` (I made the class abstract because it's not supposed to be instantiated - but it's not mandatory.) Here are the basics of how this works: 1. During container compilation, classes marked with `#[ExtendsValidationFor(Target::class)]` are collected and validated: the container checks that members declared on the source exist on the target. If not, a `MappingException` is thrown. 2. The validator is configured to map the target to its source classes. 3. At runtime, when loading validation metadata for the target, attributes (constraints, callbacks, group providers) are read from both the target and its mapped source classes and applied accordingly. Commits ------- e884a76 [Validator] Add `#[ExtendsValidationFor]` to declare new constraints for a class
2 parents 66b0c12 + e884a76 commit f603472

File tree

8 files changed

+260
-16
lines changed

8 files changed

+260
-16
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@
216216
use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
217217
use Symfony\Component\Uid\Factory\UuidFactory;
218218
use Symfony\Component\Uid\UuidV4;
219+
use Symfony\Component\Validator\Attribute\ExtendsValidationFor;
219220
use Symfony\Component\Validator\Constraint;
220221
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider;
221222
use Symfony\Component\Validator\Constraints\Traverse;
@@ -1821,10 +1822,16 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
18211822
if (class_exists(ValidatorAttributeMetadataPass::class) && (!($config['enable_attributes'] ?? false) || !$container->getParameter('kernel.debug')) && trait_exists(ArgumentTrait::class)) {
18221823
// The $reflector argument hints at where the attribute could be used
18231824
$container->registerAttributeForAutoconfiguration(Constraint::class, function (ChildDefinition $definition, Constraint $attribute, \ReflectionClass|\ReflectionMethod|\ReflectionProperty $reflector) {
1824-
$definition->addTag('validator.attribute_metadata');
1825+
$definition->addTag('validator.attribute_metadata')
1826+
->addTag('container.excluded', ['source' => 'because it\'s a validator constraint extension']);
18251827
});
18261828
}
18271829

1830+
$container->registerAttributeForAutoconfiguration(ExtendsValidationFor::class, function (ChildDefinition $definition, ExtendsValidationFor $attribute) {
1831+
$definition->addTag('validator.attribute_metadata', ['for' => $attribute->class])
1832+
->addTag('container.excluded', ['source' => 'because it\'s a validator constraint extension']);
1833+
});
1834+
18281835
if ($config['enable_attributes'] ?? false) {
18291836
$validatorBuilder->addMethodCall('enableAttributeMapping');
18301837
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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\Attribute;
13+
14+
/**
15+
* Declares that constraints listed on the current class should be added to the given class.
16+
*
17+
* Classes that use this attribute should contain only properties and methods that
18+
* exist on the target class (not necessarily all of them).
19+
*
20+
* @author Nicolas Grekas <p@tchwork.com>
21+
*/
22+
#[\Attribute(\Attribute::TARGET_CLASS)]
23+
final class ExtendsValidationFor
24+
{
25+
/**
26+
* @param class-string $class
27+
*/
28+
public function __construct(
29+
public string $class,
30+
) {
31+
}
32+
}

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add `#[ExtendsValidationFor]` to declare new constraints for a class
78
* Add `ValidatorBuilder::addAttributeMappings()` and `AttributeMetadataPass` to declare compile-time constraint metadata using attributes
89
* Add the `Video` constraint for validating video files
910
* Deprecate implementing `__sleep/wakeup()` on `GenericMetadata` implementations; use `__(un)serialize()` instead

src/Symfony/Component/Validator/DependencyInjection/AttributeMetadataPass.php

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
1616
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
17+
use Symfony\Component\Validator\Exception\MappingException;
1718

1819
/**
1920
* @author Nicolas Grekas <p@tchwork.com>
@@ -35,7 +36,14 @@ public function process(ContainerBuilder $container): void
3536
if (!$definition->hasTag('container.excluded')) {
3637
throw new InvalidArgumentException(\sprintf('The resource "%s" tagged "validator.attribute_metadata" is missing the "container.excluded" tag.', $id));
3738
}
38-
$mappedClasses[$resolve($definition->getClass())] = true;
39+
$class = $resolve($definition->getClass());
40+
foreach ($definition->getTag('validator.attribute_metadata') as $attributes) {
41+
if ($class !== $for = $attributes['for'] ?? $class) {
42+
$this->checkSourceMapsToTarget($container, $class, $for);
43+
}
44+
45+
$mappedClasses[$for][$class] = true;
46+
}
3947
}
4048

4149
if (!$mappedClasses) {
@@ -45,6 +53,24 @@ public function process(ContainerBuilder $container): void
4553
ksort($mappedClasses);
4654

4755
$container->getDefinition('validator.builder')
48-
->addMethodCall('addAttributeMappings', [array_keys($mappedClasses)]);
56+
->addMethodCall('addAttributeMappings', [array_map('array_keys', $mappedClasses)]);
57+
}
58+
59+
private function checkSourceMapsToTarget(ContainerBuilder $container, string $source, string $target): void
60+
{
61+
$source = $container->getReflectionClass($source);
62+
$target = $container->getReflectionClass($target);
63+
64+
foreach ($source->getProperties() as $p) {
65+
if ($p->class === $source->name && !($target->hasProperty($p->name) && $target->getProperty($p->name)->class === $target->name)) {
66+
throw new MappingException(\sprintf('The property "%s" on "%s" is not present on "%s".', $p->name, $source->name, $target->name));
67+
}
68+
}
69+
70+
foreach ($source->getMethods() as $m) {
71+
if ($m->class === $source->name && !($target->hasMethod($m->name) && $target->getMethod($m->name)->class === $target->name)) {
72+
throw new MappingException(\sprintf('The method "%s" on "%s" is not present on "%s".', $m->name, $source->name, $target->name));
73+
}
74+
}
4975
}
5076
}

src/Symfony/Component/Validator/Mapping/Loader/AttributeLoader.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
class AttributeLoader implements LoaderInterface
2929
{
3030
/**
31-
* @param class-string[] $mappedClasses
31+
* @param array<class-string, class-string[]> $mappedClasses
3232
*/
3333
public function __construct(
3434
private bool $allowAnyClass = true,
@@ -41,16 +41,26 @@ public function __construct(
4141
*/
4242
public function getMappedClasses(): array
4343
{
44-
return $this->mappedClasses;
44+
return array_keys($this->mappedClasses);
4545
}
4646

4747
public function loadClassMetadata(ClassMetadata $metadata): bool
4848
{
49-
if (!$this->allowAnyClass && !\in_array($metadata->getClassName(), $this->mappedClasses, true)) {
49+
if (!$sourceClasses = $this->mappedClasses[$metadata->getClassName()] ??= $this->allowAnyClass ? [$metadata->getClassName()] : []) {
5050
return false;
5151
}
5252

53-
$reflClass = $metadata->getReflectionClass();
53+
$success = false;
54+
foreach ($sourceClasses as $sourceClass) {
55+
$reflClass = $metadata->getClassName() === $sourceClass ? $metadata->getReflectionClass() : new \ReflectionClass($sourceClass);
56+
$success = $this->doLoadClassMetadata($reflClass, $metadata) || $success;
57+
}
58+
59+
return $success;
60+
}
61+
62+
public function doLoadClassMetadata(\ReflectionClass $reflClass, ClassMetadata $metadata): bool
63+
{
5464
$className = $reflClass->name;
5565
$success = false;
5666

src/Symfony/Component/Validator/Tests/DependencyInjection/AttributeMetadataPassTest.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
1617
use Symfony\Component\Validator\DependencyInjection\AttributeMetadataPass;
18+
use Symfony\Component\Validator\Exception\MappingException;
1719

1820
class AttributeMetadataPassTest extends TestCase
1921
{
@@ -67,7 +69,82 @@ public function testProcessWithTaggedServices()
6769
$this->assertEquals('addAttributeMappings', $methodCalls[1][0]);
6870

6971
// Classes should be sorted alphabetically
70-
$expectedClasses = ['App\Entity\Order', 'App\Entity\Product', 'App\Entity\User'];
72+
$expectedClasses = [
73+
'App\Entity\Order' => ['App\Entity\Order'],
74+
'App\Entity\Product' => ['App\Entity\Product'],
75+
'App\Entity\User' => ['App\Entity\User'],
76+
];
7177
$this->assertEquals([$expectedClasses], $methodCalls[1][1]);
7278
}
79+
80+
public function testThrowsWhenMissingExcludedTag()
81+
{
82+
$container = new ContainerBuilder();
83+
$container->register('validator.builder');
84+
85+
$container->register('service_without_excluded', 'App\\Entity\\User')
86+
->addTag('validator.attribute_metadata');
87+
88+
$this->expectException(InvalidArgumentException::class);
89+
(new AttributeMetadataPass())->process($container);
90+
}
91+
92+
public function testProcessWithForOptionAndMatchingMembers()
93+
{
94+
$sourceClass = _AttrMeta_Source::class;
95+
$targetClass = _AttrMeta_Target::class;
96+
97+
$container = new ContainerBuilder();
98+
$container->register('validator.builder');
99+
100+
$container->register('service.source', $sourceClass)
101+
->addTag('validator.attribute_metadata', ['for' => $targetClass])
102+
->addTag('container.excluded');
103+
104+
(new AttributeMetadataPass())->process($container);
105+
106+
$methodCalls = $container->getDefinition('validator.builder')->getMethodCalls();
107+
$this->assertNotEmpty($methodCalls);
108+
$this->assertSame('addAttributeMappings', $methodCalls[0][0]);
109+
$this->assertSame([$targetClass => [$sourceClass]], $methodCalls[0][1][0]);
110+
}
111+
112+
public function testProcessWithForOptionAndMissingMemberThrows()
113+
{
114+
$sourceClass = _AttrMeta_BadSource::class;
115+
$targetClass = _AttrMeta_Target::class;
116+
117+
$container = new ContainerBuilder();
118+
$container->register('validator.builder');
119+
120+
$container->register('service.source', $sourceClass)
121+
->addTag('validator.attribute_metadata', ['for' => $targetClass])
122+
->addTag('container.excluded');
123+
124+
$this->expectException(MappingException::class);
125+
(new AttributeMetadataPass())->process($container);
126+
}
127+
}
128+
129+
class _AttrMeta_Source
130+
{
131+
public string $name;
132+
133+
public function getName()
134+
{
135+
}
136+
}
137+
138+
class _AttrMeta_Target
139+
{
140+
public string $name;
141+
142+
public function getName()
143+
{
144+
}
145+
}
146+
147+
class _AttrMeta_BadSource
148+
{
149+
public string $extra;
73150
}

src/Symfony/Component/Validator/Tests/Mapping/Loader/AttributeLoaderTest.php

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

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Attribute\ExtendsValidationFor;
1516
use Symfony\Component\Validator\Constraints\All;
1617
use Symfony\Component\Validator\Constraints\AtLeastOneOf;
1718
use Symfony\Component\Validator\Constraints\Callback;
@@ -229,35 +230,122 @@ public function testLoadExternalGroupSequenceProvider()
229230

230231
public function testGetMappedClasses()
231232
{
232-
$classes = ['App\Entity\User', 'App\Entity\Product', 'App\Entity\Order'];
233+
$classes = [
234+
'App\Entity\User' => ['App\Entity\User'],
235+
'App\Entity\Product' => ['App\Entity\Product'],
236+
'App\Entity\Order' => ['App\Entity\Order'],
237+
];
233238
$loader = new AttributeLoader(false, $classes);
234239

235-
$this->assertSame($classes, $loader->getMappedClasses());
240+
$this->assertSame(array_keys($classes), $loader->getMappedClasses());
236241
}
237242

238243
public function testLoadClassMetadataReturnsFalseForUnmappedClass()
239244
{
240-
$loader = new AttributeLoader(false, ['App\Entity\User']);
245+
$loader = new AttributeLoader(false, ['App\Entity\User' => ['App\Entity\User']]);
241246
$metadata = new ClassMetadata('App\Entity\Product');
242247

243248
$this->assertFalse($loader->loadClassMetadata($metadata));
244249
}
245250

246251
public function testLoadClassMetadataReturnsFalseForClassWithoutAttributes()
247252
{
248-
$loader = new AttributeLoader(false, ['stdClass']);
253+
$loader = new AttributeLoader(false, ['stdClass' => ['stdClass']]);
249254
$metadata = new ClassMetadata('stdClass');
250255

251256
$this->assertFalse($loader->loadClassMetadata($metadata));
252257
}
253258

254259
public function testLoadClassMetadataForMappedClassWithAttributes()
255260
{
256-
$loader = new AttributeLoader(false, [Entity::class]);
261+
$loader = new AttributeLoader(false, [Entity::class => [Entity::class]]);
257262
$metadata = new ClassMetadata(Entity::class);
258263

259264
$this->assertTrue($loader->loadClassMetadata($metadata));
260265

261266
$this->assertNotEmpty($metadata->getConstraints());
262267
}
268+
269+
public function testLoadClassMetadataFromExplicitAttributeMappings()
270+
{
271+
$targetClass = _AttrMap_Target::class;
272+
$sourceClass = _AttrMap_Source::class;
273+
274+
$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
275+
$metadata = new ClassMetadata($targetClass);
276+
277+
$this->assertTrue($loader->loadClassMetadata($metadata));
278+
$this->assertInstanceOf(NotBlank::class, $metadata->getPropertyMetadata('name', $sourceClass)[0]->getConstraints()[0]);
279+
}
280+
281+
public function testLoadClassMetadataWithClassLevelConstraints()
282+
{
283+
$targetClass = _AttrMap_Target::class;
284+
$sourceClass = _AttrMap_ClassLevelSource::class;
285+
286+
$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
287+
$metadata = new ClassMetadata($targetClass);
288+
289+
$this->assertTrue($loader->loadClassMetadata($metadata));
290+
291+
// Check that class-level constraints are added to the target
292+
$constraints = $metadata->getConstraints();
293+
$this->assertCount(2, $constraints);
294+
295+
// Check for Callback constraint
296+
$callbackConstraint = null;
297+
foreach ($constraints as $constraint) {
298+
if ($constraint instanceof Callback) {
299+
$callbackConstraint = $constraint;
300+
break;
301+
}
302+
}
303+
$this->assertInstanceOf(Callback::class, $callbackConstraint);
304+
$this->assertEquals('validateClass', $callbackConstraint->callback);
305+
306+
// Check for Expression constraint
307+
$expressionConstraint = null;
308+
foreach ($constraints as $constraint) {
309+
if ($constraint instanceof Expression) {
310+
$expressionConstraint = $constraint;
311+
break;
312+
}
313+
}
314+
$this->assertInstanceOf(Expression::class, $expressionConstraint);
315+
$this->assertEquals('this.name != null', $expressionConstraint->expression);
316+
317+
// Check that property constraints are also added
318+
$this->assertInstanceOf(NotBlank::class, $metadata->getPropertyMetadata('name', $sourceClass)[0]->getConstraints()[0]);
319+
}
320+
}
321+
322+
class _AttrMap_Target
323+
{
324+
public string $name;
325+
326+
public function getName()
327+
{
328+
return $this->name;
329+
}
330+
331+
public function validateClass()
332+
{
333+
// This method will be called by the Callback constraint
334+
return true;
335+
}
336+
}
337+
338+
#[ExtendsValidationFor(_AttrMap_Target::class)]
339+
class _AttrMap_Source
340+
{
341+
#[NotBlank] public string $name;
342+
}
343+
344+
#[ExtendsValidationFor(_AttrMap_Target::class)]
345+
#[Callback('validateClass')]
346+
#[Expression('this.name != null')]
347+
class _AttrMap_ClassLevelSource
348+
{
349+
#[NotBlank]
350+
public string $name = '';
263351
}

0 commit comments

Comments
 (0)