Skip to content

Commit d1524d1

Browse files
authored
Fix #186: Add options' progagation for Nested rule (#317)
1 parent f0b6560 commit d1524d1

8 files changed

Lines changed: 161 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,9 @@ For multiple rules this can be also set more conveniently at validator level:
209209
use Yiisoft\Validator\SimpleRuleHandlerContainer;
210210
use Yiisoft\Validator\Validator;
211211

212-
$validator = new Validator(new SimpleRuleHandlerContainer(), skipOnEmpty: true);
212+
$validator = new Validator(new SimpleRuleHandlerContainer($translator), skipOnEmpty: true);
213213
$validator = new Validator(
214-
new SimpleRuleHandlerContainer(),
214+
new SimpleRuleHandlerContainer($translator),
215215
skipOnEmptyCallback: static function (mixed $value): bool {
216216
return $value === 0;
217217
}

src/BeforeValidationInterface.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@
77
use Closure;
88

99
/**
10-
* `BeforeValidationInterface` is the interface implemented by rules that need to execute checks before the validation.
10+
* `BeforeValidationInterface` is an interface implemented by rules that need to execute checks before the validation.
1111
*/
1212
interface BeforeValidationInterface
1313
{
14+
public function skipOnError(bool $value): static;
15+
1416
public function shouldSkipOnError(): bool;
1517

18+
/**
19+
* @psalm-param Closure(mixed, ValidationContext):bool|null $value
20+
*/
21+
public function when(?Closure $value): static;
22+
1623
/**
1724
* @psalm-return Closure(mixed, ValidationContext):bool|null
18-
*
19-
* @return Closure|null
2025
*/
2126
public function getWhen(): ?Closure;
2227
}

src/PropagateOptionsInterface.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\Validator;
6+
7+
/**
8+
* An interface implemented by rules that can propagate their common options (such as `skipOnEmpty`, `skipOnError`,
9+
* `when`) to child rules as an alternative way of specifying them explicitly in every child rule.
10+
*/
11+
interface PropagateOptionsInterface
12+
{
13+
public function propagateOptions(): void;
14+
}

src/Rule/Each.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Closure;
99
use JetBrains\PhpStorm\ArrayShape;
1010
use Yiisoft\Validator\BeforeValidationInterface;
11+
use Yiisoft\Validator\PropagateOptionsInterface;
1112
use Yiisoft\Validator\Rule\Trait\BeforeValidationTrait;
1213
use Yiisoft\Validator\Rule\Trait\RuleNameTrait;
1314
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
@@ -20,15 +21,19 @@
2021
* Validates an array by checking each of its elements against a set of rules.
2122
*/
2223
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
23-
final class Each implements SerializableRuleInterface, BeforeValidationInterface, SkipOnEmptyInterface
24+
final class Each implements
25+
SerializableRuleInterface,
26+
BeforeValidationInterface,
27+
SkipOnEmptyInterface,
28+
PropagateOptionsInterface
2429
{
2530
use BeforeValidationTrait;
2631
use RuleNameTrait;
2732
use SkipOnEmptyTrait;
2833

2934
public function __construct(
3035
/**
31-
* @var iterable<RuleInterface>
36+
* @var iterable<BeforeValidationInterface|RuleInterface|SkipOnEmptyInterface>
3237
*/
3338
private iterable $rules = [],
3439
private string $incorrectInputMessage = 'Value must be array or iterable.',
@@ -46,8 +51,26 @@ public function __construct(
4651
) {
4752
}
4853

54+
public function propagateOptions(): void
55+
{
56+
$rules = [];
57+
foreach ($this->rules as $rule) {
58+
$rule = $rule->skipOnEmpty($this->skipOnEmpty);
59+
$rule = $rule->skipOnError($this->skipOnError);
60+
$rule = $rule->when($this->when);
61+
62+
$rules[] = $rule;
63+
64+
if ($rule instanceof PropagateOptionsInterface) {
65+
$rule->propagateOptions();
66+
}
67+
}
68+
69+
$this->rules = $rules;
70+
}
71+
4972
/**
50-
* @return iterable<\Closure|\Closure[]|RuleInterface|RuleInterface[]>
73+
* @return iterable<BeforeValidationInterface|BeforeValidationInterface[]|\Closure|\Closure[]|RuleInterface|RuleInterface[]|SkipOnEmptyInterface|SkipOnEmptyInterface[]>
5174
*/
5275
public function getRules(): iterable
5376
{

src/Rule/Nested.php

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Traversable;
1313
use Yiisoft\Strings\StringHelper;
1414
use Yiisoft\Validator\BeforeValidationInterface;
15+
use Yiisoft\Validator\PropagateOptionsInterface;
1516
use Yiisoft\Validator\Rule\Trait\BeforeValidationTrait;
1617
use Yiisoft\Validator\Rule\Trait\RuleNameTrait;
1718
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
@@ -35,7 +36,11 @@
3536
* Can be used for validation of nested structures.
3637
*/
3738
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
38-
final class Nested implements SerializableRuleInterface, BeforeValidationInterface, SkipOnEmptyInterface
39+
final class Nested implements
40+
SerializableRuleInterface,
41+
BeforeValidationInterface,
42+
SkipOnEmptyInterface,
43+
PropagateOptionsInterface
3944
{
4045
use BeforeValidationTrait;
4146
use RuleNameTrait;
@@ -80,6 +85,7 @@ public function __construct(
8085
private bool $requirePropertyPath = false,
8186
private string $noPropertyPathMessage = 'Property path "{path}" is not found.',
8287
private bool $normalizeRules = true,
88+
private bool $propagateOptions = false,
8389

8490
/**
8591
* @var bool|callable|null
@@ -92,7 +98,7 @@ public function __construct(
9298
*/
9399
private ?Closure $when = null,
94100
) {
95-
$this->rules = $this->prepareRules($rules);
101+
$this->prepareRules($rules);
96102
}
97103

98104
/**
@@ -127,10 +133,12 @@ public function getNoPropertyPathMessage(): string
127133
/**
128134
* @param class-string|iterable<Closure|Closure[]|RuleInterface|RuleInterface[]>|RulesProviderInterface|null $source
129135
*/
130-
private function prepareRules(iterable|object|string|null $source): ?iterable
136+
private function prepareRules(iterable|object|string|null $source): void
131137
{
132138
if ($source === null) {
133-
return null;
139+
$this->rules = null;
140+
141+
return;
134142
}
135143

136144
if ($source instanceof RulesProviderInterface) {
@@ -144,7 +152,15 @@ private function prepareRules(iterable|object|string|null $source): ?iterable
144152
$rules = $rules instanceof Traversable ? iterator_to_array($rules) : $rules;
145153
self::ensureArrayHasRules($rules);
146154

147-
return $this->normalizeRules ? $this->normalizeRules($rules) : $rules;
155+
$this->rules = $rules;
156+
157+
if ($this->normalizeRules) {
158+
$this->normalizeRules();
159+
}
160+
161+
if ($this->propagateOptions) {
162+
$this->propagateOptions();
163+
}
148164
}
149165

150166
private static function ensureArrayHasRules(iterable &$rules)
@@ -163,8 +179,11 @@ private static function ensureArrayHasRules(iterable &$rules)
163179
}
164180
}
165181

166-
private function normalizeRules(iterable $rules): iterable
182+
private function normalizeRules(): void
167183
{
184+
/** @var iterable $rules */
185+
$rules = $this->rules;
186+
168187
while (true) {
169188
$breakWhile = true;
170189
$rulesMap = [];
@@ -209,7 +228,27 @@ private function normalizeRules(iterable $rules): iterable
209228
}
210229
}
211230

212-
return $rules;
231+
$this->rules = $rules;
232+
}
233+
234+
public function propagateOptions(): void
235+
{
236+
$rules = [];
237+
foreach ($this->rules as $attributeRulesIndex => $attributeRules) {
238+
foreach ($attributeRules as $attributeRule) {
239+
$attributeRule = $attributeRule->skipOnEmpty($this->skipOnEmpty);
240+
$attributeRule = $attributeRule->skipOnError($this->skipOnError);
241+
$attributeRule = $attributeRule->when($this->when);
242+
243+
$rules[$attributeRulesIndex][] = $attributeRule;
244+
245+
if ($attributeRule instanceof PropagateOptionsInterface) {
246+
$attributeRule->propagateOptions();
247+
}
248+
}
249+
}
250+
251+
$this->rules = $rules;
213252
}
214253

215254
#[ArrayShape([

src/Rule/Trait/BeforeValidationTrait.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,30 @@
99

1010
trait BeforeValidationTrait
1111
{
12+
public function skipOnError(bool $value): static
13+
{
14+
$new = clone $this;
15+
$new->skipOnError = $value;
16+
return $new;
17+
}
18+
1219
public function shouldSkipOnError(): bool
1320
{
1421
return $this->skipOnError;
1522
}
1623

24+
/**
25+
* @psalm-param Closure(mixed, ValidationContext):bool|null $value
26+
*/
27+
public function when(?Closure $value): static
28+
{
29+
$new = clone $this;
30+
$new->when = $value;
31+
return $new;
32+
}
33+
1734
/**
1835
* @psalm-return Closure(mixed, ValidationContext):bool|null
19-
*
20-
* @return Closure|null
2136
*/
2237
public function getWhen(): ?Closure
2338
{

tests/Rule/NestedHandlerTest.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Yiisoft\Validator\Tests\Rule;
66

7+
use Yiisoft\Arrays\ArrayHelper;
78
use Yiisoft\Validator\Error;
89
use Yiisoft\Validator\Result;
910
use Yiisoft\Validator\Rule\Callback;
@@ -499,6 +500,51 @@ public function testNestedWithoutRulesWithObject(): void
499500
], $result->getErrorMessagesIndexedByPath());
500501
}
501502

503+
public function testPropagateOptions(): void
504+
{
505+
$rule = new Nested([
506+
'posts' => [
507+
new Each([new Nested([
508+
'title' => [new HasLength(min: 3)],
509+
'authors' => [
510+
new Each([new Nested([
511+
'name' => [new HasLength(min: 5)],
512+
'age' => [
513+
new Number(min: 18),
514+
new Number(min: 20),
515+
],
516+
])]),
517+
],
518+
])]),
519+
],
520+
'meta' => [new HasLength(min: 7)],
521+
], propagateOptions: true, skipOnEmpty: true, skipOnError: true);
522+
$options = $rule->getOptions();
523+
$paths = [
524+
[],
525+
['rules', 'posts', 0],
526+
['rules', 'posts', 0, 'rules', 0],
527+
['rules', 'posts', 0, 'rules', 0, 'rules', 'title', 0],
528+
['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0],
529+
['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0],
530+
['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0, 'rules', 'name', 0],
531+
['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0, 'rules', 'age', 0],
532+
['rules', 'posts', 0, 'rules', 0, 'rules', 'authors', 0, 'rules', 0, 'rules', 'age', 1],
533+
['rules', 'meta', 0],
534+
];
535+
$keys = ['skipOnEmpty', 'skipOnError'];
536+
537+
foreach ($paths as $path) {
538+
foreach ($keys as $key) {
539+
$fullPath = $path;
540+
$fullPath[] = $key;
541+
542+
$value = ArrayHelper::getValueByPath($options, $fullPath);
543+
$this->assertTrue($value);
544+
}
545+
}
546+
}
547+
502548
protected function getRuleHandler(): RuleHandlerInterface
503549
{
504550
return new NestedHandler($this->getTranslator());

tests/Stub/TranslatorFactory.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,14 @@ final class TranslatorFactory
1414
{
1515
public function create(): TranslatorInterface
1616
{
17-
$translator = new Translator(
18-
'en'
19-
);
20-
17+
$translator = new Translator('en');
2118
$categorySource = new CategorySource(
2219
'validator',
2320
new IdMessageReader(),
2421
new SimpleMessageFormatter()
2522
);
2623
$translator->addCategorySource($categorySource);
24+
2725
return $translator->withCategory('validator');
2826
}
2927
}

0 commit comments

Comments
 (0)