Skip to content

Commit 325c0c3

Browse files
[Validator] Allow using attributes to declare compile-time constraint metadata
1 parent 3ab58a4 commit 325c0c3

File tree

17 files changed

+311
-77
lines changed

17 files changed

+311
-77
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
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\AttributeLoader;
1718
use Symfony\Component\Validator\Mapping\Loader\LoaderChain;
1819
use Symfony\Component\Validator\Mapping\Loader\LoaderInterface;
1920
use Symfony\Component\Validator\Mapping\Loader\XmlFileLoader;
2021
use Symfony\Component\Validator\Mapping\Loader\YamlFileLoader;
2122
use Symfony\Component\Validator\ValidatorBuilder;
2223

2324
/**
24-
* Warms up XML and YAML validator metadata.
25+
* Warms up validator metadata.
2526
*
2627
* @author Titouan Galopin <galopintitouan@gmail.com>
2728
*
@@ -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|AttributeLoader>
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 (method_exists($loader, 'getMappedClasses')) {
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: 21 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 as ValidatorAttributeMetadataPass;
219221
use Symfony\Component\Validator\GroupProviderInterface;
220222
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader;
221223
use Symfony\Component\Validator\ObjectInitializerInterface;
@@ -1796,22 +1798,31 @@ 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 (class_exists(ValidatorAttributeMetadataPass::class) && (!($config['enable_attributes'] ?? false) || !$container->getParameter('kernel.debug'))) {
1815+
// The $reflector argument hints at where the attribute could be used
1816+
$container->registerAttributeForAutoconfiguration(Constraint::class, function (ChildDefinition $definition, Constraint $attribute, \ReflectionClass|\ReflectionMethod|\ReflectionProperty $reflector) {
1817+
$definition->addTag('validator.attribute_metadata');
1818+
});
1819+
}
1820+
1821+
if ($config['enable_attributes'] ?? false) {
18111822
$validatorBuilder->addMethodCall('enableAttributeMapping');
18121823
}
18131824

1814-
if (\array_key_exists('static_method', $config) && $config['static_method']) {
1825+
if ($config['static_method'] ?? false) {
18151826
foreach ($config['static_method'] as $methodName) {
18161827
$validatorBuilder->addMethodCall('addMethodMapping', [$methodName]);
18171828
}
@@ -1850,9 +1861,10 @@ private function registerValidatorMapping(ContainerBuilder $container, array $co
18501861
$files['yaml' === $extension ? 'yml' : $extension][] = $path;
18511862
};
18521863

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');
1864+
if (!ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) {
1865+
$container->removeDefinition('validator.form.attribute_metadata');
1866+
} elseif (!($r = new \ReflectionClass(Form::class))->getAttributes(Traverse::class) || !class_exists(ValidatorAttributeMetadataPass::class)) {
1867+
$fileRecorder('xml', \dirname($r->getFileName()).'/Resources/config/validation.xml');
18561868
}
18571869

18581870
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
@@ -2055,7 +2067,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
20552067
}
20562068

20572069
$serializerLoaders = [];
2058-
if (isset($config['enable_attributes']) && $config['enable_attributes']) {
2070+
if ($config['enable_attributes'] ?? false) {
20592071
$attributeLoader = new Definition(AttributeLoader::class);
20602072

20612073
$serializerLoaders[] = $attributeLoader;
@@ -2095,7 +2107,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
20952107
$chainLoader->replaceArgument(0, $serializerLoaders);
20962108
$container->getDefinition('serializer.mapping.cache_warmer')->replaceArgument(0, $serializerLoaders);
20972109

2098-
if (isset($config['name_converter']) && $config['name_converter']) {
2110+
if ($config['name_converter'] ?? false) {
20992111
$container->setParameter('.serializer.name_converter', $config['name_converter']);
21002112
$container->getDefinition('serializer.name_converter.metadata_aware')->setArgument(1, new Reference($config['name_converter']));
21012113
}

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()` and `AttributeMetadataPass` to declare compile-time constraint metadata using 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

src/Symfony/Component/Validator/Constraint.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
*
2828
* @author Bernhard Schussek <bschussek@gmail.com>
2929
*/
30+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
3031
abstract class Constraint
3132
{
3233
/**

0 commit comments

Comments
 (0)