Skip to content
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

[Validator] Support \DateInterval in comparison constraints #33401

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Expand Up @@ -8,6 +8,7 @@ CHANGELOG
* added option `alpha3` to `Country` constraint
* allow to define a reusable set of constraints by extending the `Compound` constraint
* added `Sequentially` constraint, to sequentially validate a set of constraints (any violation raised will prevent further validation of the nested constraints)
* added support to validate `\DateInterval` instances with relative string date formats in the `Range` and all comparisons constraints

5.0.0
-----
Expand Down
37 changes: 37 additions & 0 deletions src/Symfony/Component/Validator/ConstraintValidator.php
Expand Up @@ -31,6 +31,12 @@ abstract class ConstraintValidator implements ConstraintValidatorInterface
*/
const OBJECT_TO_STRING = 2;

/**
* Whether to format {@link \DateInterval} objects as human readable strings
* eg: 6 hours, 1 minute and 2 seconds.
*/
const PRETTY_DATE_INTERVAL = 4;

/**
* @var ExecutionContextInterface
*/
Expand Down Expand Up @@ -98,6 +104,37 @@ protected function formatValue($value, int $format = 0)
return $value->format('Y-m-d H:i:s');
}

if (($format & self::PRETTY_DATE_INTERVAL) && $value instanceof \DateInterval) {
$formattedValueParts = [];
foreach ([
fancyweb marked this conversation as resolved.
Show resolved Hide resolved
'y' => 'year',
'm' => 'month',
'd' => 'day',
'h' => 'hour',
'i' => 'minute',
's' => 'second',
'f' => 'microsecond',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this formatting logic is English-only, which looks bad.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we did for dates just above, the format is hardcoded here except in this case there are words.

] as $p => $label) {
if (!$formattedValue = $value->format('%'.$p)) {
continue;
}

if ($formattedValue > 1) {
$label .= 's';
}

$formattedValueParts[] = $formattedValue.' '.$label;
}

if (!$formattedValueParts) {
return '0';
}

$lastFormattedValuePart = array_pop($formattedValueParts);

return $value->format('%r').(!$formattedValueParts ? $lastFormattedValuePart : implode(', ', $formattedValueParts).' and '.$lastFormattedValuePart);
fancyweb marked this conversation as resolved.
Show resolved Hide resolved
}

if (\is_object($value)) {
if (($format & self::OBJECT_TO_STRING) && method_exists($value, '__toString')) {
return $value->__toString();
Expand Down
Expand Up @@ -18,6 +18,7 @@
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Util\DateIntervalComparisonHelper;

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

// Convert strings to DateTimes if comparing another DateTime
// This allows to compare with any date/time value supported by
// the DateTime constructor:
// https://php.net/datetime.formats
$isDateIntervalComparison = false;

if (\is_string($comparedValue) && $value instanceof \DateTimeInterface) {
// Convert strings to DateTimes if comparing another DateTime
// This allows to compare with any date/time value supported by
// the DateTime constructor:
// https://php.net/datetime.formats

// If $value is immutable, convert the compared value to a DateTimeImmutable too, otherwise use DateTime
$dateTimeClass = $value instanceof \DateTimeImmutable ? \DateTimeImmutable::class : \DateTime::class;

Expand All @@ -74,13 +78,22 @@ public function validate($value, Constraint $constraint)
} catch (\Exception $e) {
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)));
}
} elseif ($isDateIntervalComparison = DateIntervalComparisonHelper::supports($value, $comparedValue)) {
$originalValue = $value;
$value = DateIntervalComparisonHelper::convertValue($dateIntervalReference = new \DateTimeImmutable(), $value);

try {
$comparedValue = DateIntervalComparisonHelper::convertComparedValue($dateIntervalReference, $comparedValue);
} catch (\InvalidArgumentException $e) {
throw new ConstraintDefinitionException(sprintf('The compared value "%s" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $comparedValue, \get_class($constraint)));
}
}

if (!$this->compareValues($value, $comparedValue)) {
$violationBuilder = $this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $this->formatValue($value, self::OBJECT_TO_STRING | self::PRETTY_DATE))
->setParameter('{{ compared_value }}', $this->formatValue($comparedValue, self::OBJECT_TO_STRING | self::PRETTY_DATE))
->setParameter('{{ compared_value_type }}', $this->formatTypeOf($comparedValue))
->setParameter('{{ value }}', $this->formatValue(!$isDateIntervalComparison ? $value : $originalValue, self::OBJECT_TO_STRING | self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
->setParameter('{{ compared_value }}', $this->formatValue($messageComparedValue = (!$isDateIntervalComparison ? $comparedValue : $dateIntervalReference->diff($comparedValue)), self::OBJECT_TO_STRING | self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
->setParameter('{{ compared_value_type }}', $this->formatTypeOf($messageComparedValue))
->setCode($this->getErrorCode());

if (null !== $path) {
Expand Down
50 changes: 38 additions & 12 deletions src/Symfony/Component/Validator/Constraints/RangeValidator.php
Expand Up @@ -18,6 +18,7 @@
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Util\DateIntervalComparisonHelper;

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

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

// Convert strings to DateTimes if comparing another DateTime
// This allows to compare with any date/time value supported by
// the DateTime constructor:
// https://php.net/datetime.formats
$minIsDateIntervalComparison = false;
$maxIsDateIntervalComparison = false;

if ($value instanceof \DateTimeInterface) {
// Convert strings to DateTimes if comparing another DateTime
// This allows to compare with any date/time value supported by
// the DateTime constructor:
// https://php.net/datetime.formats

$dateTimeClass = null;

if (\is_string($min)) {
Expand All @@ -82,16 +87,37 @@ public function validate($value, Constraint $constraint)
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)));
}
}
} elseif (($minIsDateIntervalComparison = DateIntervalComparisonHelper::supports($value, $min)) || ($maxIsDateIntervalComparison = DateIntervalComparisonHelper::supports($value, $max))) {
fancyweb marked this conversation as resolved.
Show resolved Hide resolved
$originalValue = $value;
$value = DateIntervalComparisonHelper::convertValue($dateIntervalReference = new \DateTimeImmutable(), $value);

if ($minIsDateIntervalComparison) {
try {
$min = DateIntervalComparisonHelper::convertComparedValue($dateIntervalReference, $min);
} catch (\InvalidArgumentException $e) {
throw new ConstraintDefinitionException(sprintf('The max value "%s" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $max, \get_class($constraint)));
}

$maxIsDateIntervalComparison = DateIntervalComparisonHelper::supports($originalValue, $max);
}

if ($maxIsDateIntervalComparison) {
try {
$max = DateIntervalComparisonHelper::convertComparedValue($dateIntervalReference, $max);
} catch (\InvalidArgumentException $e) {
throw new ConstraintDefinitionException(sprintf('The min value "%s" could not be converted to a "DateTimeImmutable" instance in the "%s" constraint.', $min, \get_class($constraint)));
}
}
}

$hasLowerLimit = null !== $min;
$hasUpperLimit = null !== $max;

if ($hasLowerLimit && $hasUpperLimit && ($value < $min || $value > $max)) {
$violationBuilder = $this->context->buildViolation($constraint->notInRangeMessage)
->setParameter('{{ value }}', $this->formatValue($value, self::PRETTY_DATE))
->setParameter('{{ min }}', $this->formatValue($min, self::PRETTY_DATE))
->setParameter('{{ max }}', $this->formatValue($max, self::PRETTY_DATE))
->setParameter('{{ value }}', $this->formatValue(!$minIsDateIntervalComparison && !$maxIsDateIntervalComparison ? $value : $originalValue, self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
->setParameter('{{ min }}', $this->formatValue(!$minIsDateIntervalComparison ? $min : $dateIntervalReference->diff($min), self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
->setParameter('{{ max }}', $this->formatValue(!$maxIsDateIntervalComparison ? $max : $dateIntervalReference->diff($max), self::PRETTY_DATE | self::PRETTY_DATE_INTERVAL))
->setCode(Range::NOT_IN_RANGE_ERROR);

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

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

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

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

if (null !== $constraint->maxPropertyPath) {
Expand Down
10 changes: 10 additions & 0 deletions src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php
Expand Up @@ -30,6 +30,9 @@ public function formatValueProvider()
$defaultTimezone = date_default_timezone_get();
date_default_timezone_set('Europe/Moscow'); // GMT+3

$negativeDateInterval = new \DateInterval('PT30S');
$negativeDateInterval->invert = 1;

$data = [
['true', true],
['false', false],
Expand All @@ -44,6 +47,13 @@ public function formatValueProvider()
[class_exists(\IntlDateFormatter::class) ? 'Feb 2, 1971, 8:00 AM' : '1971-02-02 08:00:00', $dateTime, ConstraintValidator::PRETTY_DATE],
[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],
[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],
['object', new \DateInterval('PT30S')],
['1 year, 1 month, 1 day, 1 hour, 1 minute and 1 second', new \DateInterval('P1Y1M1DT1H1M1S'), ConstraintValidator::PRETTY_DATE_INTERVAL],
['3 months and 4 seconds', new \DateInterval('P3MT4S'), ConstraintValidator::PRETTY_DATE_INTERVAL],
['0', new \DateInterval('PT0S'), ConstraintValidator::PRETTY_DATE_INTERVAL],
['0', ($dateTime = new \DateTimeImmutable())->diff($dateTime), ConstraintValidator::PRETTY_DATE_INTERVAL],
['7 days', new \DateInterval('P1W'), ConstraintValidator::PRETTY_DATE_INTERVAL],
['-30 seconds', $negativeDateInterval, ConstraintValidator::PRETTY_DATE_INTERVAL],
];

date_default_timezone_set($defaultTimezone);
Expand Down
Expand Up @@ -40,6 +40,9 @@ protected function getErrorCode(): ?string
*/
public function provideValidComparisons(): array
{
$negativeDateInterval = new \DateInterval('PT1H');
$negativeDateInterval->invert = 1;

return [
[3, 3],
[3, '3'],
Expand All @@ -49,6 +52,9 @@ public function provideValidComparisons(): array
[new \DateTime('2000-01-01 UTC'), '2000-01-01 UTC'],
[new ComparisonTest_Class(5), new ComparisonTest_Class(5)],
[null, 1],
['1 == 1 (string)' => new \DateInterval('PT1H'), '+1 hour'],
['1 == 1 (\DateInterval instance)' => new \DateInterval('PT1H'), new \DateInterval('PT1H')],
['-1 == -1' => $negativeDateInterval, '-1 hour'],
];
}

Expand All @@ -67,13 +73,19 @@ public function provideValidComparisonsToPropertyPath(): array
*/
public function provideInvalidComparisons(): array
{
$negativeDateInterval = new \DateInterval('PT1H');
$negativeDateInterval->invert = 1;

return [
[1, '1', 2, '2', 'integer'],
['22', '"22"', '333', '"333"', 'string'],
[new \DateTime('2001-01-01'), 'Jan 1, 2001, 12:00 AM', new \DateTime('2000-01-01'), 'Jan 1, 2000, 12:00 AM', 'DateTime'],
[new \DateTime('2001-01-01'), 'Jan 1, 2001, 12:00 AM', '2000-01-01', 'Jan 1, 2000, 12:00 AM', 'DateTime'],
[new \DateTime('2001-01-01 UTC'), 'Jan 1, 2001, 12:00 AM', '2000-01-01 UTC', 'Jan 1, 2000, 12:00 AM', 'DateTime'],
[new ComparisonTest_Class(4), '4', new ComparisonTest_Class(5), '5', __NAMESPACE__.'\ComparisonTest_Class'],
['1 != 2 (string)' => new \DateInterval('PT1H'), '1 hour', '+2 hour', '2 hours', \DateInterval::class],
['1 != 2 (\DateInterval instance)' => new \DateInterval('PT1H'), '1 hour', new \DateInterval('PT2H'), '2 hours', \DateInterval::class],
['-1 != -2' => $negativeDateInterval, '-1 hour', '-2 hours', '-2 hours', \DateInterval::class],
];
}

Expand Down
Expand Up @@ -40,6 +40,9 @@ protected function getErrorCode(): ?string
*/
public function provideValidComparisons(): array
{
$negativeDateInterval = new \DateInterval('PT30S');
$negativeDateInterval->invert = 1;

return [
[3, 2],
[1, 1],
Expand All @@ -52,6 +55,12 @@ public function provideValidComparisons(): array
['a', 'a'],
['z', 'a'],
[null, 1],
['30 > 29 (string)' => new \DateInterval('PT30S'), '+29 seconds'],
['30 > 29 (\DateInterval instance)' => new \DateInterval('PT30S'), new \DateInterval('PT29S')],
['30 = 30 (string)' => new \DateInterval('PT30S'), '+30 seconds'],
['30 = 30 (\DateInterval instance)' => new \DateInterval('PT30S'), new \DateInterval('PT30S')],
['-30 > -31' => $negativeDateInterval, '-31 seconds'],
['-30 = -30' => $negativeDateInterval, '-30 seconds'],
];
}

Expand All @@ -71,12 +80,18 @@ public function provideValidComparisonsToPropertyPath(): array
*/
public function provideInvalidComparisons(): array
{
$negativeDateInterval = new \DateInterval('PT30S');
$negativeDateInterval->invert = 1;

return [
[1, '1', 2, '2', 'integer'],
[new \DateTime('2000/01/01'), 'Jan 1, 2000, 12:00 AM', new \DateTime('2005/01/01'), 'Jan 1, 2005, 12:00 AM', 'DateTime'],
[new \DateTime('2000/01/01'), 'Jan 1, 2000, 12:00 AM', '2005/01/01', 'Jan 1, 2005, 12:00 AM', 'DateTime'],
[new \DateTime('2000/01/01 UTC'), 'Jan 1, 2000, 12:00 AM', '2005/01/01 UTC', 'Jan 1, 2005, 12:00 AM', 'DateTime'],
['b', '"b"', 'c', '"c"', 'string'],
['30 < 31 (string)' => new \DateInterval('PT30S'), '30 seconds', '+31 seconds', '31 seconds', \DateInterval::class],
['30 < 31 (\DateInterval instance)' => new \DateInterval('PT30S'), '30 seconds', new \DateInterval('PT31S'), '31 seconds', \DateInterval::class],
['-30 < -29' => $negativeDateInterval, '-30 seconds', '-29 seconds', '-29 seconds', \DateInterval::class],
];
}

Expand Down
Expand Up @@ -38,6 +38,8 @@ public function provideValidComparisons(): array
['0', '0'],
['333', '0'],
[null, 0],
['30 >= 0' => new \DateInterval('PT30S'), 0],
['0 >= 0' => new \DateInterval('PT0S'), 0],
];
}

Expand All @@ -46,10 +48,14 @@ public function provideValidComparisons(): array
*/
public function provideInvalidComparisons(): array
{
$negativeDateInterval = new \DateInterval('PT45S');
$negativeDateInterval->invert = 1;

return [
[-1, '-1', 0, '0', 'integer'],
[-2, '-2', 0, '0', 'integer'],
[-2.5, '-2.5', 0, '0', 'integer'],
['-45 < 0' => $negativeDateInterval, '-45 seconds', 0, '0', \DateInterval::class],
];
}

Expand Down