Skip to content

Commit

Permalink
Add Any rule
Browse files Browse the repository at this point in the history
  • Loading branch information
arogachev committed Apr 9, 2024
1 parent f043db3 commit 121668e
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 4 deletions.
164 changes: 164 additions & 0 deletions src/Rule/Any.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 Closure;
use JetBrains\PhpStorm\ArrayShape;
use Yiisoft\Validator\AfterInitAttributeEventInterface;
use Yiisoft\Validator\Helper\RulesNormalizer;
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
use Yiisoft\Validator\Rule\Trait\WhenTrait;
use Yiisoft\Validator\Helper\RulesDumper;
use Yiisoft\Validator\RuleWithOptionsInterface;
use Yiisoft\Validator\SkipOnEmptyInterface;
use Yiisoft\Validator\SkipOnErrorInterface;
use Yiisoft\Validator\ValidatorInterface;
use Yiisoft\Validator\WhenInterface;

/**
* Applies to a set of rules, runs validation for each one of them in the order they are defined and stops at the rule
* where validation passed. The opposite to {@see StopOnError}
*
* An example of usage:
*
* ```php
* $rule = new Any([
* new IntegerType(), // Let's say the validation passed here.
* new FloatType(), // Then this rule will be skipped.
* ]);
* ```
*
* When using with other rules, conditional validation options, such as {@see Any::$skipOnError} will be applied
* to the whole group of {@see Any::$rules}.
*
* @see StopOnErrorHandler Corresponding handler performing the actual validation.
*
* @psalm-import-type SkipOnEmptyValue from SkipOnEmptyInterface
* @psalm-import-type WhenType from WhenInterface
* @psalm-import-type NormalizedRulesList from RulesNormalizer
* @psalm-import-type RawRulesList from ValidatorInterface
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class Any implements
RuleWithOptionsInterface,
SkipOnEmptyInterface,
SkipOnErrorInterface,
WhenInterface,
AfterInitAttributeEventInterface
{
use SkipOnEmptyTrait;
use SkipOnErrorTrait;
use WhenTrait;

/**
* @var iterable A set of normalized rules that needs to be run.
*
* @psalm-var NormalizedRulesList
*/
private iterable $rules = [];

/**
* @param string $message Error message used when validation fails because none of the inner {@see $rules} has
* passed the validation.
*
* @param iterable $rules A set of rules for running the validation. They will be normalized during initialization
* using {@see RulesNormalizer}.
*
* @psalm-param RawRulesList $rules
*
* @param bool|callable|null $skipOnEmpty Whether to skip this `Any` rule with all defined {@see $rules} if the
* validated value is empty / not passed. See {@see SkipOnEmptyInterface}.
* @param bool $skipOnError Whether to skip this `Any` rule with all defined {@see $rules} if any of the previous
* rules gave an error. See {@see SkipOnErrorInterface}.
* @param Closure|null $when A callable to define a condition for applying this `Any` rule with all defined
* {@see $rules}. See {@see WhenInterface}.
*
* @psalm-param SkipOnEmptyValue $skipOnEmpty
* @psalm-param WhenType $when
*/
public function __construct(
iterable $rules,
private string $message = 'At least one of the inner rules must pass the validation.',
private mixed $skipOnEmpty = null,
private bool $skipOnError = false,
private Closure|null $when = null,
) {
$this->rules = RulesNormalizer::normalizeList($rules);
}

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

/**
* Gets a set of rules for running the validation.
*
* @return iterable A set of rules.
*
* @psalm-return NormalizedRulesList
*/
public function getRules(): iterable
{
return $this->rules;
}

/**
* Gets error message used when validation fails because none of the inner {@see $rules} has passed the validation.
*
* @return string Error message / template.
*
* @see $message
*/
public function getMessage(): string
{
return $this->message;
}

#[ArrayShape([
'message' => 'array',
'skipOnEmpty' => 'bool',
'skipOnError' => 'bool',
'rules' => 'array|null',
])]
public function getOptions(): array
{
return [
'message' => [
'template' => $this->message,
'parameters' => [],
],
'skipOnEmpty' => $this->getSkipOnEmptyOption(),
'skipOnError' => $this->skipOnError,
'rules' => $this->dumpRulesAsArray(),
];
}

public function getHandler(): string
{
return AnyHandler::class;
}

public function afterInitAttribute(object $object): void
{
foreach ($this->rules as $rule) {
if ($rule instanceof AfterInitAttributeEventInterface) {
$rule->afterInitAttribute($object);
}
}
}

/**
* Dumps defined {@see $rules} to array.
*
* @return array The array of rules with their options.
*/
private function dumpRulesAsArray(): array
{
return RulesDumper::asArray($this->getRules());
}
}
33 changes: 33 additions & 0 deletions src/Rule/AnyHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?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;

/**
* A handler for {@see Any} rule. Validates a set of rules consecutively and stops at the rule where validation
* has passed.
*/
final class AnyHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof Any) {
throw new UnexpectedRuleException(Any::class, $rule);
}

foreach ($rule->getRules() as $relatedRule) {
$result = $context->validate($value, $relatedRule);
if ($result->isValid()) {
return $result;
}
}

return (new Result())->addError($rule->getMessage(), []);
}
}
132 changes: 132 additions & 0 deletions tests/Rule/AnyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator\Tests\Rule;

use Yiisoft\Validator\Rule\Any;
use Yiisoft\Validator\Rule\AnyHandler;
use Yiisoft\Validator\Rule\Type\FloatType;
use Yiisoft\Validator\Rule\Type\IntegerType;
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;

final class AnyTest extends RuleTestCase
{
use DifferentRuleInHandlerTestTrait;
use RuleWithOptionsTestTrait;
use SkipOnErrorTestTrait;
use WhenTestTrait;

public function testGetName(): void
{
$rule = new Any([new IntegerType(), new FloatType()]);
$this->assertSame('any', $rule->getName());
}

public function dataOptions(): array
{
return [
'default' => [
new Any([new IntegerType()]),
[
'message' => [
'template' => 'At least one of the inner rules must pass the validation.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
'rules' => [
[
'integerType',
'message' => [
'template' => 'Value must be an integer.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
],
],
],
],
'custom' => [
new Any(
[new IntegerType(), new FloatType()],
message: 'Custom message.',
skipOnEmpty: true,
skipOnError: true,
),
[
'message' => [
'template' => 'Custom message.',
'parameters' => [],
],
'skipOnEmpty' => true,
'skipOnError' => true,
'rules' => [
[
'integerType',
'message' => [
'template' => 'Value must be an integer.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
],
[
'floatType',
'message' => [
'template' => 'Value must be a float.',
'parameters' => [],
],
'skipOnEmpty' => false,
'skipOnError' => false,
],
],
],
],
];
}

public function dataValidationPassed(): array
{
return [
'right away' => [1, new Any([new IntegerType(), new FloatType()])],
'later' => [1.5, new Any([new IntegerType(), new FloatType()])],
];
}

public function dataValidationFailed(): array
{
$message = 'At least one of the inner rules must pass the validation.';

return [
'none' => ['1', new Any([new IntegerType(), new FloatType()]), ['' => [$message]]],
];
}

public function testSkipOnError(): void
{
$this->testSkipOnErrorInternal(
new Any([new IntegerType(), new FloatType()]),
new Any([new IntegerType(), new FloatType()], skipOnError: true),
);
}

public function testWhen(): void
{
$when = static fn (mixed $value): bool => $value !== null;
$this->testWhenInternal(
new Any([new IntegerType(), new FloatType()]),
new Any([new IntegerType(), new FloatType()], when: $when),
);
}

protected function getDifferentRuleInHandlerItems(): array
{
return [Any::class, AnyHandler::class];
}
}
28 changes: 28 additions & 0 deletions tests/Rule/NumberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@

use InvalidArgumentException;
use stdClass;
use Yiisoft\Validator\Rule\Any;
use Yiisoft\Validator\Rule\Integer;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Type\FloatType;
use Yiisoft\Validator\Rule\Type\IntegerType;
use Yiisoft\Validator\Tests\Rule\Base\RuleTestCase;
use Yiisoft\Validator\Tests\Rule\Base\RuleWithOptionsTestTrait;
use Yiisoft\Validator\Tests\Rule\Base\SkipOnErrorTestTrait;
Expand Down Expand Up @@ -211,6 +214,22 @@ public function dataValidationPassed(): array
[-10, [new Number(min: -10, max: 20)]],

[0, [new Integer(min: -10, max: 20)]],

// https://github.com/yiisoft/validator/issues/655
'limit types with other rules, any: validation passed right away' => [
1,
[
new Any([new IntegerType(), new FloatType()]),
new Number(),
]
],
'limit types with other rules, any: validation passed later' => [
1.5,
[
new Any([new IntegerType(), new FloatType()]),
new Number(),
]
],
];
}

Expand Down Expand Up @@ -268,6 +287,15 @@ public function dataValidationFailed(): array
[new Number(min: 5, lessThanMinMessage: 'Value is too small.')],
['' => ['Value is too small.']],
],

// https://github.com/yiisoft/validator/issues/655
'limit types with other rules, any: validation failed' => [
'1.5',
[
new Any([new IntegerType(), new FloatType()]),
new Number(),
],
],
];
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Rule/Type/BooleanTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function dataOptions(): array
],
],
'custom' => [
new BooleanType(message: 'Custom message.', skipOnError: true, skipOnEmpty: true),
new BooleanType(message: 'Custom message.', skipOnEmpty: true, skipOnError: true),
[
'message' => [
'template' => 'Custom message.',
Expand Down
Loading

0 comments on commit 121668e

Please sign in to comment.