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 11, 2019
1 parent f54c89c commit 0f3cae5
Show file tree
Hide file tree
Showing 19 changed files with 937 additions and 3 deletions.
@@ -0,0 +1,58 @@
<?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\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 <dunglas@gmail.com>
*/
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;
}
94 changes: 94 additions & 0 deletions src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php
@@ -0,0 +1,94 @@
<?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\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 <dunglas@gmail.com>
*/
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(array('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 array(
array(true, null),
array(true, '{^'.preg_quote(DoctrineLoaderEntity::class).'$|^'.preg_quote(Entity::class).'$}'),
array(false, '{^'.preg_quote(Entity::class).'$}'),
);
}
}
121 changes: 121 additions & 0 deletions src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php
@@ -0,0 +1,121 @@
<?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\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 <dunglas@gmail.com>
*/
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(array('fields' => $mapping['fieldName'])));
}

if (null === $mapping['length']) {
continue;
}

$constraint = $this->getLengthConstraint($metadata, $mapping['fieldName']);
if (null === $constraint) {
$metadata->addPropertyConstraint($mapping['fieldName'], new Length(array('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 = array();
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;
}
}
Expand Up @@ -789,6 +789,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'] = array();
continue;
}

if (!\is_array($v)) {
$values[$v]['services'] = array();
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()
Expand Down
Expand Up @@ -103,6 +103,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;
Expand Down Expand Up @@ -272,7 +273,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);
Expand All @@ -293,7 +295,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);
}

Expand Down Expand Up @@ -1117,7 +1119,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;
Expand Down Expand Up @@ -1168,6 +1170,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)
Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php
Expand Up @@ -52,6 +52,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;
use Symfony\Component\Workflow\DependencyInjection\ValidateWorkflowsPass;
Expand Down Expand Up @@ -125,6 +126,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);

if ($container->getParameter('kernel.debug')) {
$container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32);
Expand Down

0 comments on commit 0f3cae5

Please sign in to comment.