Passed
Push — master ( e18482...3e6ebb )
by Alexander
11:33
created

ObjectParser   B

Complexity

Total Complexity 46

Size/Duplication

Total Lines 386
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 9
Bugs 4 Features 0
Metric Value
eloc 91
dl 0
loc 386
c 9
b 4
f 0
rs 8.72
ccs 15
cts 15
cp 1
wmc 46

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 39 5
A getRules() 0 31 5
A useCache() 0 3 1
A getAttributeTranslator() 0 5 2
A prepareRule() 0 6 3
A hasCacheItem() 0 13 3
A getReflectionSource() 0 14 3
A getData() 0 13 3
A getAttributeValue() 0 5 2
A hasAttribute() 0 3 2
A getLabels() 0 21 4
A setCacheItem() 0 11 2
A getReflectionProperties() 0 25 6
A prepareRules() 0 22 4
A getCacheItem() 0 6 1

How to fix   Complexity   

Complex Class

Complex classes like ObjectParser 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 ObjectParser, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Validator\Helper;
6
7
use Attribute;
8
use InvalidArgumentException;
9
use JetBrains\PhpStorm\ArrayShape;
10
use JetBrains\PhpStorm\ExpectedValues;
11
use ReflectionAttribute;
12
use ReflectionClass;
13
use ReflectionObject;
14
use ReflectionProperty;
15
use Yiisoft\Validator\AfterInitAttributeEventInterface;
16
use Yiisoft\Validator\AttributeTranslatorInterface;
17
use Yiisoft\Validator\AttributeTranslatorProviderInterface;
18
use Yiisoft\Validator\Label;
19
use Yiisoft\Validator\RuleInterface;
20
21
use function array_key_exists;
22
use function is_int;
23
use function is_object;
24
use function is_string;
25
26
/**
27
 * A helper class used to parse rules from PHP attributes (attached to class properties and class itself) and data from
28
 * object properties. The attributes introduced in PHP 8 simplify rules' configuration process, especially for nested
29
 * data and relations. This way the validated structures can be presented as DTO classes with references to each other.
30
 *
31 62
 * An example of parsed object with both one-to-one (requires PHP > 8.0) and one-to-many (requires PHP > 8.1) relations:
32
 *
33
 * ```php
34
 * final class Post
35
 * {
36
 *     #[Length(max: 255)]
37
 *     public string $title = '';
38
 *
39 62
 *     #[Nested]
40 61
 *     public Author|null $author = null;
41 1
 *
42
 *     // Passing instances is available only since PHP 8.1.
43
 *     #[Each(new Nested(File::class))]
44
 *     public array $files = [];
45
 *
46
 *     public function __construct()
47 39
 *     {
48
 *         $this->author = new Author();
49 39
 *     }
50
 * }
51 11
 *
52
 * final class Author
53
 * {
54 37
 *     #[Length(min: 1)]
55 37
 *     public string $name = '';
56
 * }
57 36
 *
58 36
 * // Some rules, like "Nested" can be also configured through the class attribute.
59 34
 *
60 34
 * #[Nested(['url' => new Url()])]
61
 * final class File
62 34
 * {
63 4
 *     public string $url = '';
64
 * }
65
 *
66
 * $post = new Post(title: 'Yii3 Overview 3', author: 'Dmitriy');
67
 * $parser = new ObjectParser($post);
68 36
 * $rules = $parser->getRules();
69 36
 * $data = $parser->getData();
70
 * ```
71
 *
72 36
 * The parsed `$rules` will contain:
73
 *
74
 * ```php
75 40
 * [
76
 *     new Nested([
77 40
 *         'title' => [new Length(max: 255)],
78
 *         'author' => new Nested([
79
 *             'name' => [new Length(min: 1)],
80 30
 *         ]),
81
 *         'files' => new Each([
82 30
 *             new Nested([
83
 *                 'url' => [new Url()],
84
 *             ]),
85 26
 *         ]),
86
 *     ]);
87 26
 * ];
88 26
 * ```
89
 *
90 22
 * And the result of `$data` will be:
91
 *
92
 * ```php
93 26
 * [
94
 *     'title' => 'Yii3 Overview 3',
95
 *     'author' => 'John',
96
 *     'files' => [],
97
 * ];
98
 * ```
99 66
 *
100
 * A class name string is valid as a source too. This way only rules will be parsed:
101 66
 *
102
 * ```php
103 46
 * $parser = new ObjectParser(Post::class);
104
 * $rules = $parser->getRules(); // The result is the same as in previous example.
105
 * $data = $parser->getData(); // Returns empty array.
106 52
 * ```
107 52
 *
108
 * Please refer to the guide for more examples.
109 52
 *
110 50
 * Note that the rule attributes can be combined with others without affecting parsing. Which properties to parse can be
111 1
 * configured via {@see ObjectParser::$propertyVisibility} and {@see ObjectParser::$skipStaticProperties} options.
112
 *
113
 * Uses Reflection for getting object data and metadata. Supports caching for Reflection of a class / an obhect with
114 50
 * properties and rules which can be disabled on demand.
115 50
 *
116
 * @link https://www.php.net/manual/en/language.attributes.overview.php
117
 *
118 50
 * @psalm-type RulesCacheItem = array{0:RuleInterface,1:Attribute::TARGET_*}
119
 */
120
final class ObjectParser
121 52
{
122 50
    /**
123
     * @var array A cache storage utilizing static class property:
124
     *
125 52
     * - The first nesting level is a mapping between cache keys (dynamically generated on instantiation) and item names
126
     * (one of: `rules`, `reflectionProperties`, `reflectionSource`).
127
     * - The second nesting level is a mapping between cache item names and their contents.
128 66
     *
129
     * Different properties' combinations of the same object are cached separately.
130
     *
131
     * @psalm-var array<string, array<string, mixed>>
132 66
     */
133 2
    #[ArrayShape([
134
        [
135
            'rules' => 'array',
136 64
            'reflectionAttributes' => 'array',
137 50
            'reflectionSource' => 'object',
138
            'labels' => 'array',
139
        ],
140 47
    ])]
141
    private static array $cache = [];
142
    /**
143 47
     * @var string|null A cache key. Dynamically generated on instantiation.
144
     */
145
    private string|null $cacheKey = null;
146
147
    /**
148 47
     * @throws InvalidArgumentException If a class name string provided in {@see $source} refers to a non-existing
149
     * class.
150
     */
151 50
    public function __construct(
152
        /**
153
         * @var object|string A source for parsing rules and data. Can be either a class name string or an
154
         * instance.
155
         *
156
         * @psalm-var class-string|object
157 50
         */
158
        private string|object $source,
159
        /**
160
         * @var int Visibility levels the parsed properties must have. For example: public and protected only, this
161
         * means that the rest (private ones) will be skipped. Defaults to all visibility levels (public, protected and
162
         * private).
163 66
         *
164
         * @psalm-var int-mask-of<ReflectionProperty::IS_*>
165 66
         */
166
        private int $propertyVisibility = ReflectionProperty::IS_PRIVATE |
167
        ReflectionProperty::IS_PROTECTED |
168
        ReflectionProperty::IS_PUBLIC,
169
        /**
170
         * @var bool Whether the properties with "static" modifier must be skipped.
171
         */
172
        private bool $skipStaticProperties = false,
173
        /**
174
         * @var bool Whether some results of parsing (Reflection of a class / an object with properties and rules) must
175
         * be cached.
176
         */
177
        bool $useCache = true,
178
    ) {
179
        /** @var object|string $source */
180
        if (is_string($source) && !class_exists($source)) {
181
            throw new InvalidArgumentException(
182
                sprintf('Class "%s" not found.', $source)
183
            );
184
        }
185
186
        if ($useCache) {
187
            $this->cacheKey = (is_object($source) ? $source::class : $source)
188
                . '_' . $this->propertyVisibility
189
                . '_' . (int) $this->skipStaticProperties;
190
        }
191
    }
192
193
    /**
194
     * Parses rules specified via attributes attached to class properties and class itself. Repetitive calls utilize
195
     * cache if it's enabled in {@see $useCache}.
196
     *
197
     * @return array<int, RuleInterface>|array<string, list<RuleInterface>> The resulting rules array with the following
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int, RuleInterface...g, list<RuleInterface>> at position 11 could not be parsed: Expected '>' at position 11, but found 'list'.
Loading history...
198
     * structure:
199
     *
200
     * ```php
201
     * [
202
     *     [new AtLeast(['name', 'author'])], // Parsed from class attribute.
203
     *     'files' => [new Count(max: 3)], // Parsed from property attribute.
204
     * ],
205
     * ```
206
     */
207
    public function getRules(): array
208
    {
209
        if ($this->hasCacheItem('rules')) {
210
            /** @var array $rules */
211
            $rules = $this->getCacheItem('rules');
212
            return $this->prepareRules($rules);
213
        }
214
215
        $rules = [];
216
217
        // Class rules
218
        $attributes = $this
219
            ->getReflectionSource()
220
            ->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
221
        foreach ($attributes as $attribute) {
222
            $rules[] = [$attribute->newInstance(), Attribute::TARGET_CLASS];
223
        }
224
225
        // Properties rules
226
        foreach ($this->getReflectionProperties() as $property) {
227
            // TODO: use Generator to collect attributes.
228
            $attributes = $property->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
229
            foreach ($attributes as $attribute) {
230
                /** @psalm-suppress UndefinedInterfaceMethod */
231
                $rules[$property->getName()][] = [$attribute->newInstance(), Attribute::TARGET_PROPERTY];
232
            }
233
        }
234
235
        $this->setCacheItem('rules', $rules);
236
237
        return $this->prepareRules($rules);
238
    }
239
240
    /**
241
     * Parses labels specified via {@see Label} attributes attached to class properties.
242
     *
243
     * @return array<string, string>
244
     */
245
    public function getLabels(): array
246
    {
247
        if ($this->hasCacheItem('labels')) {
248
            /** @var array<string, string> */
249
            return $this->getCacheItem('labels');
250
        }
251
252
        $labels = [];
253
254
        foreach ($this->getReflectionProperties() as $property) {
255
            $attributes = $property->getAttributes(Label::class, ReflectionAttribute::IS_INSTANCEOF);
256
            foreach ($attributes as $attribute) {
257
                /** @var Label $instance */
258
                $instance = $attribute->newInstance();
259
                $labels[$property->getName()] = $instance->getLabel();
260
            }
261
        }
262
263
        $this->setCacheItem('labels', $labels);
264
265
        return $labels;
266
    }
267
268
    /**
269
     * Returns a property value of the parsed object.
270
     *
271
     * Note that in case of non-existing property a default `null` value is returned. If you need to check the presence
272
     * of a property or return a different default value, use {@see hasAttribute()} instead.
273
     *
274
     * If a {@see $source} is a class name string, `null` value is always returned.
275
     *
276
     * @param string $attribute Attribute name.
277
     *
278
     * @return mixed Attribute value.
279
     */
280
    public function getAttributeValue(string $attribute): mixed
281
    {
282
        return is_object($this->source)
283
            ? ($this->getReflectionProperties()[$attribute] ?? null)?->getValue($this->source)
284
            : null;
285
    }
286
287
    /**
288
     * Whether the parsed object has the property with a given name. Note that this means existence only and properties
289
     * with empty values are treated as present too.
290
     *
291
     * If a {@see $source} is a class name string, `false` value is always returned.
292
     *
293
     * @return bool Whether the property exists: `true` - exists and `false` - otherwise.
294
     */
295
    public function hasAttribute(string $attribute): bool
296
    {
297
        return is_object($this->source) && array_key_exists($attribute, $this->getReflectionProperties());
298
    }
299
300
    /**
301
     * Returns the parsed object's data as a whole in a form of associative array.
302
     *
303
     * If a {@see $source} is a class name string, an empty array is always returned.
304
     *
305
     * @return array A mapping between property names and their values.
306
     */
307
    public function getData(): array
308
    {
309
        if (!is_object($this->source)) {
310
            return [];
311
        }
312
313
        $data = [];
314
        foreach ($this->getReflectionProperties() as $name => $property) {
315
            /** @var mixed */
316
            $data[$name] = $property->getValue($this->source);
317
        }
318
319
        return $data;
320
    }
321
322
    /**
323
     * An optional attribute names translator. It's taken from the {@see $source} object when
324
     * {@see AttributeTranslatorProviderInterface} is implemented. In case of it's missing or {@see $source} being a
325
     * class name string, a `null` value is returned.
326
     *
327
     * @return AttributeTranslatorInterface|null An attribute translator instance or `null if it was not provided.
328
     */
329
    public function getAttributeTranslator(): ?AttributeTranslatorInterface
330
    {
331
        return $this->source instanceof AttributeTranslatorProviderInterface
332
            ? $this->source->getAttributeTranslator()
333
            : null;
334
    }
335
336
    /**
337
     * Returns Reflection properties parsed from {@see $source} in accordance with {@see $propertyVisibility} and
338
     * {@see $skipStaticProperties} values. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
339
     *
340
     * @return array<string, ReflectionProperty> A mapping between Reflection property names and their values.
341
     *
342
     * @see https://github.com/yiisoft/form for usage in form collector.
343
     */
344
    public function getReflectionProperties(): array
345
    {
346
        if ($this->hasCacheItem('reflectionProperties')) {
347
            /** @var array<string, ReflectionProperty> */
348
            return $this->getCacheItem('reflectionProperties');
349
        }
350
351
        $reflectionProperties = [];
352
        foreach ($this->getReflectionSource()->getProperties($this->propertyVisibility) as $property) {
353
            if ($this->skipStaticProperties && $property->isStatic()) {
354
                continue;
355
            }
356
357
            /** @infection-ignore-all */
358
            if (PHP_VERSION_ID < 80100) {
359
                /** @psalm-suppress UnusedMethodCall Need for psalm with PHP 8.1+ */
360
                $property->setAccessible(true);
361
            }
362
363
            $reflectionProperties[$property->getName()] = $property;
364
        }
365
366
        $this->setCacheItem('reflectionProperties', $reflectionProperties);
367
368
        return $reflectionProperties;
369
    }
370
371
    /**
372
     * Returns Reflection of {@see $source}. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
373
     *
374
     * @return ReflectionClass|ReflectionObject Either a Reflection class or an object instance depending on what was
375
     * provided in {@see $source}.
376
     */
377
    private function getReflectionSource(): ReflectionObject|ReflectionClass
378
    {
379
        if ($this->hasCacheItem('reflectionSource')) {
380
            /** @var ReflectionClass|ReflectionObject */
381
            return $this->getCacheItem('reflectionSource');
382
        }
383
384
        $reflectionSource = is_object($this->source)
385
            ? new ReflectionObject($this->source)
386
            : new ReflectionClass($this->source);
387
388
        $this->setCacheItem('reflectionSource', $reflectionSource);
389
390
        return $reflectionSource;
391
    }
392
393
    /**
394
     * @psalm-param array $source Raw rules containing additional metadata besides rule instances.
395
     *
396
     * @return array<int, RuleInterface>|array<string, list<RuleInterface>> An array of rules ready to use for the
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int, RuleInterface...g, list<RuleInterface>> at position 11 could not be parsed: Expected '>' at position 11, but found 'list'.
Loading history...
397
     * validation.
398
     */
399
    private function prepareRules(array $source): array
400
    {
401
        $rules = [];
402
        /**
403
         * @var mixed $data
404
         */
405
        foreach ($source as $key => $data) {
406
            if (is_int($key)) {
407
                /** @psalm-var RulesCacheItem $data */
408
                $rules[$key] = $this->prepareRule($data[0], $data[1]);
409
            } else {
410
                /**
411
                 * @psalm-var list<RulesCacheItem> $data
412
                 *
413
                 * @psalm-suppress UndefinedInterfaceMethod
414
                 */
415
                foreach ($data as $rule) {
416
                    $rules[$key][] = $this->prepareRule($rule[0], $rule[1]);
417
                }
418
            }
419
        }
420
        return $rules;
421
    }
422
423
    /**
424
     * Prepares a rule instance created from a Reflection attribute to use for the validation.
425
     *
426
     * @param RuleInterface $rule A rule instance.
427
     * @param Attribute::TARGET_* $target {@see Attribute} target.
428
     *
429
     * @return RuleInterface The same rule instance.
430
     */
431
    private function prepareRule(RuleInterface $rule, int $target): RuleInterface
432
    {
433
        if (is_object($this->source) && $rule instanceof AfterInitAttributeEventInterface) {
434
            $rule->afterInitAttribute($this->source, $target);
0 ignored issues
show
Unused Code introduced by
The call to Yiisoft\Validator\AfterI...e::afterInitAttribute() has too many arguments starting with $target. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

434
            $rule->/** @scrutinizer ignore-call */ 
435
                   afterInitAttribute($this->source, $target);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
435
        }
436
        return $rule;
437
    }
438
439
    /**
440
     * Whether a cache item with a given name exists in the cache. Note that this means existence only and items with
441
     * empty values are treated as present too.
442
     *
443
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
444
     *
445
     * @return bool `true` if an item exists, `false` - if it does not or the cache is disabled in {@see $useCache}.
446
     */
447
    private function hasCacheItem(
448
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
449
        string $name,
450
    ): bool {
451
        if (!$this->useCache()) {
452
            return false;
453
        }
454
455
        if (!array_key_exists($this->cacheKey, self::$cache)) {
456
            return false;
457
        }
458
459
        return array_key_exists($name, self::$cache[$this->cacheKey]);
460
    }
461
462
    /**
463
     * Returns a cache item by its name.
464
     *
465
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
466
     *
467
     * @return mixed Cache item value.
468
     */
469
    private function getCacheItem(
470
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
471
        string $name,
472
    ): mixed {
473
        /** @psalm-suppress PossiblyNullArrayOffset */
474
        return self::$cache[$this->cacheKey][$name];
475
    }
476
477
    /**
478
     * Updates cache item contents by its name.
479
     *
480
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
481
     * @param mixed $value A new value.
482
     */
483
    private function setCacheItem(
484
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
485
        string $name,
486
        mixed $value,
487
    ): void {
488
        if (!$this->useCache()) {
489
            return;
490
        }
491
492
        /** @psalm-suppress PossiblyNullArrayOffset, MixedAssignment */
493
        self::$cache[$this->cacheKey][$name] = $value;
494
    }
495
496
    /**
497
     * Whether the cache is enabled / can be used for a particular object.
498
     *
499
     * @psalm-assert string $this->cacheKey
500
     *
501
     * @return bool `true` if the cache is enabled / can be used and `false` otherwise.
502
     */
503
    private function useCache(): bool
504
    {
505
        return $this->cacheKey !== null;
506
    }
507
}
508