Passed
Pull Request — master (#564)
by Sergei
02:39
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
 * $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