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