Skip to content

Commit

Permalink
Fix #135, fix #147: Validate nested data, add error details (#151)
Browse files Browse the repository at this point in the history
  • Loading branch information
arogachev committed Feb 6, 2022
1 parent d8d7b60 commit 2a6777d
Show file tree
Hide file tree
Showing 13 changed files with 565 additions and 97 deletions.
82 changes: 82 additions & 0 deletions README.md
Expand Up @@ -142,6 +142,88 @@ To change this behavior use `skipOnEmpty(true)`:
Number::rule()->integer()->max(100)->skipOnEmpty(true)
```

#### Nested and related data

In many cases there is a need to validate related data in addition to current entity / model. There is a `Nested` rule
for this purpose:

```php
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Nested;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;

$data = ['author' => ['name' => 'Alexey', 'age' => '31']];
$rule = Nested::rule([
'title' => [Required::rule()],
'author' => Nested::rule([
'name' => [HasLength::rule()->min(3)],
'age' => [Number::rule()->min(18)],
)];
]);
$errors = $rule->validate($data)->getErrorsIndexedByPath();
```

A more complex real-life example is a chart that is made of points. This data is represented as arrays. `Nested` can be
combined with `Each` rule to validate such similar structures:

```php
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Nested;

$data = [
'charts' => [
[
'points' => [
['coordinates' => ['x' => -11, 'y' => 11], 'rgb' => [-1, 256, 0]],
['coordinates' => ['x' => -12, 'y' => 12], 'rgb' => [0, -2, 257]]
],
],
[
'points' => [
['coordinates' => ['x' => -1, 'y' => 1], 'rgb' => [0, 0, 0]],
['coordinates' => ['x' => -2, 'y' => 2], 'rgb' => [255, 255, 255]],
],
],
[
'points' => [
['coordinates' => ['x' => -13, 'y' => 13], 'rgb' => [-3, 258, 0]],
['coordinates' => ['x' => -14, 'y' => 14], 'rgb' => [0, -4, 259]],
],
],
],
];
$rule = Nested::rule([
'charts' => Each::rule(new Rules([
Nested::rule([
'points' => Each::rule(new Rules([
Nested::rule([
'coordinates' => Nested::rule([
'x' => [Number::rule()->min(-10)->max(10)],
'y' => [Number::rule()->min(-10)->max(10)],
]),
'rgb' => Each::rule(new Rules([
Number::rule()->min(0)->max(255)->skipOnError(false),
])),
])->skipOnError(false),
])),
])->skipOnError(false),
])),
]);
$errors = $rule->validate($data)->getErrorsIndexedByPath();
```

The contents of the errors will be:

```php
$errors = [
'charts.0.points.0.coordinates.x' => ['Value must be no less than -10.'],
// ...
'charts.0.points.0.rgb.0' => ['Value must be no less than 0. -1 given.'],
// ...
];
```

### Conditional validation

In some cases there is a need to apply rule conditionally. It could be performed by using `when()`:
Expand Down
37 changes: 37 additions & 0 deletions src/Error.php
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Validator;

final class Error
{
private string $message;

/**
* @psalm-var list<int|string>
*/
private array $valuePath;

/**
* @psalm-param list<int|string> $valuePath
*/
public function __construct(string $message, array $valuePath = [])
{
$this->message = $message;
$this->valuePath = $valuePath;
}

public function getMessage(): string
{
return $this->message;
}

/**
* @psalm-return list<int|string>
*/
public function getValuePath(): array
{
return $this->valuePath;
}
}
34 changes: 29 additions & 5 deletions src/Result.php
Expand Up @@ -4,10 +4,12 @@

namespace Yiisoft\Validator;

use Yiisoft\Arrays\ArrayHelper;

final class Result
{
/**
* @psalm-var list<string>
* @var Error[]
*/
private array $errors = [];

Expand All @@ -16,16 +18,38 @@ public function isValid(): bool
return $this->errors === [];
}

public function addError(string $message): void
/**
* @return Error[]
*/
public function getErrorObjects(): array
{
$this->errors[] = $message;
return $this->errors;
}

/**
* @psalm-return list<string>
* @return string[]
*/
public function getErrors(): array
{
return $this->errors;
return ArrayHelper::getColumn($this->errors, static fn (Error $error) => $error->getMessage());
}

public function getErrorsIndexedByPath(string $separator = '.'): array
{
$errors = [];
foreach ($this->errors as $error) {
$stringValuePath = implode($separator, $error->getValuePath());
$errors[$stringValuePath][] = $error->getMessage();
}

return $errors;
}

/**
* @psalm-param array<int|string> $valuePath
*/
public function addError(string $message, array $valuePath = []): void
{
$this->errors[] = new Error($message, $valuePath);
}
}
71 changes: 45 additions & 26 deletions src/ResultSet.php
Expand Up @@ -5,6 +5,7 @@
namespace Yiisoft\Validator;

use ArrayIterator;
use Closure;
use InvalidArgumentException;
use IteratorAggregate;

Expand All @@ -21,6 +22,50 @@ final class ResultSet implements IteratorAggregate
private array $results = [];
private bool $isValid = true;

public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->results);
}

public function isValid(): bool
{
return $this->isValid;
}

/**
* @psalm-return array<string, Error>
*/
public function getErrorObjects(): array
{
return $this->getErrorsMap(static fn (Result $result): array => $result->getErrorObjects());
}

/**
* @return string[][]
* @psalm-return array<string, array<int|string, string>>
*/
public function getErrors(): array
{
return $this->getErrorsMap(static fn (Result $result): array => $result->getErrors());
}

public function getErrorsIndexedByPath(string $separator = '.'): array
{
return $this->getErrorsMap(static fn (Result $result): array => $result->getErrorsIndexedByPath($separator));
}

private function getErrorsMap(Closure $getErrorsClosure): array
{
$errors = [];
foreach ($this->results as $attribute => $result) {
if (!$result->isValid()) {
$errors[$attribute] = $getErrorsClosure($result);
}
}

return $errors;
}

public function addResult(string $attribute, Result $result): void
{
if (!$result->isValid()) {
Expand Down Expand Up @@ -49,30 +94,4 @@ public function getResult(string $attribute): Result

return $this->results[$attribute];
}

public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->results);
}

/**
* @return string[][]
* @psalm-return array<string, list<string>>
*/
public function getErrors(): array
{
$errors = [];
foreach ($this->results as $attribute => $result) {
if (!$result->isValid()) {
$errors[$attribute] = $result->getErrors();
}
}

return $errors;
}

public function isValid(): bool
{
return $this->isValid;
}
}
10 changes: 6 additions & 4 deletions src/Rule/Callback.php
Expand Up @@ -33,12 +33,14 @@ protected function validateValue($value, ValidationContext $context = null): Res
}

$result = new Result();
if ($callbackResult->isValid()) {
return $result;
}

if ($callbackResult->isValid() === false) {
foreach ($callbackResult->getErrors() as $message) {
$result->addError($this->formatMessage($message));
}
foreach ($callbackResult->getErrorObjects() as $error) {
$result->addError($this->formatMessage($error->getMessage()), $error->getValuePath());
}

return $result;
}
}
31 changes: 19 additions & 12 deletions src/Rule/Each.php
Expand Up @@ -37,20 +37,27 @@ protected function validateValue($value, ValidationContext $context = null): Res
return $result;
}

foreach ($value as $item) {
foreach ($value as $index => $item) {
$itemResult = $this->rules->validate($item, $context);
if ($itemResult->isValid() === false) {
foreach ($itemResult->getErrors() as $error) {
$result->addError(
$this->formatMessage(
$this->message,
[
'error' => $error,
'value' => $item,
]
)
);
if ($itemResult->isValid()) {
continue;
}

foreach ($itemResult->getErrorObjects() as $error) {
if (!is_array($item)) {
$errorKey = [$index];
$formatMessage = true;
} else {
$errorKey = [$index, ...$error->getValuePath()];
$formatMessage = false;
}

$message = !$formatMessage ? $error->getMessage() : $this->formatMessage($this->message, [
'error' => $error->getMessage(),
'value' => $item,
]);

$result->addError($message, $errorKey);
}
}

Expand Down

0 comments on commit 2a6777d

Please sign in to comment.