[Console] Add validation constraints support to #[MapInput]

This commit is contained in:
Robin Chalas
2026-03-21 13:23:13 +01:00
committed by Nicolas Grekas
parent 0fed979575
commit 9ceb0fb59c
10 changed files with 340 additions and 1 deletions

View File

@@ -459,6 +459,7 @@ return static function (ContainerConfigurator $container) {
service('console.argument_resolver.builtin_type'),
service('console.argument_resolver.backed_enum'),
service('console.argument_resolver.datetime'),
service('validator')->nullOnInvalid(),
])
->tag('console.argument_value_resolver', ['priority' => 100, 'name' => MapInputValueResolver::class])

View File

@@ -0,0 +1,27 @@
<?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\Bundle\FrameworkBundle\Tests\Fixtures\Console;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Validator\Constraints as Assert;
class ValidatedInput
{
#[Argument]
#[Assert\NotBlank]
public string $name;
#[Option]
#[Assert\Email]
public ?string $email = null;
}

View File

@@ -0,0 +1,32 @@
<?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\Bundle\FrameworkBundle\Tests\Fixtures\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:validated-input', description: 'Tests validated MapInput')]
class ValidatedInputCommand
{
public function __invoke(
OutputInterface $output,
#[MapInput]
ValidatedInput $input,
): int {
$output->writeln('Name: '.$input->name);
$output->writeln('Email: '.$input->email);
return Command::SUCCESS;
}
}

View File

@@ -81,6 +81,44 @@ class ConsoleArgumentResolverTest extends AbstractWebTestCase
$this->assertStringContainsString('Name: test-advanced', $output);
}
public function testValidatedMapInputWithValidData()
{
$application = new Application(static::$kernel);
$application->setAutoExit(false);
$tester = new ApplicationTester($application);
$tester->run([
'command' => 'app:validated-input',
'name' => 'John',
'--email' => 'john@example.com',
]);
$tester->assertCommandIsSuccessful();
$output = $tester->getDisplay();
$this->assertStringContainsString('Name: John', $output);
$this->assertStringContainsString('Email: john@example.com', $output);
}
public function testValidatedMapInputWithInvalidData()
{
$application = new Application(static::$kernel);
$application->setAutoExit(false);
$tester = new ApplicationTester($application);
$tester->run([
'command' => 'app:validated-input',
'name' => '',
'--email' => 'not-an-email',
]);
$this->assertNotSame(0, $tester->getStatusCode());
$output = $tester->getDisplay();
$this->assertStringContainsString('name:', $output);
$this->assertStringContainsString('email:', $output);
}
public function testValueResolverAutoconfiguration()
{
$application = new Application(static::$kernel);

View File

@@ -35,3 +35,7 @@ services:
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Console\ResolverTestCommand:
autowire: true
autoconfigure: true
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Console\ValidatedInputCommand:
autowire: true
autoconfigure: true

View File

@@ -15,7 +15,9 @@ use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InputValidationFailedException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Resolves the value of a input argument/option to an object holding the #[MapInput] attribute.
@@ -29,6 +31,7 @@ final class MapInputValueResolver implements ValueResolverInterface
private readonly ValueResolverInterface $builtinTypeResolver,
private readonly ValueResolverInterface $backedEnumResolver,
private readonly ValueResolverInterface $dateTimeResolver,
private readonly ?ValidatorInterface $validator = null,
) {
}
@@ -38,7 +41,22 @@ final class MapInputValueResolver implements ValueResolverInterface
return [];
}
return [$this->resolveMapInput($attribute, $input)];
$instance = $this->resolveMapInput($attribute, $input);
$violations = $this->validator?->validate($instance, null, $attribute->validationGroups) ?? [];
if (!\count($violations)) {
return [$instance];
}
$map = $this->buildPropertyToInputMap($attribute);
$messages = [];
foreach ($violations as $violation) {
$path = $violation->getPropertyPath();
$label = $map[$path] ?? $path;
$messages[] = $label.': '.$violation->getMessage();
}
throw new InputValidationFailedException(implode("\n", $messages), $violations);
}
private function resolveMapInput(MapInput $mapInput, InputInterface $input): object
@@ -61,6 +79,27 @@ final class MapInputValueResolver implements ValueResolverInterface
return $instance;
}
/**
* @return array<string, string>
*/
private function buildPropertyToInputMap(MapInput $mapInput, string $prefix = ''): array
{
$map = [];
foreach ($mapInput->getDefinition() as $propertyName => $spec) {
$path = $prefix.$propertyName;
$map[$path] = match (true) {
$spec instanceof Argument => $spec->name,
$spec instanceof Option => '--'.$spec->name,
default => $path,
};
if ($spec instanceof MapInput) {
$map += $this->buildPropertyToInputMap($spec, $path.'.');
}
}
return $map;
}
private function resolveArgumentSpec(Argument $argument, \ReflectionProperty $property, InputInterface $input): mixed
{
if (is_subclass_of($argument->typeName, \BackedEnum::class)) {

View File

@@ -34,6 +34,14 @@ final class MapInput
*/
private array $interactiveAttributes = [];
/**
* @param string[]|null $validationGroups
*/
public function __construct(
public readonly ?array $validationGroups = null,
) {
}
/**
* @internal
*/

View File

@@ -14,6 +14,7 @@ CHANGELOG
* Add support for method-based commands with `#[AsCommand]` attribute
* Add argument resolver support
* Add `BackedEnum` and `DateTimeInterface` support to `#[MapInput]`
* Add validation constraints support to `#[MapInput]` along with optional `validationGroups` to control which groups are validated
* Add `TesterTrait::assertCommandFailed()` to test command
* Add `TesterTrait::assertCommandIsInvalid()` to test command
* Add a result-based testing API with `CommandTester::run()`, `ExecutionResult`, and `ConsoleAssertionsTrait` to assert output and error streams together

View File

@@ -0,0 +1,32 @@
<?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\Console\Exception;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class InputValidationFailedException extends RuntimeException
{
public function __construct(
string $message,
private readonly ConstraintViolationListInterface $violations,
) {
parent::__construct($message);
}
public function getViolations(): ConstraintViolationListInterface
{
return $this->violations;
}
}

View File

@@ -20,10 +20,12 @@ use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InputValidationFailedException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Validator\Validation;
class MapInputValueResolverTest extends TestCase
{
@@ -122,6 +124,139 @@ class MapInputValueResolverTest extends TestCase
$this->assertSame('2024-01-15', $result[0]->createdAt->format('Y-m-d'));
$this->assertSame(DummyStatus::Active, $result[0]->status);
}
public function testValidationPassesWithValidInput()
{
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
$input = new ArrayInput(['username' => 'john', '--email' => 'john@example.com'], new InputDefinition([
new InputArgument('username'),
new InputOption('email'),
]));
$command = new class {
public function __invoke(
#[MapInput]
DummyValidatedInput $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$member = new ReflectionMember($reflection->getParameters()[0]);
$result = $resolver->resolve('input', $input, $member);
$this->assertCount(1, $result);
$this->assertSame('john', $result[0]->username);
$this->assertSame('john@example.com', $result[0]->email);
}
public function testValidationFailsWithInvalidInput()
{
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
$input = new ArrayInput(['username' => '', '--email' => 'not-an-email'], new InputDefinition([
new InputArgument('username'),
new InputOption('email'),
]));
$command = new class {
public function __invoke(
#[MapInput]
DummyValidatedInput $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$member = new ReflectionMember($reflection->getParameters()[0]);
try {
$resolver->resolve('input', $input, $member);
$this->fail('Expected InputValidationFailedException was not thrown.');
} catch (InputValidationFailedException $e) {
$this->assertGreaterThan(0, \count($e->getViolations()));
$this->assertStringContainsString('username:', $e->getMessage());
$this->assertStringContainsString('--email:', $e->getMessage());
$this->assertStringNotContainsString('Object(', $e->getMessage());
}
}
public function testValidationSkippedWhenNoValidator()
{
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver());
$input = new ArrayInput(['username' => '', '--email' => 'not-an-email'], new InputDefinition([
new InputArgument('username'),
new InputOption('email'),
]));
$command = new class {
public function __invoke(
#[MapInput]
DummyValidatedInput $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$member = new ReflectionMember($reflection->getParameters()[0]);
$result = $resolver->resolve('input', $input, $member);
$this->assertCount(1, $result);
$this->assertSame('', $result[0]->username);
}
public function testValidationWithGroupsSkipsNonMatchingConstraints()
{
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
$input = new ArrayInput(['username' => '', '--email' => 'john@example.com'], new InputDefinition([
new InputArgument('username'),
new InputOption('email'),
]));
$command = new class {
public function __invoke(
#[MapInput(validationGroups: ['strict'])]
DummyGroupValidatedInput $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$member = new ReflectionMember($reflection->getParameters()[0]);
$result = $resolver->resolve('input', $input, $member);
$this->assertCount(1, $result);
$this->assertSame('', $result[0]->username);
}
public function testValidationWithGroupsEnforcesMatchingConstraints()
{
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver(), $validator);
$input = new ArrayInput(['username' => '', '--email' => 'not-an-email'], new InputDefinition([
new InputArgument('username'),
new InputOption('email'),
]));
$command = new class {
public function __invoke(
#[MapInput(validationGroups: ['strict'])]
DummyGroupValidatedInput $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$member = new ReflectionMember($reflection->getParameters()[0]);
$this->expectException(InputValidationFailedException::class);
$resolver->resolve('input', $input, $member);
}
}
class DummyInput
@@ -148,3 +283,25 @@ enum DummyStatus: string
case Active = 'active';
case Inactive = 'inactive';
}
class DummyValidatedInput
{
#[Argument]
#[\Symfony\Component\Validator\Constraints\NotBlank]
public string $username;
#[Option]
#[\Symfony\Component\Validator\Constraints\Email]
public ?string $email = null;
}
class DummyGroupValidatedInput
{
#[Argument]
#[\Symfony\Component\Validator\Constraints\NotBlank]
public string $username;
#[Option]
#[\Symfony\Component\Validator\Constraints\Email(groups: ['strict'])]
public ?string $email = null;
}