Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Form] Fixed handling groups sequence validation #36343

Merged
merged 1 commit into from Apr 18, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -24,6 +24,8 @@
*/
class FormValidator extends ConstraintValidator
{
private $resolvedGroups;
nicolas-grekas marked this conversation as resolved.
Show resolved Hide resolved

/**
* {@inheritdoc}
*/
Expand All @@ -44,42 +46,68 @@ public function validate($form, Constraint $formConstraint)

if ($form->isSubmitted() && $form->isSynchronized()) {
// Validate the form data only if transformation succeeded
$groups = self::getValidationGroups($form);
$groups = $this->getValidationGroups($form);

if (!$groups) {
return;
}

$data = $form->getData();

// Validate the data against its own constraints
if ($form->isRoot() && (\is_object($data) || \is_array($data))) {
if (($groups && \is_array($groups)) || ($groups instanceof GroupSequence && $groups->groups)) {
$validator->atPath('data')->validate($form->getData(), null, $groups);
}
}
$validateDataGraph = $form->isRoot()
&& (\is_object($data) || \is_array($data))
&& (($groups && \is_array($groups)) || ($groups instanceof GroupSequence && $groups->groups))
;

// Validate the data against the constraints defined
// in the form
// Validate the data against the constraints defined in the form
/** @var Constraint[] $constraints */
$constraints = $config->getOption('constraints', []);

if ($groups instanceof GroupSequence) {
$validator->atPath('data')->validate($form->getData(), $constraints, $groups);
// Otherwise validate a constraint only once for the first
// matching group
foreach ($groups as $group) {
if (\in_array($group, $formConstraint->groups)) {
$validator->atPath('data')->validate($form->getData(), $formConstraint, $group);
if (\count($this->context->getViolations()) > 0) {
break;
// Validate the data, the form AND nested fields in sequence
$violationsCount = $this->context->getViolations()->count();
$fieldPropertyPath = \is_object($data) ? 'children[%s]' : 'children%s';
$hasChildren = $form->count() > 0;
$this->resolvedGroups = $hasChildren ? new \SplObjectStorage() : null;

foreach ($groups->groups as $group) {
if ($validateDataGraph) {
$validator->atPath('data')->validate($data, null, $group);
}

if ($groupedConstraints = self::getConstraintsInGroups($constraints, $group)) {
$validator->atPath('data')->validate($data, $groupedConstraints, $group);
}

foreach ($form->all() as $field) {
if ($field->isSubmitted()) {
// remember to validate this field is one group only
// otherwise resolving the groups would reuse the same
// sequence recursively, thus some fields could fail
// in different steps without breaking early enough
HeahDude marked this conversation as resolved.
Show resolved Hide resolved
$this->resolvedGroups[$field] = (array) $group;
$validator->atPath(sprintf($fieldPropertyPath, $field->getPropertyPath()))->validate($field, $formConstraint);
}
}

if ($violationsCount < $this->context->getViolations()->count()) {
break;
}
}

if ($hasChildren) {
// destroy storage at the end of the sequence to avoid memory leaks
$this->resolvedGroups = null;
}
} else {
if ($validateDataGraph) {
$validator->atPath('data')->validate($data, null, $groups);
}

foreach ($constraints as $constraint) {
// For the "Valid" constraint, validate the data in all groups
if ($constraint instanceof Valid) {
$validator->atPath('data')->validate($form->getData(), $constraint, $groups);
$validator->atPath('data')->validate($data, $constraint, $groups);

continue;
}
Expand All @@ -88,7 +116,7 @@ public function validate($form, Constraint $formConstraint)
// matching group
foreach ($groups as $group) {
if (\in_array($group, $constraint->groups)) {
$validator->atPath('data')->validate($form->getData(), $constraint, $group);
$validator->atPath('data')->validate($data, $constraint, $group);

// Prevent duplicate validation
if (!$constraint instanceof Composite) {
Expand Down Expand Up @@ -147,7 +175,7 @@ public function validate($form, Constraint $formConstraint)
*
* @return string|GroupSequence|(string|GroupSequence)[] The validation groups
*/
private static function getValidationGroups(FormInterface $form)
private function getValidationGroups(FormInterface $form)
{
// Determine the clicked button of the complete form tree
$clickedButton = null;
Expand All @@ -171,6 +199,10 @@ private static function getValidationGroups(FormInterface $form)
return self::resolveValidationGroups($groups, $form);
}

if (isset($this->resolvedGroups[$form])) {
return $this->resolvedGroups[$form];
}

$form = $form->getParent();
} while (null !== $form);

Expand All @@ -197,4 +229,11 @@ private static function resolveValidationGroups($groups, FormInterface $form)

return (array) $groups;
}

private static function getConstraintsInGroups($constraints, $group)
{
return array_filter($constraints, static function (Constraint $constraint) use ($group) {
return \in_array($group, $constraint->groups, true);
});
}
}
Expand Up @@ -7,7 +7,7 @@
<class name="Symfony\Component\Form\Form">
<constraint name="Symfony\Component\Form\Extension\Validator\Constraints\Form" />
<property name="children">
<constraint name="Valid" />
<constraint name="Valid" />
</property>
</class>
</constraint-mapping>
Expand Up @@ -401,8 +401,8 @@ public function testHandleGroupSequenceValidationGroups()
$form = $this->getCompoundForm($object, $options);
$form->submit([]);

$this->expectValidateAt(0, 'data', $object, new GroupSequence(['group1', 'group2']));
$this->expectValidateAt(1, 'data', $object, new GroupSequence(['group1', 'group2']));
$this->expectValidateAt(0, 'data', $object, 'group1');
$this->expectValidateAt(1, 'data', $object, 'group2');

$this->validator->validate($form, new Form());

Expand Down Expand Up @@ -756,6 +756,39 @@ public function testCompositeConstraintValidatedInEachGroup()
$this->assertSame('data[field2]', $context->getViolations()[1]->getPropertyPath());
}

public function testCompositeConstraintValidatedInSequence()
{
$form = $this->getCompoundForm([], [
'constraints' => [
new Collection([
'field1' => new NotBlank([
'groups' => ['field1'],
]),
'field2' => new NotBlank([
'groups' => ['field2'],
]),
]),
],
'validation_groups' => new GroupSequence(['field1', 'field2']),
])
->add($this->getForm('field1'))
->add($this->getForm('field2'))
;

$form->submit([
'field1' => '',
'field2' => '',
]);

$context = new ExecutionContext(Validation::createValidator(), $form, new IdentityTranslator());
$this->validator->initialize($context);
$this->validator->validate($form, new Form());

$this->assertCount(1, $context->getViolations());
$this->assertSame('This value should not be blank.', $context->getViolations()[0]->getMessage());
$this->assertSame('data[field1]', $context->getViolations()[0]->getPropertyPath());
}

protected function createValidator()
{
return new FormValidator();
Expand Down Expand Up @@ -784,7 +817,7 @@ private function getForm($name = 'name', $dataClass = null, array $options = [])

private function getCompoundForm($data, array $options = [])
{
return $this->getBuilder('name', \get_class($data), $options)
return $this->getBuilder('name', \is_object($data) ? \get_class($data) : null, $options)
->setData($data)
->setCompound(true)
->setDataMapper(new PropertyPathMapper())
Expand Down
Expand Up @@ -12,15 +12,19 @@
namespace Symfony\Component\Form\Tests\Extension\Validator\Type;

use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\Forms;
use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait;
use Symfony\Component\Form\Tests\Extension\Core\Type\FormTypeTest;
use Symfony\Component\Form\Tests\Extension\Core\Type\TextTypeTest;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Form\Tests\Fixtures\Author;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Valid;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface;
use Symfony\Component\Validator\Validation;

class FormTypeValidatorExtensionTest extends BaseValidatorExtensionTest
Expand Down Expand Up @@ -64,14 +68,69 @@ public function testGroupSequenceWithConstraintsOption()
->add('field', TextTypeTest::TESTED_TYPE, [
'constraints' => [
new Length(['min' => 10, 'groups' => ['First']]),
new Email(['groups' => ['Second']]),
new NotBlank(['groups' => ['Second']]),
],
])
;

$form->submit(['field' => 'wrong']);

$this->assertCount(1, $form->getErrors(true));
$errors = $form->getErrors(true);

$this->assertCount(1, $errors);
$this->assertInstanceOf(Length::class, $errors[0]->getCause()->getConstraint());
}

public function testManyFieldsGroupSequenceWithConstraintsOption()
{
$formMetadata = new ClassMetadata(Form::class);
$authorMetadata = (new ClassMetadata(Author::class))
->addPropertyConstraint('firstName', new NotBlank(['groups' => 'Second']))
;
$metadataFactory = $this->createMock(MetadataFactoryInterface::class);
$metadataFactory->expects($this->any())
->method('getMetadataFor')
->willReturnCallback(static function ($classOrObject) use ($formMetadata, $authorMetadata) {
if (Author::class === $classOrObject || $classOrObject instanceof Author) {
return $authorMetadata;
}

if (Form::class === $classOrObject || $classOrObject instanceof Form) {
return $formMetadata;
}

return new ClassMetadata(\is_string($classOrObject) ? $classOrObject : \get_class($classOrObject));
})
;

$validator = Validation::createValidatorBuilder()
->setMetadataFactory($metadataFactory)
->getValidator()
;
$form = Forms::createFormFactoryBuilder()
->addExtension(new ValidatorExtension($validator))
->getFormFactory()
->create(FormTypeTest::TESTED_TYPE, new Author(), (['validation_groups' => new GroupSequence(['First', 'Second'])]))
->add('firstName', TextTypeTest::TESTED_TYPE)
->add('lastName', TextTypeTest::TESTED_TYPE, [
'constraints' => [
new Length(['min' => 10, 'groups' => ['First']]),
],
])
->add('australian', TextTypeTest::TESTED_TYPE, [
'constraints' => [
new NotBlank(['groups' => ['Second']]),
],
])
;

$form->submit(['firstName' => '', 'lastName' => 'wrong_1', 'australian' => '']);

$errors = $form->getErrors(true);

$this->assertCount(1, $errors);
$this->assertInstanceOf(Length::class, $errors[0]->getCause()->getConstraint());
$this->assertSame('children[lastName].data', $errors[0]->getCause()->getPropertyPath());
}

protected function createForm(array $options = [])
Expand Down
Expand Up @@ -13,13 +13,17 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Validator\Constraints\Form as FormConstraint;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\Extension\Validator\ValidatorTypeGuesser;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormFactoryBuilder;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Mapping\CascadingStrategy;
use Symfony\Component\Validator\Mapping\ClassMetadata;
Expand Down Expand Up @@ -49,6 +53,8 @@ public function test2Dot5ValidationApi()
$this->assertCount(1, $metadata->getConstraints());
$this->assertInstanceOf(FormConstraint::class, $metadata->getConstraints()[0]);

$this->assertSame(CascadingStrategy::NONE, $metadata->cascadingStrategy);
$this->assertSame(TraversalStrategy::IMPLICIT, $metadata->traversalStrategy);
$this->assertSame(CascadingStrategy::CASCADE, $metadata->getPropertyMetadata('children')[0]->cascadingStrategy);
$this->assertSame(TraversalStrategy::IMPLICIT, $metadata->getPropertyMetadata('children')[0]->traversalStrategy);
}
Expand Down Expand Up @@ -86,7 +92,53 @@ public function testFieldConstraintsInvalidateFormIfFieldIsSubmitted()
$this->assertFalse($form->get('baz')->isValid());
}

private function createForm($type)
public function testFieldsValidateInSequence()
{
$form = $this->createForm(FormType::class, null, [
'validation_groups' => new GroupSequence(['group1', 'group2']),
])
->add('foo', TextType::class, [
'constraints' => [new Length(['min' => 10, 'groups' => ['group1']])],
])
->add('bar', TextType::class, [
'constraints' => [new NotBlank(['groups' => ['group2']])],
])
;

$form->submit(['foo' => 'invalid', 'bar' => null]);

$errors = $form->getErrors(true);

$this->assertCount(1, $errors);
$this->assertInstanceOf(Length::class, $errors[0]->getCause()->getConstraint());
}

public function testFieldsValidateInSequenceWithNestedGroupsArray()
{
$form = $this->createForm(FormType::class, null, [
'validation_groups' => new GroupSequence([['group1', 'group2'], 'group3']),
])
->add('foo', TextType::class, [
'constraints' => [new Length(['min' => 10, 'groups' => ['group1']])],
])
->add('bar', TextType::class, [
'constraints' => [new Length(['min' => 10, 'groups' => ['group2']])],
])
->add('baz', TextType::class, [
'constraints' => [new NotBlank(['groups' => ['group3']])],
])
;

$form->submit(['foo' => 'invalid', 'bar' => 'invalid', 'baz' => null]);

$errors = $form->getErrors(true);

$this->assertCount(2, $errors);
$this->assertInstanceOf(Length::class, $errors[0]->getCause()->getConstraint());
$this->assertInstanceOf(Length::class, $errors[1]->getCause()->getConstraint());
}

private function createForm($type, $data = null, array $options = [])
{
$validator = Validation::createValidatorBuilder()
->setMetadataFactory(new LazyLoadingMetadataFactory(new StaticMethodLoader()))
Expand All @@ -95,7 +147,7 @@ private function createForm($type)
$formFactoryBuilder->addExtension(new ValidatorExtension($validator));
$formFactory = $formFactoryBuilder->getFormFactory();

return $formFactory->create($type);
return $formFactory->create($type, $data, $options);
}
}

Expand Down