Skip to content

[Validator] Add SemVer constraint for semantic versioning validation #60995

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/Symfony/Component/Validator/Constraints/SemVer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.
*
* @author Oskar Stark <oskarstark@googlemail.com>
*/
#[\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;
public bool $strict;

/**
* @param string[]|null $groups
*/
#[HasNamedArguments]
public function __construct(
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->strict = $strict;
}
}
101 changes: 101 additions & 0 deletions src/Symfony/Component/Validator/Constraints/SemVerValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <oskarstark@googlemail.com>
*/
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 = '/^
(?P<major>0|[1-9]\d*) # Major version
\.
(?P<minor>0|[1-9]\d*) # Minor version
\.
(?P<patch>0|[1-9]\d*) # Patch version
(?:
-
(?P<prerelease>
(?: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<buildmetadata>[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 = '/^
(?P<prefix>v)? # Optional "v" prefix
(?P<major>0|[1-9]\d*) # Major version (required)
(?:
\.
(?P<minor>0|[1-9]\d*) # Minor version (optional)
(?:
\.
(?P<patch>0|[1-9]\d*) # Patch version (optional)
(?:
-
(?P<prerelease>
(?: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<buildmetadata>[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();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 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 (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 ['v1'];
yield ['v1.2'];
}

}
Loading