From 5dd85e437152022cd56cc426388c536e16cf712f Mon Sep 17 00:00:00 2001 From: Loic Fremont Date: Thu, 30 Jul 2020 00:05:10 +0200 Subject: [PATCH] [Validator] Debug validator command --- .../FrameworkExtension.php | 2 + .../Resources/config/console.php | 7 + .../Bundle/FrameworkBundle/composer.json | 4 +- .../Validator/Command/DebugCommand.php | 200 ++++++++++++++++++ .../Tests/Command/DebugCommandTest.php | 189 +++++++++++++++++ .../Validator/Tests/Dummy/DummyClassOne.php | 7 + .../Validator/Tests/Dummy/DummyClassTwo.php | 7 + 7 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/Validator/Command/DebugCommand.php create mode 100644 src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Dummy/DummyClassOne.php create mode 100644 src/Symfony/Component/Validator/Tests/Dummy/DummyClassTwo.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index fa606794e8..722be3aa62 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1217,6 +1217,8 @@ class FrameworkExtension extends Extension private function registerValidationConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $propertyInfoEnabled) { if (!$this->validatorConfigEnabled = $this->isConfigEnabled($container, $config)) { + $container->removeDefinition('console.command.validator_debug'); + return; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 4219faaf55..e9b3d2e36a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -47,6 +47,7 @@ use Symfony\Component\Messenger\Command\FailedMessagesShowCommand; use Symfony\Component\Messenger\Command\SetupTransportsCommand; use Symfony\Component\Messenger\Command\StopWorkersCommand; use Symfony\Component\Translation\Command\XliffLintCommand; +use Symfony\Component\Validator\Command\DebugCommand as ValidatorDebugCommand; return static function (ContainerConfigurator $container) { $container->services() @@ -225,6 +226,12 @@ return static function (ContainerConfigurator $container) { ]) ->tag('console.command', ['command' => 'translation:update']) + ->set('console.command.validator_debug', ValidatorDebugCommand::class) + ->args([ + service('validator'), + ]) + ->tag('console.command', ['command' => 'debug:validator']) + ->set('console.command.workflow_dump', WorkflowDumpCommand::class) ->tag('console.command', ['command' => 'workflow:dump']) diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 0d84c344aa..fac512d466 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -29,7 +29,8 @@ "symfony/polyfill-php80": "^1.15", "symfony/filesystem": "^4.4|^5.0", "symfony/finder": "^4.4|^5.0", - "symfony/routing": "^5.1" + "symfony/routing": "^5.1", + "symfony/validator": "^5.2" }, "require-dev": { "doctrine/annotations": "~1.7", @@ -57,7 +58,6 @@ "symfony/string": "^5.0", "symfony/translation": "^5.0", "symfony/twig-bundle": "^4.4|^5.0", - "symfony/validator": "^4.4|^5.0", "symfony/workflow": "^5.2", "symfony/yaml": "^4.4|^5.0", "symfony/property-info": "^4.4|^5.0", diff --git a/src/Symfony/Component/Validator/Command/DebugCommand.php b/src/Symfony/Component/Validator/Command/DebugCommand.php new file mode 100644 index 0000000000..8eb1fa5e2e --- /dev/null +++ b/src/Symfony/Component/Validator/Command/DebugCommand.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Dumper; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Exception\DirectoryNotFoundException; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Mapping\ClassMetadataInterface; +use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; + +/** + * A console command to debug Validators information. + * + * @author Loïc Frémont + */ +class DebugCommand extends Command +{ + protected static $defaultName = 'debug:validator'; + + private $validator; + + public function __construct(MetadataFactoryInterface $validator) + { + parent::__construct(); + + $this->validator = $validator; + } + + protected function configure() + { + $this + ->addArgument('class', InputArgument::REQUIRED, 'A fully qualified class name or a path') + ->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all classes even if they have no validation constraints') + ->setDescription('Displays validation constraints for classes') + ->setHelp(<<<'EOF' +The %command.name% 'App\Entity\Dummy' command dumps the validators for the dummy class. + +The %command.name% src/ command dumps the validators for the `src` directory. +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $class = $input->getArgument('class'); + + if (class_exists($class)) { + $this->dumpValidatorsForClass($input, $output, $class); + + return 0; + } + + try { + foreach ($this->getResourcesByPath($class) as $class) { + $this->dumpValidatorsForClass($input, $output, $class); + } + } catch (DirectoryNotFoundException $exception) { + $io = new SymfonyStyle($input, $output); + $io->error(sprintf('Neither class nor path were found with "%s" argument.', $input->getArgument('class'))); + + return 1; + } + + return 0; + } + + private function dumpValidatorsForClass(InputInterface $input, OutputInterface $output, string $class): void + { + $io = new SymfonyStyle($input, $output); + $title = sprintf('%s', $class); + $rows = []; + $dump = new Dumper($output); + + foreach ($this->getConstrainedPropertiesData($class) as $propertyName => $constraintsData) { + foreach ($constraintsData as $data) { + $rows[] = [ + $propertyName, + $data['class'], + implode(', ', $data['groups']), + $dump($data['options']), + ]; + } + } + + if (!$rows) { + if (false === $input->getOption('show-all')) { + return; + } + + $io->section($title); + $io->text('No validators were found for this class.'); + + return; + } + + $io->section($title); + + $table = new Table($output); + $table->setHeaders(['Property', 'Name', 'Groups', 'Options']); + $table->setRows($rows); + $table->setColumnMaxWidth(3, 80); + $table->render(); + } + + private function getConstrainedPropertiesData(string $class): array + { + $data = []; + + /** @var ClassMetadataInterface $classMetadata */ + $classMetadata = $this->validator->getMetadataFor($class); + + foreach ($classMetadata->getConstrainedProperties() as $constrainedProperty) { + $data[$constrainedProperty] = $this->getPropertyData($classMetadata, $constrainedProperty); + } + + return $data; + } + + private function getPropertyData(ClassMetadataInterface $classMetadata, string $constrainedProperty): array + { + $data = []; + + $propertyMetadata = $classMetadata->getPropertyMetadata($constrainedProperty); + foreach ($propertyMetadata as $metadata) { + foreach ($metadata->getConstraints() as $constraint) { + $data[] = [ + 'class' => \get_class($constraint), + 'groups' => $constraint->groups, + 'options' => $this->getConstraintOptions($constraint), + ]; + } + } + + return $data; + } + + private function getConstraintOptions(Constraint $constraint): array + { + $options = []; + + foreach (array_keys(get_object_vars($constraint)) as $propertyName) { + // Groups are dumped on a specific column. + if ('groups' === $propertyName) { + continue; + } + + $options[$propertyName] = $constraint->$propertyName; + } + + return $options; + } + + private function getResourcesByPath(string $path): array + { + $finder = new Finder(); + $finder->files()->in($path)->name('*.php')->sortByName(true); + $classes = []; + + foreach ($finder as $file) { + $fileContent = file_get_contents($file->getRealPath()); + + preg_match('/namespace (.+);/', $fileContent, $matches); + + $namespace = $matches[1] ?? null; + + if (false === preg_match('/class +([^{ ]+)/', $fileContent, $matches)) { + // no class found + continue; + } + + $className = trim($matches[1]); + + if (null !== $namespace) { + $classes[] = $namespace.'\\'.$className; + } else { + $classes[] = $className; + } + } + + return $classes; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php b/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php new file mode 100644 index 0000000000..676b28d93f --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Command/DebugCommandTest.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Validator\Command\DebugCommand; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Mapping\ClassMetadataInterface; +use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; +use Symfony\Component\Validator\Mapping\PropertyMetadataInterface; +use Symfony\Component\Validator\Tests\Dummy\DummyClassOne; + +/** + * @author Loïc Frémont + */ +class DebugCommandTest extends TestCase +{ + public function testOutputWithClassArgument(): void + { + $validator = $this->createMock(MetadataFactoryInterface::class); + $classMetadata = $this->createMock(ClassMetadataInterface::class); + $propertyMetadata = $this->createMock(PropertyMetadataInterface::class); + + $validator + ->expects($this->once()) + ->method('getMetadataFor') + ->with(DummyClassOne::class) + ->willReturn($classMetadata); + + $classMetadata + ->expects($this->once()) + ->method('getConstrainedProperties') + ->willReturn([ + 'firstArgument', + ]); + + $classMetadata + ->expects($this->once()) + ->method('getPropertyMetadata') + ->with('firstArgument') + ->willReturn([ + $propertyMetadata, + ]); + + $propertyMetadata + ->expects($this->once()) + ->method('getConstraints') + ->willReturn([new NotBlank(), new Email()]); + + $command = new DebugCommand($validator); + + $tester = new CommandTester($command); + $tester->execute(['class' => DummyClassOne::class], ['decorated' => false]); + + $this->assertSame(<< "This value should not be blank.", | +| | | | "allowNull" => false, | +| | | | "normalizer" => null, | +| | | | "payload" => null | +| | | | ] | +| firstArgument | Symfony\Component\Validator\Constraints\Email | Default | [ | +| | | | "message" => "This value is not a valid email address.", | +| | | | "mode" => null, | +| | | | "normalizer" => null, | +| | | | "payload" => null | +| | | | ] | ++---------------+--------------------------------------------------+---------+------------------------------------------------------------+ + +TXT + , $tester->getDisplay(true) + ); + } + + public function testOutputWithPathArgument(): void + { + $validator = $this->createMock(MetadataFactoryInterface::class); + $classMetadata = $this->createMock(ClassMetadataInterface::class); + $propertyMetadata = $this->createMock(PropertyMetadataInterface::class); + + $validator + ->expects($this->exactly(2)) + ->method('getMetadataFor') + ->withAnyParameters() + ->willReturn($classMetadata); + + $classMetadata + ->method('getConstrainedProperties') + ->willReturn([ + 'firstArgument', + ]); + + $classMetadata + ->method('getPropertyMetadata') + ->with('firstArgument') + ->willReturn([ + $propertyMetadata, + ]); + + $propertyMetadata + ->method('getConstraints') + ->willReturn([new NotBlank(), new Email()]); + + $command = new DebugCommand($validator); + + $tester = new CommandTester($command); + $tester->execute(['class' => __DIR__.'/../Dummy'], ['decorated' => false]); + + $this->assertSame(<< "This value should not be blank.", | +| | | | "allowNull" => false, | +| | | | "normalizer" => null, | +| | | | "payload" => null | +| | | | ] | +| firstArgument | Symfony\Component\Validator\Constraints\Email | Default | [ | +| | | | "message" => "This value is not a valid email address.", | +| | | | "mode" => null, | +| | | | "normalizer" => null, | +| | | | "payload" => null | +| | | | ] | ++---------------+--------------------------------------------------+---------+------------------------------------------------------------+ + +Symfony\Component\Validator\Tests\Dummy\DummyClassTwo +----------------------------------------------------- + ++---------------+--------------------------------------------------+---------+------------------------------------------------------------+ +| Property | Name | Groups | Options | ++---------------+--------------------------------------------------+---------+------------------------------------------------------------+ +| firstArgument | Symfony\Component\Validator\Constraints\NotBlank | Default | [ | +| | | | "message" => "This value should not be blank.", | +| | | | "allowNull" => false, | +| | | | "normalizer" => null, | +| | | | "payload" => null | +| | | | ] | +| firstArgument | Symfony\Component\Validator\Constraints\Email | Default | [ | +| | | | "message" => "This value is not a valid email address.", | +| | | | "mode" => null, | +| | | | "normalizer" => null, | +| | | | "payload" => null | +| | | | ] | ++---------------+--------------------------------------------------+---------+------------------------------------------------------------+ + +TXT + , $tester->getDisplay(true) + ); + } + + public function testOutputWithInvalidClassArgument(): void + { + $validator = $this->createMock(MetadataFactoryInterface::class); + + $command = new DebugCommand($validator); + + $tester = new CommandTester($command); + $tester->execute(['class' => 'App\\NotFoundResource'], ['decorated' => false]); + + $this->assertStringContainsString(<<getDisplay(true) + ); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Dummy/DummyClassOne.php b/src/Symfony/Component/Validator/Tests/Dummy/DummyClassOne.php new file mode 100644 index 0000000000..3fe5b6621e --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Dummy/DummyClassOne.php @@ -0,0 +1,7 @@ +