Skip to content

[Validator] Add ConstraintViolationBuilder methods: fromViolation(), setPath(), getViolation() #60582

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

Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
[Validator] Add ConstraintViolationBuilder methods: fromViolation(), …
…setPath(), getViolation()
  • Loading branch information
rela589n committed May 31, 2025
commit 29055ef2851a5f3fa1cc6306364c7404f2dd1f8a
5 changes: 5 additions & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.4
---

* Add `ConstraintViolationBuilder` methods: `fromViolation()`, `setPath()`, `getViolation()`

7.3
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use Symfony\Component\Translation\IdentityTranslator;
use Symfony\Component\Validator\Constraints\Valid;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Util\PropertyPath;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilder;
use Symfony\Contracts\Translation\TranslatorInterface;

Expand All @@ -42,7 +44,7 @@ public function testAddViolation()
{
$this->builder->addViolation();

$this->assertViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data', 'foo', null, null, new Valid()));
$this->assertBuiltViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data', 'foo', null, null, new Valid()));
}

public function testAppendPropertyPath()
Expand All @@ -51,7 +53,7 @@ public function testAppendPropertyPath()
->atPath('foo')
->addViolation();

$this->assertViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data.foo', 'foo', null, null, new Valid()));
$this->assertBuiltViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data.foo', 'foo', null, null, new Valid()));
}

public function testAppendMultiplePropertyPaths()
Expand All @@ -61,7 +63,7 @@ public function testAppendMultiplePropertyPaths()
->atPath('bar')
->addViolation();

$this->assertViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data.foo.bar', 'foo', null, null, new Valid()));
$this->assertBuiltViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data.foo.bar', 'foo', null, null, new Valid()));
}

public function testCodeCanBeSet()
Expand All @@ -70,7 +72,7 @@ public function testCodeCanBeSet()
->setCode('5')
->addViolation();

$this->assertViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data', 'foo', null, '5', new Valid()));
$this->assertBuiltViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data', 'foo', null, '5', new Valid()));
}

public function testCauseCanBeSet()
Expand All @@ -81,7 +83,7 @@ public function testCauseCanBeSet()
->setCause($cause)
->addViolation();

$this->assertViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data', 'foo', null, null, new Valid(), $cause));
$this->assertBuiltViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'data', 'foo', null, null, new Valid(), $cause));
}

public function testTranslationDomainFalse()
Expand All @@ -96,12 +98,31 @@ public function testTranslationDomainFalse()
$builder->addViolation();
}

private function assertViolationEquals(ConstraintViolation $expectedViolation)
public function testBuildViolationFromExistingViolation()
{
$originalViolation = $this->builder->getViolation();

$violation = ConstraintViolationBuilder::fromViolation($originalViolation)
->setPath(PropertyPath::append('top', $originalViolation->getPropertyPath()))
->setCause($cause = new \LogicException())
->getViolation();

$this->assertCount(0, $this->violations);

$this->assertViolationEquals(new ConstraintViolation($this->messageTemplate, $this->messageTemplate, [], $this->root, 'top.data', 'foo', null, null, new Valid(), $cause), $violation);
}

private function assertBuiltViolationEquals(ConstraintViolation $expectedViolation): void
{
$this->assertCount(1, $this->violations);

$violation = $this->violations->get(0);

$this->assertViolationEquals($expectedViolation, $violation);
}

private function assertViolationEquals(ConstraintViolation $expectedViolation, ConstraintViolationInterface $violation): void
{
$this->assertSame($expectedViolation->getMessage(), $violation->getMessage());
$this->assertSame($expectedViolation->getMessageTemplate(), $violation->getMessageTemplate());
$this->assertSame($expectedViolation->getParameters(), $violation->getParameters());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Util\PropertyPath;
use Symfony\Contracts\Translation\TranslatorInterface;

Expand All @@ -27,24 +28,52 @@
class ConstraintViolationBuilder implements ConstraintViolationBuilderInterface
{
private string $propertyPath;
private ?string $message = null;
Copy link
Member

Choose a reason for hiding this comment

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

no need to make this nullable:

Suggested change
private ?string $message = null;
private string $translatedMessage;

Copy link
Contributor Author

@rela589n rela589n May 31, 2025

Choose a reason for hiding this comment

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

is it the point that it will be eventually initialized?

right now this is only initialized as $this->message ??= $this->translateMessage()

any access to this property prior to that place will throw an exception

private ?int $plural = null;
private ?string $code = null;
private mixed $cause = null;

public function __construct(
private ConstraintViolationList $violations,
private ?ConstraintViolationListInterface $violations,
Copy link
Member

Choose a reason for hiding this comment

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

Making this property nullable doesn't look good to me as you would now have to be aware of the inner state of the ConstraintViolationBuilder class to decide whether or not you can call addViolation().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@xabbuh , what do you propose instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

to decide whether or not you can call

how often is it necessary to "decide" in the way you say it?

afaik, addViolation is only called in custom validators that create violation builder using execution context, and it itself provides ConstraintViolationList

So what is the point in "deciding"?

Copy link
Member

Choose a reason for hiding this comment

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

Take a look at this arbitrary example taken from the Symfony code base (from the ChoiceValidator):

$this->context->buildViolation($constraint->maxMessage)
    ->setParameter('{{ limit }}', $constraint->max)
    ->setPlural((int) $constraint->max)
    ->setCode(Choice::TOO_MANY_ERROR)
    ->addViolation();

You wouldn't know here if the ConstraintViolationBuilderInterface implementation returned by buildViolation() would throw or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, but what do you mean that we wouldn't know if it will throw or not?
It will never throw

Copy link
Member

Choose a reason for hiding this comment

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

That's a core design principle: not allowing clumsy behavior by making the design forbid them. Weakening means increasing the risk for others, while the benefit we'd get is to cover a niche use case that can be implemented another way.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually I do not like the idea of using constraint violation builder both for building and adding violations to the list. Though, in current circumstances when there's exactly one builder that is used both to build and to add violations, what's the best way? A separate class that entirely duplicates the current builder but addViolation() method?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree that building and adding violations are two different purposes, but isn't it what already happens in the class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nicolas-grekas , @xabbuh , what do you think about the fact that this is already so?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we would do things differently when we were to build the component again and split responsibility here. But given the impact that would have if we split the current implementation this is not going to happen.

private ?Constraint $constraint,
private string|\Stringable $message,
private string|\Stringable $messageTemplate,
Copy link
Member

Choose a reason for hiding this comment

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

let's keep the previous name

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was motivated by using the same naming as in ConstraintViolation, as these properties are named there as $message and $messageTemplate.

Naming them as $message and $translatedMessage here would confuse the picture, since $message means different things. Though, if you'd like them to be named this way, I will make a change.

Frankly speaking I myself thought about adding $translatedMessage, and letting the original $message alone, because the rename might introduce a BC break, as changing parameter name from message to messageTemplate would break the invocation code if it passes it as a named parameter.

Though, I've checked BC promise, and it says:

[10] Parameter names are only covered by the compatibility promise for constructors of Attribute classes. Using PHP named arguments might break your code when upgrading to newer Symfony versions.

[11] Only optional argument(s) of a constructor at last position may be added.

So, keeping it unified looks better to me, but in any case, feel free to ask what you will

private array $parameters,
private mixed $root,
?string $propertyPath,
private mixed $invalidValue,
private TranslatorInterface $translator,
private TranslatorInterface|null $translator = null,
private string|false|null $translationDomain = null,
) {
$this->propertyPath = $propertyPath ?? '';
}

public static function fromViolation(ConstraintViolationInterface $violation): static
{
$builder = new self(
null,
$violation->getConstraint(),
$violation->getMessageTemplate(),
$violation->getParameters(),
$violation->getRoot(),
$violation->getPropertyPath(),
$violation->getInvalidValue(),
);

$builder->message = $violation->getMessage();

return $builder
->setPlural($violation->getPlural())
->setCode($violation->getCode())
->setCause($violation->getCause());
}

public function setPath(string $path): static
Copy link
Member

Choose a reason for hiding this comment

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

having this method is probably confusing for users of this class given that we already have an atPath() method and by looking at the names it's not obvious how they differ.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In most programming languages, setter methods (which modify private variables) are conventionally named by starting with "set" followed by the variable name, with the first letter of the variable name capitalized. For example, if a variable is named firstName, the setter would be setFirstName()

{
$this->propertyPath = $path;

return $this;
}

public function atPath(string $path): static
{
$this->propertyPath = PropertyPath::append($this->propertyPath, $path);
Expand Down Expand Up @@ -79,6 +108,7 @@ public function setTranslationDomain(string $translationDomain): static
public function disableTranslation(): static
{
$this->translationDomain = false;
$this->translator = null;

return $this;
}
Expand All @@ -90,7 +120,7 @@ public function setInvalidValue(mixed $invalidValue): static
return $this;
}

public function setPlural(int $number): static
public function setPlural(?int $number): static
Copy link
Member

Choose a reason for hiding this comment

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

see my concern about this change in the interface

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed the interface

btw, from my point question arises: if at any point we'd like to extend interface, how is it handled?
is this change released on a new major version as bc break? is there any deprecation of "not using new type"?

{
$this->plural = $number;

Expand All @@ -113,20 +143,18 @@ public function setCause(mixed $cause): static

public function addViolation(): void
{
$parameters = null === $this->plural ? $this->parameters : (['%count%' => $this->plural] + $this->parameters);
if (false === $this->translationDomain) {
$translatedMessage = strtr($this->message, $parameters);
} else {
$translatedMessage = $this->translator->trans(
$this->message,
$parameters,
$this->translationDomain
);
if (null === $this->violations) {
throw new \LogicException('Cannot add a violation without an execution context.');
}

$this->violations->add(new ConstraintViolation(
$translatedMessage,
$this->message,
$this->violations->add($this->getViolation());
}

public function getViolation(): ConstraintViolationInterface
{
return new ConstraintViolation(
$this->message ??= $this->translateMessage(),
$this->messageTemplate,
$this->parameters,
$this->root,
$this->propertyPath,
Expand All @@ -135,6 +163,21 @@ public function addViolation(): void
$this->code,
$this->constraint,
$this->cause
));
);
}

private function translateMessage(): string
{
$parameters = null === $this->plural ? $this->parameters : (['%count%' => $this->plural] + $this->parameters);

if (null === $this->translator || false === $this->translationDomain) {
return strtr($this->messageTemplate, $parameters);
}

return $this->translator->trans(
$this->messageTemplate,
$parameters,
$this->translationDomain
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@

namespace Symfony\Component\Validator\Violation;

use Symfony\Component\Validator\ConstraintViolationInterface;

/**
* Builds {@link \Symfony\Component\Validator\ConstraintViolationInterface}
* Builds {@link ConstraintViolationInterface}
* objects.
*
* Use the various methods on this interface to configure the built violation.
* Finally, call {@link addViolation()} to add the violation to the current
* execution context.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*
* @method ConstraintViolationInterface getViolation()
Copy link
Member

Choose a reason for hiding this comment

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

Adding this method adds a complete new meaning to the interface. I don't feel that it's a good idea having the same interface serve two different purposes.

* @method $this setPath(string $path)
*/
interface ConstraintViolationBuilderInterface
{
Expand Down Expand Up @@ -84,13 +89,13 @@ public function setInvalidValue(mixed $invalidValue): static;
* Sets the number which determines how the plural form of the violation
* message is chosen when it is translated.
*
* @param int $number The number for determining the plural form
* @param int|null $number The number for determining the plural form
*
* @return $this
*
* @see \Symfony\Contracts\Translation\TranslatorInterface::trans()
*/
public function setPlural(int $number): static;
public function setPlural(?int $number): static;
Copy link
Member

Choose a reason for hiding this comment

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

this is a BC break
looking at the decoration test case, this might not be needed - instead, don't call setPlural if getPlural returns null

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you're absolutely right, thank you for pointing this out
for some reason I didn't think about decoration 🫤

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've changed the interface

What about ConstarintViolationBuilder itself?


/**
* Sets the violation code.
Expand Down