Passed
Pull Request — master (#564)
by Sergei
02:39
created

Nested::setRules()   B

Complexity

Conditions 8
Paths 14

Size

Total Lines 33
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 8

Importance

Changes 0
Metric Value
cc 8
eloc 21
c 0
b 0
f 0
nc 14
nop 1
dl 0
loc 33
rs 8.4444
ccs 7
cts 7
cp 1
crap 8
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Rule;
6
7
use Attribute;
8
use Closure;
9
use InvalidArgumentException;
10
use JetBrains\PhpStorm\ArrayShape;
11
use ReflectionProperty;
12
use Traversable;
13
use Yiisoft\Strings\StringHelper;
14
use Yiisoft\Validator\AfterInitAttributeEventInterface;
15
use Yiisoft\Validator\DataSet\ObjectDataSet;
16
use Yiisoft\Validator\Helper\PropagateOptionsHelper;
17
use Yiisoft\Validator\PropagateOptionsInterface;
18
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
19
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
20
use Yiisoft\Validator\Rule\Trait\WhenTrait;
21
use Yiisoft\Validator\RuleInterface;
22
use Yiisoft\Validator\Helper\RulesDumper;
23
use Yiisoft\Validator\RulesProvider\AttributesRulesProvider;
24
use Yiisoft\Validator\RulesProviderInterface;
25
use Yiisoft\Validator\RuleWithOptionsInterface;
26
use Yiisoft\Validator\SkipOnEmptyInterface;
27
use Yiisoft\Validator\SkipOnErrorInterface;
28
use Yiisoft\Validator\Tests\Rule\NestedTest;
29
use Yiisoft\Validator\WhenInterface;
30
31
use function array_pop;
32
use function count;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Yiisoft\Validator\Rule\count. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
33
use function implode;
34
use function is_string;
35
use function ltrim;
36
use function rtrim;
37
use function sprintf;
38
39
/**
40
 * Used to define rules for validation of nested structures:
41
 *
42
 * - For one-to-one relation, using `Nested` rule is enough.
43
 * - One-to-many and many-to-many relations require pairing with {@see Each} rule.
44
 *
45
 * An example with blog post:
46
 *
47
 * ```php
48
 * $rule = new Nested([
49
 *     'title' => [new Length(max: 255)],
50
 *     // One-to-one relation
51
 *     'author' => new Nested([
52
 *         'name' => [new Length(min: 1)],
53
 *     ]),
54
 *     // One-to-many relation
55
 *     'files' => new Each([
56
 *          new Nested([
57
 *             'url' => [new Url()],
58
 *         ]),
59
 *     ]),
60
 * ]);
61
 * ```
62
 *
63
 * There is an alternative way to write this using dot notation and shortcuts:
64
 *
65
 * ```php
66
 * $rule = new Nested([
67
 *     'title' => [new Length(max: 255)],
68
 *     'author.name' => [new Length(min: 1)],
69
 *     'files.*.url' => [new Url()],
70
 * ]);
71
 * ```
72
 *
73
 * For more examples please refer to the guide.
74
 *
75 26
 * It's also possible to use DTO objects with PHP attributes, see {@see ObjectDataSet} documentation and guide for
76
 * details.
77
 *
78
 * Supports propagation of options (see {@see PropagateOptionsHelper::propagate()} for available options and
79
 * requirements).
80
 *
81
 * @see NestedHandler Corresponding handler performing the actual validation.
82
 *
83
 * @psalm-import-type WhenType from WhenInterface
84
 */
85
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
86
final class Nested implements
87
    RuleWithOptionsInterface,
88
    SkipOnErrorInterface,
89
    WhenInterface,
90
    SkipOnEmptyInterface,
91
    PropagateOptionsInterface,
92
    AfterInitAttributeEventInterface
93
{
94
    use SkipOnEmptyTrait;
95
    use SkipOnErrorTrait;
96
    use WhenTrait;
97
98
    /**
99
     * A character acting as a separator when using alternative (short) syntax.
100
     */
101
    private const SEPARATOR = '.';
102
    /**
103
     * A character acting as a shortcut when using alternative (short) syntax with {@see Nested} and {@see Each}
104
     * combinations.
105
     */
106
    private const EACH_SHORTCUT = '*';
107
108
    /**
109
     * @var array<list<RuleInterface>|RuleInterface>|null A set of ready to use rule instances. The 1st level
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<list<RuleInterface>|RuleInterface>|null at position 2 could not be parsed: Expected '>' at position 2, but found 'list'.
Loading history...
110
     *     is always an array of rules, the 2nd level is either an array of rules or a single rule.
111 26
     */
112
    private array|null $rules;
113
114 2
    /**
115
     * @param iterable|object|string|null $rules Rules for validating nested structure. The following types are
116 2
     * supported:
117
     *
118
     * - Array or object implementing {@see Traversable} interface containing rules. Either iterables containing
119
     * {@see RuleInterface} implementations or a single rule instances are expected. All iterables regardless of the
120
     * nesting level will be converted to arrays at the end.
121
     * - Object implementing {@see RulesProviderInterface}.
122 53
     * - Name of a class containing rules declared via PHP attributes.
123
     * - `null` if validated value is an object. It can either implement {@see RulesProviderInterface} or contain rules
124 53
     * declared via PHP attributes.
125
     * @psalm-param iterable|object|class-string|null $rules
126
     *
127 11
     * @param int $validatedObjectPropertyVisibility Visibility levels to use for parsed properties when validated value
128
     * is an object providing rules / data. For example: public and protected only, this means that the rest (private
129 11
     * ones) will be skipped. Defaults to all visibility levels (public, protected and private). See
130
     * {@see ObjectDataSet} for details on providing rules / data in validated object and {@see ObjectParser} for
131
     * overview how parsing works.
132 5
     * @psalm-param int-mask-of<ReflectionProperty::IS_*> $validatedObjectPropertyVisibility
133
     *
134 5
     * @param int $rulesSourceClassPropertyVisibility Visibility levels to use for parsed properties when {@see $rules}
135
     * source is a name of the class providing rules. For example: public and protected only, this means that the rest
136
     * (private ones) will be skipped. Defaults to all visibility levels (public, protected and private). See
137 3
     * {@see ObjectDataSet} for details on providing rules via class and {@see ObjectParser} for overview how parsing
138
     * works.
139 3
     * @psalm-param int-mask-of<ReflectionProperty::IS_*> $rulesSourceClassPropertyVisibility
140
     *
141
     * @param string $noRulesWithNoObjectMessage Error message used when validation fails because the validated value is
142 5
     * not an object and the rules were not explicitly specified via {@see $rules}:
143
     *
144 5
     * You may use the following placeholders in the message:
145
     *
146
     * - `{attribute}`: the translated label of the attribute being validated.
147 39
     * - `{type}`: the type of the value being validated.
148
     * @param string $incorrectDataSetTypeMessage Error message used when validation fails because the validated value
149 39
     * is an object providing wrong type of data (neither array nor an object).
150
     *
151
     * You may use the following placeholders in the message:
152 10
     *
153
     * - `{type}`: the type of the data set retrieved from the validated object.
154 10
     * @param string $incorrectInputMessage Error message used when validation fails because the validated value is
155
     * neither an array nor an object.
156
     *
157
     * You may use the following placeholders in the message:
158
     *
159
     * - `{attribute}`: the translated label of the attribute being validated.
160 26
     * - `{type}`: the type of the value being validated.
161
     * @param bool $requirePropertyPath Whether to require a single data item to be passed in data according to declared
162 26
     * nesting level structure (all keys in the sequence must be the present). Used only when validated value is an
163 16
     * array. Enabled by default. See {@see $noPropertyPathMessage} for customization of error message.
164
     * @param string $noPropertyPathMessage Error message used when validation fails because {@see $requirePropertyPath}
165 16
     * option was enabled and the validated array contains missing data item.
166
     *
167
     * You may use the following placeholders in the message:
168 10
     *
169 1
     * - `{path}`: the path of the value being validated. Can be either a simple key of integer / string type for a
170 9
     * single nesting level or a sequence of keys concatenated using dot notation (see {@see SEPARATOR}).
171 4
     * - `{attribute}`: the translated label of the attribute being validated.
172
     * @param bool $handleEachShortcut Whether to handle {@see EACH_SHORTCUT}. Enabled by default meaning shortcuts are
173 5
     * supported. Can be disabled if they are not used to prevent additional checks and improve performance.
174
     * @param bool $propagateOptions Whether the propagation of options is enabled (see
175
     * {@see PropagateOptionsHelper::propagate()} for supported options and requirements). Disabled by default.
176 10
     * @param bool|callable|null $skipOnEmpty Whether to skip this `Nested` rule with all defined {@see $rules} if the
177 8
     * validated value is empty / not passed. See {@see SkipOnEmptyInterface}.
178
     * @param bool $skipOnError Whether to skip this `Nested` rule with all defined {@see $rules} if any of the previous
179 8
     * rules gave an error. See {@see SkipOnErrorInterface}.
180 8
     * @param Closure|null $when  A callable to define a condition for applying this `Nested` rule with all defined
181
     * {@see $rules}. See {@see WhenInterface}.
182
     * @psalm-param WhenType $when
183 7
     */
184 1
    public function __construct(
185
        iterable|object|string|null $rules = null,
186
        private int $validatedObjectPropertyVisibility = ReflectionProperty::IS_PRIVATE
187
        | ReflectionProperty::IS_PROTECTED
188
        | ReflectionProperty::IS_PUBLIC,
189
        private int $rulesSourceClassPropertyVisibility = ReflectionProperty::IS_PRIVATE
190
        | ReflectionProperty::IS_PROTECTED
191 10
        | ReflectionProperty::IS_PUBLIC,
192
        private string $noRulesWithNoObjectMessage = 'Nested rule without rules can be used for objects only.',
193 10
        private string $incorrectDataSetTypeMessage = 'An object data set data can only have an array or an object ' .
194
        'type.',
195 10
        private string $incorrectInputMessage = 'The value must be an array or an object.',
196 9
        private bool $requirePropertyPath = false,
197 7
        private string $noPropertyPathMessage = 'Property "{path}" is not found.',
198 9
        private bool $handleEachShortcut = true,
199 2
        private bool $propagateOptions = false,
200
        private mixed $skipOnEmpty = null,
201
        private bool $skipOnError = false,
202 2
        private Closure|null $when = null,
203
    ) {
204
        $this->setRules($rules);
205 2
    }
206
207
    public function getName(): string
208
    {
209
        return 'nested';
210 8
    }
211
212
    /**
213 8
     * @return array<array-key,list<RuleInterface>|RuleInterface>|null
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array-key,list<Rul...ce>|RuleInterface>|null at position 2 could not be parsed: Unknown type name 'array-key' at position 2 in array<array-key,list<RuleInterface>|RuleInterface>|null.
Loading history...
214 8
     */
215 8
    public function getRules(): array|null
216 8
    {
217
        return $this->rules;
218 8
    }
219 7
220 1
    /**
221
     * Gets visibility levels to use for parsed properties when validated value is an object providing rules / data.
222
     * Defaults to all visibility levels (public, protected and private)
223 6
     *
224
     * @return int A number representing visibility levels.
225
     * @psalm-return int-mask-of<ReflectionProperty::IS_*>
226
     *
227
     * @see $validatedObjectPropertyVisibility
228 6
     */
229 6
    public function getValidatedObjectPropertyVisibility(): int
230
    {
231
        return $this->validatedObjectPropertyVisibility;
232
    }
233
234
    /**
235
     * Gets error message used when validation fails because the validated value is not an object and the rules were not
236
     * explicitly specified via {@see $rules}.
237
     *
238
     * @return string Error message / template.
239
     *
240
     * @see $incorrectInputMessage
241
     */
242
    public function getNoRulesWithNoObjectMessage(): string
243
    {
244
        return $this->noRulesWithNoObjectMessage;
245
    }
246
247
    /**
248
     * Gets error message used when validation fails because the validated value is an object providing wrong type of
249
     * data (neither array nor an object).
250
     *
251
     * @return string Error message / template.
252
     *
253
     * @see $incorrectDataSetTypeMessage
254
     */
255
    public function getIncorrectDataSetTypeMessage(): string
256 7
    {
257
        return $this->incorrectDataSetTypeMessage;
258
    }
259
260
    /**
261
     * Gets error message used when validation fails because the validated value is neither an array nor an object.
262
     *
263
     * @return string Error message / template.
264
     *
265
     * @see $incorrectInputMessage
266
     */
267 7
    public function getIncorrectInputMessage(): string
268 7
    {
269
        return $this->incorrectInputMessage;
270
    }
271
272 7
    /**
273
     * Whether to require a single data item to be passed in data according to declared nesting level structure (all
274
     * keys in the sequence must be the present). Enabled by default.
275 1
     *
276
     * @return bool `true` if required and `false` otherwise.
277 1
     *
278
     * @see $requirePropertyPath
279
     */
280
    public function isPropertyPathRequired(): bool
281
    {
282
        return $this->requirePropertyPath;
283
    }
284
285
    /**
286 1
     * Gets error message used when validation fails because {@see $requirePropertyPath} option was enabled and the
287 1
     * validated array contains missing data item.
288 1
     *
289 1
     * @return string Error message / template.
290
     *
291 1
     * @see $getNoPropertyPathMessage
292 1
     */
293
    public function getNoPropertyPathMessage(): string
294 1
    {
295 1
        return $this->noPropertyPathMessage;
296
    }
297
298 1
    /**
299
     * Prepares raw rules passed in the constructor for usage in handler. As a result, {@see $rules} property will
300 1
     * contain ready to use rules.
301 1
     *
302
     * @param iterable|object|string|null $source Raw rules passed in the constructor.
303
     *
304
     * @throws InvalidArgumentException When rules' source has wrong type.
305
     * @throws InvalidArgumentException When source contains items that are not rules.
306 1
     */
307
    private function setRules(iterable|object|string|null $source): void
308
    {
309 5
        if ($source === null) {
310
            $this->rules = null;
311
            return;
312
        }
313
314
        if ($source instanceof RulesProviderInterface) {
315
            $rules = $source->getRules();
316
        } elseif (is_string($source) && class_exists($source)) {
317
            $rules = (new AttributesRulesProvider($source, $this->rulesSourceClassPropertyVisibility))->getRules();
318
        } elseif (is_iterable($source)) {
319
            $rules = $source;
320
        } else {
321
            throw new InvalidArgumentException(
322
                'The $rules argument passed to Nested rule can be either: a null, an object implementing ' .
323 5
                'RulesProviderInterface, a class string or an iterable.'
324
            );
325
        }
326
327 5
        self::ensureArrayHasRules($rules);
328
329
        if ($this->handleEachShortcut) {
330
            $this->handleEachShortcut($rules);
331 5
        }
332
333
        $preparedRules = [];
334
        $this->prepareRules($rules, $preparedRules);
335 5
336
        $this->rules = $preparedRules;
337
338 5
        if ($this->propagateOptions) {
339 5
            $this->propagateOptions();
340 5
        }
341 5
    }
342
343
    /**
344
     * @psalm-param array<array<RuleInterface>|RuleInterface> $rawRules
345 53
     * @psalm-param array<list<RuleInterface>|RuleInterface> $result
346
     */
347 53
    private function prepareRules(array $rawRules, array &$result, ?string $baseValuePath = null): void
348
    {
349
        foreach ($rawRules as $valuePath => $validationRules) {
350
            if (is_int($valuePath)) {
351
                $key = $baseValuePath;
352
            } else {
353
                $key = ($baseValuePath !== null ? $baseValuePath . '.' : '') . $valuePath;
354
            }
355
356
            if (is_array($validationRules)) {
357
                $this->prepareRules($validationRules, $result, $key);
358
                continue;
359
            }
360
361
            if ($key === null) {
362
                $result[] = $validationRules;
363
            } else {
364
                /** @psalm-suppress UndefinedInterfaceMethod */
365
                $result[$key][] = $validationRules;
366
            }
367
        }
368
    }
369
370
    /**
371
     * Recursively checks that every item of source iterable is a valid rule instance ({@see RuleInterface}). As a
372
     * result, all iterables will be converted to arrays at the end, while single rules will be kept as is.
373
     *
374
     * @param iterable $rules Source iterable that will be checked and converted to array (so it's passed by reference).
375
     *
376
     * @psalm-param-out array<array<RuleInterface>|RuleInterface> $rules
377
     *
378
     * @throws InvalidArgumentException When iterable contains items that are not rules.
379
     *
380
     * @psalm-suppress ReferenceConstraintViolation
381
     */
382
    private static function ensureArrayHasRules(iterable &$rules): void
383
    {
384
        if ($rules instanceof Traversable) {
385
            $rules = iterator_to_array($rules);
386
        }
387
388
        /** @var mixed $rule */
389
        foreach ($rules as &$rule) {
390
            if (is_iterable($rule)) {
391
                self::ensureArrayHasRules($rule);
392
            } elseif (!$rule instanceof RuleInterface) {
393
                $message = sprintf(
394
                    'Every rule must be an instance of %s, %s given.',
395
                    RuleInterface::class,
396
                    get_debug_type($rule)
397
                );
398
399
                throw new InvalidArgumentException($message);
400
            }
401
        }
402
    }
403
404
    /**
405
     * Converts rules defined with {@see EACH_SHORTCUT} to separate `Nested` and `Each` rules.
406
     *
407
     * @psalm-param array<array<RuleInterface>|RuleInterface> $rules
408
     */
409
    private function handleEachShortcut(array &$rules): void
410
    {
411
        while (true) {
412
            $breakWhile = true;
413
            $rulesMap = [];
414
415
            foreach ($rules as $valuePath => $rule) {
416
                if ($valuePath === self::EACH_SHORTCUT) {
417
                    throw new InvalidArgumentException('Bare shortcut is prohibited. Use "Each" rule instead.');
418
                }
419
420
                $parts = StringHelper::parsePath(
421
                    (string) $valuePath,
422
                    delimiter: self::EACH_SHORTCUT,
423
                    preserveDelimiterEscaping: true
424
                );
425
                if (count($parts) === 1) {
426
                    continue;
427
                }
428
429
                /**
430
                 * Might be a bug of XDebug, because these lines are covered by tests.
431
                 *
432
                 * @see NestedTest::dataWithOtherNestedAndEach() for test cases prefixed with "withShortcut".
433
                 */
434
                // @codeCoverageIgnoreStart
435
                $breakWhile = false;
436
437
                $lastValuePath = array_pop($parts);
438
                $lastValuePath = ltrim($lastValuePath, '.');
439
                $lastValuePath = str_replace('\\' . self::EACH_SHORTCUT, self::EACH_SHORTCUT, $lastValuePath);
440
441
                $remainingValuePath = implode(self::EACH_SHORTCUT, $parts);
442
                $remainingValuePath = rtrim($remainingValuePath, self::SEPARATOR);
443
444
                if (!isset($rulesMap[$remainingValuePath])) {
445
                    $rulesMap[$remainingValuePath] = [];
446
                }
447
448
                $rulesMap[$remainingValuePath][$lastValuePath] = $rule;
449
                unset($rules[$valuePath]);
450
                // @codeCoverageIgnoreEnd
451
            }
452
453
            foreach ($rulesMap as $valuePath => $nestedRules) {
454
                /**
455
                 * Might be a bug of XDebug, because this line is covered by tests.
456
                 *
457
                 * @see NestedTest::dataWithOtherNestedAndEach() for test cases prefixed with "withShortcut".
458
                 */
459
                // @codeCoverageIgnoreStart
460
                $rules[$valuePath] = new Each([new self($nestedRules, handleEachShortcut: false)]);
461
                // @codeCoverageIgnoreEnd
462
            }
463
464
            if ($breakWhile === true) {
465
                break;
466
            }
467
        }
468
    }
469
470
    public function propagateOptions(): void
471
    {
472
        if ($this->rules === null) {
473
            return;
474
        }
475
476
        $rules = [];
477
        foreach ($this->rules as $attributeRulesIndex => $attributeRules) {
478
            $rules[$attributeRulesIndex] = is_iterable($attributeRules)
479
                ? PropagateOptionsHelper::propagate($this, $attributeRules)
480
                : PropagateOptionsHelper::propagateRule($this, $attributeRules);
481
        }
482
483
        $this->rules = $rules;
484
    }
485
486
    public function afterInitAttribute(object $object, int $target): void
487
    {
488
        if ($this->rules === null) {
489
            return;
490
        }
491
492
        foreach ($this->rules as $rules) {
493
            if (is_array($rules)) {
494
                foreach ($rules as $rule) {
495
                    if ($rule instanceof AfterInitAttributeEventInterface) {
496
                        $rule->afterInitAttribute($object, $target);
497
                    }
498
                }
499
            } else {
500
                if ($rules instanceof AfterInitAttributeEventInterface) {
501
                    $rules->afterInitAttribute($object, $target);
502
                }
503
            }
504
        }
505
    }
506
507
    #[ArrayShape([
508
        'requirePropertyPath' => 'bool',
509
        'noRulesWithNoObjectMessage' => 'array',
510
        'incorrectDataSetTypeMessage' => 'array',
511
        'incorrectInputMessage' => 'array',
512
        'noPropertyPathMessage' => 'array',
513
        'skipOnEmpty' => 'bool',
514
        'skipOnError' => 'bool',
515
        'rules' => 'array|null',
516
    ])]
517
    public function getOptions(): array
518
    {
519
        return [
520
            'noRulesWithNoObjectMessage' => [
521
                'template' => $this->noRulesWithNoObjectMessage,
522
                'parameters' => [],
523
            ],
524
            'incorrectDataSetTypeMessage' => [
525
                'template' => $this->incorrectDataSetTypeMessage,
526
                'parameters' => [],
527
            ],
528
            'incorrectInputMessage' => [
529
                'template' => $this->incorrectInputMessage,
530
                'parameters' => [],
531
            ],
532
            'noPropertyPathMessage' => [
533
                'template' => $this->getNoPropertyPathMessage(),
534
                'parameters' => [],
535
            ],
536
            'requirePropertyPath' => $this->isPropertyPathRequired(),
537
            'skipOnEmpty' => $this->getSkipOnEmptyOption(),
538
            'skipOnError' => $this->skipOnError,
539
            'rules' => $this->rules === null ? null : RulesDumper::asArray($this->rules),
540
        ];
541
    }
542
543
    public function getHandler(): string
544
    {
545
        return NestedHandler::class;
546
    }
547
}
548