Passed
Pull Request — master (#580)
by
unknown
02:38
created

ObjectParser::getAttributeValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 5
ccs 0
cts 0
cp 0
crap 6
rs 10
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 ObjectParserCache = array<string, array<string, mixed>>
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
     * @psalm-var ObjectParserCache
131
     */
132 66
    #[ArrayShape([
133 2
        [
134
            'rules' => 'array',
135
            'reflectionAttributes' => 'array',
136 64
            'reflectionSource' => 'object',
137 50
        ],
138
    ])]
139
    private static array $cache = [];
140 47
    /**
141
     * @var string|null A cache key. Dynamically generated on instantiation.
142
     */
143 47
    private string|null $cacheKey = null;
144
145
    /**
146
     * @throws InvalidArgumentException If a class name string provided in {@see $source} refers to a non-existing
147
     * class.
148 47
     */
149
    public function __construct(
150
        /**
151 50
         * @var object|string A source for parsing rules and data. Can be either a class name string or an
152
         * instance.
153
         * @psalm-var class-string|object
154
         */
155
        private string|object $source,
156
        /**
157 50
         * @var int Visibility levels the parsed properties must have. For example: public and protected only, this
158
         * means that the rest (private ones) will be skipped. Defaults to all visibility levels (public, protected and
159
         * private).
160
         * @psalm-var int-mask-of<ReflectionProperty::IS_*>
161
         */
162
        private int $propertyVisibility = ReflectionProperty::IS_PRIVATE |
163 66
        ReflectionProperty::IS_PROTECTED |
164
        ReflectionProperty::IS_PUBLIC,
165 66
        /**
166
         * @var bool Whether the properties with "static" modifier must be skipped.
167
         */
168
        private bool $skipStaticProperties = false,
169
        /**
170
         * @var bool Whether some results of parsing (Reflection of a class / an object with properties and rules) must
171
         * be cached.
172
         */
173
        bool $useCache = true,
174
    ) {
175
        /** @var object|string $source */
176
        if (is_string($source) && !class_exists($source)) {
177
            throw new InvalidArgumentException(
178
                sprintf('Class "%s" not found.', $source)
179
            );
180
        }
181
182
        if ($useCache) {
183
            $this->cacheKey = (is_object($source) ? $source::class : $source)
184
                . '_' . $this->propertyVisibility
185
                . '_' . (int) $this->skipStaticProperties;
186
        }
187
    }
188
189
    /**
190
     * Parses rules specified via attributes attached to class properties and class itself. Repetitive calls utilize
191
     * cache if it's enabled in {@see $useCache}.
192
     *
193
     * @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...
194
     * structure:
195
     *
196
     * ```php
197
     * [
198
     *     [new AtLeast(['name', 'author'])], // Parsed from class attribute.
199
     *     'files' => [new Count(max: 3)], // Parsed from property attribute.
200
     * ],
201
     * ```
202
     */
203
    public function getRules(): array
204
    {
205
        if ($this->hasCacheItem('rules')) {
206
            /** @var array $rules */
207
            $rules = $this->getCacheItem('rules');
208
            return $this->prepareRules($rules);
209
        }
210
211
        $rules = [];
212
213
        // Class rules
214
        $attributes = $this
215
            ->getReflectionSource()
216
            ->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
217
        foreach ($attributes as $attribute) {
218
            $rules[] = [$attribute->newInstance(), Attribute::TARGET_CLASS];
219
        }
220
221
        // Properties rules
222
        foreach ($this->getReflectionProperties() as $property) {
223
            // TODO: use Generator to collect attributes.
224
            $attributes = $property->getAttributes(RuleInterface::class, ReflectionAttribute::IS_INSTANCEOF);
225
            foreach ($attributes as $attribute) {
226
                /** @psalm-suppress UndefinedInterfaceMethod */
227
                $rules[$property->getName()][] = [$attribute->newInstance(), Attribute::TARGET_PROPERTY];
228
            }
229
        }
230
231
        $this->setCacheItem('rules', $rules);
232
233
        return $this->prepareRules($rules);
234
    }
235
236
    /**
237
     * Returns a property value of the parsed object.
238
     *
239
     * Note that in case of non-existing property a default `null` value is returned. If you need to check the presence
240
     * of a property or return a different default value, use {@see hasAttribute()} instead.
241
     *
242
     * If a {@see $source} is a class name string, `null` value is always returned.
243
     *
244
     * @param string $attribute Attribute name.
245
     *
246
     * @return mixed Attribute value.
247
     */
248
    public function getAttributeValue(string $attribute): mixed
249
    {
250
        return is_object($this->source)
251
            ? ($this->getReflectionProperties()[$attribute] ?? null)?->getValue($this->source)
252
            : null;
253
    }
254
255
    /**
256
     * Whether the parsed object has the property with a given name. Note that this means existence only and properties
257
     * with empty values are treated as present too.
258
     *
259
     * If a {@see $source} is a class name string, `false` value is always returned.
260
     *
261
     * @return bool Whether the property exists: `true` - exists and `false` - otherwise.
262
     */
263
    public function hasAttribute(string $attribute): bool
264
    {
265
        return is_object($this->source) && array_key_exists($attribute, $this->getReflectionProperties());
266
    }
267
268
    /**
269
     * Returns the parsed object's data as a whole in a form of associative array.
270
     *
271
     * If a {@see $source} is a class name string, an empty array is always returned.
272
     *
273
     * @return array A mapping between property names and their values.
274
     */
275
    public function getData(): array
276
    {
277
        if (!is_object($this->source)) {
278
            return [];
279
        }
280
281
        $data = [];
282
        foreach ($this->getReflectionProperties() as $name => $property) {
283
            /** @var mixed */
284
            $data[$name] = $property->getValue($this->source);
285
        }
286
287
        return $data;
288
    }
289
290
    /**
291
     * An optional attribute names translator. It's taken from the {@see $source} object when
292
     * {@see AttributeTranslatorProviderInterface} is implemented. In case of it's missing or {@see $source} being a
293
     * class name string, a `null` value is returned.
294
     *
295
     * @return AttributeTranslatorInterface|null An attribute translator instance or `null if it was not provided.
296
     */
297
    public function getAttributeTranslator(): ?AttributeTranslatorInterface
298
    {
299
        return $this->source instanceof AttributeTranslatorProviderInterface
300
            ? $this->source->getAttributeTranslator()
301
            : null;
302
    }
303
304
    /**
305
     * Returns Reflection properties parsed from {@see $source} in accordance with {@see $propertyVisibility} and
306
     * {@see $skipStaticProperties} values. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
307
     *
308
     * @return array<string, ReflectionProperty> A mapping between Reflection property names and their values.
309
     *
310
     * @see https://github.com/yiisoft/form for usage in form collector.
311
     */
312
    public function getReflectionProperties(): array
313
    {
314
        if ($this->hasCacheItem('reflectionProperties')) {
315
            /** @var array<string, ReflectionProperty> */
316
            return $this->getCacheItem('reflectionProperties');
317
        }
318
319
        $reflectionProperties = [];
320
        foreach ($this->getReflectionSource()->getProperties($this->propertyVisibility) as $property) {
321
            if ($this->skipStaticProperties && $property->isStatic()) {
322
                continue;
323
            }
324
325
            /** @infection-ignore-all */
326
            if (PHP_VERSION_ID < 80100) {
327
                /** @psalm-suppress UnusedMethodCall Need for psalm with PHP 8.1+ */
328
                $property->setAccessible(true);
329
            }
330
331
            $reflectionProperties[$property->getName()] = $property;
332
        }
333
334
        $this->setCacheItem('reflectionProperties', $reflectionProperties);
335
336
        return $reflectionProperties;
337
    }
338
339
    /**
340
     * Gets cache storage.
341
     *
342
     * @return array Cache storage.
343
     * @psalm-return ObjectParserCache
344
     *
345
     * @see $cache
346
     *
347
     * @internal
348
     */
349
    public static function getCache(): array
350
    {
351
        return self::$cache;
352
    }
353
354
    /**
355
     * Returns Reflection of {@see $source}. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
356
     *
357
     * @return ReflectionClass|ReflectionObject Either a Reflection class or an object instance depending on what was
358
     * provided in {@see $source}.
359
     */
360
    private function getReflectionSource(): ReflectionObject|ReflectionClass
361
    {
362
        if ($this->hasCacheItem('reflectionSource')) {
363
            /** @var ReflectionClass|ReflectionObject */
364
            return $this->getCacheItem('reflectionSource');
365
        }
366
367
        $reflectionSource = is_object($this->source)
368
            ? new ReflectionObject($this->source)
369
            : new ReflectionClass($this->source);
370
371
        $this->setCacheItem('reflectionSource', $reflectionSource);
372
373
        return $reflectionSource;
374
    }
375
376
    /**
377
     * @psalm-param array $source Raw rules containing additional metadata besides rule instances.
378
     *
379
     * @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...
380
     * validation.
381
     */
382
    private function prepareRules(array $source): array
383
    {
384
        $rules = [];
385
        /**
386
         * @var mixed $data
387
         */
388
        foreach ($source as $key => $data) {
389
            if (is_int($key)) {
390
                /** @psalm-var RulesCacheItem $data */
391
                $rules[$key] = $this->prepareRule($data[0], $data[1]);
392
            } else {
393
                /**
394
                 * @psalm-var list<RulesCacheItem> $data
395
                 * @psalm-suppress UndefinedInterfaceMethod
396
                 */
397
                foreach ($data as $rule) {
398
                    $rules[$key][] = $this->prepareRule($rule[0], $rule[1]);
399
                }
400
            }
401
        }
402
        return $rules;
403
    }
404
405
    /**
406
     * Prepares a rule instance created from a Reflection attribute to use for the validation.
407
     *
408
     * @param RuleInterface $rule A rule instance.
409
     * @param Attribute::TARGET_* $target {@see Attribute} target.
410
     *
411
     * @return RuleInterface The same rule instance.
412
     */
413
    private function prepareRule(RuleInterface $rule, int $target): RuleInterface
414
    {
415
        if (is_object($this->source) && $rule instanceof AfterInitAttributeEventInterface) {
416
            $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

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