Skip to content

Commit 55249d9

Browse files
committed
fix(array-validator): preserve array indices in flattened error messages
Fixed ArrayValidator to collect all item validation errors with proper indexing. Previously, array indices were lost in error paths (e.g., 'symlinks.destination' instead of 'symlinks.0.destination') and only the first error was collected. - Changed from validate() to tryValidate() for item validation - Collect all errors with proper array index keys - Updated tests to reflect correct behavior - Updated documentation with examples showing preserved indices
1 parent e1bb888 commit 55249d9

5 files changed

Lines changed: 148 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Fixed
8+
- **CRITICAL BUG**: Fixed array validator error handling to preserve array indices in flattened error messages
9+
- **Issue**: Array item validation errors lost array index information, showing paths like `symlinks.destination` instead of `symlinks.0.destination`
10+
- **Root Cause**: `ArrayValidator` used `validate()` which threw immediately on first error, losing index context and preventing error collection
11+
- **Fix**: Changed to use `tryValidate()` for all items, collecting errors with proper indexing (similar to `AssociativeValidator` behavior)
12+
- **Impact**: Flattened error messages now correctly identify which array item has validation errors (e.g., `symlinks.0.destination`, `symlinks.2.destination`)
13+
- **Behavior**: All array item errors are now collected (not just the first), and indices are preserved in error paths
14+
- **Real-World Benefit**: API error responses and form validation can now accurately pinpoint which array element failed validation
15+
716
## [0.11.1] - 2025-12-28
817

918
### Fixed

docs/guides/array-validation.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,18 @@ $validator = Validator::isArray()->items(Validator::isString());
387387
try {
388388
$validator->validate(['valid', 123, 'also valid']);
389389
} catch (ValidationException $e) {
390-
// Error will indicate which item failed validation
390+
// Errors preserve array indices to identify which item failed
391391
print_r($e->getErrors());
392+
// Output:
393+
// [
394+
// '1' => ['Value must be a string']
395+
// ]
396+
397+
// Flattened errors show full path with index
398+
$flattened = $e->getFlattenedErrors();
399+
// [
400+
// ['path' => '1', 'message' => 'Value must be a string']
401+
// ]
392402
}
393403
```
394404

@@ -402,12 +412,52 @@ $validator = Validator::isArray()->items(Validator::isInt());
402412
if (!$valid) {
403413
echo "Validation failed:\n";
404414
print_r($errors);
415+
// Output:
416+
// [
417+
// '1' => ['Value must be an integer']
418+
// ]
419+
420+
// Flattened errors preserve array indices
421+
$flattened = ValidationException::flattenErrors($errors);
422+
// [
423+
// ['path' => '1', 'message' => 'Value must be an integer']
424+
// ]
405425
} else {
406426
echo "Valid array:\n";
407427
print_r($result);
408428
}
409429
```
410430

431+
### Nested Array Item Errors
432+
433+
For arrays with complex item validators (like associative arrays), errors preserve the full path including array indices:
434+
435+
```php
436+
$schema = Validator::isAssociative([
437+
'users' => Validator::isArray()->items(Validator::isAssociative([
438+
'name' => Validator::isString()->required(),
439+
'email' => Validator::isString()->email()->required(),
440+
])),
441+
]);
442+
443+
$input = [
444+
'users' => [
445+
['name' => 'John'], // Missing email
446+
['name' => 'Jane', 'email' => 'invalid-email'], // Invalid email
447+
],
448+
];
449+
450+
try {
451+
$schema->validate($input);
452+
} catch (ValidationException $e) {
453+
$flattened = $e->getFlattenedErrors();
454+
// [
455+
// ['path' => 'users.0.email', 'message' => 'Value is required'],
456+
// ['path' => 'users.1.email', 'message' => 'Value must be a valid email address']
457+
// ]
458+
}
459+
```
460+
411461
## Advanced Examples
412462

413463
### File Upload Validation

docs/guides/error-handling.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,65 @@ $invalidData = [
198198
// ]
199199
```
200200

201+
### Array Item Validation Errors
202+
203+
For arrays with item validators, errors preserve array indices to identify which item failed:
204+
205+
```php
206+
$schema = Validator::isAssociative([
207+
'items' => Validator::isArray()->items(Validator::isInt()->min(1)),
208+
]);
209+
210+
$input = [
211+
'items' => [5, -2, 0, 10], // Items at index 1 and 2 are invalid
212+
];
213+
214+
[$valid, $data, $errors] = $schema->tryValidate($input);
215+
216+
// $errors structure preserves array indices:
217+
// [
218+
// 'items' => [
219+
// '1' => ['Value must be at least 1'],
220+
// '2' => ['Value must be at least 1']
221+
// ]
222+
// ]
223+
224+
// Flattened errors show full paths with indices:
225+
$flattened = ValidationException::flattenErrors($errors);
226+
// [
227+
// ['path' => 'items.1', 'message' => 'Value must be at least 1'],
228+
// ['path' => 'items.2', 'message' => 'Value must be at least 1']
229+
// ]
230+
```
231+
232+
For nested structures with array items, the full path including indices is preserved:
233+
234+
```php
235+
$schema = Validator::isAssociative([
236+
'users' => Validator::isArray()->items(Validator::isAssociative([
237+
'name' => Validator::isString()->required(),
238+
'email' => Validator::isString()->email()->required(),
239+
])),
240+
]);
241+
242+
$input = [
243+
'users' => [
244+
['name' => 'John'], // Missing email at index 0
245+
['name' => 'Jane', 'email' => 'invalid'], // Invalid email at index 1
246+
],
247+
];
248+
249+
try {
250+
$schema->validate($input);
251+
} catch (ValidationException $e) {
252+
$flattened = $e->getFlattenedErrors();
253+
// [
254+
// ['path' => 'users.0.email', 'message' => 'Value is required'],
255+
// ['path' => 'users.1.email', 'message' => 'Value must be a valid email address']
256+
// ]
257+
}
258+
```
259+
201260
## Error Message Customization
202261

203262
### Built-in Validator Messages

src/Lemmon/Validator/ArrayValidator.php

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,29 @@ protected function validateType(mixed $value, string $key): mixed
153153
// If item validator is set, validate each item
154154
if ($this->itemValidator !== null) {
155155
$validatedItems = [];
156+
$errors = [];
157+
156158
foreach ($value as $index => $item) {
157-
try {
158-
$itemKey = ($key ? $key . '.' : '') . $index;
159-
$validatedItems[] = $this->itemValidator->validate($item, $itemKey, []);
160-
} catch (ValidationException $e) {
161-
throw $e;
159+
$itemKey = ($key ? $key . '.' : '') . $index;
160+
[$valid, $validatedItem, $itemErrors] = $this->itemValidator->tryValidate(
161+
$item,
162+
(string) $index,
163+
$value,
164+
);
165+
166+
if (!$valid) {
167+
// Wrap item errors under the index key
168+
$errors[$index] = $itemErrors;
169+
continue;
162170
}
171+
172+
$validatedItems[] = $validatedItem;
163173
}
174+
175+
if ($errors !== []) {
176+
throw new ValidationException($errors);
177+
}
178+
164179
return $validatedItems;
165180
}
166181

tests/ValidationExceptionTest.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,10 @@
115115
$schema->validate($input);
116116
} catch (ValidationException $e) {
117117
$flattened = $e->getFlattenedErrors();
118-
// Note: Currently array validation throws on first error
119-
// The error is stored at 'items' key, not 'items.1'
118+
// Array validation collects all errors with proper indices
120119
expect($flattened)->toBe([
121-
['path' => 'items', 'message' => 'Value must be at least 1'],
120+
['path' => 'items.1', 'message' => 'Value must be at least 1'],
121+
['path' => 'items.2', 'message' => 'Value must be at least 1'],
122122
]);
123123
}
124124
});
@@ -141,11 +141,11 @@
141141
$schema->validate($input);
142142
} catch (ValidationException $e) {
143143
$flattened = $e->getFlattenedErrors();
144-
// Note: Currently array item errors lose the index in the structure
145-
// The error is stored as ['users' => ['email' => ['error']]]
146-
// So the flattened path is 'users.email' not 'users.0.email'
144+
// Array item errors preserve the index in the structure
145+
// The error is stored as ['users' => ['0' => ['email' => ['error']]]]
146+
// So the flattened path is 'users.0.email'
147147
expect($flattened)->toBe([
148-
['path' => 'users.email', 'message' => 'Value is required'],
148+
['path' => 'users.0.email', 'message' => 'Value is required'],
149149
]);
150150
}
151151
});
@@ -199,8 +199,8 @@
199199
expect($paths)->toContain('title');
200200
expect($paths)->toContain('author.name');
201201
expect($paths)->toContain('author.contact.email');
202-
// Array item errors will have path 'tags' (index lost in current implementation)
203-
expect($paths)->toContain('tags');
202+
// Array item errors preserve the index, so path is 'tags.0'
203+
expect($paths)->toContain('tags.0');
204204
}
205205
});
206206

0 commit comments

Comments
 (0)