Skip to content

Commit 00e7f2a

Browse files
committed
Fix ResultQuery::findByPath() for nested paths
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)
1 parent 4390e4f commit 00e7f2a

File tree

7 files changed

+436
-39
lines changed

7 files changed

+436
-39
lines changed

docs/feature-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ You can validate data and handle the result manually without using exceptions:
3737

3838
```php
3939
$result = v::numericVal()->positive()->between(1, 255)->validate($input);
40-
if (!$result->isValid()) {
40+
if ($result->hasFailed()) {
4141
echo $result;
4242
}
4343
```

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The `validate()` method returns a `ResultQuery` object that allows you to inspec
4141

4242
```php
4343
$result = v::intType()->validate($input);
44-
if (!$result->isValid()) {
44+
if ($result->hasFailed()) {
4545
echo 'Validation failed: ' . $result->getMessage();
4646
}
4747
```

docs/handling-results.md

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
<!--
2+
SPDX-FileCopyrightText: (c) Respect Project Contributors
3+
SPDX-License-Identifier: MIT
4+
-->
5+
6+
# Handling results
7+
8+
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.
9+
10+
## Basic usage
11+
12+
```php
13+
use Respect\Validation\ValidatorBuilder as v;
14+
15+
$result = v::intType()->positive()->validate($input);
16+
if ($result->hasFailed()) {
17+
echo $result->getMessage();
18+
}
19+
```
20+
21+
## Checking validity
22+
23+
### hasFailed()
24+
25+
Returns `true` if validation passed, `false` otherwise.
26+
27+
```php
28+
$result = v::email()->validate('user@example.com');
29+
$result->hasFailed(); // false
30+
31+
$result = v::email()->validate('not-an-email');
32+
$result->hasFailed(); // true
33+
```
34+
35+
## Retrieving messages
36+
37+
### getMessage()
38+
39+
Returns the first error message from the validation result. Returns an empty string if validation passed.
40+
41+
```php
42+
$result = v::intType()->validate('not an integer');
43+
44+
echo $result->getMessage();
45+
// → "not an integer" must be an integer
46+
```
47+
48+
### getFullMessage()
49+
50+
Returns a complete error tree showing all validation failures in a nested Markdown list format. Useful for debugging or displaying comprehensive error feedback.
51+
52+
```php
53+
$result = v::alnum()->lowercase()->validate('The Panda');
54+
55+
echo $result->getFullMessage();
56+
// → - "The Panda" must pass all the rules
57+
// → - "The Panda" must contain only letters (a-z) and digits (0-9)
58+
// → - "The Panda" must contain only lowercase letters
59+
```
60+
61+
### getMessages()
62+
63+
Returns all error messages as an associative array. Keys correspond to validator IDs or paths.
64+
65+
```php
66+
$result = v::alnum()->lowercase()->validate('The Panda');
67+
68+
print_r($result->getMessages());
69+
// Array
70+
// (
71+
// [__root__] => "The Panda" must pass all the rules
72+
// [alnum] => "The Panda" must contain only letters (a-z) and digits (0-9)
73+
// [lowercase] => "The Panda" must contain only lowercase letters
74+
// )
75+
```
76+
77+
For nested structures, keys reflect the path:
78+
79+
```php
80+
$result = v::init()
81+
->key('name', v::stringType())
82+
->key('age', v::intType())
83+
->validate(['name' => 123, 'age' => 'twenty']);
84+
85+
print_r($result->getMessages());
86+
// Array
87+
// (
88+
// [__root__] => `["name": 123, "age": "twenty"]` must pass all the rules
89+
// [name] => name must be a string
90+
// [age] => age must be an integer
91+
// )
92+
```
93+
94+
### String conversion
95+
96+
`ResultQuery` implements `Stringable`, so you can use it directly in string contexts. It returns the same value as `getMessage()`.
97+
98+
```php
99+
$result = v::email()->validate('invalid');
100+
echo $result; // "invalid" must be a valid email address
101+
```
102+
103+
## Querying nested results
104+
105+
When validating complex nested structures, `ResultQuery` provides methods to find and inspect specific parts of the validation result tree.
106+
107+
### Return values
108+
109+
All finder methods (`findByPath()`, `findByName()`, `findById()`) return either:
110+
- A new `ResultQuery` instance wrapping the found result
111+
- `null` if no matching result was found
112+
113+
This allows safe chaining with null checks:
114+
115+
```php
116+
$result = $validator->validate($input);
117+
118+
$nested = $result->findByPath('user.profile.email');
119+
if ($nested?->hasFailed()) {
120+
echo $nested->getMessage();
121+
}
122+
```
123+
124+
### findByPath()
125+
126+
Finds a result by its path through the data structure. Supports dot notation for nested paths.
127+
128+
```php
129+
$result = v::init()
130+
->key('user', v::key('email', v::email()))
131+
->validate(['user' => ['email' => 'invalid']]);
132+
133+
// Find the email validation result
134+
$emailResult = $result->findByPath('user.email');
135+
if ($emailResult?->hasFailed()) {
136+
echo $emailResult->getMessage();
137+
// → `.user.email` must be a valid email address
138+
}
139+
```
140+
141+
Paths can also be integers for array indices:
142+
143+
```php
144+
$result = v::init()
145+
->each(v::positive())
146+
->validate([10, -5, 20]);
147+
148+
// Find the result for index 1
149+
$itemResult = $result->findByPath(1);
150+
if ($itemResult?->hasFailed()) {
151+
echo $itemResult->getMessage();
152+
// → `.1` must be a positive number
153+
}
154+
```
155+
156+
Combined paths work too:
157+
158+
```php
159+
$result = v::init()
160+
->each(
161+
v::key('email', v::email()),
162+
)
163+
->validate([
164+
['email' => 'valid@example.com'],
165+
['email' => 'invalid'],
166+
]);
167+
168+
// Find the email of the second item
169+
$emailResult = $result->findByPath('1.email');
170+
if ($emailResult?->hasFailed()) {
171+
echo $emailResult->getMessage();
172+
// → `.1.email` must be a valid email address
173+
}
174+
```
175+
176+
### findByName()
177+
178+
Finds a result by a custom name assigned with the `Named` validator.
179+
180+
```php
181+
$result = v::named('User Email', v::email())->validate('invalid');
182+
183+
echo $result->findByName('User Email');
184+
// → User Email must be a valid email address
185+
```
186+
187+
This is useful when you need to locate results by semantic names rather than structural paths:
188+
189+
```php
190+
$result = v::init()
191+
->key(
192+
'contact',
193+
v::named('Primary Email', v::key('email', v::email())),
194+
)
195+
->validate(['contact' => ['email' => 'bad']]);
196+
197+
echo $result->findByName('Primary Email');
198+
// → `.contact.email` (<- Primary Email) must be a valid email address
199+
```
200+
201+
### findById()
202+
203+
Finds a result by validator ID. IDs are automatically generated from validator class names (e.g., `StringType` becomes `stringType`).
204+
205+
```php
206+
$result = v::stringType()->email()->validate(123);
207+
208+
echo $result->findById('stringType');
209+
// → 123 must be a string
210+
```
211+
212+
## Practical patterns
213+
214+
### Checking specific field validity
215+
216+
```php
217+
$result = v::init()
218+
->key('email', v::email())
219+
->key('age', v::intType()->positive())
220+
->validate($formData);
221+
222+
// Check if email specifically is valid
223+
$emailResult = $result->findByPath('email');
224+
if ($emailResult?->hasFailed()) {
225+
// Email failed validation
226+
}
227+
```
228+
229+
### Collecting errors for specific fields
230+
231+
```php
232+
$result = v::init()
233+
->key('username', v::alnum()->lengthBetween(3, 20))
234+
->key('password', v::lengthGreaterThanOrEqual(8))
235+
->validate($input);
236+
237+
$errors = [
238+
'username' => $result->findByPath('username')?->getMessage(),
239+
'password' => $result->findByPath('password')?->getMessage(),
240+
];
241+
```
242+
243+
### Validating arrays of items
244+
245+
```php
246+
$items = [
247+
['name' => 'Widget', 'price' => 10],
248+
['name' => 123, 'price' => -5],
249+
['name' => 'Gadget', 'price' => 20],
250+
];
251+
252+
$result = v::init()
253+
->each(
254+
v::init()
255+
->key('name', v::stringType())
256+
->key('price', v::positive())
257+
)
258+
->validate($items);
259+
260+
// Check each item individually
261+
for ($i = 0; $i < count($items); $i++) {
262+
$itemResult = $result->findByPath($i);
263+
if ($itemResult !== null && !$itemResult->hasFailed()) {
264+
echo "Item $i has errors: " . $itemResult->getMessage() . "\n";
265+
}
266+
}
267+
268+
// Or get a specific field from a specific item
269+
$priceResult = $result->findByPath('1.price');
270+
if ($priceResult !== null) {
271+
echo $priceResult->getMessage();
272+
// → `.1.price` must be a positive number
273+
}
274+
```
275+
276+
### Combining with custom templates
277+
278+
```php
279+
$result = v::init()
280+
->key('email', v::email())
281+
->key('age', v::intType())
282+
->validate($input, [
283+
'email' => 'Please provide a valid email address',
284+
'age' => 'Age must be a whole number',
285+
]);
286+
287+
$emailResult = $result->findByPath('email');
288+
if ($emailResult?->hasFailed()) {
289+
echo $emailResult->getMessage();
290+
// → Please provide a valid email address
291+
}
292+
```

0 commit comments

Comments
 (0)