Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add enum support #734

Merged
merged 24 commits into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2.0.0 under development

- New #734: Add `InEnum` rule (@samdark)
- New #630, #718: Include attribute name in error messages when it's present (@dood-, @arogachev)
- New #646, #653: Add `DateTime` rule (@pamparam83)
- New #615: Add the `Each::PARAMETER_EACH_KEY` validation context parameter that available during `Each` rule handling
Expand Down
1 change: 1 addition & 0 deletions docs/guide/en/built-in-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Here is a list of all available built-in rules, divided by category.
### Set rules

- [In](../../../src/Rule/In.php)
- [InEnum](../../../src/Rule/InEnum.php)
- [Subset](../../../src/Rule/Subset.php)
- [UniqueIterable](../../../src/Rule/UniqueIterable.php)

Expand Down
164 changes: 164 additions & 0 deletions src/Rule/InEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Rule;

use Attribute;
use BackedEnum;
use Closure;
use InvalidArgumentException;
use UnitEnum;
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
use Yiisoft\Validator\Rule\Trait\WhenTrait;
use Yiisoft\Validator\DumpedRuleInterface;
use Yiisoft\Validator\SkipOnEmptyInterface;
use Yiisoft\Validator\SkipOnErrorInterface;
use Yiisoft\Validator\WhenInterface;

/**
* Defines validation options to check that the value is one of the values (or names) contained in an enum of the
* specified class.
* If the {@see In::$not} is set, the validation logic is inverted and the rule will ensure that the value
* is NOT one of them.
*
* @see InEnumHandler
*
* @psalm-import-type SkipOnEmptyValue from SkipOnEmptyInterface
* @psalm-import-type WhenType from WhenInterface
*/
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class InEnum implements DumpedRuleInterface, SkipOnErrorInterface, WhenInterface, SkipOnEmptyInterface
{
use SkipOnEmptyTrait;
use SkipOnErrorTrait;
use WhenTrait;

/**
* @param string $class Class of the enum to user.
* @param bool $useNames Whether to use names for backed enums instead of value.
* @param bool $strict Whether the comparison to each value in the set is strict:
*
* - Strict mode meaning the type and the value must both match.
* - Non-strict mode meaning that type juggling is performed first before the comparison. You can
* read more in the PHP docs:
*
* - {@link https://www.php.net/manual/en/language.operators.comparison.php}
* - {@link https://www.php.net/manual/en/types.comparisons.php}
* - {@link https://www.php.net/manual/en/language.types.type-juggling.php}
*
* Defaults to `false` meaning non-strict mode is used.
* @param bool $not Whether to invert the validation logic. Defaults to `false`. If set to `true`, the value must NOT
* be among the list of {@see $values}.
* @param string $message Error message when the value is not in a set of value.
*
* You may use the following placeholders in the message:
*
* - `{property}`: the name of the attribute.
* - `{Property}`: the capitalized name of the attribute.
* @param bool|callable|null $skipOnEmpty Whether to skip this rule if the value validated is empty.
* See {@see SkipOnEmptyInterface}.
* @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error.
* See {@see SkipOnErrorInterface}.
* @param Closure|null $when A callable to define a condition for applying the rule.
* See {@see WhenInterface}.
*
* @psalm-param SkipOnEmptyValue $skipOnEmpty
* @psalm-param WhenType $when
*/
public function __construct(
private string $class,
private bool $useNames = false,
samdark marked this conversation as resolved.
Show resolved Hide resolved
private bool $strict = false,
private bool $not = false,
private string $message = '{Property} is not in the list of acceptable values.',
bool|callable|null $skipOnEmpty = null,
private bool $skipOnError = false,
private Closure|null $when = null,
) {
$this->skipOnEmpty = $skipOnEmpty;

if (!is_subclass_of($this->class, UnitEnum::class)) {
throw new InvalidArgumentException(
sprintf('Class should be an enum class string, %s provided.', get_debug_type($this->class))
);
}
}

public function getName(): string
{
return 'inEnum';
}

/**
* Get a set of values to check against.
*
* @return array A set of values.
*/
public function getValues(): array
{
if (is_subclass_of($this->class, BackedEnum::class) && !$this->useNames) {
return array_column($this->class::cases(), 'value');
}

/**
* @psalm-suppress InvalidStringClass
* @psalm-var array<array-key, mixed> $cases
*/
$cases = $this->class::cases();

return array_column($cases, 'name');
}

/**
* Whether the comparison is strict (both type and value must be the same).
*
* @return bool Whether the comparison is strict.
*/
public function isStrict(): bool
{
return $this->strict;
}

/**
* Whether to invert the validation logic. Defaults to `false`. If set to `true`, the value must NOT
* be among the list of {@see $values}.
*
* @return bool Whether to invert the validation logic.
*/
public function isNot(): bool
{
return $this->not;
}

/**
* Get error message when the value is not in a set of {@see $values}.
*
* @return string Error message.
*/
public function getMessage(): string
{
return $this->message;
}

public function getOptions(): array
{
return [
'values' => $this->getValues(),
'strict' => $this->strict,
'not' => $this->not,
'message' => [
'template' => $this->message,
'parameters' => [],
],
'skipOnEmpty' => $this->getSkipOnEmptyOption(),
'skipOnError' => $this->skipOnError,
];
}

public function getHandler(): string
{
return InEnumHandler::class;
}
}
38 changes: 38 additions & 0 deletions src/Rule/InEnumHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Rule;

use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;

/**
* Validates that the value is one of the values of a specified enum.
*
* @see InEnum
*/
final class InEnumHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof InEnum) {
throw new UnexpectedRuleException(InEnum::class, $rule);
}

$result = new Result();
if ($rule->isNot() === in_array($value, $rule->getValues(), $rule->isStrict())) {
$result->addError(
$rule->getMessage(),
[
samdark marked this conversation as resolved.
Show resolved Hide resolved
'property' => $context->getTranslatedProperty(),
'Property' => $context->getCapitalizedTranslatedProperty(),
],
);
}

return $result;
}
}
165 changes: 165 additions & 0 deletions tests/Rule/InEnumTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Tests\Rule;

use InvalidArgumentException;
use Yiisoft\Validator\Rule\InEnum;
use Yiisoft\Validator\Rule\InEnumHandler;
use Yiisoft\Validator\Tests\Rule\Base\DifferentRuleInHandlerTestTrait;
use Yiisoft\Validator\Tests\Rule\Base\RuleTestCase;
use Yiisoft\Validator\Tests\Rule\Base\RuleWithOptionsTestTrait;
use Yiisoft\Validator\Tests\Rule\Base\SkipOnErrorTestTrait;
use Yiisoft\Validator\Tests\Rule\Base\WhenTestTrait;
use Yiisoft\Validator\Tests\Support\Data\Enum\BackedEnumStatus;
use Yiisoft\Validator\Tests\Support\Data\Enum\EnumStatus;
use Yiisoft\Validator\Tests\Support\Data\Enum\IntBackedEnumStatus;
use Yiisoft\Validator\ValidationContext;

final class InEnumTest extends RuleTestCase
samdark marked this conversation as resolved.
Show resolved Hide resolved
{
use DifferentRuleInHandlerTestTrait;
use RuleWithOptionsTestTrait;
use SkipOnErrorTestTrait;
use WhenTestTrait;

public function testInvalidEnum(): void
{
$this->expectException(InvalidArgumentException::class);
new InEnum('test');
}

public function testGetName(): void
{
$rule = new InEnum(EnumStatus::class);
$this->assertSame('inEnum', $rule->getName());
}

public function dataOptions(): array
{
$values = array_column(EnumStatus::class::cases(), 'name');

return [
'non-strict' => [
new InEnum(EnumStatus::class),
[
'values' => $values,
'strict' => false,
'not' => false,
'message' => [
'template' => '{Property} is not in the list of acceptable values.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
],
],
'strict' => [
new InEnum(EnumStatus::class, strict: true),
[
'values' => $values,
'strict' => true,
'not' => false,
'message' => [
'template' => '{Property} is not in the list of acceptable values.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
],
],
'not' => [
new InEnum(EnumStatus::class, not: true),
[
'values' => $values,
'strict' => false,
'not' => true,
'message' => [
'template' => '{Property} is not in the list of acceptable values.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
],
],
];
}

public function dataValidationPassed(): array
{
return [
['DRAFT', [new InEnum(EnumStatus::class)]],
['PUBLISHED', [new InEnum(EnumStatus::class)]],

['DRAFT', [new InEnum(BackedEnumStatus::class, useNames: true)]],
['PUBLISHED', [new InEnum(BackedEnumStatus::class, useNames: true)]],


['draft', [new InEnum(BackedEnumStatus::class)]],
['published', [new InEnum(BackedEnumStatus::class)]],

[1, [new InEnum(IntBackedEnumStatus::class)]],
[2, [new InEnum(IntBackedEnumStatus::class)]],
['1', [new InEnum(IntBackedEnumStatus::class)]],
['2', [new InEnum(IntBackedEnumStatus::class)]],
];
}

public function dataValidationFailed(): array
{
$errors = ['' => ['Value is not in the list of acceptable values.']];

return [
[
'42',
[new InEnum(EnumStatus::class)],
$errors,
],
[
'DRAFT',
[new InEnum(BackedEnumStatus::class)],
$errors,
],
[
'draft',
[new InEnum(BackedEnumStatus::class, useNames: true)],
$errors,
],

[
'1',
[new InEnum(IntBackedEnumStatus::class, strict: true)],
$errors,
],
];
}

public function testValidationMessageContainsNecessaryParameters(): void
{
$rule = (new InEnum(EnumStatus::class));

$result = (new InEnumHandler())->validate('aaa', $rule, new ValidationContext());
foreach ($result->getErrors() as $error) {
$parameters = $error->getParameters();
$this->assertArrayHasKey('property', $parameters);
$this->assertArrayHasKey('Property', $parameters);
}
}

public function testSkipOnError(): void
{
$this->testSkipOnErrorInternal(new InEnum(EnumStatus::class), new InEnum(EnumStatus::class, skipOnError: true));
}

public function testWhen(): void
{
$when = static fn (mixed $value): bool => $value !== null;
$this->testWhenInternal(new InEnum(EnumStatus::class), new InEnum(EnumStatus::class, when: $when));
}

protected function getDifferentRuleInHandlerItems(): array
{
return [InEnum::class, InEnumHandler::class];
}
}
11 changes: 11 additions & 0 deletions tests/Support/Data/Enum/BackedEnumStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Tests\Support\Data\Enum;

enum BackedEnumStatus: string
{
case DRAFT = 'draft';
case PUBLISHED = 'published';
}
Loading
Loading