From b197af42d979cfb4bc9d6b95611662cacadfdc00 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 --- .../AddAutoMappingConfigurationPass.php | 93 ++++++++++ Mapping/Loader/PropertyInfoLoader.php | 151 ++++++++++++++++ .../AddAutoMappingConfigurationPassTest.php | 73 ++++++++ Tests/Fixtures/PropertyInfoLoaderEntity.php | 49 +++++ .../Mapping/Loader/PropertyInfoLoaderTest.php | 171 ++++++++++++++++++ composer.json | 2 + 6 files changed, 539 insertions(+) create mode 100644 DependencyInjection/AddAutoMappingConfigurationPass.php create mode 100644 Mapping/Loader/PropertyInfoLoader.php create mode 100644 Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php create mode 100644 Tests/Fixtures/PropertyInfoLoaderEntity.php create mode 100644 Tests/Mapping/Loader/PropertyInfoLoaderTest.php diff --git a/DependencyInjection/AddAutoMappingConfigurationPass.php b/DependencyInjection/AddAutoMappingConfigurationPass.php new file mode 100644 index 000000000..fc110bbbe --- /dev/null +++ b/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/Mapping/Loader/PropertyInfoLoader.php b/Mapping/Loader/PropertyInfoLoader.php new file mode 100644 index 000000000..58ed2669d --- /dev/null +++ b/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/Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php b/Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php new file mode 100644 index 000000000..b4b569976 --- /dev/null +++ b/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/Tests/Fixtures/PropertyInfoLoaderEntity.php b/Tests/Fixtures/PropertyInfoLoaderEntity.php new file mode 100644 index 000000000..6e66c08b2 --- /dev/null +++ b/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/Tests/Mapping/Loader/PropertyInfoLoaderTest.php b/Tests/Mapping/Loader/PropertyInfoLoaderTest.php new file mode 100644 index 000000000..87898341d --- /dev/null +++ b/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/composer.json b/composer.json index c17fd098f..9bb26f567 100644 --- a/composer.json +++ b/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": {