diff --git a/docs/08-list-of-rules-by-category.md b/docs/08-list-of-rules-by-category.md index d1798d7f7..261f1731b 100644 --- a/docs/08-list-of-rules-by-category.md +++ b/docs/08-list-of-rules-by-category.md @@ -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) diff --git a/docs/rules/Between.md b/docs/rules/Between.md index 176d5298b..210f712cc 100644 --- a/docs/rules/Between.md +++ b/docs/rules/Between.md @@ -36,3 +36,4 @@ See also: - [Length](Length.md) - [LessThan](LessThan.md) - [LessThanOrEqual](LessThanOrEqual.md) +- [Min](Min.md) diff --git a/docs/rules/GreaterThan.md b/docs/rules/GreaterThan.md index 6bb209985..66ce5b064 100644 --- a/docs/rules/GreaterThan.md +++ b/docs/rules/GreaterThan.md @@ -30,3 +30,4 @@ See also: - [Between](Between.md) - [GreaterThanOrEqual](GreaterThanOrEqual.md) - [LessThanOrEqual](LessThanOrEqual.md) +- [Min](Min.md) diff --git a/docs/rules/GreaterThanOrEqual.md b/docs/rules/GreaterThanOrEqual.md index 1563736b6..62669be18 100644 --- a/docs/rules/GreaterThanOrEqual.md +++ b/docs/rules/GreaterThanOrEqual.md @@ -37,4 +37,5 @@ See also: - [LessThan](LessThan.md) - [LessThanOrEqual](LessThanOrEqual.md) - [MaxAge](MaxAge.md) +- [Min](Min.md) - [MinAge](MinAge.md) diff --git a/docs/rules/LessThan.md b/docs/rules/LessThan.md index c10f59a16..a5e6dede3 100644 --- a/docs/rules/LessThan.md +++ b/docs/rules/LessThan.md @@ -30,3 +30,4 @@ See also: - [Between](Between.md) - [GreaterThanOrEqual](GreaterThanOrEqual.md) - [LessThanOrEqual](LessThanOrEqual.md) +- [Min](Min.md) diff --git a/docs/rules/LessThanOrEqual.md b/docs/rules/LessThanOrEqual.md index 046fb0d10..6992a7d98 100644 --- a/docs/rules/LessThanOrEqual.md +++ b/docs/rules/LessThanOrEqual.md @@ -36,4 +36,5 @@ See also: - [GreaterThanOrEqual](GreaterThanOrEqual.md) - [LessThan](LessThan.md) - [MaxAge](MaxAge.md) +- [Min](Min.md) - [MinAge](MinAge.md) diff --git a/docs/rules/Min.md b/docs/rules/Min.md new file mode 100644 index 000000000..94818e00a --- /dev/null +++ b/docs/rules/Min.md @@ -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 diff --git a/library/ChainedValidator.php b/library/ChainedValidator.php index 38d00e13d..1290f295b 100644 --- a/library/ChainedValidator.php +++ b/library/ChainedValidator.php @@ -198,6 +198,8 @@ public function lessThanOrEqual(mixed $compareTo): ChainedValidator; 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; diff --git a/library/Rules/Min.php b/library/Rules/Min.php new file mode 100644 index 000000000..c6bdcd2a3 --- /dev/null +++ b/library/Rules/Min.php @@ -0,0 +1,64 @@ + + * 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 $input + * @return array + */ + private function toArray(iterable $input): array + { + if (is_array($input)) { + return $input; + } + + return iterator_to_array($input); + } +} diff --git a/library/Rules/Wrapper.php b/library/Rules/Wrapper.php index 6faab962a..08dd2fb54 100644 --- a/library/Rules/Wrapper.php +++ b/library/Rules/Wrapper.php @@ -18,7 +18,7 @@ abstract class Wrapper implements Validatable use DeprecatedValidatableMethods; public function __construct( - private readonly Validatable $rule + protected readonly Validatable $rule ) { } diff --git a/library/StaticValidator.php b/library/StaticValidator.php index ed0ae851e..53aa45847 100644 --- a/library/StaticValidator.php +++ b/library/StaticValidator.php @@ -200,6 +200,8 @@ public static function lessThanOrEqual(mixed $compareTo): ChainedValidator; 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; diff --git a/tests/integration/rules/min.phpt b/tests/integration/rules/min.phpt new file mode 100644 index 000000000..783796175 --- /dev/null +++ b/tests/integration/rules/min.phpt @@ -0,0 +1,49 @@ +--FILE-- + [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', +] + diff --git a/tests/library/RuleTestCase.php b/tests/library/RuleTestCase.php index c14f6a143..ee9c465e4 100644 --- a/tests/library/RuleTestCase.php +++ b/tests/library/RuleTestCase.php @@ -13,14 +13,6 @@ 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 @@ public function shouldValidateInvalidInput(Validatable $validator, mixed $input) { 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) - ) - ); - } } diff --git a/tests/library/TestCase.php b/tests/library/TestCase.php index aedc43eae..3790a5e95 100644 --- a/tests/library/TestCase.php +++ b/tests/library/TestCase.php @@ -9,10 +9,19 @@ 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 @@ 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 */ 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 @@ public static function providerForScalarValues(): array /** @return array */ 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 */ + public static function providerForNonIterableValues(): array + { + return array_merge( + self::providerForScalarValues(), + [ + 'closure' => [static fn() => 'foo'], + 'stdClass' => [new stdClass()], + 'null' => [null], + 'resource' => [tmpfile()], + ] + ); + } + + /** @return array */ + 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 */ + public static function providerForEmptyIterableValues(): array + { + return [ + 'empty ArrayObject' => [new ArrayObject([])], + 'empty array' => [[]], + 'empty generator' => [(static fn() => yield from [])()], + ]; + } + /** @return array */ public static function providerForStringValues(): array { diff --git a/tests/unit/Rules/MinTest.php b/tests/unit/Rules/MinTest.php new file mode 100644 index 000000000..dcbcad520 --- /dev/null +++ b/tests/unit/Rules/MinTest.php @@ -0,0 +1,85 @@ + + * 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 $input */ + #[Test] + #[DataProvider('providerForEmptyIterableValues')] + public function itShouldInvalidateEmptyIterableValues(iterable $input): void + { + $rule = new Min(Stub::daze()); + + self::assertInvalidInput($rule, $input); + } + + /** @param iterable $input */ + #[Test] + #[DataProvider('providerForNonEmptyIterableValues')] + public function itShouldValidateNonEmptyIterableValuesWhenWrappedRulePasses(iterable $input): void + { + $rule = new Min(Stub::pass(1)); + + self::assertValidInput($rule, $input); + } + + /** @param iterable $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, 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'], + ]; + } +}