Skip to content

Commit

Permalink
Update the validation engine of the "Each" rule
Browse files Browse the repository at this point in the history
These changes will also introduce an abstract rule that validates
non-empty-iterable values. The abstract rule can also be the parent of
the recently created "Min" rule. Therefore, I've changed that class too.

I've introduced many tests for the "Each" rule to make sure what its
expected behavior is. I'm not super happy with its output, but I tried a
couple of options, and it is the best choice.

Note that Each now rejects `stdClass` and empty iterable values. I
thought that would make sense, as it would be useless when the input is
empty.

Signed-off-by: Henrique Moody <[email protected]>
  • Loading branch information
henriquemoody committed Mar 3, 2024
1 parent 210aa4a commit 433ceb4
Show file tree
Hide file tree
Showing 10 changed files with 431 additions and 215 deletions.
22 changes: 9 additions & 13 deletions docs/rules/Each.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,10 @@ You can also validate array keys combining this rule with [Call](Call.md):
v::call('array_keys', v::each(v::stringType()))->validate($releaseDates); // true
```

This rule will not validate values that are not iterable, to have a more detailed
error message, add [IterableVal](IterableVal.md) to your chain, for example.
## Note

If the input is empty this rule will consider the value as valid, you use
[NotEmpty](NotEmpty.md) if convenient:

```php
v::each(v::dateTime())->validate([]); // true
v::notEmpty()->each(v::dateTime())->validate([]); // false
```
This rule uses [IterableType](IterableType.md) and [NotEmpty](NotEmpty.md) internally. If an input is non-iterable or
empty, the validation will fail.

## Categorization

Expand All @@ -39,10 +33,11 @@ v::notEmpty()->each(v::dateTime())->validate([]); // false

## Changelog

Version | Description
--------|-------------
2.0.0 | Remove support for key validation
0.3.9 | Created
| Version | Description |
|--------:|-------------------------------------------------------------|
| 3.0.0 | Rejected `stdClass`, non-iterable. or empty iterable values |
| 2.0.0 | Remove support for key validation |
| 0.3.9 | Created |

***
See also:
Expand All @@ -52,5 +47,6 @@ See also:
- [IterableType](IterableType.md)
- [IterableVal](IterableVal.md)
- [Key](Key.md)
- [Min](Min.md)
- [NotEmpty](NotEmpty.md)
- [Unique](Unique.md)
12 changes: 4 additions & 8 deletions docs/rules/Min.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,8 @@ v::min(v::lessThan(3))->validate([4, 8, 12]); // false

## Note

This rule uses PHP's [min][] function to compare the input against the given rule. The PHP manual states that:

> Values of different types will be compared using the [standard comparison rules][]. For instance, a non-numeric
> `string` will be compared to an `int` as though it were `0`, but multiple non-numeric `string` values will be compared
> alphanumerically. The actual value returned will be of the original type with no conversion applied.
This rule uses [IterableType](IterableType.md) and [NotEmpty](NotEmpty.md) internally. If an input is non-iterable or
empty, the validation will fail.

## Categorization

Expand All @@ -41,10 +38,9 @@ This rule uses PHP's [min][] function to compare the input against the given rul
See also:

- [Between](Between.md)
- [Each](Each.md)
- [GreaterThan](GreaterThan.md)
- [GreaterThanOrEqual](GreaterThanOrEqual.md)
- [LessThan](LessThan.md)
- [LessThanOrEqual](LessThanOrEqual.md)

[min]: https://www.php.net/min
[standard comparison rules]: https://www.php.net/operators.comparison
- [NotEmpty](NotEmpty.md)
1 change: 1 addition & 0 deletions docs/rules/NotEmpty.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Version | Description
See also:

- [Each](Each.md)
- [Min](Min.md)
- [NoWhitespace](NoWhitespace.md)
- [NotBlank](NotBlank.md)
- [NotOptional](NotOptional.md)
Expand Down
61 changes: 61 additions & 0 deletions library/Rules/Core/FilteredNonEmptyArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* Copyright (c) Alexandre Gomes Gaigalas <[email protected]>
* SPDX-License-Identifier: MIT
*/

declare(strict_types=1);

namespace Respect\Validation\Rules\Core;

use Respect\Validation\Helpers\CanBindEvaluateRule;
use Respect\Validation\Result;
use Respect\Validation\Rules\IterableType;
use Respect\Validation\Rules\NotEmpty;
use Respect\Validation\Rules\Wrapper;

use function is_array;
use function iterator_to_array;
use function lcfirst;
use function strrchr;
use function substr;

abstract class FilteredNonEmptyArray extends Wrapper
{
use CanBindEvaluateRule;

/** @param non-empty-array<mixed> $input */
abstract protected function evaluateNonEmptyArray(array $input): Result;

public function evaluate(mixed $input): Result
{
$id = $this->rule->getName() ?? $this->getName() ?? lcfirst(substr((string) strrchr(static::class, '\\'), 1));
$iterableResult = $this->bindEvaluate(new IterableType(), $this, $input);
if (!$iterableResult->isValid) {
return $iterableResult->withId($id);
}

$array = $this->toArray($input);
$notEmptyResult = $this->bindEvaluate(new NotEmpty(), $this, $array);
if (!$notEmptyResult->isValid) {
return $notEmptyResult->withId($id);
}

// @phpstan-ignore-next-line
return $this->evaluateNonEmptyArray($array);
}

/**
* @param iterable<mixed> $input
* @return array<mixed>
*/
private function toArray(iterable $input): array
{
if (is_array($input)) {
return $input;
}

return iterator_to_array($input);
}
}
82 changes: 9 additions & 73 deletions library/Rules/Each.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,92 +9,28 @@

namespace Respect\Validation\Rules;

use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\EachException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Helpers\CanValidateIterable;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
use Respect\Validation\Rules\Core\FilteredNonEmptyArray;

use function array_map;
use function array_reduce;

#[ExceptionClass(EachException::class)]
#[Template(
'Each item in {{name}} must be valid',
'Each item in {{name}} must not validate',
)]
final class Each extends AbstractRule
final class Each extends FilteredNonEmptyArray
{
use CanValidateIterable;

public function __construct(
private readonly Validatable $rule
) {
}

public function evaluate(mixed $input): Result
/** @param non-empty-array<mixed> $input */
protected function evaluateNonEmptyArray(array $input): Result
{
if (!$this->isIterable($input)) {
return Result::failed($input, $this);
}

$children = [];
$isValid = true;
foreach ($input as $inputItem) {
$childResult = $this->rule->evaluate($inputItem);
$isValid = $isValid && $childResult->isValid;
$children[] = $childResult;
}

$children = array_map(fn ($item) => $this->rule->evaluate($item), $input);
$isValid = array_reduce($children, static fn ($carry, $childResult) => $carry && $childResult->isValid, true);
if ($isValid) {
return Result::passed($input, $this)->withChildren(...$children);
}

return Result::failed($input, $this)->withChildren(...$children);
}

public function assert(mixed $input): void
{
if (!$this->isIterable($input)) {
throw $this->reportError($input);
}

$exceptions = [];
foreach ($input as $value) {
try {
$this->rule->assert($value);
} catch (ValidationException $exception) {
$exceptions[] = $exception;
}
}

if (!empty($exceptions)) {
/** @var EachException $eachException */
$eachException = $this->reportError($input);
$eachException->addChildren($exceptions);

throw $eachException;
}
}

public function check(mixed $input): void
{
if (!$this->isIterable($input)) {
throw $this->reportError($input);
}

foreach ($input as $value) {
$this->rule->check($value);
}
}

public function validate(mixed $input): bool
{
try {
$this->check($input);
} catch (ValidationException $exception) {
return false;
}

return true;
}
}
44 changes: 6 additions & 38 deletions library/Rules/Min.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,54 +11,22 @@

use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rules\Core\FilteredNonEmptyArray;

use function count;
use function is_array;
use function is_iterable;
use function iterator_to_array;
use function min;

#[Template('As the minimum from {{name}},', 'As the minimum from {{name}},')]
#[Template('The minimum from', 'The minimum from', self::TEMPLATE_NAMED)]
#[Template('{{name}} must have at least 1 item', '{{name}} must not have at least 1 item', self::TEMPLATE_EMPTY)]
#[Template(
'{{name}} must be an array or iterable to validate its minimum value',
'{{name}} must not be an array or iterable to validate its minimum value',
self::TEMPLATE_TYPE,
)]
final class Min extends Wrapper
final class Min extends FilteredNonEmptyArray
{
public const TEMPLATE_NAMED = '__named__';
public const TEMPLATE_EMPTY = '__empty__';
public const TEMPLATE_TYPE = '__min__';

public function evaluate(mixed $input): Result
/** @param non-empty-array<mixed> $input */
protected function evaluateNonEmptyArray(array $input): Result
{
if (!is_iterable($input)) {
return Result::failed($input, $this);
}

$array = $this->toArray($input);
if (count($array) === 0) {
return Result::failed($input, $this);
}

$result = $this->rule->evaluate(min($array));
$result = $this->rule->evaluate(min($input));
$template = $this->getName() === null ? self::TEMPLATE_STANDARD : self::TEMPLATE_NAMED;

return (new Result($result->isValid, $input, $this, [], $template,))->withNextSibling($result);
}

/**
* @param iterable<mixed> $input
* @return array<mixed>
*/
private function toArray(iterable $input): array
{
if (is_array($input)) {
return $input;
}

return iterator_to_array($input);
return (new Result($result->isValid, $input, $this, [], $template))->withNextSibling($result);
}
}
Loading

0 comments on commit 433ceb4

Please sign in to comment.