From 2d64e703c20c26f24fbb5e57dc55d0e6e02aef9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 26 Jun 2018 17:35:39 +0200 Subject: [PATCH] [Validator][DoctrineBridge][FWBundle] Automatic data validation --- .../Tests/Fixtures/DoctrineLoaderEntity.php | 58 ++++++ .../Tests/Validator/DoctrineLoaderTest.php | 94 ++++++++++ .../Doctrine/Validator/DoctrineLoader.php | 121 +++++++++++++ .../DependencyInjection/Configuration.php | 39 ++++ .../FrameworkExtension.php | 13 +- .../FrameworkBundle/FrameworkBundle.php | 2 + .../Resources/config/schema/symfony-1.0.xsd | 8 + .../Resources/config/validator.xml | 7 + .../DependencyInjection/ConfigurationTest.php | 1 + .../Fixtures/php/validation_auto_mapping.php | 12 ++ .../Fixtures/xml/validation_auto_mapping.xml | 20 ++ .../Fixtures/yml/validation_auto_mapping.yml | 7 + .../FrameworkExtensionTest.php | 19 ++ .../AddAutoMappingConfigurationPass.php | 93 ++++++++++ .../Mapping/Loader/PropertyInfoLoader.php | 151 ++++++++++++++++ .../AddAutoMappingConfigurationPassTest.php | 73 ++++++++ .../Fixtures/PropertyInfoLoaderEntity.php | 49 +++++ .../Mapping/Loader/PropertyInfoLoaderTest.php | 171 ++++++++++++++++++ src/Symfony/Component/Validator/composer.json | 2 + 19 files changed, 937 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php create mode 100644 src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml create mode 100644 src/Symfony/Component/Validator/DependencyInjection/AddAutoMappingConfigurationPass.php create mode 100644 src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php create mode 100644 src/Symfony/Component/Validator/Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Fixtures/PropertyInfoLoaderEntity.php create mode 100644 src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php new file mode 100644 index 0000000000000..4a92edec8fa14 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/DoctrineLoaderEntity.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @ORM\Entity + * @UniqueEntity(fields={"alreadyMappedUnique"}) + * + * @author Kévin Dunglas + */ +class DoctrineLoaderEntity +{ + /** + * @ORM\Id + * @ORM\Column + */ + public $id; + + /** + * @ORM\Column(length=20) + */ + public $maxLength; + + /** + * @ORM\Column(length=20) + * @Assert\Length(min=5) + */ + public $mergedMaxLength; + + /** + * @ORM\Column(length=20) + * @Assert\Length(min=1, max=10) + */ + public $alreadyMappedMaxLength; + + /** + * @ORM\Column(unique=true) + */ + public $unique; + + /** + * @ORM\Column(unique=true) + */ + public $alreadyMappedUnique; +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php new file mode 100644 index 0000000000000..9599ac8995cda --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Validator; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; +use Symfony\Bridge\Doctrine\Tests\Fixtures\DoctrineLoaderEntity; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Bridge\Doctrine\Validator\DoctrineLoader; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Tests\Fixtures\Entity; +use Symfony\Component\Validator\Validation; +use Symfony\Component\Validator\ValidatorBuilder; + +/** + * @author Kévin Dunglas + */ +class DoctrineLoaderTest extends TestCase +{ + public function testLoadClassMetadata() + { + if (!method_exists(ValidatorBuilder::class, 'addLoader')) { + $this->markTestSkipped('Auto-mapping requires symfony/validation 4.2+'); + } + + $validator = Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager())) + ->getValidator() + ; + + $classMetadata = $validator->getMetadataFor(new DoctrineLoaderEntity()); + + $classConstraints = $classMetadata->getConstraints(); + $this->assertCount(2, $classConstraints); + $this->assertInstanceOf(UniqueEntity::class, $classConstraints[0]); + $this->assertInstanceOf(UniqueEntity::class, $classConstraints[1]); + $this->assertSame(['alreadyMappedUnique'], $classConstraints[0]->fields); + $this->assertSame('unique', $classConstraints[1]->fields); + + $maxLengthMetadata = $classMetadata->getPropertyMetadata('maxLength'); + $this->assertCount(1, $maxLengthMetadata); + $maxLengthConstraints = $maxLengthMetadata[0]->getConstraints(); + $this->assertCount(1, $maxLengthConstraints); + $this->assertInstanceOf(Length::class, $maxLengthConstraints[0]); + $this->assertSame(20, $maxLengthConstraints[0]->max); + + $mergedMaxLengthMetadata = $classMetadata->getPropertyMetadata('mergedMaxLength'); + $this->assertCount(1, $mergedMaxLengthMetadata); + $mergedMaxLengthConstraints = $mergedMaxLengthMetadata[0]->getConstraints(); + $this->assertCount(1, $mergedMaxLengthConstraints); + $this->assertInstanceOf(Length::class, $mergedMaxLengthConstraints[0]); + $this->assertSame(20, $mergedMaxLengthConstraints[0]->max); + $this->assertSame(5, $mergedMaxLengthConstraints[0]->min); + + $alreadyMappedMaxLengthMetadata = $classMetadata->getPropertyMetadata('alreadyMappedMaxLength'); + $this->assertCount(1, $alreadyMappedMaxLengthMetadata); + $alreadyMappedMaxLengthConstraints = $alreadyMappedMaxLengthMetadata[0]->getConstraints(); + $this->assertCount(1, $alreadyMappedMaxLengthConstraints); + $this->assertInstanceOf(Length::class, $alreadyMappedMaxLengthConstraints[0]); + $this->assertSame(10, $alreadyMappedMaxLengthConstraints[0]->max); + $this->assertSame(1, $alreadyMappedMaxLengthConstraints[0]->min); + } + + /** + * @dataProvider regexpProvider + */ + public function testClassValidator(bool $expected, string $classValidatorRegexp = null) + { + $doctrineLoader = new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), $classValidatorRegexp); + + $classMetadata = new ClassMetadata(DoctrineLoaderEntity::class); + $this->assertSame($expected, $doctrineLoader->loadClassMetadata($classMetadata)); + } + + public function regexpProvider() + { + return [ + [true, null], + [true, '{^'.preg_quote(DoctrineLoaderEntity::class).'$|^'.preg_quote(Entity::class).'$}'], + [false, '{^'.preg_quote(Entity::class).'$}'], + ]; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php b/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php new file mode 100644 index 0000000000000..3b6d065fe6e1f --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Validator; + +use Doctrine\Common\Persistence\Mapping\MappingException; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\Mapping\MappingException as OrmMappingException; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\LoaderInterface; + +/** + * Guesses and loads the appropriate constraints using Doctrine's metadata. + * + * @author Kévin Dunglas + */ +final class DoctrineLoader implements LoaderInterface +{ + private $entityManager; + private $classValidatorRegexp; + + public function __construct(EntityManagerInterface $entityManager, string $classValidatorRegexp = null) + { + $this->entityManager = $entityManager; + $this->classValidatorRegexp = $classValidatorRegexp; + } + + /** + * {@inheritdoc} + */ + public function loadClassMetadata(ClassMetadata $metadata): bool + { + $className = $metadata->getClassName(); + if (null !== $this->classValidatorRegexp && !preg_match($this->classValidatorRegexp, $className)) { + return false; + } + + try { + $doctrineMetadata = $this->entityManager->getClassMetadata($className); + } catch (MappingException | OrmMappingException $exception) { + return false; + } + + if (!$doctrineMetadata instanceof ClassMetadataInfo) { + return false; + } + + /* Available keys: + - type + - scale + - length + - unique + - nullable + - precision + */ + $existingUniqueFields = $this->getExistingUniqueFields($metadata); + + // Type and nullable aren't handled here, use the PropertyInfo Loader instead. + foreach ($doctrineMetadata->fieldMappings as $mapping) { + if (true === $mapping['unique'] && !isset($existingUniqueFields[$mapping['fieldName']])) { + $metadata->addConstraint(new UniqueEntity(['fields' => $mapping['fieldName']])); + } + + if (null === $mapping['length']) { + continue; + } + + $constraint = $this->getLengthConstraint($metadata, $mapping['fieldName']); + if (null === $constraint) { + $metadata->addPropertyConstraint($mapping['fieldName'], new Length(['max' => $mapping['length']])); + } elseif (null === $constraint->max) { + // If a Length constraint exists and no max length has been explicitly defined, set it + $constraint->max = $mapping['length']; + } + } + + return true; + } + + private function getLengthConstraint(ClassMetadata $metadata, string $fieldName): ?Length + { + foreach ($metadata->getPropertyMetadata($fieldName) as $propertyMetadata) { + foreach ($propertyMetadata->getConstraints() as $constraint) { + if ($constraint instanceof Length) { + return $constraint; + } + } + } + + return null; + } + + private function getExistingUniqueFields(ClassMetadata $metadata): array + { + $fields = []; + foreach ($metadata->getConstraints() as $constraint) { + if (!$constraint instanceof UniqueEntity) { + continue; + } + + if (\is_string($constraint->fields)) { + $fields[$constraint->fields] = true; + } elseif (\is_array($constraint->fields) && 1 === \count($constraint->fields)) { + $fields[$constraint->fields[0]] = true; + } + } + + return $fields; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 1e52ce9322486..6f9ff0f387b4f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -792,6 +792,45 @@ private function addValidationSection(ArrayNodeDefinition $rootNode) ->end() ->end() ->end() + ->arrayNode('auto_mapping') + ->useAttributeAsKey('namespace') + ->normalizeKeys(false) + ->beforeNormalization() + ->ifArray() + ->then(function (array $values): array { + foreach ($values as $k => $v) { + if (isset($v['service'])) { + continue; + } + + if (isset($v['namespace'])) { + $values[$k]['services'] = []; + continue; + } + + if (!\is_array($v)) { + $values[$v]['services'] = []; + unset($values[$k]); + continue; + } + + $tmp = $v; + unset($values[$k]); + $values[$k]['services'] = $tmp; + } + + return $values; + }) + ->end() + ->arrayPrototype() + ->fixXmlConfig('service') + ->children() + ->arrayNode('services') + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 870c499aafbd6..eb99aed085aea 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -107,6 +107,7 @@ use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\ObjectInitializerInterface; use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow; @@ -280,7 +281,8 @@ public function load(array $configs, ContainerBuilder $container) $container->removeDefinition('console.command.messenger_debug'); } - $this->registerValidationConfiguration($config['validation'], $container, $loader); + $propertyInfoEnabled = $this->isConfigEnabled($container, $config['property_info']); + $this->registerValidationConfiguration($config['validation'], $container, $loader, $propertyInfoEnabled); $this->registerEsiConfiguration($config['esi'], $container, $loader); $this->registerSsiConfiguration($config['ssi'], $container, $loader); $this->registerFragmentsConfiguration($config['fragments'], $container, $loader); @@ -301,7 +303,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerSerializerConfiguration($config['serializer'], $container, $loader); } - if ($this->isConfigEnabled($container, $config['property_info'])) { + if ($propertyInfoEnabled) { $this->registerPropertyInfoConfiguration($container, $loader); } @@ -1152,7 +1154,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder } } - private function registerValidationConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + private function registerValidationConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader, bool $propertyInfoEnabled) { if (!$this->validatorConfigEnabled = $this->isConfigEnabled($container, $config)) { return; @@ -1203,6 +1205,11 @@ private function registerValidationConfiguration(array $config, ContainerBuilder if (!$container->getParameter('kernel.debug')) { $validatorBuilder->addMethodCall('setMetadataCache', [new Reference('validator.mapping.cache.symfony')]); } + + $container->setParameter('validator.auto_mapping', $config['auto_mapping']); + if (!$propertyInfoEnabled || !$config['auto_mapping'] || !class_exists(PropertyInfoLoader::class)) { + $container->removeDefinition('validator.property_info_loader'); + } } private function registerValidatorMapping(ContainerBuilder $container, array $config, array &$files) diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 670c7fc318e8d..c7b37222ae20e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -53,6 +53,7 @@ use Symfony\Component\Translation\DependencyInjection\TranslationExtractorPass; use Symfony\Component\Translation\DependencyInjection\TranslatorPass; use Symfony\Component\Translation\DependencyInjection\TranslatorPathsPass; +use Symfony\Component\Validator\DependencyInjection\AddAutoMappingConfigurationPass; use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass; use Symfony\Component\Validator\DependencyInjection\AddValidatorInitializersPass; @@ -124,6 +125,7 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new TestServiceContainerRealRefPass(), PassConfig::TYPE_AFTER_REMOVING); $this->addCompilerPassIfExists($container, AddMimeTypeGuesserPass::class); $this->addCompilerPassIfExists($container, MessengerPass::class); + $this->addCompilerPassIfExists($container, AddAutoMappingConfigurationPass::class); $container->addCompilerPass(new RegisterReverseContainerPass(true)); $container->addCompilerPass(new RegisterReverseContainerPass(false), PassConfig::TYPE_AFTER_REMOVING); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 98baeb978b440..f975184822545 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -190,6 +190,7 @@ + @@ -207,6 +208,13 @@ + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml index 8a0919f3e281a..4fd3da7a633ea 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.xml @@ -60,5 +60,12 @@ + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 56be70050ccf5..69fdba0726d3e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -232,6 +232,7 @@ protected static function getBundleDefaultConfig() 'mapping' => [ 'paths' => [], ], + 'auto_mapping' => [], ], 'annotations' => [ 'cache' => 'php_array', diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php new file mode 100644 index 0000000000000..e15762d6d8a13 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php @@ -0,0 +1,12 @@ +loadFromExtension('framework', [ + 'property_info' => ['enabled' => true], + 'validation' => [ + 'auto_mapping' => [ + 'App\\' => ['foo', 'bar'], + 'Symfony\\' => ['a', 'b'], + 'Foo\\', + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml new file mode 100644 index 0000000000000..a05aaf8016a56 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml @@ -0,0 +1,20 @@ + + + + + + + + + foo + bar + + + a + b + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml new file mode 100644 index 0000000000000..2564a8d243ef8 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml @@ -0,0 +1,7 @@ +framework: + property_info: { enabled: true } + validation: + auto_mapping: + 'App\': ['foo', 'bar'] + 'Symfony\': ['a', 'b'] + 'Foo\': [] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 5c0352b2658da..a793fd94674e4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -50,6 +50,8 @@ use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\DependencyInjection\TranslatorPass; use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass; +use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; +use Symfony\Component\Validator\Validation; use Symfony\Component\Workflow; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -1033,6 +1035,23 @@ public function testValidationMapping() $this->assertContains('validation.yaml', $calls[4][1][0][2]); } + public function testValidationAutoMapping() + { + if (!class_exists(PropertyInfoLoader::class)) { + $this->markTestSkipped('Auto-mapping requires symfony/validation 4.2+'); + } + + $container = $this->createContainerFromFile('validation_auto_mapping'); + $parameter = [ + 'App\\' => ['services' => ['foo', 'bar']], + 'Symfony\\' => ['services' => ['a', 'b']], + 'Foo\\' => ['services' => []], + ]; + + $this->assertSame($parameter, $container->getParameter('validator.auto_mapping')); + $this->assertTrue($container->hasDefinition('validator.property_info_loader')); + } + public function testFormsCanBeEnabledWithoutCsrfProtection() { $container = $this->createContainerFromFile('form_no_csrf'); diff --git a/src/Symfony/Component/Validator/DependencyInjection/AddAutoMappingConfigurationPass.php b/src/Symfony/Component/Validator/DependencyInjection/AddAutoMappingConfigurationPass.php new file mode 100644 index 0000000000000..fc110bbbe66a9 --- /dev/null +++ b/src/Symfony/Component/Validator/DependencyInjection/AddAutoMappingConfigurationPass.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Injects the automapping configuration as last argument of loaders tagged with the "validator.auto_mapper" tag. + * + * @author Kévin Dunglas + */ +class AddAutoMappingConfigurationPass implements CompilerPassInterface +{ + private $validatorBuilderService; + private $tag; + + public function __construct(string $validatorBuilderService = 'validator.builder', string $tag = 'validator.auto_mapper') + { + $this->validatorBuilderService = $validatorBuilderService; + $this->tag = $tag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasParameter('validator.auto_mapping') || !$container->hasDefinition($this->validatorBuilderService)) { + return; + } + + $config = $container->getParameter('validator.auto_mapping'); + + $globalNamespaces = []; + $servicesToNamespaces = []; + foreach ($config as $namespace => $value) { + if ([] === $value['services']) { + $globalNamespaces[] = $namespace; + + continue; + } + + foreach ($value['services'] as $service) { + $servicesToNamespaces[$service][] = $namespace; + } + } + + $validatorBuilder = $container->getDefinition($this->validatorBuilderService); + foreach ($container->findTaggedServiceIds($this->tag) as $id => $tags) { + $regexp = $this->getRegexp(array_merge($globalNamespaces, $servicesToNamespaces[$id] ?? [])); + + $container->getDefinition($id)->setArgument('$classValidatorRegexp', $regexp); + $validatorBuilder->addMethodCall('addLoader', [new Reference($id)]); + } + + $container->getParameterBag()->remove('validator.auto_mapping'); + } + + /** + * Builds a regexp to check if a class is auto-mapped. + */ + private function getRegexp(array $patterns): string + { + $regexps = []; + foreach ($patterns as $pattern) { + // Escape namespace + $regex = preg_quote(ltrim($pattern, '\\')); + + // Wildcards * and ** + $regex = strtr($regex, ['\\*\\*' => '.*?', '\\*' => '[^\\\\]*?']); + + // If this class does not end by a slash, anchor the end + if ('\\' !== substr($regex, -1)) { + $regex .= '$'; + } + + $regexps[] = '^'.$regex; + } + + return sprintf('{%s}', implode('|', $regexps)); + } +} diff --git a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php new file mode 100644 index 0000000000000..58ed2669d6f2f --- /dev/null +++ b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Mapping\Loader; + +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type as PropertyInfoType; +use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Mapping\ClassMetadata; + +/** + * Guesses and loads the appropriate constraints using PropertyInfo. + * + * @author Kévin Dunglas + */ +final class PropertyInfoLoader implements LoaderInterface +{ + private $listExtractor; + private $typeExtractor; + private $classValidatorRegexp; + + public function __construct(PropertyListExtractorInterface $listExtractor, PropertyTypeExtractorInterface $typeExtractor, string $classValidatorRegexp = null) + { + $this->listExtractor = $listExtractor; + $this->typeExtractor = $typeExtractor; + $this->classValidatorRegexp = $classValidatorRegexp; + } + + /** + * {@inheritdoc} + */ + public function loadClassMetadata(ClassMetadata $metadata) + { + $className = $metadata->getClassName(); + if (null !== $this->classValidatorRegexp && !preg_match($this->classValidatorRegexp, $className)) { + return false; + } + + if (!$properties = $this->listExtractor->getProperties($className)) { + return false; + } + + foreach ($properties as $property) { + $types = $this->typeExtractor->getTypes($className, $property); + if (null === $types) { + continue; + } + + $hasTypeConstraint = false; + $hasNotNullConstraint = false; + $hasNotBlankConstraint = false; + $allConstraint = null; + foreach ($metadata->getPropertyMetadata($property) as $propertyMetadata) { + foreach ($propertyMetadata->getConstraints() as $constraint) { + if ($constraint instanceof Type) { + $hasTypeConstraint = true; + } elseif ($constraint instanceof NotNull) { + $hasNotNullConstraint = true; + } elseif ($constraint instanceof NotBlank) { + $hasNotBlankConstraint = true; + } elseif ($constraint instanceof All) { + $allConstraint = $constraint; + } + } + } + + $builtinTypes = []; + $nullable = false; + $scalar = true; + foreach ($types as $type) { + $builtinTypes[] = $type->getBuiltinType(); + + if ($scalar && !\in_array($type->getBuiltinType(), [PropertyInfoType::BUILTIN_TYPE_INT, PropertyInfoType::BUILTIN_TYPE_FLOAT, PropertyInfoType::BUILTIN_TYPE_STRING, PropertyInfoType::BUILTIN_TYPE_BOOL], true)) { + $scalar = false; + } + + if (!$nullable && $type->isNullable()) { + $nullable = true; + } + } + if (!$hasTypeConstraint) { + if (1 === \count($builtinTypes)) { + if ($types[0]->isCollection() && (null !== $collectionValueType = $types[0]->getCollectionValueType())) { + $this->handleAllConstraint($property, $allConstraint, $collectionValueType, $metadata); + } + + $metadata->addPropertyConstraint($property, $this->getTypeConstraint($builtinTypes[0], $types[0])); + } elseif ($scalar) { + $metadata->addPropertyConstraint($property, new Type(['type' => 'scalar'])); + } + } + + if (!$nullable && !$hasNotBlankConstraint && !$hasNotNullConstraint) { + $metadata->addPropertyConstraint($property, new NotNull()); + } + } + + return true; + } + + private function getTypeConstraint(string $builtinType, PropertyInfoType $type): Type + { + if (PropertyInfoType::BUILTIN_TYPE_OBJECT === $builtinType && null !== $className = $type->getClassName()) { + return new Type(['type' => $className]); + } + + return new Type(['type' => $builtinType]); + } + + private function handleAllConstraint(string $property, ?All $allConstraint, PropertyInfoType $propertyInfoType, ClassMetadata $metadata) + { + $containsTypeConstraint = false; + $containsNotNullConstraint = false; + if (null !== $allConstraint) { + foreach ($allConstraint->constraints as $constraint) { + if ($constraint instanceof Type) { + $containsTypeConstraint = true; + } elseif ($constraint instanceof NotNull) { + $containsNotNullConstraint = true; + } + } + } + + $constraints = []; + if (!$containsNotNullConstraint && !$propertyInfoType->isNullable()) { + $constraints[] = new NotNull(); + } + + if (!$containsTypeConstraint) { + $constraints[] = $this->getTypeConstraint($propertyInfoType->getBuiltinType(), $propertyInfoType); + } + + if (null === $allConstraint) { + $metadata->addPropertyConstraint($property, new All(['constraints' => $constraints])); + } else { + $allConstraint->constraints = array_merge($allConstraint->constraints, $constraints); + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php b/src/Symfony/Component/Validator/Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php new file mode 100644 index 0000000000000..b4b5699760404 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Validator\DependencyInjection\AddAutoMappingConfigurationPass; +use Symfony\Component\Validator\Tests\Fixtures\PropertyInfoLoaderEntity; +use Symfony\Component\Validator\ValidatorBuilder; + +/** + * @author Kévin Dunglas + */ +class AddAutoMappingConfigurationPassTest extends TestCase +{ + public function testNoConfigParameter() + { + $container = new ContainerBuilder(); + (new AddAutoMappingConfigurationPass())->process($container); + $this->assertCount(1, $container->getDefinitions()); + } + + public function testNoValidatorBuilder() + { + $container = new ContainerBuilder(); + (new AddAutoMappingConfigurationPass())->process($container); + $this->assertCount(1, $container->getDefinitions()); + } + + /** + * @dataProvider mappingProvider + */ + public function testProcess(string $namespace, array $services, string $expectedRegexp) + { + $container = new ContainerBuilder(); + $container->setParameter('validator.auto_mapping', [ + 'App\\' => ['services' => []], + $namespace => ['services' => $services], + ]); + + $container->register('validator.builder', ValidatorBuilder::class); + foreach ($services as $service) { + $container->register($service)->addTag('validator.auto_mapper'); + } + + (new AddAutoMappingConfigurationPass())->process($container); + + foreach ($services as $service) { + $this->assertSame($expectedRegexp, $container->getDefinition($service)->getArgument('$classValidatorRegexp')); + } + $this->assertCount(\count($services), $container->getDefinition('validator.builder')->getMethodCalls()); + } + + public function mappingProvider(): array + { + return [ + ['Foo\\', ['foo', 'baz'], '{^App\\\\|^Foo\\\\}'], + [PropertyInfoLoaderEntity::class, ['class'], '{^App\\\\|^Symfony\\\\Component\\\\Validator\\\\Tests\\\\Fixtures\\\\PropertyInfoLoaderEntity$}'], + ['Symfony\Component\Validator\Tests\Fixtures\\', ['trailing_antislash'], '{^App\\\\|^Symfony\\\\Component\\\\Validator\\\\Tests\\\\Fixtures\\\\}'], + ['Symfony\Component\Validator\Tests\Fixtures\\*', ['trailing_star'], '{^App\\\\|^Symfony\\\\Component\\\\Validator\\\\Tests\\\\Fixtures\\\\[^\\\\]*?$}'], + ['Symfony\Component\Validator\Tests\Fixtures\\**', ['trailing_double_star'], '{^App\\\\|^Symfony\\\\Component\\\\Validator\\\\Tests\\\\Fixtures\\\\.*?$}'], + ]; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/PropertyInfoLoaderEntity.php b/src/Symfony/Component/Validator/Tests/Fixtures/PropertyInfoLoaderEntity.php new file mode 100644 index 0000000000000..6e66c08b20365 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/PropertyInfoLoaderEntity.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @author Kévin Dunglas + */ +class PropertyInfoLoaderEntity +{ + public $nullableString; + public $string; + public $scalar; + public $object; + public $collection; + + /** + * @Assert\Type(type="int") + */ + public $alreadyMappedType; + + /** + * @Assert\NotNull + */ + public $alreadyMappedNotNull; + + /** + * @Assert\NotBlank + */ + public $alreadyMappedNotBlank; + + /** + * @Assert\All({ + * @Assert\Type(type="string"), + * @Assert\Iban + * }) + */ + public $alreadyPartiallyMappedCollection; +} diff --git a/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php b/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php new file mode 100644 index 0000000000000..87898341d29bb --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Mapping/Loader/PropertyInfoLoaderTest.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Mapping\Loader; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraints\All; +use Symfony\Component\Validator\Constraints\Iban; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type as TypeConstraint; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; +use Symfony\Component\Validator\Tests\Fixtures\Entity; +use Symfony\Component\Validator\Tests\Fixtures\PropertyInfoLoaderEntity; +use Symfony\Component\Validator\Validation; + +/** + * @author Kévin Dunglas + */ +class PropertyInfoLoaderTest extends TestCase +{ + public function testLoadClassMetadata() + { + $propertyInfoStub = $this->createMock(PropertyInfoExtractorInterface::class); + $propertyInfoStub + ->method('getProperties') + ->willReturn([ + 'nullableString', + 'string', + 'scalar', + 'object', + 'collection', + 'alreadyMappedType', + 'alreadyMappedNotNull', + 'alreadyMappedNotBlank', + 'alreadyPartiallyMappedCollection', + ]) + ; + $propertyInfoStub + ->method('getTypes') + ->will($this->onConsecutiveCalls( + [new Type(Type::BUILTIN_TYPE_STRING, true)], + [new Type(Type::BUILTIN_TYPE_STRING)], + [new Type(Type::BUILTIN_TYPE_STRING, true), new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_BOOL)], + [new Type(Type::BUILTIN_TYPE_OBJECT, true, Entity::class)], + [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Entity::class))], + [new Type(Type::BUILTIN_TYPE_FLOAT, true)], // The existing constraint is float + [new Type(Type::BUILTIN_TYPE_STRING, true)], + [new Type(Type::BUILTIN_TYPE_STRING, true)], + [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true, null, new Type(Type::BUILTIN_TYPE_FLOAT))] + )) + ; + + $propertyInfoLoader = new PropertyInfoLoader($propertyInfoStub, $propertyInfoStub); + + $validator = Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->addLoader($propertyInfoLoader) + ->getValidator() + ; + + $classMetadata = $validator->getMetadataFor(new PropertyInfoLoaderEntity()); + + $nullableStringMetadata = $classMetadata->getPropertyMetadata('nullableString'); + $this->assertCount(1, $nullableStringMetadata); + $nullableStringConstraints = $nullableStringMetadata[0]->getConstraints(); + $this->assertCount(1, $nullableStringConstraints); + $this->assertInstanceOf(TypeConstraint::class, $nullableStringConstraints[0]); + $this->assertSame('string', $nullableStringConstraints[0]->type); + + $stringMetadata = $classMetadata->getPropertyMetadata('string'); + $this->assertCount(1, $stringMetadata); + $stringConstraints = $stringMetadata[0]->getConstraints(); + $this->assertCount(2, $stringConstraints); + $this->assertInstanceOf(TypeConstraint::class, $stringConstraints[0]); + $this->assertSame('string', $stringConstraints[0]->type); + $this->assertInstanceOf(NotNull::class, $stringConstraints[1]); + + $scalarMetadata = $classMetadata->getPropertyMetadata('scalar'); + $this->assertCount(1, $scalarMetadata); + $scalarConstraints = $scalarMetadata[0]->getConstraints(); + $this->assertCount(1, $scalarConstraints); + $this->assertInstanceOf(TypeConstraint::class, $scalarConstraints[0]); + $this->assertSame('scalar', $scalarConstraints[0]->type); + + $objectMetadata = $classMetadata->getPropertyMetadata('object'); + $this->assertCount(1, $objectMetadata); + $objectConstraints = $objectMetadata[0]->getConstraints(); + $this->assertCount(1, $objectConstraints); + $this->assertInstanceOf(TypeConstraint::class, $objectConstraints[0]); + $this->assertSame(Entity::class, $objectConstraints[0]->type); + + $collectionMetadata = $classMetadata->getPropertyMetadata('collection'); + $this->assertCount(1, $collectionMetadata); + $collectionConstraints = $collectionMetadata[0]->getConstraints(); + $this->assertCount(2, $collectionConstraints); + $this->assertInstanceOf(All::class, $collectionConstraints[0]); + $this->assertInstanceOf(NotNull::class, $collectionConstraints[0]->constraints[0]); + $this->assertInstanceOf(TypeConstraint::class, $collectionConstraints[0]->constraints[1]); + $this->assertSame(Entity::class, $collectionConstraints[0]->constraints[1]->type); + + $alreadyMappedTypeMetadata = $classMetadata->getPropertyMetadata('alreadyMappedType'); + $this->assertCount(1, $alreadyMappedTypeMetadata); + $alreadyMappedTypeConstraints = $alreadyMappedTypeMetadata[0]->getConstraints(); + $this->assertCount(1, $alreadyMappedTypeMetadata); + $this->assertInstanceOf(TypeConstraint::class, $alreadyMappedTypeConstraints[0]); + + $alreadyMappedNotNullMetadata = $classMetadata->getPropertyMetadata('alreadyMappedNotNull'); + $this->assertCount(1, $alreadyMappedNotNullMetadata); + $alreadyMappedNotNullConstraints = $alreadyMappedNotNullMetadata[0]->getConstraints(); + $this->assertCount(1, $alreadyMappedNotNullMetadata); + $this->assertInstanceOf(NotNull::class, $alreadyMappedNotNullConstraints[0]); + + $alreadyMappedNotBlankMetadata = $classMetadata->getPropertyMetadata('alreadyMappedNotBlank'); + $this->assertCount(1, $alreadyMappedNotBlankMetadata); + $alreadyMappedNotBlankConstraints = $alreadyMappedNotBlankMetadata[0]->getConstraints(); + $this->assertCount(1, $alreadyMappedNotBlankMetadata); + $this->assertInstanceOf(NotBlank::class, $alreadyMappedNotBlankConstraints[0]); + + $alreadyPartiallyMappedCollectionMetadata = $classMetadata->getPropertyMetadata('alreadyPartiallyMappedCollection'); + $this->assertCount(1, $alreadyPartiallyMappedCollectionMetadata); + $alreadyPartiallyMappedCollectionConstraints = $alreadyPartiallyMappedCollectionMetadata[0]->getConstraints(); + $this->assertCount(2, $alreadyPartiallyMappedCollectionConstraints); + $this->assertInstanceOf(All::class, $alreadyPartiallyMappedCollectionConstraints[0]); + $this->assertInstanceOf(TypeConstraint::class, $alreadyPartiallyMappedCollectionConstraints[0]->constraints[0]); + $this->assertSame('string', $alreadyPartiallyMappedCollectionConstraints[0]->constraints[0]->type); + $this->assertInstanceOf(Iban::class, $alreadyPartiallyMappedCollectionConstraints[0]->constraints[1]); + $this->assertInstanceOf(NotNull::class, $alreadyPartiallyMappedCollectionConstraints[0]->constraints[2]); + } + + /** + * @dataProvider regexpProvider + */ + public function testClassValidator(bool $expected, string $classValidatorRegexp = null) + { + $propertyInfoStub = $this->createMock(PropertyInfoExtractorInterface::class); + $propertyInfoStub + ->method('getProperties') + ->willReturn(['string']) + ; + $propertyInfoStub + ->method('getTypes') + ->willReturn([new Type(Type::BUILTIN_TYPE_STRING)]) + ; + + $propertyInfoLoader = new PropertyInfoLoader($propertyInfoStub, $propertyInfoStub, $classValidatorRegexp); + + $classMetadata = new ClassMetadata(PropertyInfoLoaderEntity::class); + $this->assertSame($expected, $propertyInfoLoader->loadClassMetadata($classMetadata)); + } + + public function regexpProvider() + { + return [ + [true, null], + [true, '{^'.preg_quote(PropertyInfoLoaderEntity::class).'$|^'.preg_quote(Entity::class).'$}'], + [false, '{^'.preg_quote(Entity::class).'$}'], + ]; + } +} diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index c17fd098f5743..9bb26f56785ec 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -32,6 +32,7 @@ "symfony/expression-language": "~3.4|~4.0", "symfony/cache": "~3.4|~4.0", "symfony/property-access": "~3.4|~4.0", + "symfony/property-info": "~3.4|~4.0", "symfony/translation": "~4.2", "doctrine/annotations": "~1.0", "doctrine/cache": "~1.0", @@ -56,6 +57,7 @@ "symfony/config": "", "egulias/email-validator": "Strict (RFC compliant) email validation", "symfony/property-access": "For accessing properties within comparison constraints", + "symfony/property-info": "To automatically add NotNull and Type constraints", "symfony/expression-language": "For using the Expression validator" }, "autoload": { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy