Skip to content

Commit

Permalink
Fixed issue with timezone conversion in formatter
Browse files Browse the repository at this point in the history
related to #5128
  • Loading branch information
cebe committed Sep 30, 2014
1 parent 0763cb4 commit 6267b9e
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 17 deletions.
2 changes: 2 additions & 0 deletions docs/guide/output-formatter.md
Expand Up @@ -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
------------------

Expand Down
2 changes: 2 additions & 0 deletions framework/CHANGELOG.md
Expand Up @@ -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)


Expand Down Expand Up @@ -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
-----------------------------

Expand Down
6 changes: 6 additions & 0 deletions framework/UPGRADE.md
Expand Up @@ -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
-------------------------
Expand Down
55 changes: 38 additions & 17 deletions framework/i18n/Formatter.php
Expand Up @@ -8,6 +8,7 @@
namespace yii\i18n;

use DateTime;
use DateTimeZone;
use IntlDateFormatter;
use NumberFormatter;
use Yii;
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -387,8 +391,9 @@ public function asBoolean($value)
* 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.
Expand All @@ -399,9 +404,9 @@ public function asBoolean($value)
* 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)
Expand All @@ -418,8 +423,9 @@ public function asDate($value, $format = null)
* 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.
Expand All @@ -430,9 +436,9 @@ public function asDate($value, $format = null)
* 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)
Expand All @@ -449,8 +455,9 @@ public function asTime($value, $format = null)
* 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.
Expand All @@ -461,9 +468,9 @@ public function asTime($value, $format = null)
* 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)
Expand All @@ -485,7 +492,14 @@ public function asDatetime($value, $format = null)
];

/**
* @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.
Expand Down Expand Up @@ -524,7 +538,7 @@ private function formatDateTimeValue($value, $format, $type)
$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);
}
Expand All @@ -533,7 +547,14 @@ private function formatDateTimeValue($value, $format, $type)
/**
* 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.
*/
Expand All @@ -548,17 +569,17 @@ protected function normalizeDatetimeValue($value)
}
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()
Expand Down Expand Up @@ -623,7 +644,7 @@ public function asRelativeTime($value, $referenceTime = null)
return $this->nullDisplay;
}
} else {
$timezone = new \DateTimeZone($this->timeZone);
$timezone = new DateTimeZone($this->timeZone);

if ($referenceTime === null) {
$dateNow = new DateTime('now', $timezone);
Expand Down
84 changes: 84 additions & 0 deletions tests/unit/framework/i18n/FormatterTest.php
Expand Up @@ -483,6 +483,90 @@ public function testDateInput($expected, $value, $expectedException = null)
}


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


Expand Down

3 comments on commit 6267b9e

@RomeroMsk
Copy link
Contributor

Choose a reason for hiding this comment

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

@cebe, what should we do if timestamps in database are not in UTC and don't have timezone identifier?
If we can't change all the data in DB, we need an ability to set timeZone of database values in application code. Otherwise the only way to show correct formatted values is to use UTC timeZone for formatter component, but this will affect all conversions (not only with database values).
I suggest adding an input timezone configuration parameter to formatter (with default value of UTC).

@cebe
Copy link
Member Author

@cebe cebe commented on 6267b9e Oct 21, 2014

Choose a reason for hiding this comment

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

@RomeroMsk can you please create an issue for this?

@RomeroMsk
Copy link
Contributor

Choose a reason for hiding this comment

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

Of course. #5683

Please sign in to comment.