Skip to content

Commit f4b9bf6

Browse files
committed
[Validator] Support \DateInterval in comparison constraints
1 parent 9eb7cb1 commit f4b9bf6

20 files changed

+549
-19
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* added option `alpha3` to `Country` constraint
99
* allow to define a reusable set of constraints by extending the `Compound` constraint
1010
* added `Sequentially` constraint, to sequentially validate a set of constraints (any violation raised will prevent further validation of the nested constraints)
11+
* added support to validate `\DateInterval` instances with relative string date formats in the `Range` and all comparisons constraints
1112

1213
5.0.0
1314
-----

src/Symfony/Component/Validator/ConstraintValidator.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ abstract class ConstraintValidator implements ConstraintValidatorInterface
3131
*/
3232
const OBJECT_TO_STRING = 2;
3333

34+
/**
35+
* Whether to format {@link \DateInterval} objects as human readable strings
36+
* eg: 6 hours, 1 minute and 2 seconds.
37+
*/
38+
const PRETTY_DATE_INTERVAL = 4;
39+
3440
/**
3541
* @var ExecutionContextInterface
3642
*/
@@ -98,6 +104,37 @@ protected function formatValue($value, int $format = 0)
98104
return $value->format('Y-m-d H:i:s');
99105
}
100106

107+
if (($format & self::PRETTY_DATE_INTERVAL) && $value instanceof \DateInterval) {
108+
$formattedValueParts = [];
109+
foreach ([
110+
'y' => 'year',
111+
'm' => 'month',
112+
'd' => 'day',
113+
'h' => 'hour',
114+
'i' => 'minute',
115+
's' => 'second',
116+
'f' => 'microsecond',
117+
] as $p => $label) {
118+
if (!$formattedValue = $value->format('%'.$p)) {
119+
continue;
120+
}
121+
122+
if ($formattedValue > 1) {
123+
$label .= 's';
124+
}
125+
126+
$formattedValueParts[] = $formattedValue.' '.$label;
127+
}
128+
129+
if (!$formattedValueParts) {
130+
return '0';
131+
}
132+
133+
$lastFormattedValuePart = array_pop($formattedValueParts);
134+
135+
return $value->format('%r').(!$formattedValueParts ? $lastFormattedValuePart : implode(', ', $formattedValueParts).' and '.$lastFormattedValuePart);
136+
}
137+
101138
if (\is_object($value)) {
102139
if (($format & self::OBJECT_TO_STRING) && method_exists($value, '__toString')) {
103140
return $value->__toString();

src/Symfony/Component/Validator/Constraints/AbstractComparisonValidator.php

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\Validator\ConstraintValidator;
1919
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
2020
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
21+
use Symfony\Component\Validator\Util\DateIntervalComparisonHelper;
2122

2223
/**
2324
* Provides a base class for the validation of property comparisons.
@@ -61,11 +62,14 @@ public function validate($value, Constraint $constraint)
6162
$comparedValue = $constraint->value;
6263
}
6364

64-
// Convert strings to DateTimes if comparing another DateTime
65-
// This allows to compare with any date/time value supported by
66-
// the DateTime constructor:
67-
// https://php.net/datetime.formats
65+
$isDateIntervalComparison = false;
66+
6867
if (\is_string($comparedValue) && $value instanceof \DateTimeInterface) {
68+
// Convert strings to DateTimes if comparing another DateTime
69+
// This allows to compare with any date/time value supported by
70+
// the DateTime constructor:
71+
// https://php.net/datetime.formats
72+
6973
// If $value is immutable, convert the compared value to a DateTimeImmutable too, otherwise use DateTime
7074
$dateTimeClass = $value instanceof \DateTimeImmutable ? \DateTimeImmutable::class : \DateTime::class;
7175

@@ -74,13 +78,22 @@ public function validate($value, Constraint $constraint)
7478
} catch (\Exception $e) {
7579
throw new ConstraintDefinitionException(sprintf('The compared value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $comparedValue, $dateTimeClass, \get_class($constraint)));
7680
}
81+
} elseif ($isDateIntervalComparison = DateIntervalComparisonHelper::supports($value, $comparedValue)) {
82+
$originalValue = $value;
83+
$value = DateIntervalComparisonHelper::convertValue($dateIntervalReference = new \DateTimeImmutable(), $value);
84+
85+
try {
86+
$comparedValue = DateIntervalComparisonHelper::convertComparedValue($dateIntervalReference, $comparedValue);
87+
} catch (\InvalidArgumentException $e) {
88+
throw new ConstraintDefinitionException(sprintf('The compared value "%s" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $comparedValue, \get_class($constraint)));
89+
}
7790
}
7891

7992
if (!$this->compareValues($value, $comparedValue)) {
8093
$violationBuilder = $this->context->buildViolation($constraint->message)
81-
->setParameter('{{ value }}', $this->formatValue($value, self::OBJECT_TO_STRING | self::PRETTY_DATE))
82-
->setParameter('{{ compared_value }}', $this->formatValue($comparedValue, self::OBJECT_TO_STRING | self::PRETTY_DATE))
83-
->setParameter('{{ compared_value_type }}', $this->formatTypeOf($comparedValue))
94+
->setParameter('{{ value }}', $this->formatValue(!$isDateIntervalComparison ? $value : $originalValue, self::OBJECT_TO_STRING | self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
95+
->setParameter('{{ compared_value }}', $this->formatValue($messageComparedValue = (!$isDateIntervalComparison ? $comparedValue : $dateIntervalReference->diff($comparedValue)), self::OBJECT_TO_STRING | self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
96+
->setParameter('{{ compared_value_type }}', $this->formatTypeOf($messageComparedValue))
8497
->setCode($this->getErrorCode());
8598

8699
if (null !== $path) {

src/Symfony/Component/Validator/Constraints/RangeValidator.php

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\Validator\ConstraintValidator;
1919
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
2020
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
21+
use Symfony\Component\Validator\Util\DateIntervalComparisonHelper;
2122

2223
/**
2324
* @author Bernhard Schussek <bschussek@gmail.com>
@@ -44,7 +45,7 @@ public function validate($value, Constraint $constraint)
4445
return;
4546
}
4647

47-
if (!is_numeric($value) && !$value instanceof \DateTimeInterface) {
48+
if (!is_numeric($value) && !$value instanceof \DateTimeInterface && !$value instanceof \DateInterval) {
4849
$this->context->buildViolation($constraint->invalidMessage)
4950
->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE))
5051
->setCode(Range::INVALID_CHARACTERS_ERROR)
@@ -56,11 +57,15 @@ public function validate($value, Constraint $constraint)
5657
$min = $this->getLimit($constraint->minPropertyPath, $constraint->min, $constraint);
5758
$max = $this->getLimit($constraint->maxPropertyPath, $constraint->max, $constraint);
5859

59-
// Convert strings to DateTimes if comparing another DateTime
60-
// This allows to compare with any date/time value supported by
61-
// the DateTime constructor:
62-
// https://php.net/datetime.formats
60+
$minIsDateIntervalComparison = false;
61+
$maxIsDateIntervalComparison = false;
62+
6363
if ($value instanceof \DateTimeInterface) {
64+
// Convert strings to DateTimes if comparing another DateTime
65+
// This allows to compare with any date/time value supported by
66+
// the DateTime constructor:
67+
// https://php.net/datetime.formats
68+
6469
$dateTimeClass = null;
6570

6671
if (\is_string($min)) {
@@ -82,16 +87,37 @@ public function validate($value, Constraint $constraint)
8287
throw new ConstraintDefinitionException(sprintf('The max value "%s" could not be converted to a "%s" instance in the "%s" constraint.', $max, $dateTimeClass, \get_class($constraint)));
8388
}
8489
}
90+
} elseif (($minIsDateIntervalComparison = DateIntervalComparisonHelper::supports($value, $min)) || ($maxIsDateIntervalComparison = DateIntervalComparisonHelper::supports($value, $max))) {
91+
$originalValue = $value;
92+
$value = DateIntervalComparisonHelper::convertValue($dateIntervalReference = new \DateTimeImmutable(), $value);
93+
94+
if ($minIsDateIntervalComparison) {
95+
try {
96+
$min = DateIntervalComparisonHelper::convertComparedValue($dateIntervalReference, $min);
97+
} catch (\InvalidArgumentException $e) {
98+
throw new ConstraintDefinitionException(sprintf('The max value "%s" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $max, \get_class($constraint)));
99+
}
100+
101+
$maxIsDateIntervalComparison = DateIntervalComparisonHelper::supports($originalValue, $max);
102+
}
103+
104+
if ($maxIsDateIntervalComparison) {
105+
try {
106+
$max = DateIntervalComparisonHelper::convertComparedValue($dateIntervalReference, $max);
107+
} catch (\InvalidArgumentException $e) {
108+
throw new ConstraintDefinitionException(sprintf('The min value "%s" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $min, \get_class($constraint)));
109+
}
110+
}
85111
}
86112

87113
$hasLowerLimit = null !== $min;
88114
$hasUpperLimit = null !== $max;
89115

90116
if ($hasLowerLimit && $hasUpperLimit && ($value < $min || $value > $max)) {
91117
$violationBuilder = $this->context->buildViolation($constraint->notInRangeMessage)
92-
->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE))
93-
->setParameter('{{ min }}', $this->formatValue($min, self::PRETTY_DATE))
94-
->setParameter('{{ max }}', $this->formatValue($max, self::PRETTY_DATE))
118+
->setParameter('{{ value }}', $this->formatValue(!$minIsDateIntervalComparison && !$maxIsDateIntervalComparison ? $value : $originalValue, self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
119+
->setParameter('{{ min }}', $this->formatValue(!$minIsDateIntervalComparison ? $min : $dateIntervalReference->diff($min), self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
120+
->setParameter('{{ max }}', $this->formatValue(!$maxIsDateIntervalComparison ? $max : $dateIntervalReference->diff($max), self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
95121
->setCode(Range::NOT_IN_RANGE_ERROR);
96122

97123
if (null !== $constraint->maxPropertyPath) {
@@ -109,8 +135,8 @@ public function validate($value, Constraint $constraint)
109135

110136
if ($hasUpperLimit && $value > $max) {
111137
$violationBuilder = $this->context->buildViolation($constraint->maxMessage)
112-
->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE))
113-
->setParameter('{{ limit }}', $this->formatValue($max, self::PRETTY_DATE))
138+
->setParameter('{{ value }}', $this->formatValue(!$minIsDateIntervalComparison && !$maxIsDateIntervalComparison ? $value : $originalValue, self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
139+
->setParameter('{{ limit }}', $this->formatValue(!$maxIsDateIntervalComparison ? $max : $dateIntervalReference->diff($max), self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
114140
->setCode(Range::TOO_HIGH_ERROR);
115141

116142
if (null !== $constraint->maxPropertyPath) {
@@ -128,8 +154,8 @@ public function validate($value, Constraint $constraint)
128154

129155
if ($hasLowerLimit && $value < $min) {
130156
$violationBuilder = $this->context->buildViolation($constraint->minMessage)
131-
->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE))
132-
->setParameter('{{ limit }}', $this->formatValue($min, self::PRETTY_DATE))
157+
->setParameter('{{ value }}', $this->formatValue(!$minIsDateIntervalComparison && !$maxIsDateIntervalComparison ? $value : $originalValue, self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
158+
->setParameter('{{ limit }}', $this->formatValue(!$minIsDateIntervalComparison ? $min : $dateIntervalReference->diff($min), self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
133159
->setCode(Range::TOO_LOW_ERROR);
134160

135161
if (null !== $constraint->maxPropertyPath) {

src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public function formatValueProvider()
3030
$defaultTimezone = date_default_timezone_get();
3131
date_default_timezone_set('Europe/Moscow'); // GMT+3
3232

33+
$negativeDateInterval = new \DateInterval('PT30S');
34+
$negativeDateInterval->invert = 1;
35+
3336
$data = [
3437
['true', true],
3538
['false', false],
@@ -44,6 +47,13 @@ public function formatValueProvider()
4447
[class_exists(\IntlDateFormatter::class) ? 'Feb 2, 1971, 8:00 AM' : '1971-02-02 08:00:00', $dateTime, ConstraintValidator::PRETTY_DATE],
4548
[class_exists(\IntlDateFormatter::class) ? 'Jan 1, 1970, 6:00 AM' : '1970-01-01 06:00:00', new \DateTimeImmutable('1970-01-01T06:00:00Z'), ConstraintValidator::PRETTY_DATE],
4649
[class_exists(\IntlDateFormatter::class) ? 'Jan 1, 1970, 3:00 PM' : '1970-01-01 15:00:00', (new \DateTimeImmutable('1970-01-01T23:00:00'))->setTimezone(new \DateTimeZone('America/New_York')), ConstraintValidator::PRETTY_DATE],
50+
['object', new \DateInterval('PT30S')],
51+
['1 year, 1 month, 1 day, 1 hour, 1 minute and 1 second', new \DateInterval('P1Y1M1DT1H1M1S'), ConstraintValidator::PRETTY_DATE_INTERVAL],
52+
['3 months and 4 seconds', new \DateInterval('P3MT4S'), ConstraintValidator::PRETTY_DATE_INTERVAL],
53+
['0', new \DateInterval('PT0S'), ConstraintValidator::PRETTY_DATE_INTERVAL],
54+
['0', ($dateTime = new \DateTimeImmutable())->diff($dateTime), ConstraintValidator::PRETTY_DATE_INTERVAL],
55+
['7 days', new \DateInterval('P1W'), ConstraintValidator::PRETTY_DATE_INTERVAL],
56+
['-30 seconds', $negativeDateInterval, ConstraintValidator::PRETTY_DATE_INTERVAL],
4757
];
4858

4959
date_default_timezone_set($defaultTimezone);

src/Symfony/Component/Validator/Tests/Constraints/EqualToValidatorTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ protected function getErrorCode(): ?string
4040
*/
4141
public function provideValidComparisons(): array
4242
{
43+
$negativeDateInterval = new \DateInterval('PT1H');
44+
$negativeDateInterval->invert = 1;
45+
4346
return [
4447
[3, 3],
4548
[3, '3'],
@@ -49,6 +52,9 @@ public function provideValidComparisons(): array
4952
[new \DateTime('2000-01-01 UTC'), '2000-01-01 UTC'],
5053
[new ComparisonTest_Class(5), new ComparisonTest_Class(5)],
5154
[null, 1],
55+
['1 == 1 (string)' => new \DateInterval('PT1H'), '+1 hour'],
56+
['1 == 1 (\DateInterval instance)' => new \DateInterval('PT1H'), new \DateInterval('PT1H')],
57+
['-1 == -1' => $negativeDateInterval, '-1 hour'],
5258
];
5359
}
5460

@@ -67,13 +73,19 @@ public function provideValidComparisonsToPropertyPath(): array
6773
*/
6874
public function provideInvalidComparisons(): array
6975
{
76+
$negativeDateInterval = new \DateInterval('PT1H');
77+
$negativeDateInterval->invert = 1;
78+
7079
return [
7180
[1, '1', 2, '2', 'integer'],
7281
['22', '"22"', '333', '"333"', 'string'],
7382
[new \DateTime('2001-01-01'), 'Jan 1, 2001, 12:00 AM', new \DateTime('2000-01-01'), 'Jan 1, 2000, 12:00 AM', 'DateTime'],
7483
[new \DateTime('2001-01-01'), 'Jan 1, 2001, 12:00 AM', '2000-01-01', 'Jan 1, 2000, 12:00 AM', 'DateTime'],
7584
[new \DateTime('2001-01-01 UTC'), 'Jan 1, 2001, 12:00 AM', '2000-01-01 UTC', 'Jan 1, 2000, 12:00 AM', 'DateTime'],
7685
[new ComparisonTest_Class(4), '4', new ComparisonTest_Class(5), '5', __NAMESPACE__.'\ComparisonTest_Class'],
86+
['1 != 2 (string)' => new \DateInterval('PT1H'), '1 hour', '+2 hour', '2 hours', \DateInterval::class],
87+
['1 != 2 (\DateInterval instance)' => new \DateInterval('PT1H'), '1 hour', new \DateInterval('PT2H'), '2 hours', \DateInterval::class],
88+
['-1 != -2' => $negativeDateInterval, '-1 hour', '-2 hours', '-2 hours', \DateInterval::class],
7789
];
7890
}
7991

src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualValidatorTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ protected function getErrorCode(): ?string
4040
*/
4141
public function provideValidComparisons(): array
4242
{
43+
$negativeDateInterval = new \DateInterval('PT30S');
44+
$negativeDateInterval->invert = 1;
45+
4346
return [
4447
[3, 2],
4548
[1, 1],
@@ -52,6 +55,12 @@ public function provideValidComparisons(): array
5255
['a', 'a'],
5356
['z', 'a'],
5457
[null, 1],
58+
['30 > 29 (string)' => new \DateInterval('PT30S'), '+29 seconds'],
59+
['30 > 29 (\DateInterval instance)' => new \DateInterval('PT30S'), new \DateInterval('PT29S')],
60+
['30 = 30 (string)' => new \DateInterval('PT30S'), '+30 seconds'],
61+
['30 = 30 (\DateInterval instance)' => new \DateInterval('PT30S'), new \DateInterval('PT30S')],
62+
['-30 > -31' => $negativeDateInterval, '-31 seconds'],
63+
['-30 = -30' => $negativeDateInterval, '-30 seconds'],
5564
];
5665
}
5766

@@ -71,12 +80,18 @@ public function provideValidComparisonsToPropertyPath(): array
7180
*/
7281
public function provideInvalidComparisons(): array
7382
{
83+
$negativeDateInterval = new \DateInterval('PT30S');
84+
$negativeDateInterval->invert = 1;
85+
7486
return [
7587
[1, '1', 2, '2', 'integer'],
7688
[new \DateTime('2000/01/01'), 'Jan 1, 2000, 12:00 AM', new \DateTime('2005/01/01'), 'Jan 1, 2005, 12:00 AM', 'DateTime'],
7789
[new \DateTime('2000/01/01'), 'Jan 1, 2000, 12:00 AM', '2005/01/01', 'Jan 1, 2005, 12:00 AM', 'DateTime'],
7890
[new \DateTime('2000/01/01 UTC'), 'Jan 1, 2000, 12:00 AM', '2005/01/01 UTC', 'Jan 1, 2005, 12:00 AM', 'DateTime'],
7991
['b', '"b"', 'c', '"c"', 'string'],
92+
['30 < 31 (string)' => new \DateInterval('PT30S'), '30 seconds', '+31 seconds', '31 seconds', \DateInterval::class],
93+
['30 < 31 (\DateInterval instance)' => new \DateInterval('PT30S'), '30 seconds', new \DateInterval('PT31S'), '31 seconds', \DateInterval::class],
94+
['-30 < -29' => $negativeDateInterval, '-30 seconds', '-29 seconds', '-29 seconds', \DateInterval::class],
8095
];
8196
}
8297

src/Symfony/Component/Validator/Tests/Constraints/GreaterThanOrEqualValidatorWithPositiveOrZeroConstraintTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public function provideValidComparisons(): array
3838
['0', '0'],
3939
['333', '0'],
4040
[null, 0],
41+
['30 >= 0' => new \DateInterval('PT30S'), 0],
42+
['0 >= 0' => new \DateInterval('PT0S'), 0],
4143
];
4244
}
4345

@@ -46,10 +48,14 @@ public function provideValidComparisons(): array
4648
*/
4749
public function provideInvalidComparisons(): array
4850
{
51+
$negativeDateInterval = new \DateInterval('PT45S');
52+
$negativeDateInterval->invert = 1;
53+
4954
return [
5055
[-1, '-1', 0, '0', 'integer'],
5156
[-2, '-2', 0, '0', 'integer'],
5257
[-2.5, '-2.5', 0, '0', 'integer'],
58+
['-45 < 0' => $negativeDateInterval, '-45 seconds', 0, '0', \DateInterval::class],
5359
];
5460
}
5561

0 commit comments

Comments
 (0)