Passed
Pull Request — master (#505)
by Alexander
03:21 queued 18s
created

Nested::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

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