Skip to content

Commit

Permalink
Add shortcut for Nested + Each (#279)
Browse files Browse the repository at this point in the history
  • Loading branch information
arogachev authored Aug 20, 2022
1 parent 9eaf909 commit 14d3e85
Show file tree
Hide file tree
Showing 11 changed files with 635 additions and 90 deletions.
114 changes: 112 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,116 @@ $errors = [
];
```

###### Using shortcut

A shortcut can be used to simplify `Nested` and `Each` combinations:

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

$rule = new Nested([
'charts.*.points.*.coordinates.x' => [new Number(min: -10, max: 10)],
'charts.*.points.*.coordinates.y' => [new Number(min: -10, max: 10)],
'charts.*.points.*.rgb' => [
new Count(exactly: 3);
new Number(min: 0, max: 255),
]),
]);
```

With additional grouping it can also be rewritten like this:

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

$rule = new Nested([
'charts.*.points.*.coordinates' => new Nested([
'x' => [new Number(min: -10, max: 10)],
'y' => [new Number(min: -10, max: 10)],
]),
'charts.*.points.*.rgb' => [
new Count(exactly: 3);
new Number(min: 0, max: 255),
]),
]);
```

This is less verbose, but the downside of this approach is that you can't additionally configure dynamically generated
`Nested` and `Each` pairs. If you need to that, please refer to example provided in "Basic usage" section.

###### Using keys containing separator / shortcut

If a key contains the separator (`.`), it must be escaped with backslash (`\`) in rule config in order to work
correctly. In the input data escaping is not needed. Here is an example with two nested keys named `author.data` and
`name.surname`:

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

$rule = new Nested([
'author\.data.name\.surname' => [
new HasLength(min: 3),
],
]);
$data = [
'author.data' => [
'name.surname' => 'Dmitry',
],
];
```

Note that in case of using multiple nested rules for configuration escaping is still required:

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

$rule = new Nested([
'author\.data' => new Nested([
'name\.surname' => [
new HasLength(min: 3),
],
]),
]);
$data = [
'author.data' => [
'name.surname' => 'Dmitriy',
],
];
```

The same applies to shortcut:

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

$rule = new Nested([
'charts\.list.*.points\*list.*.coordinates\.data.x' => [
// ...
],
'charts\.list.*.points\*list.*.coordinates\.data.y' => [
// ...
],
'charts\.list.*.points\*list.*.rgb' => [
// ...
],
]);
$data = [
'charts.list' => [
[
'points*list' => [
[
'coordinates.data' => ['x' => -11, 'y' => 11], 'rgb' => [-1, 256, 0],
],
],
],
],
];
```

#### Using attributes

##### Basic usage
Expand Down Expand Up @@ -660,7 +770,7 @@ final class CompanyNameHandler implements Rule\RuleHandlerInterface
$hasCompany = $dataSet !== null && $dataSet->getAttributeValue('hasCompany') === true;

if ($hasCompany && $this->isCompanyNameValid($value) === false) {
$result->addError($this->formatMessage('Company name is not valid.'));
$result->addError('Company name is not valid.');
}

return $result;
Expand Down Expand Up @@ -703,7 +813,7 @@ final class NoLessThanExistingBidRuleHandler implements RuleHandlerInterface

$currentMax = $connection->query('SELECT MAX(price) FROM bid')->scalar();
if ($value <= $currentMax) {
$result->addError($this->formatMessage('There is a higher bid of {bid}.', ['bid' => $currentMax]));
$result->addError($this->formatter->format('There is a higher bid of {bid}.', ['bid' => $currentMax]));
}

return $result;
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
"ext-json": "*",
"ext-mbstring": "*",
"psr/container": "^1.0|^2.0",
"yiisoft/arrays": "^2.0",
"yiisoft/arrays": "^2.1",
"yiisoft/friendly-exception": "^1.0",
"yiisoft/network-utilities": "^1.0",
"yiisoft/strings": "^2.0"
"yiisoft/strings": "^2.1"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.0",
Expand Down
13 changes: 11 additions & 2 deletions src/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ public function getMessage(): string
/**
* @psalm-return list<int|string>
*/
public function getValuePath(): array
public function getValuePath(bool $escape = false): array
{
return $this->valuePath;
if ($escape === false) {
return $this->valuePath;
}

return array_map(
static function ($key): string {
return str_replace(['.', '*'], ['\\' . '.', '\\' . '*'], (string)$key);
},
$this->valuePath
);
}
}
4 changes: 2 additions & 2 deletions src/Result.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public function getErrorMessagesIndexedByPath(string $separator = '.'): array
{
$errors = [];
foreach ($this->errors as $error) {
$stringValuePath = implode($separator, $error->getValuePath());
$stringValuePath = implode($separator, $error->getValuePath(true));
$errors[$stringValuePath][] = $error->getMessage();
}

Expand Down Expand Up @@ -127,7 +127,7 @@ public function getAttributeErrorMessagesIndexedByPath(string $attribute, string
continue;
}

$valuePath = implode($separator, array_slice($error->getValuePath(), 1));
$valuePath = implode($separator, array_slice($error->getValuePath(true), 1));
$errors[$valuePath][] = $error->getMessage();
}

Expand Down
14 changes: 13 additions & 1 deletion src/Rule/EachHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use Yiisoft\Validator\RuleInterface;
use Yiisoft\Validator\ValidationContext;

use function is_array;

/**
* Validates an array by checking each of its elements against a set of rules.
*/
Expand All @@ -31,6 +33,9 @@ public function validate(mixed $value, object $rule, ValidationContext $context)
throw new UnexpectedRuleException(Each::class, $rule);
}

/** @var Each $eachRule */
$eachRule = $rule;

$rules = $rule->getRules();
if ($rules === []) {
throw new InvalidArgumentException('Rules are required.');
Expand Down Expand Up @@ -58,10 +63,17 @@ public function validate(mixed $value, object $rule, ValidationContext $context)
foreach ($itemResult->getErrors() as $error) {
if (!is_array($item)) {
$errorKey = [$index];
$formatMessage = true;
} else {
$errorKey = [$index, ...$error->getValuePath()];
$formatMessage = false;
}
$result->addError($error->getMessage(), $errorKey);

$message = !$formatMessage ? $error->getMessage() : $this->formatter->format($eachRule->getMessage(), [
'error' => $error->getMessage(),
'value' => $item,
]);
$result->addError($message, $errorKey);
}
}

Expand Down
75 changes: 70 additions & 5 deletions src/Rule/Nested.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,22 @@
use InvalidArgumentException;
use JetBrains\PhpStorm\ArrayShape;
use Traversable;
use Yiisoft\Validator\SerializableRuleInterface;
use Yiisoft\Strings\StringHelper;
use Yiisoft\Validator\BeforeValidationInterface;
use Yiisoft\Validator\Rule\Trait\BeforeValidationTrait;
use Yiisoft\Validator\Rule\Trait\RuleNameTrait;
use Yiisoft\Validator\RuleInterface;
use Yiisoft\Validator\RulesDumper;

use Yiisoft\Validator\SerializableRuleInterface;
use Yiisoft\Validator\ValidationContext;

use function array_pop;
use function count;
use function implode;
use function is_array;
use function ltrim;
use function rtrim;
use function sprintf;

/**
* Can be used for validation of nested structures.
Expand All @@ -29,13 +35,17 @@ final class Nested implements SerializableRuleInterface, BeforeValidationInterfa
use BeforeValidationTrait;
use RuleNameTrait;

private const SEPARATOR = '.';
private const EACH_SHORTCUT = '*';

public function __construct(
/**
* @var iterable<\Closure|\Closure[]|RuleInterface|RuleInterface[]>
*/
private iterable $rules = [],
private bool $requirePropertyPath = false,
private string $noPropertyPathMessage = 'Property path "{path}" is not found.',
private bool $normalizeRules = true,
private bool $skipOnEmpty = false,
private bool $skipOnError = false,
/**
Expand All @@ -48,12 +58,16 @@ public function __construct(
throw new InvalidArgumentException('Rules must not be empty.');
}

if ($this->checkRules($rules)) {
if (self::checkRules($rules)) {
$message = sprintf('Each rule should be an instance of %s.', RuleInterface::class);
throw new InvalidArgumentException($message);
}

$this->rules = $rules;

if ($this->normalizeRules === true) {
$this->normalizeRules();
}
}

/**
Expand All @@ -80,17 +94,68 @@ public function getNoPropertyPathMessage(): string
return $this->noPropertyPathMessage;
}

private function checkRules(array $rules): bool
private static function checkRules($rules): bool
{
return array_reduce(
$rules,
function (bool $carry, $rule) {
return $carry || (is_array($rule) ? $this->checkRules($rule) : !$rule instanceof RuleInterface);
return $carry || (is_array($rule) ? self::checkRules($rule) : !$rule instanceof RuleInterface);
},
false
);
}

private function normalizeRules(): void
{
/** @var iterable $rules */
$rules = $this->getRules();
while (true) {
$breakWhile = true;
$rulesMap = [];

foreach ($rules as $valuePath => $rule) {
if ($valuePath === self::EACH_SHORTCUT) {
throw new InvalidArgumentException('Bare shortcut is prohibited. Use "Each" rule instead.');
}

$parts = StringHelper::parsePath(
(string) $valuePath,
delimiter: self::EACH_SHORTCUT,
preserveDelimiterEscaping: true
);
if (count($parts) === 1) {
continue;
}

$breakWhile = false;

$lastValuePath = array_pop($parts);
$lastValuePath = ltrim($lastValuePath, '.');
$lastValuePath = str_replace('\\' . self::EACH_SHORTCUT, self::EACH_SHORTCUT, $lastValuePath);

$remainingValuePath = implode(self::EACH_SHORTCUT, $parts);
$remainingValuePath = rtrim($remainingValuePath, self::SEPARATOR);

if (!isset($rulesMap[$remainingValuePath])) {
$rulesMap[$remainingValuePath] = [];
}

$rulesMap[$remainingValuePath][$lastValuePath] = $rule;
unset($rules[$valuePath]);
}

foreach ($rulesMap as $valuePath => $nestedRules) {
$rules[$valuePath] = new Each([new self($nestedRules, normalizeRules: false)]);
}

if ($breakWhile === true) {
break;
}
}

$this->rules = $rules;
}

#[ArrayShape([
'requirePropertyPath' => 'bool',
'noPropertyPathMessage' => 'array',
Expand Down
Loading

0 comments on commit 14d3e85

Please sign in to comment.