Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
use Symfony\Component\Uid\Factory\UuidFactory;
use Symfony\Component\Uid\UuidV4;
use Symfony\Component\Validator\Attribute\ExtendsValidationFor;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider;
use Symfony\Component\Validator\Constraints\Traverse;
Expand Down Expand Up @@ -1819,10 +1820,16 @@ private function registerValidationConfiguration(array $config, ContainerBuilder
if (class_exists(ValidatorAttributeMetadataPass::class) && (!($config['enable_attributes'] ?? false) || !$container->getParameter('kernel.debug'))) {
// The $reflector argument hints at where the attribute could be used
$container->registerAttributeForAutoconfiguration(Constraint::class, function (ChildDefinition $definition, Constraint $attribute, \ReflectionClass|\ReflectionMethod|\ReflectionProperty $reflector) {
$definition->addTag('validator.attribute_metadata');
$definition->addTag('validator.attribute_metadata')
->addTag('container.excluded', ['source' => 'because it\'s a validator constraint extension']);
});
}

$container->registerAttributeForAutoconfiguration(ExtendsValidationFor::class, function (ChildDefinition $definition, ExtendsValidationFor $attribute) {
$definition->addTag('validator.attribute_metadata', ['for' => $attribute->class])
->addTag('container.excluded', ['source' => 'because it\'s a validator constraint extension']);
});

if ($config['enable_attributes'] ?? false) {
$validatorBuilder->addMethodCall('enableAttributeMapping');
}
Expand Down
32 changes: 32 additions & 0 deletions src/Symfony/Component/Validator/Attribute/ExtendsValidationFor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Validator\Attribute;

/**
* Declares that constraints listed on the current class should be added to the given class.
*
* Classes that use this attribute should contain only properties and methods that
* exist on the target class (not necessarily all of them).
*
* @author Nicolas Grekas <[email protected]>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class ExtendsValidationFor
{
/**
* @param class-string $class
*/
public function __construct(
public string $class,
) {
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
7.4
---

* Add `#[ExtendsValidationFor]` to declare new constraints for a class
* Add `ValidatorBuilder::addAttributeMappings()` and `AttributeMetadataPass` to declare compile-time constraint metadata using attributes
* Add the `Video` constraint for validating video files
* Deprecate implementing `__sleep/wakeup()` on `GenericMetadata` implementations; use `__(un)serialize()` instead
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\Validator\Exception\MappingException;

/**
* @author Nicolas Grekas <[email protected]>
Expand All @@ -35,7 +36,14 @@ public function process(ContainerBuilder $container): void
if (!$definition->hasTag('container.excluded')) {
throw new InvalidArgumentException(\sprintf('The resource "%s" tagged "validator.attribute_metadata" is missing the "container.excluded" tag.', $id));
}
$mappedClasses[$resolve($definition->getClass())] = true;
$class = $resolve($definition->getClass());
foreach ($definition->getTag('validator.attribute_metadata') as $attributes) {
if ($class !== $for = $attributes['for'] ?? $class) {
$this->checkSourceMapsToTarget($container, $class, $for);
}

$mappedClasses[$for][$class] = true;
}
}

if (!$mappedClasses) {
Expand All @@ -45,6 +53,24 @@ public function process(ContainerBuilder $container): void
ksort($mappedClasses);

$container->getDefinition('validator.builder')
->addMethodCall('addAttributeMappings', [array_keys($mappedClasses)]);
->addMethodCall('addAttributeMappings', [array_map('array_keys', $mappedClasses)]);
}

private function checkSourceMapsToTarget(ContainerBuilder $container, string $source, string $target): void
{
$source = $container->getReflectionClass($source);
$target = $container->getReflectionClass($target);

foreach ($source->getProperties() as $p) {
if ($p->class === $source->name && !($target->hasProperty($p->name) && $target->getProperty($p->name)->class === $target->name)) {
throw new MappingException(\sprintf('The property "%s" on "%s" is not present on "%s".', $p->name, $source->name, $target->name));
}
}

foreach ($source->getMethods() as $m) {
if ($m->class === $source->name && !($target->hasMethod($m->name) && $target->getMethod($m->name)->class === $target->name)) {
throw new MappingException(\sprintf('The method "%s" on "%s" is not present on "%s".', $m->name, $source->name, $target->name));
}
}
}
}
18 changes: 14 additions & 4 deletions src/Symfony/Component/Validator/Mapping/Loader/AttributeLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
class AttributeLoader implements LoaderInterface
{
/**
* @param class-string[] $mappedClasses
* @param array<class-string, class-string[]> $mappedClasses
*/
public function __construct(
private bool $allowAnyClass = true,
Expand All @@ -41,16 +41,26 @@ public function __construct(
*/
public function getMappedClasses(): array
{
return $this->mappedClasses;
return array_keys($this->mappedClasses);
}

public function loadClassMetadata(ClassMetadata $metadata): bool
{
if (!$this->allowAnyClass && !\in_array($metadata->getClassName(), $this->mappedClasses, true)) {
if (!$sourceClasses = $this->mappedClasses[$metadata->getClassName()] ??= $this->allowAnyClass ? [$metadata->getClassName()] : []) {
return false;
}

$reflClass = $metadata->getReflectionClass();
$success = false;
foreach ($sourceClasses as $sourceClass) {
$reflClass = $metadata->getClassName() === $sourceClass ? $metadata->getReflectionClass() : new \ReflectionClass($sourceClass);
$success = $this->doLoadClassMetadata($reflClass, $metadata) || $success;
}

return $success;
}

public function doLoadClassMetadata(\ReflectionClass $reflClass, ClassMetadata $metadata): bool
{
$className = $reflClass->name;
$success = false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\Validator\DependencyInjection\AttributeMetadataPass;
use Symfony\Component\Validator\Exception\MappingException;

class AttributeMetadataPassTest extends TestCase
{
Expand Down Expand Up @@ -67,7 +69,82 @@ public function testProcessWithTaggedServices()
$this->assertEquals('addAttributeMappings', $methodCalls[1][0]);

// Classes should be sorted alphabetically
$expectedClasses = ['App\Entity\Order', 'App\Entity\Product', 'App\Entity\User'];
$expectedClasses = [
'App\Entity\Order' => ['App\Entity\Order'],
'App\Entity\Product' => ['App\Entity\Product'],
'App\Entity\User' => ['App\Entity\User'],
];
$this->assertEquals([$expectedClasses], $methodCalls[1][1]);
}

public function testThrowsWhenMissingExcludedTag()
{
$container = new ContainerBuilder();
$container->register('validator.builder');

$container->register('service_without_excluded', 'App\\Entity\\User')
->addTag('validator.attribute_metadata');

$this->expectException(InvalidArgumentException::class);
(new AttributeMetadataPass())->process($container);
}

public function testProcessWithForOptionAndMatchingMembers()
{
$sourceClass = _AttrMeta_Source::class;
$targetClass = _AttrMeta_Target::class;

$container = new ContainerBuilder();
$container->register('validator.builder');

$container->register('service.source', $sourceClass)
->addTag('validator.attribute_metadata', ['for' => $targetClass])
->addTag('container.excluded');

(new AttributeMetadataPass())->process($container);

$methodCalls = $container->getDefinition('validator.builder')->getMethodCalls();
$this->assertNotEmpty($methodCalls);
$this->assertSame('addAttributeMappings', $methodCalls[0][0]);
$this->assertSame([$targetClass => [$sourceClass]], $methodCalls[0][1][0]);
}

public function testProcessWithForOptionAndMissingMemberThrows()
{
$sourceClass = _AttrMeta_BadSource::class;
$targetClass = _AttrMeta_Target::class;

$container = new ContainerBuilder();
$container->register('validator.builder');

$container->register('service.source', $sourceClass)
->addTag('validator.attribute_metadata', ['for' => $targetClass])
->addTag('container.excluded');

$this->expectException(MappingException::class);
(new AttributeMetadataPass())->process($container);
}
}

class _AttrMeta_Source
{
public string $name;

public function getName()
{
}
}

class _AttrMeta_Target
{
public string $name;

public function getName()
{
}
}

class _AttrMeta_BadSource
{
public string $extra;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\Component\Validator\Tests\Mapping\Loader;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Attribute\ExtendsValidationFor;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\AtLeastOneOf;
use Symfony\Component\Validator\Constraints\Callback;
Expand Down Expand Up @@ -229,35 +230,122 @@ public function testLoadExternalGroupSequenceProvider()

public function testGetMappedClasses()
{
$classes = ['App\Entity\User', 'App\Entity\Product', 'App\Entity\Order'];
$classes = [
'App\Entity\User' => ['App\Entity\User'],
'App\Entity\Product' => ['App\Entity\Product'],
'App\Entity\Order' => ['App\Entity\Order'],
];
$loader = new AttributeLoader(false, $classes);

$this->assertSame($classes, $loader->getMappedClasses());
$this->assertSame(array_keys($classes), $loader->getMappedClasses());
}

public function testLoadClassMetadataReturnsFalseForUnmappedClass()
{
$loader = new AttributeLoader(false, ['App\Entity\User']);
$loader = new AttributeLoader(false, ['App\Entity\User' => ['App\Entity\User']]);
$metadata = new ClassMetadata('App\Entity\Product');

$this->assertFalse($loader->loadClassMetadata($metadata));
}

public function testLoadClassMetadataReturnsFalseForClassWithoutAttributes()
{
$loader = new AttributeLoader(false, ['stdClass']);
$loader = new AttributeLoader(false, ['stdClass' => ['stdClass']]);
$metadata = new ClassMetadata('stdClass');

$this->assertFalse($loader->loadClassMetadata($metadata));
}

public function testLoadClassMetadataForMappedClassWithAttributes()
{
$loader = new AttributeLoader(false, [Entity::class]);
$loader = new AttributeLoader(false, [Entity::class => [Entity::class]]);
$metadata = new ClassMetadata(Entity::class);

$this->assertTrue($loader->loadClassMetadata($metadata));

$this->assertNotEmpty($metadata->getConstraints());
}

public function testLoadClassMetadataFromExplicitAttributeMappings()
{
$targetClass = _AttrMap_Target::class;
$sourceClass = _AttrMap_Source::class;

$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
$metadata = new ClassMetadata($targetClass);

$this->assertTrue($loader->loadClassMetadata($metadata));
$this->assertInstanceOf(NotBlank::class, $metadata->getPropertyMetadata('name', $sourceClass)[0]->getConstraints()[0]);
}

public function testLoadClassMetadataWithClassLevelConstraints()
{
$targetClass = _AttrMap_Target::class;
$sourceClass = _AttrMap_ClassLevelSource::class;

$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
$metadata = new ClassMetadata($targetClass);

$this->assertTrue($loader->loadClassMetadata($metadata));

// Check that class-level constraints are added to the target
$constraints = $metadata->getConstraints();
$this->assertCount(2, $constraints);

// Check for Callback constraint
$callbackConstraint = null;
foreach ($constraints as $constraint) {
if ($constraint instanceof Callback) {
$callbackConstraint = $constraint;
break;
}
}
$this->assertInstanceOf(Callback::class, $callbackConstraint);
$this->assertEquals('validateClass', $callbackConstraint->callback);

// Check for Expression constraint
$expressionConstraint = null;
foreach ($constraints as $constraint) {
if ($constraint instanceof Expression) {
$expressionConstraint = $constraint;
break;
}
}
$this->assertInstanceOf(Expression::class, $expressionConstraint);
$this->assertEquals('this.name != null', $expressionConstraint->expression);

// Check that property constraints are also added
$this->assertInstanceOf(NotBlank::class, $metadata->getPropertyMetadata('name', $sourceClass)[0]->getConstraints()[0]);
}
}

class _AttrMap_Target
{
public string $name;

public function getName()
{
return $this->name;
}

public function validateClass()
{
// This method will be called by the Callback constraint
return true;
}
}

#[ExtendsValidationFor(_AttrMap_Target::class)]
class _AttrMap_Source
{
#[NotBlank] public string $name;
}

#[ExtendsValidationFor(_AttrMap_Target::class)]
#[Callback('validateClass')]
#[Expression('this.name != null')]
class _AttrMap_ClassLevelSource
{
#[NotBlank]
public string $name = '';
}
Loading
Loading