Skip to content

Commit e884a76

Browse files
[Validator] Add #[ExtendsValidationFor] to declare new constraints for a class
1 parent c539c77 commit e884a76

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
@@ -214,6 +214,7 @@
214214
use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
215215
use Symfony\Component\Uid\Factory\UuidFactory;
216216
use Symfony\Component\Uid\UuidV4;
217+
use Symfony\Component\Validator\Attribute\ExtendsValidationFor;
217218
use Symfony\Component\Validator\Constraint;
218219
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider;
219220
use Symfony\Component\Validator\Constraints\Traverse;
@@ -1819,10 +1820,16 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
18191820
if (class_exists(ValidatorAttributeMetadataPass::class) && (!($config['enable_attributes'] ?? false) || !$container->getParameter('kernel.debug'))) {
18201821
// The $reflector argument hints at where the attribute could be used
18211822
$container->registerAttributeForAutoconfiguration(Constraint::class, function (ChildDefinition $definition, Constraint $attribute, \ReflectionClass|\ReflectionMethod|\ReflectionProperty $reflector) {
1822-
$definition->addTag('validator.attribute_metadata');
1823+
$definition->addTag('validator.attribute_metadata')
1824+
->addTag('container.excluded', ['source' => 'because it\'s a validator constraint extension']);
18231825
});
18241826
}
18251827

1828+
$container->registerAttributeForAutoconfiguration(ExtendsValidationFor::class, function (ChildDefinition $definition, ExtendsValidationFor $attribute) {
1829+
$definition->addTag('validator.attribute_metadata', ['for' => $attribute->class])
1830+
->addTag('container.excluded', ['source' => 'because it\'s a validator constraint extension']);
1831+
});
1832+
18261833
if ($config['enable_attributes'] ?? false) {
18271834
$validatorBuilder->addMethodCall('enableAttributeMapping');
18281835
}
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
@@ -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)