mirror of
https://github.com/Respect/Validation.git
synced 2026-03-15 14:55:44 +01:00
This commit introduces a mechanism for validators to return early once the validation outcome is determined, rather than evaluating all child validators. The ShortCircuit validator evaluates validators sequentially and stops at the first failure, similar to how PHP's && operator works. This is useful when later validators depend on earlier ones passing, or when you want only the first error message. The ShortCircuitCapable interface allows composite validators (AllOf, AnyOf, OneOf, NoneOf, Each, All) to implement their own short-circuit logic. Why "ShortCircuit" instead of "FailFast": The name "FailFast" was initially considered but proved misleading. While AllOf stops on failure (fail fast), AnyOf stops on success (succeed fast), and OneOf stops on the second success. The common behavior is not about failing quickly, but about returning as soon as the outcome is determined—which is exactly what short-circuit evaluation means. This terminology is familiar to developers from boolean operators (&& and ||), making the behavior immediately understandable. Co-authored-by: Alexandre Gomes Gaigalas <alganet@gmail.com> Assisted-by: Claude Code (Opus 4.5)
337 lines
15 KiB
PHP
337 lines
15 KiB
PHP
<?php
|
|
|
|
/*
|
|
* SPDX-License-Identifier: MIT
|
|
* SPDX-FileCopyrightText: (c) Respect Project Contributors
|
|
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
|
|
* SPDX-FileContributor: Dominick Johnson
|
|
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
test('Non-iterable', catchAll(
|
|
fn() => v::each(v::intType())->assert(null),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`null` must be iterable')
|
|
->and($fullMessage)->toBe('- `null` must be iterable')
|
|
->and($messages)->toBe(['each' => '`null` must be iterable']),
|
|
));
|
|
|
|
test('Default', catchAll(
|
|
fn() => v::each(v::intType())->assert(['a', 'b', 'c']),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` must be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in `["a", "b", "c"]` must be valid
|
|
- `.0` must be an integer
|
|
- `.1` must be an integer
|
|
- `.2` must be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in `["a", "b", "c"]` must be valid',
|
|
0 => '`.0` must be an integer',
|
|
1 => '`.1` must be an integer',
|
|
2 => '`.2` must be an integer',
|
|
]),
|
|
));
|
|
|
|
test('Inverted', catchAll(
|
|
fn() => v::not(v::each(v::intType()))->assert([1, 2, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` must not be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in `[1, 2, 3]` must be invalid
|
|
- `.0` must not be an integer
|
|
- `.1` must not be an integer
|
|
- `.2` must not be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in `[1, 2, 3]` must be invalid',
|
|
0 => '`.0` must not be an integer',
|
|
1 => '`.1` must not be an integer',
|
|
2 => '`.2` must not be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With name, non-iterable', catchAll(
|
|
fn() => v::each(v::named('Wrapped', v::intType()))->assert(null),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`null` must be iterable')
|
|
->and($fullMessage)->toBe('- `null` must be iterable')
|
|
->and($messages)->toBe(['each' => '`null` must be iterable']),
|
|
));
|
|
|
|
test('With name, default', catchAll(
|
|
fn() => v::named('Outer', v::each(v::named('Inner', v::intType())))->assert(['a', 'b', 'c']),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` (<- Inner) must be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in Outer must be valid
|
|
- `.0` (<- Inner) must be an integer
|
|
- `.1` (<- Inner) must be an integer
|
|
- `.2` (<- Inner) must be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in Outer must be valid',
|
|
0 => '`.0` (<- Inner) must be an integer',
|
|
1 => '`.1` (<- Inner) must be an integer',
|
|
2 => '`.2` (<- Inner) must be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With name, inverted', catchAll(
|
|
fn() => v::named('Not', v::not(v::named('Outer', v::each(v::named('Inner', v::intType())))))->assert([1, 2, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` (<- Inner) must not be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in Outer must be invalid
|
|
- `.0` (<- Inner) must not be an integer
|
|
- `.1` (<- Inner) must not be an integer
|
|
- `.2` (<- Inner) must not be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in Outer must be invalid',
|
|
0 => '`.0` (<- Inner) must not be an integer',
|
|
1 => '`.1` (<- Inner) must not be an integer',
|
|
2 => '`.2` (<- Inner) must not be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With wrapper name, default', catchAll(
|
|
fn() => v::named('Wrapper', v::each(v::intType()))->assert(['a', 'b', 'c']),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` (<- Wrapper) must be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in Wrapper must be valid
|
|
- `.0` must be an integer
|
|
- `.1` must be an integer
|
|
- `.2` must be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in Wrapper must be valid',
|
|
0 => '`.0` must be an integer',
|
|
1 => '`.1` must be an integer',
|
|
2 => '`.2` must be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With wrapper name, inverted', catchAll(
|
|
fn() => v::named('Not', v::not(v::named('Wrapper', v::each(v::intType()))))->assert([1, 2, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` (<- Wrapper) must not be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in Wrapper must be invalid
|
|
- `.0` must not be an integer
|
|
- `.1` must not be an integer
|
|
- `.2` must not be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in Wrapper must be invalid',
|
|
0 => '`.0` must not be an integer',
|
|
1 => '`.1` must not be an integer',
|
|
2 => '`.2` must not be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With Not name, inverted', catchAll(
|
|
fn() => v::named('Not', v::not(v::each(v::intType())))->assert([1, 2, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` (<- Not) must not be an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in Not must be invalid
|
|
- `.0` must not be an integer
|
|
- `.1` must not be an integer
|
|
- `.2` must not be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in Not must be invalid',
|
|
0 => '`.0` must not be an integer',
|
|
1 => '`.1` must not be an integer',
|
|
2 => '`.2` must not be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With template, non-iterable', catchAll(
|
|
fn() => v::templated('You should have passed an iterable', v::each(v::intType()))->assert(null),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('You should have passed an iterable')
|
|
->and($fullMessage)->toBe('- You should have passed an iterable')
|
|
->and($messages)->toBe(['each' => 'You should have passed an iterable']),
|
|
));
|
|
|
|
test('With template, default', catchAll(
|
|
fn() => v::templated('All items should have been integers', v::each(v::intType()))
|
|
->assert(['a', 'b', 'c']),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('All items should have been integers')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- All items should have been integers
|
|
- `.0` must be an integer
|
|
- `.1` must be an integer
|
|
- `.2` must be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'All items should have been integers',
|
|
0 => '`.0` must be an integer',
|
|
1 => '`.1` must be an integer',
|
|
2 => '`.2` must be an integer',
|
|
]),
|
|
));
|
|
|
|
test('with template, inverted', catchAll(
|
|
fn() => v::templated('All items should not have been integers', v::not(v::each(v::intType())))
|
|
->assert([1, 2, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('All items should not have been integers')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- All items should not have been integers
|
|
- `.0` must not be an integer
|
|
- `.1` must not be an integer
|
|
- `.2` must not be an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'All items should not have been integers',
|
|
0 => '`.0` must not be an integer',
|
|
1 => '`.1` must not be an integer',
|
|
2 => '`.2` must not be an integer',
|
|
]),
|
|
));
|
|
|
|
test('With array template, default', catchAll(
|
|
fn() => v::each(v::intType())
|
|
->assert(['a', 'b', 'c'], [
|
|
'__root__' => 'Here a sequence of items that did not pass the validation',
|
|
0 => 'First item should have been an integer',
|
|
1 => 'Second item should have been an integer',
|
|
2 => 'Third item should have been an integer',
|
|
]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('First item should have been an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Here a sequence of items that did not pass the validation
|
|
- First item should have been an integer
|
|
- Second item should have been an integer
|
|
- Third item should have been an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Here a sequence of items that did not pass the validation',
|
|
0 => 'First item should have been an integer',
|
|
1 => 'Second item should have been an integer',
|
|
2 => 'Third item should have been an integer',
|
|
]),
|
|
));
|
|
|
|
test('With array template and name, default', catchAll(
|
|
fn() => v::named('Wrapper', v::each(v::named('Wrapped', v::intType())))
|
|
->assert(['a', 'b', 'c'], [
|
|
'__root__' => 'Here a sequence of items that did not pass the validation',
|
|
0 => 'First item should have been an integer',
|
|
1 => 'Second item should have been an integer',
|
|
2 => 'Third item should have been an integer',
|
|
]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('First item should have been an integer')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Here a sequence of items that did not pass the validation
|
|
- First item should have been an integer
|
|
- Second item should have been an integer
|
|
- Third item should have been an integer
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Here a sequence of items that did not pass the validation',
|
|
0 => 'First item should have been an integer',
|
|
1 => 'Second item should have been an integer',
|
|
2 => 'Third item should have been an integer',
|
|
]),
|
|
));
|
|
|
|
test('Chained wrapped rule', catchAll(
|
|
fn() => v::each(v::between(5, 7)->odd())->assert([2, 4]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` must be between 5 and 7')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in `[2, 4]` must be valid
|
|
- `.0` must pass all the rules
|
|
- `.0` must be between 5 and 7
|
|
- `.0` must be an odd number
|
|
- `.1` must pass all the rules
|
|
- `.1` must be between 5 and 7
|
|
- `.1` must be an odd number
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in `[2, 4]` must be valid',
|
|
0 => [
|
|
'__root__' => '`.0` must pass all the rules',
|
|
0 => '`.0` must be between 5 and 7',
|
|
1 => '`.0` must be an odd number',
|
|
],
|
|
1 => [
|
|
'__root__' => '`.1` must pass all the rules',
|
|
0 => '`.1` must be between 5 and 7',
|
|
1 => '`.1` must be an odd number',
|
|
],
|
|
]),
|
|
));
|
|
|
|
test('Multiple nested rules', catchAll(
|
|
fn() => v::each(v::arrayType()->key('my_int', v::intType()->odd())->length(v::equals(1)))->assert([['not_int' => 'wrong'], ['my_int' => 2], 'not an array']),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0.my_int` must be present')
|
|
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
|
|
- Each item in `[["not_int": "wrong"], ["my_int": 2], "not an array"]` must be valid
|
|
- `.0` must pass the rules
|
|
- `.0.my_int` must be present
|
|
- `.1` must pass the rules
|
|
- `.1.my_int` must be an odd number
|
|
- `.2` must pass all the rules
|
|
- `.2` must be an array
|
|
- `.2.my_int` must be present
|
|
- The length of `.2` must be equal to 1
|
|
FULL_MESSAGE)
|
|
->and($messages)->toBe([
|
|
'__root__' => 'Each item in `[["not_int": "wrong"], ["my_int": 2], "not an array"]` must be valid',
|
|
0 => '`.0.my_int` must be present',
|
|
1 => '`.1.my_int` must be an odd number',
|
|
2 => [
|
|
'__root__' => '`.2` must pass all the rules',
|
|
'arrayType' => '`.2` must be an array',
|
|
'my_int' => '`.2.my_int` must be present',
|
|
'lengthEquals' => 'The length of `.2` must be equal to 1',
|
|
],
|
|
]),
|
|
));
|
|
|
|
test('short-circuit: first item fails', catchAll(
|
|
fn() => v::shortCircuit(v::each(v::intType()))->assert(['a', 2, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.0` must be an integer')
|
|
->and($fullMessage)->toBe('- `.0` must be an integer')
|
|
->and($messages)->toBe([0 => '`.0` must be an integer']),
|
|
));
|
|
|
|
test('short-circuit: second item fails', catchAll(
|
|
fn() => v::shortCircuit(v::each(v::intType()))->assert([1, 2.5, 3]),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`.1` must be an integer')
|
|
->and($fullMessage)->toBe('- `.1` must be an integer')
|
|
->and($messages)->toBe([1 => '`.1` must be an integer']),
|
|
));
|
|
|
|
test('short-circuit: all items pass', function (): void {
|
|
$validator = v::shortCircuit(v::each(v::intType()));
|
|
expect($validator->isValid([1, 2, 3]))->toBeTrue();
|
|
});
|
|
|
|
test('short-circuit: empty array', function (): void {
|
|
$validator = v::shortCircuit(v::each(v::intType()));
|
|
expect($validator->isValid([]))->toBeTrue();
|
|
});
|
|
|
|
test('short-circuit: non-iterable input', catchAll(
|
|
fn() => v::shortCircuit(v::each(v::intType()))->assert(null),
|
|
fn(string $message, string $fullMessage, array $messages) => expect()
|
|
->and($message)->toBe('`null` must be iterable')
|
|
->and($fullMessage)->toBe('- `null` must be iterable')
|
|
->and($messages)->toBe(['each' => '`null` must be iterable']),
|
|
));
|