From 62333825cb037f4406efd56a957a74ad072ade7c Mon Sep 17 00:00:00 2001 From: Raffaele Carelle Date: Fri, 11 Oct 2024 14:24:50 +0200 Subject: [PATCH] [Validator] Add `Slug` constraint --- src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Component/Validator/Constraints/Slug.php | 41 +++++++ .../Validator/Constraints/SlugValidator.php | 47 ++++++++ .../Validator/Tests/Constraints/SlugTest.php | 47 ++++++++ .../Tests/Constraints/SlugValidatorTest.php | 106 ++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 src/Symfony/Component/Validator/Constraints/Slug.php create mode 100644 src/Symfony/Component/Validator/Constraints/SlugValidator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/SlugTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/SlugValidatorTest.php diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index b5e79134e98a9..70468d4d3fdbf 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support for ratio checks for SVG files to the `Image` constraint + * Add the `Slug` constraint 7.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/Slug.php b/src/Symfony/Component/Validator/Constraints/Slug.php new file mode 100644 index 0000000000000..68dcf9925e14a --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Slug.php @@ -0,0 +1,41 @@ + + * + * 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; + +/** + * Validates that a value is a valid slug. + * + * @author Raffaele Carelle + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Slug extends Constraint +{ + public const NOT_SLUG_ERROR = '14e6df1e-c8ab-4395-b6ce-04b132a3765e'; + + public string $message = 'This value is not a valid slug.'; + public string $regex = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/'; + + public function __construct( + ?array $options = null, + ?string $regex = null, + ?string $message = null, + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct($options, $groups, $payload); + + $this->message = $message ?? $this->message; + $this->regex = $regex ?? $this->regex; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/SlugValidator.php b/src/Symfony/Component/Validator/Constraints/SlugValidator.php new file mode 100644 index 0000000000000..b914cad31b466 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SlugValidator.php @@ -0,0 +1,47 @@ + + * + * 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 Raffaele Carelle + */ +class SlugValidator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Slug) { + throw new UnexpectedTypeException($constraint, Slug::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + $value = (string) $value; + + if (0 === preg_match($constraint->regex, $value)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(Slug::NOT_SLUG_ERROR) + ->addViolation(); + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SlugTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SlugTest.php new file mode 100644 index 0000000000000..a2c5b07d3f873 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SlugTest.php @@ -0,0 +1,47 @@ + + * + * 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\Slug; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +class SlugTest extends TestCase +{ + public function testAttributes() + { + $metadata = new ClassMetadata(SlugDummy::class); + $loader = new AttributeLoader(); + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + self::assertSame('myMessage', $bConstraint->message); + self::assertSame(['Default', 'SlugDummy'], $bConstraint->groups); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + self::assertSame(['my_group'], $cConstraint->groups); + self::assertSame('some attached data', $cConstraint->payload); + } +} + +class SlugDummy +{ + #[Slug] + private $a; + + #[Slug(message: 'myMessage')] + private $b; + + #[Slug(groups: ['my_group'], payload: 'some attached data')] + private $c; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SlugValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SlugValidatorTest.php new file mode 100644 index 0000000000000..8a2270ff225a9 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SlugValidatorTest.php @@ -0,0 +1,106 @@ + + * + * 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\Slug; +use Symfony\Component\Validator\Constraints\SlugValidator; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class SlugValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): SlugValidator + { + return new SlugValidator(); + } + + public function testNullIsValid() + { + $this->validator->validate(null, new Slug()); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate('', new Slug()); + + $this->assertNoViolation(); + } + + public function testExpectsStringCompatibleType() + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(new \stdClass(), new Slug()); + } + + /** + * @testWith ["test-slug"] + * ["slug-123-test"] + * ["slug"] + */ + public function testValidSlugs($slug) + { + $this->validator->validate($slug, new Slug()); + + $this->assertNoViolation(); + } + + /** + * @testWith ["NotASlug"] + * ["Not a slug"] + * ["not-รก-slug"] + * ["not-@-slug"] + */ + public function testInvalidSlugs($slug) + { + $constraint = new Slug([ + 'message' => 'myMessage', + ]); + + $this->validator->validate($slug, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$slug.'"') + ->setCode(Slug::NOT_SLUG_ERROR) + ->assertRaised(); + } + + /** + * @testWith ["test-slug", true] + * ["slug-123-test", true] + */ + public function testCustomRegexInvalidSlugs($slug) + { + $constraint = new Slug(regex: '/^[a-z0-9]+$/i'); + + $this->validator->validate($slug, $constraint); + + $this->buildViolation($constraint->message) + ->setParameter('{{ value }}', '"'.$slug.'"') + ->setCode(Slug::NOT_SLUG_ERROR) + ->assertRaised(); + } + + /** + * @testWith ["slug"] + * @testWith ["test1234"] + */ + public function testCustomRegexValidSlugs($slug) + { + $constraint = new Slug(regex: '/^[a-z0-9]+$/i'); + + $this->validator->validate($slug, $constraint); + + $this->assertNoViolation(); + } +}