Skip to content

Commit 289783f

Browse files
[Validator] Allow using attributes on classes as constraint metadata configuration
1 parent 3ab58a4 commit 289783f

File tree

16 files changed

+332
-40
lines changed

16 files changed

+332
-40
lines changed

src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ValidatorCacheWarmer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Cache\Adapter\ArrayAdapter;
1515
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
1616
use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory;
17+
use Symfony\Component\Validator\Mapping\Loader\AttributeServicesLoader;
1718
use Symfony\Component\Validator\Mapping\Loader\LoaderChain;
1819
use Symfony\Component\Validator\Mapping\Loader\LoaderInterface;
1920
use Symfony\Component\Validator\Mapping\Loader\XmlFileLoader;
@@ -77,14 +78,14 @@ protected function warmUpPhpArrayAdapter(PhpArrayAdapter $phpArrayAdapter, array
7778
/**
7879
* @param LoaderInterface[] $loaders
7980
*
80-
* @return XmlFileLoader[]|YamlFileLoader[]
81+
* @return list<XmlFileLoader|YamlFileLoader|AttributeServicesLoader>
8182
*/
8283
private function extractSupportedLoaders(array $loaders): array
8384
{
8485
$supportedLoaders = [];
8586

8687
foreach ($loaders as $loader) {
87-
if ($loader instanceof XmlFileLoader || $loader instanceof YamlFileLoader) {
88+
if ($loader instanceof XmlFileLoader || $loader instanceof YamlFileLoader || $loader instanceof AttributeServicesLoader) {
8889
$supportedLoaders[] = $loader;
8990
} elseif ($loader instanceof LoaderChain) {
9091
$supportedLoaders = array_merge($supportedLoaders, $this->extractSupportedLoaders($loader->getLoaders()));

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class UnusedTagsPass implements CompilerPassInterface
101101
'twig.extension',
102102
'twig.loader',
103103
'twig.runtime',
104+
'validator.attribute_metadata',
104105
'validator.auto_mapper',
105106
'validator.constraint_validator',
106107
'validator.group_provider',

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@
215215
use Symfony\Component\Uid\UuidV4;
216216
use Symfony\Component\Validator\Constraint;
217217
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider;
218+
use Symfony\Component\Validator\Constraints\Traverse;
218219
use Symfony\Component\Validator\ConstraintValidatorInterface;
220+
use Symfony\Component\Validator\DependencyInjection\AttributeMetadataPass;
219221
use Symfony\Component\Validator\GroupProviderInterface;
220222
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader;
221223
use Symfony\Component\Validator\ObjectInitializerInterface;
@@ -1796,22 +1798,29 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
17961798
$files = ['xml' => [], 'yml' => []];
17971799
$this->registerValidatorMapping($container, $config, $files);
17981800

1799-
if (!empty($files['xml'])) {
1801+
if ($files['xml']) {
18001802
$validatorBuilder->addMethodCall('addXmlMappings', [$files['xml']]);
18011803
}
18021804

1803-
if (!empty($files['yml'])) {
1805+
if ($files['yml']) {
18041806
$validatorBuilder->addMethodCall('addYamlMappings', [$files['yml']]);
18051807
}
18061808

18071809
$definition = $container->findDefinition('validator.email');
18081810
$definition->replaceArgument(0, $config['email_validation_mode']);
18091811

1810-
if (\array_key_exists('enable_attributes', $config) && $config['enable_attributes']) {
1812+
// When attributes are disabled, it means from runtime-discovery only; autoconfiguration should still happen.
1813+
// And when runtime-discovery of attributes is enabled, we can skip compile-time autoconfiguration in debug mode.
1814+
if (!($config['enable_attributes'] ?? false) || !$container->getParameter('kernel.debug')) {
1815+
$container->registerForAutoconfiguration(Constraint::class)
1816+
->addTag('validator.attribute_metadata');
1817+
}
1818+
1819+
if ($config['enable_attributes'] ?? false) {
18111820
$validatorBuilder->addMethodCall('enableAttributeMapping');
18121821
}
18131822

1814-
if (\array_key_exists('static_method', $config) && $config['static_method']) {
1823+
if ($config['static_method'] ?? false) {
18151824
foreach ($config['static_method'] as $methodName) {
18161825
$validatorBuilder->addMethodCall('addMethodMapping', [$methodName]);
18171826
}
@@ -1850,9 +1859,10 @@ private function registerValidatorMapping(ContainerBuilder $container, array $co
18501859
$files['yaml' === $extension ? 'yml' : $extension][] = $path;
18511860
};
18521861

1853-
if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) {
1854-
$reflClass = new \ReflectionClass(Form::class);
1855-
$fileRecorder('xml', \dirname($reflClass->getFileName()).'/Resources/config/validation.xml');
1862+
if (!ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) {
1863+
$container->removeDefinition('validator.form.attribute_metadata');
1864+
} elseif (!class_exists(AttributeMetadataPass::class) || !($r = new \ReflectionClass(Form::class))->getAttributes(Traverse::class)) {
1865+
$fileRecorder('xml', \dirname($r->getFileName()).'/Resources/config/validation.xml');
18561866
}
18571867

18581868
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
@@ -2055,7 +2065,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
20552065
}
20562066

20572067
$serializerLoaders = [];
2058-
if (isset($config['enable_attributes']) && $config['enable_attributes']) {
2068+
if ($config['enable_attributes'] ?? false) {
20592069
$attributeLoader = new Definition(AttributeLoader::class);
20602070

20612071
$serializerLoaders[] = $attributeLoader;
@@ -2095,7 +2105,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
20952105
$chainLoader->replaceArgument(0, $serializerLoaders);
20962106
$container->getDefinition('serializer.mapping.cache_warmer')->replaceArgument(0, $serializerLoaders);
20972107

2098-
if (isset($config['name_converter']) && $config['name_converter']) {
2108+
if ($config['name_converter'] ?? false) {
20992109
$container->setParameter('.serializer.name_converter', $config['name_converter']);
21002110
$container->getDefinition('serializer.name_converter.metadata_aware')->setArgument(1, new Reference($config['name_converter']));
21012111
}

src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
use Symfony\Component\Validator\DependencyInjection\AddAutoMappingConfigurationPass;
7676
use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass;
7777
use Symfony\Component\Validator\DependencyInjection\AddValidatorInitializersPass;
78+
use Symfony\Component\Validator\DependencyInjection\AttributeMetadataPass;
7879
use Symfony\Component\VarExporter\Internal\Hydrator;
7980
use Symfony\Component\VarExporter\Internal\Registry;
8081
use Symfony\Component\Workflow\DependencyInjection\WorkflowDebugPass;
@@ -155,6 +156,7 @@ public function build(ContainerBuilder $container): void
155156
$container->addCompilerPass($registerListenersPass, PassConfig::TYPE_BEFORE_REMOVING);
156157
$this->addCompilerPassIfExists($container, AddConstraintValidatorsPass::class);
157158
$this->addCompilerPassIfExists($container, AddValidatorInitializersPass::class);
159+
$this->addCompilerPassIfExists($container, AttributeMetadataPass::class);
158160
$this->addCompilerPassIfExists($container, AddConsoleCommandPass::class, PassConfig::TYPE_BEFORE_REMOVING);
159161
// must be registered before the AddConsoleCommandPass
160162
$container->addCompilerPass(new TranslationLintCommandPass(), PassConfig::TYPE_BEFORE_REMOVING, 10);

src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Bundle\FrameworkBundle\CacheWarmer\ValidatorCacheWarmer;
1515
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
1616
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
17+
use Symfony\Component\Form\Form;
1718
use Symfony\Component\Validator\Constraints\EmailValidator;
1819
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider;
1920
use Symfony\Component\Validator\Constraints\ExpressionValidator;
@@ -127,5 +128,9 @@
127128
service('property_info'),
128129
])
129130
->tag('validator.auto_mapper')
131+
132+
->set('validator.form.attribute_metadata', Form::class)
133+
->tag('container.excluded')
134+
->tag('validator.attribute_metadata')
130135
;
131136
};

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
use Symfony\Component\Translation\DependencyInjection\TranslatorPass;
9494
use Symfony\Component\Translation\LocaleSwitcher;
9595
use Symfony\Component\Translation\TranslatableMessage;
96+
use Symfony\Component\Validator\Constraints\Traverse;
9697
use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass;
9798
use Symfony\Component\Validator\Validation;
9899
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -1312,16 +1313,17 @@ public function testValidation()
13121313
$projectDir = $container->getParameter('kernel.project_dir');
13131314

13141315
$ref = new \ReflectionClass(Form::class);
1315-
$xmlMappings = [
1316-
\dirname($ref->getFileName()).'/Resources/config/validation.xml',
1317-
strtr($projectDir.'/config/validator/foo.xml', '/', \DIRECTORY_SEPARATOR),
1318-
];
1316+
$xmlMappings = [];
1317+
if (!$ref->getAttributes(Traverse::class)) {
1318+
$xmlMappings[] = \dirname($ref->getFileName()).'/Resources/config/validation.xml';
1319+
}
1320+
$xmlMappings[] =strtr($projectDir.'/config/validator/foo.xml', '/', \DIRECTORY_SEPARATOR);
13191321

13201322
$calls = $container->getDefinition('validator.builder')->getMethodCalls();
13211323

1322-
$annotations = !class_exists(FullStack::class);
1324+
$attributes = !class_exists(FullStack::class);
13231325

1324-
$this->assertCount($annotations ? 8 : 7, $calls);
1326+
$this->assertCount($attributes ? 8 : 7, $calls);
13251327
$this->assertSame('setConstraintValidatorFactory', $calls[0][0]);
13261328
$this->assertEquals([new Reference('validator.validator_factory')], $calls[0][1]);
13271329
$this->assertSame('setGroupProviderLocator', $calls[1][0]);
@@ -1333,7 +1335,7 @@ public function testValidation()
13331335
$this->assertSame('addXmlMappings', $calls[4][0]);
13341336
$this->assertSame([$xmlMappings], $calls[4][1]);
13351337
$i = 4;
1336-
if ($annotations) {
1338+
if ($attributes) {
13371339
$this->assertSame('enableAttributeMapping', $calls[++$i][0]);
13381340
}
13391341
$this->assertSame('addMethodMapping', $calls[++$i][0]);
@@ -1408,15 +1410,19 @@ public function testValidationPaths()
14081410
$this->assertEquals([new Reference('validator.mapping.cache.adapter')], $calls[8][1]);
14091411

14101412
$xmlMappings = $calls[4][1][0];
1411-
$this->assertCount(3, $xmlMappings);
1412-
try {
1413-
// Testing symfony/symfony
1414-
$this->assertStringEndsWith('Component'.\DIRECTORY_SEPARATOR.'Form/Resources/config/validation.xml', $xmlMappings[0]);
1415-
} catch (\Exception $e) {
1416-
// Testing symfony/framework-bundle with deps=high
1417-
$this->assertStringEndsWith('symfony'.\DIRECTORY_SEPARATOR.'form/Resources/config/validation.xml', $xmlMappings[0]);
1413+
1414+
if (!(new \ReflectionClass(Form::class))->getAttributes(Traverse::class)) {
1415+
try {
1416+
// Testing symfony/symfony
1417+
$this->assertStringEndsWith('Component'.\DIRECTORY_SEPARATOR.'Form/Resources/config/validation.xml', $xmlMappings[0]);
1418+
} catch (\Exception $e) {
1419+
// Testing symfony/framework-bundle with deps=high
1420+
$this->assertStringEndsWith('symfony'.\DIRECTORY_SEPARATOR.'form/Resources/config/validation.xml', $xmlMappings[0]);
1421+
}
1422+
array_shift($xmlMappings);
14181423
}
1419-
$this->assertStringEndsWith('TestBundle/Resources/config/validation.xml', $xmlMappings[1]);
1424+
$this->assertCount(2, $xmlMappings);
1425+
$this->assertStringEndsWith('TestBundle/Resources/config/validation.xml', $xmlMappings[0]);
14201426

14211427
$yamlMappings = $calls[5][1][0];
14221428
$this->assertCount(1, $yamlMappings);
@@ -1434,16 +1440,19 @@ public function testValidationPathsUsingCustomBundlePath()
14341440

14351441
$calls = $container->getDefinition('validator.builder')->getMethodCalls();
14361442
$xmlMappings = $calls[4][1][0];
1437-
$this->assertCount(3, $xmlMappings);
1438-
1439-
try {
1440-
// Testing symfony/symfony
1441-
$this->assertStringEndsWith('Component'.\DIRECTORY_SEPARATOR.'Form/Resources/config/validation.xml', $xmlMappings[0]);
1442-
} catch (\Exception $e) {
1443-
// Testing symfony/framework-bundle with deps=high
1444-
$this->assertStringEndsWith('symfony'.\DIRECTORY_SEPARATOR.'form/Resources/config/validation.xml', $xmlMappings[0]);
1443+
1444+
if (!(new \ReflectionClass(Form::class))->getAttributes(Traverse::class)) {
1445+
try {
1446+
// Testing symfony/symfony
1447+
$this->assertStringEndsWith('Component'.\DIRECTORY_SEPARATOR.'Form/Resources/config/validation.xml', $xmlMappings[0]);
1448+
} catch (\Exception $e) {
1449+
// Testing symfony/framework-bundle with deps=high
1450+
$this->assertStringEndsWith('symfony'.\DIRECTORY_SEPARATOR.'form/Resources/config/validation.xml', $xmlMappings[0]);
1451+
}
1452+
array_shift($xmlMappings);
14451453
}
1446-
$this->assertStringEndsWith('CustomPathBundle/Resources/config/validation.xml', $xmlMappings[1]);
1454+
$this->assertCount(2, $xmlMappings);
1455+
$this->assertStringEndsWith('CustomPathBundle/Resources/config/validation.xml', $xmlMappings[0]);
14471456

14481457
$yamlMappings = $calls[5][1][0];
14491458
$this->assertCount(1, $yamlMappings);
@@ -1490,7 +1499,6 @@ public function testValidationMapping()
14901499
$calls = $container->getDefinition('validator.builder')->getMethodCalls();
14911500

14921501
$this->assertSame('addXmlMappings', $calls[4][0]);
1493-
$this->assertCount(3, $calls[4][1][0]);
14941502

14951503
$this->assertSame('addYamlMappings', $calls[5][0]);
14961504
$this->assertCount(3, $calls[5][1][0]);

src/Symfony/Component/Form/Extension/Validator/Constraints/Form.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111

1212
namespace Symfony\Component\Form\Extension\Validator\Constraints;
1313

14+
use Symfony\Component\Validator\Attribute\HasNamedArguments;
1415
use Symfony\Component\Validator\Constraint;
1516

1617
/**
1718
* @author Bernhard Schussek <bschussek@gmail.com>
1819
*/
20+
#[\Attribute(\Attribute::TARGET_CLASS)]
1921
class Form extends Constraint
2022
{
2123
public const NOT_SYNCHRONIZED_ERROR = '1dafa156-89e1-4736-b832-419c2e501fca';
@@ -26,6 +28,12 @@ class Form extends Constraint
2628
self::NO_SUCH_FIELD_ERROR => 'NO_SUCH_FIELD_ERROR',
2729
];
2830

31+
#[HasNamedArguments()]
32+
public function __construct(mixed $options = null, ?array $groups = null, mixed $payload = null)
33+
{
34+
parent::__construct($options, $groups, $payload);
35+
}
36+
2937
public function getTargets(): string|array
3038
{
3139
return self::CLASS_CONSTRAINT;

src/Symfony/Component/Form/Form.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
use Symfony\Component\Form\Exception\RuntimeException;
2323
use Symfony\Component\Form\Exception\TransformationFailedException;
2424
use Symfony\Component\Form\Extension\Core\Type\TextType;
25+
use Symfony\Component\Form\Extension\Validator\Constraints\Form as AssertForm;
2526
use Symfony\Component\Form\Util\FormUtil;
2627
use Symfony\Component\Form\Util\InheritDataAwareIterator;
2728
use Symfony\Component\Form\Util\OrderedHashMap;
2829
use Symfony\Component\PropertyAccess\PropertyPath;
2930
use Symfony\Component\PropertyAccess\PropertyPathInterface;
31+
use Symfony\Component\Validator\Constraints\Traverse;
3032

3133
/**
3234
* Form represents a form.
@@ -68,6 +70,8 @@
6870
*
6971
* @implements \IteratorAggregate<string, FormInterface>
7072
*/
73+
#[AssertForm()]
74+
#[Traverse(false)]
7175
class Form implements \IteratorAggregate, FormInterface, ClearableErrorsInterface
7276
{
7377
private ?FormInterface $parent = null;

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 `ValidatorBuilder::addAttributeMappings()`, `AttributeServicesLoader` and `AttributeMetadataPass` to load constraint metadata from attributes
78
* Deprecate implementing `__sleep/wakeup()` on `GenericMetadata` implementations; use `__(un)serialize()` instead
89
* Deprecate passing a list of choices to the first argument of the `Choice` constraint. Use the `choices` option instead
910
* Add the `min` and `max` parameter to the `Length` constraint violation
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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\DependencyInjection;
13+
14+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
15+
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
17+
18+
/**
19+
* @author Nicolas Grekas <p@tchwork.com>
20+
*/
21+
final class AttributeMetadataPass implements CompilerPassInterface
22+
{
23+
public function process(ContainerBuilder $container): void
24+
{
25+
if (!$container->hasDefinition('validator.builder')) {
26+
return;
27+
}
28+
29+
$resolve = $container->getParameterBag()->resolveValue(...);
30+
$taggedClasses = [];
31+
foreach ($container->getDefinitions() as $id => $definition) {
32+
if (!$definition->hasTag('validator.attribute_metadata')) {
33+
continue;
34+
}
35+
if (!$definition->hasTag('container.excluded')) {
36+
throw new InvalidArgumentException(\sprintf('The resource "%s" tagged "validator.attribute_metadata" is missing the "container.excluded" tag.', $id));
37+
}
38+
$taggedClasses[$resolve($definition->getClass())] = true;
39+
}
40+
41+
ksort($taggedClasses);
42+
43+
if ($taggedClasses) {
44+
$container->getDefinition('validator.builder')
45+
->addMethodCall('addAttributeMappings', [array_keys($taggedClasses)]);
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)