diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index a73a1be607..9ef12ece1e 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -8,6 +8,7 @@ Yii Framework 2 Change Log - Bug #4823: `yii message` accuracy and error handling were improved (samdark) - Bug #4889: Application was getting into redirect loop when user wasn't allowed accessing login page. Now shows 403 (samdark) - Bug #5402: Debugger was not loading when there were closures in asset classes (samdark) +- Bug #5448: Date formatter was doing timezone conversion on date only values resulting in different date displayed than provided (cebe) - Bug #5452: Errors occurring after the response is sent are not displayed (qiangxue) - Bug #5521: Fixed `yii\console\controllers\AssetController` breaks CSS URLs, which start from '/' (klimov-paul) - Bug #5570: `yii\bootstrap\Tabs` would throw an exception if `content` is not set for one of its `items` (RomeroMsk) diff --git a/framework/i18n/DateTimeExtended.php b/framework/i18n/DateTimeExtended.php new file mode 100644 index 0000000000..ecbeb74557 --- /dev/null +++ b/framework/i18n/DateTimeExtended.php @@ -0,0 +1,97 @@ + + * @since 2.0 + */ +class DateTimeExtended extends \DateTime +{ + private $_isDateOnly = false; + + /** + * The DateTimeExtended constructor. + * + * @param string $time + * @param \DateTimeZone $timezone + * @return DateTimeExtended + * @see http://php.net/manual/en/datetime.construct.php + */ + public function __construct ($time = 'now', \DateTimeZone $timezone = null) + { + parent::__construct($time, $timezone); + + $info = date_parse($time); + if ($info['hour'] === false && $info['minute'] === false && $info['second'] === false) { + $this->_isDateOnly = true; + } else { + $this->_isDateOnly = false; + } + } + + /** + * Parse a string into a new DateTime object according to the specified format + * @param string $format Format accepted by date(). + * @param string $time String representing the time. + * @param \DateTimeZone $timezone A DateTimeZone object representing the desired time zone. + * @return DateTimeExtended + * @link http://php.net/manual/en/datetime.createfromformat.php + */ + public static function createFromFormat ($format, $time, $timezone = null) + { + if (($originalDateTime = parent::createFromFormat($format, $time, $timezone)) === false) { + return false; + } + $info = date_parse_from_format($format, $time); + + /** @var $dateTime \DateTime */ + $dateTime = new static; + if ($info['hour'] === false && $info['minute'] === false && $info['second'] === false) { + $dateTime->_isDateOnly = true; + } else { + $dateTime->_isDateOnly = false; + } + $dateTime->setTimezone($originalDateTime->getTimezone()); + $dateTime->setTimestamp($originalDateTime->getTimestamp()); + + return $dateTime; + } + + public function isDateOnly() + { + return $this->_isDateOnly; + } + + public function getTimezone() + { + if ($this->_isDateOnly) { + return false; + } else { + return parent::getTimezone(); + } + } + + public function getOffset() + { + if ($this->_isDateOnly) { + return false; + } else { + return parent::getOffset(); + } + } +} diff --git a/framework/i18n/Formatter.php b/framework/i18n/Formatter.php index 5533aa5cba..79d3bbcf33 100644 --- a/framework/i18n/Formatter.php +++ b/framework/i18n/Formatter.php @@ -532,20 +532,27 @@ class Formatter extends Component return $this->nullDisplay; } + // avoid time zone conversion for date-only values + if ($type === 'date' && $timestamp->isDateOnly()) { + $timeZone = $this->defaultTimeZone; + } else { + $timeZone = $this->timeZone; + } + if ($this->_intlLoaded) { if (strncmp($format, 'php:', 4) === 0) { $format = FormatConverter::convertDatePhpToIcu(substr($format, 4)); } if (isset($this->_dateFormats[$format])) { if ($type === 'date') { - $formatter = new IntlDateFormatter($this->locale, $this->_dateFormats[$format], IntlDateFormatter::NONE, $this->timeZone); + $formatter = new IntlDateFormatter($this->locale, $this->_dateFormats[$format], IntlDateFormatter::NONE, $timeZone); } elseif ($type === 'time') { - $formatter = new IntlDateFormatter($this->locale, IntlDateFormatter::NONE, $this->_dateFormats[$format], $this->timeZone); + $formatter = new IntlDateFormatter($this->locale, IntlDateFormatter::NONE, $this->_dateFormats[$format], $timeZone); } else { - $formatter = new IntlDateFormatter($this->locale, $this->_dateFormats[$format], $this->_dateFormats[$format], $this->timeZone); + $formatter = new IntlDateFormatter($this->locale, $this->_dateFormats[$format], $this->_dateFormats[$format], $timeZone); } } else { - $formatter = new IntlDateFormatter($this->locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, $this->timeZone, null, $format); + $formatter = new IntlDateFormatter($this->locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, $timeZone, null, $format); } if ($formatter === null) { throw new InvalidConfigException(intl_get_error_message()); @@ -557,8 +564,8 @@ class Formatter extends Component } else { $format = FormatConverter::convertDateIcuToPhp($format, $type, $this->locale); } - if ($this->timeZone != null) { - $timestamp->setTimezone(new DateTimeZone($this->timeZone)); + if ($timeZone != null) { + $timestamp->setTimezone(new DateTimeZone($timeZone)); } return $timestamp->format($format); } @@ -575,7 +582,7 @@ class Formatter extends Component * The timestamp is assumed to be in [[defaultTimeZone]] unless a time zone is explicitly given. * - a PHP [DateTime](http://php.net/manual/en/class.datetime.php) object * - * @return DateTime the normalized datetime value + * @return DateTimeExtended the normalized datetime value * @throws InvalidParamException if the input value can not be evaluated as a date value. */ protected function normalizeDatetimeValue($value) @@ -589,17 +596,17 @@ class Formatter extends Component } try { if (is_numeric($value)) { // process as unix timestamp, which is always in UTC - if (($timestamp = DateTime::createFromFormat('U', $value, new DateTimeZone('UTC'))) === false) { + if (($timestamp = DateTimeExtended::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, new DateTimeZone($this->defaultTimeZone))) !== false) { // try Y-m-d format (support invalid dates like 2012-13-01) + } elseif (($timestamp = DateTimeExtended::createFromFormat('Y-m-d', $value, new DateTimeZone($this->defaultTimeZone))) !== 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, new DateTimeZone($this->defaultTimeZone))) !== false) { // try Y-m-d H:i:s format (support invalid dates like 2012-13-01 12:63:12) + } elseif (($timestamp = DateTimeExtended::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone($this->defaultTimeZone))) !== 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, new DateTimeZone($this->defaultTimeZone)); + $timestamp = new DateTimeExtended($value, new DateTimeZone($this->defaultTimeZone)); return $timestamp; } catch(\Exception $e) { throw new InvalidParamException("'$value' is not a valid date time value: " . $e->getMessage() diff --git a/tests/unit/framework/i18n/FormatterTest.php b/tests/unit/framework/i18n/FormatterTest.php index 652a4d95b5..a5a3b78c8e 100644 --- a/tests/unit/framework/i18n/FormatterTest.php +++ b/tests/unit/framework/i18n/FormatterTest.php @@ -629,6 +629,18 @@ class FormatterTest extends TestCase } + public function testDateOnlyValues() + { + date_default_timezone_set('Pacific/Kiritimati'); + // timzones with exactly 24h difference, ensure this test does not fail on a certain time + $this->formatter->defaultTimeZone = 'Pacific/Kiritimati'; // always UTC+14 + $this->formatter->timeZone = 'Pacific/Honolulu'; // always UTC-10 + + // when timezone conversion is made on this date, it will result in 2014-07-31 to be returned. + // ensure this does not happen on date only values + $this->assertSame('2014-08-01', $this->formatter->asDate('2014-08-01', 'yyyy-MM-dd')); + } + // number format /**