Skip to content

Commit 8b40374

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, `ResultGenerator`, alongside a new abstract class `Generative`, that implements a solution for this architecturally from the bottom-up. This interface provides a way for "Generating Results" in a stream-like fashion, leveraging PHP Generators. Existing "gather full results into collection" implementations can reuse the stream interface by using iterator_to_array easily, while 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 8b40374

12 files changed

Lines changed: 381 additions & 21 deletions

File tree

src/ResultGenerator.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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 Generator;
13+
14+
interface ResultGenerator extends Validator
15+
{
16+
/** @return Generator<int, Result> */
17+
public function generate(mixed $input): Generator;
18+
19+
public function isValid(mixed $input): bool;
20+
}

src/ValidatorBuilder.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,17 @@ public function validate(mixed $input, array|string|null $template = null): Resu
7373

7474
public function isValid(mixed $input): bool
7575
{
76-
return $this->evaluate($input)->hasPassed;
76+
$validator = match (count($this->validators)) {
77+
0 => throw new ComponentException('No validators have been added.'),
78+
1 => current($this->validators),
79+
default => new AllOf(...$this->validators),
80+
};
81+
82+
if ($validator instanceof ResultGenerator) {
83+
return $validator->isValid($input);
84+
}
85+
86+
return $validator->evaluate($input)->hasPassed;
7787
}
7888

7989
/** @param array<string|int, mixed>|callable(ValidationException): Throwable|string|Throwable|null $template */

src/Validators/AllOf.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@
1717
use Attribute;
1818
use Respect\Validation\Message\Template;
1919
use Respect\Validation\Result;
20-
use Respect\Validation\Validator;
21-
use Respect\Validation\Validators\Core\Composite;
20+
use Respect\Validation\Validators\Core\Generative;
2221

2322
use function array_filter;
24-
use function array_map;
2523
use function array_reduce;
2624
use function count;
25+
use function iterator_to_array;
2726

2827
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2928
#[Template(
@@ -36,14 +35,14 @@
3635
'{{subject}} must pass all the rules',
3736
self::TEMPLATE_ALL,
3837
)]
39-
final class AllOf extends Composite
38+
final class AllOf extends Generative
4039
{
4140
public const string TEMPLATE_ALL = '__all__';
4241
public const string TEMPLATE_SOME = '__some__';
4342

4443
public function evaluate(mixed $input): Result
4544
{
46-
$children = array_map(static fn(Validator $validator) => $validator->evaluate($input), $this->validators);
45+
$children = iterator_to_array($this->generate($input), false);
4746
$valid = array_reduce($children, static fn(bool $carry, Result $result) => $carry && $result->hasPassed, true);
4847
$failed = array_filter($children, static fn(Result $result): bool => !$result->hasPassed);
4948
$template = self::TEMPLATE_SOME;

src/Validators/AnyOf.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,21 @@
1717
use Attribute;
1818
use Respect\Validation\Message\Template;
1919
use Respect\Validation\Result;
20-
use Respect\Validation\Validator;
21-
use Respect\Validation\Validators\Core\Composite;
20+
use Respect\Validation\Validators\Core\Generative;
2221

23-
use function array_map;
2422
use function array_reduce;
23+
use function iterator_to_array;
2524

2625
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2726
#[Template(
2827
'{{subject}} must pass at least one of the rules',
2928
'{{subject}} must pass at least one of the rules',
3029
)]
31-
final class AnyOf extends Composite
30+
final class AnyOf extends Generative
3231
{
3332
public function evaluate(mixed $input): Result
3433
{
35-
$children = array_map(static fn(Validator $validator) => $validator->evaluate($input), $this->validators);
34+
$children = iterator_to_array($this->generate($input), false);
3635
$valid = array_reduce(
3736
$children,
3837
static fn(bool $carry, Result $result) => $carry || $result->hasPassed,
@@ -41,4 +40,15 @@ public function evaluate(mixed $input): Result
4140

4241
return Result::of($valid, $input, $this)->withChildren(...$children);
4342
}
43+
44+
public function isValid(mixed $input): bool
45+
{
46+
foreach ($this->generate($input) as $child) {
47+
if ($child->hasPassed) {
48+
return true;
49+
}
50+
}
51+
52+
return false;
53+
}
4454
}

src/Validators/Core/Generative.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Validation\Validators\Core;
12+
13+
use Generator;
14+
use Respect\Validation\ResultGenerator;
15+
use Respect\Validation\Validator;
16+
17+
use function array_merge;
18+
19+
abstract class Generative implements ResultGenerator
20+
{
21+
/** @var non-empty-array<Validator> */
22+
protected readonly array $validators;
23+
24+
public function __construct(Validator $validator1, Validator $validator2, Validator ...$validators)
25+
{
26+
$this->validators = array_merge([$validator1, $validator2], $validators);
27+
}
28+
29+
/** @return non-empty-array<Validator> */
30+
public function getValidators(): array
31+
{
32+
return $this->validators;
33+
}
34+
35+
public function generate(mixed $input): Generator
36+
{
37+
yield from $this->generateChildren($input);
38+
}
39+
40+
public function isValid(mixed $input): bool
41+
{
42+
foreach ($this->generate($input) as $child) {
43+
if (!$child->hasPassed) {
44+
return false;
45+
}
46+
}
47+
48+
return true;
49+
}
50+
51+
protected function generateChildren(mixed $input): Generator
52+
{
53+
foreach ($this->validators as $validator) {
54+
yield $validator->evaluate($input);
55+
}
56+
}
57+
}

src/Validators/NoneOf.php

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

1717
use Attribute;
18+
use Generator;
1819
use Respect\Validation\Message\Template;
1920
use Respect\Validation\Result;
20-
use Respect\Validation\Validators\Core\Composite;
21+
use Respect\Validation\Validators\Core\Generative;
2122

2223
use function count;
24+
use function iterator_to_array;
2325

2426
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
2527
#[Template(
@@ -32,18 +34,16 @@
3234
'{{subject}} must pass all the rules',
3335
self::TEMPLATE_ALL,
3436
)]
35-
final class NoneOf extends Composite
37+
final class NoneOf extends Generative
3638
{
3739
public const string TEMPLATE_ALL = '__all__';
3840
public const string TEMPLATE_SOME = '__some__';
3941

4042
public function evaluate(mixed $input): Result
4143
{
4244
$failedCount = 0;
43-
$children = [];
44-
foreach ($this->validators as $validator) {
45-
$child = $validator->evaluate($input)->withToggledModeAndValidation();
46-
$children[] = $child;
45+
$children = iterator_to_array($this->generate($input), false);
46+
foreach ($children as $child) {
4747
if ($child->hasPassed) {
4848
continue;
4949
}
@@ -59,4 +59,11 @@ public function evaluate(mixed $input): Result
5959
count($children) === $failedCount ? self::TEMPLATE_ALL : self::TEMPLATE_SOME,
6060
)->withChildren(...$children);
6161
}
62+
63+
public function generate(mixed $input): Generator
64+
{
65+
foreach ($this->validators as $validator) {
66+
yield $validator->evaluate($input)->withToggledModeAndValidation();
67+
}
68+
}
6269
}

src/Validators/OneOf.php

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
use Attribute;
1919
use Respect\Validation\Message\Template;
2020
use Respect\Validation\Result;
21-
use Respect\Validation\Validator;
22-
use Respect\Validation\Validators\Core\Composite;
21+
use Respect\Validation\Validators\Core\Generative;
2322

2423
use function array_filter;
2524
use function array_map;
2625
use function array_reduce;
2726
use function count;
27+
use function iterator_to_array;
2828
use function usort;
2929

3030
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
@@ -38,14 +38,14 @@
3838
'{{subject}} must pass only one of the rules',
3939
self::TEMPLATE_MORE_THAN_ONE,
4040
)]
41-
final class OneOf extends Composite
41+
final class OneOf extends Generative
4242
{
4343
public const string TEMPLATE_NONE = '__none__';
4444
public const string TEMPLATE_MORE_THAN_ONE = '__more_than_one__';
4545

4646
public function evaluate(mixed $input): Result
4747
{
48-
$children = array_map(static fn(Validator $validator) => $validator->evaluate($input), $this->validators);
48+
$children = iterator_to_array($this->generate($input), false);
4949
$valid = array_reduce(
5050
$children,
5151
static fn(bool $carry, Result $result) => $carry xor $result->hasPassed,
@@ -66,4 +66,21 @@ public function evaluate(mixed $input): Result
6666

6767
return Result::of($valid, $input, $this, [], $template)->withChildren(...$children);
6868
}
69+
70+
public function isValid(mixed $input): bool
71+
{
72+
$passedCount = 0;
73+
foreach ($this->generate($input) as $child) {
74+
if (!$child->hasPassed) {
75+
continue;
76+
}
77+
78+
$passedCount++;
79+
if ($passedCount > 1) {
80+
return false;
81+
}
82+
}
83+
84+
return $passedCount === 1;
85+
}
6986
}
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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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\Test\TestCase;
16+
use Respect\Validation\Test\Validators\Stub;
17+
use Respect\Validation\Validators\AllOf;
18+
19+
#[Group('validator')]
20+
#[CoversClass(ValidatorBuilder::class)]
21+
final class ValidatorBuilderTest extends TestCase
22+
{
23+
#[Test]
24+
public function shouldDelegateToGenerativeValidatorWhenSingleValidatorInBuilder(): void
25+
{
26+
$builder = ValidatorBuilder::init(new AllOf(Stub::pass(1), Stub::pass(1)));
27+
28+
self::assertTrue($builder->isValid([]));
29+
}
30+
31+
#[Test]
32+
public function shouldCallIsValidOnCombinedGenerativeValidatorWhenMultipleValidatorsExist(): void
33+
{
34+
$builder = ValidatorBuilder::init(
35+
new AllOf(Stub::pass(1), Stub::pass(1)),
36+
new AllOf(Stub::pass(1), Stub::fail(1)),
37+
);
38+
39+
self::assertFalse($builder->isValid([]));
40+
}
41+
}

0 commit comments

Comments
 (0)