diff --git a/docs/feature-guide.md b/docs/feature-guide.md index 45f6f7396..08c25891e 100644 --- a/docs/feature-guide.md +++ b/docs/feature-guide.md @@ -27,12 +27,20 @@ Note that you can combine multiple validators for a complex validation. ### Validating using exceptions -The `assert()` method throws an exception when validation fails. You can handle those exceptions with `try/catch` for more robust error handling. +The `assert()` method throws an exception when validation fails. It evaluates all validators in the chain and collects every error before throwing. You can handle those exceptions with `try/catch` for more robust error handling. ```php v::intType()->positive()->assert($input); ``` +The `check()` method also throws an exception when validation fails, but it stops at the first failure instead of collecting all errors. Internally, it wraps the chain in a `ShortCircuit` validator. + +```php +v::intType()->positive()->check($input); +``` + +The difference is visible when multiple validators fail. With `assert()`, you get all error messages; with `check()`, you get only the first one. + ### Validating using results You can validate data and handle the result manually without using exceptions: @@ -131,7 +139,9 @@ Beyond the examples above, Respect\Validation provides specialized validators fo - **Grouped validation**: Combine validators with AND/OR logic using [AllOf](validators/AllOf.md), [AnyOf](validators/AnyOf.md), [NoneOf](validators/NoneOf.md), [OneOf](validators/OneOf.md). - **Iteration**: Validate every item in a collection with [Each](validators/Each.md). - **Length, Min, Max**: Validate derived values with [Length](validators/Length.md), [Min](validators/Min.md), [Max](validators/Max.md). -- **Special cases**: Handle dynamic rules with [Factory](validators/Factory.md), short-circuit on first failure with [Circuit](validators/Circuit.md), or transform input before validation with [After](validators/After.md). +- **Special cases**: Handle dynamic rules with [Factory](validators/Factory.md), selectively short-circuit on first failure with [ShortCircuit](validators/ShortCircuit.md), or transform input before validation with [After](validators/After.md). + +Note: While `check()` automatically short-circuits the entire chain, the `ShortCircuit` validator gives you fine-grained control over which specific group of validators should stop at the first failure. Use `check()` when you want the whole chain to fail fast, and `ShortCircuit` when you want only a specific part of your validation to fail fast while the rest continues collecting errors. ## Customizing error messages diff --git a/docs/handling-exceptions.md b/docs/handling-exceptions.md index 083b4810a..4cecb7a86 100644 --- a/docs/handling-exceptions.md +++ b/docs/handling-exceptions.md @@ -7,7 +7,9 @@ SPDX-FileContributor: Henrique Moody # Handling exceptions -The `ValidatorBuilder::assert()` method throws a `ValidationException` when validation fails. This exception provides detailed feedback on what went wrong. +Both `ValidatorBuilder::assert()` and `ValidatorBuilder::check()` throw a `ValidationException` when validation fails. This exception provides detailed feedback on what went wrong. + +The difference between the two methods is that `assert()` evaluates all validators in the chain and collects every error, while `check()` stops at the first failure (using `ShortCircuit` internally). ## The `ValidationException` @@ -21,6 +23,16 @@ try { } ``` +The same applies to `check()`: + +```php +try { + v::alnum()->lowercase()->check($input); +} catch (InvalidArgumentException $exception) { + echo $exception->getMessage(); // Only the first failure +} +``` + ### Helpful stack traces When an exception is thrown, PHP reports where it was *created*, not where it was *caused*. In most validation libraries that means stack traces point deep inside library internals. You end up hunting through the trace to find your actual code. diff --git a/docs/migrating-from-v2-to-v3.md b/docs/migrating-from-v2-to-v3.md index ec71b90cb..94fdd09ef 100644 --- a/docs/migrating-from-v2-to-v3.md +++ b/docs/migrating-from-v2-to-v3.md @@ -69,30 +69,24 @@ In 2.x, `validate()` returned a boolean. In 3.0, it returns a `ResultQuery` obje } ``` -#### `check()` removed, `assert()` unified +#### `check()` and `assert()` behavior changes -In 2.x, there were two exception-based methods: +In 2.x, both `check()` and `assert()` threw validator-specific exception classes (e.g., `IntTypeException`), which were children of `ValidationException`. The difference was that `check()` threw these exceptions directly and stopped at the first failure, while `assert()` threw a `NestedValidationException` (also a child of `ValidationException`) that collected all errors and provided methods like `getFullMessage()` and `getMessages()` not available on the base `ValidationException`. -- `check()` threw rule-specific exceptions (e.g., `IntTypeException`) -- `assert()` threw `NestedValidationException` - -In 3.0, both are unified into `assert()`, which throws `ValidationException`: +In 3.0, validator-specific exception classes and `NestedValidationException` no longer exist. Both `check()` and `assert()` throw a unified `ValidationException` that includes `getMessage()`, `getFullMessage()`, and `getMessages()`. The behavioral distinction is preserved: `check()` still fails fast (stopping at the first failure, using `ShortCircuit` internally), while `assert()` collects all errors. ```diff -use Respect\Validation\Exceptions\IntTypeException; +use Respect\Validation\Exceptions\ValidationException; try { -- v::intType()->check($input); + v::intType()->check($input); -} catch (IntTypeException $exception) { -+ v::intType()->assert($input); +} catch (ValidationException $exception) { echo $exception->getMessage(); } ``` -The `ValidationException` provides all methods previously split between exceptions: - ```diff -use Respect\Validation\Exceptions\NestedValidationException; +use Respect\Validation\Exceptions\ValidationException; @@ -589,9 +583,9 @@ Version 3.0 introduces several new validators: | `All` | Validates that every item in an iterable passes validation | | `Attributes` | Validates object properties using PHP attributes | | `BetweenExclusive` | Validates that a value is between two bounds (exclusive) | -| `Circuit` | Short-circuit validation, stops at first failure | | `ContainsCount` | Validates the count of occurrences in a value | | `DateTimeDiff` | Validates date/time differences (replaces Age validators) | +| `ShortCircuit` | Stops at first failure instead of collecting all errors | | `Hetu` | Validates Finnish personal identity codes (henkilötunnus) | | `KeyExists` | Checks if an array key exists | | `KeyOptional` | Validates an array key only if it exists | @@ -647,26 +641,6 @@ v::betweenExclusive(1, 10)->assert(1); // fails (1 is not > 1) v::betweenExclusive(1, 10)->assert(10); // fails (10 is not < 10) ``` -#### Circuit - -Validates input against a series of validators, stopping at the first failure. Useful for dependent validations: - -```php -$validator = v::circuit( - v::key('countryCode', v::countryCode()), - v::factory( - fn($input) => v::key( - 'subdivisionCode', - v::subdivisionCode($input['countryCode']) - ) - ), -); - -$validator->assert([]); // → `.countryCode` must be present -$validator->assert(['countryCode' => 'US']); // → `.subdivisionCode` must be present -$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']); // passes -``` - #### ContainsCount Validates the count of occurrences of a value: @@ -685,6 +659,26 @@ v::dateTimeDiff('years', v::greaterThanOrEqual(18))->assert('2000-01-01'); // pa v::dateTimeDiff('days', v::lessThan(30))->assert('2024-01-15'); // passes if less than 30 days ago ``` +#### ShortCircuit + +Validates input against a series of validators, stopping at the first failure. Useful for dependent validations: + +```php +$validator = v::shortCircuit( + v::key('countryCode', v::countryCode()), + v::factory( + fn($input) => v::key( + 'subdivisionCode', + v::subdivisionCode($input['countryCode']) + ) + ), +); + +$validator->assert([]); // → `.countryCode` must be present +$validator->assert(['countryCode' => 'US']); // → `.subdivisionCode` must be present +$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']); // passes +``` + #### Hetu Validates Finnish personal identity codes (henkilötunnus): @@ -983,11 +977,12 @@ v::templated( - v::intType()->validate($input); // bool + v::intType()->isValid($input); // bool - // Exception-based validation -- v::intType()->check($input); // IntTypeException -+ v::intType()->assert($input); // ValidationException + // Exception-based validation (fail-fast) +- v::intType()->check($input); // IntTypeException (child of ValidationException) ++ v::intType()->check($input); // ValidationException -- v::intType()->assert($input); // NestedValidationException + // Exception-based validation (collect all errors) +- v::intType()->assert($input); // AllOfExceptopn (child of NestedValidationException) + v::intType()->assert($input); // ValidationException // Renamed validators diff --git a/docs/validators.md b/docs/validators.md index 5a908eaa8..d91a443ce 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -19,9 +19,9 @@ In this page you will find a list of validators by their category. **Comparisons**: [All][] - [Between][] - [BetweenExclusive][] - [Equals][] - [Equivalent][] - [GreaterThan][] - [GreaterThanOrEqual][] - [Identical][] - [In][] - [Length][] - [LessThan][] - [LessThanOrEqual][] - [Max][] - [Min][] -**Composite**: [AllOf][] - [AnyOf][] - [Circuit][] - [NoneOf][] - [OneOf][] +**Composite**: [AllOf][] - [AnyOf][] - [NoneOf][] - [OneOf][] - [ShortCircuit][] -**Conditions**: [Circuit][] - [Not][] - [When][] +**Conditions**: [Not][] - [ShortCircuit][] - [When][] **Core**: [Named][] - [Not][] - [Templated][] @@ -43,7 +43,7 @@ In this page you will find a list of validators by their category. **Miscellaneous**: [Blank][] - [Falsy][] - [Masked][] - [Named][] - [Templated][] - [Undef][] -**Nesting**: [After][] - [AllOf][] - [AnyOf][] - [Circuit][] - [Each][] - [Factory][] - [Key][] - [KeySet][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [UndefOr][] - [When][] +**Nesting**: [After][] - [AllOf][] - [AnyOf][] - [Each][] - [Factory][] - [Key][] - [KeySet][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [ShortCircuit][] - [UndefOr][] - [When][] **Numbers**: [Base][] - [Decimal][] - [Digit][] - [Even][] - [Factor][] - [Finite][] - [FloatType][] - [FloatVal][] - [Infinite][] - [IntType][] - [IntVal][] - [Multiple][] - [Negative][] - [Number][] - [NumericVal][] - [Odd][] - [Positive][] - [Roman][] @@ -80,7 +80,6 @@ In this page you will find a list of validators by their category. - [Bsn][] - `v::bsn()->assert('612890053');` - [CallableType][] - `v::callableType()->assert(function () {});` - [Charset][] - `v::charset('ASCII')->assert('sugar');` -- [Circuit][] - `v::circuit(v::intVal(), v::floatVal())->assert(15);` - [Cnh][] - `v::cnh()->assert('02650306461');` - [Cnpj][] - `v::cnpj()->assert('00394460005887');` - [Consonant][] - `v::consonant()->assert('xkcd');` @@ -189,6 +188,7 @@ In this page you will find a list of validators by their category. - [Roman][] - `v::roman()->assert('IV');` - [Satisfies][] - `v::satisfies(fn (int $input): bool => $input % 5 === 0,)->assert(10);` - [ScalarVal][] - `v::scalarVal()->assert(135.0);` +- [ShortCircuit][] - `v::shortCircuit(v::intVal(), v::positive())->assert(15);` - [Size][] - `v::size('KB', v::greaterThan(1))->assert('/path/to/file');` - [Slug][] - `v::slug()->assert('my-wordpress-title');` - [Sorted][] - `v::sorted('ASC')->assert([1, 2, 3]);` @@ -237,7 +237,6 @@ In this page you will find a list of validators by their category. [Bsn]: validators/Bsn.md "Validates a Dutch citizen service number (BSN)." [CallableType]: validators/CallableType.md "Validates whether the pseudo-type of the input is callable." [Charset]: validators/Charset.md "Validates if a string is in a specific charset." -[Circuit]: validators/Circuit.md "Validates the input against a series of validators until the first fails." [Cnh]: validators/Cnh.md "Validates a Brazilian driver's license." [Cnpj]: validators/Cnpj.md "Validates the structure and mathematical integrity of Brazilian CNPJ identifiers." [Consonant]: validators/Consonant.md "Validates if the input contains only consonants." @@ -346,6 +345,7 @@ In this page you will find a list of validators by their category. [Roman]: validators/Roman.md "Validates if the input is a Roman numeral." [Satisfies]: validators/Satisfies.md "Validates the input using the return of a given callable." [ScalarVal]: validators/ScalarVal.md "Validates whether the input is a scalar value or not." +[ShortCircuit]: validators/ShortCircuit.md "Validates the input against a series of validators, stopping at the first failure." [Size]: validators/Size.md "Validates whether the input is a file that is of a certain size or not." [Slug]: validators/Slug.md "Validates whether the input is a valid slug." [Sorted]: validators/Sorted.md "Validates whether the input is sorted in a certain order or not." diff --git a/docs/validators/After.md b/docs/validators/After.md index cba15e397..fe2435fe1 100644 --- a/docs/validators/After.md +++ b/docs/validators/After.md @@ -55,17 +55,17 @@ v::after( ``` `After` does not handle possible errors (type mismatches). If you need to -ensure that your callback is of a certain type, use [Circuit](Circuit.md) or +ensure that your callback is of a certain type, use [ShortCircuit](ShortCircuit.md) or handle it using a closure: ```php v::after('strtolower', v::equals('ABC'))->assert(123); // 𝙭 strtolower(): Argument #1 ($string) must be of type string, int given -v::circuit(v::stringType(), v::after('strtolower', v::equals('abc')))->assert(123); +v::shortCircuit(v::stringType(), v::after('strtolower', v::equals('abc')))->assert(123); // → 123 must be a string -v::circuit(v::stringType(), v::after('strtolower', v::equals('abc')))->assert('ABC'); +v::shortCircuit(v::stringType(), v::after('strtolower', v::equals('abc')))->assert('ABC'); // Validation passes successfully ``` diff --git a/docs/validators/AllOf.md b/docs/validators/AllOf.md index bfc8c65d3..3e0174c2a 100644 --- a/docs/validators/AllOf.md +++ b/docs/validators/AllOf.md @@ -58,7 +58,7 @@ Used when all validators have failed. ## See Also - [AnyOf](AnyOf.md) -- [Circuit](Circuit.md) - [NoneOf](NoneOf.md) - [OneOf](OneOf.md) +- [ShortCircuit](ShortCircuit.md) - [When](When.md) diff --git a/docs/validators/AnyOf.md b/docs/validators/AnyOf.md index 6deffb172..2dcd92fe0 100644 --- a/docs/validators/AnyOf.md +++ b/docs/validators/AnyOf.md @@ -53,8 +53,8 @@ so `AnyOf()` returns true. ## See Also - [AllOf](AllOf.md) -- [Circuit](Circuit.md) - [ContainsAny](ContainsAny.md) - [NoneOf](NoneOf.md) - [OneOf](OneOf.md) +- [ShortCircuit](ShortCircuit.md) - [When](When.md) diff --git a/docs/validators/Circuit.md b/docs/validators/Circuit.md deleted file mode 100644 index 6360d5968..000000000 --- a/docs/validators/Circuit.md +++ /dev/null @@ -1,72 +0,0 @@ - - -# Circuit - -- `Circuit(Validator $validator1, Validator $validator2)` -- `Circuit(Validator $validator1, Validator $validator2, Validator ...$validators)` - -Validates the input against a series of validators until the first fails. - -```php -v::circuit(v::intVal(), v::floatVal())->assert(15); -// Validation passes successfully -``` - -This validator can be handy for getting the least error messages possible from a chain. - -This validator can be helpful in combinations with [Factory](Factory.md). An excellent example is when you want to validate a -country code and a subdivision code. - -```php -$validator = v::circuit( - v::key('countryCode', v::countryCode()), - v::factory(static fn($input) => v::key('subdivisionCode', v::subdivisionCode($input['countryCode']))), -); - -$validator->assert([]); -// → `.countryCode` must be present - -$validator->assert(['countryCode' => 'US']); -// → `.subdivisionCode` must be present - -$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'ZZ']); -// → `.subdivisionCode` must be a subdivision code of United States - -$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']); -// Validation passes successfully -``` - -You need a valid country code to create a [SubdivisionCode](SubdivisionCode.md), so it makes sense only to validate the -subdivision code only if the country code is valid. In this case, you could also have used [When](When.md), but you -would then have to write `v::key('countryCode', v::countryCode())` twice in your chain. - -## Templates - -This validator does not have any templates, because it will always return the result of the first validator that fails. When all the validators pass, it will return the result of the last validator of the circuit. - -## Categorization - -- Composite -- Conditions -- Nesting - -## Changelog - -| Version | Description | -| ------: | :---------- | -| 3.0.0 | Created | - -## See Also - -- [AllOf](AllOf.md) -- [AnyOf](AnyOf.md) -- [Factory](Factory.md) -- [NoneOf](NoneOf.md) -- [OneOf](OneOf.md) -- [SubdivisionCode](SubdivisionCode.md) -- [When](When.md) diff --git a/docs/validators/Factory.md b/docs/validators/Factory.md index 92e699c8e..faa20b92c 100644 --- a/docs/validators/Factory.md +++ b/docs/validators/Factory.md @@ -60,4 +60,4 @@ on the input itself (`$_POST`), but it will use any input that’s given to the - [After](After.md) - [CallableType](CallableType.md) -- [Circuit](Circuit.md) +- [ShortCircuit](ShortCircuit.md) diff --git a/docs/validators/NoneOf.md b/docs/validators/NoneOf.md index 4b379b02e..8d9b20b34 100644 --- a/docs/validators/NoneOf.md +++ b/docs/validators/NoneOf.md @@ -62,7 +62,7 @@ Used when all validators have passed. - [AllOf](AllOf.md) - [AnyOf](AnyOf.md) -- [Circuit](Circuit.md) - [Not](Not.md) - [OneOf](OneOf.md) +- [ShortCircuit](ShortCircuit.md) - [When](When.md) diff --git a/docs/validators/OneOf.md b/docs/validators/OneOf.md index 73292e15a..f98604508 100644 --- a/docs/validators/OneOf.md +++ b/docs/validators/OneOf.md @@ -76,6 +76,6 @@ Used when more than one validator has passed. - [AllOf](AllOf.md) - [AnyOf](AnyOf.md) -- [Circuit](Circuit.md) - [NoneOf](NoneOf.md) +- [ShortCircuit](ShortCircuit.md) - [When](When.md) diff --git a/docs/validators/ShortCircuit.md b/docs/validators/ShortCircuit.md new file mode 100644 index 000000000..6cf1f5fca --- /dev/null +++ b/docs/validators/ShortCircuit.md @@ -0,0 +1,84 @@ + + +# ShortCircuit + +- `ShortCircuit()` +- `ShortCircuit(Validator ...$validators)` + +Validates the input against a series of validators, stopping at the first failure. + +Like PHP's `&&` operator, it uses short-circuit evaluation: once the outcome is determined, remaining validators are +skipped. Unlike [AllOf](AllOf.md), which evaluates all validators and collects all failures, `ShortCircuit` returns +immediately. + +```php +v::shortCircuit(v::intVal(), v::positive())->assert(15); +// Validation passes successfully +``` + +This is useful when: + +- You want only the first error message instead of all of them +- Later validators depend on earlier ones passing (e.g., checking a format before checking a value) +- You want to avoid unnecessary validation work + +This validator is particularly useful in combination with [Factory](Factory.md) when later validations depend on earlier +results. For example, validating a subdivision code that depends on a valid country code: + +```php +$validator = v::shortCircuit( + v::key('countryCode', v::countryCode()), + v::factory(static fn($input) => v::key('subdivisionCode', v::subdivisionCode($input['countryCode']))), +); + +$validator->assert([]); +// → `.countryCode` must be present + +$validator->assert(['countryCode' => 'US']); +// → `.subdivisionCode` must be present + +$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'ZZ']); +// → `.subdivisionCode` must be a subdivision code of United States + +$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']); +// Validation passes successfully +``` + +Because [SubdivisionCode](SubdivisionCode.md) requires a valid country code, it only makes sense to validate the +subdivision after the country code passes. You could achieve this with [When](When.md), but you would have to repeat +`v::key('countryCode', v::countryCode())` twice. + +## Note + +The `check()` method in `ValidatorBuilder` uses `ShortCircuit` internally to short-circuit the entire validation chain. Use `ShortCircuit` directly when you need fine-grained control over which specific group of validators should fail fast, while letting the rest of the validation continue collecting errors via `assert()`. + +## Templates + +This validator does not have templates of its own. It returns the result of the first failing validator, or the result +of the last validator when all pass. + +## Categorization + +- Composite +- Conditions +- Nesting + +## Changelog + +| Version | Description | +| ------: | :---------- | +| 3.0.0 | Created | + +## See Also + +- [AllOf](AllOf.md) +- [AnyOf](AnyOf.md) +- [NoneOf](NoneOf.md) +- [OneOf](OneOf.md) +- [SubdivisionCode](SubdivisionCode.md) +- [When](When.md) diff --git a/docs/validators/SubdivisionCode.md b/docs/validators/SubdivisionCode.md index 673cf296d..e980efef6 100644 --- a/docs/validators/SubdivisionCode.md +++ b/docs/validators/SubdivisionCode.md @@ -53,13 +53,13 @@ v::subdivisionCode('US')->assert('CA'); ## See Also -- [Circuit](Circuit.md) - [CountryCode](CountryCode.md) - [CurrencyCode](CurrencyCode.md) - [Nip](Nip.md) - [Pesel](Pesel.md) - [PolishIdCard](PolishIdCard.md) - [PublicDomainSuffix](PublicDomainSuffix.md) +- [ShortCircuit](ShortCircuit.md) - [Tld](Tld.md) [ISO 3166-1 alpha-2]: http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 "ISO 3166-1 alpha-2" diff --git a/docs/validators/When.md b/docs/validators/When.md index c6d2a95dc..b51581210 100644 --- a/docs/validators/When.md +++ b/docs/validators/When.md @@ -58,6 +58,6 @@ When `$else` is not defined use [AlwaysInvalid](AlwaysInvalid.md) - [AllOf](AllOf.md) - [AlwaysInvalid](AlwaysInvalid.md) - [AnyOf](AnyOf.md) -- [Circuit](Circuit.md) - [NoneOf](NoneOf.md) - [OneOf](OneOf.md) +- [ShortCircuit](ShortCircuit.md) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 41aaa0dc1..9a0d4638f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,6 +9,8 @@ parameters: path: src/Validators/ArrayVal.php - message: '/Call to an undefined method .+::expectException\(\)/' path: tests/Pest.php + - message: '/Call to an undefined method .+::throwsNoExceptions\(\)/' + path: tests/feature - message: '/Undefined variable: \$this/' path: tests/feature/Validators/SizeTest.php - message: '/Undefined variable: \$this/' diff --git a/src/Helpers/CanEvaluateShortCircuit.php b/src/Helpers/CanEvaluateShortCircuit.php new file mode 100644 index 000000000..f0bb521c6 --- /dev/null +++ b/src/Helpers/CanEvaluateShortCircuit.php @@ -0,0 +1,27 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Helpers; + +use Respect\Validation\Result; +use Respect\Validation\Validator; +use Respect\Validation\Validators\Core\ShortCircuitable; + +trait CanEvaluateShortCircuit +{ + private function evaluateShortCircuitWith(Validator $validator, mixed $input): Result + { + if ($validator instanceof ShortCircuitable) { + return $validator->evaluateShortCircuit($input); + } + + return $validator->evaluate($input); + } +} diff --git a/src/Mixins/AllBuilder.php b/src/Mixins/AllBuilder.php index 861381f23..ae2e0adbc 100644 --- a/src/Mixins/AllBuilder.php +++ b/src/Mixins/AllBuilder.php @@ -55,8 +55,6 @@ public static function allCallableType(): Chain; public static function allCharset(string $charset, string ...$charsets): Chain; - public static function allCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function allCnh(): Chain; public static function allCnpj(): Chain; @@ -260,6 +258,8 @@ public static function allSatisfies(callable $callback, mixed ...$arguments): Ch public static function allScalarVal(): Chain; + public static function allShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function allSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/AllChain.php b/src/Mixins/AllChain.php index 17fe52f8a..515042265 100644 --- a/src/Mixins/AllChain.php +++ b/src/Mixins/AllChain.php @@ -55,8 +55,6 @@ public function allCallableType(): Chain; public function allCharset(string $charset, string ...$charsets): Chain; - public function allCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function allCnh(): Chain; public function allCnpj(): Chain; @@ -260,6 +258,8 @@ public function allSatisfies(callable $callback, mixed ...$arguments): Chain; public function allScalarVal(): Chain; + public function allShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function allSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/Builder.php b/src/Mixins/Builder.php index 45cce9a21..f27b4bc61 100644 --- a/src/Mixins/Builder.php +++ b/src/Mixins/Builder.php @@ -60,8 +60,6 @@ public static function callableType(): Chain; public static function charset(string $charset, string ...$charsets): Chain; - public static function circuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function cnh(): Chain; public static function cnpj(): Chain; @@ -285,6 +283,8 @@ public static function satisfies(callable $callback, mixed ...$arguments): Chain public static function scalarVal(): Chain; + public static function shortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function size(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/Chain.php b/src/Mixins/Chain.php index ca09407b5..7f1a16e4e 100644 --- a/src/Mixins/Chain.php +++ b/src/Mixins/Chain.php @@ -62,8 +62,6 @@ public function callableType(): Chain; public function charset(string $charset, string ...$charsets): Chain; - public function circuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function cnh(): Chain; public function cnpj(): Chain; @@ -287,6 +285,8 @@ public function satisfies(callable $callback, mixed ...$arguments): Chain; public function scalarVal(): Chain; + public function shortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function size(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/KeyBuilder.php b/src/Mixins/KeyBuilder.php index 6e9691625..4c1c1d02c 100644 --- a/src/Mixins/KeyBuilder.php +++ b/src/Mixins/KeyBuilder.php @@ -57,8 +57,6 @@ public static function keyCallableType(int|string $key): Chain; public static function keyCharset(int|string $key, string $charset, string ...$charsets): Chain; - public static function keyCircuit(int|string $key, Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function keyCnh(int|string $key): Chain; public static function keyCnpj(int|string $key): Chain; @@ -262,6 +260,8 @@ public static function keySatisfies(int|string $key, callable $callback, mixed . public static function keyScalarVal(int|string $key): Chain; + public static function keyShortCircuit(int|string $key, Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function keySize(int|string $key, string $unit, Validator $validator): Chain; diff --git a/src/Mixins/KeyChain.php b/src/Mixins/KeyChain.php index bd7c41d7b..933cc7602 100644 --- a/src/Mixins/KeyChain.php +++ b/src/Mixins/KeyChain.php @@ -57,8 +57,6 @@ public function keyCallableType(int|string $key): Chain; public function keyCharset(int|string $key, string $charset, string ...$charsets): Chain; - public function keyCircuit(int|string $key, Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function keyCnh(int|string $key): Chain; public function keyCnpj(int|string $key): Chain; @@ -262,6 +260,8 @@ public function keySatisfies(int|string $key, callable $callback, mixed ...$argu public function keyScalarVal(int|string $key): Chain; + public function keyShortCircuit(int|string $key, Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function keySize(int|string $key, string $unit, Validator $validator): Chain; diff --git a/src/Mixins/NotBuilder.php b/src/Mixins/NotBuilder.php index 0e78808ef..57ceb5124 100644 --- a/src/Mixins/NotBuilder.php +++ b/src/Mixins/NotBuilder.php @@ -57,8 +57,6 @@ public static function notCallableType(): Chain; public static function notCharset(string $charset, string ...$charsets): Chain; - public static function notCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function notCnh(): Chain; public static function notCnpj(): Chain; @@ -276,6 +274,8 @@ public static function notSatisfies(callable $callback, mixed ...$arguments): Ch public static function notScalarVal(): Chain; + public static function notShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function notSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/NotChain.php b/src/Mixins/NotChain.php index 79d7b8bd2..686bca591 100644 --- a/src/Mixins/NotChain.php +++ b/src/Mixins/NotChain.php @@ -57,8 +57,6 @@ public function notCallableType(): Chain; public function notCharset(string $charset, string ...$charsets): Chain; - public function notCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function notCnh(): Chain; public function notCnpj(): Chain; @@ -276,6 +274,8 @@ public function notSatisfies(callable $callback, mixed ...$arguments): Chain; public function notScalarVal(): Chain; + public function notShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function notSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/NullOrBuilder.php b/src/Mixins/NullOrBuilder.php index 1972f0bfa..7a959b272 100644 --- a/src/Mixins/NullOrBuilder.php +++ b/src/Mixins/NullOrBuilder.php @@ -57,8 +57,6 @@ public static function nullOrCallableType(): Chain; public static function nullOrCharset(string $charset, string ...$charsets): Chain; - public static function nullOrCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function nullOrCnh(): Chain; public static function nullOrCnpj(): Chain; @@ -278,6 +276,8 @@ public static function nullOrSatisfies(callable $callback, mixed ...$arguments): public static function nullOrScalarVal(): Chain; + public static function nullOrShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function nullOrSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/NullOrChain.php b/src/Mixins/NullOrChain.php index b0f1aca8e..0cb7c16a6 100644 --- a/src/Mixins/NullOrChain.php +++ b/src/Mixins/NullOrChain.php @@ -57,8 +57,6 @@ public function nullOrCallableType(): Chain; public function nullOrCharset(string $charset, string ...$charsets): Chain; - public function nullOrCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function nullOrCnh(): Chain; public function nullOrCnpj(): Chain; @@ -278,6 +276,8 @@ public function nullOrSatisfies(callable $callback, mixed ...$arguments): Chain; public function nullOrScalarVal(): Chain; + public function nullOrShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function nullOrSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/PropertyBuilder.php b/src/Mixins/PropertyBuilder.php index 9ff6b2f04..a6ab30e7e 100644 --- a/src/Mixins/PropertyBuilder.php +++ b/src/Mixins/PropertyBuilder.php @@ -57,8 +57,6 @@ public static function propertyCallableType(string $propertyName): Chain; public static function propertyCharset(string $propertyName, string $charset, string ...$charsets): Chain; - public static function propertyCircuit(string $propertyName, Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function propertyCnh(string $propertyName): Chain; public static function propertyCnpj(string $propertyName): Chain; @@ -262,6 +260,8 @@ public static function propertySatisfies(string $propertyName, callable $callbac public static function propertyScalarVal(string $propertyName): Chain; + public static function propertyShortCircuit(string $propertyName, Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function propertySize(string $propertyName, string $unit, Validator $validator): Chain; diff --git a/src/Mixins/PropertyChain.php b/src/Mixins/PropertyChain.php index 24e944053..384b4e7d9 100644 --- a/src/Mixins/PropertyChain.php +++ b/src/Mixins/PropertyChain.php @@ -57,8 +57,6 @@ public function propertyCallableType(string $propertyName): Chain; public function propertyCharset(string $propertyName, string $charset, string ...$charsets): Chain; - public function propertyCircuit(string $propertyName, Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function propertyCnh(string $propertyName): Chain; public function propertyCnpj(string $propertyName): Chain; @@ -262,6 +260,8 @@ public function propertySatisfies(string $propertyName, callable $callback, mixe public function propertyScalarVal(string $propertyName): Chain; + public function propertyShortCircuit(string $propertyName, Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function propertySize(string $propertyName, string $unit, Validator $validator): Chain; diff --git a/src/Mixins/UndefOrBuilder.php b/src/Mixins/UndefOrBuilder.php index f06725e75..ac6353e14 100644 --- a/src/Mixins/UndefOrBuilder.php +++ b/src/Mixins/UndefOrBuilder.php @@ -55,8 +55,6 @@ public static function undefOrCallableType(): Chain; public static function undefOrCharset(string $charset, string ...$charsets): Chain; - public static function undefOrCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public static function undefOrCnh(): Chain; public static function undefOrCnpj(): Chain; @@ -276,6 +274,8 @@ public static function undefOrSatisfies(callable $callback, mixed ...$arguments) public static function undefOrScalarVal(): Chain; + public static function undefOrShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public static function undefOrSize(string $unit, Validator $validator): Chain; diff --git a/src/Mixins/UndefOrChain.php b/src/Mixins/UndefOrChain.php index 412abe305..952691fd4 100644 --- a/src/Mixins/UndefOrChain.php +++ b/src/Mixins/UndefOrChain.php @@ -55,8 +55,6 @@ public function undefOrCallableType(): Chain; public function undefOrCharset(string $charset, string ...$charsets): Chain; - public function undefOrCircuit(Validator $validator1, Validator $validator2, Validator ...$validators): Chain; - public function undefOrCnh(): Chain; public function undefOrCnpj(): Chain; @@ -276,6 +274,8 @@ public function undefOrSatisfies(callable $callback, mixed ...$arguments): Chain public function undefOrScalarVal(): Chain; + public function undefOrShortCircuit(Validator ...$validators): Chain; + /** @param "B"|"KB"|"MB"|"GB"|"TB"|"PB"|"EB"|"ZB"|"YB" $unit */ public function undefOrSize(string $unit, Validator $validator): Chain; diff --git a/src/ValidatorBuilder.php b/src/ValidatorBuilder.php index 05016ccef..5c279989f 100644 --- a/src/ValidatorBuilder.php +++ b/src/ValidatorBuilder.php @@ -20,6 +20,8 @@ use Respect\Validation\Mixins\Builder; use Respect\Validation\Validators\AllOf; use Respect\Validation\Validators\Core\Nameable; +use Respect\Validation\Validators\Core\ShortCircuitable; +use Respect\Validation\Validators\ShortCircuit; use Throwable; use function count; @@ -29,7 +31,7 @@ use function is_string; /** @mixin Builder */ -final readonly class ValidatorBuilder implements Nameable +final readonly class ValidatorBuilder implements Nameable, ShortCircuitable { /** @var array */ private array $validators; @@ -68,6 +70,11 @@ public function evaluate(mixed $input): Result return $validator->evaluate($input); } + public function evaluateShortCircuit(mixed $input): Result + { + return (new ShortCircuit(...$this->validators))->evaluate($input); + } + /** @param array|string|null $template */ public function validate(mixed $input, array|string|null $template = null): ResultQuery { @@ -76,29 +83,19 @@ public function validate(mixed $input, array|string|null $template = null): Resu public function isValid(mixed $input): bool { - return $this->evaluate($input)->hasPassed; + return $this->evaluateShortCircuit($input)->hasPassed; } /** @param array|callable(ValidationException): Throwable|string|Throwable|null $template */ - public function assert(mixed $input, array|string|Throwable|callable|null $template = null): void + public function check(mixed $input, array|string|Throwable|callable|null $template = null): void { - $result = $this->evaluate($input); - if ($result->hasPassed) { - return; - } - - if ($template instanceof Throwable) { - throw $template; - } - - $resultQuery = $this->toResultQuery($result, is_callable($template) ? null : $template); - - $exception = new ValidationException($resultQuery->getMessage(), $resultQuery, ...$this->ignoredBacktracePaths); - if (is_callable($template)) { - throw $template($exception); - } + $this->throwOnFailure($this->evaluateShortCircuit($input), $template); + } - throw $exception; + /** @param array|callable(ValidationException): Throwable|string|Throwable|null $template */ + public function assert(mixed $input, array|string|Throwable|callable|null $template = null): void + { + $this->throwOnFailure($this->evaluate($input), $template); } public function with(Validator $validator, Validator ...$validators): self @@ -134,6 +131,27 @@ private function toResultQuery(Result $result, array|string|null $template): Res ); } + /** @param array|callable(ValidationException): Throwable|string|Throwable|null $template */ + private function throwOnFailure(Result $result, array|callable|Throwable|string|null $template): void + { + if ($result->hasPassed) { + return; + } + + if ($template instanceof Throwable) { + throw $template; + } + + $resultQuery = $this->toResultQuery($result, is_callable($template) ? null : $template); + + $exception = new ValidationException($resultQuery->getMessage(), $resultQuery, ...$this->ignoredBacktracePaths); + if (is_callable($template)) { + throw $template($exception); + } + + throw $exception; + } + /** @param array $arguments */ public static function __callStatic(string $ruleName, array $arguments): self { diff --git a/src/Validators/All.php b/src/Validators/All.php index 1c799dfda..613ad6cf3 100644 --- a/src/Validators/All.php +++ b/src/Validators/All.php @@ -15,14 +15,41 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; +use Respect\Validation\Path; use Respect\Validation\Result; use Respect\Validation\Validators\Core\FilteredArray; +use Respect\Validation\Validators\Core\ShortCircuitable; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] #[Template('Every item in', 'Every item in')] -final class All extends FilteredArray +final class All extends FilteredArray implements ShortCircuitable { + use CanEvaluateShortCircuit; + + public function evaluateShortCircuit(mixed $input): Result + { + $iterableResult = (new IterableType())->evaluate($input); + if (!$iterableResult->hasPassed) { + return $iterableResult->withIdFrom($this); + } + + $result = null; + foreach ($input as $key => $value) { + $result = $this->evaluateShortCircuitWith($this->validator, $value); + if (!$result->hasPassed) { + return $result->withPath(new Path($key)); + } + } + + if ($result === null) { + return Result::passed($input, $this)->asIndeterminate(); + } + + return Result::passed($input, $this)->asAdjacentOf($result, 'all'); + } + /** @param non-empty-array $input */ protected function evaluateArray(array $input): Result { diff --git a/src/Validators/AllOf.php b/src/Validators/AllOf.php index 5ee7ca546..234a9693f 100644 --- a/src/Validators/AllOf.php +++ b/src/Validators/AllOf.php @@ -15,10 +15,12 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; use Respect\Validation\Validators\Core\Composite; +use Respect\Validation\Validators\Core\ShortCircuitable; use function array_filter; use function array_map; @@ -36,8 +38,10 @@ '{{subject}} must pass all the rules', self::TEMPLATE_ALL, )] -final class AllOf extends Composite +final class AllOf extends Composite implements ShortCircuitable { + use CanEvaluateShortCircuit; + public const string TEMPLATE_ALL = '__all__'; public const string TEMPLATE_SOME = '__some__'; @@ -53,4 +57,18 @@ public function evaluate(mixed $input): Result return Result::of($valid, $input, $this, [], $template)->withChildren(...$children); } + + public function evaluateShortCircuit(mixed $input): Result + { + $children = []; + foreach ($this->validators as $validator) { + $result = $this->evaluateShortCircuitWith($validator, $input); + $children[] = $result; + if (!$result->hasPassed) { + return $result; + } + } + + return Result::passed($input, $this, [], self::TEMPLATE_ALL)->withChildren(...$children); + } } diff --git a/src/Validators/AnyOf.php b/src/Validators/AnyOf.php index 5518e8045..61ac0a3bc 100644 --- a/src/Validators/AnyOf.php +++ b/src/Validators/AnyOf.php @@ -15,10 +15,12 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; use Respect\Validation\Validators\Core\Composite; +use Respect\Validation\Validators\Core\ShortCircuitable; use function array_map; use function array_reduce; @@ -28,8 +30,10 @@ '{{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 ShortCircuitable { + use CanEvaluateShortCircuit; + public function evaluate(mixed $input): Result { $children = array_map(static fn(Validator $validator) => $validator->evaluate($input), $this->validators); @@ -41,4 +45,18 @@ public function evaluate(mixed $input): Result return Result::of($valid, $input, $this)->withChildren(...$children); } + + public function evaluateShortCircuit(mixed $input): Result + { + $children = []; + foreach ($this->validators as $validator) { + $result = $this->evaluateShortCircuitWith($validator, $input); + $children[] = $result; + if ($result->hasPassed) { + return Result::passed($input, $this)->withChildren(...$children); + } + } + + return Result::failed($input, $this)->withChildren(...$children); + } } diff --git a/src/Validators/Core/ShortCircuitable.php b/src/Validators/Core/ShortCircuitable.php new file mode 100644 index 000000000..4f18e246d --- /dev/null +++ b/src/Validators/Core/ShortCircuitable.php @@ -0,0 +1,19 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators\Core; + +use Respect\Validation\Result; +use Respect\Validation\Validator; + +interface ShortCircuitable extends Validator +{ + public function evaluateShortCircuit(mixed $input): Result; +} diff --git a/src/Validators/Domain.php b/src/Validators/Domain.php index e1829f9b3..e4ec03bc0 100644 --- a/src/Validators/Domain.php +++ b/src/Validators/Domain.php @@ -63,9 +63,9 @@ public function evaluate(mixed $input): Result return Result::of($this->partsRule->evaluate($parts)->hasPassed, $input, $this); } - private function createGenericRule(): Circuit + private function createGenericRule(): ShortCircuit { - return new Circuit( + return new ShortCircuit( new StringType(), new Not(new Spaced()), new Contains('.'), @@ -79,13 +79,13 @@ private function createTldRule(bool $realTldCheck): Validator return new Tld(); } - return new Circuit(new Not(new StartsWith('-')), new Length(new GreaterThanOrEqual(2))); + return new ShortCircuit(new Not(new StartsWith('-')), new Length(new GreaterThanOrEqual(2))); } private function createPartsRule(): Validator { return new Each( - new Circuit( + new ShortCircuit( new Alnum('-'), new Not(new StartsWith('-')), new AnyOf( diff --git a/src/Validators/Each.php b/src/Validators/Each.php index 3450e20b0..43d24f04f 100644 --- a/src/Validators/Each.php +++ b/src/Validators/Each.php @@ -18,10 +18,12 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; use Respect\Validation\Path; use Respect\Validation\Result; use Respect\Validation\Validators\Core\FilteredArray; +use Respect\Validation\Validators\Core\ShortCircuitable; use function array_reduce; @@ -30,8 +32,32 @@ 'Each item in {{subject}} must be valid', 'Each item in {{subject}} must be invalid', )] -final class Each extends FilteredArray +final class Each extends FilteredArray implements ShortCircuitable { + use CanEvaluateShortCircuit; + + public function evaluateShortCircuit(mixed $input): Result + { + $iterableResult = (new IterableType())->evaluate($input); + if (!$iterableResult->hasPassed) { + return $iterableResult->withIdFrom($this); + } + + $children = []; + foreach ($input as $key => $value) { + $result = $this->evaluateShortCircuitWith($this->validator, $value) + ->withPath(new Path($key)) + ->withPrecedentName(false); + if (!$result->hasPassed) { + return $result; + } + + $children[] = $result; + } + + return Result::passed($input, $this)->withChildren(...$children); + } + /** @param array $input */ protected function evaluateArray(array $input): Result { diff --git a/src/Validators/KeySet.php b/src/Validators/KeySet.php index 85327287a..562007a74 100644 --- a/src/Validators/KeySet.php +++ b/src/Validators/KeySet.php @@ -16,12 +16,14 @@ use Attribute; use Respect\Validation\Exceptions\InvalidValidatorException; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; use Respect\Validation\ValidatorBuilder; use Respect\Validation\Validators\Core\KeyRelated; use Respect\Validation\Validators\Core\Reducer; +use Respect\Validation\Validators\Core\ShortCircuitable; use function array_diff; use function array_filter; @@ -50,8 +52,10 @@ '{{subject}} contains no missing keys', self::TEMPLATE_MISSING_KEYS, )] -final readonly class KeySet implements Validator +final readonly class KeySet implements ShortCircuitable { + use CanEvaluateShortCircuit; + public const string TEMPLATE_BOTH = '__both__'; public const string TEMPLATE_EXTRA_KEYS = '__extra_keys__'; public const string TEMPLATE_MISSING_KEYS = '__missing_keys__'; @@ -96,6 +100,33 @@ public function evaluate(mixed $input): Result ->withChildren(...($keysResult->children === [] ? [$keysResult] : $keysResult->children)); } + public function evaluateShortCircuit(mixed $input): Result + { + $arrayResult = (new ArrayType())->evaluate($input); + if (!$arrayResult->hasPassed) { + return $arrayResult; + } + + $children = []; + foreach ($this->validators as $validator) { + $result = $this->evaluateShortCircuitWith($validator, $input); + if (!$result->hasPassed) { + return $result; + } + + $children[] = $result; + } + + $extraKeys = array_slice(array_diff(array_keys($input), $this->allKeys), 0, self::MAX_DIFF_KEYS); + foreach ($extraKeys as $key) { + return (new Not(new KeyExists($key)))->evaluate($input); + } + + $template = $this->getTemplateFromKeys(array_keys($input)); + + return Result::passed($input, $this, [], $template)->withChildren(...$children); + } + /** * @param array $validators * diff --git a/src/Validators/NoneOf.php b/src/Validators/NoneOf.php index f7fb46d5c..223f92dab 100644 --- a/src/Validators/NoneOf.php +++ b/src/Validators/NoneOf.php @@ -15,9 +15,11 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validators\Core\Composite; +use Respect\Validation\Validators\Core\ShortCircuitable; use function count; @@ -32,8 +34,10 @@ '{{subject}} must pass all the rules', self::TEMPLATE_ALL, )] -final class NoneOf extends Composite +final class NoneOf extends Composite implements ShortCircuitable { + use CanEvaluateShortCircuit; + public const string TEMPLATE_ALL = '__all__'; public const string TEMPLATE_SOME = '__some__'; @@ -59,4 +63,19 @@ public function evaluate(mixed $input): Result count($children) === $failedCount ? self::TEMPLATE_ALL : self::TEMPLATE_SOME, )->withChildren(...$children); } + + public function evaluateShortCircuit(mixed $input): Result + { + $children = []; + foreach ($this->validators as $validator) { + $result = $this->evaluateShortCircuitWith($validator, $input)->withToggledModeAndValidation(); + if (!$result->hasPassed) { + return $result; + } + + $children[] = $result; + } + + return Result::passed($input, $this, [], self::TEMPLATE_ALL)->withChildren(...$children); + } } diff --git a/src/Validators/OneOf.php b/src/Validators/OneOf.php index b3b5f3eee..d5489e5ba 100644 --- a/src/Validators/OneOf.php +++ b/src/Validators/OneOf.php @@ -16,10 +16,12 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; use Respect\Validation\Validators\Core\Composite; +use Respect\Validation\Validators\Core\ShortCircuitable; use function array_filter; use function array_map; @@ -38,8 +40,10 @@ '{{subject}} must pass only one of the rules', self::TEMPLATE_MORE_THAN_ONE, )] -final class OneOf extends Composite +final class OneOf extends Composite implements ShortCircuitable { + use CanEvaluateShortCircuit; + public const string TEMPLATE_NONE = '__none__'; public const string TEMPLATE_MORE_THAN_ONE = '__more_than_one__'; @@ -66,4 +70,24 @@ public function evaluate(mixed $input): Result return Result::of($valid, $input, $this, [], $template)->withChildren(...$children); } + + public function evaluateShortCircuit(mixed $input): Result + { + $children = []; + $passedCount = 0; + foreach ($this->validators as $validator) { + $result = $this->evaluateShortCircuitWith($validator, $input); + $children[] = $result; + if ($result->hasPassed) { + $passedCount++; + } + + if ($passedCount > 1) { + return Result::failed($input, $this, [], self::TEMPLATE_MORE_THAN_ONE)->withChildren(...$children); + } + } + + return Result::of($passedCount === 1, $input, $this, [], self::TEMPLATE_NONE) + ->withChildren(...$children); + } } diff --git a/src/Validators/Circuit.php b/src/Validators/ShortCircuit.php similarity index 60% rename from src/Validators/Circuit.php rename to src/Validators/ShortCircuit.php index b65d29fc0..afda5cdfc 100644 --- a/src/Validators/Circuit.php +++ b/src/Validators/ShortCircuit.php @@ -13,16 +13,27 @@ namespace Respect\Validation\Validators; use Attribute; +use Respect\Validation\Helpers\CanEvaluateShortCircuit; use Respect\Validation\Result; -use Respect\Validation\Validators\Core\Composite; +use Respect\Validation\Validator; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] -final class Circuit extends Composite +final readonly class ShortCircuit implements Validator { + use CanEvaluateShortCircuit; + + /** @var non-empty-array */ + private array $validators; + + public function __construct(Validator ...$validators) + { + $this->validators = $validators === [] ? [new AlwaysValid()] : $validators; + } + public function evaluate(mixed $input): Result { foreach ($this->validators as $validator) { - $result = $validator->evaluate($input); + $result = $this->evaluateShortCircuitWith($validator, $input); if (!$result->hasPassed) { return $result; } diff --git a/src/Validators/Tld.php b/src/Validators/Tld.php index e91a766aa..9221bed8a 100644 --- a/src/Validators/Tld.php +++ b/src/Validators/Tld.php @@ -33,7 +33,7 @@ final class Tld extends Envelope { public function __construct() { - parent::__construct(new Circuit( + parent::__construct(new ShortCircuit( new StringType(), new After('mb_strtoupper', new In(DataLoader::load('domain/tld.php'))), )); diff --git a/src/Validators/Url.php b/src/Validators/Url.php index 161c78bea..92600a2ab 100644 --- a/src/Validators/Url.php +++ b/src/Validators/Url.php @@ -34,17 +34,17 @@ public function __construct() { $this->validator = new After( 'parse_url', - new Circuit( + new ShortCircuit( new ArrayType(), new OneOf( - new Circuit( + new ShortCircuit( new Key('scheme', new In(['http', 'https', 'ftp', 'telnet', 'gopher', 'ldap'])), new Key('host', new OneOf( new Domain(), new After([self::class, 'formatIp'], new Ip()), )), ), - new Circuit( + new ShortCircuit( new Key('scheme', new Equals('mailto')), new Key('path', new Email()), ), diff --git a/tests/benchmark/CompositeValidatorsBench.php b/tests/benchmark/CompositeValidatorsBench.php new file mode 100644 index 000000000..c5114c04e --- /dev/null +++ b/tests/benchmark/CompositeValidatorsBench.php @@ -0,0 +1,123 @@ + + * SPDX-FileContributor: Henrique Moody + */ + +declare(strict_types=1); + +namespace Respect\Validation\Benchmarks; + +use Generator; +use PhpBench\Attributes as Bench; +use Respect\Validation\Validator; +use Respect\Validation\ValidatorBuilder; +use Respect\Validation\Validators\Alnum; +use Respect\Validation\Validators\Alpha; +use Respect\Validation\Validators\BoolType; +use Respect\Validation\Validators\Digit; +use Respect\Validation\Validators\Even; +use Respect\Validation\Validators\FloatType; +use Respect\Validation\Validators\IntType; +use Respect\Validation\Validators\Negative; +use Respect\Validation\Validators\Positive; +use Respect\Validation\Validators\StringType; + +use function count; +use function range; + +final class CompositeValidatorsBench +{ + /** @param array{string, array} $params */ + #[Bench\ParamProviders(['provideGroupedValidatorBuilder'])] + #[Bench\Iterations(5)] + #[Bench\Revs(50)] + #[Bench\Warmup(1)] + #[Bench\Subject] + public function isValidGrouped(array $params): void + { + ValidatorBuilder::__callStatic(...$params)->isValid(42); + } + + /** @param array{string, array} $params */ + #[Bench\ParamProviders(['provideArrayBasedValidatorBuilder'])] + #[Bench\Iterations(5)] + #[Bench\Revs(50)] + #[Bench\Warmup(1)] + #[Bench\Subject] + public function isValidArrayBased(array $params): void + { + [$validator, $validators] = $params; + ValidatorBuilder::__callStatic($validator, $validators)->isValid(range(1, count($validators))); + } + + #[Bench\ParamProviders(['provideInvalidDomain'])] + #[Bench\Iterations(5)] + #[Bench\Revs(50)] + #[Bench\Warmup(1)] + #[Bench\Subject] + public function isValidDomain(mixed $input): void + { + ValidatorBuilder::domain()->isValid($input); + } + + public function provideGroupedValidatorBuilder(): 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 'shortCircuit(10)' => ['shortCircuit', $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)]; + yield 'shortCircuit(100)' => ['shortCircuit', $this->buildValidators(100)]; + } + + public function provideArrayBasedValidatorBuilder(): Generator + { + yield 'all(10)' => ['all', $this->buildValidators(10)]; + yield 'each(10)' => ['each', $this->buildValidators(10)]; + yield 'all(100)' => ['all', $this->buildValidators(100)]; + yield 'each(100)' => ['each', $this->buildValidators(100)]; + } + + public function provideInvalidDomain(): Generator + { + yield 'no dots' => ['no dots']; + yield 'starts with "-"' => ['-example-invalid.com']; + yield 'ends with "-"' => ['example.invalid-.com']; + yield 'double "--"' => ['xn--bcher--kva.ch']; + } + + /** @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/feature/ValidatorBuilder/CheckTest.php b/tests/feature/ValidatorBuilder/CheckTest.php new file mode 100644 index 000000000..8594e8485 --- /dev/null +++ b/tests/feature/ValidatorBuilder/CheckTest.php @@ -0,0 +1,110 @@ + + */ + +declare(strict_types=1); + +use Respect\Validation\Exceptions\ValidationException; + +test('It should not throw an exception when a single validator passes', function (): void { + v::intType()->check(42); +})->throwsNoExceptions(); + +test('It should not throw an exception when all chained validators pass', function (): void { + v::intType()->positive()->check(42); +})->throwsNoExceptions(); + +test('It should throw an exception when a single validator fails', catchMessage( + fn() => v::intType()->check('not an integer'), + fn(string $message) => expect($message)->toBe('"not an integer" must be an integer'), +)); + +test('It should stop at the first failure with multiple validators', catchMessage( + fn() => v::intType()->positive()->check('not valid'), + fn(string $message) => expect($message)->toBe('"not valid" must be an integer'), +)); + +test('It should report the second validator when only it fails', catchMessage( + fn() => v::intType()->positive()->check(-5), + fn(string $message) => expect($message)->toBe('-5 must be a positive number'), +)); + +test('It should only report the first failing key', catchMessage( + fn() => v::key('name', v::stringType())->key('age', v::intType())->check(['name' => 123, 'age' => 'old']), + fn(string $message) => expect($message)->toBe('`.name` must be a string'), +)); + +test('It should report the second key when the first passes', catchMessage( + fn() => v::key('name', v::stringType())->key('age', v::intType())->check(['name' => 'John', 'age' => 'old']), + fn(string $message) => expect($message)->toBe('`.age` must be an integer'), +)); + +test('It should use a string template to override the message', catchMessage( + fn() => v::intType()->check('test', 'The input must be a number'), + fn(string $message) => expect($message)->toBe('The input must be a number'), +)); + +test('It should use an array template to override specific validator messages', catchMessage( + fn() => v::intType()->positive()->check('test', ['intType' => 'Must be a whole number']), + fn(string $message) => expect($message)->toBe('Must be a whole number'), +)); + +test('It should throw a custom Throwable when provided as template', function (): void { + $exception = new RuntimeException('Custom error'); + + expect(fn() => v::intType()->check('test', $exception))->toThrow($exception); +}); + +test('It should not throw a custom Throwable when validation passes', function (): void { + v::intType()->check(42, new RuntimeException('Custom error')); +})->throwsNoExceptions(); + +test('It should use a callable template to transform the exception', function (): void { + expect(fn() => v::intType()->check( + 'test', + fn(ValidationException $e) => new DomainException($e->getMessage()), + ),)->toThrow(new DomainException('"test" must be an integer')); +}); + +test('It should provide full message with single failure', catchFullMessage( + fn() => v::intType()->check('test'), + fn(string $fullMessage) => expect($fullMessage)->toBe('- "test" must be an integer'), +)); + +test('It should provide messages array with single failure', catchMessages( + fn() => v::intType()->check('test'), + fn(array $messages) => expect($messages)->toBe(['intType' => '"test" must be an integer']), +)); + +test('It should provide all exception details on short-circuited failure', catchAll( + fn() => v::intType()->positive()->check('test'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"test" must be an integer') + ->and($fullMessage)->toBe('- "test" must be an integer') + ->and($messages)->toBe(['intType' => '"test" must be an integer']), +)); + +test('It should not throw when validating a present key', function (): void { + v::key('name', v::stringType())->check(['name' => 'John']); + + expect(true)->toBeTrue(); +}); + +test('It should throw when a required key is missing', catchMessage( + fn() => v::key('name', v::stringType())->check([]), + fn(string $message) => expect($message)->toBe('`.name` must be present'), +)); + +test('It should work with named validators', catchMessage( + fn() => v::named('user age', v::intType())->check('old'), + fn(string $message) => expect($message)->toBe('user age must be an integer'), +)); + +test('It should work with negated validators', catchMessage( + fn() => v::not(v::intType())->check(42), + fn(string $message) => expect($message)->toBe('42 must not be an integer'), +)); diff --git a/tests/feature/Validators/AllOfTest.php b/tests/feature/Validators/AllOfTest.php index 7d6caf011..4e8474e5f 100644 --- a/tests/feature/Validators/AllOfTest.php +++ b/tests/feature/Validators/AllOfTest.php @@ -124,3 +124,35 @@ 'uppercase' => 'Template for "uppercase"', ]), )); + +test('short-circuit: first validator fails', catchAll( + fn() => v::shortCircuit(v::intType(), v::negative(), v::greaterThan(10))->assert('string'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"string" must be an integer') + ->and($fullMessage)->toBe('- "string" must be an integer') + ->and($messages)->toBe(['intType' => '"string" must be an integer']), +)); + +test('short-circuit: second validator fails', catchAll( + fn() => v::shortCircuit(v::intType(), v::negative(), v::greaterThan(10))->assert(5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must be a negative number') + ->and($fullMessage)->toBe('- 5 must be a negative number') + ->and($messages)->toBe(['negative' => '5 must be a negative number']), +)); + +test('short-circuit: inverted when all validators pass', catchAll( + fn() => v::not(v::shortCircuit(v::allOf(v::intType(), v::negative())))->assert(-1), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('-1 must not be an integer') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - -1 must pass all the rules + - -1 must not be an integer + - -1 must not be a negative number + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '-1 must pass all the rules', + 'intType' => '-1 must not be an integer', + 'negative' => '-1 must not be a negative number', + ]), +)); diff --git a/tests/feature/Validators/AnyOfTest.php b/tests/feature/Validators/AnyOfTest.php index 733807a71..c6f4210c0 100644 --- a/tests/feature/Validators/AnyOfTest.php +++ b/tests/feature/Validators/AnyOfTest.php @@ -56,3 +56,67 @@ 'negative' => '-1 must not be a negative number', ]), )); + +test('short-circuit: first validator passes', catchAll( + fn() => v::shortCircuit(v::intType(), v::negative(), v::greaterThan(10))->assert('string'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"string" must be an integer') + ->and($fullMessage)->toBe('- "string" must be an integer') + ->and($messages)->toBe(['intType' => '"string" must be an integer']), +)); + +test('short-circuit: second validator fails', catchAll( + fn() => v::shortCircuit(v::intType(), v::negative(), v::greaterThan(10))->assert(5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must be a negative number') + ->and($fullMessage)->toBe('- 5 must be a negative number') + ->and($messages)->toBe(['negative' => '5 must be a negative number']), +)); + +test('short-circuit: all validators fail', catchAll( + fn() => v::shortCircuit(v::intType(), v::negative())->assert(5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must be a negative number') + ->and($fullMessage)->toBe('- 5 must be a negative number') + ->and($messages)->toBe(['negative' => '5 must be a negative number']), +)); + +test('short-circuit: AnyOf wrapped by ShortCircuit stops on first validator fail', catchAll( + fn() => v::shortCircuit(v::alwaysInvalid(), v::anyOf(v::stringType(), v::intType()))->assert('hello'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"hello" must be valid') + ->and($fullMessage)->toBe('- "hello" must be valid') + ->and($messages)->toBe(['alwaysInvalid' => '"hello" must be valid']), +)); + +test('short-circuit: AnyOf wrapped by ShortCircuit passes on first match of AnyOf', catchAll( + fn() => v::shortCircuit(v::stringType(), v::anyOf(v::intType(), v::negative()))->assert(5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must be a string') + ->and($fullMessage)->toBe('- 5 must be a string') + ->and($messages)->toBe(['stringType' => '5 must be a string']), +)); + +test('short-circuit: AnyOf wrapped by ShortCircuit passes on second match of AnyOf', catchAll( + fn() => v::shortCircuit(v::stringType(), v::anyOf(v::intType(), v::negative()))->assert(-5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('-5 must be a string') + ->and($fullMessage)->toBe('- -5 must be a string') + ->and($messages)->toBe(['stringType' => '-5 must be a string']), +)); + +test('short-circuit: AnyOf wrapped by ShortCircuit fails when AnyOf all fail', catchAll( + fn() => v::shortCircuit(v::stringType(), v::anyOf(v::intType(), v::negative()))->assert(3.14), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('3.14 must be a string') + ->and($fullMessage)->toBe('- 3.14 must be a string') + ->and($messages)->toBe(['stringType' => '3.14 must be a string']), +)); + +test('short-circuit: inverted when all validators pass', catchAll( + fn() => v::not(v::shortCircuit(v::anyOf(v::intType(), v::negative())))->assert(-1), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('-1 must not be an integer') + ->and($fullMessage)->toBe('- -1 must not be an integer') + ->and($messages)->toBe(['intType' => '-1 must not be an integer']), +)); diff --git a/tests/feature/Validators/EachTest.php b/tests/feature/Validators/EachTest.php index d700482a1..f6e353bc8 100644 --- a/tests/feature/Validators/EachTest.php +++ b/tests/feature/Validators/EachTest.php @@ -301,3 +301,37 @@ ], ]), )); + +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']), +)); diff --git a/tests/feature/Validators/KeySetTest.php b/tests/feature/Validators/KeySetTest.php index cda8fca94..89eee5f90 100644 --- a/tests/feature/Validators/KeySetTest.php +++ b/tests/feature/Validators/KeySetTest.php @@ -228,3 +228,134 @@ 'baz' => '`.baz` must be present', ]), )); + +test('short-circuit / first key fails', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::key('foo', v::intType()), + v::key('bar', v::intType()), + ), + ) + ->assert(['foo' => 'string', 'bar' => 'string']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.foo` must be an integer') + ->and($fullMessage)->toBe('- `.foo` must be an integer') + ->and($messages)->toBe(['foo' => '`.foo` must be an integer']), +)); + +test('short-circuit / extra key', catchAll( + fn() => v::shortCircuit(v::keySet(v::keyExists('foo')))->assert(['foo' => 42, 'bar' => 'extra']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.bar` must not be present') + ->and($fullMessage)->toBe('- `.bar` must not be present') + ->and($messages)->toBe(['bar' => '`.bar` must not be present']), +)); + +test('short-circuit / not an array', catchAll( + fn() => v::shortCircuit(v::keySet(v::keyExists('foo')))->assert('not-an-array'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"not-an-array" must be an array') + ->and($fullMessage)->toBe('- "not-an-array" must be an array') + ->and($messages)->toBe(['arrayType' => '"not-an-array" must be an array']), +)); + +test('short-circuit / second key fails', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::key('foo', v::intType()), + v::key('bar', v::intType()), + v::key('baz', v::intType()), + ), + ) + ->assert(['foo' => 1, 'bar' => 'string', 'baz' => 3]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.bar` must be an integer') + ->and($fullMessage)->toBe('- `.bar` must be an integer') + ->and($messages)->toBe(['bar' => '`.bar` must be an integer']), +)); + +test('short-circuit / third key fails', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::key('foo', v::intType()), + v::key('bar', v::intType()), + v::key('baz', v::intType()), + ), + ) + ->assert(['foo' => 1, 'bar' => 2, 'baz' => 'string']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.baz` must be an integer') + ->and($fullMessage)->toBe('- `.baz` must be an integer') + ->and($messages)->toBe(['baz' => '`.baz` must be an integer']), +)); + +test('short-circuit / extra key before third key', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::key('foo', v::intType()), + v::key('bar', v::intType()), + ), + ) + ->assert(['foo' => 1, 'bar' => 2, 'baz' => 'extra']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.baz` must not be present') + ->and($fullMessage)->toBe('- `.baz` must not be present') + ->and($messages)->toBe(['baz' => '`.baz` must not be present']), +)); + +test('short-circuit / first extra key fails', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::keyExists('foo'), + v::keyExists('bar'), + ), + ) + ->assert(['foo' => 1, 'bar' => 2, 'extra1' => 'value', 'extra2' => 'value']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.extra1` must not be present') + ->and($fullMessage)->toBe('- `.extra1` must not be present') + ->and($messages)->toBe(['extra1' => '`.extra1` must not be present']), +)); + +test('short-circuit / missing key before extra keys', catchAll( + fn() => v::shortCircuit(v::keySet(v::keyExists('foo'), v::keyExists('bar')))->assert(['foo' => 1, 'extra' => 'value']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.bar` must be present') + ->and($fullMessage)->toBe('- `.bar` must be present') + ->and($messages)->toBe(['bar' => '`.bar` must be present']), +)); + +test('short-circuit / nested KeySet fails', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::key('user', v::keySet( + v::key('name', v::stringType()), + v::key('email', v::email()), + )), + ), + ) + ->assert([ + 'user' => [ + 'name' => 'John Doe', + 'email' => 'invalid-email', + ], + ]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.user.email` must be an email address') + ->and($fullMessage)->toBe('- `.user.email` must be an email address') + ->and($messages)->toBe(['email' => '`.user.email` must be an email address']), +)); + +test('short-circuit / with keyOptional that fails', catchAll( + fn() => v::shortCircuit( + v::keySet( + v::key('foo', v::stringType()), + v::keyOptional('bar', v::intType()), + ), + ) + ->assert(['foo' => 1, 'bar' => 'string']), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.foo` must be a string') + ->and($fullMessage)->toBe('- `.foo` must be a string') + ->and($messages)->toBe(['foo' => '`.foo` must be a string']), +)); diff --git a/tests/feature/Validators/NoneOfTest.php b/tests/feature/Validators/NoneOfTest.php index 91e73e39c..fd77188ff 100644 --- a/tests/feature/Validators/NoneOfTest.php +++ b/tests/feature/Validators/NoneOfTest.php @@ -66,3 +66,43 @@ 'negative' => '"string" must be a negative number', ]), )); + +test('short-circuit: first validator passes (should fail)', catchAll( + fn() => v::shortCircuit(v::noneOf(v::intType(), v::negative(), v::greaterThan(10)))->assert(5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must not be an integer') + ->and($fullMessage)->toBe('- 5 must not be an integer') + ->and($messages)->toBe(['intType' => '5 must not be an integer']), +)); + +test('short-circuit: second validator passes (should fail)', catchAll( + fn() => v::shortCircuit(v::noneOf(v::intType(), v::negative(), v::greaterThan(10)))->assert('-5'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"-5" must not be a negative number') + ->and($fullMessage)->toBe('- "-5" must not be a negative number') + ->and($messages)->toBe(['negative' => '"-5" must not be a negative number']), +)); + +test('short-circuit: second validator passes (noneOf fails)', catchAll( + fn() => v::shortCircuit(v::noneOf(v::intType(), v::negative()))->assert('-1'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"-1" must not be a negative number') + ->and($fullMessage)->toBe('- "-1" must not be a negative number') + ->and($messages)->toBe(['negative' => '"-1" must not be a negative number']), +)); + +test('short-circuit: inverted when all validators false', catchAll( + fn() => v::not(v::shortCircuit(v::noneOf(v::intType(), v::negative())))->assert(1.9), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('1.9 must be an integer') + ->and($fullMessage)->toBe(<<<'FULL_MESSAGE' + - 1.9 must pass all the rules + - 1.9 must be an integer + - 1.9 must be a negative number + FULL_MESSAGE) + ->and($messages)->toBe([ + '__root__' => '1.9 must pass all the rules', + 'intType' => '1.9 must be an integer', + 'negative' => '1.9 must be a negative number', + ]), +)); diff --git a/tests/feature/Validators/OneOfTest.php b/tests/feature/Validators/OneOfTest.php index d60357dba..2b90554b0 100644 --- a/tests/feature/Validators/OneOfTest.php +++ b/tests/feature/Validators/OneOfTest.php @@ -94,3 +94,11 @@ ->and($fullMessage)->toBe('- -1 must not be a negative number') ->and($messages)->toBe(['negative' => '-1 must not be a negative number']), )); + +test('short-circuit: inverted when one validator passes', catchAll( + fn() => v::not(v::shortCircuit(v::oneOf(v::stringType(), v::intType())))->assert(5), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('5 must not be an integer') + ->and($fullMessage)->toBe('- 5 must not be an integer') + ->and($messages)->toBe(['intType' => '5 must not be an integer']), +)); diff --git a/tests/feature/Validators/CircuitTest.php b/tests/feature/Validators/ShortCircuitTest.php similarity index 66% rename from tests/feature/Validators/CircuitTest.php rename to tests/feature/Validators/ShortCircuitTest.php index 62e7cfc88..b87da9800 100644 --- a/tests/feature/Validators/CircuitTest.php +++ b/tests/feature/Validators/ShortCircuitTest.php @@ -10,15 +10,23 @@ declare(strict_types=1); test('Default', catchAll( - fn() => v::circuit(v::alwaysValid(), v::trueVal())->assert(false), + fn() => v::shortCircuit(v::alwaysValid(), v::trueVal())->assert(false), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('`false` must evaluate to `true`') ->and($fullMessage)->toBe('- `false` must evaluate to `true`') ->and($messages)->toBe(['trueVal' => '`false` must evaluate to `true`']), )); +test('With recursive', catchAll( + fn() => v::shortCircuit(v::each(v::intType()))->assert([1, 2, '3', 4]), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('`.2` must be an integer') + ->and($fullMessage)->toBe('- `.2` must be an integer') + ->and($messages)->toBe([2 => '`.2` must be an integer']), +)); + test('Inverted', catchAll( - fn() => v::not(v::circuit(v::alwaysValid(), v::trueVal()))->assert(true), + fn() => v::not(v::shortCircuit(v::alwaysValid(), v::trueVal()))->assert(true), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('`true` must not evaluate to `true`') ->and($fullMessage)->toBe('- `true` must not evaluate to `true`') @@ -26,7 +34,7 @@ )); test('Default with inverted failing rule', catchAll( - fn() => v::circuit(v::alwaysValid(), v::not(v::trueVal()))->assert(true), + fn() => v::shortCircuit(v::alwaysValid(), v::not(v::trueVal()))->assert(true), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('`true` must not evaluate to `true`') ->and($fullMessage)->toBe('- `true` must not evaluate to `true`') @@ -34,7 +42,7 @@ )); test('With wrapped name, default', catchAll( - fn() => v::named('Wrapper', v::circuit(v::alwaysValid(), v::named('Wrapped', v::trueVal())))->assert(false), + fn() => v::named('Wrapper', v::shortCircuit(v::alwaysValid(), v::named('Wrapped', v::trueVal())))->assert(false), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('Wrapped must evaluate to `true`') ->and($fullMessage)->toBe('- Wrapped must evaluate to `true`') @@ -42,7 +50,7 @@ )); test('With wrapper name, default', catchAll( - fn() => v::named('Wrapper', v::circuit(v::alwaysValid(), v::trueVal()))->assert(false), + fn() => v::named('Wrapper', v::shortCircuit(v::alwaysValid(), v::trueVal()))->assert(false), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('Wrapper must evaluate to `true`') ->and($fullMessage)->toBe('- Wrapper must evaluate to `true`') @@ -50,7 +58,7 @@ )); test('With the name set in the wrapped rule of an inverted failing rule', catchAll( - fn() => v::named('Wrapper', v::circuit(v::alwaysValid(), v::named('Not', v::not(v::named('Wrapped', v::trueVal())))))->assert(true), + fn() => v::named('Wrapper', v::shortCircuit(v::alwaysValid(), v::named('Not', v::not(v::named('Wrapped', v::trueVal())))))->assert(true), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('Wrapped must not evaluate to `true`') ->and($fullMessage)->toBe('- Wrapped must not evaluate to `true`') @@ -58,15 +66,15 @@ )); test('With the name set in an inverted failing rule', catchAll( - fn() => v::named('Wrapper', v::circuit(v::alwaysValid(), v::named('Not', v::not(v::trueVal()))))->assert(true), + fn() => v::named('Wrapper', v::shortCircuit(v::alwaysValid(), v::named('Not', v::not(v::trueVal()))))->assert(true), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('Not must not evaluate to `true`') ->and($fullMessage)->toBe('- Not must not evaluate to `true`') ->and($messages)->toBe(['notTrueVal' => 'Not must not evaluate to `true`']), )); -test('With the name set in the "circuit" that has an inverted failing rule', catchAll( - fn() => v::named('Wrapper', v::circuit(v::alwaysValid(), v::not(v::trueVal())))->assert(true), +test('With the name set in the "shortCircuit" that has an inverted failing rule', catchAll( + fn() => v::named('Wrapper', v::shortCircuit(v::alwaysValid(), v::not(v::trueVal())))->assert(true), fn(string $message, string $fullMessage, array $messages) => expect() ->and($message)->toBe('Wrapper must not evaluate to `true`') ->and($fullMessage)->toBe('- Wrapper must not evaluate to `true`') @@ -74,25 +82,25 @@ )); test('With template', catchAll( - fn() => v::templated('Circuit cool cats cunningly continuous cookies', v::circuit(v::alwaysValid(), v::trueVal())) + fn() => v::templated('ShortCircuit cool cats cunningly continuous cookies', v::shortCircuit(v::alwaysValid(), v::trueVal())) ->assert(false), fn(string $message, string $fullMessage, array $messages) => expect() - ->and($message)->toBe('Circuit cool cats cunningly continuous cookies') - ->and($fullMessage)->toBe('- Circuit cool cats cunningly continuous cookies') - ->and($messages)->toBe(['trueVal' => 'Circuit cool cats cunningly continuous cookies']), + ->and($message)->toBe('ShortCircuit cool cats cunningly continuous cookies') + ->and($fullMessage)->toBe('- ShortCircuit cool cats cunningly continuous cookies') + ->and($messages)->toBe(['trueVal' => 'ShortCircuit cool cats cunningly continuous cookies']), )); test('With multiple templates', catchAll( - fn() => v::circuit(v::alwaysValid(), v::trueVal()) - ->assert(false, ['trueVal' => 'Clever clowns craft circuit clever clocks']), + fn() => v::shortCircuit(v::alwaysValid(), v::trueVal()) + ->assert(false, ['trueVal' => 'Clever clowns craft shortCircuit clever clocks']), fn(string $message, string $fullMessage, array $messages) => expect() - ->and($message)->toBe('Clever clowns craft circuit clever clocks') - ->and($fullMessage)->toBe('- Clever clowns craft circuit clever clocks') - ->and($messages)->toBe(['trueVal' => 'Clever clowns craft circuit clever clocks']), + ->and($message)->toBe('Clever clowns craft shortCircuit clever clocks') + ->and($fullMessage)->toBe('- Clever clowns craft shortCircuit clever clocks') + ->and($messages)->toBe(['trueVal' => 'Clever clowns craft shortCircuit clever clocks']), )); test('Real example', catchAll( - fn() => v::circuit( + fn() => v::shortCircuit( v::key('countyCode', v::countryCode()), v::factory( fn($input) => v::key('subdivisionCode', v::subdivisionCode($input['countyCode'])), diff --git a/tests/src/SmokeTestProvider.php b/tests/src/SmokeTestProvider.php index 831a9d68c..212aae12b 100644 --- a/tests/src/SmokeTestProvider.php +++ b/tests/src/SmokeTestProvider.php @@ -46,7 +46,6 @@ public static function provideValidatorInput(): Generator yield 'Bsn' => [new vs\Bsn(), '612890053']; yield 'CallableType' => [new vs\CallableType(), [static::class, 'callableTarget']]; yield 'Charset' => [new vs\Charset('UTF-8'), 'example']; - yield 'Circuit' => [new vs\Circuit(new vs\IntVal(), new vs\GreaterThan(0)), 5]; yield 'Cnh' => [new vs\Cnh(), '02650306461']; yield 'Cnpj' => [new vs\Cnpj(), '11444777000161']; yield 'Consonant' => [new vs\Consonant(), 'bcdf']; @@ -77,6 +76,7 @@ public static function provideValidatorInput(): Generator yield 'Exists' => [new vs\Exists(), 'tests/fixtures/valid-image.png']; yield 'Extension' => [new vs\Extension('png'), 'image.png']; yield 'Factor' => [new vs\Factor(0), 36]; + yield 'ShortCircuit' => [new vs\ShortCircuit(new vs\IntVal(), new vs\GreaterThan(0)), 5]; yield 'FalseVal' => [new vs\FalseVal(), false]; yield 'Falsy' => [new vs\Falsy(), 0]; yield 'File' => [new vs\File(), __FILE__]; diff --git a/tests/src/Validators/Stub.php b/tests/src/Validators/Stub.php index 70485f550..89b002227 100644 --- a/tests/src/Validators/Stub.php +++ b/tests/src/Validators/Stub.php @@ -30,7 +30,7 @@ final class Stub extends Simple public array $validations; /** @var array */ - public array $inputs; + public array $inputs = []; public function __construct(bool ...$validations) { diff --git a/tests/unit/Validators/AllOfTest.php b/tests/unit/Validators/AllOfTest.php index 74a4911ed..7d4df314e 100644 --- a/tests/unit/Validators/AllOfTest.php +++ b/tests/unit/Validators/AllOfTest.php @@ -13,13 +13,15 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Respect\Validation\Test\RuleTestCase; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Respect\Validation\Test\Validators\Stub; #[Group('validator')] #[CoversClass(AllOf::class)] -final class AllOfTest extends RuleTestCase +final class AllOfTest extends TestCase { /** @return iterable */ public static function providerForValidInput(): iterable @@ -37,4 +39,48 @@ 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 shouldEvaluateShortCircuitValidInput(AllOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateShortCircuitValidInput(AllOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateValidInput(AllOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluate($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateValidInput(AllOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluate($input)->hasPassed); + } + + #[Test] + public function shouldShortCircuitStopEvaluatingAfterFirstFailure(): void + { + $stub1 = new Stub(false); + $stub2 = Stub::daze(); + $stub3 = Stub::daze(); + $validator = new AllOf($stub1, $stub2, $stub3); + + $result = $validator->evaluateShortCircuit([]); + + self::assertFalse($result->hasPassed); + self::assertCount(1, $stub1->inputs); + self::assertCount(0, $stub2->inputs); + self::assertCount(0, $stub3->inputs); + } } diff --git a/tests/unit/Validators/AllTest.php b/tests/unit/Validators/AllTest.php index f05e69ec3..3c8ba6c0a 100644 --- a/tests/unit/Validators/AllTest.php +++ b/tests/unit/Validators/AllTest.php @@ -11,6 +11,7 @@ namespace Respect\Validation\Validators; +use ArrayIterator; use ArrayObject; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -63,4 +64,75 @@ public function shouldValidateInvalidInput(Stub $stub, mixed $input): void $validator = new All($stub); self::assertInvalidInput($validator, $input); } + + #[Test] + public function shouldShortCircuitOnFirstFailure(): void + { + $stub = new Stub(true, false, true); + $validator = new All($stub); + + $result = $validator->evaluateShortCircuit([1, 2, 3]); + + self::assertFalse($result->hasPassed); + self::assertCount(2, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitPassWhenAllItemsPass(): void + { + $stub = Stub::pass(3); + $validator = new All($stub); + + $result = $validator->evaluateShortCircuit([1, 2, 3]); + + self::assertTrue($result->hasPassed); + self::assertCount(3, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitFailForNonIterableInput(): void + { + $stub = Stub::daze(); + $validator = new All($stub); + + $result = $validator->evaluateShortCircuit('not an array'); + + self::assertFalse($result->hasPassed); + } + + #[Test] + public function shouldShortCircuitReturnIndeterminateForEmptyArray(): void + { + $stub = Stub::daze(); + $validator = new All($stub); + + $result = $validator->evaluateShortCircuit([]); + + self::assertTrue($result->hasPassed); + self::assertTrue($result->isIndeterminate); + } + + #[Test] + public function shouldShortCircuitWorkWithIterator(): void + { + $stub = new Stub(true, false, true); + $validator = new All($stub); + + $result = $validator->evaluateShortCircuit(new ArrayIterator([1, 2, 3])); + + self::assertFalse($result->hasPassed); + self::assertCount(2, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitIncludePathOnFailure(): void + { + $stub = new Stub(true, false, true); + $validator = new All($stub); + + $result = $validator->evaluateShortCircuit(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertFalse($result->hasPassed); + self::assertSame('b', $result->path?->value); + } } diff --git a/tests/unit/Validators/AnyOfTest.php b/tests/unit/Validators/AnyOfTest.php index 24ca91498..e8f9b263a 100644 --- a/tests/unit/Validators/AnyOfTest.php +++ b/tests/unit/Validators/AnyOfTest.php @@ -13,13 +13,15 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Respect\Validation\Test\RuleTestCase; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Respect\Validation\Test\Validators\Stub; #[Group('validator')] #[CoversClass(AnyOf::class)] -final class AnyOfTest extends RuleTestCase +final class AnyOfTest extends TestCase { /** @return iterable */ public static function providerForValidInput(): iterable @@ -37,4 +39,48 @@ 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 shouldEvaluateShortCircuitValidInput(AnyOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateShortCircuitValidInput(AnyOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateValidInput(AnyOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluate($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateValidInput(AnyOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluate($input)->hasPassed); + } + + #[Test] + public function shouldShortCircuitStopEvaluatingAfterFirstSuccess(): void + { + $stub1 = new Stub(true); + $stub2 = Stub::daze(); + $stub3 = Stub::daze(); + $validator = new AnyOf($stub1, $stub2, $stub3); + + $result = $validator->evaluateShortCircuit([]); + + self::assertTrue($result->hasPassed); + self::assertCount(1, $stub1->inputs); + self::assertCount(0, $stub2->inputs); + self::assertCount(0, $stub3->inputs); + } } diff --git a/tests/unit/Validators/EachTest.php b/tests/unit/Validators/EachTest.php index 3ff37158d..ae5bec3ba 100644 --- a/tests/unit/Validators/EachTest.php +++ b/tests/unit/Validators/EachTest.php @@ -12,9 +12,12 @@ namespace Respect\Validation\Validators; +use ArrayIterator; use ArrayObject; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Respect\Validation\Test\RuleTestCase; use Respect\Validation\Test\Validators\Stub; use stdClass; @@ -49,4 +52,86 @@ public static function providerForInvalidInput(): iterable [new Each(Stub::fail(5)), (object) ['foo' => true]], ]; } + + #[Test] + public function shouldShortCircuitOnFirstFailure(): void + { + $stub = new Stub(true, false, true, true, true); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit([1, 2, 3, 4, 5]); + + TestCase::assertFalse($result->hasPassed); + TestCase::assertCount(2, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitPassWhenAllItemsPass(): void + { + $stub = Stub::pass(5); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit([1, 2, 3, 4, 5]); + + TestCase::assertTrue($result->hasPassed); + TestCase::assertCount(5, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitFailForNonIterableInput(): void + { + $stub = Stub::daze(); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit('not an array'); + + TestCase::assertFalse($result->hasPassed); + } + + #[Test] + public function shouldShortCircuitReturnPassedForEmptyArray(): void + { + $stub = Stub::daze(); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit([]); + + TestCase::assertTrue($result->hasPassed); + } + + #[Test] + public function shouldShortCircuitWorkWithIterator(): void + { + $stub = new Stub(true, false, true, true, true); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit(new ArrayIterator([1, 2, 3, 4, 5])); + + TestCase::assertFalse($result->hasPassed); + TestCase::assertCount(2, $stub->inputs); + } + + #[Test] + public function shouldShortCircuitIncludePathOnFailure(): void + { + $stub = new Stub(true, false, true, true, true); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit([1, 2, 3, 4, 5]); + + TestCase::assertFalse($result->hasPassed); + TestCase::assertSame(1, $result->path?->value); + } + + #[Test] + public function shouldShortCircuitWorkWithNamedKeys(): void + { + $stub = new Stub(true, false, true); + $validator = new Each($stub); + + $result = $validator->evaluateShortCircuit(['a' => 1, 'b' => 2, 'c' => 3]); + + TestCase::assertFalse($result->hasPassed); + TestCase::assertSame('b', $result->path?->value); + } } diff --git a/tests/unit/Validators/KeySetTest.php b/tests/unit/Validators/KeySetTest.php index dd6bef9ae..9725c581b 100644 --- a/tests/unit/Validators/KeySetTest.php +++ b/tests/unit/Validators/KeySetTest.php @@ -13,15 +13,17 @@ 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 PHPUnit\Framework\TestCase; use Respect\Validation\Exceptions\InvalidValidatorException; -use Respect\Validation\Test\RuleTestCase; use Respect\Validation\Test\Validators\Stub; +use Respect\Validation\ValidatorBuilder; #[Group('validator')] #[CoversClass(KeySet::class)] -final class KeySetTest extends RuleTestCase +final class KeySetTest extends TestCase { #[Test] public function nonKeyRelatedRuleShouldNotBeAllowed(): void @@ -36,6 +38,10 @@ public function nonKeyRelatedRuleShouldNotBeAllowed(): void public static function providerForValidInput(): iterable { yield 'correct keys, with passing rule' => [new KeySet(new Key('foo', Stub::pass(1))), ['foo' => 'bar']]; + yield 'multiple correct keys, with passing rules' => [ + new KeySet(new Key('foo', Stub::pass(1)), new Key('bar', Stub::pass(1)), new Key('baz', Stub::pass(1))), + ['foo' => 'value1', 'bar' => 'value2', 'baz' => 'value3'], + ]; } /** @return iterable */ @@ -65,4 +71,57 @@ public static function providerForInvalidInput(): iterable yield 'correct keys, with failing rule' => [new KeySet(new Key('foo', Stub::fail(1))), ['foo' => 'bar']]; } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateShortCircuitValidInput(KeySet $validator, mixed $input): void + { + self::assertTrue($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateShortCircuitValidInput(KeySet $validator, mixed $input): void + { + self::assertFalse($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateValidInput(KeySet $validator, mixed $input): void + { + self::assertTrue($validator->evaluate($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateValidInput(KeySet $validator, mixed $input): void + { + self::assertFalse($validator->evaluate($input)->hasPassed); + } + + #[Test] + public function shouldExtractKeyRelatedValidatorsFromValidatorBuilder(): void + { + $validator = new KeySet(ValidatorBuilder::init(new Key('foo', Stub::pass(2)), new Key('bar', Stub::pass(2)))); + + self::assertTrue($validator->evaluate(['foo' => 'value1', 'bar' => 'value2'])->hasPassed); + self::assertTrue($validator->evaluateShortCircuit(['foo' => 'value1', 'bar' => 'value2'])->hasPassed); + } + + #[Test] + public function shouldShortCircuitStopEvaluatingAfterFirstFailure(): void + { + $stub1 = new Stub(false); + $stub2 = Stub::daze(); + $stub3 = Stub::daze(); + $validator = new KeySet(new Key('foo', $stub1), new Key('bar', $stub2), new Key('baz', $stub3)); + + $result = $validator->evaluateShortCircuit(['foo' => 'value', 'bar' => 'value', 'baz' => 'value']); + + self::assertFalse($result->hasPassed); + self::assertCount(1, $stub1->inputs); + self::assertCount(0, $stub2->inputs); + self::assertCount(0, $stub3->inputs); + } } diff --git a/tests/unit/Validators/NoneOfTest.php b/tests/unit/Validators/NoneOfTest.php index a1aee5874..6d19a3207 100644 --- a/tests/unit/Validators/NoneOfTest.php +++ b/tests/unit/Validators/NoneOfTest.php @@ -13,13 +13,15 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Respect\Validation\Test\RuleTestCase; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Respect\Validation\Test\Validators\Stub; #[Group('validator')] #[CoversClass(NoneOf::class)] -final class NoneOfTest extends RuleTestCase +final class NoneOfTest extends TestCase { /** @return iterable */ public static function providerForValidInput(): iterable @@ -37,4 +39,48 @@ 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 shouldEvaluateShortCircuitValidInput(NoneOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateShortCircuitValidInput(NoneOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateValidInput(NoneOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluate($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateValidInput(NoneOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluate($input)->hasPassed); + } + + #[Test] + public function shouldShortCircuitStopEvaluatingAfterFirstFailure(): void + { + $stub1 = new Stub(true); + $stub2 = Stub::daze(); + $stub3 = Stub::daze(); + $validator = new NoneOf($stub1, $stub2, $stub3); + + $result = $validator->evaluateShortCircuit([]); + + self::assertFalse($result->hasPassed); + self::assertCount(1, $stub1->inputs); + self::assertCount(0, $stub2->inputs); + self::assertCount(0, $stub3->inputs); + } } diff --git a/tests/unit/Validators/OneOfTest.php b/tests/unit/Validators/OneOfTest.php index 19cbc30f6..850f5db58 100644 --- a/tests/unit/Validators/OneOfTest.php +++ b/tests/unit/Validators/OneOfTest.php @@ -14,13 +14,15 @@ namespace Respect\Validation\Validators; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Respect\Validation\Test\RuleTestCase; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Respect\Validation\Test\Validators\Stub; #[Group('validator')] #[CoversClass(OneOf::class)] -final class OneOfTest extends RuleTestCase +final class OneOfTest extends TestCase { /** @return iterable */ public static function providerForValidInput(): iterable @@ -39,4 +41,48 @@ public static function providerForInvalidInput(): iterable 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)), []]; } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateShortCircuitValidInput(OneOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateShortCircuitValidInput(OneOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluateShortCircuit($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForValidInput')] + public function shouldEvaluateValidInput(OneOf $validator, mixed $input): void + { + self::assertTrue($validator->evaluate($input)->hasPassed); + } + + #[Test] + #[DataProvider('providerForInvalidInput')] + public function shouldNotEvaluateValidInput(OneOf $validator, mixed $input): void + { + self::assertFalse($validator->evaluate($input)->hasPassed); + } + + #[Test] + public function shouldShortCircuitStopEvaluatingAfterSecondSuccess(): void + { + $stub1 = new Stub(true); + $stub2 = new Stub(true); + $stub3 = Stub::daze(); + $validator = new OneOf($stub1, $stub2, $stub3); + + $result = $validator->evaluateShortCircuit([]); + + self::assertFalse($result->hasPassed); + self::assertCount(1, $stub1->inputs); + self::assertCount(1, $stub2->inputs); + self::assertCount(0, $stub3->inputs); + } } diff --git a/tests/unit/Validators/CircuitTest.php b/tests/unit/Validators/ShortCircuitTest.php similarity index 85% rename from tests/unit/Validators/CircuitTest.php rename to tests/unit/Validators/ShortCircuitTest.php index d39ed4afc..45bb85635 100644 --- a/tests/unit/Validators/CircuitTest.php +++ b/tests/unit/Validators/ShortCircuitTest.php @@ -21,21 +21,21 @@ use function rand; #[Group('validator')] -#[CoversClass(Circuit::class)] -final class CircuitTest extends TestCase +#[CoversClass(ShortCircuit::class)] +final class ShortCircuitTest extends TestCase { #[Test] #[DataProvider('providerForAnyValues')] public function itShouldValidateInputWhenAllValidatorsValidatesTheInput(mixed $input): void { - self::assertValidInput(new Circuit(Stub::pass(1), Stub::pass(1)), $input); + self::assertValidInput(new ShortCircuit(Stub::pass(1), Stub::pass(1)), $input); } #[Test] #[DataProvider('providerForFailingValidators')] public function itShouldExecuteValidatorsInSequenceUntilOneFails(Stub ...$stub): void { - $validator = new Circuit(...$stub); + $validator = new ShortCircuit(...$stub); self::assertInvalidInput($validator, rand()); } @@ -45,7 +45,7 @@ public function itShouldReturnTheResultOfTheFailingRule(): void { $input = rand(); - $validator = new Circuit(Stub::fail(1), Stub::daze()); + $validator = new ShortCircuit(Stub::fail(1), Stub::daze()); $actual = $validator->evaluate($input); $expected = Stub::fail(1)->evaluate($input);