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