diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index e358e8f23299a..ec3eacc6bbd68 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Add `Uuid::TIME_BASED_VERSIONS` to match that a UUID being validated embeds a timestamp * Add the `pattern` parameter in violations of the `Regex` constraint * Add a `NoSuspiciousCharacters` constraint to validate a string is not a spoofing attempt + * Add the `countUnit` option to the `Length` constraint to allow counting the string length either by code points (like before, now the default setting `Length::COUNT_CODEPOINTS`), bytes (`Length::COUNT_BYTES`) or graphemes (`Length::COUNT_GRAPHEMES`) 6.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/Length.php b/src/Symfony/Component/Validator/Constraints/Length.php index a25bf520ea608..59360ace13fe1 100644 --- a/src/Symfony/Component/Validator/Constraints/Length.php +++ b/src/Symfony/Component/Validator/Constraints/Length.php @@ -36,6 +36,16 @@ class Length extends Constraint self::INVALID_CHARACTERS_ERROR => 'INVALID_CHARACTERS_ERROR', ]; + public const COUNT_BYTES = 'bytes'; + public const COUNT_CODEPOINTS = 'codepoints'; + public const COUNT_GRAPHEMES = 'graphemes'; + + private const VALID_COUNT_UNITS = [ + self::COUNT_BYTES, + self::COUNT_CODEPOINTS, + self::COUNT_GRAPHEMES, + ]; + /** * @deprecated since Symfony 6.1, use const ERROR_NAMES instead */ @@ -49,13 +59,19 @@ class Length extends Constraint public $min; public $charset = 'UTF-8'; public $normalizer; + /* @var self::COUNT_* */ + public string $countUnit = self::COUNT_CODEPOINTS; + /** + * @param self::COUNT_*|null $countUnit + */ public function __construct( int|array $exactly = null, int $min = null, int $max = null, string $charset = null, callable $normalizer = null, + string $countUnit = null, string $exactMessage = null, string $minMessage = null, string $maxMessage = null, @@ -84,6 +100,7 @@ public function __construct( $this->max = $max; $this->charset = $charset ?? $this->charset; $this->normalizer = $normalizer ?? $this->normalizer; + $this->countUnit = $countUnit ?? $this->countUnit; $this->exactMessage = $exactMessage ?? $this->exactMessage; $this->minMessage = $minMessage ?? $this->minMessage; $this->maxMessage = $maxMessage ?? $this->maxMessage; @@ -96,5 +113,9 @@ public function __construct( if (null !== $this->normalizer && !\is_callable($this->normalizer)) { throw new InvalidArgumentException(sprintf('The "normalizer" option must be a valid callable ("%s" given).', get_debug_type($this->normalizer))); } + + if (!\in_array($this->countUnit, self::VALID_COUNT_UNITS)) { + throw new InvalidArgumentException(sprintf('The "countUnit" option must be one of the "%s"::COUNT_* constants ("%s" given).', __CLASS__, $this->countUnit)); + } } } diff --git a/src/Symfony/Component/Validator/Constraints/LengthValidator.php b/src/Symfony/Component/Validator/Constraints/LengthValidator.php index 98044c7c532a2..f70adf1cba13c 100644 --- a/src/Symfony/Component/Validator/Constraints/LengthValidator.php +++ b/src/Symfony/Component/Validator/Constraints/LengthValidator.php @@ -54,7 +54,13 @@ public function validate(mixed $value, Constraint $constraint) $invalidCharset = true; } - if ($invalidCharset) { + $length = $invalidCharset ? 0 : match ($constraint->countUnit) { + Length::COUNT_BYTES => \strlen($stringValue), + Length::COUNT_CODEPOINTS => mb_strlen($stringValue, $constraint->charset), + Length::COUNT_GRAPHEMES => grapheme_strlen($stringValue), + }; + + if ($invalidCharset || false === ($length ?? false)) { $this->context->buildViolation($constraint->charsetMessage) ->setParameter('{{ value }}', $this->formatValue($stringValue)) ->setParameter('{{ charset }}', $constraint->charset) @@ -65,8 +71,6 @@ public function validate(mixed $value, Constraint $constraint) return; } - $length = mb_strlen($stringValue, $constraint->charset); - if (null !== $constraint->max && $length > $constraint->max) { $exactlyOptionEnabled = $constraint->min == $constraint->max; diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php index 119ea3aa676f3..c3b8606c3d994 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LengthTest.php @@ -43,6 +43,25 @@ public function testInvalidNormalizerObjectThrowsException() new Length(['min' => 0, 'max' => 10, 'normalizer' => new \stdClass()]); } + public function testDefaultCountUnitIsUsed() + { + $length = new Length(['min' => 0, 'max' => 10]); + $this->assertSame(Length::COUNT_CODEPOINTS, $length->countUnit); + } + + public function testNonDefaultCountUnitCanBeSet() + { + $length = new Length(['min' => 0, 'max' => 10, 'countUnit' => Length::COUNT_GRAPHEMES]); + $this->assertSame(Length::COUNT_GRAPHEMES, $length->countUnit); + } + + public function testInvalidCountUnitThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('The "countUnit" option must be one of the "%s"::COUNT_* constants ("%s" given).', Length::class, 'nonExistentCountUnit')); + new Length(['min' => 0, 'max' => 10, 'countUnit' => 'nonExistentCountUnit']); + } + public function testConstraintDefaultOption() { $constraint = new Length(5); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/LengthValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/LengthValidatorTest.php index a1fdbf69b15f5..5dbe7523b74f9 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/LengthValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/LengthValidatorTest.php @@ -18,6 +18,9 @@ class LengthValidatorTest extends ConstraintValidatorTestCase { + // 🧚‍♀️ "Woman Fairy" emoji ZWJ sequence + private const SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES = "\u{1F9DA}\u{200D}\u{2640}\u{FE0F}"; + protected function createValidator(): LengthValidator { return new LengthValidator(); @@ -156,6 +159,30 @@ public function testValidNormalizedValues($value) $this->assertNoViolation(); } + public function testValidGraphemesValues() + { + $constraint = new Length(min: 1, max: 1, countUnit: Length::COUNT_GRAPHEMES); + $this->validator->validate(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES, $constraint); + + $this->assertNoViolation(); + } + + public function testValidCodepointsValues() + { + $constraint = new Length(min: 4, max: 4, countUnit: Length::COUNT_CODEPOINTS); + $this->validator->validate(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES, $constraint); + + $this->assertNoViolation(); + } + + public function testValidBytesValues() + { + $constraint = new Length(min: 13, max: 13, countUnit: Length::COUNT_BYTES); + $this->validator->validate(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES, $constraint); + + $this->assertNoViolation(); + } + /** * @dataProvider getThreeOrLessCharacters */ @@ -321,4 +348,34 @@ public function testOneCharset($value, $charset, $isValid) ->assertRaised(); } } + + public function testInvalidValuesExactDefaultCountUnitWithGraphemeInput() + { + $constraint = new Length(min: 1, max: 1, exactMessage: 'myMessage'); + + $this->validator->validate(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES.'"') + ->setParameter('{{ limit }}', 1) + ->setInvalidValue(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES) + ->setPlural(1) + ->setCode(Length::NOT_EQUAL_LENGTH_ERROR) + ->assertRaised(); + } + + public function testInvalidValuesExactBytesCountUnitWithGraphemeInput() + { + $constraint = new Length(min: 1, max: 1, countUnit: Length::COUNT_BYTES, exactMessage: 'myMessage'); + + $this->validator->validate(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES.'"') + ->setParameter('{{ limit }}', 1) + ->setInvalidValue(self::SINGLE_GRAPHEME_WITH_FOUR_CODEPOINTS_AND_THIRTEEN_BYTES) + ->setPlural(1) + ->setCode(Length::NOT_EQUAL_LENGTH_ERROR) + ->assertRaised(); + } }