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 Jun 26, 2018
1 parent 84ada0c commit 1dd97e4
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 4 deletions.
86 changes: 86 additions & 0 deletions src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?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\Mapping\ClassMetadataFactory;
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\ConstraintChecker;
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
{
use ConstraintChecker;

private $classMetadataFactory;

public function __construct(ClassMetadataFactory $classMetadataFactory)
{
$this->classMetadataFactory = $classMetadataFactory;
}

/**
* {@inheritdoc}
*/
public function loadClassMetadata(ClassMetadata $metadata): bool
{
try {
$doctrineMetadata = $this->classMetadataFactory->getMetadataFor($metadata->getClassName());
} catch (MappingException $exception) {
return false;
} catch (OrmMappingException $exception) {
return false;
}

if (!$doctrineMetadata instanceof ClassMetadataInfo) {
return false;
}

/* Available keys:
- type
- scale
- length
- unique
- nullable
- precision
*/

$uniqueFields = array();

// Type and nullable aren't handled here, use the PropertyInfo Loader instead.
foreach ($doctrineMetadata->fieldMappings as $mapping) {
// TODO: Currently, I don't add a constraint if one of the same type already exists, but it's maybe safer to add both (min/max)?
if (null !== $mapping['length'] && !$this->propertyHasConstraint($metadata, Length::class, $mapping['fieldName'])) {
$metadata->addPropertyConstraint($mapping['fieldName'], new Length(array('max' => $mapping['length'])));
}

if (true === $mapping['unique']) {
$uniqueFields[] = $mapping['fieldName'];
}
}

if ($uniqueFields && !$this->classHasConstraint($metadata, UniqueEntity::class)) {
$metadata->addConstraint(new UniqueEntity(array('fields' => $uniqueFields)));
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Doctrine\Common\Annotations\Reader;
use Doctrine\Common\Annotations\AnnotationRegistry;
use Symfony\Bridge\Doctrine\Validator\DoctrineLoader;
use Symfony\Bridge\Monolog\Processor\DebugProcessor;
use Symfony\Bridge\Twig\Extension\CsrfExtension;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
Expand Down Expand Up @@ -72,6 +73,7 @@
use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
use Symfony\Component\Routing\Loader\AnnotationFileLoader;
use Symfony\Component\Security\Core\Security;
Expand Down Expand Up @@ -1080,6 +1082,23 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
if (!$container->getParameter('kernel.debug')) {
$validatorBuilder->addMethodCall('setMetadataCache', array(new Reference('validator.mapping.cache.symfony')));
}

// TODO: add a configuration option to enable or disable this feature (validator.auto_validate: ['@validator.property_info_loader', '@doctrine_bundle.validator_loader'])?
if (class_exists(Type::class)) {
$validatorBuilder->addMethodCall('addLoader', array(new Reference('validator.property_info_loader')));
} else {
$container->removeDefinition('validator.property_info_loader');
}

// TODO: move this in a compiler pass in DoctrineBundle
// TODO: support multiple entity managers (reuse DoctrineBundle's extension logic)
// TODO: Use XML definitions instead
if (class_exists(DoctrineLoader::class)) {
$definition = $container->register('doctrine_loader', DoctrineLoader::class);
$definition->addArgument(new Reference('doctrine.orm.default_entity_manager.metadata_factory'));

$validatorBuilder->addMethodCall('addLoader', array(new Reference('doctrine_loader')));
}
}

private function registerValidatorMapping(ContainerBuilder $container, array $config, array &$files)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,10 @@
<argument></argument>
<tag name="validator.constraint_validator" alias="Symfony\Component\Validator\Constraints\EmailValidator" />
</service>

<service id="validator.property_info_loader" class="Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader">
<argument type="service" id="Symfony\Component\PropertyInfo\PropertyListExtractorInterface" />
<argument type="service" id="Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface" />
</service>
</services>
</container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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;

use Symfony\Component\Validator\Mapping\Loader\LoaderInterface;

/**
* Allows to register extra metadata loaders (for instance the Doctrine one).
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
interface ExtraLoaderValidatorBuilderInterface extends ValidatorBuilderInterface
{
/**
* Adds a metadata loader at the end of the chain.
*
* @return $this
*/
public function addLoader(LoaderInterface $loader): self;
}
46 changes: 46 additions & 0 deletions src/Symfony/Component/Validator/Mapping/ConstraintChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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;

/**
* Helper methods to check if a property or a class has a given constraint.
*
* @internal
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
trait ConstraintChecker
{
private function propertyHasConstraint(ClassMetadata $metadata, string $constraintType, string $fieldName): bool
{
foreach ($metadata->getPropertyMetadata($fieldName) as $propertyMetadata) {
foreach ($propertyMetadata->getConstraints() as $constraint) {
if (is_a($constraint, $constraintType, true)) {
return true;
}
}
}

return false;
}

private function classHasConstraint(ClassMetadata $metadata, string $constraintType): bool
{
foreach ($metadata->getConstraints() as $constraint) {
if (is_a($constraint, $constraintType, true)) {
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?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\NotNull;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Mapping\ConstraintChecker;

/**
* Guesses and loads the appropriate constraints using PropertyInfo.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class PropertyInfoLoader implements LoaderInterface
{
use ConstraintChecker;

private $listExtractor;
private $typeExtractor;

public function __construct(PropertyListExtractorInterface $listExtractor, PropertyTypeExtractorInterface $typeExtractor)
{
$this->listExtractor = $listExtractor;
$this->typeExtractor = $typeExtractor;
}

/**
* {@inheritdoc}
*/
public function loadClassMetadata(ClassMetadata $metadata)
{
$class = $metadata->getClassName();
if (!$properties = $this->listExtractor->getProperties($class)) {
return;
}

foreach ($properties as $property) {
$types = $this->typeExtractor->getTypes($metadata->getClassName(), $property);
if (null === $types) {
continue;
}

$builtinTypes = array();
$nullable = false;
$scalar = true;

foreach ($types as $type) {
$builtinTypes[] = $type->getBuiltinType();

if ($scalar && !\in_array($type->getBuiltinType(), array(PropertyInfoType::BUILTIN_TYPE_INT, PropertyInfoType::BUILTIN_TYPE_FLOAT, PropertyInfoType::BUILTIN_TYPE_STRING, PropertyInfoType::BUILTIN_TYPE_BOOL))) {
$scalar = false;
}

if (!$nullable && $type->isNullable()) {
$nullable = true;
}
}

if (!$this->propertyHasConstraint($metadata, Type::class, $property)) {
if (1 === \count($builtinTypes)) {
$metadata->addPropertyConstraint($property, new Type(array('type' => $builtinTypes[0])));
} elseif ($scalar) {
$metadata->addPropertyConstraint($property, new Type(array('type' => 'scalar')));
}
}

if (!$nullable && !$this->propertyHasConstraint($metadata, NotNull::class, $property)) {
$metadata->addPropertyConstraint($property, new NotNull());
}
}
}
}
23 changes: 19 additions & 4 deletions src/Symfony/Component/Validator/ValidatorBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,18 @@
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class ValidatorBuilder implements ValidatorBuilderInterface
class ValidatorBuilder implements ExtraLoaderValidatorBuilderInterface
{
private $initializers = array();
private $xmlMappings = array();
private $yamlMappings = array();
private $methodMappings = array();

/**
* @var LoaderInterface[]
*/
private $extraLoaders = array();

/**
* @var Reader|null
*/
Expand Down Expand Up @@ -92,6 +97,16 @@ public function addObjectInitializers(array $initializers)
return $this;
}

/**
* {@inheritdoc}
*/
public function addLoader(LoaderInterface $loader): ExtraLoaderValidatorBuilderInterface
{
$this->extraLoaders[] = $loader;

return $this;
}

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -213,8 +228,8 @@ public function disableAnnotationMapping()
*/
public function setMetadataFactory(MetadataFactoryInterface $metadataFactory)
{
if (count($this->xmlMappings) > 0 || count($this->yamlMappings) > 0 || count($this->methodMappings) > 0 || null !== $this->annotationReader) {
throw new ValidatorException('You cannot set a custom metadata factory after adding custom mappings. You should do either of both.');
if ($this->xmlMappings || $this->yamlMappings || $this->methodMappings || $this->extraLoaders || null !== $this->annotationReader) {
throw new ValidatorException('You cannot set a custom metadata factory after adding custom mappings or extra loaders. You should do either of both.');
}

$this->metadataFactory = $metadataFactory;
Expand Down Expand Up @@ -289,7 +304,7 @@ public function getLoaders()
$loaders[] = new AnnotationLoader($this->annotationReader);
}

return $loaders;
return array_merge($loaders, $this->extraLoaders);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Component/Validator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"doctrine/annotations": "~1.0",
"doctrine/cache": "~1.0",
"egulias/email-validator": "^1.2.8|~2.0"
Expand All @@ -53,6 +54,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": {
Expand Down

0 comments on commit 1dd97e4

Please sign in to comment.