From 3786495e07d33d67b951f6d032e644d0a954febb Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 12 Jul 2024 16:39:00 +0200 Subject: [PATCH] [Validator] Add the `WordCount` constraint --- src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Validator/Constraints/WordCount.php | 65 +++++++++ .../Constraints/WordCountValidator.php | 65 +++++++++ .../Tests/Constraints/WordCountTest.php | 125 ++++++++++++++++++ .../Constraints/WordCountValidatorTest.php | 97 ++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 src/Symfony/Component/Validator/Constraints/WordCount.php create mode 100644 src/Symfony/Component/Validator/Constraints/WordCountValidator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/WordCountTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/WordCountValidatorTest.php diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index b5461ca6a4896..46709ac0ac814 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Add the `Yaml` constraint for validating YAML content * Add `errorPath` to Unique constraint * Add the `format` option to the `Ulid` constraint to allow accepting different ULID formats + * Add the `WordCount` constraint 7.1 --- diff --git a/src/Symfony/Component/Validator/Constraints/WordCount.php b/src/Symfony/Component/Validator/Constraints/WordCount.php new file mode 100644 index 0000000000000..6a85318b1f786 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/WordCount.php @@ -0,0 +1,65 @@ + + * + * 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\Attribute\HasNamedArguments; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\MissingOptionsException; + +/** + * @author Alexandre Daubois + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class WordCount extends Constraint +{ + public const TOO_SHORT_ERROR = 'cc4925df-b5a6-42dd-87f3-21919f349bf3'; + public const TOO_LONG_ERROR = 'a951a642-f662-4fad-8761-79250eef74cb'; + + protected const ERROR_NAMES = [ + self::TOO_SHORT_ERROR => 'TOO_SHORT_ERROR', + self::TOO_LONG_ERROR => 'TOO_LONG_ERROR', + ]; + + #[HasNamedArguments] + public function __construct( + public ?int $min = null, + public ?int $max = null, + public ?string $locale = null, + 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.', + 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.', + ?array $groups = null, + mixed $payload = null, + ) { + if (!class_exists(\IntlBreakIterator::class)) { + throw new \RuntimeException(\sprintf('The "%s" constraint requires the "intl" PHP extension.', __CLASS__)); + } + + if (null === $min && null === $max) { + throw new MissingOptionsException(\sprintf('Either option "min" or "max" must be given for constraint "%s".', __CLASS__), ['min', 'max']); + } + + if (null !== $min && $min < 0) { + throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the min word count to be a positive integer or 0 if set.', __CLASS__)); + } + + if (null !== $max && $max <= 0) { + throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the max word count to be a positive integer if set.', __CLASS__)); + } + + if (null !== $min && null !== $max && $min > $max) { + throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the min word count to be less than or equal to the max word count.', __CLASS__)); + } + + parent::__construct(null, $groups, $payload); + } +} diff --git a/src/Symfony/Component/Validator/Constraints/WordCountValidator.php b/src/Symfony/Component/Validator/Constraints/WordCountValidator.php new file mode 100644 index 0000000000000..ee090de2648de --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/WordCountValidator.php @@ -0,0 +1,65 @@ + + * + * 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; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +/** + * @author Alexandre Daubois + */ +final class WordCountValidator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!class_exists(\IntlBreakIterator::class)) { + throw new \RuntimeException(\sprintf('The "%s" constraint requires the "intl" PHP extension.', __CLASS__)); + } + + if (!$constraint instanceof WordCount) { + throw new UnexpectedTypeException($constraint, WordCount::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_string($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + $iterator = \IntlBreakIterator::createWordInstance($constraint->locale); + $iterator->setText($value); + $words = iterator_to_array($iterator->getPartsIterator()); + + // erase "blank words" and don't count them as words + $wordsCount = \count(array_filter(array_map(trim(...), $words))); + + if (null !== $constraint->min && $wordsCount < $constraint->min) { + $this->context->buildViolation($constraint->minMessage) + ->setParameter('{{ count }}', $wordsCount) + ->setParameter('{{ min }}', $constraint->min) + ->setPlural($constraint->min) + ->setInvalidValue($value) + ->addViolation(); + } elseif (null !== $constraint->max && $wordsCount > $constraint->max) { + $this->context->buildViolation($constraint->maxMessage) + ->setParameter('{{ count }}', $wordsCount) + ->setParameter('{{ max }}', $constraint->max) + ->setPlural($constraint->max) + ->setInvalidValue($value) + ->addViolation(); + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WordCountTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WordCountTest.php new file mode 100644 index 0000000000000..7ec911371b7f9 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/WordCountTest.php @@ -0,0 +1,125 @@ + + * + * 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\WordCount; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\MissingOptionsException; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +/** + * @requires extension intl + */ +class WordCountTest extends TestCase +{ + public function testLocaleIsSet() + { + $wordCount = new WordCount(min: 1, locale: 'en'); + + $this->assertSame('en', $wordCount->locale); + } + + public function testOnlyMinIsSet() + { + $wordCount = new WordCount(1); + + $this->assertSame(1, $wordCount->min); + $this->assertNull($wordCount->max); + $this->assertNull($wordCount->locale); + } + + public function testOnlyMaxIsSet() + { + $wordCount = new WordCount(max: 1); + + $this->assertNull($wordCount->min); + $this->assertSame(1, $wordCount->max); + $this->assertNull($wordCount->locale); + } + + public function testMinMustBeNatural() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the min word count to be a positive integer or 0 if set.'); + + new WordCount(-1); + } + + public function testMaxMustBePositive() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\WordCount" constraint requires the max word count to be a positive integer if set.'); + + new WordCount(max: 0); + } + + public function testNothingIsSet() + { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('Either option "min" or "max" must be given for constraint "Symfony\Component\Validator\Constraints\WordCount".'); + + new WordCount(); + } + + public function testMaxIsLessThanMin() + { + $this->expectException(ConstraintDefinitionException::class); + $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.'); + + new WordCount(2, 1); + } + + public function testMinAndMaxAreEquals() + { + $wordCount = new WordCount(1, 1); + + $this->assertSame(1, $wordCount->min); + $this->assertSame(1, $wordCount->max); + $this->assertNull($wordCount->locale); + } + + public function testAttributes() + { + $metadata = new ClassMetadata(WordCountDummy::class); + $loader = new AttributeLoader(); + $this->assertTrue($loader->loadClassMetadata($metadata)); + + [$aConstraint] = $metadata->properties['a']->getConstraints(); + $this->assertSame(1, $aConstraint->min); + $this->assertSame(null, $aConstraint->max); + $this->assertNull($aConstraint->locale); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + $this->assertSame(2, $bConstraint->min); + $this->assertSame(5, $bConstraint->max); + $this->assertNull($bConstraint->locale); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + $this->assertSame(3, $cConstraint->min); + $this->assertNull($cConstraint->max); + $this->assertSame('en', $cConstraint->locale); + } +} + +class WordCountDummy +{ + #[WordCount(min: 1)] + private string $a; + + #[WordCount(min: 2, max: 5)] + private string $b; + + #[WordCount(min: 3, locale: 'en')] + private string $c; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WordCountValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WordCountValidatorTest.php new file mode 100644 index 0000000000000..65f34947b43f4 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/WordCountValidatorTest.php @@ -0,0 +1,97 @@ + + * + * 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\WordCount; +use Symfony\Component\Validator\Constraints\WordCountValidator; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Symfony\Component\Validator\Tests\Constraints\Fixtures\StringableValue; + +/** + * @requires extension intl + */ +class WordCountValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): WordCountValidator + { + return new WordCountValidator(); + } + + /** + * @dataProvider provideValidValues + */ + public function testValidWordCount(string|\Stringable|null $value, int $expectedWordCount) + { + $this->validator->validate($value, new WordCount(min: $expectedWordCount, max: $expectedWordCount)); + + $this->assertNoViolation(); + } + + public function testTooShort() + { + $constraint = new WordCount(min: 4, minMessage: 'myMessage'); + $this->validator->validate('my ascii string', $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ count }}', 3) + ->setParameter('{{ min }}', 4) + ->setPlural(4) + ->setInvalidValue('my ascii string') + ->assertRaised(); + } + + public function testTooLong() + { + $constraint = new WordCount(max: 3, maxMessage: 'myMessage'); + $this->validator->validate('my beautiful ascii string', $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ count }}', 4) + ->setParameter('{{ max }}', 3) + ->setPlural(3) + ->setInvalidValue('my beautiful ascii string') + ->assertRaised(); + } + + /** + * @dataProvider provideInvalidTypes + */ + public function testNonStringValues(mixed $value) + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/Expected argument of type "string", ".*" given/'); + + $this->validator->validate($value, new WordCount(min: 1)); + } + + public static function provideValidValues() + { + yield ['my ascii string', 3]; + yield [" with a\nnewline", 3]; + yield ["皆さん、こんにちは。", 4]; + yield ["你好,世界!这是一个测试。", 9]; + yield [new StringableValue('my ûtf 8'), 3]; + yield [null, 1]; // null should always pass and eventually be handled by NotNullValidator + yield ['', 1]; // empty string should always pass and eventually be handled by NotBlankValidator + } + + public static function provideInvalidTypes() + { + yield [true]; + yield [false]; + yield [1]; + yield [1.1]; + yield [[]]; + yield [new \stdClass()]; + } +}