diff --git a/src/Symfony/Component/Validator/Constraints/Timezone.php b/src/Symfony/Component/Validator/Constraints/Timezone.php index bf6fece9e91e..0f4b57b0875a 100644 --- a/src/Symfony/Component/Validator/Constraints/Timezone.php +++ b/src/Symfony/Component/Validator/Constraints/Timezone.php @@ -26,15 +26,18 @@ class Timezone extends Constraint public const TIMEZONE_IDENTIFIER_ERROR = '5ce113e6-5e64-4ea2-90fe-d2233956db13'; public const TIMEZONE_IDENTIFIER_IN_ZONE_ERROR = 'b57767b1-36c0-40ac-a3d7-629420c775b8'; public const TIMEZONE_IDENTIFIER_IN_COUNTRY_ERROR = 'c4a22222-dc92-4fc0-abb0-d95b268c7d0b'; + public const TIMEZONE_IDENTIFIER_INTL_ERROR = '45863c26-88dc-41ba-bf53-c73bd1f7e90d'; public $zone = \DateTimeZone::ALL; public $countryCode; + public $intlCompatible = false; public $message = 'This value is not a valid timezone.'; protected static $errorNames = [ self::TIMEZONE_IDENTIFIER_ERROR => 'TIMEZONE_IDENTIFIER_ERROR', self::TIMEZONE_IDENTIFIER_IN_ZONE_ERROR => 'TIMEZONE_IDENTIFIER_IN_ZONE_ERROR', self::TIMEZONE_IDENTIFIER_IN_COUNTRY_ERROR => 'TIMEZONE_IDENTIFIER_IN_COUNTRY_ERROR', + self::TIMEZONE_IDENTIFIER_INTL_ERROR => 'TIMEZONE_IDENTIFIER_INTL_ERROR', ]; /** @@ -51,5 +54,8 @@ public function __construct(array $options = null) } elseif (\DateTimeZone::PER_COUNTRY !== (\DateTimeZone::PER_COUNTRY & $this->zone)) { throw new ConstraintDefinitionException('The option "countryCode" can only be used when the "zone" option is configured with "\DateTimeZone::PER_COUNTRY".'); } + if ($this->intlCompatible && !class_exists(\IntlTimeZone::class)) { + throw new ConstraintDefinitionException('The option "intlCompatible" can only be used when the PHP intl extension is available.'); + } } } diff --git a/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php b/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php index a9941ad987db..ac0d751a4532 100644 --- a/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php +++ b/src/Symfony/Component/Validator/Constraints/TimezoneValidator.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\Intl\Exception\MissingResourceException; +use Symfony\Component\Intl\Timezones; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\UnexpectedTypeException; @@ -43,14 +45,28 @@ public function validate($value, Constraint $constraint) $value = (string) $value; - // @see: https://bugs.php.net/bug.php?id=75928 + if ($constraint->intlCompatible && 'Etc/Unknown' === \IntlTimeZone::createTimeZone($value)->getID()) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode(Timezone::TIMEZONE_IDENTIFIER_INTL_ERROR) + ->addViolation(); + + return; + } + if ($constraint->countryCode) { - $timezoneIds = @\DateTimeZone::listIdentifiers($constraint->zone, $constraint->countryCode) ?: []; + $phpTimezoneIds = @\DateTimeZone::listIdentifiers($constraint->zone, $constraint->countryCode) ?: []; + try { + $intlTimezoneIds = Timezones::forCountryCode($constraint->countryCode); + } catch (MissingResourceException $e) { + $intlTimezoneIds = []; + } } else { - $timezoneIds = \DateTimeZone::listIdentifiers($constraint->zone); + $phpTimezoneIds = \DateTimeZone::listIdentifiers($constraint->zone); + $intlTimezoneIds = self::getIntlTimezones($constraint->zone); } - if (\in_array($value, $timezoneIds, true)) { + if (\in_array($value, $phpTimezoneIds, true) || \in_array($value, $intlTimezoneIds, true)) { return; } @@ -63,9 +79,9 @@ public function validate($value, Constraint $constraint) } $this->context->buildViolation($constraint->message) - ->setParameter('{{ value }}', $this->formatValue($value)) - ->setCode($code) - ->addViolation(); + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode($code) + ->addViolation(); } /** @@ -89,4 +105,26 @@ protected function formatValue($value, $format = 0) return array_search($value, (new \ReflectionClass(\DateTimeZone::class))->getConstants(), true) ?: $value; } + + private static function getIntlTimezones(int $zone): array + { + $timezones = Timezones::getIds(); + + if (\DateTimeZone::ALL === (\DateTimeZone::ALL & $zone)) { + return $timezones; + } + + $filtered = []; + foreach ((new \ReflectionClass(\DateTimeZone::class))->getConstants() as $const => $flag) { + if ($flag !== ($flag & $zone)) { + continue; + } + + $filtered[] = array_filter($timezones, static function ($id) use ($const) { + return 0 === stripos($id, $const.'/'); + }); + } + + return $filtered ? array_merge(...$filtered) : []; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php index a81e642cdfaa..c5dc5c40d458 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/TimezoneValidatorTest.php @@ -60,7 +60,26 @@ public function testValidTimezones(string $timezone) public function getValidTimezones(): iterable { + // ICU standard (alias/BC in PHP) + yield ['Etc/UTC']; + yield ['Etc/GMT']; + yield ['America/Buenos_Aires']; + + // PHP standard (alias in ICU) + yield ['UTC']; yield ['America/Argentina/Buenos_Aires']; + + // not deprecated in ICU + yield ['CST6CDT']; + yield ['EST5EDT']; + yield ['MST7MDT']; + yield ['PST8PDT']; + yield ['America/Montreal']; + + // expired in ICU + yield ['Europe/Saratov']; + + // standard yield ['America/Barbados']; yield ['America/Toronto']; yield ['Antarctica/Syowa']; @@ -71,7 +90,6 @@ public function getValidTimezones(): iterable yield ['Europe/Copenhagen']; yield ['Europe/Paris']; yield ['Pacific/Noumea']; - yield ['UTC']; } /** @@ -90,6 +108,8 @@ public function testValidGroupedTimezones(string $timezone, int $zone) public function getValidGroupedTimezones(): iterable { + yield ['America/Buenos_Aires', \DateTimeZone::AMERICA | \DateTimeZone::AUSTRALIA]; // icu + yield ['America/Argentina/Buenos_Aires', \DateTimeZone::AMERICA]; // php yield ['America/Argentina/Cordoba', \DateTimeZone::AMERICA]; yield ['America/Barbados', \DateTimeZone::AMERICA]; yield ['Africa/Cairo', \DateTimeZone::AFRICA]; @@ -124,6 +144,7 @@ public function testInvalidTimezoneWithoutZone(string $timezone) public function getInvalidTimezones(): iterable { + yield ['Buenos_Aires/America']; yield ['Buenos_Aires/Argentina/America']; yield ['Mayotte/Indian']; yield ['foobar']; @@ -149,11 +170,15 @@ public function testInvalidGroupedTimezones(string $timezone, int $zone) public function getInvalidGroupedTimezones(): iterable { + yield ['America/Buenos_Aires', \DateTimeZone::ASIA | \DateTimeZone::AUSTRALIA]; // icu + yield ['America/Argentina/Buenos_Aires', \DateTimeZone::EUROPE]; // php yield ['Antarctica/McMurdo', \DateTimeZone::AMERICA]; yield ['America/Barbados', \DateTimeZone::ANTARCTICA]; yield ['Europe/Kiev', \DateTimeZone::ARCTIC]; yield ['Asia/Ho_Chi_Minh', \DateTimeZone::INDIAN]; yield ['Asia/Ho_Chi_Minh', \DateTimeZone::INDIAN | \DateTimeZone::ANTARCTICA]; + yield ['UTC', \DateTimeZone::EUROPE]; + yield ['Etc/UTC', \DateTimeZone::EUROPE]; } /** @@ -173,6 +198,8 @@ public function testValidGroupedTimezonesByCountry(string $timezone, string $cou public function getValidGroupedTimezonesByCountry(): iterable { + yield ['America/Buenos_Aires', 'AR']; // icu + yield ['America/Argentina/Buenos_Aires', 'AR']; // php yield ['America/Argentina/Cordoba', 'AR']; yield ['America/Barbados', 'BB']; yield ['Africa/Cairo', 'EG']; @@ -215,6 +242,7 @@ public function getInvalidGroupedTimezonesByCountry(): iterable yield ['America/Argentina/Cordoba', 'FR']; yield ['America/Barbados', 'PT']; yield ['Europe/Bern', 'FR']; + yield ['Etc/UTC', 'NL']; yield ['Europe/Amsterdam', 'AC']; // "AC" has no timezones, but is a valid country code } @@ -267,8 +295,6 @@ public function testDeprecatedTimezonesAreInvalidWithoutBC(string $timezone) public function getDeprecatedTimezones(): iterable { - yield ['America/Buenos_Aires']; - yield ['America/Montreal']; yield ['Australia/ACT']; yield ['Australia/LHI']; yield ['Australia/Queensland']; @@ -277,13 +303,29 @@ public function getDeprecatedTimezones(): iterable yield ['Canada/Mountain']; yield ['Canada/Pacific']; yield ['CET']; - yield ['CST6CDT']; - yield ['Etc/GMT']; + yield ['GMT']; yield ['Etc/Greenwich']; yield ['Etc/UCT']; yield ['Etc/Universal']; - yield ['Etc/UTC']; yield ['Etc/Zulu']; yield ['US/Pacific']; } + + /** + * @requires extension intl + */ + public function testIntlCompatibility() + { + $constraint = new Timezone([ + 'message' => 'myMessage', + 'intlCompatible' => true, + ]); + + $this->validator->validate('Europe/Saratov', $constraint); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', '"Europe/Saratov"') + ->setCode(Timezone::TIMEZONE_IDENTIFIER_INTL_ERROR) + ->assertRaised(); + } }