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

ObjectParser::getReflectionProperties()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

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