[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:
lacatoire
2026-02-11 22:08:33 +01:00
committed by Nicolas Grekas
parent c8a496b804
commit c6d89387e2
6 changed files with 171 additions and 7 deletions

View File

@@ -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

View File

@@ -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)));
}

View File

@@ -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)));
}

View File

@@ -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');
}
}

View File

@@ -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

View File

@@ -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",