Skip to content

Commit

Permalink
[Validator][DoctrineBridge][FWBundle] Automatic data validation
Browse files Browse the repository at this point in the history
  • Loading branch information
dunglas committed Mar 21, 2019
1 parent 40f8423 commit b197af4
Show file tree
Hide file tree
Showing 6 changed files with 539 additions and 0 deletions.
93 changes: 93 additions & 0 deletions DependencyInjection/AddAutoMappingConfigurationPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
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));
}
}
151 changes: 151 additions & 0 deletions Mapping/Loader/PropertyInfoLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
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);
}
}
}
73 changes: 73 additions & 0 deletions Tests/DependencyInjection/AddAutoMappingConfigurationPassTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
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\\\\.*?$}'],
];
}
}
49 changes: 49 additions & 0 deletions Tests/Fixtures/PropertyInfoLoaderEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <dunglas@gmail.com>
*/
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;
}

0 comments on commit b197af4

Please sign in to comment.