diff --git a/src/Symfony/Component/Validator/Constraints/SemVer.php b/src/Symfony/Component/Validator/Constraints/SemVer.php new file mode 100644 index 0000000000000..ad253c27b50c0 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SemVer.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Attribute\HasNamedArguments; +use Symfony\Component\Validator\Constraint; + +/** + * Validates that a value is a valid semantic version. + * + * @see https://semver.org + * + * Available options: + * - strict: Whether to validate strict semantic versioning (default: true) + * - min: Minimum version constraint (inclusive) + * - max: Maximum version constraint (inclusive) + * + * @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'; + 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 $minMessage; + public string $maxMessage; + public bool $strict; + 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 $min = null, + ?string $max = null, + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct(null, $groups, $payload); + + $this->message = $message; + $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 new file mode 100644 index 0000000000000..e1dfca92624d2 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SemVerValidator.php @@ -0,0 +1,157 @@ + + * + * 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 +{ + /** + * 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 + */ + 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 (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 + { + 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; + + $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) + ->addViolation(); + + return; + } + + $normalizedValue = $this->normalizeVersion($value); + + 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->minMessage) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setParameter('{{ min }}', $constraint->min) + ->setCode(SemVer::TOO_LOW_ERROR) + ->addViolation(); + } + } + + 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->maxMessage) + ->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 + { + $version = ltrim($version, 'v'); + + $parts = explode('.', explode('-', explode('+', $version)[0])[0]); + + while (\count($parts) < 3) { + $parts[] = '0'; + } + + // Get pre-release and build metadata if any + $suffix = preg_match('/^[^-+]+(.+)$/', $version, $matches) ? $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 new file mode 100644 index 0000000000000..464220e6e2f55 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SemVerValidatorTest.php @@ -0,0 +1,363 @@ + + * + * 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; + +final 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 getValidLooseSemVersions + */ + public function testValidLooseSemVersions(string $version) + { + $this->validator->validate($version, new SemVer(strict: false)); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getValidStrictSemVersions + */ + public function testValidStrictSemVersions(string $version) + { + $this->validator->validate($version, new SemVer()); + + $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 getInvalidStrictSemVersions + */ + public function testInvalidStrictSemVersions(string $version) + { + $constraint = new SemVer(message: 'myMessage'); + + $this->validator->validate($version, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$version.'"') + ->setCode(SemVer::INVALID_SEMVER_ERROR) + ->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 + 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 getValidStrictSemVersions(): iterable + { + // 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 + { + 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 getInvalidStrictSemVersions(): iterable + { + // Versions with v prefix + yield ['v1.0.0']; + yield ['v1.2.3']; + yield ['v1.0.0-alpha']; + yield ['v1.0.0+20130313144700']; + + // Partial versions + yield ['1']; + yield ['1.2']; + yield ['v1']; + yield ['v1.2']; + } + + /** + * @dataProvider getValidVersionsWithMinMax + */ + public function testValidVersionsWithMinMax(string $version, ?string $min, ?string $max, bool $strict) + { + $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, 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', 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', 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, 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]; + yield ['2', '1', null, false]; + yield ['v1.5', 'v1.0', 'v2.0', false]; + } + + /** + * @dataProvider getTooLowVersions + */ + public function testTooLowVersions(string $version, string $min, bool $strict) + { + $constraint = new SemVer( + minMessage: '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', 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]; + yield ['1', '2', false]; + } + + /** + * @dataProvider getTooHighVersions + */ + public function testTooHighVersions(string $version, string $max, bool $strict) + { + $constraint = new SemVer( + maxMessage: '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', 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]; + yield ['3', '2', false]; + } + + 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.'); + $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.'); + $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.'); + $this->validator->validate('2.0.0', $constraint); + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy