Skip to content

Commit ffdac43

Browse files
committed
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.
1 parent 68ed5d2 commit ffdac43

12 files changed

Lines changed: 377 additions & 15 deletions

File tree

src/IsValid.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Respect\Validation;
11+
12+
interface IsValid extends Validator
13+
{
14+
public function isValid(mixed $input): bool;
15+
}

src/ValidatorBuilder.php

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,7 @@ public static function init(Validator ...$validators): self
5656

5757
public function evaluate(mixed $input): Result
5858
{
59-
$validator = match (count($this->validators)) {
60-
0 => throw new ComponentException('No validators have been added.'),
61-
1 => current($this->validators),
62-
default => new AllOf(...$this->validators),
63-
};
64-
65-
return $validator->evaluate($input);
59+
return $this->getEvaluationTarget()->evaluate($input);
6660
}
6761

6862
/** @param array<string|int, mixed>|string|null $template */
@@ -73,7 +67,13 @@ public function validate(mixed $input, array|string|null $template = null): Resu
7367

7468
public function isValid(mixed $input): bool
7569
{
76-
return $this->evaluate($input)->hasPassed;
70+
$validator = $this->getEvaluationTarget();
71+
72+
if ($validator instanceof IsValid) {
73+
return $validator->isValid($input);
74+
}
75+
76+
return $validator->evaluate($input)->hasPassed;
7777
}
7878

7979
/** @param array<string|int, mixed>|callable(ValidationException): Throwable|string|Throwable|null $template */
@@ -118,6 +118,15 @@ public function getName(): Name|null
118118
return null;
119119
}
120120

121+
private function getEvaluationTarget(): Validator
122+
{
123+
return match (count($this->validators)) {
124+
0 => throw new ComponentException('No validators have been added.'),
125+
1 => current($this->validators),
126+
default => new AllOf(...$this->validators),
127+
};
128+
}
129+
121130
/** @param array<string|int, mixed>|string|null $template */
122131
private function toResultQuery(Result $result, array|string|null $template): ResultQuery
123132
{

src/Validators/AllOf.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
namespace Respect\Validation\Validators;
1616

1717
use Attribute;
18+
use Respect\Validation\IsValid;
1819
use Respect\Validation\Message\Template;
1920
use Respect\Validation\Result;
2021
use Respect\Validation\Validator;
@@ -36,7 +37,7 @@
3637
'{{subject}} must pass all the rules',
3738
self::TEMPLATE_ALL,
3839
)]
39-
final class AllOf extends Composite
40+
final class AllOf extends Composite implements IsValid
4041
{
4142
public const string TEMPLATE_ALL = '__all__';
4243
public const string TEMPLATE_SOME = '__some__';
@@ -53,4 +54,19 @@ public function evaluate(mixed $input): Result
5354

5455
return Result::of($valid, $input, $this, [], $template)->withChildren(...$children);
5556
}
57+
58+
public function isValid(mixed $input): bool
59+
{
60+
foreach ($this->validators as $validator) {
61+
if ($validator instanceof IsValid) {
62+
return $validator->isValid($input);
63+
}
64+
65+
if (!$validator->evaluate($input)->hasPassed) {
66+
return false;
67+
}
68+
}
69+
70+
return true;
71+
}
5672
}

src/Validators/AnyOf.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
namespace Respect\Validation\Validators;
1616

1717
use Attribute;
18+
use Respect\Validation\IsValid;
1819
use Respect\Validation\Message\Template;
1920
use Respect\Validation\Result;
2021
use Respect\Validation\Validator;
@@ -28,11 +29,11 @@
2829
'{{subject}} must pass at least one of the rules',
2930
'{{subject}} must pass at least one of the rules',
3031
)]
31-
final class AnyOf extends Composite
32+
final class AnyOf extends Composite implements IsValid
3233
{
3334
public function evaluate(mixed $input): Result
3435
{
35-
$children = array_map(static fn(Validator $validator) => $validator->evaluate($input), $this->validators);
36+
$children = array_map(static fn(Validator $validator) => $validator->evaluate($input), $this->validators);
3637
$valid = array_reduce(
3738
$children,
3839
static fn(bool $carry, Result $result) => $carry || $result->hasPassed,
@@ -41,4 +42,23 @@ public function evaluate(mixed $input): Result
4142

4243
return Result::of($valid, $input, $this)->withChildren(...$children);
4344
}
45+
46+
public function isValid(mixed $input): bool
47+
{
48+
foreach ($this->validators as $validator) {
49+
if ($validator instanceof IsValid) {
50+
if ($validator->isValid($input)) {
51+
return true;
52+
}
53+
54+
continue;
55+
}
56+
57+
if ($validator->evaluate($input)->hasPassed) {
58+
return true;
59+
}
60+
}
61+
62+
return false;
63+
}
4464
}

src/Validators/NoneOf.php

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
namespace Respect\Validation\Validators;
1616

1717
use Attribute;
18+
use Respect\Validation\IsValid;
1819
use Respect\Validation\Message\Template;
1920
use Respect\Validation\Result;
2021
use Respect\Validation\Validators\Core\Composite;
@@ -32,7 +33,7 @@
3233
'{{subject}} must pass all the rules',
3334
self::TEMPLATE_ALL,
3435
)]
35-
final class NoneOf extends Composite
36+
final class NoneOf extends Composite implements IsValid
3637
{
3738
public const string TEMPLATE_ALL = '__all__';
3839
public const string TEMPLATE_SOME = '__some__';
@@ -41,8 +42,8 @@ public function evaluate(mixed $input): Result
4142
{
4243
$failedCount = 0;
4344
$children = [];
44-
foreach ($this->validators as $validator) {
45-
$child = $validator->evaluate($input)->withToggledModeAndValidation();
45+
foreach ($this->validators as $child) {
46+
$child = $child->evaluate($input)->withToggledModeAndValidation();
4647
$children[] = $child;
4748
if ($child->hasPassed) {
4849
continue;
@@ -59,4 +60,19 @@ public function evaluate(mixed $input): Result
5960
count($children) === $failedCount ? self::TEMPLATE_ALL : self::TEMPLATE_SOME,
6061
)->withChildren(...$children);
6162
}
63+
64+
public function isValid(mixed $input): bool
65+
{
66+
foreach ($this->validators as $validator) {
67+
if ($validator instanceof IsValid) {
68+
return !$validator->isValid($input);
69+
}
70+
71+
if ($validator->evaluate($input)->hasPassed) {
72+
return false;
73+
}
74+
}
75+
76+
return true;
77+
}
6278
}

src/Validators/OneOf.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
namespace Respect\Validation\Validators;
1717

1818
use Attribute;
19+
use Respect\Validation\IsValid;
1920
use Respect\Validation\Message\Template;
2021
use Respect\Validation\Result;
2122
use Respect\Validation\Validator;
@@ -38,7 +39,7 @@
3839
'{{subject}} must pass only one of the rules',
3940
self::TEMPLATE_MORE_THAN_ONE,
4041
)]
41-
final class OneOf extends Composite
42+
final class OneOf extends Composite implements IsValid
4243
{
4344
public const string TEMPLATE_NONE = '__none__';
4445
public const string TEMPLATE_MORE_THAN_ONE = '__more_than_one__';
@@ -66,4 +67,30 @@ public function evaluate(mixed $input): Result
6667

6768
return Result::of($valid, $input, $this, [], $template)->withChildren(...$children);
6869
}
70+
71+
public function isValid(mixed $input): bool
72+
{
73+
$passed = 0;
74+
foreach ($this->validators as $validator) {
75+
if ($passed > 1) {
76+
return false;
77+
}
78+
79+
if ($validator instanceof IsValid) {
80+
if ($validator->isValid($input)) {
81+
$passed++;
82+
}
83+
84+
continue;
85+
}
86+
87+
if (!$validator->evaluate($input)->hasPassed) {
88+
continue;
89+
}
90+
91+
$passed++;
92+
}
93+
94+
return $passed === 1;
95+
}
6996
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Respect\Validation\Benchmarks;
11+
12+
use Generator;
13+
use PhpBench\Attributes as Bench;
14+
use Respect\Validation\Validator;
15+
use Respect\Validation\ValidatorBuilder;
16+
use Respect\Validation\Validators\Alnum;
17+
use Respect\Validation\Validators\Alpha;
18+
use Respect\Validation\Validators\BoolType;
19+
use Respect\Validation\Validators\Digit;
20+
use Respect\Validation\Validators\Even;
21+
use Respect\Validation\Validators\FloatType;
22+
use Respect\Validation\Validators\IntType;
23+
use Respect\Validation\Validators\Negative;
24+
use Respect\Validation\Validators\Positive;
25+
use Respect\Validation\Validators\StringType;
26+
27+
final class CompositeValidatorsBench
28+
{
29+
/** @param array{string, array<Validator>} $params */
30+
#[Bench\ParamProviders(['provideValidatorBuilder'])]
31+
#[Bench\Iterations(5)]
32+
#[Bench\Revs(50)]
33+
#[Bench\Warmup(1)]
34+
#[Bench\Subject]
35+
public function isValid(array $params): void
36+
{
37+
ValidatorBuilder::__callStatic(...$params)->isValid(42);
38+
}
39+
40+
public function provideValidatorBuilder(): Generator
41+
{
42+
yield 'allOf(10)' => ['allOf', $this->buildValidators(10)];
43+
yield 'oneOf(10)' => ['oneOf', $this->buildValidators(10)];
44+
yield 'anyOf(10)' => ['anyOf', $this->buildValidators(10)];
45+
yield 'noneOf(10)' => ['noneOf', $this->buildValidators(10)];
46+
yield 'allOf(100)' => ['allOf', $this->buildValidators(100)];
47+
yield 'oneOf(100)' => ['oneOf', $this->buildValidators(100)];
48+
yield 'anyOf(100)' => ['anyOf', $this->buildValidators(100)];
49+
yield 'noneOf(100)' => ['noneOf', $this->buildValidators(100)];
50+
}
51+
52+
/** @return array<Validator> */
53+
private function buildValidators(int $count): array
54+
{
55+
$validators = [];
56+
for ($i = 0; $i < $count; $i++) {
57+
$validators[] = $this->makeValidator($i);
58+
}
59+
60+
return $validators;
61+
}
62+
63+
private function makeValidator(int $index): Validator
64+
{
65+
return match ($index % 10) {
66+
0 => new IntType(),
67+
1 => new Positive(),
68+
2 => new Negative(),
69+
3 => new Even(),
70+
4 => new FloatType(),
71+
5 => new StringType(),
72+
6 => new Alpha(),
73+
7 => new Alnum(),
74+
8 => new Digit(),
75+
default => new BoolType(),
76+
};
77+
}
78+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Respect\Validation;
11+
12+
use PHPUnit\Framework\Attributes\CoversClass;
13+
use PHPUnit\Framework\Attributes\Group;
14+
use PHPUnit\Framework\Attributes\Test;
15+
use Respect\Validation\Exceptions\ComponentException;
16+
use Respect\Validation\Test\TestCase;
17+
use Respect\Validation\Test\Validators\Stub;
18+
use Respect\Validation\Validators\AllOf;
19+
20+
#[Group('validator')]
21+
#[CoversClass(ValidatorBuilder::class)]
22+
final class ValidatorBuilderTest extends TestCase
23+
{
24+
#[Test]
25+
public function shouldDelegateToIsValidWhenSingleValidatorInBuilder(): void
26+
{
27+
$builder = ValidatorBuilder::init(new AllOf(Stub::pass(1), Stub::pass(1)));
28+
29+
self::assertTrue($builder->isValid([]));
30+
}
31+
32+
#[Test]
33+
public function shouldCallIsValidOnCombinedIsValidWhenMultipleValidatorsExist(): void
34+
{
35+
$builder = ValidatorBuilder::init(
36+
Stub::pass(1),
37+
Stub::pass(1),
38+
Stub::pass(1),
39+
Stub::fail(1),
40+
);
41+
42+
self::assertFalse($builder->isValid([]));
43+
}
44+
45+
#[Test]
46+
public function shouldThrowComponentExceptionWhenNoValidatorsExist(): void
47+
{
48+
$this->expectException(ComponentException::class);
49+
50+
ValidatorBuilder::init()->isValid([]);
51+
}
52+
}

0 commit comments

Comments
 (0)