Passed
Pull Request — master (#505)
by
unknown
03:17
created

Nested::getRequirePropertyPath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
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 $handleEachShortcut Whether to handle {@see EACH_SHORTCUT}. Enabled by default meaning shortcuts are
180 8
     * supported. Can be disabled if they are not used to prevent additional checks and improve performance.
181
     * @param bool $propagateOptions Whether the propagation of options is enabled (see
182
     * {@see PropagateOptionsHelper::propagate()} for supported options and requirements). Disabled by default.
183 7
     * @param bool|callable|null $skipOnEmpty Whether to skip this `Nested` rule with all defined {@see $rules} if the
184 1
     * validated value is empty / not passed. See {@see SkipOnEmptyInterface}.
185
     * @param bool $skipOnError Whether to skip this `Nested` rule with all defined {@see $rules} if any of the previous
186
     * rules gave an error. See {@see SkipOnErrorInterface}.
187
     * @param Closure|null $when  A callable to define a condition for applying this `Nested` rule with all defined
188
     * {@see $rules}. See {@see WhenInterface}.
189
     * @psalm-param WhenType $when
190
     */
191 10
    public function __construct(
192
        iterable|object|string|null $rules = null,
193 10
        private int $validatedObjectPropertyVisibility = ReflectionProperty::IS_PRIVATE
194
        | ReflectionProperty::IS_PROTECTED
195 10
        | ReflectionProperty::IS_PUBLIC,
196 9
        private int $rulesSourceClassPropertyVisibility = ReflectionProperty::IS_PRIVATE
197 7
        | ReflectionProperty::IS_PROTECTED
198 9
        | ReflectionProperty::IS_PUBLIC,
199 2
        private string $noRulesWithNoObjectMessage = 'Nested rule without rules can be used for objects only.',
200
        private string $incorrectDataSetTypeMessage = 'An object data set data can only have an array or an object ' .
201
        'type.',
202 2
        private string $incorrectInputMessage = 'The value must have an array or an object type.',
203
        private bool $requirePropertyPath = false,
204
        private string $noPropertyPathMessage = 'Property "{path}" is not found.',
205 2
        private bool $handleEachShortcut = true,
206
        private bool $propagateOptions = false,
207
        private mixed $skipOnEmpty = null,
208
        private bool $skipOnError = false,
209
        private Closure|null $when = null,
210 8
    ) {
211
        $this->prepareRules($rules);
212
    }
213 8
214 8
    public function getName(): string
215 8
    {
216 8
        return 'nested';
217
    }
218 8
219 7
    /**
220 1
     * @return iterable<iterable<RuleInterface>|RuleInterface>|null
221
     */
222
    public function getRules(): iterable|null
223 6
    {
224
        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...
225
    }
226
227
    /**
228 6
     * Gets visibility levels to use for parsed properties when validated value is an object providing rules / data.
229 6
     * Defaults to all visibility levels (public, protected and private)
230
     *
231
     * @return int A number representing visibility levels.
232
     * @psalm-return int-mask-of<ReflectionProperty::IS_*>
233
     *
234
     * @see $validatedObjectPropertyVisibility
235
     */
236
    public function getValidatedObjectPropertyVisibility(): int
237
    {
238
        return $this->validatedObjectPropertyVisibility;
239
    }
240
241
    /**
242
     * Gets error message used when validation fails because the validated value is not an object and the rules were not
243
     * explicitly specified via {@see $rules}.
244
     *
245
     * @return string Error message / template.
246
     *
247
     * @see $incorrectInputMessage
248
     */
249
    public function getNoRulesWithNoObjectMessage(): string
250
    {
251
        return $this->noRulesWithNoObjectMessage;
252
    }
253
254
    /**
255
     * Gets error message used when validation fails because the validated value is an object providing wrong type of
256 7
     * data (neither array nor an object).
257
     *
258
     * @return string Error message / template.
259
     *
260
     * @see $incorrectDataSetTypeMessage
261
     */
262
    public function getIncorrectDataSetTypeMessage(): string
263
    {
264
        return $this->incorrectDataSetTypeMessage;
265
    }
266
267 7
    /**
268 7
     * Gets error message used when validation fails because the validated value is neither an array nor an object.
269
     *
270
     * @return string Error message / template.
271
     *
272 7
     * @see $incorrectInputMessage
273
     */
274
    public function getIncorrectInputMessage(): string
275 1
    {
276
        return $this->incorrectInputMessage;
277 1
    }
278
279
    /**
280
     * Whether to require a single data item to be passed in data according to declared nesting level structure (all
281
     * keys in the sequence must be the present). Enabled by default.
282
     *
283
     * @return bool `true` if required and `false` otherwise.
284
     *
285
     * @see $requirePropertyPath
286 1
     */
287 1
    public function isPropertyPathRequired(): bool
288 1
    {
289 1
        return $this->requirePropertyPath;
290
    }
291 1
292 1
    /**
293
     * Gets error message used when validation fails because {@see $requirePropertyPath} option was enabled and the
294 1
     * validated array contains missing data item.
295 1
     *
296
     * @return string Error message / template.
297
     *
298 1
     * @see $getNoPropertyPathMessage
299
     */
300 1
    public function getNoPropertyPathMessage(): string
301 1
    {
302
        return $this->noPropertyPathMessage;
303
    }
304
305
    /**
306 1
     * Prepares raw rules passed in the constructor for usage in handler. As a result, {@see $rules} property will
307
     * contain ready to use rules.
308
     *
309 5
     * @param iterable|object|string|null $source Raw rules passed in the constructor.
310
     *
311
     * @throws InvalidArgumentException When the maximum level for iterables has been reached.
312
     * @throws InvalidArgumentException When rules' source has wrong type.
313
     * @throws InvalidArgumentException When source contains items that are not rules.
314
     */
315
    private function prepareRules(iterable|object|string|null $source): void
316
    {
317
        if ($source === null) {
318
            $this->rules = null;
319
320
            return;
321
        }
322
323 5
        if ($source instanceof RulesProviderInterface) {
324
            $rules = $source->getRules();
325
        } elseif (is_string($source) && class_exists($source)) {
326
            $rules = (new AttributesRulesProvider($source, $this->rulesSourceClassPropertyVisibility))->getRules();
327 5
        } elseif (is_iterable($source)) {
328
            $rules = $source;
329
        } else {
330
            throw new InvalidArgumentException(
331 5
                'The $rules argument passed to Nested rule can be either: a null, an object implementing ' .
332
                'RulesProviderInterface, a class string or an iterable.'
333
            );
334
        }
335 5
336
        self::ensureArrayHasRules($rules);
337
        /** @var array<array<RuleInterface>|RuleInterface>|null $rules */
338 5
        $this->rules = $rules;
339 5
340 5
        if ($this->handleEachShortcut) {
341 5
            $this->handleEachShortcut();
342
        }
343
344
        if ($this->propagateOptions) {
345 53
            $this->propagateOptions();
346
        }
347 53
    }
348
349
    /**
350
     * Recursively checks that every item of source iterable is a valid rule instance ({@see RuleInterface}). The
351
     * maximum nesting level for iterables is 2 and single rules are only allowed within it. As a result, all iterables
352
     * will be converted to arrays at the end, while single rules will be kept as is.
353
     *
354
     * @param iterable $rules Source iterable that will be checked and converted to array (so it's passed by reference).
355
     * @param bool $allowRecursion An extra flag indicating that the maximum nesting level for iterables is reached and
356
     * recursion must be stopped.
357
     *
358
     * @throws InvalidArgumentException When the maximum level for iterables has been reached.
359
     * @throws InvalidArgumentException When iterable contains items that are not rules.
360
     */
361
    private static function ensureArrayHasRules(iterable &$rules, bool $allowRecursion = true): void
362
    {
363
        if ($rules instanceof Traversable) {
364
            $rules = iterator_to_array($rules);
365
        }
366
367
        /** @var mixed $rule */
368
        foreach ($rules as &$rule) {
369
            if (is_iterable($rule)) {
370
                if (!$allowRecursion) {
371
                    $message = 'The maximum nesting level for iterables is 2. For further nesting, use another ' .
372
                        'instance instead.';
373
374
                    throw new InvalidArgumentException($message);
375
                }
376
377
                self::ensureArrayHasRules($rule, false);
378
            } elseif (!$rule instanceof RuleInterface) {
379
                $message = sprintf(
380
                    'Every rule must be an instance of %s, %s given.',
381
                    RuleInterface::class,
382
                    get_debug_type($rule)
383
                );
384
385
                throw new InvalidArgumentException($message);
386
            }
387
        }
388
    }
389
390
    /**
391
     * Converts rules defined with {@see EACH_SHORTCUT} to separate `Nested` and `Each` rules.
392
     */
393
    private function handleEachShortcut(): void
394
    {
395
        /** @var RuleInterface[] $rules Conversion to array is done in {@see ensureArrayHasRules()}. */
396
        $rules = $this->rules;
397
        while (true) {
398
            $breakWhile = true;
399
            $rulesMap = [];
400
401
            foreach ($rules as $valuePath => $rule) {
402
                if ($valuePath === self::EACH_SHORTCUT) {
403
                    throw new InvalidArgumentException('Bare shortcut is prohibited. Use "Each" rule instead.');
404
                }
405
406
                $parts = StringHelper::parsePath(
407
                    (string) $valuePath,
408
                    delimiter: self::EACH_SHORTCUT,
409
                    preserveDelimiterEscaping: true
410
                );
411
                if (count($parts) === 1) {
412
                    continue;
413
                }
414
415
                /**
416
                 * Might be a bug of XDebug, because these lines are covered by tests.
417
                 *
418
                 * @see NestedTest::dataWithOtherNestedAndEach() for test cases prefixed with "withShortcut".
419
                 */
420
                // @codeCoverageIgnoreStart
421
                $breakWhile = false;
422
423
                $lastValuePath = array_pop($parts);
424
                $lastValuePath = ltrim($lastValuePath, '.');
425
                $lastValuePath = str_replace('\\' . self::EACH_SHORTCUT, self::EACH_SHORTCUT, $lastValuePath);
426
427
                $remainingValuePath = implode(self::EACH_SHORTCUT, $parts);
428
                $remainingValuePath = rtrim($remainingValuePath, self::SEPARATOR);
429
430
                if (!isset($rulesMap[$remainingValuePath])) {
431
                    $rulesMap[$remainingValuePath] = [];
432
                }
433
434
                $rulesMap[$remainingValuePath][$lastValuePath] = $rule;
435
                unset($rules[$valuePath]);
436
                // @codeCoverageIgnoreEnd
437
            }
438
439
            foreach ($rulesMap as $valuePath => $nestedRules) {
440
                /**
441
                 * Might be a bug of XDebug, because this line is covered by tests.
442
                 *
443
                 * @see NestedTest::dataWithOtherNestedAndEach() for test cases prefixed with "withShortcut".
444
                 */
445
                // @codeCoverageIgnoreStart
446
                $rules[$valuePath] = new Each([new self($nestedRules, handleEachShortcut: false)]);
447
                // @codeCoverageIgnoreEnd
448
            }
449
450
            if ($breakWhile === true) {
451
                break;
452
            }
453
        }
454
455
        $this->rules = $rules;
456
    }
457
458
    public function propagateOptions(): void
459
    {
460
        if ($this->rules === null) {
461
            return;
462
        }
463
464
        $rules = [];
465
        foreach ($this->rules as $attributeRulesIndex => $attributeRules) {
466
            $rules[$attributeRulesIndex] = PropagateOptionsHelper::propagate(
467
                $this,
468
                is_iterable($attributeRules) ? $attributeRules : [$attributeRules],
469
            );
470
        }
471
472
        $this->rules = $rules;
473
    }
474
475
    public function afterInitAttribute(object $object, int $target): void
476
    {
477
        if ($this->rules === null) {
478
            return;
479
        }
480
481
        foreach ($this->rules as $rules) {
482
            foreach ((is_iterable($rules) ? $rules : [$rules]) as $rule) {
483
                if ($rule instanceof AfterInitAttributeEventInterface) {
484
                    $rule->afterInitAttribute($object, $target);
485
                }
486
            }
487
        }
488
    }
489
490
    #[ArrayShape([
491
        'requirePropertyPath' => 'bool',
492
        'noRulesWithNoObjectMessage' => 'array',
493
        'incorrectDataSetTypeMessage' => 'array',
494
        'incorrectInputMessage' => 'array',
495
        'noPropertyPathMessage' => 'array',
496
        'skipOnEmpty' => 'bool',
497
        'skipOnError' => 'bool',
498
        'rules' => 'array|null',
499
    ])]
500
    public function getOptions(): array
501
    {
502
        return [
503
            'noRulesWithNoObjectMessage' => [
504
                'template' => $this->noRulesWithNoObjectMessage,
505
                'parameters' => [],
506
            ],
507
            'incorrectDataSetTypeMessage' => [
508
                'template' => $this->incorrectDataSetTypeMessage,
509
                'parameters' => [],
510
            ],
511
            'incorrectInputMessage' => [
512
                'template' => $this->incorrectInputMessage,
513
                'parameters' => [],
514
            ],
515
            'noPropertyPathMessage' => [
516
                'template' => $this->getNoPropertyPathMessage(),
517
                'parameters' => [],
518
            ],
519
            'requirePropertyPath' => $this->isPropertyPathRequired(),
520
            'skipOnEmpty' => $this->getSkipOnEmptyOption(),
521
            'skipOnError' => $this->skipOnError,
522
            'rules' => $this->rules === null ? null : RulesDumper::asArray($this->rules),
523
        ];
524
    }
525
526
    public function getHandler(): string
527
    {
528
        return NestedHandler::class;
529
    }
530
}
531