Skip to content

Commit

Permalink
feature #34334 [Validator] Allow to define a reusable set of constrai…
Browse files Browse the repository at this point in the history
…nts (ogizanagi)

This PR was squashed before being merged into the 5.1-dev branch (closes #34334).

Discussion
----------

[Validator] Allow to define a reusable set of constraints

| Q             | A
| ------------- | ---
| Branch?       | 5.1 <!-- see below -->
| Bug fix?      | no
| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->
| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tickets       | N/A <!-- prefix each issue number with "Fix #", if any -->
| License       | MIT
| Doc PR        | TODO

The goal of this feature is to simplify writing a set of validation constraints to be reused consistently across the application. Which is especially useful with DTOs, as a same set of constraints can be used in different places.

For instance, given multiple DTOs containing the new user password in for different use-cases (register, forgot pwd, change pwd), the same rules apply on the property. Hence with this PR, you can write a single constraint class to be reused:

```php
/**
 * @annotation
 */
class MatchesPasswordRequirements extends Compound
{
    protected function getConstraints(array $options): array
    {
        return [
            new NotBlank(),
            new Type('string'),
            new Length(['min' => 12]),
            new NotCompromisedPassword(),
        ];
    }
}
```

I'm open to better naming and ways to expose the options to the `Compound::getConstraints` method, so options can be forwarded to the nested constraints for most specific use-cases.

Commits
-------

8f1b0df [Validator] Allow to define a reusable set of constraints
  • Loading branch information
fabpot committed Feb 8, 2020
2 parents f4490a6 + 8f1b0df commit f53ea3d
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 2 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* added the `Hostname` constraint and validator
* added option `alpha3` to `Country` constraint
* allow to define a reusable set of constraints by extending the `Compound` constraint

5.0.0
-----
Expand Down
14 changes: 12 additions & 2 deletions src/Symfony/Component/Validator/Constraint.php
Expand Up @@ -105,6 +105,14 @@ public static function getErrorName($errorCode)
*/
public function __construct($options = null)
{
foreach ($this->normalizeOptions($options) as $name => $value) {
$this->$name = $value;
}
}

protected function normalizeOptions($options): array
{
$normalizedOptions = [];
$defaultOption = $this->getDefaultOption();
$invalidOptions = [];
$missingOptions = array_flip((array) $this->getRequiredOptions());
Expand All @@ -128,7 +136,7 @@ public function __construct($options = null)
if ($options && \is_array($options) && \is_string(key($options))) {
foreach ($options as $option => $value) {
if (\array_key_exists($option, $knownOptions)) {
$this->$option = $value;
$normalizedOptions[$option] = $value;
unset($missingOptions[$option]);
} else {
$invalidOptions[] = $option;
Expand All @@ -140,7 +148,7 @@ public function __construct($options = null)
}

if (\array_key_exists($defaultOption, $knownOptions)) {
$this->$defaultOption = $options;
$normalizedOptions[$defaultOption] = $options;
unset($missingOptions[$defaultOption]);
} else {
$invalidOptions[] = $defaultOption;
Expand All @@ -154,6 +162,8 @@ public function __construct($options = null)
if (\count($missingOptions) > 0) {
throw new MissingOptionsException(sprintf('The options "%s" must be set for constraint "%s".', implode('", "', array_keys($missingOptions)), static::class), array_keys($missingOptions));
}

return $normalizedOptions;
}

/**
Expand Down
52 changes: 52 additions & 0 deletions src/Symfony/Component/Validator/Constraints/Compound.php
@@ -0,0 +1,52 @@
<?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\Component\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;

/**
* Extend this class to create a reusable set of constraints.
*
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
abstract class Compound extends Composite
{
/** @var Constraint[] */
public $constraints = [];

public function __construct($options = null)
{
if (isset($options[$this->getCompositeOption()])) {
throw new ConstraintDefinitionException(sprintf('You can\'t redefine the "%s" option. Use the %s::getConstraints() method instead.', $this->getCompositeOption(), __CLASS__));
}

$this->constraints = $this->getConstraints($this->normalizeOptions($options));

parent::__construct($options);
}

final protected function getCompositeOption()
{
return 'constraints';
}

final public function validatedBy()
{
return CompoundValidator::class;
}

/**
* @return Constraint[]
*/
abstract protected function getConstraints(array $options): array;
}
35 changes: 35 additions & 0 deletions src/Symfony/Component/Validator/Constraints/CompoundValidator.php
@@ -0,0 +1,35 @@
<?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\Component\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
class CompoundValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof Compound) {
throw new UnexpectedTypeException($constraint, Compound::class);
}

$context = $this->context;

$validator = $context->getValidator()->inContext($context);

$validator->validate($value, $constraint->constraints);
}
}
Expand Up @@ -181,6 +181,15 @@ protected function expectValidateAt($i, $propertyPath, $value, $group)
->willReturn($validator);
}

protected function expectValidateValue(int $i, $value, array $constraints = [], $group = null)
{
$contextualValidator = $this->context->getValidator()->inContext($this->context);
$contextualValidator->expects($this->at($i))
->method('validate')
->with($value, $constraints, $group)
->willReturn($contextualValidator);
}

protected function expectValidateValueAt($i, $propertyPath, $value, $constraints, $group = null)
{
$contextualValidator = $this->context->getValidator()->inContext($this->context);
Expand Down
60 changes: 60 additions & 0 deletions src/Symfony/Component/Validator/Tests/Constraints/CompoundTest.php
@@ -0,0 +1,60 @@
<?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\Component\Validator\Tests\Constraints;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraints\Compound;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;

class CompoundTest extends TestCase
{
public function testItCannotRedefineConstraintsOption()
{
$this->expectException(ConstraintDefinitionException::class);
$this->expectExceptionMessage('You can\'t redefine the "constraints" option. Use the Symfony\Component\Validator\Constraints\Compound::getConstraints() method instead.');
new EmptyCompound(['constraints' => [new NotBlank()]]);
}

public function testCanDependOnNormalizedOptions()
{
$constraint = new ForwardingOptionCompound($min = 3);

$this->assertSame($min, $constraint->constraints[0]->min);
}
}

class EmptyCompound extends Compound
{
protected function getConstraints(array $options): array
{
return [];
}
}

class ForwardingOptionCompound extends Compound
{
public $min;

public function getDefaultOption()
{
return 'min';
}

protected function getConstraints(array $options): array
{
return [
new Length(['min' => $options['min'] ?? null]),
];
}
}
@@ -0,0 +1,56 @@
<?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\Component\Validator\Tests\Constraints;

use Symfony\Component\Validator\Constraints\Compound;
use Symfony\Component\Validator\Constraints\CompoundValidator;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;

class CompoundValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return new CompoundValidator();
}

public function testValidValue()
{
$this->validator->validate('foo', new DummyCompoundConstraint());

$this->assertNoViolation();
}

public function testValidateWithConstraints()
{
$value = 'foo';
$constraint = new DummyCompoundConstraint();

$this->expectValidateValue(0, $value, $constraint->constraints);

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

$this->assertNoViolation();
}
}

class DummyCompoundConstraint extends Compound
{
protected function getConstraints(array $options): array
{
return [
new NotBlank(),
new Length(['max' => 3]),
];
}
}

0 comments on commit f53ea3d

Please sign in to comment.