Skip to content

Commit

Permalink
feature #31292 [Validator] Allow intl timezones (ro0NL)
Browse files Browse the repository at this point in the history
This PR was merged into the 4.3-dev branch.

Discussion
----------

[Validator] Allow intl timezones

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no     <!-- see https://symfony.com/bc -->
| Deprecations? | no
| Tests pass?   | yes    <!-- please add some, will be required by reviewers -->
| Fixed tickets | ref #28836
| License       | MIT
| Doc PR        | symfony/symfony-docs#11502

This PR considers the ICU timezones (#28831) as valid, as well as the PHP ones.

Both can be used in `DateTimeZone`, but expired timezones cannot be used with `IntlTimeZone`. The `intlCompatibility` option ensures compatibility between both.

Practically this allows for both `UTC` and `Etc/UTC`, where the latter should be preferred. I.e. currently `Etc/UTC` is considered an invalid timezone ... which it's not.

Commits
-------

7294b59 [Validator] Allow intl timezones
  • Loading branch information
fabpot committed May 1, 2019
2 parents 798f8bb + 7294b59 commit 2b923a7
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 13 deletions.
6 changes: 6 additions & 0 deletions src/Symfony/Component/Validator/Constraints/Timezone.php
Expand Up @@ -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',
];

/**
Expand All @@ -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.');
}
}
}
52 changes: 45 additions & 7 deletions src/Symfony/Component/Validator/Constraints/TimezoneValidator.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

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

/**
Expand All @@ -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) : [];
}
}
Expand Up @@ -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'];
Expand All @@ -71,7 +90,6 @@ public function getValidTimezones(): iterable
yield ['Europe/Copenhagen'];
yield ['Europe/Paris'];
yield ['Pacific/Noumea'];
yield ['UTC'];
}

/**
Expand All @@ -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];
Expand Down Expand Up @@ -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'];
Expand All @@ -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];
}

/**
Expand All @@ -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'];
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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'];
Expand All @@ -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();
}
}

0 comments on commit 2b923a7

Please sign in to comment.