Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 119 additions & 49 deletions src/Validation/FieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,71 +220,42 @@ 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;
}
}

return $errors;
}

/**
* Determine whether this field should be included in validated output.
* Return the resolved values that should be included in validated output.
*
* @return array<string, mixed>
*/
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
Expand Down Expand Up @@ -342,6 +313,105 @@ private function formatMessage(
return $default;
}

/**
* @return array<string, mixed>
*/
private function resolveValues(array $data): array
{
return $this->resolveSegments($data, explode('.', $this->field));
}

/**
* @param list<string> $segments
* @return array<string, mixed>
*/
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<string>
*/
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)) {
Expand Down
34 changes: 34 additions & 0 deletions src/Validation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
40 changes: 33 additions & 7 deletions src/Validation/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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<string, mixed> $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];
}
}
}
60 changes: 60 additions & 0 deletions tests/Unit/Validation/ValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading