diff --git a/src/Symfony/Component/Validator/Constraints/Timezone.php b/src/Symfony/Component/Validator/Constraints/Timezone.php new file mode 100644 index 0000000000000..485e14b602a63 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Timezone.php @@ -0,0 +1,49 @@ + + * + * 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\Exception\ConstraintDefinitionException; + +/** + * @Annotation + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Javier Spagnoletti + */ +class Timezone extends Constraint +{ + const NO_SUCH_TIMEZONE_ERROR = '45de6628-3479-46d6-a210-00ad584f530a'; + const NO_SUCH_TIMEZONE_IN_ZONE_ERROR = 'b57767b1-36c0-40ac-a3d7-629420c775b8'; + const NO_SUCH_TIMEZONE_IN_COUNTRY_ERROR = 'c4a22222-dc92-4fc0-abb0-d95b268c7d0b'; + public $zone = \DateTimeZone::ALL; + public $countryCode; + public $message = 'This value is not a valid timezone.'; + + protected static $errorNames = array( + self::NO_SUCH_TIMEZONE_ERROR => 'NO_SUCH_TIMEZONE_ERROR', + self::NO_SUCH_TIMEZONE_IN_ZONE_ERROR => 'NO_SUCH_TIMEZONE_IN_ZONE_ERROR', + self::NO_SUCH_TIMEZONE_IN_COUNTRY_ERROR => 'NO_SUCH_TIMEZONE_IN_COUNTRY_ERROR', + ); + + /** + * {@inheritdoc} + */ + public function __construct(array $options = null) + { + parent::__construct($options); + + if ($this->countryCode && \DateTimeZone::PER_COUNTRY !== $this->zone) { + throw new ConstraintDefinitionException('The option "countryCode" can only be used when "zone" option has `\DateTimeZone::PER_COUNTRY` as value'); + } + } +} diff --git a/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php b/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php new file mode 100644 index 0000000000000..88030595d5e93 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php @@ -0,0 +1,95 @@ + + * + * 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; + +/** + * Validates whether a value is a valid timezone identifier. + * + * @author Javier Spagnoletti + */ +class TimezoneValidator extends ConstraintValidator +{ + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof Timezone) { + throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\Timezone'); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) { + throw new UnexpectedTypeException($value, 'string'); + } + + $value = (string) $value; + + // @see: https://bugs.php.net/bug.php?id=75928 + if ($constraint->countryCode) { + $timezoneIds = \DateTimeZone::listIdentifiers($constraint->zone, $constraint->countryCode); + } else { + $timezoneIds = \DateTimeZone::listIdentifiers($constraint->zone); + } + + if ($timezoneIds && !\in_array($value, $timezoneIds, true)) { + if ($constraint->countryCode) { + $code = Timezone::NO_SUCH_TIMEZONE_IN_COUNTRY_ERROR; + } elseif (\DateTimeZone::ALL !== $constraint->zone) { + $code = Timezone::NO_SUCH_TIMEZONE_IN_ZONE_ERROR; + } else { + $code = Timezone::NO_SUCH_TIMEZONE_ERROR; + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode($code) + ->addViolation(); + } + } + + /** + * {@inheritdoc} + */ + public function getDefaultOption() + { + return 'zone'; + } + + /** + * {@inheritdoc} + */ + protected function formatValue($value, $format = 0) + { + $value = parent::formatValue($value, $format); + if ($value) { + if (\DateTimeZone::PER_COUNTRY !== $value) { + $r = new \ReflectionClass(\DateTimeZone::class); + $consts = $r->getConstants(); + if ($zoneFound = array_search($value, $consts, true)) { + return $zoneFound; + } + + return $value; + } + } + + return $value; + } +} diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf index 4bb2760b418e3..ce058b2a3587a 100644 --- a/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf +++ b/src/Symfony/Component/Validator/Resources/translations/validators.en.xlf @@ -326,6 +326,10 @@ This value should be a multiple of {{ compared_value }}. This value should be a multiple of {{ compared_value }}. + + This value is not a valid timezone. + This value is not a valid timezone. + diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.es.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.es.xlf index 18eb8f4ca2da3..989f14592e280 100644 --- a/src/Symfony/Component/Validator/Resources/translations/validators.es.xlf +++ b/src/Symfony/Component/Validator/Resources/translations/validators.es.xlf @@ -326,6 +326,10 @@ This value should be a multiple of {{ compared_value }}. Este valor debería ser un múltiplo de {{ compared_value }}. + + This value is not a valid timezone. + Este valor no es una zona horaria válida. + diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php new file mode 100644 index 0000000000000..cd9e556a8885e --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneTest.php @@ -0,0 +1,63 @@ + + * + * 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\Timezone; + +/** + * @author Javier Spagnoletti + */ +class TimezoneTest extends TestCase +{ + public function testValidTimezoneConstraints() + { + $constraint = new Timezone(); + + $constraint = new Timezone(array( + 'message' => 'myMessage', + 'zone' => \DateTimeZone::PER_COUNTRY, + 'countryCode' => 'AR', + )); + + $constraint = new Timezone(array( + 'message' => 'myMessage', + 'zone' => \DateTimeZone::ALL, + )); + + // Make an assertion in order to avoid this test to be marked as risky + $this->assertInstanceOf(Timezone::class, $constraint); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\ConstraintDefinitionException + */ + public function testExceptionForGroupedTimezonesByCountryWithWrongTimezone() + { + $constraint = new Timezone(array( + 'message' => 'myMessage', + 'zone' => \DateTimeZone::ALL, + 'countryCode' => 'AR', + )); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\ConstraintDefinitionException + */ + public function testExceptionForGroupedTimezonesByCountryWithoutTimezone() + { + $constraint = new Timezone(array( + 'message' => 'myMessage', + 'countryCode' => 'AR', + )); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php new file mode 100644 index 0000000000000..41422e31d92db --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php @@ -0,0 +1,261 @@ + + * + * 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\Timezone; +use Symfony\Component\Validator\Constraints\TimezoneValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @author Javier Spagnoletti + */ +class TimezoneValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): TimezoneValidator + { + return new TimezoneValidator(); + } + + public function testNullIsValid() + { + $this->validator->validate(null, new Timezone()); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate('', new Timezone()); + + $this->assertNoViolation(); + } + + /** + * @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException + */ + public function testExpectsStringCompatibleType() + { + $this->validator->validate(new \stdClass(), new Timezone()); + } + + /** + * @dataProvider getValidTimezones + */ + public function testValidTimezones(string $timezone) + { + $this->validator->validate($timezone, new Timezone()); + + $this->assertNoViolation(); + } + + public function getValidTimezones(): iterable + { + return array( + array('America/Argentina/Buenos_Aires'), + array('America/Barbados'), + array('Antarctica/Syowa'), + array('Africa/Douala'), + array('Atlantic/Canary'), + array('Asia/Gaza'), + array('Europe/Copenhagen'), + ); + } + + /** + * @dataProvider getValidGroupedTimezones + */ + public function testValidGroupedTimezones(string $timezone, int $what) + { + $constraint = new Timezone(array( + 'zone' => $what, + )); + + $this->validator->validate($timezone, $constraint); + + $this->assertNoViolation(); + } + + public function getValidGroupedTimezones(): iterable + { + return array( + array('America/Argentina/Cordoba', \DateTimeZone::AMERICA), + array('America/Barbados', \DateTimeZone::AMERICA), + array('Africa/Cairo', \DateTimeZone::AFRICA), + array('Atlantic/Cape_Verde', \DateTimeZone::ATLANTIC), + array('Europe/Bratislava', \DateTimeZone::EUROPE), + array('Indian/Christmas', \DateTimeZone::INDIAN), + array('Pacific/Kiritimati', \DateTimeZone::ALL), + array('Pacific/Kiritimati', \DateTimeZone::ALL_WITH_BC), + array('Pacific/Kiritimati', \DateTimeZone::PACIFIC), + array('Arctic/Longyearbyen', \DateTimeZone::ARCTIC), + array('Asia/Beirut', \DateTimeZone::ASIA), + array('Atlantic/Bermuda', \DateTimeZone::ASIA | \DateTimeZone::ATLANTIC), + array('Atlantic/Azores', \DateTimeZone::ATLANTIC | \DateTimeZone::ASIA), + ); + } + + /** + * @dataProvider getInvalidTimezones + */ + public function testInvalidTimezonesWithoutZone(string $timezone) + { + $constraint = new Timezone(array( + 'message' => 'myMessage', + )); + + $this->validator->validate($timezone, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$timezone.'"') + ->setCode(Timezone::NO_SUCH_TIMEZONE_ERROR) + ->assertRaised(); + } + + public function getInvalidTimezones(): iterable + { + return array( + array('Buenos_Aires/Argentina/America'), + array('Mayotte/Indian'), + array('foobar'), + ); + } + + /** + * @dataProvider getInvalidGroupedTimezones + */ + public function testInvalidGroupedTimezones(string $timezone, int $what) + { + $constraint = new Timezone(array( + 'zone' => $what, + 'message' => 'myMessage', + )); + + $this->validator->validate($timezone, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$timezone.'"') + ->setCode(Timezone::NO_SUCH_TIMEZONE_IN_ZONE_ERROR) + ->assertRaised(); + } + + public function getInvalidGroupedTimezones(): iterable + { + return array( + array('Antarctica/McMurdo', \DateTimeZone::AMERICA), + array('America/Barbados', \DateTimeZone::ANTARCTICA), + array('Europe/Kiev', \DateTimeZone::ARCTIC), + array('Asia/Ho_Chi_Minh', \DateTimeZone::INDIAN), + array('Asia/Ho_Chi_Minh', \DateTimeZone::INDIAN | \DateTimeZone::ANTARCTICA), + ); + } + + /** + * @dataProvider getValidGroupedTimezonesByCountry + */ + public function testValidGroupedTimezonesByCountry(string $timezone, int $what, string $country) + { + $constraint = new Timezone(array( + 'zone' => $what, + 'countryCode' => $country, + )); + + $this->validator->validate($timezone, $constraint); + + $this->assertNoViolation(); + } + + public function getValidGroupedTimezonesByCountry(): iterable + { + return array( + array('America/Argentina/Cordoba', \DateTimeZone::PER_COUNTRY, 'AR'), + array('America/Barbados', \DateTimeZone::PER_COUNTRY, 'BB'), + array('Africa/Cairo', \DateTimeZone::PER_COUNTRY, 'EG'), + array('Atlantic/Cape_Verde', \DateTimeZone::PER_COUNTRY, 'CV'), + array('Europe/Bratislava', \DateTimeZone::PER_COUNTRY, 'SK'), + array('Indian/Christmas', \DateTimeZone::PER_COUNTRY, 'CX'), + array('Pacific/Kiritimati', \DateTimeZone::PER_COUNTRY, 'KI'), + array('Pacific/Kiritimati', \DateTimeZone::PER_COUNTRY, 'KI'), + array('Pacific/Kiritimati', \DateTimeZone::PER_COUNTRY, 'KI'), + array('Arctic/Longyearbyen', \DateTimeZone::PER_COUNTRY, 'SJ'), + array('Asia/Beirut', \DateTimeZone::PER_COUNTRY, 'LB'), + array('Atlantic/Bermuda', \DateTimeZone::PER_COUNTRY, 'BM'), + array('Atlantic/Azores', \DateTimeZone::PER_COUNTRY, 'PT'), + ); + } + + /** + * @dataProvider getInvalidGroupedTimezonesByCountry + */ + public function testInvalidGroupedTimezonesByCountry(string $timezone, int $what, string $country) + { + $constraint = new Timezone(array( + 'message' => 'myMessage', + 'zone' => $what, + 'countryCode' => $country, + )); + + $this->validator->validate($timezone, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$timezone.'"') + ->setCode(Timezone::NO_SUCH_TIMEZONE_IN_COUNTRY_ERROR) + ->assertRaised(); + } + + public function getInvalidGroupedTimezonesByCountry(): iterable + { + return array( + array('America/Argentina/Cordoba', \DateTimeZone::PER_COUNTRY, 'FR'), + array('America/Barbados', \DateTimeZone::PER_COUNTRY, 'PT'), + ); + } + + /** + * @dataProvider getDeprecatedTimezones + */ + public function testDeprecatedTimezonesAreVaildWithBC(string $timezone) + { + $constraint = new Timezone(array( + 'zone' => \DateTimeZone::ALL_WITH_BC, + )); + + $this->validator->validate($timezone, $constraint); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getDeprecatedTimezones + */ + public function testDeprecatedTimezonesAreInvaildWithoutBC(string $timezone) + { + $constraint = new Timezone(array( + 'message' => 'myMessage', + )); + + $this->validator->validate($timezone, $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"'.$timezone.'"') + ->setCode(Timezone::NO_SUCH_TIMEZONE_ERROR) + ->assertRaised(); + } + + public function getDeprecatedTimezones(): iterable + { + return array( + array('America/Buenos_Aires'), + array('Etc/GMT'), + array('US/Pacific'), + ); + } +}