diff --git a/extensions/debug/views/default/index.php b/extensions/debug/views/default/index.php index a35214a85f..f2745f34ec 100644 --- a/extensions/debug/views/default/index.php +++ b/extensions/debug/views/default/index.php @@ -32,7 +32,6 @@ $this->title = 'Yii Debugger'; if (isset($this->context->module->panels['db']) && isset($this->context->module->panels['request'])) { echo "

Available Debug Data

"; - $timeFormatter = extension_loaded('intl') ? Yii::createObject(['class' => 'yii\i18n\Formatter']) : Yii::$app->formatter; $codes = []; foreach ($manifest as $tag => $vals) { @@ -66,8 +65,8 @@ if (isset($this->context->module->panels['db']) && isset($this->context->module- ], [ 'attribute' => 'time', - 'value' => function ($data) use ($timeFormatter) { - return '' . $timeFormatter->asDateTime($data['time'], 'short') . ''; + 'value' => function ($data) { + return '' . Yii::$app->formatter->asDateTime($data['time'], 'short') . ''; }, 'format' => 'html', ], diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 98abe4b026..676decc740 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -227,6 +227,7 @@ Yii Framework 2 Change Log - Chg #2359: Refactored formatter class. One class with or without intl extension and PHP format pattern as standard (Erik_r, cebe) - `yii\base\Formatter` functionality has been merged into `yii\i18n\Formatter` - removed the `yii\base\Formatter` class +- Chg #1551: Refactored DateValidator to support ICU date format and uses the format defined in Formatter by default (cebe) - Chg #2380: `yii\widgets\ActiveForm` will register validation js even if there are not fields inside (qiangxue) - Chg #2898: `yii\console\controllers\AssetController` is now using hashes instead of timestamps (samdark) - Chg #2913: RBAC `DbManager` is now initialized via migration (samdark) diff --git a/framework/UPGRADE.md b/framework/UPGRADE.md index a5b27618f6..c124ffef9b 100644 --- a/framework/UPGRADE.md +++ b/framework/UPGRADE.md @@ -244,6 +244,14 @@ new ones save the following code as `convert.php` that should be placed in the s The specification of the date and time formats is now using the ICU pattern format even if PHP intl extension is not installed. You can prefix a date format with `php:` to use the old format of the PHP `date()`-function. +* The DateValidator has been refactored to use the same format as the Formatter class now (see previous change). + When you use the DateValidator and did not specify a format it will now be what is configured in the formatter class instead of 'Y-m-d'. + To get the old behavior of the DateValidator you have to set the format explicitly in your validation rule: + + ```php + ['attributeName', 'date', 'format' => 'php:Y-m-d'], + ``` + * `beforeValidate()`, `beforeValidateAll()`, `afterValidate()`, `afterValidateAll()`, `ajaxBeforeSend()` and `ajaxComplete()` are removed from `ActiveForm`. The same functionality is now achieved via JavaScript event mechanism like the following: diff --git a/framework/helpers/BaseFormatConverter.php b/framework/helpers/BaseFormatConverter.php index fd3fa802c9..10f04648cc 100644 --- a/framework/helpers/BaseFormatConverter.php +++ b/framework/helpers/BaseFormatConverter.php @@ -103,10 +103,10 @@ class BaseFormatConverter public static function convertDateIcuToPhp($pattern, $type = 'date', $locale = null) { if (isset(self::$_icuShortFormats[$pattern])) { - if ($locale === null) { - $locale = Yii::$app->language; - } if (extension_loaded('intl')) { + if ($locale === null) { + $locale = Yii::$app->language; + } if ($type === 'date') { $formatter = new IntlDateFormatter($locale, self::$_icuShortFormats[$pattern], IntlDateFormatter::NONE); } elseif ($type === 'time') { @@ -306,10 +306,10 @@ class BaseFormatConverter public static function convertDateIcuToJui($pattern, $type = 'date', $locale = null) { if (isset(self::$_icuShortFormats[$pattern])) { - if ($locale === null) { - $locale = Yii::$app->language; - } if (extension_loaded('intl')) { + if ($locale === null) { + $locale = Yii::$app->language; + } if ($type === 'date') { $formatter = new IntlDateFormatter($locale, self::$_icuShortFormats[$pattern], IntlDateFormatter::NONE); } elseif ($type === 'time') { diff --git a/framework/i18n/Formatter.php b/framework/i18n/Formatter.php index 41f7af2f01..9519295593 100644 --- a/framework/i18n/Formatter.php +++ b/framework/i18n/Formatter.php @@ -76,6 +76,13 @@ class Formatter extends Component * It can also be a custom format as specified in the [ICU manual](http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax). * 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. + * + * For example: + * + * ```php + * 'MM/dd/yyyy' // date in ICU format + * 'php:m/d/Y' // the same date in PHP format + * ``` */ public $dateFormat = 'medium'; /** @@ -85,6 +92,13 @@ class Formatter extends Component * It can also be a custom format as specified in the [ICU manual](http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax). * 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. + * + * For example: + * + * ```php + * 'HH:mm:ss' // time in ICU format + * 'php:H:i:s' // the same time in PHP format + * ``` */ public $timeFormat = 'medium'; /** @@ -95,6 +109,13 @@ 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. + * + * For example: + * + * ```php + * 'MM/dd/yyyy HH:mm:ss' // date and time in ICU format + * 'php:m/d/Y H:i:s' // the same date and time in PHP format + * ``` */ public $datetimeFormat = 'medium'; /** @@ -479,7 +500,7 @@ class Formatter extends Component } if ($this->_intlLoaded) { - if (strpos($format, 'php:') === 0) { + if (strncmp($format, 'php:', 4) === 0) { $format = FormatConverter::convertDatePhpToIcu(substr($format, 4)); } if (isset($this->_dateFormats[$format])) { @@ -498,7 +519,7 @@ class Formatter extends Component } return $formatter->format($value); } else { - if (strpos($format, 'php:') === 0) { + if (strncmp($format, 'php:', 4) === 0) { $format = substr($format, 4); } else { $format = FormatConverter::convertDateIcuToPhp($format, $type, $this->locale); diff --git a/framework/validators/DateValidator.php b/framework/validators/DateValidator.php index fbcb711456..42767fde8a 100644 --- a/framework/validators/DateValidator.php +++ b/framework/validators/DateValidator.php @@ -7,23 +7,52 @@ namespace yii\validators; +use IntlDateFormatter; use Yii; use DateTime; +use yii\helpers\FormatConverter; /** * DateValidator verifies if the attribute represents a date, time or datetime in a proper format. * * @author Qiang Xue + * @author Carsten Brandt * @since 2.0 */ class DateValidator extends Validator { /** * @var string the date format that the value being validated should follow. - * Please refer to on - * supported formats. + * This can be a date time pattern as described in the [ICU manual](http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax). + * + * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by the PHP Datetime class. + * Please refer to on supported formats. + * + * If this property is not set, the default value will be obtained from `Yii::$app->formatter->dateFormat`, see [[\yii\i18n\Formatter::dateFormat]] for details. + * + * Here are some example values: + * + * ```php + * 'MM/dd/yyyy' // date in ICU format + * 'php:m/d/Y' // the same date in PHP format + * ``` */ - public $format = 'Y-m-d'; + public $format; + /** + * @var string the locale ID that is used to localize the date parsing. + * This is only effective when the [PHP intl extension](http://php.net/manual/en/book.intl.php) is installed. + * If not set, the locale of the [[\yii\base\Application::formatter|formatter]] will be used. + * See also [[\yii\i18n\Formatter::locale]]. + */ + public $locale; + /** + * @var string the timezone to use for parsing date and time values. + * This can be any value that may be passed to [date_default_timezone_set()](http://www.php.net/manual/en/function.date-default-timezone-set.php) + * 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. + */ + public $timeZone; /** * @var string the name of the attribute to receive the parsing result. * When this property is not null and the validation is successful, the named attribute will @@ -31,6 +60,16 @@ class DateValidator extends Validator */ public $timestampAttribute; + /** + * @var array map of short format names to IntlDateFormatter constant values. + */ + private $_dateFormats = [ + 'short' => 3, // IntlDateFormatter::SHORT, + 'medium' => 2, // IntlDateFormatter::MEDIUM, + 'long' => 1, // IntlDateFormatter::LONG, + 'full' => 0, // IntlDateFormatter::FULL, + ]; + /** * @inheritdoc @@ -41,6 +80,15 @@ class DateValidator extends Validator if ($this->message === null) { $this->message = Yii::t('yii', 'The format of {attribute} is invalid.'); } + if ($this->format === null) { + $this->format = Yii::$app->formatter->dateFormat; + } + if ($this->locale === null) { + $this->locale = Yii::$app->language; + } + if ($this->timeZone === null) { + $this->timeZone = Yii::$app->timeZone; + } } /** @@ -49,12 +97,11 @@ class DateValidator extends Validator public function validateAttribute($object, $attribute) { $value = $object->$attribute; - $result = $this->validateValue($value); - if (!empty($result)) { - $this->addError($object, $attribute, $result[0], $result[1]); + $timestamp = $this->parseDateValue($value); + if ($timestamp === false) { + $this->addError($object, $attribute, $this->message, []); } elseif ($this->timestampAttribute !== null) { - $date = DateTime::createFromFormat($this->format, $value); - $object->{$this->timestampAttribute} = $date->getTimestamp(); + $object->{$this->timestampAttribute} = $timestamp; } } @@ -63,13 +110,42 @@ class DateValidator extends Validator */ protected function validateValue($value) { - if (is_array($value)) { - return [$this->message, []]; - } - $date = DateTime::createFromFormat($this->format, $value); - $errors = DateTime::getLastErrors(); - $invalid = $date === false || $errors['error_count'] || $errors['warning_count']; + return $this->parseDateValue($value) === false ? [$this->message, []] : null; + } - return $invalid ? [$this->message, []] : null; + protected function parseDateValue($value) + { + if (is_array($value)) { + return false; + } + $format = $this->format; + if (strncmp($this->format, 'php:', 4) === 0) { + $format = substr($format, 4); + } else { + if (extension_loaded('intl')) { + if (isset($this->_dateFormats[$format])) { + $formatter = new IntlDateFormatter($this->locale, $this->_dateFormats[$format], IntlDateFormatter::NONE, $this->timeZone); + } else { + $formatter = new IntlDateFormatter($this->locale, IntlDateFormatter::NONE, IntlDateFormatter::NONE, $this->timeZone, null, $format); + } + // enable strict parsing to avoid getting invalid date values + $formatter->setLenient(false); + return $formatter->parse($value); + } else { + // fallback to PHP if intl is not installed + $format = FormatConverter::convertDateIcuToPhp($format, 'date'); + } + } + $date = DateTime::createFromFormat($format, $value, new \DateTimeZone($this->timeZone)); + $errors = DateTime::getLastErrors(); + if ($date === false || $errors['error_count'] || $errors['warning_count']) { + return false; + } else { + // if no time was provided in the format string set time to 0 to get a simple date timestamp + if (strpbrk($format, 'HhGgis') === false) { + $date->setTime(0, 0, 0); + } + return $date->getTimestamp(); + } } } diff --git a/tests/unit/framework/helpers/FormatConverterTest.php b/tests/unit/framework/helpers/FormatConverterTest.php index 7a274e9b91..bf14b6c5b6 100644 --- a/tests/unit/framework/helpers/FormatConverterTest.php +++ b/tests/unit/framework/helpers/FormatConverterTest.php @@ -1,25 +1,11 @@ getName(false), 'testIntl', 8) === 0) { - if (!extension_loaded('intl')) { - $this->markTestSkipped('intl extension is not installed.'); - } - FormatterTest::$enableIntl = true; - } else { - FormatterTest::$enableIntl = false; - } + IntlTestHelper::setIntlStatus($this); $this->mockApplication([ 'timeZone' => 'UTC', @@ -52,7 +28,7 @@ class FormatConverterTest extends TestCase protected function tearDown() { parent::tearDown(); - FormatterTest::$enableIntl = null; + IntlTestHelper::resetIntlStatus(); } public function testIntlIcuToPhpShortForm() @@ -75,4 +51,3 @@ class FormatConverterTest extends TestCase $this->assertEquals('24.8.2014', $formatter->asDate('2014-8-24', 'd.M.yyyy')); } } -} \ No newline at end of file diff --git a/tests/unit/framework/i18n/FormatterTest.php b/tests/unit/framework/i18n/FormatterTest.php index 502a101beb..415a9b16d8 100644 --- a/tests/unit/framework/i18n/FormatterTest.php +++ b/tests/unit/framework/i18n/FormatterTest.php @@ -1,35 +1,7 @@ getName(false), 'testIntl', 8) === 0) { - if (!extension_loaded('intl')) { - $this->markTestSkipped('intl extension is not installed.'); - } - static::$enableIntl = true; - } else { - static::$enableIntl = false; - } + IntlTestHelper::setIntlStatus($this); $this->mockApplication([ 'timeZone' => 'UTC', @@ -73,7 +33,7 @@ class FormatterTest extends TestCase protected function tearDown() { parent::tearDown(); - static::$enableIntl = null; + IntlTestHelper::resetIntlStatus(); $this->formatter = null; } @@ -857,4 +817,3 @@ class FormatterTest extends TestCase $this->assertSame("1023 bytes", $this->formatter->asSize(1023)); } } -} diff --git a/tests/unit/framework/i18n/IntlTestHelper.php b/tests/unit/framework/i18n/IntlTestHelper.php new file mode 100644 index 0000000000..d9a4eb7f75 --- /dev/null +++ b/tests/unit/framework/i18n/IntlTestHelper.php @@ -0,0 +1,77 @@ +getName(false), 'testIntl', 8) === 0) { + if (!extension_loaded('intl')) { + $test->markTestSkipped('intl extension is not installed.'); + } + static::$enableIntl = true; + } else { + static::$enableIntl = false; + } + } + + public static function resetIntlStatus() + { + static::$enableIntl = null; + } + } +} + +namespace yii\i18n { + use yiiunit\framework\i18n\IntlTestHelper; + + if (!function_exists('yii\i18n\extension_loaded')) { + function extension_loaded($name) + { + if ($name === 'intl' && IntlTestHelper::$enableIntl !== null) { + return IntlTestHelper::$enableIntl; + } + return \extension_loaded($name); + } + } +} + +namespace yii\helpers { + use yiiunit\framework\i18n\IntlTestHelper; + + if (!function_exists('yii\helpers\extension_loaded')) { + function extension_loaded($name) + { + if ($name === 'intl' && IntlTestHelper::$enableIntl !== null) { + return IntlTestHelper::$enableIntl; + } + return \extension_loaded($name); + } + } +} + +namespace yii\validators { + use yiiunit\framework\i18n\IntlTestHelper; + + if (!function_exists('yii\validators\extension_loaded')) { + function extension_loaded($name) + { + if ($name === 'intl' && IntlTestHelper::$enableIntl !== null) { + return IntlTestHelper::$enableIntl; + } + return \extension_loaded($name); + } + } +} diff --git a/tests/unit/framework/validators/DateValidatorTest.php b/tests/unit/framework/validators/DateValidatorTest.php index 58b508f0d3..e0bb933d4f 100644 --- a/tests/unit/framework/validators/DateValidatorTest.php +++ b/tests/unit/framework/validators/DateValidatorTest.php @@ -5,6 +5,7 @@ namespace yiiunit\framework\validators; use DateTime; use yii\validators\DateValidator; use yiiunit\data\validators\models\FakedValidationModel; +use yiiunit\framework\i18n\IntlTestHelper; use yiiunit\TestCase; /** @@ -15,7 +16,19 @@ class DateValidatorTest extends TestCase protected function setUp() { parent::setUp(); - $this->mockApplication(); + + IntlTestHelper::setIntlStatus($this); + + $this->mockApplication([ + 'timeZone' => 'UTC', + 'language' => 'ru-RU', + ]); + } + + protected function tearDown() + { + parent::tearDown(); + IntlTestHelper::resetIntlStatus(); } public function testEnsureMessageIsSet() @@ -24,26 +37,79 @@ class DateValidatorTest extends TestCase $this->assertTrue($val->message !== null && strlen($val->message) > 1); } + public function testIntlValidateValue() + { + $this->testValidateValue(); + + $this->mockApplication([ + 'language' => 'en-GB', + 'components' => [ + 'formatter' => [ + 'dateFormat' => 'short', + ] + ] + ]); + $val = new DateValidator(); + $this->assertTrue($val->validate('31/5/2017')); + $this->assertFalse($val->validate('5/31/2017')); + $val = new DateValidator(['format' => 'short', 'locale' => 'en-GB']); + $this->assertTrue($val->validate('31/5/2017')); + $this->assertFalse($val->validate('5/31/2017')); + + $this->mockApplication([ + 'language' => 'de-DE', + 'components' => [ + 'formatter' => [ + 'dateFormat' => 'short', + ] + ] + ]); + $val = new DateValidator(); + $this->assertTrue($val->validate('31.5.2017')); + $this->assertFalse($val->validate('5.31.2017')); + $val = new DateValidator(['format' => 'short', 'locale' => 'de-DE']); + $this->assertTrue($val->validate('31.5.2017')); + $this->assertFalse($val->validate('5.31.2017')); + } + public function testValidateValue() { - $val = new DateValidator; + // test PHP format + $val = new DateValidator(['format' => 'php:Y-m-d']); $this->assertFalse($val->validate('3232-32-32')); $this->assertTrue($val->validate('2013-09-13')); $this->assertFalse($val->validate('31.7.2013')); $this->assertFalse($val->validate('31-7-2013')); $this->assertFalse($val->validate(time())); - $val->format = 'U'; + $val->format = 'php:U'; $this->assertTrue($val->validate(time())); - $val->format = 'd.m.Y'; + $val->format = 'php:d.m.Y'; $this->assertTrue($val->validate('31.7.2013')); - $val->format = 'Y-m-!d H:i:s'; + $val->format = 'php:Y-m-!d H:i:s'; + $this->assertTrue($val->validate('2009-02-15 15:16:17')); + + // test ICU format + $val = new DateValidator(['format' => 'yyyy-MM-dd']); + $this->assertFalse($val->validate('3232-32-32')); + $this->assertTrue($val->validate('2013-09-13')); + $this->assertFalse($val->validate('31.7.2013')); + $this->assertFalse($val->validate('31-7-2013')); + $this->assertFalse($val->validate(time())); + $val->format = 'dd.MM.yyyy'; + $this->assertTrue($val->validate('31.7.2013')); + $val->format = 'yyyy-MM-dd HH:mm:ss'; $this->assertTrue($val->validate('2009-02-15 15:16:17')); } - public function testValidateAttribute() + public function testIntlValidateAttributePHP() + { + $this->testValidateAttributePHPFormat(); + } + + public function testValidateAttributePHPFormat() { // error-array-add - $val = new DateValidator; + $val = new DateValidator(['format' => 'php:Y-m-d']); $model = new FakedValidationModel; $model->attr_date = '2013-09-13'; $val->validateAttribute($model, 'attr_date'); @@ -53,7 +119,7 @@ class DateValidatorTest extends TestCase $val->validateAttribute($model, 'attr_date'); $this->assertTrue($model->hasErrors('attr_date')); //// timestamp attribute - $val = new DateValidator(['timestampAttribute' => 'attr_timestamp']); + $val = new DateValidator(['format' => 'php:Y-m-d', 'timestampAttribute' => 'attr_timestamp']); $model = new FakedValidationModel; $model->attr_date = '2013-09-13'; $model->attr_timestamp = true; @@ -61,10 +127,48 @@ class DateValidatorTest extends TestCase $this->assertFalse($model->hasErrors('attr_date')); $this->assertFalse($model->hasErrors('attr_timestamp')); $this->assertEquals( - DateTime::createFromFormat($val->format, '2013-09-13')->getTimestamp(), + mktime(0, 0, 0, 9, 13, 2013), // 2013-09-13 +// DateTime::createFromFormat('Y-m-d', '2013-09-13')->getTimestamp(), $model->attr_timestamp ); - $val = new DateValidator(); + $val = new DateValidator(['format' => 'php:Y-m-d']); + $model = FakedValidationModel::createWithAttributes(['attr_date' => []]); + $val->validateAttribute($model, 'attr_date'); + $this->assertTrue($model->hasErrors('attr_date')); + + } + + public function testIntlValidateAttributeICU() + { + $this->testValidateAttributeICUFormat(); + } + + public function testValidateAttributeICUFormat() + { + // error-array-add + $val = new DateValidator(['format' => 'yyyy-MM-dd']); + $model = new FakedValidationModel; + $model->attr_date = '2013-09-13'; + $val->validateAttribute($model, 'attr_date'); + $this->assertFalse($model->hasErrors('attr_date')); + $model = new FakedValidationModel; + $model->attr_date = '1375293913'; + $val->validateAttribute($model, 'attr_date'); + $this->assertTrue($model->hasErrors('attr_date')); + //// timestamp attribute + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'timestampAttribute' => 'attr_timestamp']); + $model = new FakedValidationModel; + $model->attr_date = '2013-09-13'; + $model->attr_timestamp = true; + $val->validateAttribute($model, 'attr_date'); + $this->assertFalse($model->hasErrors('attr_date')); + $this->assertFalse($model->hasErrors('attr_timestamp')); + $this->assertEquals( + mktime(0, 0, 0, 9, 13, 2013), // 2013-09-13 +// DateTime::createFromFormat('Y-m-d', '2013-09-13')->getTimestamp(), + $model->attr_timestamp + ); + $val = new DateValidator(['format' => 'yyyy-MM-dd']); $model = FakedValidationModel::createWithAttributes(['attr_date' => []]); $val->validateAttribute($model, 'attr_date'); $this->assertTrue($model->hasErrors('attr_date'));