diff --git a/src/IsValid.php b/src/IsValid.php new file mode 100644 index 000000000..de518abaf --- /dev/null +++ b/src/IsValid.php @@ -0,0 +1,15 @@ +validators)) { - 0 => throw new ComponentException('No validators have been added.'), - 1 => current($this->validators), - default => new AllOf(...$this->validators), - }; - - return $validator->evaluate($input); + return $this->getEvaluationTarget()->evaluate($input); } /** @param array|string|null $template */ @@ -73,7 +67,13 @@ public function validate(mixed $input, array|string|null $template = null): Resu public function isValid(mixed $input): bool { - return $this->evaluate($input)->hasPassed; + $validator = $this->getEvaluationTarget(); + + if ($validator instanceof IsValid) { + return $validator->isValid($input); + } + + return $validator->evaluate($input)->hasPassed; } /** @param array|callable(ValidationException): Throwable|string|Throwable|null $template */ @@ -118,6 +118,15 @@ public function getName(): Name|null return null; } + private function getEvaluationTarget(): Validator + { + return match (count($this->validators)) { + 0 => throw new ComponentException('No validators have been added.'), + 1 => current($this->validators), + default => new AllOf(...$this->validators), + }; + } + /** @param array|string|null $template */ private function toResultQuery(Result $result, array|string|null $template): ResultQuery { diff --git a/src/Validators/AllOf.php b/src/Validators/AllOf.php index 5ee7ca546..e3b8e289c 100644 --- a/src/Validators/AllOf.php +++ b/src/Validators/AllOf.php @@ -15,6 +15,7 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\IsValid; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; @@ -36,7 +37,7 @@ '{{subject}} must pass all the rules', self::TEMPLATE_ALL, )] -final class AllOf extends Composite +final class AllOf extends Composite implements IsValid { public const string TEMPLATE_ALL = '__all__'; public const string TEMPLATE_SOME = '__some__'; @@ -53,4 +54,19 @@ public function evaluate(mixed $input): Result return Result::of($valid, $input, $this, [], $template)->withChildren(...$children); } + + public function isValid(mixed $input): bool + { + foreach ($this->validators as $validator) { + if ($validator instanceof IsValid) { + return $validator->isValid($input); + } + + if (!$validator->evaluate($input)->hasPassed) { + return false; + } + } + + return true; + } } diff --git a/src/Validators/AnyOf.php b/src/Validators/AnyOf.php index 5518e8045..9be4dcb62 100644 --- a/src/Validators/AnyOf.php +++ b/src/Validators/AnyOf.php @@ -15,6 +15,7 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\IsValid; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; @@ -28,7 +29,7 @@ '{{subject}} must pass at least one of the rules', '{{subject}} must pass at least one of the rules', )] -final class AnyOf extends Composite +final class AnyOf extends Composite implements IsValid { public function evaluate(mixed $input): Result { @@ -41,4 +42,23 @@ public function evaluate(mixed $input): Result return Result::of($valid, $input, $this)->withChildren(...$children); } + + public function isValid(mixed $input): bool + { + foreach ($this->validators as $validator) { + if ($validator instanceof IsValid) { + if ($validator->isValid($input)) { + return true; + } + + continue; + } + + if ($validator->evaluate($input)->hasPassed) { + return true; + } + } + + return false; + } } diff --git a/src/Validators/NoneOf.php b/src/Validators/NoneOf.php index f7fb46d5c..7e664a48d 100644 --- a/src/Validators/NoneOf.php +++ b/src/Validators/NoneOf.php @@ -15,6 +15,7 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\IsValid; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validators\Core\Composite; @@ -32,7 +33,7 @@ '{{subject}} must pass all the rules', self::TEMPLATE_ALL, )] -final class NoneOf extends Composite +final class NoneOf extends Composite implements IsValid { public const string TEMPLATE_ALL = '__all__'; public const string TEMPLATE_SOME = '__some__'; @@ -41,8 +42,8 @@ public function evaluate(mixed $input): Result { $failedCount = 0; $children = []; - foreach ($this->validators as $validator) { - $child = $validator->evaluate($input)->withToggledModeAndValidation(); + foreach ($this->validators as $child) { + $child = $child->evaluate($input)->withToggledModeAndValidation(); $children[] = $child; if ($child->hasPassed) { continue; @@ -59,4 +60,19 @@ public function evaluate(mixed $input): Result count($children) === $failedCount ? self::TEMPLATE_ALL : self::TEMPLATE_SOME, )->withChildren(...$children); } + + public function isValid(mixed $input): bool + { + foreach ($this->validators as $validator) { + if ($validator instanceof IsValid) { + return !$validator->isValid($input); + } + + if ($validator->evaluate($input)->hasPassed) { + return false; + } + } + + return true; + } } diff --git a/src/Validators/OneOf.php b/src/Validators/OneOf.php index b3b5f3eee..dc39e41f7 100644 --- a/src/Validators/OneOf.php +++ b/src/Validators/OneOf.php @@ -16,6 +16,7 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\IsValid; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; @@ -38,7 +39,7 @@ '{{subject}} must pass only one of the rules', self::TEMPLATE_MORE_THAN_ONE, )] -final class OneOf extends Composite +final class OneOf extends Composite implements IsValid { public const string TEMPLATE_NONE = '__none__'; public const string TEMPLATE_MORE_THAN_ONE = '__more_than_one__'; @@ -66,4 +67,30 @@ public function evaluate(mixed $input): Result return Result::of($valid, $input, $this, [], $template)->withChildren(...$children); } + + public function isValid(mixed $input): bool + { + $passed = 0; + foreach ($this->validators as $validator) { + if ($passed > 1) { + return false; + } + + if ($validator instanceof IsValid) { + if ($validator->isValid($input)) { + $passed++; + } + + continue; + } + + if (!$validator->evaluate($input)->hasPassed) { + continue; + } + + $passed++; + } + + return $passed === 1; + } } diff --git a/tests/benchmark/CompositeValidatorsBench.php b/tests/benchmark/CompositeValidatorsBench.php new file mode 100644 index 000000000..1a718898e --- /dev/null +++ b/tests/benchmark/CompositeValidatorsBench.php @@ -0,0 +1,78 @@ +} $params */ + #[Bench\ParamProviders(['provideValidatorBuilder'])] + #[Bench\Iterations(5)] + #[Bench\Revs(50)] + #[Bench\Warmup(1)] + #[Bench\Subject] + public function isValid(array $params): void + { + ValidatorBuilder::__callStatic(...$params)->isValid(42); + } + + public function provideValidatorBuilder(): Generator + { + yield 'allOf(10)' => ['allOf', $this->buildValidators(10)]; + yield 'oneOf(10)' => ['oneOf', $this->buildValidators(10)]; + yield 'anyOf(10)' => ['anyOf', $this->buildValidators(10)]; + yield 'noneOf(10)' => ['noneOf', $this->buildValidators(10)]; + yield 'allOf(100)' => ['allOf', $this->buildValidators(100)]; + yield 'oneOf(100)' => ['oneOf', $this->buildValidators(100)]; + yield 'anyOf(100)' => ['anyOf', $this->buildValidators(100)]; + yield 'noneOf(100)' => ['noneOf', $this->buildValidators(100)]; + } + + /** @return array */ + private function buildValidators(int $count): array + { + $validators = []; + for ($i = 0; $i < $count; $i++) { + $validators[] = $this->makeValidator($i); + } + + return $validators; + } + + private function makeValidator(int $index): Validator + { + return match ($index % 10) { + 0 => new IntType(), + 1 => new Positive(), + 2 => new Negative(), + 3 => new Even(), + 4 => new FloatType(), + 5 => new StringType(), + 6 => new Alpha(), + 7 => new Alnum(), + 8 => new Digit(), + default => new BoolType(), + }; + } +} diff --git a/tests/unit/ValidatorBuilderTest.php b/tests/unit/ValidatorBuilderTest.php new file mode 100644 index 000000000..c80f351d9 --- /dev/null +++ b/tests/unit/ValidatorBuilderTest.php @@ -0,0 +1,52 @@ +isValid([])); + } + + #[Test] + public function shouldCallIsValidOnCombinedIsValidWhenMultipleValidatorsExist(): void + { + $builder = ValidatorBuilder::init( + Stub::pass(1), + Stub::pass(1), + Stub::pass(1), + Stub::fail(1), + ); + + self::assertFalse($builder->isValid([])); + } + + #[Test] + public function shouldThrowComponentExceptionWhenNoValidatorsExist(): void + { + $this->expectException(ComponentException::class); + + ValidatorBuilder::init()->isValid([]); + } +} diff --git a/tests/unit/Validators/AllOfTest.php b/tests/unit/Validators/AllOfTest.php index 36309cdd7..fc45444c3 100644 --- a/tests/unit/Validators/AllOfTest.php +++ b/tests/unit/Validators/AllOfTest.php @@ -15,7 +15,9 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use Respect\Validation\Test\RuleTestCase; use Respect\Validation\Test\Validators\Stub; @@ -39,4 +41,32 @@ public static function providerForInvalidInput(): iterable yield 'pass, fail, pass' => [new AllOf(Stub::pass(1), Stub::fail(1), Stub::pass(1)), []]; yield 'fail, pass, pass' => [new AllOf(Stub::fail(1), Stub::pass(1), Stub::pass(1)), []]; } + + #[Test] + #[DataProvider('providerForValidInput')] + public function isValid(AllOf $allOf, mixed $input): void + { + self::assertTrue($allOf->isValid($input)); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function notIsValid(AllOf $allOf, mixed $input): void + { + self::assertFalse($allOf->isValid($input)); + } + + #[Test] + public function shouldRecursivelyCheckIsValid(): void + { + $validator = new AllOf( + new AllOf( + Stub::pass(1), + Stub::pass(1), + ), + Stub::pass(1), + ); + + self::assertTrue($validator->isValid('any input')); + } } diff --git a/tests/unit/Validators/AnyOfTest.php b/tests/unit/Validators/AnyOfTest.php index d5f81c532..ba0755323 100644 --- a/tests/unit/Validators/AnyOfTest.php +++ b/tests/unit/Validators/AnyOfTest.php @@ -18,7 +18,9 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use Respect\Validation\Test\RuleTestCase; use Respect\Validation\Test\Validators\Stub; @@ -42,4 +44,36 @@ public static function providerForInvalidInput(): iterable yield 'fail, fail' => [new AnyOf(Stub::fail(1), Stub::fail(1)), []]; yield 'fail, fail, fail' => [new AnyOf(Stub::fail(1), Stub::fail(1), Stub::fail(1)), []]; } + + #[Test] + #[DataProvider('providerForValidInput')] + public function isValid(AnyOf $anyOf, mixed $input): void + { + self::assertTrue($anyOf->isValid($input)); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function notIsValid(AnyOf $anyOf, mixed $input): void + { + self::assertFalse($anyOf->isValid($input)); + } + + #[Test] + public function shouldRecursivelyCheckIsValid(): void + { + $validator = new AnyOf( + new AllOf( + Stub::pass(2), + Stub::fail(2), + ), + new AnyOf( + Stub::fail(2), + Stub::pass(2), + ), + Stub::fail(1), + ); + + self::assertTrue($validator->isValid('any value')); + } } diff --git a/tests/unit/Validators/NoneOfTest.php b/tests/unit/Validators/NoneOfTest.php index 37d1778c8..45c9e94ae 100644 --- a/tests/unit/Validators/NoneOfTest.php +++ b/tests/unit/Validators/NoneOfTest.php @@ -15,7 +15,9 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use Respect\Validation\Test\RuleTestCase; use Respect\Validation\Test\Validators\Stub; @@ -39,4 +41,32 @@ public static function providerForInvalidInput(): iterable yield 'pass, fail, pass' => [new NoneOf(Stub::pass(1), Stub::fail(1), Stub::pass(1)), []]; yield 'fail, pass, pass' => [new NoneOf(Stub::fail(1), Stub::pass(1), Stub::pass(1)), []]; } + + #[Test] + #[DataProvider('providerForValidInput')] + public function isValid(NoneOf $noneOf, mixed $input): void + { + self::assertTrue($noneOf->isValid($input)); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function notIsValid(NoneOf $noneOf, mixed $input): void + { + self::assertFalse($noneOf->isValid($input)); + } + + #[Test] + public function shouldRecursivelyCheckIsValid(): void + { + $validator = new NoneOf( + new AllOf( + Stub::pass(2), + Stub::fail(2), + ), + Stub::fail(1), + ); + + self::assertTrue($validator->isValid('any value')); + } } diff --git a/tests/unit/Validators/OneOfTest.php b/tests/unit/Validators/OneOfTest.php index a00e90fdd..e550d3770 100644 --- a/tests/unit/Validators/OneOfTest.php +++ b/tests/unit/Validators/OneOfTest.php @@ -19,7 +19,9 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use Respect\Validation\Test\RuleTestCase; use Respect\Validation\Test\Validators\Stub; @@ -43,5 +45,38 @@ public static function providerForInvalidInput(): iterable yield 'fail, fail' => [new OneOf(Stub::fail(1), Stub::fail(1)), []]; yield 'fail, fail, fail' => [new OneOf(Stub::fail(1), Stub::fail(1), Stub::fail(1)), []]; yield 'fail, pass, pass' => [new OneOf(Stub::fail(1), Stub::pass(1), Stub::pass(1)), []]; + yield 'pass, pass, fail' => [new OneOf(Stub::pass(1), Stub::pass(1), Stub::fail(1)), []]; + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function isValid(OneOf $oneOf, mixed $input): void + { + self::assertTrue($oneOf->isValid($input)); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function notIsValid(OneOf $oneOf, mixed $input): void + { + self::assertFalse($oneOf->isValid($input)); + } + + #[Test] + public function shouldRecursivelyCheckIsValid(): void + { + $validator = new OneOf( + new AllOf( + Stub::pass(2), + Stub::fail(2), + ), + new AnyOf( + Stub::fail(2), + Stub::pass(2), + ), + Stub::fail(1), + ); + + self::assertTrue($validator->isValid('any value')); } }