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": {