Skip to content

Commit c539c77

Browse files
feature #61528 [Validator] Allow using attributes to declare compile-time constraint metadata (nicolas-grekas)
This PR was merged into the 7.4 branch. Discussion ---------- [Validator] Allow using attributes to declare compile-time constraint metadata | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT Prerequisite for #61288 At the moment, validation attributes are read at runtime when `framework.validation.enable_attributes` is true. This means they don't fit for bundles nor can't they be warmed up. This PR fixes both issues by using a new `validator.attribute_metadata` resource tag, that's turned into a list of classes to parse for attributes at compile-time. For apps, the tag is added by autoconfiguration: any `Constraint`-derived attributes found on a class in the `src/` folder will trigger the rule to add the tag. For bundles (and for apps if they want to), the tag is added by explicit service configuration. In an "eat your own dog-food" spirit, this capability is used to declare the constraints of the `Form` class: instead of loading the `validation.xml` file, we now declare this service resource: ```php ->set('validator.form.attribute_metadata', Form::class) ->tag('container.excluded') ->tag('validator.attribute_metadata') ``` This reads the attributes added to the `Form` class: ```php #[AssertForm()] #[Traverse(false)] class Form implements \IteratorAggregate, FormInterface, ClearableErrorsInterface ``` Bundles can do the same and replace their XML files by attributes. As a next step, we could also deprecate runtime-discovery of attributes. This could be worth it if this discovery has a measurable performance impact. To be measured if one wants to dig this idea. Side note: I'm hoping this could allow removing the yaml and xml config formats one day. For serialization metadata also (PR coming). BUT, this doesn't (yet) cover the use case of overriding metadata defined by bundles. For that, apps still have to use xml or yaml in config/validation/. I have an idea to cover this, coming to a next PR if it works. (failures unrelated) Commits ------- 0197b50 [Validator] Allow using attributes to declare compile-time constraint metadata
2 parents d052b75 + 0197b50 commit c539c77

File tree

17 files changed

+305
-78
lines changed

17 files changed

+305
-78
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
@@ -102,6 +102,7 @@ class UnusedTagsPass implements CompilerPassInterface
102102
'twig.extension',
103103
'twig.loader',
104104
'twig.runtime',
105+
'validator.attribute_metadata',
105106
'validator.auto_mapper',
106107
'validator.constraint_validator',
107108
'validator.group_provider',

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

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,9 @@
216216
use Symfony\Component\Uid\UuidV4;
217217
use Symfony\Component\Validator\Constraint;
218218
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider;
219+
use Symfony\Component\Validator\Constraints\Traverse;
219220
use Symfony\Component\Validator\ConstraintValidatorInterface;
221+
use Symfony\Component\Validator\DependencyInjection\AttributeMetadataPass as ValidatorAttributeMetadataPass;
220222
use Symfony\Component\Validator\GroupProviderInterface;
221223
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader;
222224
use Symfony\Component\Validator\ObjectInitializerInterface;
@@ -1801,22 +1803,31 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
18011803
$files = ['xml' => [], 'yml' => []];
18021804
$this->registerValidatorMapping($container, $config, $files);
18031805

1804-
if (!empty($files['xml'])) {
1806+
if ($files['xml']) {
18051807
$validatorBuilder->addMethodCall('addXmlMappings', [$files['xml']]);
18061808
}
18071809

1808-
if (!empty($files['yml'])) {
1810+
if ($files['yml']) {
18091811
$validatorBuilder->addMethodCall('addYamlMappings', [$files['yml']]);
18101812
}
18111813

18121814
$definition = $container->findDefinition('validator.email');
18131815
$definition->replaceArgument(0, $config['email_validation_mode']);
18141816

1815-
if (\array_key_exists('enable_attributes', $config) && $config['enable_attributes']) {
1817+
// When attributes are disabled, it means from runtime-discovery only; autoconfiguration should still happen.
1818+
// And when runtime-discovery of attributes is enabled, we can skip compile-time autoconfiguration in debug mode.
1819+
if (class_exists(ValidatorAttributeMetadataPass::class) && (!($config['enable_attributes'] ?? false) || !$container->getParameter('kernel.debug'))) {
1820+
// The $reflector argument hints at where the attribute could be used
1821+
$container->registerAttributeForAutoconfiguration(Constraint::class, function (ChildDefinition $definition, Constraint $attribute, \ReflectionClass|\ReflectionMethod|\ReflectionProperty $reflector) {
1822+
$definition->addTag('validator.attribute_metadata');
1823+
});
1824+
}
1825+
1826+
if ($config['enable_attributes'] ?? false) {
18161827
$validatorBuilder->addMethodCall('enableAttributeMapping');
18171828
}
18181829

1819-
if (\array_key_exists('static_method', $config) && $config['static_method']) {
1830+
if ($config['static_method'] ?? false) {
18201831
foreach ($config['static_method'] as $methodName) {
18211832
$validatorBuilder->addMethodCall('addMethodMapping', [$methodName]);
18221833
}
@@ -1855,9 +1866,11 @@ private function registerValidatorMapping(ContainerBuilder $container, array $co
18551866
$files['yaml' === $extension ? 'yml' : $extension][] = $path;
18561867
};
18571868

1858-
if (ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) {
1859-
$reflClass = new \ReflectionClass(Form::class);
1860-
$fileRecorder('xml', \dirname($reflClass->getFileName()).'/Resources/config/validation.xml');
1869+
if (!ContainerBuilder::willBeAvailable('symfony/form', Form::class, ['symfony/framework-bundle', 'symfony/validator'])) {
1870+
$container->removeDefinition('validator.form.attribute_metadata');
1871+
} elseif (!($r = new \ReflectionClass(Form::class))->getAttributes(Traverse::class) || !class_exists(ValidatorAttributeMetadataPass::class)) {
1872+
// BC with symfony/form & symfony/validator < 7.4
1873+
$fileRecorder('xml', \dirname($r->getFileName()).'/Resources/config/validation.xml');
18611874
}
18621875

18631876
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
@@ -2060,7 +2073,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
20602073
}
20612074

20622075
$serializerLoaders = [];
2063-
if (isset($config['enable_attributes']) && $config['enable_attributes']) {
2076+
if ($config['enable_attributes'] ?? false) {
20642077
$attributeLoader = new Definition(AttributeLoader::class);
20652078

20662079
$serializerLoaders[] = $attributeLoader;
@@ -2100,7 +2113,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
21002113
$chainLoader->replaceArgument(0, $serializerLoaders);
21012114
$container->getDefinition('serializer.mapping.cache_warmer')->replaceArgument(0, $serializerLoaders);
21022115

2103-
if (isset($config['name_converter']) && $config['name_converter']) {
2116+
if ($config['name_converter'] ?? false) {
21042117
$container->setParameter('.serializer.name_converter', $config['name_converter']);
21052118
$container->getDefinition('serializer.name_converter.metadata_aware')->setArgument(1, new Reference($config['name_converter']));
21062119
}

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: 5 additions & 1 deletion
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;
@@ -301,7 +305,7 @@ public function setData(mixed $modelData): static
301305
if (null !== $dataClass && !$viewData instanceof $dataClass) {
302306
$actualType = get_debug_type($viewData);
303307

304-
throw new LogicException('The form\'s view data is expected to be a "'.$dataClass.'", but it is a "'.$actualType.'". You can avoid this error by setting the "data_class" option to null or by adding a view transformer that transforms "'.$actualType.'" to an instance of "'.$dataClass.'".');
308+
throw new LogicException(\sprintf('The form\'s view data is expected to be a "%s", but it is a "%s". You can avoid this error by setting the "data_class" option to null or by adding a view transformer that transforms "%2$s" to an instance of "%1$s".', $dataClass, $actualType));
305309
}
306310
}
307311

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
* Add the `Video` constraint for validating video files
89
* Deprecate implementing `__sleep/wakeup()` on `GenericMetadata` implementations; use `__(un)serialize()` instead
910
* Deprecate passing a list of choices to the first argument of the `Choice` constraint. Use the `choices` option instead

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)