Skip to content

Commit 3786495

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

File tree

5 files changed

+353
-0
lines changed

5 files changed

+353
-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 <[email protected]>
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 <[email protected]>
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 one 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 one word.|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 <[email protected]>
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 <[email protected]>
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($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($constraint->max)
61+
->setInvalidValue($value)
62+
->addViolation();
63+
}
64+
}
65+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
/**
22+
* @requires extension intl
23+
*/
24+
class WordCountTest extends TestCase
25+
{
26+
public function testLocaleIsSet()
27+
{
28+
$wordCount = new WordCount(min: 1, locale: 'en');
29+
30+
$this->assertSame('en', $wordCount->locale);
31+
}
32+
33+
public function testOnlyMinIsSet()
34+
{
35+
$wordCount = new WordCount(1);
36+
37+
$this->assertSame(1, $wordCount->min);
38+
$this->assertNull($wordCount->max);
39+
$this->assertNull($wordCount->locale);
40+
}
41+
42+
public function testOnlyMaxIsSet()
43+
{
44+
$wordCount = new WordCount(max: 1);
45+
46+
$this->assertNull($wordCount->min);
47+
$this->assertSame(1, $wordCount->max);
48+
$this->assertNull($wordCount->locale);
49+
}
50+
51+
public function testMinMustBeNatural()
52+
{
53+
$this->expectException(ConstraintDefinitionException::class);
54+
$this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the min word count to be a positive integer or 0 if set.');
55+
56+
new WordCount(-1);
57+
}
58+
59+
public function testMaxMustBePositive()
60+
{
61+
$this->expectException(ConstraintDefinitionException::class);
62+
$this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the max word count to be a positive integer if set.');
63+
64+
new WordCount(max: 0);
65+
}
66+
67+
public function testNothingIsSet()
68+
{
69+
$this->expectException(MissingOptionsException::class);
70+
$this->expectExceptionMessage('Either option "min" or "max" must be given for constraint "Symfony\Component\Validator\Constraints\WordCount".');
71+
72+
new WordCount();
73+
}
74+
75+
public function testMaxIsLessThanMin()
76+
{
77+
$this->expectException(ConstraintDefinitionException::class);
78+
$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.');
79+
80+
new WordCount(2, 1);
81+
}
82+
83+
public function testMinAndMaxAreEquals()
84+
{
85+
$wordCount = new WordCount(1, 1);
86+
87+
$this->assertSame(1, $wordCount->min);
88+
$this->assertSame(1, $wordCount->max);
89+
$this->assertNull($wordCount->locale);
90+
}
91+
92+
public function testAttributes()
93+
{
94+
$metadata = new ClassMetadata(WordCountDummy::class);
95+
$loader = new AttributeLoader();
96+
$this->assertTrue($loader->loadClassMetadata($metadata));
97+
98+
[$aConstraint] = $metadata->properties['a']->getConstraints();
99+
$this->assertSame(1, $aConstraint->min);
100+
$this->assertSame(null, $aConstraint->max);
101+
$this->assertNull($aConstraint->locale);
102+
103+
[$bConstraint] = $metadata->properties['b']->getConstraints();
104+
$this->assertSame(2, $bConstraint->min);
105+
$this->assertSame(5, $bConstraint->max);
106+
$this->assertNull($bConstraint->locale);
107+
108+
[$cConstraint] = $metadata->properties['c']->getConstraints();
109+
$this->assertSame(3, $cConstraint->min);
110+
$this->assertNull($cConstraint->max);
111+
$this->assertSame('en', $cConstraint->locale);
112+
}
113+
}
114+
115+
class WordCountDummy
116+
{
117+
#[WordCount(min: 1)]
118+
private string $a;
119+
120+
#[WordCount(min: 2, max: 5)]
121+
private string $b;
122+
123+
#[WordCount(min: 3, locale: 'en')]
124+
private string $c;
125+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
/**
21+
* @requires extension intl
22+
*/
23+
class WordCountValidatorTest extends ConstraintValidatorTestCase
24+
{
25+
protected function createValidator(): WordCountValidator
26+
{
27+
return new WordCountValidator();
28+
}
29+
30+
/**
31+
* @dataProvider provideValidValues
32+
*/
33+
public function testValidWordCount(string|\Stringable|null $value, int $expectedWordCount)
34+
{
35+
$this->validator->validate($value, new WordCount(min: $expectedWordCount, max: $expectedWordCount));
36+
37+
$this->assertNoViolation();
38+
}
39+
40+
public function testTooShort()
41+
{
42+
$constraint = new WordCount(min: 4, minMessage: 'myMessage');
43+
$this->validator->validate('my ascii string', $constraint);
44+
45+
$this->buildViolation('myMessage')
46+
->setParameter('{{ count }}', 3)
47+
->setParameter('{{ min }}', 4)
48+
->setPlural(4)
49+
->setInvalidValue('my ascii string')
50+
->assertRaised();
51+
}
52+
53+
public function testTooLong()
54+
{
55+
$constraint = new WordCount(max: 3, maxMessage: 'myMessage');
56+
$this->validator->validate('my beautiful ascii string', $constraint);
57+
58+
$this->buildViolation('myMessage')
59+
->setParameter('{{ count }}', 4)
60+
->setParameter('{{ max }}', 3)
61+
->setPlural(3)
62+
->setInvalidValue('my beautiful ascii string')
63+
->assertRaised();
64+
}
65+
66+
/**
67+
* @dataProvider provideInvalidTypes
68+
*/
69+
public function testNonStringValues(mixed $value)
70+
{
71+
$this->expectException(UnexpectedValueException::class);
72+
$this->expectExceptionMessageMatches('/Expected argument of type "string", ".*" given/');
73+
74+
$this->validator->validate($value, new WordCount(min: 1));
75+
}
76+
77+
public static function provideValidValues()
78+
{
79+
yield ['my ascii string', 3];
80+
yield [" with a\nnewline", 3];
81+
yield ["皆さん、こんにちは。", 4];
82+
yield ["你好,世界!这是一个测试。", 9];
83+
yield [new StringableValue('my ûtf 8'), 3];
84+
yield [null, 1]; // null should always pass and eventually be handled by NotNullValidator
85+
yield ['', 1]; // empty string should always pass and eventually be handled by NotBlankValidator
86+
}
87+
88+
public static function provideInvalidTypes()
89+
{
90+
yield [true];
91+
yield [false];
92+
yield [1];
93+
yield [1.1];
94+
yield [[]];
95+
yield [new \stdClass()];
96+
}
97+
}

0 commit comments

Comments
 (0)