Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions framework/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Yii Framework 2 Change Log
- Bug #20751: Fix `@param` annotation for `$param` parameter in `Sort::parseSortParam()` (mspirkov)
- Enh #20756: Remove dead code for PHP < 5.6 in `SchemaBuilderTrait::json()` (WarLikeLaux)
- Bug #20764: Fix `@return` annotation for `Model::rules()` (mspirkov)
- Bug #20794: Fix PHP enum support in `BaseHtml::getAttributeValue()` and `RangeValidator::getClientOptions()` (WarLikeLaux)
- Enh #20794: Add `enum` and `target` properties to `RangeValidator` for enum-based validation (WarLikeLaux)


2.0.54 January 09, 2026
Expand Down
8 changes: 8 additions & 0 deletions framework/helpers/BaseHtml.php
Original file line number Diff line number Diff line change
Expand Up @@ -2311,12 +2311,20 @@ public static function getAttributeValue($model, $attribute)
if ($v instanceof ActiveRecordInterface) {
$v = $v->getPrimaryKey(false);
$value[$i] = is_array($v) ? json_encode($v) : $v;
} elseif (PHP_VERSION_ID >= 80100 && $v instanceof \BackedEnum) {
$value[$i] = $v->value;
} elseif (PHP_VERSION_ID >= 80100 && $v instanceof \UnitEnum) {
$value[$i] = $v->name;
}
}
} elseif ($value instanceof ActiveRecordInterface) {
$value = $value->getPrimaryKey(false);

return is_array($value) ? json_encode($value) : $value;
} elseif (PHP_VERSION_ID >= 80100 && $value instanceof \BackedEnum) {
return $value->value;
} elseif (PHP_VERSION_ID >= 80100 && $value instanceof \UnitEnum) {
return $value->name;
}

return $value;
Expand Down
41 changes: 40 additions & 1 deletion framework/validators/RangeValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ class RangeValidator extends Validator
* @var bool whether to allow array type attribute.
*/
public $allowArray = false;
/**
* @var string|null the enum class name. If set, [[range]] will be automatically
* populated with enum values or names depending on [[target]].
* Requires PHP 8.1 or higher.
* @since 2.0.55
*/
public $enum;
/**
* @var string whether to use enum case 'value' or 'name' when populating [[range]]
* from [[enum]]. Defaults to 'value' for backed enums. For unit enums only 'name' is supported.
* @since 2.0.55
*/
public $target = 'value';


/**
Expand All @@ -58,6 +71,26 @@ class RangeValidator extends Validator
public function init()
{
parent::init();
if ($this->enum !== null) {
if (PHP_VERSION_ID < 80100) {
throw new InvalidConfigException('The "enum" property requires PHP 8.1 or higher.');
}
if (!is_subclass_of($this->enum, \UnitEnum::class)) {
throw new InvalidConfigException('The "enum" property must be a valid enum class.');
}
if ($this->target === 'value') {
if (!is_subclass_of($this->enum, \BackedEnum::class)) {
throw new InvalidConfigException('The "value" target requires a backed enum. Use \'name\' for unit enums.');
}
$this->range = array_map(function ($case) {
return $case->value;
}, $this->enum::cases());
} else {
$this->range = array_map(function ($case) {
return $case->name;
}, $this->enum::cases());
}
}
if (
!is_array($this->range)
&& !($this->range instanceof \Closure)
Expand Down Expand Up @@ -125,7 +158,13 @@ public function getClientOptions($model, $attribute)
{
$range = [];
foreach ($this->range as $value) {
$range[] = (string) $value;
if (PHP_VERSION_ID >= 80100 && $value instanceof \BackedEnum) {
$range[] = (string) $value->value;
} elseif (PHP_VERSION_ID >= 80100 && $value instanceof \UnitEnum) {
$range[] = $value->name;
} else {
$range[] = (string) $value;
}
}
$options = [
'range' => $range,
Expand Down
52 changes: 52 additions & 0 deletions phpstan-baseline-7x.neon
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,58 @@ parameters:
count: 1
path: framework/db/QueryBuilder.php

# PHP 7.4 does not have enums, so we ignore these errors.
-
message: "#^Class BackedEnum not found\\.$#"
count: 2
path: framework/helpers/BaseHtml.php

-
message: "#^Access to property \\$value on an unknown class BackedEnum\\.$#"
count: 2
path: framework/helpers/BaseHtml.php

-
message: "#^Class UnitEnum not found\\.$#"
count: 2
path: framework/helpers/BaseHtml.php

-
message: "#^Access to property \\$name on an unknown class UnitEnum\\.$#"
count: 2
path: framework/helpers/BaseHtml.php

# PHP 7.4 does not have enums, so we ignore these errors.
-
message: "#^Class BackedEnum not found\\.$#"
count: 2
path: framework/validators/RangeValidator.php

-
message: "#^Access to property \\$value on an unknown class BackedEnum\\.$#"
count: 1
path: framework/validators/RangeValidator.php

-
message: "#^Class UnitEnum not found\\.$#"
count: 2
path: framework/validators/RangeValidator.php

-
message: "#^Access to property \\$name on an unknown class UnitEnum\\.$#"
count: 1
path: framework/validators/RangeValidator.php

-
message: "#^Call to an undefined static method BackedEnum&UnitEnum\\:\\:cases\\(\\)\\.$#"
count: 1
path: framework/validators/RangeValidator.php

-
message: "#^Call to an undefined static method UnitEnum\\:\\:cases\\(\\)\\.$#"
count: 1
path: framework/validators/RangeValidator.php

# PHP 7.4 does not have enums, so we ignore these errors.
-
message: "#^Access to constant Active on an unknown class yiiunit\\\\framework\\\\db\\\\enums\\\\StatusTypeString\\.$#"
Expand Down
69 changes: 69 additions & 0 deletions tests/framework/helpers/HtmlEnumTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

/**
* @link https://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license https://www.yiiframework.com/license/
*/

declare(strict_types=1);

namespace yiiunit\framework\helpers;

use yii\base\DynamicModel;
use yii\helpers\Html;
use yiiunit\framework\validators\stubs\IntStatus;
use yiiunit\framework\validators\stubs\StringStatus;
use yiiunit\framework\validators\stubs\Suit;
use yiiunit\TestCase;

/**
* @group helpers
* @requires PHP >= 8.1
*/
class HtmlEnumTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
require_once __DIR__ . '/../validators/stubs/EnumStubs.php';
$this->destroyApplication();
}

public function testGetAttributeValueWithStringBackedEnum(): void
{
$model = new DynamicModel(['status' => StringStatus::Active]);
$this->assertSame('active', Html::getAttributeValue($model, 'status'));
}

public function testGetAttributeValueWithIntBackedEnum(): void
{
$model = new DynamicModel(['status' => IntStatus::On]);
$this->assertSame(1, Html::getAttributeValue($model, 'status'));
}

public function testGetAttributeValueWithUnitEnum(): void
{
$model = new DynamicModel(['suit' => Suit::Hearts]);
$this->assertSame('Hearts', Html::getAttributeValue($model, 'suit'));
}

public function testGetAttributeValueWithArrayOfEnums(): void
{
$model = new DynamicModel(['statuses' => [StringStatus::Active, StringStatus::Inactive]]);
$this->assertSame(['active', 'inactive'], Html::getAttributeValue($model, 'statuses'));
}

public function testGetAttributeValueWithArrayOfUnitEnums(): void
{
$model = new DynamicModel(['suits' => [Suit::Hearts, Suit::Spades]]);
$this->assertSame(['Hearts', 'Spades'], Html::getAttributeValue($model, 'suits'));
}

public function testGetAttributeValueWithMixedArray(): void
{
$model = new DynamicModel(['items' => [StringStatus::Active, 'plain', 42]]);
$result = Html::getAttributeValue($model, 'items');
$this->assertSame(['active', 'plain', 42], $result);
}
}
143 changes: 143 additions & 0 deletions tests/framework/validators/RangeValidatorEnumTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

/**
* @link https://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license https://www.yiiframework.com/license/
*/

declare(strict_types=1);

namespace yiiunit\framework\validators;

use yii\validators\RangeValidator;
use yiiunit\data\validators\models\FakedValidationModel;
use yiiunit\framework\validators\stubs\IntStatus;
use yiiunit\framework\validators\stubs\StringStatus;
use yiiunit\framework\validators\stubs\Suit;
use yiiunit\TestCase;

/**
* @group validators
* @requires PHP >= 8.1
*/
class RangeValidatorEnumTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
require_once __DIR__ . '/stubs/EnumStubs.php';
$this->destroyApplication();
}

public function testEnumWithStringBackedValues(): void
{
$val = new RangeValidator(['enum' => StringStatus::class]);
$this->assertTrue($val->validate('active'));
$this->assertTrue($val->validate('inactive'));
$this->assertFalse($val->validate('pending'));
$this->assertFalse($val->validate(''));
}

public function testEnumWithIntBackedValues(): void
{
$val = new RangeValidator(['enum' => IntStatus::class]);
$this->assertTrue($val->validate(1));
$this->assertTrue($val->validate(0));
$this->assertFalse($val->validate(2));
}

public function testEnumWithNameTarget(): void
{
$val = new RangeValidator(['enum' => StringStatus::class, 'target' => 'name']);
$this->assertTrue($val->validate('Active'));
$this->assertTrue($val->validate('Inactive'));
$this->assertFalse($val->validate('active'));
}

public function testEnumUnitEnumWithNameTarget(): void
{
$val = new RangeValidator(['enum' => Suit::class, 'target' => 'name']);
$this->assertTrue($val->validate('Hearts'));
$this->assertTrue($val->validate('Spades'));
$this->assertFalse($val->validate('hearts'));
$this->assertFalse($val->validate(''));
}

public function testEnumUnitEnumWithValueTargetThrows(): void
{
$this->expectException('yii\base\InvalidConfigException');
$this->expectExceptionMessage('The "value" target requires a backed enum');
new RangeValidator(['enum' => Suit::class]);
}

public function testEnumInvalidClassThrows(): void
{
$this->expectException('yii\base\InvalidConfigException');
$this->expectExceptionMessage('The "enum" property must be a valid enum class');
new RangeValidator(['enum' => \stdClass::class]);
}

public function testEnumWithNotProperty(): void
{
$val = new RangeValidator(['enum' => StringStatus::class, 'not' => true]);
$this->assertFalse($val->validate('active'));
$this->assertTrue($val->validate('pending'));
}

public function testEnumWithStrictProperty(): void
{
$val = new RangeValidator(['enum' => IntStatus::class, 'strict' => true]);
$this->assertTrue($val->validate(1));
$this->assertFalse($val->validate('1'));
}

public function testEnumValidateAttribute(): void
{
$val = new RangeValidator(['enum' => StringStatus::class]);
$m = FakedValidationModel::createWithAttributes(['attr_status' => 'active']);
$val->validateAttribute($m, 'attr_status');
$this->assertFalse($m->hasErrors('attr_status'));

$m = FakedValidationModel::createWithAttributes(['attr_status' => 'bogus']);
$val->validateAttribute($m, 'attr_status');
$this->assertTrue($m->hasErrors('attr_status'));
$this->assertSame(['attr_status is invalid.'], $m->getErrors('attr_status'));
}

public function testGetClientOptionsWithEnumCasesInRange(): void
{
$this->mockWebApplication();
$val = new RangeValidator(['range' => StringStatus::cases()]);
$m = FakedValidationModel::createWithAttributes(['attr_status' => 'active']);
$options = $val->getClientOptions($m, 'attr_status');
$this->assertSame(['active', 'inactive'], $options['range']);
}

public function testGetClientOptionsWithIntEnumCasesInRange(): void
{
$this->mockWebApplication();
$val = new RangeValidator(['range' => IntStatus::cases()]);
$m = FakedValidationModel::createWithAttributes(['attr_status' => 1]);
$options = $val->getClientOptions($m, 'attr_status');
$this->assertSame(['1', '0'], $options['range']);
}

public function testGetClientOptionsWithUnitEnumCasesInRange(): void
{
$this->mockWebApplication();
$val = new RangeValidator(['range' => Suit::cases()]);
$m = FakedValidationModel::createWithAttributes(['attr_suit' => 'Hearts']);
$options = $val->getClientOptions($m, 'attr_suit');
$this->assertSame(['Hearts', 'Diamonds', 'Clubs', 'Spades'], $options['range']);
}

public function testGetClientOptionsWithEnumProperty(): void
{
$this->mockWebApplication();
$val = new RangeValidator(['enum' => StringStatus::class]);
$m = FakedValidationModel::createWithAttributes(['attr_status' => 'active']);
$options = $val->getClientOptions($m, 'attr_status');
$this->assertSame(['active', 'inactive'], $options['range']);
}
}
31 changes: 31 additions & 0 deletions tests/framework/validators/stubs/EnumStubs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/**
* @link https://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license https://www.yiiframework.com/license/
*/

declare(strict_types=1);

namespace yiiunit\framework\validators\stubs;

enum StringStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
}

enum IntStatus: int
{
case On = 1;
case Off = 0;
}

enum Suit
{
case Hearts;
case Diamonds;
case Clubs;
case Spades;
}
Loading