mirror of
https://github.com/symfony/validator.git
synced 2026-03-24 01:12:13 +01:00
[Validator] Add clock-awareness to comparison and range validators
Use ClockInterface and DatePoint in AbstractComparisonValidator and RangeValidator so that relative date strings (e.g. "now", "-40 days") are resolved against the injected clock instead of the system clock. This makes date comparisons testable with MockClock while remaining fully backward compatible (falls back to system clock when no clock is injected).
This commit is contained in:
committed by
Nicolas Grekas
parent
c8a496b804
commit
c6d89387e2
@@ -4,6 +4,7 @@ CHANGELOG
|
||||
8.1
|
||||
---
|
||||
|
||||
* Add clock-awareness to comparison and range validators for testable date comparisons
|
||||
* Add the `Xml` constraint for validating XML content
|
||||
|
||||
8.0
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
namespace Symfony\Component\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Clock\DatePoint;
|
||||
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
|
||||
use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException;
|
||||
use Symfony\Component\PropertyAccess\PropertyAccess;
|
||||
@@ -28,8 +30,10 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
*/
|
||||
abstract class AbstractComparisonValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private ?PropertyAccessorInterface $propertyAccessor = null)
|
||||
{
|
||||
public function __construct(
|
||||
private ?PropertyAccessorInterface $propertyAccessor = null,
|
||||
private ?ClockInterface $clock = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
@@ -63,7 +67,7 @@ abstract class AbstractComparisonValidator extends ConstraintValidator
|
||||
// https://php.net/datetime.formats
|
||||
if (\is_string($comparedValue) && $value instanceof \DateTimeInterface) {
|
||||
try {
|
||||
$comparedValue = new $value($comparedValue);
|
||||
$comparedValue = $this->clock ? $value::createFromInterface(new DatePoint($comparedValue, null, $this->clock->now())) : new $value($comparedValue);
|
||||
} catch (\Exception) {
|
||||
throw new ConstraintDefinitionException(\sprintf('The compared value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $comparedValue, get_debug_type($value), get_debug_type($constraint)));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
namespace Symfony\Component\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Clock\DatePoint;
|
||||
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
|
||||
use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException;
|
||||
use Symfony\Component\PropertyAccess\PropertyAccess;
|
||||
@@ -25,8 +27,10 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
*/
|
||||
class RangeValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private ?PropertyAccessorInterface $propertyAccessor = null)
|
||||
{
|
||||
public function __construct(
|
||||
private ?PropertyAccessorInterface $propertyAccessor = null,
|
||||
private ?ClockInterface $clock = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
@@ -65,7 +69,7 @@ class RangeValidator extends ConstraintValidator
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
if (\is_string($min)) {
|
||||
try {
|
||||
$min = new $value($min);
|
||||
$min = $this->clock ? $value::createFromInterface(new DatePoint($min, null, $this->clock->now())) : new $value($min);
|
||||
} catch (\Exception) {
|
||||
throw new ConstraintDefinitionException(\sprintf('The min value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $min, get_debug_type($value), get_debug_type($constraint)));
|
||||
}
|
||||
@@ -73,7 +77,7 @@ class RangeValidator extends ConstraintValidator
|
||||
|
||||
if (\is_string($max)) {
|
||||
try {
|
||||
$max = new $value($max);
|
||||
$max = $this->clock ? $value::createFromInterface(new DatePoint($max, null, $this->clock->now())) : new $value($max);
|
||||
} catch (\Exception) {
|
||||
throw new ConstraintDefinitionException(\sprintf('The max value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $max, get_debug_type($value), get_debug_type($constraint)));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
namespace Symfony\Component\Validator\Tests\Constraints;
|
||||
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\Constraints\GreaterThan;
|
||||
use Symfony\Component\Validator\Constraints\GreaterThanValidator;
|
||||
@@ -83,4 +84,80 @@ class GreaterThanValidatorTest extends AbstractComparisonValidatorTestCase
|
||||
['22', '"22"', '22', '"22"', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testValidRelativeDateWithMockClock()
|
||||
{
|
||||
$clock = new MockClock('2025-01-15 00:00:00 UTC');
|
||||
$this->validator = new GreaterThanValidator(null, $clock);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
// Value is 20 days after the frozen "now", compared to "-10 days" (Jan 5)
|
||||
$value = new \DateTimeImmutable('2025-01-20 00:00:00 UTC');
|
||||
$constraint = new GreaterThan('-10 days');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testInvalidRelativeDateWithMockClock()
|
||||
{
|
||||
$clock = new MockClock('2025-01-15 00:00:00 UTC');
|
||||
$this->validator = new GreaterThanValidator(null, $clock);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
// Value (Jan 1) is before the frozen "now" (Jan 15) minus 10 days (Jan 5)
|
||||
$value = new \DateTimeImmutable('2025-01-01 00:00:00 UTC');
|
||||
$constraint = new GreaterThan(value: '-10 days', message: 'myMessage');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$comparedValue = $clock->now()->modify('-10 days');
|
||||
|
||||
$this->buildViolation('myMessage')
|
||||
->setParameter('{{ value }}', self::formatDateTime($value))
|
||||
->setParameter('{{ compared_value }}', self::formatDateTime($comparedValue))
|
||||
->setParameter('{{ compared_value_type }}', \DateTimeImmutable::class)
|
||||
->setCode(GreaterThan::TOO_LOW_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testAbsoluteDateWithMockClock()
|
||||
{
|
||||
$clock = new MockClock('2025-01-15 00:00:00 UTC');
|
||||
$this->validator = new GreaterThanValidator(null, $clock);
|
||||
|
||||
// Absolute dates should still work with a mock clock
|
||||
$value = new \DateTimeImmutable('2025-06-01 00:00:00 UTC');
|
||||
$constraint = new GreaterThan('2025-01-01');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testBackwardCompatWithoutClock()
|
||||
{
|
||||
// Without setClock(), the validator should still work (falls back to system clock)
|
||||
$value = new \DateTimeImmutable('2000-01-01 UTC');
|
||||
$constraint = new GreaterThan('1999-01-01');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
private static function formatDateTime(\DateTimeInterface $value): string
|
||||
{
|
||||
if (class_exists(\IntlDateFormatter::class)) {
|
||||
$formatter = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, 'UTC');
|
||||
|
||||
return $formatter->format(new \DateTimeImmutable(
|
||||
$value->format('Y-m-d H:i:s.u'),
|
||||
new \DateTimeZone('UTC')
|
||||
));
|
||||
}
|
||||
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
namespace Symfony\Component\Validator\Tests\Constraints;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Intl\Util\IntlTestHelper;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
use Symfony\Component\Validator\Constraints\RangeValidator;
|
||||
@@ -835,6 +836,82 @@ class RangeValidatorTest extends ConstraintValidatorTestCase
|
||||
->setCode($expectedCode)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testValidRelativeRangeWithMockClock()
|
||||
{
|
||||
$clock = new MockClock('2025-01-15 00:00:00 UTC');
|
||||
$this->validator = new RangeValidator(null, $clock);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
// Value is Jan 10, within range [-10 days (Jan 5) .. +10 days (Jan 25)]
|
||||
$value = new \DateTimeImmutable('2025-01-10 00:00:00 UTC');
|
||||
$constraint = new Range(min: '-10 days', max: '+10 days');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testInvalidRelativeRangeWithMockClock()
|
||||
{
|
||||
$clock = new MockClock('2025-01-15 00:00:00 UTC');
|
||||
$this->validator = new RangeValidator(null, $clock);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
// Value is Feb 1, outside range [-10 days (Jan 5) .. +10 days (Jan 25)]
|
||||
$value = new \DateTimeImmutable('2025-02-01 00:00:00 UTC');
|
||||
$constraint = new Range(min: '-10 days', max: '+10 days', notInRangeMessage: 'myMessage');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$now = $clock->now();
|
||||
$this->buildViolation('myMessage')
|
||||
->setParameter('{{ value }}', self::formatDateTime($value))
|
||||
->setParameter('{{ min }}', self::formatDateTime($now->modify('-10 days')))
|
||||
->setParameter('{{ max }}', self::formatDateTime($now->modify('+10 days')))
|
||||
->setCode(Range::NOT_IN_RANGE_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testAbsoluteRangeWithMockClock()
|
||||
{
|
||||
$clock = new MockClock('2025-01-15 00:00:00 UTC');
|
||||
$this->validator = new RangeValidator(null, $clock);
|
||||
$this->validator->initialize($this->context);
|
||||
|
||||
// Absolute dates should still work with a mock clock
|
||||
$value = new \DateTimeImmutable('2025-03-15 00:00:00 UTC');
|
||||
$constraint = new Range(min: '2025-01-01', max: '2025-12-31');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testBackwardCompatWithoutClock()
|
||||
{
|
||||
// Without setClock(), the validator should still work (falls back to system clock)
|
||||
$value = new \DateTimeImmutable('2025-03-15 00:00:00 UTC');
|
||||
$constraint = new Range(min: '2025-01-01', max: '2025-12-31');
|
||||
|
||||
$this->validator->validate($value, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
private static function formatDateTime(\DateTimeInterface $value): string
|
||||
{
|
||||
if (class_exists(\IntlDateFormatter::class)) {
|
||||
$formatter = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, 'UTC');
|
||||
|
||||
return $formatter->format(new \DateTimeImmutable(
|
||||
$value->format('Y-m-d H:i:s.u'),
|
||||
new \DateTimeZone('UTC')
|
||||
));
|
||||
}
|
||||
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
final class Limit
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"require-dev": {
|
||||
"egulias/email-validator": "^2.1.10|^3|^4",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/clock": "^7.4|^8.0",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/console": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
|
||||
Reference in New Issue
Block a user