From 65c0b3ab5fd44fa461482cb4533bf060ab42ae1d Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Mon, 30 Jun 2025 22:41:44 +0200 Subject: [PATCH 01/24] [Validator] Add SemVer constraint for semantic versioning validation Add a new constraint to validate semantic versioning strings according to the SemVer 2.0.0 specification. Supports partial versions (e.g., "3", "3.1"), full versions (e.g., "3.1.2"), optional "v" prefix, pre-release versions (e.g., "3.1.2-beta"), and build metadata (e.g., "3.1.2+20130313144700"). Configurable options: - requirePrefix: Enforce the "v" prefix - allowPreRelease: Allow pre-release versions (default: true) - allowBuildMetadata: Allow build metadata (default: true) --- .../Validator/Constraints/SemVer.php | 55 +++++ .../Validator/Constraints/SemVerValidator.php | 91 +++++++ .../Tests/Constraints/SemVerValidatorTest.php | 222 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 src/Symfony/Component/Validator/Constraints/SemVer.php create mode 100644 src/Symfony/Component/Validator/Constraints/SemVerValidator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php new file mode 100644 index 000000000000..55ecb8b2a355 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -0,0 +1,55 @@ + + * + * 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 semantic version. + * + * @author Oskar Stark + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class SemVer extends Constraint +{ + public const INVALID_SEMVER_ERROR = '3e7a8b8f-4d8f-4c7a-b5e9-1a2b3c4d5e6f'; + + protected const ERROR_NAMES = [ + self::INVALID_SEMVER_ERROR => 'INVALID_SEMVER_ERROR', + ]; + + public string $message = 'This value is not a valid semantic version.'; + public bool $requirePrefix = false; + public bool $allowPreRelease = true; + public bool $allowBuildMetadata = true; + + /** + * @param array|null $options + * @param string[]|null $groups + */ + public function __construct( + ?array $options = null, + ?string $message = null, + ?bool $requirePrefix = null, + ?bool $allowPreRelease = null, + ?bool $allowBuildMetadata = null, + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct($options, $groups, $payload); + + $this->message = $message ?? $this->message; + $this->requirePrefix = $requirePrefix ?? $this->requirePrefix; + $this->allowPreRelease = $allowPreRelease ?? $this->allowPreRelease; + $this->allowBuildMetadata = $allowBuildMetadata ?? $this->allowBuildMetadata; + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php new file mode 100644 index 000000000000..4d857eaea379 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -0,0 +1,91 @@ + + * + * 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 Oskar Stark + */ +class SemVerValidator extends ConstraintValidator +{ + /** + * Semantic Versioning 2.0.0 regex pattern. + * Supports: 1.0.0, 1.2.3, 1.2.3-alpha, 1.2.3-alpha.1, 1.2.3+20130313144700, 1.2.3-beta+exp.sha.5114f85 + * With optional "v" prefix: v1.0.0, v1.2.3-alpha + */ + private const SEMVER_PATTERN = '/^(?Pv)?(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/'; + + /** + * Loose semantic versioning pattern that allows partial versions. + * Supports: 1, 1.2, 1.2.3, v1, v1.2, v1.2.3, plus all the variations above + */ + private const LOOSE_SEMVER_PATTERN = '/^(?Pv)?(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?)?(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/'; + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof SemVer) { + throw new UnexpectedTypeException($constraint, SemVer::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_string($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + $value = (string) $value; + + // Use loose pattern by default to allow partial versions + if (!preg_match(self::LOOSE_SEMVER_PATTERN, $value, $matches)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->addViolation(); + + return; + } + + // Check prefix requirement + if ($constraint->requirePrefix && empty($matches['prefix'])) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->addViolation(); + + return; + } + + // Check pre-release + if (!$constraint->allowPreRelease && !empty($matches['prerelease'])) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->addViolation(); + + return; + } + + // Check build metadata + if (!$constraint->allowBuildMetadata && !empty($matches['buildmetadata'])) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->addViolation(); + } + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php new file mode 100644 index 000000000000..254315b23b54 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -0,0 +1,222 @@ + + * + * 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\SemVer; +use Symfony\Component\Validator\Constraints\SemVerValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class SemVerValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): SemVerValidator + { + return new SemVerValidator(); + } + + public function testNullIsValid() + { + $this->validator->validate(null, new SemVer()); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate('', new SemVer()); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidSemVersions + */ + public function testValidSemVersions(string $version) + { + $this->validator->validate($version, new SemVer()); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidSemVersionsWithPrefix + */ + public function testValidSemVersionsWithPrefix(string $version) + { + $this->validator->validate($version, new SemVer(requirePrefix: true)); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getInvalidSemVersions + */ + public function testInvalidSemVersions(string $version) + { + $constraint = new SemVer(message: 'myMessage'); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getInvalidSemVersionsWithoutPrefix + */ + public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) + { + $constraint = new SemVer(requirePrefix: true, message: 'myMessage'); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getSemVersionsWithPreRelease + */ + public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) + { + $constraint = new SemVer(allowPreRelease: false, message: 'myMessage'); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider getSemVersionsWithBuildMetadata + */ + public function testDisallowBuildMetadataRejectsBuildMetadataVersions(string $version) + { + $constraint = new SemVer(allowBuildMetadata: false, message: 'myMessage'); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->assertRaised(); + } + + public static function getValidSemVersions(): iterable + { + // Full versions + yield ['0.0.0']; + yield ['1.0.0']; + yield ['1.2.3']; + yield ['10.20.30']; + + // Partial versions + yield ['1']; + yield ['1.2']; + yield ['10.20']; + + // With prefix + yield ['v1.0.0']; + yield ['v1.2.3']; + yield ['v1']; + yield ['v1.2']; + + // With pre-release + yield ['1.0.0-alpha']; + yield ['1.0.0-alpha.1']; + yield ['1.0.0-0.3.7']; + yield ['1.0.0-x.7.z.92']; + yield ['1.0.0-alpha+001']; + yield ['1.0.0+20130313144700']; + yield ['1.0.0-beta+exp.sha.5114f85']; + yield ['1.0.0+21AF26D3----117B344092BD']; + + // Complex examples + yield ['1.2.3-alpha.1.2+build.123']; + yield ['v1.2.3-rc.1+build.123']; + } + + public static function getValidSemVersionsWithPrefix(): iterable + { + yield ['v1.0.0']; + yield ['v1.2.3']; + yield ['v1']; + yield ['v1.2']; + yield ['v1.0.0-alpha']; + yield ['v1.0.0-alpha.1']; + yield ['v1.0.0+20130313144700']; + yield ['v1.0.0-beta+exp.sha.5114f85']; + } + + public static function getInvalidSemVersions(): iterable + { + yield ['']; + yield ['v']; + yield ['1.2.3.4']; + yield ['01.2.3']; + yield ['1.02.3']; + yield ['1.2.03']; + yield ['1.2-alpha']; + yield ['1.2.3-']; + yield ['1.2.3-+']; + yield ['1.2.3-+123']; + yield ['1.2.3-']; + yield ['+invalid']; + yield ['-invalid']; + yield ['-invalid+invalid']; + yield ['-invalid.01']; + yield ['alpha']; + yield ['1.2.3.DEV']; + yield ['1.2-SNAPSHOT']; + yield ['1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788']; + yield ['1.2-RC-SNAPSHOT']; + yield ['1.0.0+']; + yield ['1.0.0-']; + } + + public static function getInvalidSemVersionsWithoutPrefix(): iterable + { + yield ['1.0.0']; + yield ['1.2.3']; + yield ['1']; + yield ['1.2']; + yield ['1.0.0-alpha']; + yield ['1.0.0+20130313144700']; + } + + public static function getSemVersionsWithPreRelease(): iterable + { + yield ['1.0.0-alpha']; + yield ['1.0.0-alpha.1']; + yield ['1.0.0-0.3.7']; + yield ['1.0.0-x.7.z.92']; + yield ['1.0.0-alpha+001']; + yield ['1.0.0-beta+exp.sha.5114f85']; + yield ['v1.0.0-rc.1']; + yield ['v1.2.3-alpha.1.2+build.123']; + } + + public static function getSemVersionsWithBuildMetadata(): iterable + { + yield ['1.0.0+20130313144700']; + yield ['1.0.0-alpha+001']; + yield ['1.0.0-beta+exp.sha.5114f85']; + yield ['1.0.0+21AF26D3----117B344092BD']; + yield ['v1.2.3-alpha.1.2+build.123']; + yield ['v1.2.3+build.123']; + } +} \ No newline at end of file From 07e3d3153ffa655292613a054bfba0b388119326 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Mon, 30 Jun 2025 22:46:24 +0200 Subject: [PATCH 02/24] Add newlines at EOF and improve code formatting - Add missing newlines at end of files - Break up long regex patterns with inline comments for better readability - Convert PHPDoc @dataProvider annotations to PHP 8 attributes --- .../Validator/Constraints/SemVer.php | 2 +- .../Validator/Constraints/SemVerValidator.php | 22 +++++++++++++--- .../Tests/Constraints/SemVerValidatorTest.php | 26 +++++-------------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index 55ecb8b2a355..b7b9aa432d04 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -52,4 +52,4 @@ public function __construct( $this->allowPreRelease = $allowPreRelease ?? $this->allowPreRelease; $this->allowBuildMetadata = $allowBuildMetadata ?? $this->allowBuildMetadata; } -} \ No newline at end of file +} diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 4d857eaea379..70fb13aa1bbd 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -26,13 +26,29 @@ class SemVerValidator extends ConstraintValidator * Supports: 1.0.0, 1.2.3, 1.2.3-alpha, 1.2.3-alpha.1, 1.2.3+20130313144700, 1.2.3-beta+exp.sha.5114f85 * With optional "v" prefix: v1.0.0, v1.2.3-alpha */ - private const SEMVER_PATTERN = '/^(?Pv)?(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/'; + private const SEMVER_PATTERN = '/^' + .'(?Pv)?' // Optional "v" prefix + .'(?P0|[1-9]\d*)' // Major version + .'\.(?P0|[1-9]\d*)' // Minor version + .'\.(?P0|[1-9]\d*)' // Patch version + .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version + .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments + .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata + .'$/'; /** * Loose semantic versioning pattern that allows partial versions. * Supports: 1, 1.2, 1.2.3, v1, v1.2, v1.2.3, plus all the variations above */ - private const LOOSE_SEMVER_PATTERN = '/^(?Pv)?(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?)?(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/'; + private const LOOSE_SEMVER_PATTERN = '/^' + .'(?Pv)?' // Optional "v" prefix + .'(?P0|[1-9]\d*)' // Major version (required) + .'(?:\.(?P0|[1-9]\d*)' // Minor version (optional) + .'(?:\.(?P0|[1-9]\d*))?)?' // Patch version (optional) + .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version + .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments + .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata + .'$/'; public function validate(mixed $value, Constraint $constraint): void { @@ -88,4 +104,4 @@ public function validate(mixed $value, Constraint $constraint): void ->addViolation(); } } -} \ No newline at end of file +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 254315b23b54..04a1beb4e621 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -36,9 +36,7 @@ public function testEmptyStringIsValid() $this->assertNoViolation(); } - /** - * @dataProvider getValidSemVersions - */ + #[\PHPUnit\Framework\Attributes\DataProvider('getValidSemVersions')] public function testValidSemVersions(string $version) { $this->validator->validate($version, new SemVer()); @@ -46,9 +44,7 @@ public function testValidSemVersions(string $version) $this->assertNoViolation(); } - /** - * @dataProvider getValidSemVersionsWithPrefix - */ + #[\PHPUnit\Framework\Attributes\DataProvider('getValidSemVersionsWithPrefix')] public function testValidSemVersionsWithPrefix(string $version) { $this->validator->validate($version, new SemVer(requirePrefix: true)); @@ -56,9 +52,7 @@ public function testValidSemVersionsWithPrefix(string $version) $this->assertNoViolation(); } - /** - * @dataProvider getInvalidSemVersions - */ + #[\PHPUnit\Framework\Attributes\DataProvider('getInvalidSemVersions')] public function testInvalidSemVersions(string $version) { $constraint = new SemVer(message: 'myMessage'); @@ -71,9 +65,7 @@ public function testInvalidSemVersions(string $version) ->assertRaised(); } - /** - * @dataProvider getInvalidSemVersionsWithoutPrefix - */ + #[\PHPUnit\Framework\Attributes\DataProvider('getInvalidSemVersionsWithoutPrefix')] public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) { $constraint = new SemVer(requirePrefix: true, message: 'myMessage'); @@ -86,9 +78,7 @@ public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) ->assertRaised(); } - /** - * @dataProvider getSemVersionsWithPreRelease - */ + #[\PHPUnit\Framework\Attributes\DataProvider('getSemVersionsWithPreRelease')] public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) { $constraint = new SemVer(allowPreRelease: false, message: 'myMessage'); @@ -101,9 +91,7 @@ public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) ->assertRaised(); } - /** - * @dataProvider getSemVersionsWithBuildMetadata - */ + #[\PHPUnit\Framework\Attributes\DataProvider('getSemVersionsWithBuildMetadata')] public function testDisallowBuildMetadataRejectsBuildMetadataVersions(string $version) { $constraint = new SemVer(allowBuildMetadata: false, message: 'myMessage'); @@ -219,4 +207,4 @@ public static function getSemVersionsWithBuildMetadata(): iterable yield ['v1.2.3-alpha.1.2+build.123']; yield ['v1.2.3+build.123']; } -} \ No newline at end of file +} From 09fd2cd353ccbb5c2db57b1dee92ce298280948c Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Mon, 30 Jun 2025 22:49:17 +0200 Subject: [PATCH 03/24] Import DataProvider attribute and use short syntax --- .../Tests/Constraints/SemVerValidatorTest.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 04a1beb4e621..a122890c9ca0 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Validator\Tests\Constraints; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Validator\Constraints\SemVer; use Symfony\Component\Validator\Constraints\SemVerValidator; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; @@ -36,7 +37,7 @@ public function testEmptyStringIsValid() $this->assertNoViolation(); } - #[\PHPUnit\Framework\Attributes\DataProvider('getValidSemVersions')] + #[DataProvider('getValidSemVersions')] public function testValidSemVersions(string $version) { $this->validator->validate($version, new SemVer()); @@ -44,7 +45,7 @@ public function testValidSemVersions(string $version) $this->assertNoViolation(); } - #[\PHPUnit\Framework\Attributes\DataProvider('getValidSemVersionsWithPrefix')] + #[DataProvider('getValidSemVersionsWithPrefix')] public function testValidSemVersionsWithPrefix(string $version) { $this->validator->validate($version, new SemVer(requirePrefix: true)); @@ -52,7 +53,7 @@ public function testValidSemVersionsWithPrefix(string $version) $this->assertNoViolation(); } - #[\PHPUnit\Framework\Attributes\DataProvider('getInvalidSemVersions')] + #[DataProvider('getInvalidSemVersions')] public function testInvalidSemVersions(string $version) { $constraint = new SemVer(message: 'myMessage'); @@ -65,7 +66,7 @@ public function testInvalidSemVersions(string $version) ->assertRaised(); } - #[\PHPUnit\Framework\Attributes\DataProvider('getInvalidSemVersionsWithoutPrefix')] + #[DataProvider('getInvalidSemVersionsWithoutPrefix')] public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) { $constraint = new SemVer(requirePrefix: true, message: 'myMessage'); @@ -78,7 +79,7 @@ public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) ->assertRaised(); } - #[\PHPUnit\Framework\Attributes\DataProvider('getSemVersionsWithPreRelease')] + #[DataProvider('getSemVersionsWithPreRelease')] public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) { $constraint = new SemVer(allowPreRelease: false, message: 'myMessage'); @@ -91,7 +92,7 @@ public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) ->assertRaised(); } - #[\PHPUnit\Framework\Attributes\DataProvider('getSemVersionsWithBuildMetadata')] + #[DataProvider('getSemVersionsWithBuildMetadata')] public function testDisallowBuildMetadataRejectsBuildMetadataVersions(string $version) { $constraint = new SemVer(allowBuildMetadata: false, message: 'myMessage'); From 61e946abf1633e7e267ccc485485dacfd74e0a22 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Mon, 30 Jun 2025 22:49:59 +0200 Subject: [PATCH 04/24] fix --- .../Validator/Constraints/SemVerValidator.php | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 70fb13aa1bbd..7dfd003106de 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -27,13 +27,13 @@ class SemVerValidator extends ConstraintValidator * With optional "v" prefix: v1.0.0, v1.2.3-alpha */ private const SEMVER_PATTERN = '/^' - .'(?Pv)?' // Optional "v" prefix - .'(?P0|[1-9]\d*)' // Major version - .'\.(?P0|[1-9]\d*)' // Minor version - .'\.(?P0|[1-9]\d*)' // Patch version - .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version - .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments - .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata + .'(?Pv)?' // Optional "v" prefix + .'(?P0|[1-9]\d*)' // Major version + .'\.(?P0|[1-9]\d*)' // Minor version + .'\.(?P0|[1-9]\d*)' // Patch version + .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version + .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments + .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata .'$/'; /** @@ -41,13 +41,13 @@ class SemVerValidator extends ConstraintValidator * Supports: 1, 1.2, 1.2.3, v1, v1.2, v1.2.3, plus all the variations above */ private const LOOSE_SEMVER_PATTERN = '/^' - .'(?Pv)?' // Optional "v" prefix - .'(?P0|[1-9]\d*)' // Major version (required) - .'(?:\.(?P0|[1-9]\d*)' // Minor version (optional) - .'(?:\.(?P0|[1-9]\d*))?)?' // Patch version (optional) - .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version - .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments - .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata + .'(?Pv)?' // Optional "v" prefix + .'(?P0|[1-9]\d*)' // Major version (required) + .'(?:\.(?P0|[1-9]\d*)' // Minor version (optional) + .'(?:\.(?P0|[1-9]\d*))?)?' // Patch version (optional) + .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version + .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments + .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata .'$/'; public function validate(mixed $value, Constraint $constraint): void From 6c9c41bb50421d15d80ed92137a8871f448d95bd Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Mon, 30 Jun 2025 22:50:50 +0200 Subject: [PATCH 05/24] - --- .../Tests/Constraints/SemVerValidatorTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index a122890c9ca0..9e89c09d6e5e 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\SemVerValidator; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; -class SemVerValidatorTest extends ConstraintValidatorTestCase +final class SemVerValidatorTest extends ConstraintValidatorTestCase { protected function createValidator(): SemVerValidator { @@ -112,18 +112,18 @@ public static function getValidSemVersions(): iterable yield ['1.0.0']; yield ['1.2.3']; yield ['10.20.30']; - + // Partial versions yield ['1']; yield ['1.2']; yield ['10.20']; - + // With prefix yield ['v1.0.0']; yield ['v1.2.3']; yield ['v1']; yield ['v1.2']; - + // With pre-release yield ['1.0.0-alpha']; yield ['1.0.0-alpha.1']; @@ -133,7 +133,7 @@ public static function getValidSemVersions(): iterable yield ['1.0.0+20130313144700']; yield ['1.0.0-beta+exp.sha.5114f85']; yield ['1.0.0+21AF26D3----117B344092BD']; - + // Complex examples yield ['1.2.3-alpha.1.2+build.123']; yield ['v1.2.3-rc.1+build.123']; From 7839cfd1e0fb373da6f5fcd2b18d9f909b57997e Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Mon, 30 Jun 2025 22:56:53 +0200 Subject: [PATCH 06/24] Fix SemVer validation pattern and tests - Fix LOOSE_SEMVER_PATTERN to only allow pre-release and build metadata with full version (major.minor.patch) - Remove empty string from invalid test cases as it's handled separately - Revert to PHPDoc @dataProvider annotations for PHPUnit 9.6 compatibility --- .../Validator/Constraints/SemVerValidator.php | 14 ++++++---- .../Tests/Constraints/SemVerValidatorTest.php | 26 +++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 7dfd003106de..2d9f83128189 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -43,11 +43,15 @@ class SemVerValidator extends ConstraintValidator private const LOOSE_SEMVER_PATTERN = '/^' .'(?Pv)?' // Optional "v" prefix .'(?P0|[1-9]\d*)' // Major version (required) - .'(?:\.(?P0|[1-9]\d*)' // Minor version (optional) - .'(?:\.(?P0|[1-9]\d*))?)?' // Patch version (optional) - .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version - .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments - .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata + .'(?:' + .'\.(?P0|[1-9]\d*)' // Minor version + .'(?:' + .'\.(?P0|[1-9]\d*)' // Patch version + .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release (only with full version) + .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments + .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata (only with full version) + .')?' + .')?' .'$/'; public function validate(mixed $value, Constraint $constraint): void diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 9e89c09d6e5e..5fef44f03580 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Validator\Tests\Constraints; -use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Validator\Constraints\SemVer; use Symfony\Component\Validator\Constraints\SemVerValidator; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; @@ -37,7 +36,9 @@ public function testEmptyStringIsValid() $this->assertNoViolation(); } - #[DataProvider('getValidSemVersions')] + /** + * @dataProvider getValidSemVersions + */ public function testValidSemVersions(string $version) { $this->validator->validate($version, new SemVer()); @@ -45,7 +46,9 @@ public function testValidSemVersions(string $version) $this->assertNoViolation(); } - #[DataProvider('getValidSemVersionsWithPrefix')] + /** + * @dataProvider getValidSemVersionsWithPrefix + */ public function testValidSemVersionsWithPrefix(string $version) { $this->validator->validate($version, new SemVer(requirePrefix: true)); @@ -53,7 +56,9 @@ public function testValidSemVersionsWithPrefix(string $version) $this->assertNoViolation(); } - #[DataProvider('getInvalidSemVersions')] + /** + * @dataProvider getInvalidSemVersions + */ public function testInvalidSemVersions(string $version) { $constraint = new SemVer(message: 'myMessage'); @@ -66,7 +71,9 @@ public function testInvalidSemVersions(string $version) ->assertRaised(); } - #[DataProvider('getInvalidSemVersionsWithoutPrefix')] + /** + * @dataProvider getInvalidSemVersionsWithoutPrefix + */ public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) { $constraint = new SemVer(requirePrefix: true, message: 'myMessage'); @@ -79,7 +86,9 @@ public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) ->assertRaised(); } - #[DataProvider('getSemVersionsWithPreRelease')] + /** + * @dataProvider getSemVersionsWithPreRelease + */ public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) { $constraint = new SemVer(allowPreRelease: false, message: 'myMessage'); @@ -92,7 +101,9 @@ public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) ->assertRaised(); } - #[DataProvider('getSemVersionsWithBuildMetadata')] + /** + * @dataProvider getSemVersionsWithBuildMetadata + */ public function testDisallowBuildMetadataRejectsBuildMetadataVersions(string $version) { $constraint = new SemVer(allowBuildMetadata: false, message: 'myMessage'); @@ -153,7 +164,6 @@ public static function getValidSemVersionsWithPrefix(): iterable public static function getInvalidSemVersions(): iterable { - yield ['']; yield ['v']; yield ['1.2.3.4']; yield ['01.2.3']; From 26e250180ec3862ad24a2183806052e120951722 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 09:45:50 +0200 Subject: [PATCH 07/24] Simplify SemVer constraint to use single 'strict' option - Replace requirePrefix, allowPreRelease, and allowBuildMetadata with a single 'strict' boolean option - When strict=true: follows official SemVer spec (no 'v' prefix, requires full version) - When strict=false: allows common variations (partial versions, 'v' prefix) - Update tests to reflect the new behavior --- .../Validator/Constraints/SemVer.php | 12 +- .../Validator/Constraints/SemVerValidator.php | 43 ++----- .../Tests/Constraints/SemVerValidatorTest.php | 114 ++++++------------ 3 files changed, 50 insertions(+), 119 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index b7b9aa432d04..dfca6077815e 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -28,9 +28,7 @@ class SemVer extends Constraint ]; public string $message = 'This value is not a valid semantic version.'; - public bool $requirePrefix = false; - public bool $allowPreRelease = true; - public bool $allowBuildMetadata = true; + public bool $strict = false; /** * @param array|null $options @@ -39,17 +37,13 @@ class SemVer extends Constraint public function __construct( ?array $options = null, ?string $message = null, - ?bool $requirePrefix = null, - ?bool $allowPreRelease = null, - ?bool $allowBuildMetadata = null, + ?bool $strict = null, ?array $groups = null, mixed $payload = null, ) { parent::__construct($options, $groups, $payload); $this->message = $message ?? $this->message; - $this->requirePrefix = $requirePrefix ?? $this->requirePrefix; - $this->allowPreRelease = $allowPreRelease ?? $this->allowPreRelease; - $this->allowBuildMetadata = $allowBuildMetadata ?? $this->allowBuildMetadata; + $this->strict = $strict ?? $this->strict; } } diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 2d9f83128189..aadad2fb6653 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -22,12 +22,11 @@ class SemVerValidator extends ConstraintValidator { /** - * Semantic Versioning 2.0.0 regex pattern. + * Strict Semantic Versioning 2.0.0 regex pattern. + * According to https://semver.org, no "v" prefix allowed * Supports: 1.0.0, 1.2.3, 1.2.3-alpha, 1.2.3-alpha.1, 1.2.3+20130313144700, 1.2.3-beta+exp.sha.5114f85 - * With optional "v" prefix: v1.0.0, v1.2.3-alpha */ - private const SEMVER_PATTERN = '/^' - .'(?Pv)?' // Optional "v" prefix + private const STRICT_SEMVER_PATTERN = '/^' .'(?P0|[1-9]\d*)' // Major version .'\.(?P0|[1-9]\d*)' // Minor version .'\.(?P0|[1-9]\d*)' // Patch version @@ -70,38 +69,10 @@ public function validate(mixed $value, Constraint $constraint): void $value = (string) $value; - // Use loose pattern by default to allow partial versions - if (!preg_match(self::LOOSE_SEMVER_PATTERN, $value, $matches)) { - $this->context->buildViolation($constraint->message) - ->setParameter('{{ value }}', $this->formatValue($value)) - ->setCode(SemVer::INVALID_SEMVER_ERROR) - ->addViolation(); - - return; - } - - // Check prefix requirement - if ($constraint->requirePrefix && empty($matches['prefix'])) { - $this->context->buildViolation($constraint->message) - ->setParameter('{{ value }}', $this->formatValue($value)) - ->setCode(SemVer::INVALID_SEMVER_ERROR) - ->addViolation(); - - return; - } - - // Check pre-release - if (!$constraint->allowPreRelease && !empty($matches['prerelease'])) { - $this->context->buildViolation($constraint->message) - ->setParameter('{{ value }}', $this->formatValue($value)) - ->setCode(SemVer::INVALID_SEMVER_ERROR) - ->addViolation(); - - return; - } - - // Check build metadata - if (!$constraint->allowBuildMetadata && !empty($matches['buildmetadata'])) { + // Use strict pattern (official SemVer spec) or loose pattern (common variations) + $pattern = $constraint->strict ? self::STRICT_SEMVER_PATTERN : self::LOOSE_SEMVER_PATTERN; + + if (!preg_match($pattern, $value)) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $this->formatValue($value)) ->setCode(SemVer::INVALID_SEMVER_ERROR) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 5fef44f03580..66d94b7085ce 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -37,9 +37,9 @@ public function testEmptyStringIsValid() } /** - * @dataProvider getValidSemVersions + * @dataProvider getValidLooseSemVersions */ - public function testValidSemVersions(string $version) + public function testValidLooseSemVersions(string $version) { $this->validator->validate($version, new SemVer()); @@ -47,11 +47,11 @@ public function testValidSemVersions(string $version) } /** - * @dataProvider getValidSemVersionsWithPrefix + * @dataProvider getValidStrictSemVersions */ - public function testValidSemVersionsWithPrefix(string $version) + public function testValidStrictSemVersions(string $version) { - $this->validator->validate($version, new SemVer(requirePrefix: true)); + $this->validator->validate($version, new SemVer(strict: true)); $this->assertNoViolation(); } @@ -72,11 +72,11 @@ public function testInvalidSemVersions(string $version) } /** - * @dataProvider getInvalidSemVersionsWithoutPrefix + * @dataProvider getInvalidStrictSemVersions */ - public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) + public function testInvalidStrictSemVersions(string $version) { - $constraint = new SemVer(requirePrefix: true, message: 'myMessage'); + $constraint = new SemVer(strict: true, message: 'myMessage'); $this->validator->validate($version, $constraint); @@ -86,37 +86,7 @@ public function testRequirePrefixRejectsVersionsWithoutPrefix(string $version) ->assertRaised(); } - /** - * @dataProvider getSemVersionsWithPreRelease - */ - public function testDisallowPreReleaseRejectsPreReleaseVersions(string $version) - { - $constraint = new SemVer(allowPreRelease: false, message: 'myMessage'); - - $this->validator->validate($version, $constraint); - - $this->buildViolation('myMessage') - ->setParameter('{{ value }}', '"'.$version.'"') - ->setCode(SemVer::INVALID_SEMVER_ERROR) - ->assertRaised(); - } - - /** - * @dataProvider getSemVersionsWithBuildMetadata - */ - public function testDisallowBuildMetadataRejectsBuildMetadataVersions(string $version) - { - $constraint = new SemVer(allowBuildMetadata: false, message: 'myMessage'); - - $this->validator->validate($version, $constraint); - - $this->buildViolation('myMessage') - ->setParameter('{{ value }}', '"'.$version.'"') - ->setCode(SemVer::INVALID_SEMVER_ERROR) - ->assertRaised(); - } - - public static function getValidSemVersions(): iterable + public static function getValidLooseSemVersions(): iterable { // Full versions yield ['0.0.0']; @@ -150,16 +120,28 @@ public static function getValidSemVersions(): iterable yield ['v1.2.3-rc.1+build.123']; } - public static function getValidSemVersionsWithPrefix(): iterable + public static function getValidStrictSemVersions(): iterable { - yield ['v1.0.0']; - yield ['v1.2.3']; - yield ['v1']; - yield ['v1.2']; - yield ['v1.0.0-alpha']; - yield ['v1.0.0-alpha.1']; - yield ['v1.0.0+20130313144700']; - yield ['v1.0.0-beta+exp.sha.5114f85']; + // Only valid according to official SemVer spec (no v prefix) + yield ['0.0.0']; + yield ['1.0.0']; + yield ['1.2.3']; + yield ['10.20.30']; + + // With pre-release + yield ['1.0.0-alpha']; + yield ['1.0.0-alpha.1']; + yield ['1.0.0-0.3.7']; + yield ['1.0.0-x.7.z.92']; + + // With build metadata + yield ['1.0.0+20130313144700']; + yield ['1.0.0+21AF26D3----117B344092BD']; + + // With both + yield ['1.0.0-alpha+001']; + yield ['1.0.0-beta+exp.sha.5114f85']; + yield ['1.2.3-alpha.1.2+build.123']; } public static function getInvalidSemVersions(): iterable @@ -187,35 +169,19 @@ public static function getInvalidSemVersions(): iterable yield ['1.0.0-']; } - public static function getInvalidSemVersionsWithoutPrefix(): iterable + public static function getInvalidStrictSemVersions(): iterable { - yield ['1.0.0']; - yield ['1.2.3']; + // Versions with v prefix (not allowed in strict mode) + yield ['v1.0.0']; + yield ['v1.2.3']; + yield ['v1.0.0-alpha']; + yield ['v1.0.0+20130313144700']; + + // Partial versions (not allowed in strict mode) yield ['1']; yield ['1.2']; - yield ['1.0.0-alpha']; - yield ['1.0.0+20130313144700']; - } - - public static function getSemVersionsWithPreRelease(): iterable - { - yield ['1.0.0-alpha']; - yield ['1.0.0-alpha.1']; - yield ['1.0.0-0.3.7']; - yield ['1.0.0-x.7.z.92']; - yield ['1.0.0-alpha+001']; - yield ['1.0.0-beta+exp.sha.5114f85']; - yield ['v1.0.0-rc.1']; - yield ['v1.2.3-alpha.1.2+build.123']; + yield ['v1']; + yield ['v1.2']; } - public static function getSemVersionsWithBuildMetadata(): iterable - { - yield ['1.0.0+20130313144700']; - yield ['1.0.0-alpha+001']; - yield ['1.0.0-beta+exp.sha.5114f85']; - yield ['1.0.0+21AF26D3----117B344092BD']; - yield ['v1.2.3-alpha.1.2+build.123']; - yield ['v1.2.3+build.123']; - } } From 90a91d971ebab7c8fb24ffc93b2a210b8a2d29f5 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 09:49:01 +0200 Subject: [PATCH 08/24] Update src/Symfony/Component/Validator/Constraints/SemVerValidator.php --- src/Symfony/Component/Validator/Constraints/SemVerValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index aadad2fb6653..9212c81949eb 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -46,7 +46,7 @@ class SemVerValidator extends ConstraintValidator .'\.(?P0|[1-9]\d*)' // Minor version .'(?:' .'\.(?P0|[1-9]\d*)' // Patch version - .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release (only with full version) + .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release (only with full version) .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata (only with full version) .')?' From 53bbc785dc800ecb7190c9730afbb9e99b139876 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 09:50:03 +0200 Subject: [PATCH 09/24] Change default value of strict option to true - Default behavior now follows the official SemVer specification - Users must explicitly set strict=false to allow loose validation --- src/Symfony/Component/Validator/Constraints/SemVer.php | 2 +- .../Validator/Tests/Constraints/SemVerValidatorTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index dfca6077815e..c52fe3cb55a5 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -28,7 +28,7 @@ class SemVer extends Constraint ]; public string $message = 'This value is not a valid semantic version.'; - public bool $strict = false; + public bool $strict = true; /** * @param array|null $options diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 66d94b7085ce..46c01edb5d9a 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -41,7 +41,7 @@ public function testEmptyStringIsValid() */ public function testValidLooseSemVersions(string $version) { - $this->validator->validate($version, new SemVer()); + $this->validator->validate($version, new SemVer(strict: false)); $this->assertNoViolation(); } @@ -51,7 +51,7 @@ public function testValidLooseSemVersions(string $version) */ public function testValidStrictSemVersions(string $version) { - $this->validator->validate($version, new SemVer(strict: true)); + $this->validator->validate($version, new SemVer()); $this->assertNoViolation(); } @@ -76,7 +76,7 @@ public function testInvalidSemVersions(string $version) */ public function testInvalidStrictSemVersions(string $version) { - $constraint = new SemVer(strict: true, message: 'myMessage'); + $constraint = new SemVer(message: 'myMessage'); $this->validator->validate($version, $constraint); From 7830db9a0a675b63fbea451d6eb67a6bccc2e462 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 09:53:58 +0200 Subject: [PATCH 10/24] Remove array-based configuration support from SemVer constraint - Remove off on off off off off off off off off on off on off on off off off on off off off on on off off off on on off off off off off off off on off off off off on off on off off off off off on off on on off off off on off on on off on on off off on on on off on on off on off off off off on off off off on off off on off on off off off on on off on off on off off on off off off on off off off off off off off off on off on off on on on off on on off off on off on on on off on on off on off on off off off off off on on off off on off off off off off on off off on on off on off off on off off off on off off off off on on off on off off off off off on off on off off off off off off off off off off on on off on off off off parameter from constructor as array-based configuration is deprecated in Symfony 8.0 - Only support named arguments for configuration - This follows the modern Symfony constraint pattern --- src/Symfony/Component/Validator/Constraints/SemVer.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index c52fe3cb55a5..d25e629da2cf 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -31,17 +31,15 @@ class SemVer extends Constraint public bool $strict = true; /** - * @param array|null $options * @param string[]|null $groups */ public function __construct( - ?array $options = null, ?string $message = null, ?bool $strict = null, ?array $groups = null, mixed $payload = null, ) { - parent::__construct($options, $groups, $payload); + parent::__construct(null, $groups, $payload); $this->message = $message ?? $this->message; $this->strict = $strict ?? $this->strict; From 9cbff302423c4047e4d9220c3a461a2fff36c3eb Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 09:54:32 +0200 Subject: [PATCH 11/24] Remove unnecessary comment --- src/Symfony/Component/Validator/Constraints/SemVerValidator.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 9212c81949eb..1b68c8e767b9 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -69,7 +69,6 @@ public function validate(mixed $value, Constraint $constraint): void $value = (string) $value; - // Use strict pattern (official SemVer spec) or loose pattern (common variations) $pattern = $constraint->strict ? self::STRICT_SEMVER_PATTERN : self::LOOSE_SEMVER_PATTERN; if (!preg_match($pattern, $value)) { From 642dfb1c1f8fb96b821ae7a418e4601fba5be253 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 10:11:14 +0200 Subject: [PATCH 12/24] Use x modifier for regex patterns to improve readability - Convert regex patterns to multi-line format with x modifier - Add inline comments to explain each part of the pattern - Makes complex regex patterns more maintainable --- .../Validator/Constraints/SemVerValidator.php | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 1b68c8e767b9..7ab9c3ef89fc 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -26,32 +26,52 @@ class SemVerValidator extends ConstraintValidator * According to https://semver.org, no "v" prefix allowed * Supports: 1.0.0, 1.2.3, 1.2.3-alpha, 1.2.3-alpha.1, 1.2.3+20130313144700, 1.2.3-beta+exp.sha.5114f85 */ - private const STRICT_SEMVER_PATTERN = '/^' - .'(?P0|[1-9]\d*)' // Major version - .'\.(?P0|[1-9]\d*)' // Minor version - .'\.(?P0|[1-9]\d*)' // Patch version - .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release version - .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments - .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata - .'$/'; + private const STRICT_SEMVER_PATTERN = '/^ + (?P0|[1-9]\d*) # Major version + \. + (?P0|[1-9]\d*) # Minor version + \. + (?P0|[1-9]\d*) # Patch version + (?: + - + (?P + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) # Pre-release identifier + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* # Additional dot-separated identifiers + ) + )? + (?: + \+ + (?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*) # Build metadata + )? + $/x'; /** * Loose semantic versioning pattern that allows partial versions. * Supports: 1, 1.2, 1.2.3, v1, v1.2, v1.2.3, plus all the variations above */ - private const LOOSE_SEMVER_PATTERN = '/^' - .'(?Pv)?' // Optional "v" prefix - .'(?P0|[1-9]\d*)' // Major version (required) - .'(?:' - .'\.(?P0|[1-9]\d*)' // Minor version - .'(?:' - .'\.(?P0|[1-9]\d*)' // Patch version - .'(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' // Pre-release (only with full version) - .'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' // Pre-release segments - .'(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?' // Build metadata (only with full version) - .')?' - .')?' - .'$/'; + private const LOOSE_SEMVER_PATTERN = '/^ + (?Pv)? # Optional "v" prefix + (?P0|[1-9]\d*) # Major version (required) + (?: + \. + (?P0|[1-9]\d*) # Minor version (optional) + (?: + \. + (?P0|[1-9]\d*) # Patch version (optional) + (?: + - + (?P + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) # Pre-release identifier + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* # Additional identifiers + ) + )? + (?: + \+ + (?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*) # Build metadata + )? + )? + )? + $/x'; public function validate(mixed $value, Constraint $constraint): void { From 4ce63f85f21c9f58869c5a208baafa54de461248 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 10:12:09 +0200 Subject: [PATCH 13/24] Apply stof's suggestions for better compatibility - Add HasNamedArguments attribute for XML/YAML mapping compatibility - Move property defaults to constructor arguments as non-nullable - Follow modern Symfony constraint pattern --- .../Component/Validator/Constraints/SemVer.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index d25e629da2cf..c628dd1a5c1f 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\Validator\Attribute\HasNamedArguments; use Symfony\Component\Validator\Constraint; /** @@ -27,21 +28,22 @@ class SemVer extends Constraint self::INVALID_SEMVER_ERROR => 'INVALID_SEMVER_ERROR', ]; - public string $message = 'This value is not a valid semantic version.'; - public bool $strict = true; + public string $message; + public bool $strict; /** * @param string[]|null $groups */ + #[HasNamedArguments] public function __construct( - ?string $message = null, - ?bool $strict = null, + string $message = 'This value is not a valid semantic version.', + bool $strict = true, ?array $groups = null, mixed $payload = null, ) { parent::__construct(null, $groups, $payload); - $this->message = $message ?? $this->message; - $this->strict = $strict ?? $this->strict; + $this->message = $message; + $this->strict = $strict; } } From bf43ccc0d64f6fafc356f8a1b8bebd0900b2b5ba Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Tue, 1 Jul 2025 10:28:54 +0200 Subject: [PATCH 14/24] Apply suggestions from code review --- .../Component/Validator/Constraints/SemVerValidator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 7ab9c3ef89fc..a9c3157481b3 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -36,7 +36,7 @@ class SemVerValidator extends ConstraintValidator - (?P (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) # Pre-release identifier - (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* # Additional dot-separated identifiers + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* # Additional dot-separated identifiers ) )? (?: @@ -62,7 +62,7 @@ class SemVerValidator extends ConstraintValidator - (?P (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) # Pre-release identifier - (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* # Additional identifiers + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* # Additional identifiers ) )? (?: From ebdc360dec4a2678b57d545477b89bac9831e92c Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:27:59 +0200 Subject: [PATCH 15/24] Update src/Symfony/Component/Validator/Constraints/SemVer.php Co-authored-by: Alexandre Daubois <2144837+alexandre-daubois@users.noreply.github.com> --- src/Symfony/Component/Validator/Constraints/SemVer.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index c628dd1a5c1f..a70573ac3f54 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -17,6 +17,8 @@ /** * Validates that a value is a valid semantic version. * + * @see https://semver.org + * * @author Oskar Stark */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] From a62f9f95fec7b8a4ed48f8d452f4e48e8580935f Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:33:57 +0200 Subject: [PATCH 16/24] Add min/max version constraints to SemVer validator - Add min and max parameters to SemVer constraint - Implement version comparison logic using version_compare() - Add normalizeVersion() method to handle version normalization - Add comprehensive test coverage for min/max functionality - Validate min/max parameters follow strict mode rules --- .../Validator/Constraints/SemVer.php | 16 +++ .../Validator/Constraints/SemVerValidator.php | 65 +++++++++ .../Tests/Constraints/SemVerValidatorTest.php | 133 ++++++++++++++++++ 3 files changed, 214 insertions(+) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index a70573ac3f54..561887fdb5e2 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -25,13 +25,21 @@ class SemVer extends Constraint { public const INVALID_SEMVER_ERROR = '3e7a8b8f-4d8f-4c7a-b5e9-1a2b3c4d5e6f'; + public const TOO_LOW_ERROR = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789'; + public const TOO_HIGH_ERROR = 'b1c2d3e4-f5a6-7890-bcde-f01234567890'; protected const ERROR_NAMES = [ self::INVALID_SEMVER_ERROR => 'INVALID_SEMVER_ERROR', + self::TOO_LOW_ERROR => 'TOO_LOW_ERROR', + self::TOO_HIGH_ERROR => 'TOO_HIGH_ERROR', ]; public string $message; + public string $tooLowMessage; + public string $tooHighMessage; public bool $strict; + public ?string $min; + public ?string $max; /** * @param string[]|null $groups @@ -39,13 +47,21 @@ class SemVer extends Constraint #[HasNamedArguments] public function __construct( string $message = 'This value is not a valid semantic version.', + string $tooLowMessage = 'This value should be {{ min }} or more.', + string $tooHighMessage = 'This value should be {{ max }} or less.', bool $strict = true, + ?string $min = null, + ?string $max = null, ?array $groups = null, mixed $payload = null, ) { parent::__construct(null, $groups, $payload); $this->message = $message; + $this->tooLowMessage = $tooLowMessage; + $this->tooHighMessage = $tooHighMessage; $this->strict = $strict; + $this->min = $min; + $this->max = $max; } } diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index a9c3157481b3..6053c6973de2 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -96,6 +96,71 @@ public function validate(mixed $value, Constraint $constraint): void ->setParameter('{{ value }}', $this->formatValue($value)) ->setCode(SemVer::INVALID_SEMVER_ERROR) ->addViolation(); + + return; } + + // Normalize the version for comparison (remove 'v' prefix if present) + $normalizedValue = $this->normalizeVersion($value); + + // Check min constraint + if (null !== $constraint->min) { + $normalizedMin = $this->normalizeVersion($constraint->min); + + if (!preg_match($pattern, $constraint->min)) { + throw new \InvalidArgumentException(sprintf('The "min" option value "%s" is not a valid semantic version according to the "strict" option.', $constraint->min)); + } + + if (version_compare($normalizedValue, $normalizedMin, '<')) { + $this->context->buildViolation($constraint->tooLowMessage) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setParameter('{{ min }}', $constraint->min) + ->setCode(SemVer::TOO_LOW_ERROR) + ->addViolation(); + } + } + + // Check max constraint + if (null !== $constraint->max) { + $normalizedMax = $this->normalizeVersion($constraint->max); + + if (!preg_match($pattern, $constraint->max)) { + throw new \InvalidArgumentException(sprintf('The "max" option value "%s" is not a valid semantic version according to the "strict" option.', $constraint->max)); + } + + if (version_compare($normalizedValue, $normalizedMax, '>')) { + $this->context->buildViolation($constraint->tooHighMessage) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setParameter('{{ max }}', $constraint->max) + ->setCode(SemVer::TOO_HIGH_ERROR) + ->addViolation(); + } + } + } + + /** + * Normalizes a version string for comparison by removing the 'v' prefix and + * ensuring it has all three version components (major.minor.patch). + */ + private function normalizeVersion(string $version): string + { + // Remove 'v' prefix if present + $version = ltrim($version, 'v'); + + // Split into parts + $parts = explode('.', explode('-', explode('+', $version)[0])[0]); + + // Ensure we have at least 3 parts for version_compare + while (count($parts) < 3) { + $parts[] = '0'; + } + + // Get pre-release and build metadata if any + $suffix = ''; + if (preg_match('/^[^-+]+(.+)$/', $version, $matches)) { + $suffix = $matches[1]; + } + + return implode('.', array_slice($parts, 0, 3)) . $suffix; } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 46c01edb5d9a..15496f5baf43 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -184,4 +184,137 @@ public static function getInvalidStrictSemVersions(): iterable yield ['v1.2']; } + /** + * @dataProvider getValidVersionsWithMinMax + */ + public function testValidVersionsWithMinMax(string $version, ?string $min, ?string $max, bool $strict = true) + { + $constraint = new SemVer(strict: $strict, min: $min, max: $max); + + $this->validator->validate($version, $constraint); + + $this->assertNoViolation(); + } + + public static function getValidVersionsWithMinMax(): iterable + { + // Test min only + yield ['2.0.0', '1.0.0', null]; + yield ['2.0.0', '2.0.0', null]; + yield ['2.0.1', '2.0.0', null]; + + // Test max only + yield ['1.0.0', null, '2.0.0']; + yield ['2.0.0', null, '2.0.0']; + yield ['1.9.9', null, '2.0.0']; + + // Test both min and max + yield ['1.5.0', '1.0.0', '2.0.0']; + yield ['1.0.0', '1.0.0', '2.0.0']; + yield ['2.0.0', '1.0.0', '2.0.0']; + + // Test with pre-release versions + yield ['1.0.0-alpha', '1.0.0-alpha', null]; + yield ['1.0.0', '1.0.0-alpha', null]; + yield ['1.0.0-beta', '1.0.0-alpha', null]; + yield ['1.0.0-alpha.2', '1.0.0-alpha.1', null]; + + // Test with loose versions + yield ['v2.0', 'v1.0', null, false]; + yield ['2', '1', null, false]; + yield ['v1.5', 'v1.0', 'v2.0', false]; + } + + /** + * @dataProvider getTooLowVersions + */ + public function testTooLowVersions(string $version, string $min, bool $strict = true) + { + $constraint = new SemVer( + tooLowMessage: 'myMessage', + strict: $strict, + min: $min + ); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setParameter('{{ min }}', $min) + ->setCode(SemVer::TOO_LOW_ERROR) + ->assertRaised(); + } + + public static function getTooLowVersions(): iterable + { + yield ['0.9.9', '1.0.0']; + yield ['1.0.0', '1.0.1']; + yield ['1.0.0-alpha', '1.0.0']; + yield ['1.0.0-alpha.1', '1.0.0-alpha.2']; + + // Test with loose versions + yield ['v0.9', 'v1.0', false]; + yield ['1', '2', false]; + } + + /** + * @dataProvider getTooHighVersions + */ + public function testTooHighVersions(string $version, string $max, bool $strict = true) + { + $constraint = new SemVer( + tooHighMessage: 'myMessage', + strict: $strict, + max: $max + ); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setParameter('{{ max }}', $max) + ->setCode(SemVer::TOO_HIGH_ERROR) + ->assertRaised(); + } + + public static function getTooHighVersions(): iterable + { + yield ['2.0.1', '2.0.0']; + yield ['1.0.1', '1.0.0']; + yield ['1.0.0', '1.0.0-alpha']; + yield ['1.0.0-alpha.2', '1.0.0-alpha.1']; + + // Test with loose versions + yield ['v2.1', 'v2.0', false]; + yield ['3', '2', false]; + } + + public function testInvalidMinOption() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "min" option value "invalid" is not a valid semantic version according to the "strict" option.'); + + $constraint = new SemVer(min: 'invalid'); + $this->validator->validate('1.0.0', $constraint); + } + + public function testInvalidMaxOption() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "max" option value "invalid" is not a valid semantic version according to the "strict" option.'); + + $constraint = new SemVer(max: 'invalid'); + $this->validator->validate('1.0.0', $constraint); + } + + public function testMinMaxOptionsFollowStrictMode() + { + // In strict mode, min/max with 'v' prefix should be invalid + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "min" option value "v1.0.0" is not a valid semantic version according to the "strict" option.'); + + $constraint = new SemVer(strict: true, min: 'v1.0.0'); + $this->validator->validate('2.0.0', $constraint); + } + } From 5dce35646d18fc54709cf303c9ffd16a09c6e0b5 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:39:26 +0200 Subject: [PATCH 17/24] Rename tooHighMessage/tooLowMessage to minMessage/maxMessage and make strict parameter required in data providers --- .../Validator/Constraints/SemVer.php | 12 ++--- .../Validator/Constraints/SemVerValidator.php | 4 +- .../Tests/Constraints/SemVerValidatorTest.php | 52 +++++++++---------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index 561887fdb5e2..4c9d443786cc 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -35,8 +35,8 @@ class SemVer extends Constraint ]; public string $message; - public string $tooLowMessage; - public string $tooHighMessage; + public string $minMessage; + public string $maxMessage; public bool $strict; public ?string $min; public ?string $max; @@ -47,8 +47,8 @@ class SemVer extends Constraint #[HasNamedArguments] public function __construct( string $message = 'This value is not a valid semantic version.', - string $tooLowMessage = 'This value should be {{ min }} or more.', - string $tooHighMessage = 'This value should be {{ max }} or less.', + string $minMessage = 'This value should be {{ min }} or more.', + string $maxMessage = 'This value should be {{ max }} or less.', bool $strict = true, ?string $min = null, ?string $max = null, @@ -58,8 +58,8 @@ public function __construct( parent::__construct(null, $groups, $payload); $this->message = $message; - $this->tooLowMessage = $tooLowMessage; - $this->tooHighMessage = $tooHighMessage; + $this->minMessage = $minMessage; + $this->maxMessage = $maxMessage; $this->strict = $strict; $this->min = $min; $this->max = $max; diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 6053c6973de2..3d2b91b4edec 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -112,7 +112,7 @@ public function validate(mixed $value, Constraint $constraint): void } if (version_compare($normalizedValue, $normalizedMin, '<')) { - $this->context->buildViolation($constraint->tooLowMessage) + $this->context->buildViolation($constraint->minMessage) ->setParameter('{{ value }}', $this->formatValue($value)) ->setParameter('{{ min }}', $constraint->min) ->setCode(SemVer::TOO_LOW_ERROR) @@ -129,7 +129,7 @@ public function validate(mixed $value, Constraint $constraint): void } if (version_compare($normalizedValue, $normalizedMax, '>')) { - $this->context->buildViolation($constraint->tooHighMessage) + $this->context->buildViolation($constraint->maxMessage) ->setParameter('{{ value }}', $this->formatValue($value)) ->setParameter('{{ max }}', $constraint->max) ->setCode(SemVer::TOO_HIGH_ERROR) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 15496f5baf43..4410f3d3217f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -187,7 +187,7 @@ public static function getInvalidStrictSemVersions(): iterable /** * @dataProvider getValidVersionsWithMinMax */ - public function testValidVersionsWithMinMax(string $version, ?string $min, ?string $max, bool $strict = true) + public function testValidVersionsWithMinMax(string $version, ?string $min, ?string $max, bool $strict) { $constraint = new SemVer(strict: $strict, min: $min, max: $max); @@ -199,25 +199,25 @@ public function testValidVersionsWithMinMax(string $version, ?string $min, ?stri public static function getValidVersionsWithMinMax(): iterable { // Test min only - yield ['2.0.0', '1.0.0', null]; - yield ['2.0.0', '2.0.0', null]; - yield ['2.0.1', '2.0.0', null]; + yield ['2.0.0', '1.0.0', null, true]; + yield ['2.0.0', '2.0.0', null, true]; + yield ['2.0.1', '2.0.0', null, true]; // Test max only - yield ['1.0.0', null, '2.0.0']; - yield ['2.0.0', null, '2.0.0']; - yield ['1.9.9', null, '2.0.0']; + yield ['1.0.0', null, '2.0.0', true]; + yield ['2.0.0', null, '2.0.0', true]; + yield ['1.9.9', null, '2.0.0', true]; // Test both min and max - yield ['1.5.0', '1.0.0', '2.0.0']; - yield ['1.0.0', '1.0.0', '2.0.0']; - yield ['2.0.0', '1.0.0', '2.0.0']; + yield ['1.5.0', '1.0.0', '2.0.0', true]; + yield ['1.0.0', '1.0.0', '2.0.0', true]; + yield ['2.0.0', '1.0.0', '2.0.0', true]; // Test with pre-release versions - yield ['1.0.0-alpha', '1.0.0-alpha', null]; - yield ['1.0.0', '1.0.0-alpha', null]; - yield ['1.0.0-beta', '1.0.0-alpha', null]; - yield ['1.0.0-alpha.2', '1.0.0-alpha.1', null]; + yield ['1.0.0-alpha', '1.0.0-alpha', null, true]; + yield ['1.0.0', '1.0.0-alpha', null, true]; + yield ['1.0.0-beta', '1.0.0-alpha', null, true]; + yield ['1.0.0-alpha.2', '1.0.0-alpha.1', null, true]; // Test with loose versions yield ['v2.0', 'v1.0', null, false]; @@ -228,10 +228,10 @@ public static function getValidVersionsWithMinMax(): iterable /** * @dataProvider getTooLowVersions */ - public function testTooLowVersions(string $version, string $min, bool $strict = true) + public function testTooLowVersions(string $version, string $min, bool $strict) { $constraint = new SemVer( - tooLowMessage: 'myMessage', + minMessage: 'myMessage', strict: $strict, min: $min ); @@ -247,10 +247,10 @@ public function testTooLowVersions(string $version, string $min, bool $strict = public static function getTooLowVersions(): iterable { - yield ['0.9.9', '1.0.0']; - yield ['1.0.0', '1.0.1']; - yield ['1.0.0-alpha', '1.0.0']; - yield ['1.0.0-alpha.1', '1.0.0-alpha.2']; + yield ['0.9.9', '1.0.0', true]; + yield ['1.0.0', '1.0.1', true]; + yield ['1.0.0-alpha', '1.0.0', true]; + yield ['1.0.0-alpha.1', '1.0.0-alpha.2', true]; // Test with loose versions yield ['v0.9', 'v1.0', false]; @@ -260,10 +260,10 @@ public static function getTooLowVersions(): iterable /** * @dataProvider getTooHighVersions */ - public function testTooHighVersions(string $version, string $max, bool $strict = true) + public function testTooHighVersions(string $version, string $max, bool $strict) { $constraint = new SemVer( - tooHighMessage: 'myMessage', + maxMessage: 'myMessage', strict: $strict, max: $max ); @@ -279,10 +279,10 @@ public function testTooHighVersions(string $version, string $max, bool $strict = public static function getTooHighVersions(): iterable { - yield ['2.0.1', '2.0.0']; - yield ['1.0.1', '1.0.0']; - yield ['1.0.0', '1.0.0-alpha']; - yield ['1.0.0-alpha.2', '1.0.0-alpha.1']; + yield ['2.0.1', '2.0.0', true]; + yield ['1.0.1', '1.0.0', true]; + yield ['1.0.0', '1.0.0-alpha', true]; + yield ['1.0.0-alpha.2', '1.0.0-alpha.1', true]; // Test with loose versions yield ['v2.1', 'v2.0', false]; From 763920c9ab21b3b441bec9aae5dff84210165e56 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:40:13 +0200 Subject: [PATCH 18/24] Update src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php --- .../Validator/Tests/Constraints/SemVerValidatorTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 4410f3d3217f..1c4a5152a4f5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -316,5 +316,4 @@ public function testMinMaxOptionsFollowStrictMode() $constraint = new SemVer(strict: true, min: 'v1.0.0'); $this->validator->validate('2.0.0', $constraint); } - } From 7f525715e7af67cfe6685734cbcd8ce8144e9fa7 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:41:27 +0200 Subject: [PATCH 19/24] Move expectException calls directly before the code that triggers the exception --- .../Tests/Constraints/SemVerValidatorTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 1c4a5152a4f5..30159479c6de 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -291,29 +291,29 @@ public static function getTooHighVersions(): iterable public function testInvalidMinOption() { + $constraint = new SemVer(min: 'invalid'); + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The "min" option value "invalid" is not a valid semantic version according to the "strict" option.'); - - $constraint = new SemVer(min: 'invalid'); $this->validator->validate('1.0.0', $constraint); } public function testInvalidMaxOption() { + $constraint = new SemVer(max: 'invalid'); + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The "max" option value "invalid" is not a valid semantic version according to the "strict" option.'); - - $constraint = new SemVer(max: 'invalid'); $this->validator->validate('1.0.0', $constraint); } public function testMinMaxOptionsFollowStrictMode() { // In strict mode, min/max with 'v' prefix should be invalid + $constraint = new SemVer(strict: true, min: 'v1.0.0'); + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The "min" option value "v1.0.0" is not a valid semantic version according to the "strict" option.'); - - $constraint = new SemVer(strict: true, min: 'v1.0.0'); $this->validator->validate('2.0.0', $constraint); } } From 1bb4b7a3ce136e89651c015d0f571c81c3106c3f Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:44:57 +0200 Subject: [PATCH 20/24] Add tests for custom message, minMessage and maxMessage --- .../Tests/Constraints/SemVerValidatorTest.php | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 30159479c6de..6e318e982e0e 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -86,6 +86,50 @@ public function testInvalidStrictSemVersions(string $version) ->assertRaised(); } + public function testCustomInvalidMessage() + { + $constraint = new SemVer(message: 'Custom invalid version message'); + + $this->validator->validate('invalid-version', $constraint); + + $this->buildViolation('Custom invalid version message') + ->setParameter('{{ value }}', '"invalid-version"') + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->assertRaised(); + } + + public function testCustomMinMessage() + { + $constraint = new SemVer( + minMessage: 'Custom minimum version message', + min: '2.0.0' + ); + + $this->validator->validate('1.0.0', $constraint); + + $this->buildViolation('Custom minimum version message') + ->setParameter('{{ value }}', '"1.0.0"') + ->setParameter('{{ min }}', '2.0.0') + ->setCode(SemVer::TOO_LOW_ERROR) + ->assertRaised(); + } + + public function testCustomMaxMessage() + { + $constraint = new SemVer( + maxMessage: 'Custom maximum version message', + max: '1.0.0' + ); + + $this->validator->validate('2.0.0', $constraint); + + $this->buildViolation('Custom maximum version message') + ->setParameter('{{ value }}', '"2.0.0"') + ->setParameter('{{ max }}', '1.0.0') + ->setCode(SemVer::TOO_HIGH_ERROR) + ->assertRaised(); + } + public static function getValidLooseSemVersions(): iterable { // Full versions From ae62ffafbfb2e7f15eb6814782d8123915a409bc Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 2 Jul 2025 22:48:30 +0200 Subject: [PATCH 21/24] Follow Symfony constraint pattern: initialize message properties with default values --- .../Validator/Constraints/SemVer.php | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php index 4c9d443786cc..5361249a88aa 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVer.php +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -34,22 +34,22 @@ class SemVer extends Constraint self::TOO_HIGH_ERROR => 'TOO_HIGH_ERROR', ]; - public string $message; - public string $minMessage; - public string $maxMessage; - public bool $strict; - public ?string $min; - public ?string $max; + public string $message = 'This value is not a valid semantic version.'; + public string $minMessage = 'This value should be {{ min }} or more.'; + public string $maxMessage = 'This value should be {{ max }} or less.'; + public bool $strict = true; + public ?string $min = null; + public ?string $max = null; /** * @param string[]|null $groups */ #[HasNamedArguments] public function __construct( - string $message = 'This value is not a valid semantic version.', - string $minMessage = 'This value should be {{ min }} or more.', - string $maxMessage = 'This value should be {{ max }} or less.', - bool $strict = true, + ?string $message = null, + ?string $minMessage = null, + ?string $maxMessage = null, + ?bool $strict = null, ?string $min = null, ?string $max = null, ?array $groups = null, @@ -57,10 +57,10 @@ public function __construct( ) { parent::__construct(null, $groups, $payload); - $this->message = $message; - $this->minMessage = $minMessage; - $this->maxMessage = $maxMessage; - $this->strict = $strict; + $this->message = $message ?? $this->message; + $this->minMessage = $minMessage ?? $this->minMessage; + $this->maxMessage = $maxMessage ?? $this->maxMessage; + $this->strict = $strict ?? $this->strict; $this->min = $min; $this->max = $max; } From b09799cdccb794190049d46bb1e76fd6a45e5c06 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Thu, 3 Jul 2025 08:51:48 +0200 Subject: [PATCH 22/24] Update src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php Co-authored-by: Alexandre Daubois <2144837+alexandre-daubois@users.noreply.github.com> --- .../Validator/Tests/Constraints/SemVerValidatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 6e318e982e0e..26a855584cb3 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -215,7 +215,7 @@ public static function getInvalidSemVersions(): iterable public static function getInvalidStrictSemVersions(): iterable { - // Versions with v prefix (not allowed in strict mode) + // Versions with v prefix yield ['v1.0.0']; yield ['v1.2.3']; yield ['v1.0.0-alpha']; From c6f154536b2de16a55ce773f83fc30fd8997a85b Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Thu, 3 Jul 2025 08:51:57 +0200 Subject: [PATCH 23/24] Update src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php Co-authored-by: Alexandre Daubois <2144837+alexandre-daubois@users.noreply.github.com> --- .../Validator/Tests/Constraints/SemVerValidatorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php index 26a855584cb3..464220e6e2f5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -221,7 +221,7 @@ public static function getInvalidStrictSemVersions(): iterable yield ['v1.0.0-alpha']; yield ['v1.0.0+20130313144700']; - // Partial versions (not allowed in strict mode) + // Partial versions yield ['1']; yield ['1.2']; yield ['v1']; From b7a6d49bc45fc0ad0913fbc578fb4aaf15317037 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Thu, 3 Jul 2025 09:06:44 +0200 Subject: [PATCH 24/24] Remove unnecessary comments as requested in code review As per Alex Daubois's review comments, removed explanatory comments that were deemed unnecessary for clean, self-documenting code. --- .../Component/Validator/Constraints/SemVerValidator.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php index 3d2b91b4edec..9f1f9c412809 100644 --- a/src/Symfony/Component/Validator/Constraints/SemVerValidator.php +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -100,10 +100,8 @@ public function validate(mixed $value, Constraint $constraint): void return; } - // Normalize the version for comparison (remove 'v' prefix if present) $normalizedValue = $this->normalizeVersion($value); - // Check min constraint if (null !== $constraint->min) { $normalizedMin = $this->normalizeVersion($constraint->min); @@ -120,7 +118,6 @@ public function validate(mixed $value, Constraint $constraint): void } } - // Check max constraint if (null !== $constraint->max) { $normalizedMax = $this->normalizeVersion($constraint->max); @@ -144,13 +141,10 @@ public function validate(mixed $value, Constraint $constraint): void */ private function normalizeVersion(string $version): string { - // Remove 'v' prefix if present $version = ltrim($version, 'v'); - // Split into parts $parts = explode('.', explode('-', explode('+', $version)[0])[0]); - // Ensure we have at least 3 parts for version_compare while (count($parts) < 3) { $parts[] = '0'; }