8000 [Validator] Add the `WordCount` constraint · symfony/symfony@37f8770 · GitHub
[go: up one dir, main page]

Skip to content

Commit 37f8770

Browse files
[Validator] Add the WordCount constraint
1 parent 1a16ebc commit 37f8770

File tree

5 files changed

+347
-0
lines changed

5 files changed

+347
-0
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Add the `Yaml` constraint for validating YAML content
1010
* Add `errorPath` to Unique constraint
1111
* Add the `format` option to the `Ulid` constraint to allow accepting different ULID formats
12+
* Add the `WordCount` constraint
1213

1314
7.1
1415
---
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Attribute\HasNamedArguments;
15+
use Symfony\Component\Validator\Constraint;
16+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
17+
use Symfony\Component\Validator\Exception\MissingOptionsException;
18+
19+
/**
20+
* @author Alexandre Daubois <alex.daubois@gmail.com>
21+
*/
22+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
23+
final class WordCount extends Constraint
24+
{
25+
public const TOO_SHORT_ERROR = 'cc4925df-b5a6-42dd-87f3-21919f349bf3';
26+
public const TOO_LONG_ERROR = 'a951a642-f662-4fad-8761-79250eef74cb';
27+
28+
protected const ERROR_NAMES = [
29+
self::TOO_SHORT_ERROR => 'TOO_SHORT_ERROR',
30+
self::TOO_LONG_ERROR => 'TOO_LONG_ERROR',
31+
];
32+
33+
#[HasNamedArguments]
34+
public function __construct(
35+
public ?int $min = null,
36+
public ?int $max = null,
37+
public ?string $locale = null,
38+
public string $minMessage = 'This value is too short. It should contain at least {{ min }} word.|This value is too short. It should contain at least {{ min }} words.',
39+
public string $maxMessage = 'This value is too long. It should contain {{ max }} word or less.|This value is too long. It should contain {{ max }} words or less.',
40+
?array $groups = null,
41+
mixed $payload = null,
42+
) {
43+
if (!class_exists(\IntlBreakIterator::class)) {
44+
throw new \RuntimeException(\sprintf('The "%s" constraint requires the "intl" PHP extension.', __CLASS__));
45+
}
46+
47+
if (null === $min && null === $max) {
48+
throw new MissingOptionsException(\sprintf('Either option "min" or "max" must be given for constraint "%s".', __CLASS__), ['min', 'max']);
49+
}
50+
51+
if (null !== $min && $min < 0) {
52+
throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the min word count to be a positive integer or 0 if set.', __CLASS__));
53+
}
54+
55+
if (null !== $max && $max <= 0) {
56+
throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the max word count to be a positive integer if set.', __CLASS__));
57+
}
58+
59+
if (null !== $min && null !== $max && $min > $max) {
60+
throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the min word count to be less than or equal to the max word count.', __CLASS__));
61+
}
62+
63+
parent::__construct(null, $groups, $payload);
64+
}
65+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
17+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
18+
19+
/**
20+
* @author Alexandre Daubois <alex.daubois@gmail.com>
21+
*/
22+
final class WordCountValidator extends ConstraintValidator
23+
{
24+
public function validate(mixed $value, Constraint $constraint): void
25+
{
26+
if (!class_exists(\IntlBreakIterator::class)) {
27+
throw new \RuntimeException(\sprintf('The "%s" constraint requires the "intl" PHP extension.', __CLASS__));
28+
}
29+
30+
if (!$constraint instanceof WordCount) {
31+
throw new UnexpectedTypeException($constraint, WordCount::class);
32+
}
33+
34+
if (null === $value || '' === $value) {
35+
return;
36+
}
37+
38+
if (!\is_string($value) && !$value instanceof \Stringable) {
39+
throw new UnexpectedValueException($value, 'string');
40+
}
41+
42+
$iterator = \IntlBreakIterator::createWordInstance($constraint->locale);
43+
$iterator->setText($value);
44+
$words = iterator_to_array($iterator->getPartsIterator());
45+
46+
// erase "blank words" and don't count them as words
47+
$wordsCount = \count(array_filter(array_map(trim(...), $words)));
48+
49+
if (null !== $constraint->min && $wordsCount < $constraint->min) {
50+
$this->context->buildViolation($constraint->minMessage)
51+
->setParameter('{{ count }}', $wordsCount)
52+
->setParameter('{{ min }}', $constraint->min)
53+
->setPlural((int) $constraint->min)
54+
->setInvalidValue($value)
55+
->addViolation();
56+
} elseif (null !== $constraint->max && $wordsCount > $constraint->max) {
57+
$this->context->buildViolation($constraint->maxMessage)
58+
->setParameter('{{ count }}', $wordsCount)
59+
->setParameter('{{ max }}', $constraint->max)
60+
->setPlural((int) $constraint->max)
61+
->setInvalidValue($value)
62+
->addViolation();
63+
}
64+
}
65+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Constraints\WordCount;
16+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
17+
use Symfony\Component\Validator\Exception\MissingOptionsException;
18+
use Symfony\Component\Validator\Mapping\ClassMetadata;
19+
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
20+
21+
class WordCountTest extends TestCase
22+
{
23+
public function testLocaleIsSet()
24+
{
25+
$wordCount = new WordCount(min: 1, locale: 'en');
26+
27+
$this->assertSame('en', $wordCount->locale);
28+
}
29+
30+
public function testOnlyMinIsSet()
31+
{
32+
$wordCount = new WordCount(1);
33+
34+
$this->assertSame(1, $wordCount->min);
35+
$this->assertNull($wordCount->max);
36+
$this->assertNull($wordCount->locale);
37+
}
38+
39+
public function testOnlyMaxIsSet()
40+
{
41+
$wordCount = new WordCount(max: 1);
42+
43+
$this->assertNull($wordCount->min);
44+
$this->assertSame(1, $wordCount->max);
45+
$this->assertNull($wordCount->locale);
46+
}
47+
48+
public function testMinMustBeNatural()
49+
{
50+
$this->expectException(ConstraintDefinitionException::class);
51+
$this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the min word count to be a positive integer or 0 if set.');
52+
53+
new WordCount(-1);
54+
}
55+
56+
public function testMaxMustBePositive()
57+
{
58+
$this->expectException(ConstraintDefinitionException::class);
59+
$this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the max word count to be a positive integer if set.');
60+
61+
new WordCount(max: 0);
62+
}
63+
64+
public function testNothingIsSet()
65+
{
66+
$this->expectException(MissingOptionsException::class);
67+
$this->expectExceptionMessage('Either option "min" or "max" must be given for constraint "Symfony\Component\Validator\Constraints\WordCount".');
68+
69+
new WordCount();
70+
}
71+
72+
public function testMaxIsLessThanMin()
73+
{
74+
$this->expectException(ConstraintDefinitionException::class);
75+
$this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the min word count to be less than or equal to the max word count.');
76+
77+
new WordCount(2, 1);
78+
}
79+
80+
public function testMinAndMaxAreEquals()
81+
{
82+
$wordCount = new WordCount(1, 1);
83+
84+
$this->assertSame(1, $wordCount->min);
85+
$this->assertSame(1, $wordCount->max);
86+
$this->assertNull($wordCount->locale);
87+
}
88+
89+
public function testAttributes()
90+
{
91+
$metadata = new ClassMetadata(WordCountDummy::class);
92+
$loader = new AttributeLoader();
93+
$this->assertTrue($loader->loadClassMetadata($metadata));
94+
95+
[$aConstraint] = $metadata->properties['a']->getConstraints();
96+
$this->assertSame(1, $aConstraint->min);
97+
$this->assertSame(null, $aConstraint->max);
98+
$this->assertNull($aConstraint->locale);
99+
100+
[$bConstraint] = $metadata->properties['b']->getConstraints();
101+
$this->assertSame(2, $bConstraint->min);
102+
$this->assertSame(5, $bConstraint->max);
103+
$this->assertNull($bConstraint->locale);
104+
105+
[$cConstraint] = $metadata->properties['c']->getConstraints();
106+
$this->assertSame(3, $cConstraint->min);
107+
$this->assertNull($cConstraint->max);
108+
$this->assertSame('en', $cConstraint->locale);
109+
}
110+
}
111+
112+
class WordCountDummy
113+
{
114+
#[WordCount(min: 1)]
115+
private string $a;
116+
117+
#[WordCount(min: 2, max: 5)]
118+
private string $b;
119+
120+
#[WordCount(min: 3, locale: 'en')]
121+
private string $c;
122+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use Symfony\Component\Validator\Constraints\WordCount;
15+
use Symfony\Component\Validator\Constraints\WordCountValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
17+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
18+
use Symfony\Component\Validator\Tests\Constraints\Fixtures\StringableValue;
19+
20+
class WordCountValidatorTest extends ConstraintValidatorTestCase
21+
{
22+
protected function createValidator(): WordCountValidator
23+
{
24+
return new WordCountValidator();
25+
}
26+
27+
/**
28+
* @dataProvider provideValidValues
29+
*/
30+
public function testValidWordCount(string|\Stringable|null $value, int $expectedWordCount)
31+
{
32+
$this->validator->validate($value, new WordCount(min: $expectedWordCount, max: $expectedWordCount));
33+
34+
$this->assertNoViolation();
35+
}
36+
37+
public function testTooShort()
38+
{
39+
$constraint = new WordCount(min: 4, minMessage: 'myMessage');
40+
$this->validator->validate('my ascii string', $constraint);
41+
42+
$this->buildViolation('myMessage')
43+
->setParameter('{{ count }}', 3)
44+
->setParameter('{{ min }}', 4)
45+
->setPlural(4)
46+
->setInvalidValue('my ascii string')
47+
->assertRaised();
48+
}
49+
50+
public function testTooLong()
51+
{
52+
$constraint = new WordCount(max: 3, maxMessage: 'myMessage');
53+
$this->validator->validate('my beautiful ascii string', $constraint);
54+
55+
$this->buildViolation('myMessage')
56+
->setParameter('{{ count }}', 4)
57+
->setParameter('{{ max }}', 3)
58+
->setPlural(3)
59+
->setInvalidValue('my beautiful ascii string')
60+
->assertRaised();
61+
}
62+
63+
/**
64+
* @dataProvider provideInvalidTypes
65+
*/
66+
public function testNonStringValues(mixed $value)
67+
{
68+
$this->expectException(UnexpectedValueException::class);
69+
$this->expectExceptionMessageMatches('/Expected argument of type "string", ".*" given/');
70+
71+
$this->validator->validate($value, new WordCount(min: 1));
72+
}
73+
74+
public static function provideValidValues()
75+
{
76+
yield ['my ascii string', 3];
77+
yield [" with a\nnewline", 3];
78+
yield ["皆さん、こんにちは。", 4];
79+
yield ["你好,世界!这是一个测试。", 9];
80+
yield [new StringableValue('my ûtf 8'), 3];
81+
yield [null, 1]; // null should always pass and eventually be handled by NotNullValidator
82+
yield ['', 1]; // empty string should always pass and eventually be handled by NotBlankValidator
83+
}
84+
85+
public static function provideInvalidTypes()
86+
{
87+
yield [true];
88+
yield [false];
89+
yield [1];
90+
yield [1.1];
91+
yield [[]];
92+
yield [new \stdClass()];
93+
}
94+
}

0 commit comments

Comments
 (0)
0