diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 46709ac0ac814..d8ae5e6cc1671 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * Add `errorPath` to Unique constraint * Add the `format` option to the `Ulid` constraint to allow accepting different ULID formats * Add the `WordCount` constraint + * Add the `Week` constraint 7.1 --- diff --git a/src/Symfony/Component/Validator/Constraints/Week.php b/src/Symfony/Component/Validator/Constraints/Week.php new file mode 100644 index 0000000000000..46b8869752fbe --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Week.php @@ -0,0 +1,66 @@ + + * + * 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; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +/** + * @author Alexandre Daubois + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class Week extends Constraint +{ + public const INVALID_FORMAT_ERROR = '19012dd1-01c8-4ce8-959f-72ad22684f5f'; + public const INVALID_WEEK_NUMBER_ERROR = 'd67ebfc9-45fe-4e4c-a038-5eaa56895ea3'; + public const TOO_LOW_ERROR = '9b506423-77a3-4749-aa34-c822a08be978'; + public const TOO_HIGH_ERROR = '85156377-d1e6-42cd-8f6e-dc43c2ecb72b'; + + protected const ERROR_NAMES = [ + self::INVALID_FORMAT_ERROR => 'INVALID_FORMAT_ERROR', + self::INVALID_WEEK_NUMBER_ERROR => 'INVALID_WEEK_NUMBER_ERROR', + self::TOO_LOW_ERROR => 'TOO_LOW_ERROR', + self::TOO_HIGH_ERROR => 'TOO_HIGH_ERROR', + ]; + + #[HasNamedArguments] + public function __construct( + public ?string $min = null, + public ?string $max = null, + public string $invalidFormatMessage = 'This value does not represent a valid week in the ISO 8601 format.', + public string $invalidWeekNumberMessage = 'The week "{{ value }}" is not a valid week.', + public string $tooLowMessage = 'The value should not be before week "{{ min }}".', + public string $tooHighMessage = 'The value should not be after week "{{ max }}".', + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct(null, $groups, $payload); + + if (null !== $min && !preg_match('/^\d{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$/', $min)) { + throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the min week to be in the ISO 8601 format if set.', __CLASS__)); + } + + if (null !== $max && !preg_match('/^\d{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$/', $max)) { + throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the max week to be in the ISO 8601 format if set.', __CLASS__)); + } + + if (null !== $min && null !== $max) { + [$minYear, $minWeekNumber] = \explode('-W', $min, 2); + [$maxYear, $maxWeekNumber] = \explode('-W', $max, 2); + + if ($minYear > $maxYear || ($minYear === $maxYear && $minWeekNumber > $maxWeekNumber)) { + throw new ConstraintDefinitionException(\sprintf('The "%s" constraint requires the min week to be less than or equal to the max week.', __CLASS__)); + } + } + } +} diff --git a/src/Symfony/Component/Validator/Constraints/WeekValidator.php b/src/Symfony/Component/Validator/Constraints/WeekValidator.php new file mode 100644 index 0000000000000..83052c1a9cb20 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/WeekValidator.php @@ -0,0 +1,82 @@ + + * + * 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 Alexandre Daubois + */ +final class WeekValidator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Week) { + throw new UnexpectedTypeException($constraint, Week::class); + } + + if (null === $value) { + return; + } + + if (!\is_string($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + if (!preg_match('/^\d{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$/D', $value)) { + $this->context->buildViolation($constraint->invalidFormatMessage) + ->setCode(Week::INVALID_FORMAT_ERROR) + ->addViolation(); + + return; + } + + [$year, $weekNumber] = \explode('-W', $value, 2); + $weeksInYear = (int) \date('W', \mktime(0, 0, 0, 12, 28, $year)); + + if ($weekNumber > $weeksInYear) { + $this->context->buildViolation($constraint->invalidWeekNumberMessage) + ->setCode(Week::INVALID_WEEK_NUMBER_ERROR) + ->setParameter('{{ value }}', $value) + ->addViolation(); + + return; + } + + if ($constraint->min) { + [$minYear, $minWeekNumber] = \explode('-W', $constraint->min, 2); + if ($year < $minYear || ($year === $minYear && $weekNumber < $minWeekNumber)) { + $this->context->buildViolation($constraint->tooLowMessage) + ->setCode(Week::TOO_LOW_ERROR) + ->setInvalidValue($value) + ->setParameter('{{ min }}', $constraint->min) + ->addViolation(); + + return; + } + } + + if ($constraint->max) { + [$maxYear, $maxWeekNumber] = \explode('-W', $constraint->max, 2); + if ($year > $maxYear || ($year === $maxYear && $weekNumber > $maxWeekNumber)) { + $this->context->buildViolation($constraint->tooHighMessage) + ->setCode(Week::TOO_HIGH_ERROR) + ->setInvalidValue($value) + ->setParameter('{{ max }}', $constraint->max) + ->addViolation(); + } + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WeekTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WeekTest.php new file mode 100644 index 0000000000000..0fc9aac627178 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/WeekTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\Week; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +class WeekTest extends TestCase +{ + public function testWithoutArgument() + { + $week = new Week(); + + $this->assertNull($week->min); + $this->assertNull($week->max); + } + + public function testConstructor() + { + $week = new Week(min: '2010-W01', max: '2010-W02'); + + $this->assertSame('2010-W01', $week->min); + $this->assertSame('2010-W02', $week->max); + } + + public function testMinYearIsAfterMaxYear() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\Week" constraint requires the min week to be less than or equal to the max week.'); + + new Week(min: '2011-W01', max: '2010-W02'); + } + + public function testMinWeekIsAfterMaxWeek() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\Week" constraint requires the min week to be less than or equal to the max week.'); + + new Week(min: '2010-W02', max: '2010-W01'); + } + + public function testMinAndMaxWeeksAreTheSame() + { + $week = new Week(min: '2010-W01', max: '2010-W01'); + + $this->assertSame('2010-W01', $week->min); + $this->assertSame('2010-W01', $week->max); + } + + public function testMinIsBadlyFormatted() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\Week" constraint requires the min week to be in the ISO 8601 format if set.'); + + new Week(min: '2010-01'); + } + + public function testMaxIsBadlyFormatted() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Component\Validator\Constraints\Week" constraint requires the max week to be in the ISO 8601 format if set.'); + + new Week(max: '2010-01'); + } + + public function testAttributes() + { + $metadata = new ClassMetadata(WeekDummy::class); + $loader = new AttributeLoader(); + $this->assertTrue($loader->loadClassMetadata($metadata)); + + [$aConstraint] = $metadata->properties['a']->getConstraints(); + $this->assertNull($aConstraint->min); + $this->assertNull($aConstraint->max); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + $this->assertSame('2010-W01', $bConstraint->min); + $this->assertSame('2010-W02', $bConstraint->max); + } +} + +class WeekDummy +{ + #[Week] + private string $a; + + #[Week(min: '2010-W01', max: '2010-W02')] + private string $b; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WeekValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WeekValidatorTest.php new file mode 100644 index 0000000000000..08bc3b29b7aa9 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/WeekValidatorTest.php @@ -0,0 +1,142 @@ + + * + * 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\Week; +use Symfony\Component\Validator\Constraints\WeekValidator; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Symfony\Component\Validator\Tests\Constraints\Fixtures\StringableValue; + +class WeekValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): WeekValidator + { + return new WeekValidator(); + } + + /** + * @dataProvider provideWeekNumber + */ + public function testWeekIsValidWeekNumber(string|\Stringable $value, bool $expectedViolation) + { + $constraint = new Week(); + $this->validator->validate($value, $constraint); + + if ($expectedViolation) { + $this->buildViolation('The week "{{ value }}" is not a valid week.') + ->setCode(Week::INVALID_WEEK_NUMBER_ERROR) + ->setParameter('{{ value }}', $value) + ->assertRaised(); + + return; + } + + $this->assertNoViolation(); + } + + public static function provideWeekNumber() + { + yield ['2015-W53', false]; // 2015 has 53 weeks + yield ['2020-W53', false]; // 2020 also has 53 weeks + yield ['2024-W53', true]; // 2024 has 52 weeks + yield [new StringableValue('2024-W53'), true]; + } + + public function testBounds() + { + $constraint = new Week(min: '2015-W10', max: '2016-W25'); + + $this->validator->validate('2015-W10', $constraint); + $this->assertNoViolation(); + + $this->validator->validate('2016-W25', $constraint); + $this->assertNoViolation(); + } + + public function testTooLow() + { + $constraint = new Week(min: '2015-W10'); + + $this->validator->validate('2015-W08', $constraint); + $this->buildViolation('The value should not be before week "{{ min }}".') + ->setInvalidValue('2015-W08') + ->setParameter('{{ min }}', '2015-W10') + ->setCode(Week::TOO_LOW_ERROR) + ->assertRaised(); + } + + public function testTooHigh() + { + $constraint = new Week(max: '2016-W25'); + + $this->validator->validate('2016-W30', $constraint); + $this->buildViolation('The value should not be after week "{{ max }}".') + ->setInvalidValue('2016-W30') + ->setParameter('{{ max }}', '2016-W25') + ->setCode(Week::TOO_HIGH_ERROR) + ->assertRaised(); + } + + public function testWithNewLine() + { + $this->validator->validate("2015-W10\n", new Week()); + + $this->buildViolation('This value does not represent a valid week in the ISO 8601 format.') + ->setCode(Week::INVALID_FORMAT_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider provideInvalidValues + */ + public function testInvalidValues(string $value) + { + $this->validator->validate($value, new Week()); + + $this->buildViolation('This value does not represent a valid week in the ISO 8601 format.') + ->setCode(Week::INVALID_FORMAT_ERROR) + ->assertRaised(); + } + + /** + * @dataProvider provideInvalidTypes + */ + public function testNonStringValues(mixed $value) + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/Expected argument of type "string", ".*" given/'); + + $this->validator->validate($value, new Week()); + } + + public static function provideInvalidValues() + { + yield ['1970-01']; + yield ['1970-W00']; + yield ['1970-W54']; + yield ['1970-W100']; + yield ['1970-W01-01']; + yield ['-W01']; + yield ['24-W01']; + } + + public static function provideInvalidTypes() + { + yield [true]; + yield [false]; + yield [1]; + yield [1.1]; + yield [[]]; + yield [new \stdClass()]; + } +}