Passed
Pull Request — master (#445)
by
unknown
02:58
created

ObjectParser::getReflectionProperties()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

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