diff --git a/docs/feature-guide.md b/docs/feature-guide.md index 0b4a65364..8547ec343 100644 --- a/docs/feature-guide.md +++ b/docs/feature-guide.md @@ -37,7 +37,7 @@ You can validate data and handle the result manually without using exceptions: ```php $result = v::numericVal()->positive()->between(1, 255)->validate($input); -if (!$result->isValid()) { +if ($result->hasFailed()) { echo $result; } ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 7658a09ef..8b1fc955a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,7 +41,7 @@ The `validate()` method returns a `ResultQuery` object that allows you to inspec ```php $result = v::intType()->validate($input); -if (!$result->isValid()) { +if ($result->hasFailed()) { echo 'Validation failed: ' . $result->getMessage(); } ``` diff --git a/docs/handling-results.md b/docs/handling-results.md new file mode 100644 index 000000000..ebae43316 --- /dev/null +++ b/docs/handling-results.md @@ -0,0 +1,292 @@ + + +# Handling results + +The `ResultQuery` class provides a fluent interface for inspecting validation results. It's returned by the `validate()` method and offers methods to check validity, retrieve error messages, and query nested validation results. + +## Basic usage + +```php +use Respect\Validation\ValidatorBuilder as v; + +$result = v::intType()->positive()->validate($input); +if ($result->hasFailed()) { + echo $result->getMessage(); +} +``` + +## Checking validity + +### hasFailed() + +Returns `true` if validation passed, `false` otherwise. + +```php +$result = v::email()->validate('user@example.com'); +$result->hasFailed(); // false + +$result = v::email()->validate('not-an-email'); +$result->hasFailed(); // true +``` + +## Retrieving messages + +### getMessage() + +Returns the first error message from the validation result. Returns an empty string if validation passed. + +```php +$result = v::intType()->validate('not an integer'); + +echo $result->getMessage(); +// → "not an integer" must be an integer +``` + +### getFullMessage() + +Returns a complete error tree showing all validation failures in a nested Markdown list format. Useful for debugging or displaying comprehensive error feedback. + +```php +$result = v::alnum()->lowercase()->validate('The Panda'); + +echo $result->getFullMessage(); +// → - "The Panda" must pass all the rules +// → - "The Panda" must contain only letters (a-z) and digits (0-9) +// → - "The Panda" must contain only lowercase letters +``` + +### getMessages() + +Returns all error messages as an associative array. Keys correspond to validator IDs or paths. + +```php +$result = v::alnum()->lowercase()->validate('The Panda'); + +print_r($result->getMessages()); +// Array +// ( +// [__root__] => "The Panda" must pass all the rules +// [alnum] => "The Panda" must contain only letters (a-z) and digits (0-9) +// [lowercase] => "The Panda" must contain only lowercase letters +// ) +``` + +For nested structures, keys reflect the path: + +```php +$result = v::init() + ->key('name', v::stringType()) + ->key('age', v::intType()) + ->validate(['name' => 123, 'age' => 'twenty']); + +print_r($result->getMessages()); +// Array +// ( +// [__root__] => `["name": 123, "age": "twenty"]` must pass all the rules +// [name] => name must be a string +// [age] => age must be an integer +// ) +``` + +### String conversion + +`ResultQuery` implements `Stringable`, so you can use it directly in string contexts. It returns the same value as `getMessage()`. + +```php +$result = v::email()->validate('invalid'); +echo $result; // "invalid" must be a valid email address +``` + +## Querying nested results + +When validating complex nested structures, `ResultQuery` provides methods to find and inspect specific parts of the validation result tree. + +### Return values + +All finder methods (`findByPath()`, `findByName()`, `findById()`) return either: +- A new `ResultQuery` instance wrapping the found result +- `null` if no matching result was found + +This allows safe chaining with null checks: + +```php +$result = $validator->validate($input); + +$nested = $result->findByPath('user.profile.email'); +if ($nested?->hasFailed()) { + echo $nested->getMessage(); +} +``` + +### findByPath() + +Finds a result by its path through the data structure. Supports dot notation for nested paths. + +```php +$result = v::init() + ->key('user', v::key('email', v::email())) + ->validate(['user' => ['email' => 'invalid']]); + +// Find the email validation result +$emailResult = $result->findByPath('user.email'); +if ($emailResult?->hasFailed()) { + echo $emailResult->getMessage(); + // → `.user.email` must be a valid email address +} +``` + +Paths can also be integers for array indices: + +```php +$result = v::init() + ->each(v::positive()) + ->validate([10, -5, 20]); + +// Find the result for index 1 +$itemResult = $result->findByPath(1); +if ($itemResult?->hasFailed()) { + echo $itemResult->getMessage(); + // → `.1` must be a positive number +} +``` + +Combined paths work too: + +```php +$result = v::init() + ->each( + v::key('email', v::email()), + ) + ->validate([ + ['email' => 'valid@example.com'], + ['email' => 'invalid'], + ]); + +// Find the email of the second item +$emailResult = $result->findByPath('1.email'); +if ($emailResult?->hasFailed()) { + echo $emailResult->getMessage(); + // → `.1.email` must be a valid email address +} +``` + +### findByName() + +Finds a result by a custom name assigned with the `Named` validator. + +```php +$result = v::named('User Email', v::email())->validate('invalid'); + +echo $result->findByName('User Email'); +// → User Email must be a valid email address +``` + +This is useful when you need to locate results by semantic names rather than structural paths: + +```php +$result = v::init() + ->key( + 'contact', + v::named('Primary Email', v::key('email', v::email())), + ) + ->validate(['contact' => ['email' => 'bad']]); + +echo $result->findByName('Primary Email'); +// → `.contact.email` (<- Primary Email) must be a valid email address +``` + +### findById() + +Finds a result by validator ID. IDs are automatically generated from validator class names (e.g., `StringType` becomes `stringType`). + +```php +$result = v::stringType()->email()->validate(123); + +echo $result->findById('stringType'); +// → 123 must be a string +``` + +## Practical patterns + +### Checking specific field validity + +```php +$result = v::init() + ->key('email', v::email()) + ->key('age', v::intType()->positive()) + ->validate($formData); + +// Check if email specifically is valid +$emailResult = $result->findByPath('email'); +if ($emailResult?->hasFailed()) { + // Email failed validation +} +``` + +### Collecting errors for specific fields + +```php +$result = v::init() + ->key('username', v::alnum()->lengthBetween(3, 20)) + ->key('password', v::lengthGreaterThanOrEqual(8)) + ->validate($input); + +$errors = [ + 'username' => $result->findByPath('username')?->getMessage(), + 'password' => $result->findByPath('password')?->getMessage(), +]; +``` + +### Validating arrays of items + +```php +$items = [ + ['name' => 'Widget', 'price' => 10], + ['name' => 123, 'price' => -5], + ['name' => 'Gadget', 'price' => 20], +]; + +$result = v::init() + ->each( + v::init() + ->key('name', v::stringType()) + ->key('price', v::positive()) + ) + ->validate($items); + +// Check each item individually +for ($i = 0; $i < count($items); $i++) { + $itemResult = $result->findByPath($i); + if ($itemResult !== null && !$itemResult->hasFailed()) { + echo "Item $i has errors: " . $itemResult->getMessage() . "\n"; + } +} + +// Or get a specific field from a specific item +$priceResult = $result->findByPath('1.price'); +if ($priceResult !== null) { + echo $priceResult->getMessage(); + // → `.1.price` must be a positive number +} +``` + +### Combining with custom templates + +```php +$result = v::init() + ->key('email', v::email()) + ->key('age', v::intType()) + ->validate($input, [ + 'email' => 'Please provide a valid email address', + 'age' => 'Age must be a whole number', + ]); + +$emailResult = $result->findByPath('email'); +if ($emailResult?->hasFailed()) { + echo $emailResult->getMessage(); + // → Please provide a valid email address +} +``` diff --git a/src/ResultQuery.php b/src/ResultQuery.php index 0fb013f56..ea6cb6e34 100644 --- a/src/ResultQuery.php +++ b/src/ResultQuery.php @@ -15,9 +15,11 @@ use Respect\Validation\Message\StringFormatter; use Stringable; -use function array_shift; +use function array_find; +use function array_map; +use function array_reverse; +use function ctype_digit; use function explode; -use function implode; use function is_string; final readonly class ResultQuery implements Stringable @@ -71,32 +73,18 @@ public function findByName(string $name): self|null public function findByPath(string|int $path): self|null { - if ($this->result->path?->value === $path) { - return $this; - } - - $paths = is_string($path) ? explode('.', $path) : [$path]; - $currentPath = array_shift($paths); - - foreach ($this->result->children as $child) { - if ($child->path?->value !== $currentPath) { - continue; - } - - $resultQuery = clone ($this, ['result' => $child]); - if ($paths === []) { - return $resultQuery; - } - - return $resultQuery->findByPath(is_string($path) ? implode('.', $paths) : $path); - } + $result = $this->findBySearchPaths($this->result, $this->getSearchPathsFromScalar($path)); - return null; + return match ($result) { + null => null, + $this->result => $this, + default => clone ($this, ['result' => $result]), + }; } - public function isValid(): bool + public function hasFailed(): bool { - return $this->result->hasPassed; + return $this->result->hasPassed == false; } public function getMessage(): string @@ -127,6 +115,49 @@ public function getMessages(): array return $this->messagesFormatter->format($this->result, $this->renderer, $this->templates); } + /** @param array $searchPaths */ + private function findBySearchPaths(Result $result, array $searchPaths): Result|null + { + if ($this->getSearchPathsFromPath($result->path) === $searchPaths) { + return $result; + } + + return array_find( + $result->children, + fn($child) => $this->getSearchPathsFromPath($child->path) === $searchPaths, + ); + } + + /** @return array */ + private function getSearchPathsFromScalar(string|int $path): array + { + if (!is_string($path)) { + return [$path]; + } + + return array_map( + static fn(string $part): string|int => ctype_digit($part) ? (int) $part : $part, + explode('.', $path), + ); + } + + /** @return array */ + private function getSearchPathsFromPath(Path|null $path): array + { + if ($path === null) { + return []; + } + + $parts = []; + $current = $path; + while ($current !== null) { + $parts[] = $current->value; + $current = $current->parent; + } + + return array_reverse($parts); + } + public function __toString(): string { return $this->getMessage(); diff --git a/tests/feature/ResultQueryTest.php b/tests/feature/ResultQueryTest.php new file mode 100644 index 000000000..e48c49dbd --- /dev/null +++ b/tests/feature/ResultQueryTest.php @@ -0,0 +1,77 @@ + + */ + +declare(strict_types=1); + +test('findByPath with nested keys', function (): void { + $validator = v::key('user', v::key('email', v::email())) + ->key('items', v::each(v::positive())); + + $result = $validator->validate([ + 'user' => ['email' => 'invalid'], + 'items' => [10, -5, 20], + ]); + + $emailResult = $result->findByPath('user.email'); + expect() + ->and($emailResult)->not->toBeNull() + ->and($emailResult?->hasFailed())->toBeTrue() + ->and($emailResult?->getMessage())->toBe('`.user.email` must be a valid email address'); +}); + +test('findByPath with array index', function (): void { + $validator = v::key('items', v::each(v::positive())); + + $result = $validator->validate([ + 'items' => [10, -5, 20], + ]); + + $itemResult = $result->findByPath('items.1'); + expect() + ->and($itemResult)->not->toBeNull() + ->and($itemResult?->hasFailed())->toBeTrue() + ->and($itemResult?->getMessage())->toBe('`.items.1` must be a positive number'); +}); + +test('findByName with named validator', function (): void { + $result = v::named('User Email', v::email())->validate('bad'); + + $namedResult = $result->findByName('User Email'); + expect() + ->and($namedResult)->not->toBeNull() + ->and($namedResult?->hasFailed())->toBeTrue() + ->and($namedResult?->getMessage())->toBe('User Email must be a valid email address'); +}); + +test('findById with validator id', function (): void { + $result = v::stringType()->email()->validate(123); + + $stringResult = $result->findById('stringType'); + expect() + ->and($stringResult)->not->toBeNull() + ->and($stringResult?->hasFailed())->toBeTrue() + ->and($stringResult?->getMessage())->toBe('123 must be a string'); +}); + +test('findByPath returns null when path not found', function (): void { + $result = v::key('user', v::email())->validate(['user' => 'bad']); + + expect($result->findByPath('nonexistent'))->toBeNull(); +}); + +test('findByName returns null when name not found', function (): void { + $result = v::email()->validate('bad'); + + expect($result->findByName('Nonexistent'))->toBeNull(); +}); + +test('findById returns null when id not found', function (): void { + $result = v::email()->validate('bad'); + + expect($result->findById('nonexistent'))->toBeNull(); +}); diff --git a/tests/unit/ResultQueryTest.php b/tests/unit/ResultQueryTest.php index 06673034f..03b138fd9 100644 --- a/tests/unit/ResultQueryTest.php +++ b/tests/unit/ResultQueryTest.php @@ -32,7 +32,7 @@ public function itShouldReturnTrueWhenResultHasPassed(): void $resultQuery = $this->createResultQuery($result); - self::assertTrue($resultQuery->isValid()); + self::assertFalse($resultQuery->hasFailed()); } #[Test] @@ -42,7 +42,7 @@ public function itShouldReturnFalseWhenResultHasNotPassed(): void $resultQuery = $this->createResultQuery($result); - self::assertFalse($resultQuery->isValid()); + self::assertTrue($resultQuery->hasFailed()); } #[Test] @@ -394,21 +394,18 @@ public function itShouldFindByDottedPathInNestedChildren(): void $childPath = uniqid(); $grandchildPath = uniqid(); - $grandchild = (new ResultBuilder()) - ->path(new Path($grandchildPath)) - ->hasPassed(false) - ->build(); + // Create path chain: grandchild path has parent pointing to child path + $childPathObj = new Path($childPath); + $grandchildPathObj = new Path($grandchildPath, $childPathObj); - $child = (new ResultBuilder()) - ->path(new Path($childPath)) + $grandchild = (new ResultBuilder()) + ->path($grandchildPathObj) ->hasPassed(false) - ->children($grandchild) ->build(); $parent = (new ResultBuilder()) - ->path(new Path(uniqid())) ->hasPassed(false) - ->children($child) + ->children($grandchild) ->build(); $resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter); diff --git a/tests/unit/ValidatorTest.php b/tests/unit/ValidatorTest.php index 5d1d4c802..06d64b11d 100644 --- a/tests/unit/ValidatorTest.php +++ b/tests/unit/ValidatorTest.php @@ -89,7 +89,7 @@ public function itShouldValidateAndReturnValidResultQueryWhenValidationPasses(): $resultQuery = $validator->validate('whatever'); - self::assertTrue($resultQuery->isValid()); + self::assertFalse($resultQuery->hasFailed()); } #[Test] @@ -99,7 +99,7 @@ public function itShouldValidateAndReturnInvalidResultQueryWhenValidationFails() $resultQuery = $validator->validate('whatever'); - self::assertFalse($resultQuery->isValid()); + self::assertTrue($resultQuery->hasFailed()); } #[Test]