Passed
Pull Request — master (#643)
by
unknown
11:36
created

ObjectParser::getLabels()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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

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