Passed
Pull Request — master (#505)
by
unknown
30:46 queued 28:03
created

Nested::getPropertyVisibility()   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(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