Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shortcut for Nested + Each #279

Merged
merged 33 commits into from
Aug 20, 2022
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
68ca90e
Restore test for nested + each combination
arogachev Aug 1, 2022
e20e445
Upgrade test to the latest condition before deletion
arogachev Aug 1, 2022
db7fc68
Implement basic functionality
arogachev Aug 1, 2022
659ddf1
Apply fixes from StyleCI
StyleCIBot Aug 1, 2022
c0fe59c
Fix Psalm
arogachev Aug 1, 2022
6b419e4
Additional test with grouping, use getRules(), DRY
arogachev Aug 1, 2022
e0fc8a0
Apply fixes from StyleCI
StyleCIBot Aug 1, 2022
b9d7460
Update README
arogachev Aug 1, 2022
26fef85
Merge remote-tracking branch 'origin/195-nested-each-shortcut' into 1…
arogachev Aug 1, 2022
39487d0
Handle bare shortcut
arogachev Aug 1, 2022
f706e52
Add support for using dots in Nested rule keys
arogachev Aug 3, 2022
47e8a63
Add section about escaping separator to README.md
arogachev Aug 3, 2022
a6f0f9d
Suppress some of Psalm errors
arogachev Aug 3, 2022
3ee89c1
Add note to README about configuration of dynamically generated rules
arogachev Aug 3, 2022
36ac8f5
Add option to escape asterisk as well
arogachev Aug 3, 2022
47b6d01
Actualize tests after changes in arrays package
arogachev Aug 4, 2022
6923637
Actualize README after changes in arrays package
arogachev Aug 4, 2022
92e4799
Add option to escape shortcut as well
arogachev Aug 8, 2022
877a8fe
Apply fixes from StyleCI
StyleCIBot Aug 8, 2022
669a3a5
Prepare getting errors for new changes
arogachev Aug 8, 2022
653cd21
Merge remote-tracking branch 'origin/195-nested-each-shortcut' into 1…
arogachev Aug 8, 2022
1fd02c9
Fix Psalm
arogachev Aug 8, 2022
c090193
Merge branch 'master' into 195-nested-each-shortcut
arogachev Aug 8, 2022
f73cf37
Fix #280: regression of error message for Each rule
arogachev Aug 8, 2022
d25f25d
Apply fixes from StyleCI
StyleCIBot Aug 8, 2022
d5bc83d
Sync with arrays changes
arogachev Aug 15, 2022
a4626a9
Sync with arrays
arogachev Aug 17, 2022
88516ef
Merge branch 'master' into 195-nested-each-shortcut
arogachev Aug 17, 2022
f7f978e
Move data provider close to test
arogachev Aug 17, 2022
5d8d1b4
Move data provider close to test in NestedHandlerTest too
arogachev Aug 17, 2022
760482d
Fix regression
arogachev Aug 17, 2022
d905d8b
adopt
vjik Aug 19, 2022
b267815
very minor refactoring
vjik Aug 20, 2022
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
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
10 changes: 8 additions & 2 deletions src/Error.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ 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