Passed
Push — master ( 22adb7...6fe6d4 )
by Sergei
02:37
created

Nested::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 13
dl 0
loc 21
ccs 3
cts 3
cp 1
crap 1
rs 10
c 2
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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