From 6267b9ee1adb637f1bda0b7c2ec898f47d94da79 Mon Sep 17 00:00:00 2001 From: Carsten Brandt <mail@cebe.cc> Date: Tue, 30 Sep 2014 02:15:45 +0200 Subject: [PATCH] Fixed issue with timezone conversion in formatter related to #5128 --- docs/guide/output-formatter.md | 2 ++ framework/CHANGELOG.md | 2 ++ framework/UPGRADE.md | 6 ++++++ framework/i18n/Formatter.php | 55 ++++++++++++++++++++++++++++++++++++++----------------- tests/unit/framework/i18n/FormatterTest.php | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 17 deletions(-) diff --git a/docs/guide/output-formatter.md b/docs/guide/output-formatter.md index 485fe52..8939f10 100644 --- a/docs/guide/output-formatter.md +++ b/docs/guide/output-formatter.md @@ -88,6 +88,8 @@ See http://site.icu-project.org/ for the format. and now in human readable form. +The input value for date and time formatting is assumed to be in UTC unless a timezone is explicitly given. + Formatting Numbers ------------------ diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index cfdd605..eabd0fd 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -4,6 +4,7 @@ Yii Framework 2 Change Log 2.0.0 under development ----------------------- +- Bug: Date and time formatting now assumes UTC as the timezone for input dates unless a timezone is explicitly given (cebe) - Enh #4275: Added `removeChildren()` to `yii\rbac\ManagerInterface` and implementations (samdark) @@ -623,6 +624,7 @@ Yii Framework 2 Change Log - New: Added various authentication methods, including `HttpBasicAuth`, `HttpBearerAuth`, `QueryParamAuth`, and `CompositeAuth` (qiangxue) - New: Added `HtmlResponseFormatter` and `JsonResponseFormatter` (qiangxue) + 2.0.0-alpha, December 1, 2013 ----------------------------- diff --git a/framework/UPGRADE.md b/framework/UPGRADE.md index bfb0f65..66028cd 100644 --- a/framework/UPGRADE.md +++ b/framework/UPGRADE.md @@ -13,6 +13,12 @@ Upgrade from Yii 2.0 RC * If you've implemented `yii\rbac\ManagerInterface` you need to add implementation for new method `removeChildren()`. +* The input dates for datetime formatting are now assumed to be in UTC unless a timezone is explicitly given. + Before, the timezone assumed for input dates was the default timezone set by PHP which is the same as `Yii::$app->timeZone`. + This causes trouble because the formatter uses `Yii::$app->timeZone` as the default values for output so no timezone conversion + was possible. If your timestamps are stored in the database without a timezone identifier you have to ensure they are in UTC or + add a timezone identifier explicitly. + Upgrade from Yii 2.0 Beta ------------------------- diff --git a/framework/i18n/Formatter.php b/framework/i18n/Formatter.php index 7283a7c..3837ecc 100644 --- a/framework/i18n/Formatter.php +++ b/framework/i18n/Formatter.php @@ -8,6 +8,7 @@ namespace yii\i18n; use DateTime; +use DateTimeZone; use IntlDateFormatter; use NumberFormatter; use Yii; @@ -66,6 +67,9 @@ class Formatter extends Component * e.g. `UTC`, `Europe/Berlin` or `America/Chicago`. * Refer to the [php manual](http://www.php.net/manual/en/timezones.php) for available timezones. * If this property is not set, [[\yii\base\Application::timeZone]] will be used. + * + * Note that the input timezone is assumed to be UTC always if no timezone is included in the input date value. + * Make sure to store datetime values in UTC in your database. */ public $timeZone; /** @@ -387,8 +391,9 @@ class Formatter extends Component * types of value are supported: * * - an integer representing a UNIX timestamp - * - a string that can be parsed into a UNIX timestamp via `strtotime()` - * - a PHP DateTime object + * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php). + * The timestamp is assumed to be in UTC unless a timezone is explicitly given. + * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object * * @param string $format the format used to convert the value into a date string. * If null, [[dateFormat]] will be used. @@ -399,9 +404,9 @@ class Formatter extends Component * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the * PHP [date()](http://php.net/manual/de/function.date.php)-function. * + * @return string the formatted result. * @throws InvalidParamException if the input value can not be evaluated as a date value. * @throws InvalidConfigException if the date format is invalid. - * @return string the formatted result. * @see dateFormat */ public function asDate($value, $format = null) @@ -418,8 +423,9 @@ class Formatter extends Component * types of value are supported: * * - an integer representing a UNIX timestamp - * - a string that can be parsed into a UNIX timestamp via `strtotime()` - * - a PHP DateTime object + * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php). + * The timestamp is assumed to be in UTC unless a timezone is explicitly given. + * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object * * @param string $format the format used to convert the value into a date string. * If null, [[timeFormat]] will be used. @@ -430,9 +436,9 @@ class Formatter extends Component * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the * PHP [date()](http://php.net/manual/de/function.date.php)-function. * + * @return string the formatted result. * @throws InvalidParamException if the input value can not be evaluated as a date value. * @throws InvalidConfigException if the date format is invalid. - * @return string the formatted result. * @see timeFormat */ public function asTime($value, $format = null) @@ -449,8 +455,9 @@ class Formatter extends Component * types of value are supported: * * - an integer representing a UNIX timestamp - * - a string that can be parsed into a UNIX timestamp via `strtotime()` - * - a PHP DateTime object + * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php). + * The timestamp is assumed to be in UTC unless a timezone is explicitly given. + * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object * * @param string $format the format used to convert the value into a date string. * If null, [[dateFormat]] will be used. @@ -461,9 +468,9 @@ class Formatter extends Component * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the * PHP [date()](http://php.net/manual/de/function.date.php)-function. * + * @return string the formatted result. * @throws InvalidParamException if the input value can not be evaluated as a date value. * @throws InvalidConfigException if the date format is invalid. - * @return string the formatted result. * @see datetimeFormat */ public function asDatetime($value, $format = null) @@ -485,7 +492,14 @@ class Formatter extends Component ]; /** - * @param integer $value normalized datetime value + * @param integer|string|DateTime $value the value to be formatted. The following + * types of value are supported: + * + * - an integer representing a UNIX timestamp + * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php). + * The timestamp is assumed to be in UTC unless a timezone is explicitly given. + * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object + * * @param string $format the format used to convert the value into a date string. * @param string $type 'date', 'time', or 'datetime'. * @throws InvalidConfigException if the date format is invalid. @@ -524,7 +538,7 @@ class Formatter extends Component $format = FormatConverter::convertDateIcuToPhp($format, $type, $this->locale); } if ($this->timeZone != null) { - $timestamp->setTimezone(new \DateTimeZone($this->timeZone)); + $timestamp->setTimezone(new DateTimeZone($this->timeZone)); } return $timestamp->format($format); } @@ -533,7 +547,14 @@ class Formatter extends Component /** * Normalizes the given datetime value as a DateTime object that can be taken by various date/time formatting methods. * - * @param mixed $value the datetime value to be normalized. + * @param integer|string|DateTime $value the datetime value to be normalized. The following + * types of value are supported: + * + * - an integer representing a UNIX timestamp + * - a string that can be [parsed to create a DateTime object](http://php.net/manual/en/datetime.formats.php). + * The timestamp is assumed to be in UTC unless a timezone is explicitly given. + * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object + * * @return DateTime the normalized datetime value * @throws InvalidParamException if the input value can not be evaluated as a date value. */ @@ -548,17 +569,17 @@ class Formatter extends Component } try { if (is_numeric($value)) { // process as unix timestamp - if (($timestamp = DateTime::createFromFormat('U', $value)) === false) { + if (($timestamp = DateTime::createFromFormat('U', $value, new DateTimeZone('UTC'))) === false) { throw new InvalidParamException("Failed to parse '$value' as a UNIX timestamp."); } return $timestamp; - } elseif (($timestamp = DateTime::createFromFormat('Y-m-d', $value)) !== false) { // try Y-m-d format + } elseif (($timestamp = DateTime::createFromFormat('Y-m-d', $value, new DateTimeZone('UTC'))) !== false) { // try Y-m-d format (support invalid dates like 2012-13-01) return $timestamp; - } elseif (($timestamp = DateTime::createFromFormat('Y-m-d H:i:s', $value)) !== false) { // try Y-m-d H:i:s format + } elseif (($timestamp = DateTime::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC'))) !== false) { // try Y-m-d H:i:s format (support invalid dates like 2012-13-01 12:63:12) return $timestamp; } // finally try to create a DateTime object with the value - $timestamp = new DateTime($value); + $timestamp = new DateTime($value, new DateTimeZone('UTC')); return $timestamp; } catch(\Exception $e) { throw new InvalidParamException("'$value' is not a valid date time value: " . $e->getMessage() @@ -623,7 +644,7 @@ class Formatter extends Component return $this->nullDisplay; } } else { - $timezone = new \DateTimeZone($this->timeZone); + $timezone = new DateTimeZone($this->timeZone); if ($referenceTime === null) { $dateNow = new DateTime('now', $timezone); diff --git a/tests/unit/framework/i18n/FormatterTest.php b/tests/unit/framework/i18n/FormatterTest.php index 1cf26ab..81372f1 100644 --- a/tests/unit/framework/i18n/FormatterTest.php +++ b/tests/unit/framework/i18n/FormatterTest.php @@ -483,6 +483,90 @@ class FormatterTest extends TestCase } + public function provideTimezones() + { + return [ + ['UTC'], + ['Europe/Berlin'], + ['America/Jamaica'], + ]; + } + + /** + * provide default timezones times input date value + */ + public function provideTimesAndTz() + { + $result = []; + foreach($this->provideTimezones() as $tz) { + $result[] = [$tz[0], 1407674460, 1388580060]; + $result[] = [$tz[0], '2014-08-10 12:41:00', '2014-01-01 12:41:00']; + $result[] = [$tz[0], '2014-08-10 12:41:00 UTC', '2014-01-01 12:41:00 UTC']; + $result[] = [$tz[0], '2014-08-10 14:41:00 Europe/Berlin', '2014-01-01 13:41:00 Europe/Berlin']; + $result[] = [$tz[0], '2014-08-10 14:41:00 CEST', '2014-01-01 13:41:00 CET']; + $result[] = [$tz[0], '2014-08-10 14:41:00+0200', '2014-01-01 13:41:00+0100']; + $result[] = [$tz[0], '2014-08-10 14:41:00+02:00', '2014-01-01 13:41:00+01:00']; + $result[] = [$tz[0], '2014-08-10 14:41:00 +0200', '2014-01-01 13:41:00 +0100']; + $result[] = [$tz[0], '2014-08-10 14:41:00 +02:00', '2014-01-01 13:41:00 +01:00']; + $result[] = [$tz[0], '2014-08-10T14:41:00+02:00', '2014-01-01T13:41:00+01:00']; // ISO 8601 + } + return $result; + } + + /** + * Test timezones with input date and time in other timezones + * @dataProvider provideTimesAndTz + */ + public function testIntlTimezoneInput($defaultTz, $inputTimeDst, $inputTimeNonDst) + { + $this->testTimezoneInput($defaultTz, $inputTimeDst, $inputTimeNonDst); + } + + /** + * Test timezones with input date and time in other timezones + * @dataProvider provideTimesAndTz + */ + public function testTimezoneInput($defaultTz, $inputTimeDst, $inputTimeNonDst) + { + date_default_timezone_set($defaultTz); // formatting has to be independent of the default timezone set by PHP + $this->formatter->datetimeFormat = 'yyyy-MM-dd HH:mm:ss'; + $this->formatter->dateFormat = 'yyyy-MM-dd'; + $this->formatter->timeFormat = 'HH:mm:ss'; + + // daylight saving time + $this->formatter->timeZone = 'UTC'; + $this->assertSame('2014-08-10 12:41:00', $this->formatter->asDatetime($inputTimeDst)); + $this->assertSame('2014-08-10', $this->formatter->asDate($inputTimeDst)); + $this->assertSame('12:41:00', $this->formatter->asTime($inputTimeDst)); + $this->assertSame('1407674460', $this->formatter->asTimestamp($inputTimeDst)); + $this->formatter->timeZone = 'Europe/Berlin'; + $this->assertSame('2014-08-10 14:41:00', $this->formatter->asDatetime($inputTimeDst)); + $this->assertSame('2014-08-10', $this->formatter->asDate($inputTimeDst)); + $this->assertSame('14:41:00', $this->formatter->asTime($inputTimeDst)); + $this->assertSame('1407674460', $this->formatter->asTimestamp($inputTimeDst)); + + // non daylight saving time + $this->formatter->timeZone = 'UTC'; + $this->assertSame('2014-01-01 12:41:00', $this->formatter->asDatetime($inputTimeNonDst)); + $this->assertSame('2014-01-01', $this->formatter->asDate($inputTimeNonDst)); + $this->assertSame('12:41:00', $this->formatter->asTime($inputTimeNonDst)); + $this->assertSame('1388580060', $this->formatter->asTimestamp($inputTimeNonDst)); + $this->formatter->timeZone = 'Europe/Berlin'; + $this->assertSame('2014-01-01 13:41:00', $this->formatter->asDatetime($inputTimeNonDst)); + $this->assertSame('2014-01-01', $this->formatter->asDate($inputTimeNonDst)); + $this->assertSame('13:41:00', $this->formatter->asTime($inputTimeNonDst)); + $this->assertSame('1388580060', $this->formatter->asTimestamp($inputTimeNonDst)); + + // tests for relative time + if ($inputTimeDst !== 1407674460) { + $this->assertSame('3 hours ago', $this->formatter->asRelativeTime($inputTimeDst, $relativeTime = str_replace(['14:41', '12:41'], ['17:41', '15:41'], $inputTimeDst))); + $this->assertSame('in 3 hours', $this->formatter->asRelativeTime($relativeTime, $inputTimeDst)); + $this->assertSame('3 hours ago', $this->formatter->asRelativeTime($inputTimeNonDst, $relativeTime = str_replace(['13:41', '12:41'], ['16:41', '15:41'], $inputTimeNonDst))); + $this->assertSame('in 3 hours', $this->formatter->asRelativeTime($relativeTime, $inputTimeNonDst)); + } + } + + // number format -- libgit2 0.27.1