diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index ba577852c5..06b72db7cc 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -46,6 +46,7 @@ Yii Framework 2 Change Log - Enh #7409: Allow `yii\filters\auth\CompositeAuth::authMethods` to take authentication objects (fernandezekiel, qiangxue) - Enh #7443: Allow specification of the `$key` as an array at `yii\helpers\ArrayHelper::getValue()` (Alex-Code) - Enh #7488: Added `StringHelper::explode` to perform explode with trimming and skipping of empty elements (SilverFire, nineinchnick, creocoder, samdark) +- Enh #7514: Added min/max validation to DateValidator (nkovacs) - Enh #7515: Added support to use `indexBy()` together with `column()` in query builder (qiangxue) - Enh #7530: Improved default values for `yii\data\Sort` link labels in a `ListView` when used with an `ActiveDataProvider` (cebe) - Enh #7539: `yii\console\controllers\AssetController` provides dependency trace in case bundle circular dependency detected (klimov-paul) diff --git a/framework/validators/DateValidator.php b/framework/validators/DateValidator.php index 0c5c223fc1..2b9d16bd53 100644 --- a/framework/validators/DateValidator.php +++ b/framework/validators/DateValidator.php @@ -7,11 +7,11 @@ namespace yii\validators; -use DateTimeInterface; +use DateTime; use IntlDateFormatter; use Yii; -use DateTime; use yii\base\Exception; +use yii\base\InvalidConfigException; use yii\helpers\FormatConverter; /** @@ -90,6 +90,44 @@ class DateValidator extends Validator * @since 2.0.4 */ public $timestampAttributeTimeZone = 'UTC'; + /** + * @var integer|string upper limit of the date. Defaults to null, meaning no upper limit. + * This can be a unix timestamp or a string representing a date time value. + * If this property is a string, [[format]] will be used to parse it. + * @see tooBig for the customized message used when the date is too big. + * @since 2.0.4 + */ + public $max; + /** + * @var integer|string lower limit of the date. Defaults to null, meaning no lower limit. + * This can be a unix timestamp or a string representing a date time value. + * If this property is a string, [[format]] will be used to parse it. + * @see tooSmall for the customized message used when the date is too small. + * @since 2.0.4 + */ + public $min; + /** + * @var string user-defined error message used when the value is bigger than [[max]]. + * @since 2.0.4 + */ + public $tooBig; + /** + * @var string user-defined error message used when the value is smaller than [[min]]. + * @since 2.0.4 + */ + public $tooSmall; + /** + * @var string user friendly value of upper limit to display in the error message. + * If this property is null, the value of [[max]] will be used (before parsing). + * @since 2.0.4 + */ + public $maxString; + /** + * @var string user friendly value of lower limit to display in the error message. + * If this property is null, the value of [[min]] will be used (before parsing). + * @since 2.0.4 + */ + public $minString; /** * @var array map of short format names to IntlDateFormatter constant values. @@ -120,6 +158,32 @@ class DateValidator extends Validator if ($this->timeZone === null) { $this->timeZone = Yii::$app->timeZone; } + if ($this->min !== null && $this->tooSmall === null) { + $this->tooSmall = Yii::t('yii', '{attribute} must be no less than {min}.'); + } + if ($this->max !== null && $this->tooBig === null) { + $this->tooBig = Yii::t('yii', '{attribute} must be no greater than {max}.'); + } + if ($this->maxString === null) { + $this->maxString = (string)$this->max; + } + if ($this->minString === null) { + $this->minString = (string)$this->min; + } + if ($this->max !== null && is_string($this->max)) { + $timestamp = $this->parseDateValue($this->max); + if ($timestamp === false) { + throw new InvalidConfigException("Invalid max date value: {$this->max}"); + } + $this->max = $timestamp; + } + if ($this->min !== null && is_string($this->min)) { + $timestamp = $this->parseDateValue($this->min); + if ($timestamp === false) { + throw new InvalidConfigException("Invalid min date value: {$this->min}"); + } + $this->min = $timestamp; + } } /** @@ -131,6 +195,10 @@ class DateValidator extends Validator $timestamp = $this->parseDateValue($value); if ($timestamp === false) { $this->addError($model, $attribute, $this->message, []); + } elseif ($this->min !== null && $timestamp < $this->min) { + $this->addError($model, $attribute, $this->tooSmall, ['min' => $this->minString]); + } elseif ($this->max !== null && $timestamp > $this->max) { + $this->addError($model, $attribute, $this->tooBig, ['max' => $this->maxString]); } elseif ($this->timestampAttribute !== null) { if ($this->timestampAttributeFormat === null) { $model->{$this->timestampAttribute} = $timestamp; @@ -145,14 +213,23 @@ class DateValidator extends Validator */ protected function validateValue($value) { - return $this->parseDateValue($value) === false ? [$this->message, []] : null; + $timestamp = $this->parseDateValue($value); + if ($timestamp === false) { + return [$this->message, []]; + } elseif ($this->min !== null && $timestamp < $this->min) { + return [$this->tooSmall, ['min' => $this->minString]]; + } elseif ($this->max !== null && $timestamp > $this->max) { + return [$this->tooBig, ['max' => $this->maxString]]; + } else { + return null; + } } /** * Parses date string into UNIX timestamp * * @param string $value string representing date - * @return integer|boolean a UNIX timestamp or `false` on failure. + * @return integer|false a UNIX timestamp or `false` on failure. */ protected function parseDateValue($value) { diff --git a/tests/framework/validators/DateValidatorTest.php b/tests/framework/validators/DateValidatorTest.php index 0a657cc772..213435f31e 100644 --- a/tests/framework/validators/DateValidatorTest.php +++ b/tests/framework/validators/DateValidatorTest.php @@ -7,6 +7,7 @@ use yii\validators\DateValidator; use yiiunit\data\validators\models\FakedValidationModel; use yiiunit\framework\i18n\IntlTestHelper; use yiiunit\TestCase; +use IntlDateFormatter; /** * @group validators @@ -409,4 +410,113 @@ class DateValidatorTest extends TestCase $this->assertFalse($model->hasErrors('attr_timestamp')); $this->assertSame('2013-09-13 10:23:15', $model->attr_timestamp); } + + public function testIntlValidateRange() + { + $this->testValidateValueRange(); + } + + public function testValidateValueRange() + { + $date = '14-09-13'; + $val = new DateValidator(['format' => 'yyyy-MM-dd']); + $this->assertTrue($val->validate($date), "$date is valid"); + + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'min' => '1900-01-01']); + $date = "1958-01-12"; + $this->assertTrue($val->validate($date), "$date is valid"); + + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'max' => '2000-01-01']); + $date = '2014-09-13'; + $this->assertFalse($val->validate($date), "$date is too big"); + $date = "1958-01-12"; + $this->assertTrue($val->validate($date), "$date is valid"); + + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'min' => '1900-01-01', 'max' => '2000-01-01']); + $this->assertTrue($val->validate('1999-12-31'), "max -1 day is valid"); + $this->assertTrue($val->validate('2000-01-01'), "max is inside range"); + $this->assertTrue($val->validate('1900-01-01'), "min is inside range"); + $this->assertFalse($val->validate('1899-12-31'), "min -1 day is invalid"); + $this->assertFalse($val->validate('2000-01-02'), "max +1 day is invalid"); + } + + private function validateModelAttribute($validator, $date, $expected, $message = '') + { + $model = new FakedValidationModel; + $model->attr_date = $date; + $validator->validateAttribute($model, 'attr_date'); + if (!$expected) { + $this->assertTrue($model->hasErrors('attr_date'), $message); + } else { + $this->assertFalse($model->hasErrors('attr_date'), $message); + } + } + + public function testIntlValidateAttributeRange() { + $this->testValidateAttributeRange(); + } + + public function testValidateAttributeRange() + { + $val = new DateValidator(['format' => 'yyyy-MM-dd']); + $date = '14-09-13'; + $this->validateModelAttribute($val, $date, true, "$date is valid"); + + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'min' => '1900-01-01']); + $date = '1958-01-12'; + $this->validateModelAttribute($val, $date, true, "$date is valid"); + + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'max' => '2000-01-01']); + $date = '2014-09-13'; + $this->validateModelAttribute($val, $date, false, "$date is too big"); + $date = '1958-01-12'; + $this->validateModelAttribute($val, $date, true, "$date is valid"); + + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'min' => '1900-01-01', 'max' => '2000-01-01']); + $this->validateModelAttribute($val, '1999-12-31', true, "max -1 day is valid"); + $this->validateModelAttribute($val, '2000-01-01', true, "max is inside range"); + $this->validateModelAttribute($val, '1900-01-01', true, "min is inside range"); + $this->validateModelAttribute($val, '1899-12-31', false, "min -1 day is invalid"); + $this->validateModelAttribute($val, '2000-01-02', false, "max +1 day is invalid"); + } + + public function testIntlValidateValueRangeOld() + { + if ($this->checkOldIcuBug()) { + $this->markTestSkipped("ICU is too old."); + } + $date = '14-09-13'; + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'min' => '1900-01-01']); + $this->assertFalse($val->validate($date), "$date is too small"); + } + + public function testIntlValidateAttributeRangeOld() + { + if ($this->checkOldIcuBug()) { + $this->markTestSkipped("ICU is too old."); + } + $date = '14-09-13'; + $val = new DateValidator(['format' => 'yyyy-MM-dd', 'min' => '1900-01-01']); + $this->validateModelAttribute($val, $date, false, "$date is too small"); + } + + /** + * returns true if the version of ICU is old and has a bug that makes it + * impossible to parse two digit years properly. + * see http://bugs.icu-project.org/trac/ticket/9836 + * @return boolean + */ + private function checkOldIcuBug() + { + $date = '14'; + $formatter = new IntlDateFormatter('en-US', IntlDateFormatter::NONE, IntlDateFormatter::NONE, null, null, 'yyyy'); + $parsePos = 0; + $parsedDate = @$formatter->parse($date, $parsePos); + + if (is_int($parsedDate) && $parsedDate > 0) { + return true; + } + + return false; + } }