From d496fa001f52acc5cbf90f745b6e60050869c7d0 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Wed, 28 Jan 2026 07:52:47 -0300 Subject: [PATCH] Make ValidatorBuilder::isValid fail fast implicitly The evaluation methods of `ValidatorBuilder`, namely `evaluate` and `isValid`, are very similar. Each of them performs a full evaluation, only differing in their return. Whereas `evaluate` returns a full `Result` object, `isValid` only returns a boolean. As it turns out, if the user is interested only in the boolean, we do not need to gather all `Result` data. Here we introduce a new interface, `IsValid`, that defines a method to skip all kinds of message generation for such cases. Fail-fast scenarios such as `isValid` can leverage early failed results to stop the chain when only a boolean is needed. This results in 70% performance improvement for chains with 10 nodes, and the gains increase with chain size. The more nodes, the faster this change makes. Chains with 100 nodes can gain up to 90% performance compared to the previous implementation. A benchmark was added to ensure these gains remain in future library iterations. Furthermore, this change is only internal and backwards-compatible, making no public interface changes that would affect how users interact with the library. TL;DR makes ValidatorBuilder::isValid super fast. --- src/IsValid.php | 15 ++++ src/ValidatorBuilder.php | 25 +++++-- src/Validators/AllOf.php | 18 ++++- src/Validators/AnyOf.php | 22 +++++- src/Validators/NoneOf.php | 22 +++++- src/Validators/OneOf.php | 29 +++++++- tests/benchmark/CompositeValidatorsBench.php | 78 ++++++++++++++++++++ tests/unit/ValidatorBuilderTest.php | 52 +++++++++++++ tests/unit/Validators/AllOfTest.php | 30 ++++++++ tests/unit/Validators/AnyOfTest.php | 34 +++++++++ tests/unit/Validators/NoneOfTest.php | 30 ++++++++ tests/unit/Validators/OneOfTest.php | 35 +++++++++ 12 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 src/IsValid.php create mode 100644 tests/benchmark/CompositeValidatorsBench.php create mode 100644 tests/unit/ValidatorBuilderTest.php 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')); } }