Skip to content

[DoctrineBridge] In Profiler, Show all fields and values for validation constraints #57963

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

Open
wants to merge 6 commits into
base: 7.4
Choose a base branch
from
Open
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
@@ -0,0 +1,39 @@
<?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\Bridge\Doctrine\Tests\Fixtures;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[Entity]
class UniqueFieldFormValidationEntity
{
public function __construct(
#[Id, Column(type: 'integer')]
protected ?int $id = null,

#[Column(type: 'string', nullable: true)]
public ?string $name = null,

#[Column(type: 'string', nullable: true)]
public ?string $lastname = null,
) {
}

public function __toString(): string
{
return (string) $this->name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?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\Bridge\Doctrine\Tests\Form\Validation;

use Doctrine\ORM\EntityManager;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Bridge\Doctrine\Tests\DoctrineTestHelper;
use Symfony\Bridge\Doctrine\Tests\Fixtures\UniqueFieldFormValidationEntity;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\Extension\Validator\ViolationMapper\ViolationMapper;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Form\Tests\Extension\Core\Type\FormTypeTest;
use Symfony\Component\Form\Tests\Extension\Core\Type\TextTypeTest;
use Symfony\Component\Validator\ConstraintValidatorFactory;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\Validation;

class UniqueFieldEntityFormValidationTest extends TypeTestCase
{
private EntityManager $em;
private MockObject&ManagerRegistry $emRegistry;

protected function setUp(): void
{
$this->em = DoctrineTestHelper::createTestEntityManager();
$this->emRegistry = $this->createRegistryMock('default', $this->em);

parent::setUp();
}

protected function createRegistryMock($name, $em): MockObject&ManagerRegistry
{
$registry = $this->createMock(ManagerRegistry::class);
$registry->expects($this->any())
->method('getManager')
->with($this->equalTo($name))
->willReturn($em);

return $registry;
}

protected function getExtensions(): array
{
$factory = new ConstraintValidatorFactory([
'doctrine.orm.validator.unique' => new UniqueEntityValidator($this->emRegistry),
]);

$validator = Validation::createValidatorBuilder()
->setConstraintValidatorFactory($factory)
->enableAttributeMapping()
->getValidator();

return [
new ValidatorExtension($validator),
];
}

public function testFormValidationForEntityWithUniqueFieldNotValid()
{
$entity1 = new UniqueFieldFormValidationEntity(1, 'Foo');

$form = $this->factory
->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, ['data_class' => UniqueFieldFormValidationEntity::class])
->add('name', TextTypeTest::TESTED_TYPE)
->add('token', TextTypeTest::TESTED_TYPE)
->getForm();

$constraintViolation = new ConstraintViolation(
'This value should not be used.',
'This value should not be used.',
[
'{{ value }}' => 'myNameValue',
'{{ name value }}' => '"myNameValue"',
],
$form,
'data.name',
'myNameValue',
null,
'code',
new UniqueEntity(
['name']
),
$entity1
);

$violationMapper = new ViolationMapper();
$violationMapper->mapViolation($constraintViolation, $form, true);

$this->assertCount(0, $form->getErrors());
$this->assertCount(1, $form->get('name')->getErrors());
$this->assertSame('This value should not be used.', $form->get('name')->getErrors()[0]->getMessage());
}

public function testFormValidationForEntityWithUniqueGroupFieldsNotValid()
{
$entity1 = new UniqueFieldFormValidationEntity(1, 'Foo');

$form = $this->factory
->createNamedBuilder('parent', FormTypeTest::TESTED_TYPE, null, ['data_class' => UniqueFieldFormValidationEntity::class])
->add('name', TextTypeTest::TESTED_TYPE)
->add('token', TextTypeTest::TESTED_TYPE)
->getForm();

$constraintViolation = new ConstraintViolation(
'This value should not be used.',
'This value should not be used.',
[
'{{ value }}' => 'myTokenValue, myNameValue',
'{{ token value }}' => '"myTokenValue"',
'{{ name value }}' => '"myNameValue"',
],
$form,
'data.name, token',
'myTokenValue, myNameValue',
null,
'code',
new UniqueEntity(
['name', 'token']
),
$entity1
);

$violationMapper = new ViolationMapper();
$violationMapper->mapViolation($constraintViolation, $form, true);

$this->assertCount(1, $form->getErrors());
$this->assertCount(0, $form->get('name')->getErrors());
$this->assertSame('This value should not be used.', $form->getErrors()[0]->getMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,11 @@ public function testValidateUniquenessWithIgnoreNullDisableOnSecondField(UniqueE
$this->validator->validate($entity2, $constraint);

$this->buildViolation('myMessage')
->atPath('property.path.name')
->setParameter('{{ value }}', '"Foo"')
->setInvalidValue('Foo')
->atPath('property.path.name, name2')
->setParameter('{{ value }}', '"Foo, "')
->setParameter('{{ name value }}', '"Foo"')
->setParameter('{{ name2 value }}', 'null')
->setInvalidValue('Foo, ')
->setCause([$entity1])
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
->assertRaised();
Expand Down Expand Up @@ -407,6 +409,8 @@ public function testValidateUniquenessWithValidCustomErrorPath()
$this->buildViolation('myMessage')
->atPath('property.path.name2')
->setParameter('{{ value }}', '"Bar"')
->setParameter('{{ name value }}', '"Foo"')
->setParameter('{{ name2 value }}', '"Bar"')
->setInvalidValue('Bar')
->setCause([$entity1])
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
Expand Down Expand Up @@ -828,12 +832,15 @@ public function testValidateUniquenessWithCompositeObjectNoToStringIdEntity()

$this->validator->validate($newEntity, $constraint);

$expectedValue = 'object("Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdNoToStringEntity") identified by (id => 1)';
$objectOneExpectedValue = 'object("Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdNoToStringEntity") identified by (id => 1)';
$objectTwoExpectedValue = 'object("Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdNoToStringEntity") identified by (id => 2)';

$this->buildViolation('myMessage')
->atPath('property.path.objectOne')
->setParameter('{{ value }}', $expectedValue)
->setInvalidValue($objectOne)
->atPath('property.path.objectOne, objectTwo')
->setParameter('{{ value }}', '""object", "object""')
->setParameter('{{ objectOne value }}', $objectOneExpectedValue)
->setParameter('{{ objectTwo value }}', $objectTwoExpectedValue)
->setInvalidValue('"object", "object"')
->setCause([$entity])
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
->assertRaised();
Expand Down Expand Up @@ -1098,9 +1105,11 @@ public function testValidateMappingOfFieldNames()
$this->validator->validate($dto, $constraint);

$this->buildViolation('myMessage')
->atPath('property.path.name')
->setParameter('{{ value }}', '"Foo"')
->setInvalidValue('Foo')
->atPath('property.path.name, name2')
->setParameter('{{ value }}', '"Foo, Bar"')
->setParameter('{{ name value }}', '"Foo"')
->setParameter('{{ name2 value }}', '"Bar"')
->setInvalidValue('Foo, Bar')
->setCause([$entity])
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
->assertRaised();
Expand All @@ -1124,9 +1133,11 @@ public function testValidateMappingOfFieldNamesDoctrineStyle()
$this->validator->validate($dto, $constraint);

$this->buildViolation('myMessage')
->atPath('property.path.name')
->setParameter('{{ value }}', '"Foo"')
->setInvalidValue('Foo')
->atPath('property.path.name, name2')
->setParameter('{{ value }}', '"Foo, Bar"')
->setParameter('{{ name value }}', '"Foo"')
->setParameter('{{ name2 value }}', '"Bar"')
->setInvalidValue('Foo, Bar')
->setCause([$entity])
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
->assertRaised();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,16 +207,23 @@ public function validate(mixed $value, Constraint $constraint): void
}
}

$errorPath = $constraint->errorPath ?? current($fields);
$invalidValue = $criteria[$errorPath] ?? $criteria[current($fields)];
$errorPath = $constraint->errorPath ?? implode(', ', array_keys($criteria));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change will break the integration into the Form component as the error now can no longer be mapped back to the corresponding form type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if one field, error stay on field, but if there are many fields, error go on top form instead one arbitrary field

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it'd be great to have a test case that covers the issue that @xabbuh spotted here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this PR again I'd say if we were to change anything here the constraint could accept the errorPath option to be configured as an array which would lead to the violation being added to all these property paths. But doing the suggested change is a no go to me as it breaks the Form integration as explained.

Copy link
Contributor Author

@eltharin eltharin Aug 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add to all property can be OK but can create backward compatibility breaks,
but actually we have violation on only one field is not good, it's not A field whitch is bad but a group,
if you set firstname and lastname unique, form will respond to you "firstname is incorrect Christian is already used".
That's why put this to the parent form instead one field is preferable.
I think.
I'm trying to do tests and I'll look for an array (or an object stringable) for errorPath

$invalidValue = $criteria[$errorPath] ?? implode(', ', array_map(fn ($o) => \is_object($o) ? '"object"' : $o, $criteria));

$this->context->buildViolation($constraint->message)
$violation = $this->context->buildViolation($constraint->message)
->atPath($errorPath)
->setParameter('{{ value }}', $this->formatWithIdentifiers($em, $class, $invalidValue))
->setInvalidValue($invalidValue)
->setCode(UniqueEntity::NOT_UNIQUE_ERROR)
->setCause($result)
->addViolation();
->setCause($result);

if (\is_array($criteria) && \count($criteria) > 1) {
foreach ($criteria as $field => $value) {
$violation->setParameter('{{ '.$field.' value }}', $this->formatWithIdentifiers($em, $class, $value));
}
}

$violation->addViolation();
}

private function ignoreNullForField(UniqueEntity $constraint, string $fieldName): bool
Expand Down