From ac5e884f36636620a176e97b2053e42a8d6e16cc Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Thu, 27 Apr 2017 17:08:18 +0200 Subject: [PATCH] [Profiler][Validator] Add a validator panel in profiler --- .../FrameworkExtension.php | 7 +- .../Resources/config/validator_debug.xml | 20 +++ .../Bundle/FrameworkBundle/composer.json | 4 +- .../views/Collector/validator.html.twig | 98 +++++++++++++ .../Resources/views/Icon/validator.svg | 1 + .../views/Profiler/profiler.css.twig | 25 ++++ .../DataCollector/ValidatorDataCollector.php | 105 ++++++++++++++ .../ValidatorDataCollectorTest.php | 57 ++++++++ .../Validator/TraceableValidatorTest.php | 104 ++++++++++++++ .../Validator/TraceableValidator.php | 131 ++++++++++++++++++ src/Symfony/Component/Validator/composer.json | 2 + 11 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.xml create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/validator.html.twig create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/validator.svg create mode 100644 src/Symfony/Component/Validator/DataCollector/ValidatorDataCollector.php create mode 100644 src/Symfony/Component/Validator/Tests/DataCollector/ValidatorDataCollectorTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Validator/TraceableValidatorTest.php create mode 100644 src/Symfony/Component/Validator/Validator/TraceableValidator.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 8537497d6a4b..176925821547 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -81,6 +81,7 @@ class FrameworkExtension extends Extension private $translationConfigEnabled = false; private $sessionConfigEnabled = false; private $annotationsConfigEnabled = false; + private $validatorConfigEnabled = false; /** * @var string|null @@ -456,6 +457,10 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ $loader->load('form_debug.xml'); } + if ($this->validatorConfigEnabled) { + $loader->load('validator_debug.xml'); + } + if ($this->translationConfigEnabled) { $loader->load('translation_debug.xml'); $container->getDefinition('translator.data_collector')->setDecoratedService('translator'); @@ -1107,7 +1112,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder */ private function registerValidationConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) { - if (!$this->isConfigEnabled($container, $config)) { + if (!$this->validatorConfigEnabled = $this->isConfigEnabled($container, $config)) { return; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.xml new file mode 100644 index 000000000000..ac4724580a53 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index d46d5e128bea..9699ae85d6d6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -49,7 +49,7 @@ "symfony/stopwatch": "~2.8|~3.0|~4.0", "symfony/translation": "~3.2|~4.0", "symfony/templating": "~2.8|~3.0|~4.0", - "symfony/validator": "~3.3|~4.0", + "symfony/validator": "~3.4|~4.0", "symfony/var-dumper": "~3.3|~4.0", "symfony/workflow": "~3.3|~4.0", "symfony/yaml": "~3.2|~4.0", @@ -69,7 +69,7 @@ "symfony/property-info": "<3.3", "symfony/serializer": "<3.3", "symfony/translation": "<3.2", - "symfony/validator": "<3.3", + "symfony/validator": "<3.4", "symfony/workflow": "<3.3" }, "suggest": { diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/validator.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/validator.html.twig new file mode 100644 index 000000000000..6b8ba44dac94 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/validator.html.twig @@ -0,0 +1,98 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block toolbar %} + {% if collector.violationsCount > 0 or collector.calls|length %} + {% set status_color = collector.violationsCount ? 'red' : '' %} + {% set icon %} + {{ include('@WebProfiler/Icon/validator.svg') }} + + {{ collector.violationsCount }} + + {% endset %} + + {% set text %} +
+ Validator calls + {{ collector.calls|length }} +
+
+ Number of violations + {{ collector.violationsCount }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }} + {% endif %} +{% endblock %} + +{% block menu %} + + {{ include('@WebProfiler/Icon/validator.svg') }} + Validator + {% if collector.violationsCount > 0 %} + + {{ collector.violationsCount }} + + {% endif %} + +{% endblock %} + +{% block panel %} +

Validator calls

+ + {% for call in collector.calls %} +
+ + + + + + + {% if call.violations|length %} + + + + + + + + + + {% for violation in call.violations %} + + + + + + + {% endfor %} +
PathMessageInvalid valueViolation
{{ violation.propertyPath }}{{ violation.message }}{{ profiler_dump(violation.seek('invalidValue')) }}{{ profiler_dump(violation) }}
+ {% else %} + No violations + {% endif %} +
+ {% else %} +
+

No calls to the validator were collected during this request.

+
+ {% endfor %} +{% endblock %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/validator.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/validator.svg new file mode 100644 index 000000000000..b501fe548680 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/validator.svg @@ -0,0 +1 @@ + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig index 5b6647df8866..94ac2abd2548 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig @@ -880,6 +880,31 @@ table.logs .metadata { white-space: pre-wrap; } +{# Validator panel + ========================================================================= #} + +#collector-content .sf-validator { + margin-bottom: 2em; +} + +#collector-content .sf-validator .sf-validator-context, +#collector-content .sf-validator .trace { + border: 1px solid #DDD; + background: #FFF; + padding: 10px; + margin: 0.5em 0; +} +#collector-content .sf-validator .trace { + font-size: 12px; +} +#collector-content .sf-validator .trace li { + margin-bottom: 0; + padding: 0; +} +#collector-content .sf-validator .trace li.selected { + background: rgba(255, 255, 153, 0.5); +} + {# Dump panel ========================================================================= #} #collector-content .sf-dump { diff --git a/src/Symfony/Component/Validator/DataCollector/ValidatorDataCollector.php b/src/Symfony/Component/Validator/DataCollector/ValidatorDataCollector.php new file mode 100644 index 000000000000..02c672191e96 --- /dev/null +++ b/src/Symfony/Component/Validator/DataCollector/ValidatorDataCollector.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\DataCollector; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\Validator\Validator\TraceableValidator; +use Symfony\Component\VarDumper\Caster\Caster; +use Symfony\Component\VarDumper\Caster\ClassStub; +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Cloner\VarCloner; + +/** + * @author Maxime Steinhausser + */ +class ValidatorDataCollector extends DataCollector implements LateDataCollectorInterface +{ + private $validator; + private $cloner; + + public function __construct(TraceableValidator $validator) + { + $this->validator = $validator; + $this->data = array( + 'calls' => array(), + 'violations_count' => 0, + ); + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, \Exception $exception = null) + { + // Everything is collected once, on kernel terminate. + } + + /** + * {@inheritdoc} + */ + public function lateCollect() + { + $collected = $this->validator->getCollectedData(); + $this->data['calls'] = $this->cloneVar($collected); + $this->data['violations_count'] += array_reduce($collected, function ($previous, $item) { + return $previous += count($item['violations']); + }, 0); + } + + public function getCalls() + { + return $this->data['calls']; + } + + public function getViolationsCount() + { + return $this->data['violations_count']; + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'validator'; + } + + /** + * {@inheritdoc} + */ + protected function cloneVar($var) + { + if ($var instanceof Data) { + return $var; + } + + if (null === $this->cloner) { + $this->cloner = new VarCloner(); + $this->cloner->setMaxItems(-1); + $this->cloner->addCasters(array( + FormInterface::class => function (FormInterface $f, array $a) { + return array( + Caster::PREFIX_VIRTUAL.'name' => $f->getName(), + Caster::PREFIX_VIRTUAL.'type_class' => new ClassStub(get_class($f->getConfig()->getType()->getInnerType())), + Caster::PREFIX_VIRTUAL.'data' => $f->getData(), + ); + }, + )); + } + + return $this->cloner->cloneVar($var, Caster::EXCLUDE_VERBOSE); + } +} diff --git a/src/Symfony/Component/Validator/Tests/DataCollector/ValidatorDataCollectorTest.php b/src/Symfony/Component/Validator/Tests/DataCollector/ValidatorDataCollectorTest.php new file mode 100644 index 000000000000..811a55829a47 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/DataCollector/ValidatorDataCollectorTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\DataCollector\ValidatorDataCollector; +use Symfony\Component\Validator\Validator\TraceableValidator; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +class ValidatorDataCollectorTest extends TestCase +{ + public function testCollectsValidatorCalls() + { + $originalValidator = $this->createMock(ValidatorInterface::class); + $validator = new TraceableValidator($originalValidator); + + $collector = new ValidatorDataCollector($validator); + + $violations = new ConstraintViolationList(array( + $this->createMock(ConstraintViolation::class), + $this->createMock(ConstraintViolation::class), + )); + $originalValidator->method('validate')->willReturn($violations); + + $validator->validate(new \stdClass()); + + $collector->lateCollect(); + + $calls = $collector->getCalls(); + + $this->assertCount(1, $calls); + $this->assertSame(2, $collector->getViolationsCount()); + + $call = $calls[0]; + + $this->assertArrayHasKey('caller', $call); + $this->assertArrayHasKey('context', $call); + $this->assertArrayHasKey('violations', $call); + $this->assertCount(2, $call['violations']); + } + + protected function createMock($classname) + { + return $this->getMockBuilder($classname)->disableOriginalConstructor()->getMock(); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Validator/TraceableValidatorTest.php b/src/Symfony/Component/Validator/Tests/Validator/TraceableValidatorTest.php new file mode 100644 index 000000000000..b2eef769ecf3 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Validator/TraceableValidatorTest.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Validator; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Symfony\Component\Validator\Mapping\MetadataInterface; +use Symfony\Component\Validator\Validator\ContextualValidatorInterface; +use Symfony\Component\Validator\Validator\TraceableValidator; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +class TraceableValidatorTest extends TestCase +{ + public function testValidate() + { + $originalValidator = $this->createMock(ValidatorInterface::class); + $violations = new ConstraintViolationList(array( + $this->createMock(ConstraintViolation::class), + $this->createMock(ConstraintViolation::class), + )); + $originalValidator->expects($this->exactly(2))->method('validate')->willReturn($violations); + + $validator = new TraceableValidator($originalValidator); + + $object = new \stdClass(); + $constraints = array($this->createMock(Constraint::class)); + $groups = array('Default', 'Create'); + + $validator->validate($object, $constraints, $groups); + $line = __LINE__ - 1; + + $collectedData = $validator->getCollectedData(); + + $this->assertCount(1, $collectedData); + + $callData = $collectedData[0]; + + $this->assertSame(iterator_to_array($violations), $callData['violations']); + + $this->assertSame(array( + 'value' => $object, + 'constraints' => $constraints, + 'groups' => $groups, + ), $callData['context']); + + $this->assertEquals(array( + 'name' => 'TraceableValidatorTest.php', + 'file' => __FILE__, + 'line' => $line, + ), $callData['caller']); + + $validator->validate($object, $constraints, $groups); + $collectedData = $validator->getCollectedData(); + + $this->assertCount(2, $collectedData); + } + + public function testForwardsToOriginalValidator() + { + $originalValidator = $this->createMock(ValidatorInterface::class); + $validator = new TraceableValidator($originalValidator); + + $expects = function ($method) use ($originalValidator) { return $originalValidator->expects($this->once())->method($method); }; + + $expects('getMetadataFor')->willReturn($expected = $this->createMock(MetadataInterface::class)); + $this->assertSame($expected, $validator->getMetadataFor('value'), 'returns original validator getMetadataFor() result'); + + $expects('hasMetadataFor')->willReturn($expected = false); + $this->assertSame($expected, $validator->hasMetadataFor('value'), 'returns original validator hasMetadataFor() result'); + + $expects('inContext')->willReturn($expected = $this->createMock(ContextualValidatorInterface::class)); + $this->assertSame($expected, $validator->inContext($this->createMock(ExecutionContextInterface::class)), 'returns original validator inContext() result'); + + $expects('startContext')->willReturn($expected = $this->createMock(ContextualValidatorInterface::class)); + $this->assertSame($expected, $validator->startContext(), 'returns original validator startContext() result'); + + $expects('validate')->willReturn($expected = $this->createMock(ConstraintViolationListInterface::class)); + $this->assertSame($expected, $validator->validate('value'), 'returns original validator validate() result'); + + $expects('validateProperty')->willReturn($expected = $this->createMock(ConstraintViolationListInterface::class)); + $this->assertSame($expected, $validator->validateProperty(new \stdClass(), 'property'), 'returns original validator validateProperty() result'); + + $expects('validatePropertyValue')->willReturn($expected = $this->createMock(ConstraintViolationListInterface::class)); + $this->assertSame($expected, $validator->validatePropertyValue(new \stdClass(), 'property', 'value'), 'returns original validator validatePropertyValue() result'); + } + + protected function createMock($classname) + { + return $this->getMockBuilder($classname)->disableOriginalConstructor()->getMock(); + } +} diff --git a/src/Symfony/Component/Validator/Validator/TraceableValidator.php b/src/Symfony/Component/Validator/Validator/TraceableValidator.php new file mode 100644 index 000000000000..019559ae0023 --- /dev/null +++ b/src/Symfony/Component/Validator/Validator/TraceableValidator.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Validator; + +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Context\ExecutionContextInterface; + +/** + * Collects some data about validator calls. + * + * @author Maxime Steinhausser + */ +class TraceableValidator implements ValidatorInterface +{ + private $validator; + private $collectedData = array(); + + public function __construct(ValidatorInterface $validator) + { + $this->validator = $validator; + } + + /** + * @return ConstraintViolationList[] + */ + public function getCollectedData() + { + return $this->collectedData; + } + + /** + * {@inheritdoc} + */ + public function getMetadataFor($value) + { + return $this->validator->getMetadataFor($value); + } + + /** + * {@inheritdoc} + */ + public function hasMetadataFor($value) + { + return $this->validator->hasMetadataFor($value); + } + + /** + * {@inheritdoc} + */ + public function validate($value, $constraints = null, $groups = null) + { + $violations = $this->validator->validate($value, $constraints, $groups); + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7); + + $file = $trace[0]['file']; + $line = $trace[0]['line']; + + for ($i = 1; $i < 7; ++$i) { + if (isset($trace[$i]['class'], $trace[$i]['function']) + && 'validate' === $trace[$i]['function'] + && is_a($trace[$i]['class'], ValidatorInterface::class, true) + ) { + $file = $trace[$i]['file']; + $line = $trace[$i]['line']; + + while (++$i < 7) { + if (isset($trace[$i]['function'], $trace[$i]['file']) && empty($trace[$i]['class']) && 0 !== strpos($trace[$i]['function'], 'call_user_func')) { + $file = $trace[$i]['file']; + $line = $trace[$i]['line']; + + break; + } + } + break; + } + } + + $name = str_replace('\\', '/', $file); + $name = substr($name, strrpos($name, '/') + 1); + + $this->collectedData[] = array( + 'caller' => compact('name', 'file', 'line'), + 'context' => compact('value', 'constraints', 'groups'), + 'violations' => iterator_to_array($violations), + ); + + return $violations; + } + + /** + * {@inheritdoc} + */ + public function validateProperty($object, $propertyName, $groups = null) + { + return $this->validator->validateProperty($object, $propertyName, $groups); + } + + /** + * {@inheritdoc} + */ + public function validatePropertyValue($objectOrClass, $propertyName, $value, $groups = null) + { + return $this->validator->validatePropertyValue($objectOrClass, $propertyName, $value, $groups); + } + + /** + * {@inheritdoc} + */ + public function startContext() + { + return $this->validator->startContext(); + } + + /** + * {@inheritdoc} + */ + public function inContext(ExecutionContextInterface $context) + { + return $this->validator->inContext($context); + } +} diff --git a/src/Symfony/Component/Validator/composer.json b/src/Symfony/Component/Validator/composer.json index 21c0a8a7cd02..2b0a0cab2971 100644 --- a/src/Symfony/Component/Validator/composer.json +++ b/src/Symfony/Component/Validator/composer.json @@ -22,6 +22,8 @@ }, "require-dev": { "symfony/http-foundation": "~2.8|~3.0|~4.0", + "symfony/http-kernel": "~2.8|~3.0|~4.0.0", + "symfony/var-dumper": "~3.3|~4.0.0", "symfony/intl": "^2.8.18|^3.2.5|~4.0", "symfony/yaml": "~3.3|~4.0", "symfony/config": "~2.8|~3.0|~4.0",