feature #31528 [Validator] Add a Length::$allowEmptyString option to reject empty strings (ogizanagi)

This PR was merged into the 4.4 branch.

Discussion
----------

[Validator] Add a Length::$allowEmptyString option to reject empty strings

| Q             | A
| ------------- | ---
| Branch?       | 4.4 <!-- see below -->
| Bug fix?      | no
| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->
| BC breaks?    | no     <!-- see https://symfony.com/bc -->
| Deprecations? | yes <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tests pass?   | yes    <!-- please add some, will be required by reviewers -->
| Fixed tickets | N/A   <!-- #-prefixed issue number(s), if any -->
| License       | MIT
| Doc PR        | Todo (change the warning on top of https://symfony.com/doc/current/reference/constraints/Length.html)

which defaults to `true` in 4.4 but will trigger a deprecation if not set explicitly
in order to make the default `false` in 5.0.

While it could be solved now thanks to #29641 by using both `@Length(min=1)` & `@NotBlank(allowNull=true)` constraints,
as expressed in https://github.com/symfony/symfony/issues/27876#issuecomment-403307783 and following comments, the `@Length(min=1)` behavior doesn't match our expectations when reading it: it feels logical to invalidate empty strings, but it actually doesn't.
Hence the proposal of making the behavior of rejecting empty strings the default in 5.0.

In my opinion, the flag could even be removed later.

Commits
-------

e113e7f812 [Validator] Add a Length::$allowEmptyString option to reject empty strings
This commit is contained in:
Fabien Potencier 2019-07-03 15:15:57 +02:00
commit 4e32643bdf
13 changed files with 89 additions and 26 deletions

View File

@ -107,3 +107,7 @@ Validator
* Deprecated passing an `ExpressionLanguage` instance as the second argument of `ExpressionValidator::__construct()`. * Deprecated passing an `ExpressionLanguage` instance as the second argument of `ExpressionValidator::__construct()`.
Pass it as the first argument instead. Pass it as the first argument instead.
* The `Length` constraint expects the `allowEmptyString` option to be defined
when the `min` option is used.
Set it to `true` to keep the current behavior and `false` to reject empty strings.
In 5.0, it'll become optional and will default to `false`.

View File

@ -2,6 +2,9 @@
namespace Symfony\Bridge\Doctrine\Tests\Fixtures; namespace Symfony\Bridge\Doctrine\Tests\Fixtures;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
/** /**
* Class BaseUser. * Class BaseUser.
*/ */
@ -46,4 +49,15 @@ class BaseUser
{ {
return $this->username; return $this->username;
} }
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$allowEmptyString = property_exists(Assert\Length::class, 'allowEmptyString') ? ['allowEmptyString' => true] : [];
$metadata->addPropertyConstraint('username', new Assert\Length([
'min' => 2,
'max' => 120,
'groups' => ['Registration'],
] + $allowEmptyString));
}
} }

View File

@ -14,6 +14,7 @@ namespace Symfony\Bridge\Doctrine\Tests\Fixtures;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
/** /**
* @ORM\Entity * @ORM\Entity
@ -36,13 +37,11 @@ class DoctrineLoaderEntity extends DoctrineLoaderParentEntity
/** /**
* @ORM\Column(length=20) * @ORM\Column(length=20)
* @Assert\Length(min=5)
*/ */
public $mergedMaxLength; public $mergedMaxLength;
/** /**
* @ORM\Column(length=20) * @ORM\Column(length=20)
* @Assert\Length(min=1, max=10)
*/ */
public $alreadyMappedMaxLength; public $alreadyMappedMaxLength;
@ -69,4 +68,12 @@ class DoctrineLoaderEntity extends DoctrineLoaderParentEntity
/** @ORM\Column(type="simple_array", length=100) */ /** @ORM\Column(type="simple_array", length=100) */
public $simpleArrayField = []; public $simpleArrayField = [];
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$allowEmptyString = property_exists(Assert\Length::class, 'allowEmptyString') ? ['allowEmptyString' => true] : [];
$metadata->addPropertyConstraint('mergedMaxLength', new Assert\Length(['min' => 5] + $allowEmptyString));
$metadata->addPropertyConstraint('alreadyMappedMaxLength', new Assert\Length(['min' => 1, 'max' => 10] + $allowEmptyString));
}
} }

View File

@ -9,11 +9,6 @@
<constraint name="NotBlank"> <constraint name="NotBlank">
<option name="groups">Registration</option> <option name="groups">Registration</option>
</constraint> </constraint>
<constraint name="Length">
<option name="min">2</option>
<option name="max">120</option>
<option name="groups">Registration</option>
</constraint>
</property> </property>
</class> </class>
</constraint-mapping> </constraint-mapping>

View File

@ -40,6 +40,7 @@ class DoctrineLoaderTest extends TestCase
} }
$validator = Validation::createValidatorBuilder() $validator = Validation::createValidatorBuilder()
->addMethodMapping('loadValidatorMetadata')
->enableAnnotationMapping() ->enableAnnotationMapping()
->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), '{^Symfony\\\\Bridge\\\\Doctrine\\\\Tests\\\\Fixtures\\\\DoctrineLoader}')) ->addLoader(new DoctrineLoader(DoctrineTestHelper::createTestEntityManager(), '{^Symfony\\\\Bridge\\\\Doctrine\\\\Tests\\\\Fixtures\\\\DoctrineLoader}'))
->getValidator() ->getValidator()
@ -142,6 +143,7 @@ class DoctrineLoaderTest extends TestCase
} }
$validator = Validation::createValidatorBuilder() $validator = Validation::createValidatorBuilder()
->addMethodMapping('loadValidatorMetadata')
->enableAnnotationMapping() ->enableAnnotationMapping()
->addXmlMappings([__DIR__.'/../Resources/validator/BaseUser.xml']) ->addXmlMappings([__DIR__.'/../Resources/validator/BaseUser.xml'])
->addLoader( ->addLoader(

View File

@ -57,13 +57,15 @@ class FormTypeValidatorExtensionTest extends BaseValidatorExtensionTest
public function testGroupSequenceWithConstraintsOption() public function testGroupSequenceWithConstraintsOption()
{ {
$allowEmptyString = property_exists(Length::class, 'allowEmptyString') ? ['allowEmptyString' => true] : [];
$form = Forms::createFormFactoryBuilder() $form = Forms::createFormFactoryBuilder()
->addExtension(new ValidatorExtension(Validation::createValidator())) ->addExtension(new ValidatorExtension(Validation::createValidator()))
->getFormFactory() ->getFormFactory()
->create(FormTypeTest::TESTED_TYPE, null, (['validation_groups' => new GroupSequence(['First', 'Second'])])) ->create(FormTypeTest::TESTED_TYPE, null, (['validation_groups' => new GroupSequence(['First', 'Second'])]))
->add('field', TextTypeTest::TESTED_TYPE, [ ->add('field', TextTypeTest::TESTED_TYPE, [
'constraints' => [ 'constraints' => [
new Length(['min' => 10, 'groups' => ['First']]), new Length(['min' => 10, 'groups' => ['First']] + $allowEmptyString),
new Email(['groups' => ['Second']]), new Email(['groups' => ['Second']]),
], ],
]) ])

View File

@ -61,11 +61,13 @@ class ValidatorTypeGuesserTest extends TestCase
public function guessRequiredProvider() public function guessRequiredProvider()
{ {
$allowEmptyString = property_exists(Length::class, 'allowEmptyString') ? ['allowEmptyString' => true] : [];
return [ return [
[new NotNull(), new ValueGuess(true, Guess::HIGH_CONFIDENCE)], [new NotNull(), new ValueGuess(true, Guess::HIGH_CONFIDENCE)],
[new NotBlank(), new ValueGuess(true, Guess::HIGH_CONFIDENCE)], [new NotBlank(), new ValueGuess(true, Guess::HIGH_CONFIDENCE)],
[new IsTrue(), new ValueGuess(true, Guess::HIGH_CONFIDENCE)], [new IsTrue(), new ValueGuess(true, Guess::HIGH_CONFIDENCE)],
[new Length(10), new ValueGuess(false, Guess::LOW_CONFIDENCE)], [new Length(['min' => 10, 'max' => 10] + $allowEmptyString), new ValueGuess(false, Guess::LOW_CONFIDENCE)],
[new Range(['min' => 1, 'max' => 20]), new ValueGuess(false, Guess::LOW_CONFIDENCE)], [new Range(['min' => 1, 'max' => 20]), new ValueGuess(false, Guess::LOW_CONFIDENCE)],
]; ];
} }
@ -101,7 +103,9 @@ class ValidatorTypeGuesserTest extends TestCase
public function testGuessMaxLengthForConstraintWithMinValue() public function testGuessMaxLengthForConstraintWithMinValue()
{ {
$constraint = new Length(['min' => '2']); $allowEmptyString = property_exists(Length::class, 'allowEmptyString') ? ['allowEmptyString' => true] : [];
$constraint = new Length(['min' => '2'] + $allowEmptyString);
$result = $this->guesser->guessMaxLengthForConstraint($constraint); $result = $this->guesser->guessMaxLengthForConstraint($constraint);
$this->assertNull($result); $this->assertNull($result);

View File

@ -8,6 +8,7 @@ CHANGELOG
* added the `compared_value_path` parameter in violations when using any * added the `compared_value_path` parameter in violations when using any
comparison constraint with the `propertyPath` option. comparison constraint with the `propertyPath` option.
* added support for checking an array of types in `TypeValidator` * added support for checking an array of types in `TypeValidator`
* added a new `allowEmptyString` option to the `Length` constraint to allow rejecting empty strings when `min` is set, by setting it to `false`.
4.3.0 4.3.0
----- -----

View File

@ -41,6 +41,7 @@ class Length extends Constraint
public $min; public $min;
public $charset = 'UTF-8'; public $charset = 'UTF-8';
public $normalizer; public $normalizer;
public $allowEmptyString;
public function __construct($options = null) public function __construct($options = null)
{ {
@ -56,6 +57,13 @@ class Length extends Constraint
parent::__construct($options); parent::__construct($options);
if (null === $this->allowEmptyString) {
$this->allowEmptyString = true;
if (null !== $this->min) {
@trigger_error(sprintf('Using the "%s" constraint with the "min" option without setting the "allowEmptyString" one is deprecated and defaults to true. In 5.0, it will become optional and default to false.', self::class), E_USER_DEPRECATED);
}
}
if (null === $this->min && null === $this->max) { if (null === $this->min && null === $this->max) {
throw new MissingOptionsException(sprintf('Either option "min" or "max" must be given for constraint %s', __CLASS__), ['min', 'max']); throw new MissingOptionsException(sprintf('Either option "min" or "max" must be given for constraint %s', __CLASS__), ['min', 'max']);
} }

View File

@ -30,7 +30,7 @@ class LengthValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\Length'); throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\Length');
} }
if (null === $value || '' === $value) { if (null === $value || ('' === $value && $constraint->allowEmptyString)) {
return; return;
} }

View File

@ -21,7 +21,7 @@ class LengthTest extends TestCase
{ {
public function testNormalizerCanBeSet() public function testNormalizerCanBeSet()
{ {
$length = new Length(['min' => 0, 'max' => 10, 'normalizer' => 'trim']); $length = new Length(['min' => 0, 'max' => 10, 'normalizer' => 'trim', 'allowEmptyString' => false]);
$this->assertEquals('trim', $length->normalizer); $this->assertEquals('trim', $length->normalizer);
} }
@ -32,7 +32,7 @@ class LengthTest extends TestCase
*/ */
public function testInvalidNormalizerThrowsException() public function testInvalidNormalizerThrowsException()
{ {
new Length(['min' => 0, 'max' => 10, 'normalizer' => 'Unknown Callable']); new Length(['min' => 0, 'max' => 10, 'normalizer' => 'Unknown Callable', 'allowEmptyString' => false]);
} }
/** /**
@ -41,6 +41,6 @@ class LengthTest extends TestCase
*/ */
public function testInvalidNormalizerObjectThrowsException() public function testInvalidNormalizerObjectThrowsException()
{ {
new Length(['min' => 0, 'max' => 10, 'normalizer' => new \stdClass()]); new Length(['min' => 0, 'max' => 10, 'normalizer' => new \stdClass(), 'allowEmptyString' => false]);
} }
} }

View File

@ -22,26 +22,47 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
return new LengthValidator(); return new LengthValidator();
} }
public function testNullIsValid() public function testLegacyNullIsValid()
{ {
$this->validator->validate(null, new Length(6)); $this->validator->validate(null, new Length(['value' => 6, 'allowEmptyString' => false]));
$this->assertNoViolation(); $this->assertNoViolation();
} }
public function testEmptyStringIsValid() /**
* @group legacy
* @expectedDeprecation Using the "Symfony\Component\Validator\Constraints\Length" constraint with the "min" option without setting the "allowEmptyString" one is deprecated and defaults to true. In 5.0, it will become optional and default to false.
*/
public function testLegacyEmptyStringIsValid()
{ {
$this->validator->validate('', new Length(6)); $this->validator->validate('', new Length(6));
$this->assertNoViolation(); $this->assertNoViolation();
} }
public function testEmptyStringIsInvalid()
{
$this->validator->validate('', new Length([
'value' => $limit = 6,
'allowEmptyString' => false,
'exactMessage' => 'myMessage',
]));
$this->buildViolation('myMessage')
->setParameter('{{ value }}', '""')
->setParameter('{{ limit }}', $limit)
->setInvalidValue('')
->setPlural($limit)
->setCode(Length::TOO_SHORT_ERROR)
->assertRaised();
}
/** /**
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedValueException * @expectedException \Symfony\Component\Validator\Exception\UnexpectedValueException
*/ */
public function testExpectsStringCompatibleType() public function testExpectsStringCompatibleType()
{ {
$this->validator->validate(new \stdClass(), new Length(5)); $this->validator->validate(new \stdClass(), new Length(['value' => 5, 'allowEmptyString' => false]));
} }
public function getThreeOrLessCharacters() public function getThreeOrLessCharacters()
@ -109,7 +130,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
*/ */
public function testValidValuesMin($value) public function testValidValuesMin($value)
{ {
$constraint = new Length(['min' => 5]); $constraint = new Length(['min' => 5, 'allowEmptyString' => false]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
$this->assertNoViolation(); $this->assertNoViolation();
@ -131,7 +152,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
*/ */
public function testValidValuesExact($value) public function testValidValuesExact($value)
{ {
$constraint = new Length(4); $constraint = new Length(['value' => 4, 'allowEmptyString' => false]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
$this->assertNoViolation(); $this->assertNoViolation();
@ -142,7 +163,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
*/ */
public function testValidNormalizedValues($value) public function testValidNormalizedValues($value)
{ {
$constraint = new Length(['min' => 3, 'max' => 3, 'normalizer' => 'trim']); $constraint = new Length(['min' => 3, 'max' => 3, 'normalizer' => 'trim', 'allowEmptyString' => false]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
$this->assertNoViolation(); $this->assertNoViolation();
@ -156,6 +177,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
$constraint = new Length([ $constraint = new Length([
'min' => 4, 'min' => 4,
'minMessage' => 'myMessage', 'minMessage' => 'myMessage',
'allowEmptyString' => false,
]); ]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
@ -199,6 +221,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
'min' => 4, 'min' => 4,
'max' => 4, 'max' => 4,
'exactMessage' => 'myMessage', 'exactMessage' => 'myMessage',
'allowEmptyString' => false,
]); ]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
@ -221,6 +244,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
'min' => 4, 'min' => 4,
'max' => 4, 'max' => 4,
'exactMessage' => 'myMessage', 'exactMessage' => 'myMessage',
'allowEmptyString' => false,
]); ]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
@ -244,6 +268,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
'max' => 1, 'max' => 1,
'charset' => $charset, 'charset' => $charset,
'charsetMessage' => 'myMessage', 'charsetMessage' => 'myMessage',
'allowEmptyString' => false,
]); ]);
$this->validator->validate($value, $constraint); $this->validator->validate($value, $constraint);
@ -262,7 +287,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
public function testConstraintDefaultOption() public function testConstraintDefaultOption()
{ {
$constraint = new Length(5); $constraint = new Length(['value' => 5, 'allowEmptyString' => false]);
$this->assertEquals(5, $constraint->min); $this->assertEquals(5, $constraint->min);
$this->assertEquals(5, $constraint->max); $this->assertEquals(5, $constraint->max);
@ -270,7 +295,7 @@ class LengthValidatorTest extends ConstraintValidatorTestCase
public function testConstraintAnnotationDefaultOption() public function testConstraintAnnotationDefaultOption()
{ {
$constraint = new Length(['value' => 5, 'exactMessage' => 'message']); $constraint = new Length(['value' => 5, 'exactMessage' => 'message', 'allowEmptyString' => false]);
$this->assertEquals(5, $constraint->min); $this->assertEquals(5, $constraint->min);
$this->assertEquals(5, $constraint->max); $this->assertEquals(5, $constraint->max);

View File

@ -103,7 +103,7 @@ class RecursiveValidatorTest extends AbstractTest
public function testCollectionConstraintValidateAllGroupsForNestedConstraints() public function testCollectionConstraintValidateAllGroupsForNestedConstraints()
{ {
$this->metadata->addPropertyConstraint('data', new Collection(['fields' => [ $this->metadata->addPropertyConstraint('data', new Collection(['fields' => [
'one' => [new NotBlank(['groups' => 'one']), new Length(['min' => 2, 'groups' => 'two'])], 'one' => [new NotBlank(['groups' => 'one']), new Length(['min' => 2, 'groups' => 'two', 'allowEmptyString' => false])],
'two' => [new NotBlank(['groups' => 'two'])], 'two' => [new NotBlank(['groups' => 'two'])],
]])); ]]));
@ -121,7 +121,7 @@ class RecursiveValidatorTest extends AbstractTest
{ {
$this->metadata->addPropertyConstraint('data', new All(['constraints' => [ $this->metadata->addPropertyConstraint('data', new All(['constraints' => [
new NotBlank(['groups' => 'one']), new NotBlank(['groups' => 'one']),
new Length(['min' => 2, 'groups' => 'two']), new Length(['min' => 2, 'groups' => 'two', 'allowEmptyString' => false]),
]])); ]]));
$entity = new Entity(); $entity = new Entity();
@ -129,8 +129,9 @@ class RecursiveValidatorTest extends AbstractTest
$violations = $this->validator->validate($entity, null, ['one', 'two']); $violations = $this->validator->validate($entity, null, ['one', 'two']);
$this->assertCount(2, $violations); $this->assertCount(3, $violations);
$this->assertInstanceOf(NotBlank::class, $violations->get(0)->getConstraint()); $this->assertInstanceOf(NotBlank::class, $violations->get(0)->getConstraint());
$this->assertInstanceOf(Length::class, $violations->get(1)->getConstraint()); $this->assertInstanceOf(Length::class, $violations->get(1)->getConstraint());
$this->assertInstanceOf(Length::class, $violations->get(2)->getConstraint());
} }
} }