Passed
Pull Request — master (#550)
by Alexander
05:08 queued 02:32
created

Nested   A

Complexity

Total Complexity 41

Size/Duplication

Total Lines 428
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 5
Bugs 3 Features 0
Metric Value
eloc 112
c 5
b 3
f 0
dl 0
loc 428
ccs 78
cts 78
cp 1
rs 9.1199
wmc 41

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getValidatedObjectPropertyVisibility() 0 3 1
A getNoRulesWithNoObjectMessage() 0 3 1
A getOptions() 0 33 1
A getRules() 0 3 1
A afterInitAttribute() 0 6 5
A getHandler() 0 3 1
A ensureArrayHasRules() 0 18 5
A __construct() 0 21 1
A propagateOptions() 0 15 4
A getIncorrectInputMessage() 0 3 1
B prepareRules() 0 31 8
B handleEachShortcut() 0 63 8
A getNoPropertyPathMessage() 0 3 1
A getName() 0 3 1
A isPropertyPathRequired() 0 3 1
A getIncorrectDataSetTypeMessage() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Nested often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Nested, and based on these observations, apply Extract Interface, too.

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