From 00e7f2a6fffc788f37871a2ad365809990192c05 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Tue, 27 Jan 2026 10:20:07 +0100 Subject: [PATCH] Fix ResultQuery::findByPath() for nested paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `findByPath()` method was failing to return results when using nested dot-notation paths such as `user.email` or `items.1`. However, it’s returning `null` instead of the expected result in some cases. The root cause was a mismatch between how paths are stored vs searched: - Storage: Validators like Key and Each create results where the path is stored as a linked list. For `user.email`, the "email" result has `path="email"` with `parent="user"`. - Search (old): The method expected a tree structure where it would find a child with `path="user"`, then search that child for `path="email"`. But no child had `path="user"` - only "email" (with "user" as its parent). The fix computes each result's full path by walking up the parent chain and compares it against the search path. Also converts numeric strings to integers when parsing paths (e.g., `items.1` → `['items', 1]`) since array indices are stored as integers. While working on this fix, I also realised that to expose the result's status, it’s best to use `hasFailed()` instead of `isValid()` in `ResultQuery`, since users will mostly use results when validation failed, not when it passed. Assisted-by: Claude Code (Opus 4.5) --- docs/feature-guide.md | 2 +- docs/getting-started.md | 2 +- docs/handling-results.md | 292 ++++++++++++++++++++++++++++++ src/ResultQuery.php | 79 +++++--- tests/feature/ResultQueryTest.php | 77 ++++++++ tests/unit/ResultQueryTest.php | 19 +- tests/unit/ValidatorTest.php | 4 +- 7 files changed, 436 insertions(+), 39 deletions(-) create mode 100644 docs/handling-results.md create mode 100644 tests/feature/ResultQueryTest.php 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]