Skip to content

Commit 4581b5e

Browse files
committed
feat(ArrayValidator): add uniqueField() for cross-item uniqueness validation
- Add uniqueField(string $fieldName, ?string $message = null) convenience method - Produces field-level error paths (e.g., symlinks.2.destination) via nested error structure - Skip items where field is null or missing; use serialize() for strict type comparison - Fix json_encode false fallback for non-encodable values; remove dead-code satisfies fallback - Add edge-case tests: empty array, 3+ duplicates, scalar items, filterEmpty combo - Update ROADMAP with message placeholders idea
1 parent fa95c31 commit 4581b5e

10 files changed

Lines changed: 389 additions & 134 deletions

File tree

CHANGELOG.md

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

55
## [Unreleased]
66

7+
### Added
8+
9+
- `uniqueField(string $fieldName, ?string $message = null)` on `ArrayValidator` for cross-item uniqueness validation of a nested field; convenience wrapper around `satisfies()` that produces field-level error paths (e.g., `symlinks.2.destination`) by structuring errors as `[index => [fieldName => [message]]]`; skips items where the field is null or missing; works with both associative arrays and objects; uses strict comparison via `serialize()` to distinguish types
10+
711
## [0.13.0] - 2026-03-06
812

913
### Added

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ Focused on core validation primitives, consistent pipelines, and extensibility v
1616
- [x] Universal allowed-value validators: `const()` (single allowed value), `enum()` (PHP BackedEnum)
1717
- [x] Schema output key remapping: `outputKey(string $key)`
1818
- [ ] Structured error codes in validation errors (backward compatible)
19-
- [ ] ArrayValidator `uniqueField(string $fieldName, ?string $message = null)`
19+
- [ ] Message placeholders for custom error messages (e.g., `{value}`, `{index}`) -- pair with error codes
20+
- [x] ArrayValidator `uniqueField(string $fieldName, ?string $message = null)`
2021
- [ ] Mutation testing pilot (Infection + baseline)
2122
- [ ] Property-based tests for core validators
2223

TASKS.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
# Task Pool (next steps)
22

3-
Public repo; keep this list short (about 7) and up to date. Rules: no numbering (keeps churn low), prune completed items, and replace them with the next priority. Contributors: pick one task, keep PRs focused, and update the list as things land.
3+
Public repo; keep this list short (about 7) and up to date. No numbering. Prune completed items and replace with the next priority. Contributors: pick one task, keep PRs focused, update the list as things land.
44

55
- Structured error codes
6-
- Add programmatic error codes to validation errors (e.g., 'STRING_TOO_SHORT', 'INVALID_EMAIL') for better error handling and i18n support; keep backward compatibility.
6+
7+
Add programmatic error codes to validation errors (e.g., STRING_TOO_SHORT, INVALID_EMAIL) for better error handling and i18n support; keep backward compatibility.
78

89
- Mutation testing pilot
9-
- Wire Infection (or similar) to the test suite, add baseline config, and document how to run it locally for enhanced test quality verification.
10+
11+
Wire Infection (or similar) to the test suite, add baseline config, and document how to run it locally for enhanced test quality verification.
1012

1113
- Property-based tests
12-
- Add a small property-based test set for key validators (string patterns, numeric constraints) to harden edge cases and catch regressions.
1314

14-
- `uniqueField()` array validation method
15-
- 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.
15+
Add a small property-based test set for key validators (string patterns, numeric constraints) to harden edge cases and catch regressions.
16+
17+
- Deprecation cleanup
18+
19+
Remove or finalize deprecated methods (addValidation, allOf, anyOf, not) before v1.0; update call sites and docs.
20+
21+
- forbidKeys
22+
23+
Explicit key deny-listing on AssociativeValidator/ObjectValidator: forbidKeys(array $keys, ?string $message = null).
24+
25+
- Performance benchmarking
26+
27+
Establish baseline for hot paths (validate, tryValidate, schema validation); document how to run and interpret results.
28+
29+
- Docs refresh
30+
31+
Update examples, ensure API reference matches current surface, add migration notes where relevant.

docs/api-reference/validator-factory.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,48 @@ $validator->validate([]); // Throws ValidationException
573573

574574
---
575575

576+
#### `uniqueField(string $fieldName, ?string $message = null): ArrayValidator`
577+
578+
Validates that a nested field is unique across all array items. Produces field-level error paths (e.g., `symlinks.2.destination`) automatically. Items where the field is null or missing are skipped. Works with associative arrays and objects.
579+
580+
```php
581+
$schema = Validator::isAssociative([
582+
'symlinks' => Validator::isArray()
583+
->items(Validator::isAssociative([
584+
'source' => Validator::isString()->default('.'),
585+
'destination' => Validator::isString()->required(),
586+
]))
587+
->uniqueField('destination')
588+
->required(),
589+
]);
590+
591+
$result = $schema->validate([
592+
'symlinks' => [
593+
['destination' => '/var/www/public'],
594+
['destination' => '/var/www/storage'],
595+
],
596+
]); // Valid
597+
598+
// Duplicate destination throws ValidationException with path 'symlinks.2.destination'
599+
$schema->validate([
600+
'symlinks' => [
601+
['destination' => '/same'],
602+
['destination' => '/same'],
603+
],
604+
]);
605+
```
606+
607+
**Parameters:**
608+
609+
- `$fieldName`: The field name to check for uniqueness
610+
- `$message` (optional): Custom error message for duplicate values
611+
612+
**Returns:** `ArrayValidator` instance with unique-field validation enabled.
613+
614+
**See Also:** [Array Validation Guide - Cross-Item Validation](../guides/array-validation.md#cross-item-validation)
615+
616+
---
617+
576618
## Universal Methods
577619

578620
All validators created by the factory methods inherit these universal methods from `FieldValidator`:

docs/guides/array-validation.md

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -477,11 +477,9 @@ try {
477477

478478
## Cross-Item Validation
479479

480-
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.
480+
### uniqueField() for Uniqueness
481481

482-
### Uniqueness Validation with Field-Level Errors
483-
484-
When validating uniqueness of a nested field (e.g., `destination` in each symlink), structure errors to attach them to the specific field:
482+
Use `uniqueField()` to validate that a nested field is unique across all array items. It produces field-level error paths (e.g., `symlinks.2.destination`) automatically. Items where the field is null or missing are skipped.
485483

486484
```php
487485
$schema = Validator::isAssociative([
@@ -490,50 +488,7 @@ $schema = Validator::isAssociative([
490488
'source' => Validator::isString()->default('.'),
491489
'destination' => Validator::isString()->required(),
492490
]))
493-
->satisfies(
494-
function ($symlinks, $key, $input) {
495-
// Extract all destination values with their indices
496-
$destinations = [];
497-
foreach ($symlinks as $index => $symlink) {
498-
if (isset($symlink['destination'])) {
499-
$dest = $symlink['destination'];
500-
if (!isset($destinations[$dest])) {
501-
$destinations[$dest] = [];
502-
}
503-
$destinations[$dest][] = $index;
504-
}
505-
}
506-
507-
// Check for duplicates
508-
$duplicates = [];
509-
foreach ($destinations as $dest => $indices) {
510-
if (count($indices) > 1) {
511-
$duplicates[$dest] = $indices;
512-
}
513-
}
514-
515-
if (empty($duplicates)) {
516-
return true;
517-
}
518-
519-
// Nested error structure: [index => [field => [message]]]
520-
// This creates field-level error paths: 'symlinks.2.destination'
521-
$errors = [];
522-
foreach ($duplicates as $dest => $indices) {
523-
// Mark all but the first occurrence as duplicates
524-
foreach (array_slice($indices, 1) as $idx) {
525-
$errors[$idx] = [
526-
'destination' => [
527-
"Destination '{$dest}' is not unique (also used at index {$indices[0]})"
528-
]
529-
];
530-
}
531-
}
532-
533-
throw new ValidationException($errors);
534-
},
535-
'All destinations must be unique'
536-
)
491+
->uniqueField('destination')
537492
->required(),
538493
]);
539494

@@ -550,12 +505,20 @@ try {
550505
} catch (ValidationException $e) {
551506
$flattened = $e->getFlattenedErrors();
552507
// [
553-
// ['path' => 'symlinks.2.destination', 'message' => "Destination '/same/path' is not unique (also used at index 0)"]
508+
// ['path' => 'symlinks.2.destination', 'message' => "Value '/same/path' is not unique (also used at index 0)"]
554509
// ]
555510
}
556511
```
557512

558-
**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.
513+
Custom message:
514+
515+
```php
516+
->uniqueField('destination', 'Destination must be unique across all symlinks')
517+
```
518+
519+
### Custom Cross-Item Validation with satisfies()
520+
521+
For validations beyond uniqueness (ordering, dependencies, custom logic), use `satisfies()` on the array validator. Structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level paths.
559522

560523
### Simple Uniqueness Check (Array-Level Error)
561524

docs/guides/custom-validation.md

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,9 @@ $validator = Validator::isString()
294294

295295
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.
296296

297-
### Uniqueness Validation with Field-Level Errors
297+
### Uniqueness: uniqueField() (Convenience Method)
298298

299-
When validating uniqueness of a nested field across array items, structure errors to attach them to specific fields within items:
299+
For simple field uniqueness across items, use `uniqueField()`:
300300

301301
```php
302302
$schema = Validator::isAssociative([
@@ -305,42 +305,7 @@ $schema = Validator::isAssociative([
305305
'id' => Validator::isInt()->required(),
306306
'name' => Validator::isString()->required(),
307307
]))
308-
->satisfies(
309-
function ($items, $key, $input) {
310-
// Check uniqueness of 'id' field
311-
$ids = [];
312-
foreach ($items as $index => $item) {
313-
if (isset($item['id'])) {
314-
$id = $item['id'];
315-
$ids[$id][] = $index;
316-
}
317-
}
318-
319-
$duplicates = [];
320-
foreach ($ids as $id => $indices) {
321-
if (count($indices) > 1) {
322-
$duplicates[$id] = $indices;
323-
}
324-
}
325-
326-
if (empty($duplicates)) {
327-
return true;
328-
}
329-
330-
// Nested error structure: [index => [field => [message]]]
331-
// This creates field-level error paths: 'items.2.id'
332-
$errors = [];
333-
foreach ($duplicates as $id => $indices) {
334-
foreach (array_slice($indices, 1) as $idx) {
335-
$errors[$idx] = [
336-
'id' => ["ID {$id} is not unique (also used at index {$indices[0]})"]
337-
];
338-
}
339-
}
340-
341-
throw new ValidationException($errors);
342-
}
343-
)
308+
->uniqueField('id'),
344309
]);
345310

346311
$input = [
@@ -356,12 +321,12 @@ try {
356321
} catch (ValidationException $e) {
357322
$flattened = $e->getFlattenedErrors();
358323
// [
359-
// ['path' => 'items.2.id', 'message' => 'ID 1 is not unique (also used at index 0)']
324+
// ['path' => 'items.2.id', 'message' => "Value '1' is not unique (also used at index 0)"]
360325
// ]
361326
}
362327
```
363328

364-
**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.
329+
Use `satisfies()` when you need custom duplicate logic, custom messages per value, or cross-field uniqueness. See the [Array Validation Guide](array-validation.md#custom-cross-item-validation-with-satisfies) for the manual `satisfies()` pattern.
365330

366331
### Other Cross-Item Validations
367332

docs/guides/error-handling.md

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -261,47 +261,15 @@ try {
261261

262262
**Cross-Item Validation Errors (Field-Level):**
263263

264-
For cross-item validations (like uniqueness) that need to attach errors to specific fields within items, use nested error structure:
264+
For uniqueness of a nested field, use `uniqueField()` -- it produces the nested error structure and field-level paths automatically:
265265

266266
```php
267267
$schema = Validator::isAssociative([
268268
'symlinks' => Validator::isArray()
269269
->items(Validator::isAssociative([
270270
'destination' => Validator::isString()->required(),
271271
]))
272-
->satisfies(
273-
function ($symlinks) {
274-
// Check uniqueness
275-
$destinations = [];
276-
foreach ($symlinks as $index => $item) {
277-
$dest = $item['destination'] ?? null;
278-
if ($dest) {
279-
$destinations[$dest][] = $index;
280-
}
281-
}
282-
283-
$duplicates = [];
284-
foreach ($destinations as $dest => $indices) {
285-
if (count($indices) > 1) {
286-
$duplicates[$dest] = $indices;
287-
}
288-
}
289-
290-
if (empty($duplicates)) return true;
291-
292-
// Nested structure: [index => [field => [message]]]
293-
// Flattens to 'symlinks.2.destination'
294-
$errors = [];
295-
foreach ($duplicates as $dest => $indices) {
296-
foreach (array_slice($indices, 1) as $idx) {
297-
$errors[$idx] = [
298-
'destination' => ["'{$dest}' is not unique"]
299-
];
300-
}
301-
}
302-
throw new ValidationException($errors);
303-
}
304-
)
272+
->uniqueField('destination'),
305273
]);
306274

307275
try {
@@ -315,12 +283,12 @@ try {
315283
} catch (ValidationException $e) {
316284
$flattened = $e->getFlattenedErrors();
317285
// [
318-
// ['path' => 'symlinks.2.destination', 'message' => "'/path1' is not unique"]
286+
// ['path' => 'symlinks.2.destination', 'message' => "Value '/path1' is not unique (also used at index 0)"]
319287
// ]
320288
}
321289
```
322290

323-
**Key Pattern:** Structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level error paths in flattened output.
291+
For custom cross-item logic, use `satisfies()` and structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level paths in flattened output.
324292

325293
## Error Message Customization
326294

llms.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ notEmpty(?string $message = null): static
136136
minItems(int $min, ?string $message = null): static
137137
maxItems(int $max, ?string $message = null): static
138138
contains(mixed|FieldValidator $valueOrValidator, ?string $message = null): static
139+
uniqueField(string $fieldName, ?string $message = null): static
139140
```
140141

141-
**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.
142+
**Cross-Item Validation:** `uniqueField()` validates a nested field is unique across items with field-level error paths (e.g., `symlinks.2.destination`). For custom cross-item logic, use `satisfies()` directly -- structure errors as `[index => [field => [message]]]` for field-level paths. Both run after item validation and receive validated data.
142143

143144
### AssociativeValidator / ObjectValidator
144145

0 commit comments

Comments
 (0)