Passed
Pull Request — master (#468)
by Sergei
03:08
created

Nested   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 324
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 4
Bugs 2 Features 0
Metric Value
eloc 120
dl 0
loc 324
ccs 82
cts 82
cp 1
rs 8.64
c 4
b 2
f 0
wmc 47

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getRequirePropertyPath() 0 3 1
A getOptions() 0 33 2
A getNoRulesWithNoObjectMessage() 0 3 1
A getRules() 0 3 1
A ensureArrayHasRules() 0 15 5
A __construct() 0 37 1
B propagateOptions() 0 34 8
A getIncorrectInputMessage() 0 3 1
B prepareRules() 0 30 8
B normalizeRules() 0 63 8
A getNoPropertyPathMessage() 0 3 1
A getPropertyVisibility() 0 3 1
A getName() 0 3 1
A getIncorrectDataSetTypeMessage() 0 3 1
A afterInitAttribute() 0 10 6
A getHandler() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Nested often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Nested, and based on these observations, apply Extract Interface, too.

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\PropagateOptionsInterface;
16
use Yiisoft\Validator\Rule\Trait\SkipOnEmptyTrait;
17
use Yiisoft\Validator\Rule\Trait\SkipOnErrorTrait;
18
use Yiisoft\Validator\Rule\Trait\WhenTrait;
19
use Yiisoft\Validator\RuleInterface;
20
use Yiisoft\Validator\Helper\RulesDumper;
21
use Yiisoft\Validator\RulesProvider\AttributesRulesProvider;
22
use Yiisoft\Validator\RulesProviderInterface;
23
use Yiisoft\Validator\RuleWithOptionsInterface;
24
use Yiisoft\Validator\SkipOnEmptyInterface;
25
use Yiisoft\Validator\SkipOnErrorInterface;
26
use Yiisoft\Validator\Tests\Rule\NestedTest;
27
use Yiisoft\Validator\WhenInterface;
28
29
use function array_pop;
30
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...
31
use function implode;
32
use function is_string;
33
use function ltrim;
34
use function rtrim;
35
use function sprintf;
36
37
/**
38
 * Can be used for validation of nested structures.
39
 *
40
 * @psalm-import-type WhenType from WhenInterface
41
 */
42
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
43
final class Nested implements
44
    RuleWithOptionsInterface,
45
    SkipOnErrorInterface,
46
    WhenInterface,
47
    SkipOnEmptyInterface,
48
    PropagateOptionsInterface,
49
    AfterInitAttributeEventInterface
50
{
51
    use SkipOnEmptyTrait;
52
    use SkipOnErrorTrait;
53
    use WhenTrait;
54
55
    private const SEPARATOR = '.';
56
    private const EACH_SHORTCUT = '*';
57
58
    /**
59
     * @var iterable<iterable<RuleInterface>|RuleInterface>|null
60
     */
61
    private iterable|null $rules;
62
63
    /**
64
     * @param iterable|object|string|null $rules Rules for validate value that can be described by:
65
     *
66
     * - object that implement {@see RulesProviderInterface};
67
     * - name of class from whose attributes their will be derived;
68
     * - array or object implementing the `Traversable` interface that contain {@see RuleInterface} implementations
69
     *   or closures.
70
     *
71
     * `$rules` can be null if validatable value is object. In this case rules will be derived from object via
72
     * `getRules()` method if object implement {@see RulesProviderInterface} or from attributes otherwise.
73
     */
74
    public function __construct(
75 26
        iterable|object|string|null $rules = null,
76
0 ignored issues
show
Coding Style introduced by
Blank lines are not allowed in a multi-line function declaration
Loading history...
77
        /**
78
         * @var int What visibility levels to use when reading data and rules from validated object.
79
         */
80
        private int $propertyVisibility = ReflectionProperty::IS_PRIVATE
81
        | ReflectionProperty::IS_PROTECTED
82
        | ReflectionProperty::IS_PUBLIC,
83
        /**
84
         * @var int What visibility levels to use when reading rules from the class specified in {@see $rules}
85
         * attribute.
86
         */
87
        private int $rulesPropertyVisibility = ReflectionProperty::IS_PRIVATE
88
        | ReflectionProperty::IS_PROTECTED
89
        | ReflectionProperty::IS_PUBLIC,
90
        private string $noRulesWithNoObjectMessage = 'Nested rule without rules can be used for objects only.',
91
        private string $incorrectDataSetTypeMessage = 'An object data set data can only have an array or an object ' .
92
        'type.',
93
        private string $incorrectInputMessage = 'The value must have an array or an object type.',
94
        private bool $requirePropertyPath = false,
95
        private string $noPropertyPathMessage = 'Property "{path}" is not found.',
96
        private bool $normalizeRules = true,
97
        private bool $propagateOptions = false,
98
0 ignored issues
show
Coding Style introduced by
Blank lines are not allowed in a multi-line function declaration
Loading history...
99
        /**
100
         * @var bool|callable|null
101
         */
102
        private $skipOnEmpty = null,
103
        private bool $skipOnError = false,
104
0 ignored issues
show
Coding Style introduced by
Blank lines are not allowed in a multi-line function declaration
Loading history...
105
        /**
106
         * @var WhenType
107
         */
108
        private Closure|null $when = null,
109
    ) {
110
        $this->prepareRules($rules);
111 26
    }
112
113
    public function getName(): string
114 2
    {
115
        return 'nested';
116 2
    }
117
118
    /**
119
     * @return iterable<iterable<RuleInterface>|RuleInterface>|null
120
     */
121
    public function getRules(): iterable|null
122 53
    {
123
        return $this->rules;
124 53
    }
125
126
    public function getPropertyVisibility(): int
127 11
    {
128
        return $this->propertyVisibility;
129 11
    }
130
131
    public function getNoRulesWithNoObjectMessage(): string
132 5
    {
133
        return $this->noRulesWithNoObjectMessage;
134 5
    }
135
136
    public function getIncorrectDataSetTypeMessage(): string
137 3
    {
138
        return $this->incorrectDataSetTypeMessage;
139 3
    }
140
141
    public function getIncorrectInputMessage(): string
142 5
    {
143
        return $this->incorrectInputMessage;
144 5
    }
145
146
    public function getRequirePropertyPath(): bool
147 39
    {
148
        return $this->requirePropertyPath;
149 39
    }
150
151
    public function getNoPropertyPathMessage(): string
152 10
    {
153
        return $this->noPropertyPathMessage;
154 10
    }
155
156
    private function prepareRules(iterable|object|string|null $source): void
157
    {
158
        if ($source === null) {
159
            $this->rules = null;
160 26
161
            return;
162 26
        }
163 16
164
        if ($source instanceof RulesProviderInterface) {
165 16
            $rules = $source->getRules();
166
        } elseif (is_string($source) && class_exists($source)) {
167
            $rules = (new AttributesRulesProvider($source, $this->rulesPropertyVisibility))->getRules();
168 10
        } elseif (is_iterable($source)) {
169 1
            $rules = $source;
170 9
        } else {
171 4
            throw new InvalidArgumentException(
172
                'The $rules argument passed to Nested rule can be either: a null, an object implementing ' .
173 5
                'RulesProviderInterface, a class string or an iterable.'
174
            );
175
        }
176 10
177 8
        self::ensureArrayHasRules($rules);
178
        $this->rules = $rules;
179 8
180 8
        if ($this->normalizeRules) {
181
            $this->normalizeRules();
182
        }
183 7
184 1
        if ($this->propagateOptions) {
185
            $this->propagateOptions();
186
        }
187
    }
188
189
    /**
190
     * @psalm-assert iterable<RuleInterface> $rules
191 10
     */
192
    private static function ensureArrayHasRules(iterable &$rules): void
193 10
    {
194
        $rules = $rules instanceof Traversable ? iterator_to_array($rules) : $rules;
195 10
        /** @var mixed $rule */
196 9
        foreach ($rules as &$rule) {
197 7
            if (is_iterable($rule)) {
198 9
                self::ensureArrayHasRules($rule);
199 2
            } elseif (!$rule instanceof RuleInterface) {
200
                $message = sprintf(
201
                    'Every rule must be an instance of %s, %s given.',
202 2
                    RuleInterface::class,
203
                    get_debug_type($rule)
204
                );
205 2
206
                throw new InvalidArgumentException($message);
207
            }
208
        }
209
    }
210 8
211
    private function normalizeRules(): void
212
    {
213 8
        /** @var RuleInterface[] $rules Conversion to array is done in {@see ensureArrayHasRules()}. */
214 8
        $rules = $this->rules;
215 8
        while (true) {
216 8
            $breakWhile = true;
217
            $rulesMap = [];
218 8
219 7
            foreach ($rules as $valuePath => $rule) {
220 1
                if ($valuePath === self::EACH_SHORTCUT) {
221
                    throw new InvalidArgumentException('Bare shortcut is prohibited. Use "Each" rule instead.');
222
                }
223 6
224
                $parts = StringHelper::parsePath(
225
                    (string) $valuePath,
226
                    delimiter: self::EACH_SHORTCUT,
227
                    preserveDelimiterEscaping: true
228 6
                );
229 6
                if (count($parts) === 1) {
230
                    continue;
231
                }
232
233
                /**
234
                 * Might be a bug of XDebug, because these lines are covered by tests.
235
                 *
236
                 * @see NestedTest::dataWithOtherNestedAndEach() for test cases prefixed with "withShortcut".
237
                 */
238
                // @codeCoverageIgnoreStart
239
                $breakWhile = false;
240
241
                $lastValuePath = array_pop($parts);
242
                $lastValuePath = ltrim($lastValuePath, '.');
243
                $lastValuePath = str_replace('\\' . self::EACH_SHORTCUT, self::EACH_SHORTCUT, $lastValuePath);
244
245
                $remainingValuePath = implode(self::EACH_SHORTCUT, $parts);
246
                $remainingValuePath = rtrim($remainingValuePath, self::SEPARATOR);
247
248
                if (!isset($rulesMap[$remainingValuePath])) {
249
                    $rulesMap[$remainingValuePath] = [];
250
                }
251
252
                $rulesMap[$remainingValuePath][$lastValuePath] = $rule;
253
                unset($rules[$valuePath]);
254
                // @codeCoverageIgnoreEnd
255
            }
256 7
257
            foreach ($rulesMap as $valuePath => $nestedRules) {
258
                /**
259
                 * Might be a bug of XDebug, because this line is covered by tests.
260
                 *
261
                 * @see NestedTest::dataWithOtherNestedAndEach() for test cases prefixed with "withShortcut".
262
                 */
263
                // @codeCoverageIgnoreStart
264
                $rules[$valuePath] = new Each([new self($nestedRules, normalizeRules: false)]);
265
                // @codeCoverageIgnoreEnd
266
            }
267 7
268 7
            if ($breakWhile === true) {
269
                break;
270
            }
271
        }
272 7
273
        $this->rules = $rules;
274
    }
275 1
276
    public function propagateOptions(): void
277 1
    {
278
        if ($this->rules === null) {
279
            return;
280
        }
281
282
        $rules = [];
283
284
        /**
285
         * @var int|string $attributeRulesIndex Index is either integer or string because of the array conversion in
286 1
         * {@see ensureArrayHasRules()}.
287 1
         * @var RuleInterface[] $attributeRules Conversion to array is done in {@see ensureArrayHasRules()}.
288 1
         */
289 1
        foreach ($this->rules as $attributeRulesIndex => $attributeRules) {
290
            foreach ($attributeRules as $attributeRule) {
291 1
                if ($attributeRule instanceof SkipOnEmptyInterface) {
292 1
                    $attributeRule = $attributeRule->skipOnEmpty($this->skipOnEmpty);
293
                }
294 1
                if ($attributeRule instanceof SkipOnErrorInterface) {
295 1
                    $attributeRule = $attributeRule->skipOnError($this->skipOnError);
296
                }
297
                if ($attributeRule instanceof WhenInterface) {
298 1
                    $attributeRule = $attributeRule->when($this->when);
299
                }
300 1
301 1
                $rules[$attributeRulesIndex][] = $attributeRule;
302
303
                if ($attributeRule instanceof PropagateOptionsInterface) {
304
                    $attributeRule->propagateOptions();
305
                }
306 1
            }
307
        }
308
309 5
        $this->rules = $rules;
310
    }
311
312
    public function afterInitAttribute(object $object, int $target): void
313
    {
314
        if ($this->rules === null) {
315
            return;
316
        }
317
318
        foreach ($this->rules as $rules) {
319
            foreach ((is_iterable($rules) ? $rules : [$rules]) as $rule) {
320
                if ($rule instanceof AfterInitAttributeEventInterface) {
321
                    $rule->afterInitAttribute($object, $target);
322
                }
323 5
            }
324
        }
325
    }
326
327 5
    #[ArrayShape([
328
        'requirePropertyPath' => 'bool',
329
        'noRulesWithNoObjectMessage' => 'array',
330
        'incorrectDataSetTypeMessage' => 'array',
331 5
        'incorrectInputMessage' => 'array',
332
        'noPropertyPathMessage' => 'array',
333
        'skipOnEmpty' => 'bool',
334
        'skipOnError' => 'bool',
335 5
        'rules' => 'array|null',
336
    ])]
337
    public function getOptions(): array
338 5
    {
339 5
        return [
340 5
            'noRulesWithNoObjectMessage' => [
341 5
                'template' => $this->noRulesWithNoObjectMessage,
342
                'parameters' => [],
343
            ],
344
            'incorrectDataSetTypeMessage' => [
345 53
                'template' => $this->incorrectDataSetTypeMessage,
346
                'parameters' => [],
347 53
            ],
348
            'incorrectInputMessage' => [
349
                'template' => $this->incorrectInputMessage,
350
                'parameters' => [],
351
            ],
352
            'noPropertyPathMessage' => [
353
                'template' => $this->getNoPropertyPathMessage(),
354
                'parameters' => [],
355
            ],
356
            'requirePropertyPath' => $this->getRequirePropertyPath(),
357
            'skipOnEmpty' => $this->getSkipOnEmptyOption(),
358
            'skipOnError' => $this->skipOnError,
359
            'rules' => $this->rules === null ? null : (new RulesDumper())->asArray($this->rules),
360
        ];
361
    }
362
363
    public function getHandler(): string
364
    {
365
        return NestedHandler::class;
366
    }
367
}
368