Create "Min" rule

With this rule, we introduce a new type of rule, which is only possible
due to the changes in the validation engine.

Signed-off-by: Henrique Moody <henriquemoody@gmail.com>
This commit is contained in:
Henrique Moody 2024-02-23 15:15:01 +01:00
parent 2f12b6c8d8
commit cc96ee9102
No known key found for this signature in database
GPG key ID: 221E9281655813A6
15 changed files with 352 additions and 52 deletions

View file

@ -1,5 +1,9 @@
# List of rules by category
## Aggregations
- [Min](rules/Min.md)
## Arrays
- [ArrayType](rules/ArrayType.md)
@ -51,6 +55,7 @@
- [In](rules/In.md)
- [LessThan](rules/LessThan.md)
- [LessThanOrEqual](rules/LessThanOrEqual.md)
- [Min](rules/Min.md)
## Composite
@ -352,6 +357,7 @@
- [MacAddress](rules/MacAddress.md)
- [MaxAge](rules/MaxAge.md)
- [Mimetype](rules/Mimetype.md)
- [Min](rules/Min.md)
- [MinAge](rules/MinAge.md)
- [Multiple](rules/Multiple.md)
- [Negative](rules/Negative.md)

View file

@ -36,3 +36,4 @@ See also:
- [Length](Length.md)
- [LessThan](LessThan.md)
- [LessThanOrEqual](LessThanOrEqual.md)
- [Min](Min.md)

View file

@ -30,3 +30,4 @@ See also:
- [Between](Between.md)
- [GreaterThanOrEqual](GreaterThanOrEqual.md)
- [LessThanOrEqual](LessThanOrEqual.md)
- [Min](Min.md)

View file

@ -37,4 +37,5 @@ See also:
- [LessThan](LessThan.md)
- [LessThanOrEqual](LessThanOrEqual.md)
- [MaxAge](MaxAge.md)
- [Min](Min.md)
- [MinAge](MinAge.md)

View file

@ -30,3 +30,4 @@ See also:
- [Between](Between.md)
- [GreaterThanOrEqual](GreaterThanOrEqual.md)
- [LessThanOrEqual](LessThanOrEqual.md)
- [Min](Min.md)

View file

@ -36,4 +36,5 @@ See also:
- [GreaterThanOrEqual](GreaterThanOrEqual.md)
- [LessThan](LessThan.md)
- [MaxAge](MaxAge.md)
- [Min](Min.md)
- [MinAge](MinAge.md)

50
docs/rules/Min.md Normal file
View file

@ -0,0 +1,50 @@
# Min
- `Min(Validatable $rule)`
Validates the minimum value of the input against a given rule.
```php
v::min(v::equals(10))->validate([10, 20, 30]); // true
v::min(v::between('a', 'c'))->validate(['b', 'd', 'f']); // true
v::min(v::greaterThan(new DateTime('yesterday')))
->validate([new DateTime('today'), new DateTime('tomorrow')]); // true
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.
## Categorization
- Aggregations
- Comparisons
## Changelog
| Version | Description |
|--------:|-----------------------------|
| 3.0.0 | Became an aggregation |
| 2.0.0 | Became always inclusive |
| 1.0.0 | Became inclusive by default |
| 0.3.9 | Created |
***
See also:
- [Between](Between.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

View file

@ -198,6 +198,8 @@ interface ChainedValidator extends Validatable
public function maxAge(int $age, ?string $format = null): ChainedValidator;
public function min(Validatable $rule): ChainedValidator;
public function mimetype(string $mimetype): ChainedValidator;
public function greaterThanOrEqual(mixed $compareTo): ChainedValidator;

64
library/Rules/Min.php Normal file
View file

@ -0,0 +1,64 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Rules;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
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
{
public const TEMPLATE_NAMED = '__named__';
public const TEMPLATE_EMPTY = '__empty__';
public const TEMPLATE_TYPE = '__min__';
public function evaluate(mixed $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));
$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);
}
}

View file

@ -18,7 +18,7 @@ abstract class Wrapper implements Validatable
use DeprecatedValidatableMethods;
public function __construct(
private readonly Validatable $rule
protected readonly Validatable $rule
) {
}

View file

@ -200,6 +200,8 @@ interface StaticValidator
public static function maxAge(int $age, ?string $format = null): ChainedValidator;
public static function min(Validatable $rule): ChainedValidator;
public static function mimetype(string $mimetype): ChainedValidator;
public static function greaterThanOrEqual(mixed $compareTo): ChainedValidator;

View file

@ -0,0 +1,49 @@
--FILE--
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Respect\Validation\Validator as v;
run([
'Default' => [v::min(v::equals(1)), [2, 3]],
'Negative' => [v::not(v::min(v::equals(1))), [1, 2, 3]],
'With template' => [v::min(v::equals(1)), [2, 3], 'That did not go as planned'],
'With name' => [v::min(v::equals(1))->setName('Options'), [2, 3]],
]);
?>
--EXPECT--
Default
⎺⎺⎺⎺⎺⎺⎺
As the minimum from `[2, 3]`, 2 must equal 1
- As the minimum from `[2, 3]`, 2 must equal 1
[
'min' => 'As the minimum from `[2, 3]`, 2 must equal 1',
]
Negative
⎺⎺⎺⎺⎺⎺⎺⎺
As the minimum from `[1, 2, 3]`, 1 must not equal 1
- As the minimum from `[1, 2, 3]`, 1 must not equal 1
[
'min' => 'As the minimum from `[1, 2, 3]`, 1 must not equal 1',
]
With template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
That did not go as planned
- That did not go as planned
[
'min' => 'That did not go as planned',
]
With name
⎺⎺⎺⎺⎺⎺⎺⎺⎺
The minimum from Options must equal 1
- The minimum from Options must equal 1
[
'Options' => 'The minimum from Options must equal 1',
]

View file

@ -13,14 +13,6 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\Validatable;
use function implode;
use function ltrim;
use function realpath;
use function Respect\Stringifier\stringify;
use function sprintf;
use function strrchr;
use function substr;
abstract class RuleTestCase extends TestCase
{
/**
@ -58,44 +50,4 @@ abstract class RuleTestCase extends TestCase
{
self::assertInvalidInput($validator, $input);
}
public static function fixture(?string $filename = null): string
{
$parts = [(string) realpath(__DIR__ . '/../fixtures')];
if ($filename !== null) {
$parts[] = ltrim($filename, '/');
}
return implode('/', $parts);
}
public static function assertValidInput(Validatable $rule, mixed $input): void
{
$result = $rule->evaluate($input);
self::assertTrue(
$result->isValid,
sprintf(
'%s should pass with input %s and parameters %s',
substr((string) strrchr($rule::class, '\\'), 1),
stringify($input),
stringify($result->parameters)
)
);
}
public static function assertInvalidInput(Validatable $rule, mixed $input): void
{
$result = $rule->evaluate($input);
self::assertFalse(
$result->isValid,
sprintf(
'%s should fail with input %s and parameters %s',
substr((string) strrchr($rule::class, '\\'), 1),
stringify($input),
stringify($result->parameters)
)
);
}
}

View file

@ -9,10 +9,19 @@ declare(strict_types=1);
namespace Respect\Validation\Test;
use ArrayObject;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Respect\Validation\Validatable;
use stdClass;
use function array_merge;
use function implode;
use function ltrim;
use function realpath;
use function Respect\Stringifier\stringify;
use function sprintf;
use function strrchr;
use function substr;
use function tmpfile;
use const PHP_INT_MAX;
@ -20,12 +29,55 @@ use const PHP_INT_MIN;
abstract class TestCase extends PHPUnitTestCase
{
public static function fixture(?string $filename = null): string
{
$parts = [(string) realpath(__DIR__ . '/../fixtures')];
if ($filename !== null) {
$parts[] = ltrim($filename, '/');
}
return implode('/', $parts);
}
public static function assertValidInput(Validatable $rule, mixed $input): void
{
$result = $rule->evaluate($input);
self::assertTrue(
$result->isValid,
sprintf(
'%s should pass with input %s and parameters %s',
substr((string) strrchr($rule::class, '\\'), 1),
stringify($input),
stringify($result->parameters)
)
);
}
public static function assertInvalidInput(Validatable $rule, mixed $input): void
{
$result = $rule->evaluate($input);
self::assertFalse(
$result->isValid,
sprintf(
'%s should fail with input %s and parameters %s',
substr((string) strrchr($rule::class, '\\'), 1),
stringify($input),
stringify($result->parameters)
)
);
}
/** @return array<array{mixed}> */
public static function providerForAnyValues(): array
{
return array_merge(
self::providerForStringValues(),
self::providerForNonScalarValues(),
self::providerForEmptyIterableValues(),
self::providerForNonEmptyIterableValues(),
self::providerForNonIterableValues(),
self::providerForIntegerValues(),
self::providerForBooleanValues(),
self::providerForFloatValues(),
@ -46,15 +98,48 @@ abstract class TestCase extends PHPUnitTestCase
/** @return array<array{mixed}> */
public static function providerForNonScalarValues(): array
{
return [
return self::providerForNonEmptyIterableValues() + self::providerForNonEmptyIterableValues() + [
'closure' => [static fn() => 'foo'],
'array' => [[]],
'object' => [new stdClass()],
'stdClass' => [new stdClass()],
'null' => [null],
'resource' => [tmpfile()],
];
}
/** @return array<array{mixed}> */
public static function providerForNonIterableValues(): array
{
return array_merge(
self::providerForScalarValues(),
[
'closure' => [static fn() => 'foo'],
'stdClass' => [new stdClass()],
'null' => [null],
'resource' => [tmpfile()],
]
);
}
/** @return array<array{mixed}> */
public static function providerForNonEmptyIterableValues(): array
{
return [
'ArrayObject' => [new ArrayObject([1, 2, 3])],
'array' => [[4, 5, 6]],
'generator' => [(static fn() => yield 7)()], // phpcs:ignore
];
}
/** @return array<array{mixed}> */
public static function providerForEmptyIterableValues(): array
{
return [
'empty ArrayObject' => [new ArrayObject([])],
'empty array' => [[]],
'empty generator' => [(static fn() => yield from [])()],
];
}
/** @return array<array{string}> */
public static function providerForStringValues(): array
{

View file

@ -0,0 +1,85 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Rules;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\Test\Rules\Stub;
use Respect\Validation\Test\TestCase;
#[Group('rule')]
#[CoversClass(Min::class)]
final class MinTest extends TestCase
{
#[Test]
#[DataProvider('providerForNonIterableValues')]
public function itShouldInvalidateNonIterableValues(mixed $input): void
{
$rule = new Min(Stub::daze());
self::assertInvalidInput($rule, $input);
}
/** @param iterable<mixed> $input */
#[Test]
#[DataProvider('providerForEmptyIterableValues')]
public function itShouldInvalidateEmptyIterableValues(iterable $input): void
{
$rule = new Min(Stub::daze());
self::assertInvalidInput($rule, $input);
}
/** @param iterable<mixed> $input */
#[Test]
#[DataProvider('providerForNonEmptyIterableValues')]
public function itShouldValidateNonEmptyIterableValuesWhenWrappedRulePasses(iterable $input): void
{
$rule = new Min(Stub::pass(1));
self::assertValidInput($rule, $input);
}
/** @param iterable<mixed> $input */
#[Test]
#[DataProvider('providerForMinValues')]
public function itShouldValidateWithTheMinimumValue(iterable $input, mixed $min): void
{
$wrapped = Stub::pass(1);
$rule = new Min($wrapped);
$rule->evaluate($input);
self::assertSame($min, $wrapped->inputs[0]);
}
/** @return array<string, array{iterable<mixed>, mixed}> */
public static function providerForMinValues(): array
{
$yesterday = new DateTimeImmutable('yesterday');
$today = new DateTimeImmutable('today');
$tomorrow = new DateTimeImmutable('tomorrow');
return [
'3 DateTime objects' => [[$yesterday, $today, $tomorrow], $yesterday],
'2 DateTime objects' => [[$today, $tomorrow], $today],
'1 DateTime objects' => [[$tomorrow], $tomorrow],
'3 integers' => [[1, 2, 3], 1],
'2 integers' => [[2, 3], 2],
'1 integer' => [[3], 3],
'3 characters' => [['a', 'b', 'c'], 'a'],
'2 characters' => [['b', 'c'], 'b'],
'1 character' => [['c'], 'c'],
];
}
}