Skip to content

Commit b2679e6

Browse files
committed
docs: add cross-item validation with field-level error paths
Document the nested error structure pattern for cross-item array validation (uniqueness, relationships) that enables field-level error paths like symlinks.2.destination. Added examples to array-validation, custom-validation, and error-handling guides. Updated planning docs with uniqueField() feature. Updated llms.txt for AI agent reference.
1 parent 14e8fb8 commit b2679e6

6 files changed

Lines changed: 272 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,15 @@ $stringValidator = Validator::isString()
262262
### Array Enhancements
263263
- [x] **`minItems()`** / **`maxItems()`** - Array length constraints
264264
- [x] **`contains()`** - Validates array contains specific item or item matching validator
265+
- [ ] **`uniqueField(string $fieldName, ?string $message = null)`** - Validates uniqueness of a specific field across array items with field-level error paths (e.g., `symlinks.2.destination`); convenience wrapper around `satisfies()` for common use case
265266

266267
### Array Transformations
267268
- [x] **`transform()`** / **`pipe()`** - Generic transformation methods (already implemented!)
268269
- [x] **`nullifyEmpty()`** - Convert empty arrays to null (already implemented!)
269270
- [x] **`filterEmpty()`** - Remove empty/null values and reindex automatically (already implemented!)
270271

271272
### Removed from Scope
272-
- ~~`unique()`~~ -- Use `array_unique()` or Laravel Collections (complex deduplication logic)
273+
- ~~`unique()`~~ -- Use `array_unique()` or Laravel Collections (complex deduplication logic). Note: `uniqueField()` validates uniqueness of a specific field across array items, which is different from deduplicating the array itself.
273274
- ~~`flatten()`~~ -- Complex recursive logic, use Laravel Collections or `array_merge_recursive()`
274275

275276
### Numeric Enhancements

TASKS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ Public repo; keep this list short (about 7) and up to date. Rules: no numbering
2222

2323
- Property-based tests
2424
- Add a small property-based test set for key validators (string patterns, numeric constraints) to harden edge cases and catch regressions.
25+
26+
- `uniqueField()` array validation method
27+
- Add `uniqueField(string $fieldName, ?string $message = null)` to ArrayValidator for validating uniqueness of nested fields across array items; convenience wrapper around `satisfies()` that automatically handles nested error structure for field-level error paths (e.g., `symlinks.2.destination`); common use case that deserves convenience method similar to Laravel's `distinct` rule.

docs/guides/array-validation.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,104 @@ try {
458458
}
459459
```
460460

461+
## Cross-Item Validation
462+
463+
For validations that need to check relationships across multiple array items (like uniqueness), use `satisfies()` on the array validator. To attach errors to specific fields within items, use a nested error structure.
464+
465+
### Uniqueness Validation with Field-Level Errors
466+
467+
When validating uniqueness of a nested field (e.g., `destination` in each symlink), structure errors to attach them to the specific field:
468+
469+
```php
470+
$schema = Validator::isAssociative([
471+
'symlinks' => Validator::isArray()
472+
->items(Validator::isAssociative([
473+
'source' => Validator::isString()->default('.'),
474+
'destination' => Validator::isString()->required(),
475+
]))
476+
->satisfies(
477+
function ($symlinks, $key, $input) {
478+
// Extract all destination values with their indices
479+
$destinations = [];
480+
foreach ($symlinks as $index => $symlink) {
481+
if (isset($symlink['destination'])) {
482+
$dest = $symlink['destination'];
483+
if (!isset($destinations[$dest])) {
484+
$destinations[$dest] = [];
485+
}
486+
$destinations[$dest][] = $index;
487+
}
488+
}
489+
490+
// Check for duplicates
491+
$duplicates = [];
492+
foreach ($destinations as $dest => $indices) {
493+
if (count($indices) > 1) {
494+
$duplicates[$dest] = $indices;
495+
}
496+
}
497+
498+
if (empty($duplicates)) {
499+
return true;
500+
}
501+
502+
// Nested error structure: [index => [field => [message]]]
503+
// This creates field-level error paths: 'symlinks.2.destination'
504+
$errors = [];
505+
foreach ($duplicates as $dest => $indices) {
506+
// Mark all but the first occurrence as duplicates
507+
foreach (array_slice($indices, 1) as $idx) {
508+
$errors[$idx] = [
509+
'destination' => [
510+
"Destination '{$dest}' is not unique (also used at index {$indices[0]})"
511+
]
512+
];
513+
}
514+
}
515+
516+
throw new ValidationException($errors);
517+
},
518+
'All destinations must be unique'
519+
)
520+
->required(),
521+
]);
522+
523+
$input = [
524+
'symlinks' => [
525+
['source' => 'path1', 'destination' => '/same/path'],
526+
['source' => 'path2', 'destination' => '/unique/path'],
527+
['source' => 'path3', 'destination' => '/same/path'], // Duplicate
528+
],
529+
];
530+
531+
try {
532+
$schema->validate($input);
533+
} catch (ValidationException $e) {
534+
$flattened = $e->getFlattenedErrors();
535+
// [
536+
// ['path' => 'symlinks.2.destination', 'message' => "Destination '/same/path' is not unique (also used at index 0)"]
537+
// ]
538+
}
539+
```
540+
541+
**Key Pattern:** Structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level error paths. The flattening logic automatically builds paths like `symlinks.2.destination` from this nested structure.
542+
543+
### Simple Uniqueness Check (Array-Level Error)
544+
545+
If you don't need field-level errors, you can use a simpler approach that returns a single error at the array level:
546+
547+
```php
548+
->satisfies(
549+
function ($items) {
550+
$destinations = array_column($items, 'destination');
551+
return count($destinations) === count(array_unique($destinations));
552+
},
553+
'All destination values must be unique'
554+
)
555+
```
556+
557+
This will produce an error at `symlinks` rather than `symlinks.2.destination`.
558+
461559
## Advanced Examples
462560

463561
### File Upload Validation

docs/guides/custom-validation.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,107 @@ $validator = Validator::isString()
288288
- Validators that frequently receive specification updates or new variants
289289
- Production applications requiring comprehensive validation features
290290

291+
## Cross-Item Array Validation
292+
293+
For validations that need to check relationships across multiple array items (like uniqueness, ordering, or dependencies), use `satisfies()` on the array validator. This runs **after** item validation, so you receive the validated data structure.
294+
295+
### Uniqueness Validation with Field-Level Errors
296+
297+
When validating uniqueness of a nested field across array items, structure errors to attach them to specific fields within items:
298+
299+
```php
300+
$schema = Validator::isAssociative([
301+
'items' => Validator::isArray()
302+
->items(Validator::isAssociative([
303+
'id' => Validator::isInt()->required(),
304+
'name' => Validator::isString()->required(),
305+
]))
306+
->satisfies(
307+
function ($items, $key, $input) {
308+
// Check uniqueness of 'id' field
309+
$ids = [];
310+
foreach ($items as $index => $item) {
311+
if (isset($item['id'])) {
312+
$id = $item['id'];
313+
$ids[$id][] = $index;
314+
}
315+
}
316+
317+
$duplicates = [];
318+
foreach ($ids as $id => $indices) {
319+
if (count($indices) > 1) {
320+
$duplicates[$id] = $indices;
321+
}
322+
}
323+
324+
if (empty($duplicates)) {
325+
return true;
326+
}
327+
328+
// Nested error structure: [index => [field => [message]]]
329+
// This creates field-level error paths: 'items.2.id'
330+
$errors = [];
331+
foreach ($duplicates as $id => $indices) {
332+
foreach (array_slice($indices, 1) as $idx) {
333+
$errors[$idx] = [
334+
'id' => ["ID {$id} is not unique (also used at index {$indices[0]})"]
335+
];
336+
}
337+
}
338+
339+
throw new ValidationException($errors);
340+
}
341+
)
342+
]);
343+
344+
$input = [
345+
'items' => [
346+
['id' => 1, 'name' => 'First'],
347+
['id' => 2, 'name' => 'Second'],
348+
['id' => 1, 'name' => 'Duplicate'], // Duplicate ID
349+
],
350+
];
351+
352+
try {
353+
$schema->validate($input);
354+
} catch (ValidationException $e) {
355+
$flattened = $e->getFlattenedErrors();
356+
// [
357+
// ['path' => 'items.2.id', 'message' => 'ID 1 is not unique (also used at index 0)']
358+
// ]
359+
}
360+
```
361+
362+
**Key Pattern:** Structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level error paths. The error flattening logic automatically builds paths like `items.2.id` from this nested structure.
363+
364+
### Other Cross-Item Validations
365+
366+
This pattern works for any cross-item validation:
367+
368+
```php
369+
// Validate that items are in ascending order by 'order' field
370+
->satisfies(
371+
function ($items) {
372+
$orders = array_column($items, 'order');
373+
$sorted = $orders;
374+
sort($sorted);
375+
return $orders === $sorted;
376+
},
377+
'Items must be in ascending order'
378+
)
379+
380+
// Validate that total doesn't exceed limit
381+
->satisfies(
382+
function ($items, $key, $input) {
383+
$total = array_sum(array_column($items, 'amount'));
384+
return $total <= ($input['limit'] ?? 1000);
385+
},
386+
'Total amount exceeds limit'
387+
)
388+
```
389+
390+
## External Library Integration
391+
291392
```php
292393
// UUID validation with ramsey/uuid (recommended for production)
293394
use Ramsey\Uuid\Uuid;

docs/guides/error-handling.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ $invalidData = [
202202

203203
For arrays with item validators, errors preserve array indices to identify which item failed:
204204

205+
**Standard Item Errors:**
206+
Errors from item validation automatically preserve indices:
207+
205208
```php
206209
$schema = Validator::isAssociative([
207210
'items' => Validator::isArray()->items(Validator::isInt()->min(1)),
@@ -253,10 +256,73 @@ try {
253256
// [
254257
// ['path' => 'users.0.email', 'message' => 'Value is required'],
255258
// ['path' => 'users.1.email', 'message' => 'Value must be a valid email address']
259+
// ]
260+
}
261+
```
262+
263+
**Cross-Item Validation Errors (Field-Level):**
264+
265+
For cross-item validations (like uniqueness) that need to attach errors to specific fields within items, use nested error structure:
266+
267+
```php
268+
$schema = Validator::isAssociative([
269+
'symlinks' => Validator::isArray()
270+
->items(Validator::isAssociative([
271+
'destination' => Validator::isString()->required(),
272+
]))
273+
->satisfies(
274+
function ($symlinks) {
275+
// Check uniqueness
276+
$destinations = [];
277+
foreach ($symlinks as $index => $item) {
278+
$dest = $item['destination'] ?? null;
279+
if ($dest) {
280+
$destinations[$dest][] = $index;
281+
}
282+
}
283+
284+
$duplicates = [];
285+
foreach ($destinations as $dest => $indices) {
286+
if (count($indices) > 1) {
287+
$duplicates[$dest] = $indices;
288+
}
289+
}
290+
291+
if (empty($duplicates)) return true;
292+
293+
// Nested structure: [index => [field => [message]]]
294+
// Flattens to 'symlinks.2.destination'
295+
$errors = [];
296+
foreach ($duplicates as $dest => $indices) {
297+
foreach (array_slice($indices, 1) as $idx) {
298+
$errors[$idx] = [
299+
'destination' => ["'{$dest}' is not unique"]
300+
];
301+
}
302+
}
303+
throw new ValidationException($errors);
304+
}
305+
)
306+
]);
307+
308+
try {
309+
$schema->validate([
310+
'symlinks' => [
311+
['destination' => '/path1'],
312+
['destination' => '/path2'],
313+
['destination' => '/path1'], // Duplicate
314+
],
315+
]);
316+
} catch (ValidationException $e) {
317+
$flattened = $e->getFlattenedErrors();
318+
// [
319+
// ['path' => 'symlinks.2.destination', 'message' => "'/path1' is not unique"]
256320
// ]
257321
}
258322
```
259323

324+
**Key Pattern:** Structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level error paths in flattened output.
325+
260326
## Error Message Customization
261327

262328
### Built-in Validator Messages

llms.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ maxItems(int $max, ?string $message = null): static
115115
contains(mixed|FieldValidator $valueOrValidator, ?string $message = null): static
116116
```
117117

118+
**Cross-Item Validation:** Use `satisfies()` on array validator for uniqueness/relationships. Structure errors as `[index => [field => [message]]]` to get field-level paths (e.g., `symlinks.2.destination`). Runs after item validation, receives validated data structure.
119+
118120
### AssociativeValidator / ObjectValidator
119121
*Schema validation*
120122
```php

0 commit comments

Comments
 (0)