diff --git a/src/Validation/FieldValidator.php b/src/Validation/FieldValidator.php index b4ca2a1..a147bb4 100644 --- a/src/Validation/FieldValidator.php +++ b/src/Validation/FieldValidator.php @@ -220,59 +220,28 @@ public function exists( */ public function validate(array $data): array { - $present = array_key_exists($this->field, $data); - $value = $present ? $data[$this->field] : null; + $resolved = $this->resolveValues($data); - if (!$present) { + if ($resolved === []) { return $this->required - ? [$this->formatMessage( - $this->requiredMessage, - sprintf('The %s field is required.', $this->field), - null, - $this->field, - )] + ? [$this->field => [ + $this->formatMessage( + $this->requiredMessage, + sprintf('The %s field is required.', $this->field), + null, + $this->field, + ), + ]] : []; } - if ($value === null) { - if ($this->nullable) { - return []; - } - - return $this->required - ? [$this->formatMessage( - $this->requiredMessage, - sprintf('The %s field is required.', $this->field), - $value, - $this->field, - )] - : []; - } - - if ($this->required && is_string($value) && trim($value) === '') { - return [$this->formatMessage( - $this->requiredMessage, - sprintf('The %s field is required.', $this->field), - $value, - $this->field, - )]; - } - - if ($this->required && is_array($value) && $value === []) { - return [$this->formatMessage( - $this->requiredMessage, - sprintf('The %s field is required.', $this->field), - $value, - $this->field, - )]; - } - $errors = []; - foreach ($this->rules as $rule) { - $error = $rule($value, $this->field); - if (is_string($error)) { - $errors[] = $error; + foreach ($resolved as $field => $value) { + $fieldErrors = $this->validateValue($value, $field); + + if ($fieldErrors !== []) { + $errors[$field] = $fieldErrors; } } @@ -280,11 +249,13 @@ public function validate(array $data): array } /** - * Determine whether this field should be included in validated output. + * Return the resolved values that should be included in validated output. + * + * @return array */ - public function shouldInclude(array $data): bool + public function validatedValues(array $data): array { - return array_key_exists($this->field, $data); + return $this->resolveValues($data); } private function valueExists(mixed $value, string|array|callable $source, ?string $column): bool @@ -342,6 +313,105 @@ private function formatMessage( return $default; } + /** + * @return array + */ + private function resolveValues(array $data): array + { + return $this->resolveSegments($data, explode('.', $this->field)); + } + + /** + * @param list $segments + * @return array + */ + private function resolveSegments(mixed $value, array $segments, string $path = ''): array + { + if ($segments === []) { + return [$path => $value]; + } + + $segment = array_shift($segments); + + if ($segment === '*') { + if (!is_array($value)) { + return []; + } + + $resolved = []; + + foreach ($value as $key => $item) { + $resolved = array_replace( + $resolved, + $this->resolveSegments($item, $segments, $this->appendPath($path, (string) $key)), + ); + } + + return $resolved; + } + + if (!is_array($value) || !array_key_exists($segment, $value)) { + return []; + } + + return $this->resolveSegments($value[$segment], $segments, $this->appendPath($path, $segment)); + } + + /** + * @return list + */ + private function validateValue(mixed $value, string $field): array + { + if ($value === null) { + if ($this->nullable) { + return []; + } + + return $this->required + ? [$this->formatMessage( + $this->requiredMessage, + sprintf('The %s field is required.', $field), + $value, + $field, + )] + : []; + } + + if ($this->required && is_string($value) && trim($value) === '') { + return [$this->formatMessage( + $this->requiredMessage, + sprintf('The %s field is required.', $field), + $value, + $field, + )]; + } + + if ($this->required && is_array($value) && $value === []) { + return [$this->formatMessage( + $this->requiredMessage, + sprintf('The %s field is required.', $field), + $value, + $field, + )]; + } + + $errors = []; + + foreach ($this->rules as $rule) { + $error = $rule($value, $field); + if (is_string($error)) { + $errors[] = $error; + } + } + + return $errors; + } + + private function appendPath(string $path, string $segment): string + { + return $path === '' ? $segment : $path . '.' . $segment; + } + private static function sizeOf(mixed $value): int|float|null { if (is_string($value)) { diff --git a/src/Validation/README.md b/src/Validation/README.md index c6eda9e..6ff6851 100644 --- a/src/Validation/README.md +++ b/src/Validation/README.md @@ -78,6 +78,8 @@ Behavior: - missing fields fail only when `required()` is configured - `nullable()` allows an explicit `null` value - when a field is `null` and `nullable()` is set, the remaining rules for that field are skipped +- nested fields can be targeted with dot paths such as `user.email` +- array items can be targeted with `*` wildcards such as `tags.*` ### Type and Format Rules @@ -99,6 +101,38 @@ The size meaning depends on the value type: - arrays use item count - numeric values use their numeric value +## Nested Fields and Array Items + +Use dot notation to validate nested input: + +```php +$validator = (new ValidationManager())->make([ + 'user' => [ + 'name' => 'John', + 'roles' => ['admin', 'editor'], + ], +]); + +$validator->field('user.name')->required()->string()->min(2); +$validator->field('user.roles')->required()->array()->min(1); +``` + +Use `*` to validate each array item: + +```php +$validator->field('user.roles.*')->required()->string()->min(3); +``` + +When wildcard validation fails, errors are keyed by the concrete item path: + +```php +[ + 'user.roles.1' => [ + 'The user.roles.1 field must be a string.', + ], +] +``` + ## Exists Validation `exists()` can validate against several sources: diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index 39bbc46..aa9e69d 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -38,10 +38,11 @@ public function passes(): bool { $this->errors = []; - foreach ($this->fields as $field => $validator) { - $errors = $validator->validate($this->data); - if ($errors !== []) { - $this->errors[$field] = $errors; + foreach ($this->fields as $validator) { + foreach ($validator->validate($this->data) as $field => $errors) { + if ($errors !== []) { + $this->errors[$field] = $errors; + } } } @@ -107,12 +108,37 @@ public function validated(): array $validated = []; - foreach ($this->fields as $field => $validator) { - if ($validator->shouldInclude($this->data)) { - $validated[$field] = $this->data[$field]; + foreach ($this->fields as $validator) { + foreach ($validator->validatedValues($this->data) as $field => $value) { + $this->setValidatedValue($validated, $field, $value); } } return $validated; } + + /** + * @param array $validated + */ + private function setValidatedValue(array &$validated, string $field, mixed $value): void + { + $segments = explode('.', $field); + $cursor = &$validated; + + foreach ($segments as $index => $segment) { + $isLast = $index === count($segments) - 1; + + if ($isLast) { + $cursor[$segment] = $value; + + return; + } + + if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) { + $cursor[$segment] = []; + } + + $cursor = &$cursor[$segment]; + } + } } diff --git a/tests/Unit/Validation/ValidationTest.php b/tests/Unit/Validation/ValidationTest.php index dce8431..2b8d8ee 100644 --- a/tests/Unit/Validation/ValidationTest.php +++ b/tests/Unit/Validation/ValidationTest.php @@ -129,6 +129,66 @@ public function testExistsSupportsCustomSources(): void self::assertTrue($validator->passes()); } + public function testNestedFieldsAndWildcardItemsCanBeValidated(): void + { + $validator = (new ValidationManager())->make([ + 'user' => [ + 'name' => 'John', + 'roles' => ['admin', 'editor'], + ], + ]); + + $validator->field('user.name')->required()->string()->min(2); + $validator->field('user.roles')->required()->array()->min(1); + $validator->field('user.roles.*')->required()->string()->min(3); + + self::assertTrue($validator->passes()); + self::assertSame([ + 'user' => [ + 'name' => 'John', + 'roles' => ['admin', 'editor'], + ], + ], $validator->validated()); + } + + public function testWildcardValidationReportsConcreteNestedPaths(): void + { + $validator = (new ValidationManager())->make([ + 'user' => [ + 'roles' => ['admin', 7, ''], + ], + ]); + + $validator->field('user.roles')->required()->array()->min(1); + $validator->field('user.roles.*')->required()->string(); + + self::assertTrue($validator->fails()); + self::assertSame([ + 'user.roles.1' => [ + 'The user.roles.1 field must be a string.', + ], + 'user.roles.2' => [ + 'The user.roles.2 field is required.', + ], + ], $validator->errors()); + } + + public function testNestedRequiredFieldUsesFullPathInErrors(): void + { + $validator = (new ValidationManager())->make([ + 'user' => [], + ]); + + $validator->field('user.profile.name')->required()->string(); + + self::assertTrue($validator->fails()); + self::assertSame([ + 'user.profile.name' => [ + 'The user.profile.name field is required.', + ], + ], $validator->errors()); + } + public function testRulesSupportCustomMessagesAndCallables(): void { $validator = (new ValidationManager())->make([