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

ObjectParser::getCacheItem()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 6
ccs 0
cts 0
cp 0
crap 2
rs 10
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
     * @param string $attribute
197
     *
198
     * @return bool Whether the property exists: `true` - exists and `false` - otherwise.
199
     */
200
    public function hasAttribute(string $attribute): bool
201
    {
202
        return array_key_exists($attribute, $this->getReflectionProperties());
203
    }
204
205
    /**
206
     * Returns the parsed object's data as a whole in a form of associative array.
207
     *
208
     * @return array  A mapping between property names and their values.
209
     */
210
    public function getData(): array
211
    {
212
        $data = [];
213
        foreach ($this->getReflectionProperties() as $name => $property) {
214
            /** @var mixed */
215
            $data[$name] = $property->getValue($this->object);
216
        }
217
218
        return $data;
219
    }
220
221
    /**
222
     * Returns Reflection properties parsed from {@see $object} in accordance with {@see $propertyVisibility} and
223
     * {@see $skipStaticProperties} values. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
224
     *
225
     * @return array<string, ReflectionProperty>
226
     */
227
    private function getReflectionProperties(): array
228
    {
229
        if ($this->hasCacheItem('reflectionProperties')) {
230
            /** @var array<string, ReflectionProperty> */
231
            return $this->getCacheItem('reflectionProperties');
232
        }
233
234
        $reflection = $this->getReflectionObject();
235
236
        $reflectionProperties = [];
237
238
        foreach ($reflection->getProperties($this->propertyVisibility) as $property) {
239
            if ($this->skipStaticProperties && $property->isStatic()) {
240
                continue;
241
            }
242
243
            if (PHP_VERSION_ID < 80100) {
244
                $property->setAccessible(true);
245
            }
246
247
            $reflectionProperties[$property->getName()] = $property;
248
        }
249
250
        $this->setCacheItem('reflectionProperties', $reflectionProperties);
251
252
        return $reflectionProperties;
253
    }
254
255
    /**
256
     * Returns Reflection of {@see $object}. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
257
     *
258
     * @return ReflectionObject
259
     */
260
    private function getReflectionObject(): ReflectionObject
261
    {
262
        if ($this->hasCacheItem('reflectionObject')) {
263
            /** @var ReflectionObject */
264
            return $this->getCacheItem('reflectionObject');
265
        }
266
267
        $reflectionObject = new ReflectionObject($this->object);
268
        $this->setCacheItem('reflectionObject', $reflectionObject);
269
270
        return $reflectionObject;
271
    }
272
273
    /**
274
     * Creates a rule instance from a Reflection attribute.
275
     *
276
     * @param ReflectionAttribute<RuleInterface> $attribute Reflection attribute.
277
     *
278
     * @return RuleInterface A new rule instance.
279
     */
280
    private function createRule(ReflectionAttribute $attribute): RuleInterface
281
    {
282
        $rule = $attribute->newInstance();
283
284
        if ($rule instanceof AfterInitAttributeEventInterface) {
285
            $rule->afterInitAttribute($this->object);
286
        }
287
288
        return $rule;
289
    }
290
291
    /**
292
     * Whether a cache item with a given name exists in the cache. Note that this means existence only and items with
293
     * empty values are treated as present too.
294
     *
295
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionObject`.
296
     *
297
     * @return bool `true` if a item exists, `false` - if it does not or cache is disabled in {@see $useCache}.
298
     */
299
    private function hasCacheItem(
300
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionObject'])]
301
        string $name,
302
    ): bool {
303
        if (!$this->useCache()) {
304
            return false;
305
        }
306
307
        if (!array_key_exists($this->cacheKey, self::$cache)) {
308
            return false;
309
        }
310
311
        return array_key_exists($name, self::$cache[$this->cacheKey]);
312
    }
313
314
    /**
315
     * Returns a cache item by its name.
316
     *
317
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionObject`.
318
     *
319
     * @return mixed Cache item value.
320
     */
321
    private function getCacheItem(
322
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionObject'])]
323
        string $name,
324
    ): mixed {
325
        /** @psalm-suppress PossiblyNullArrayOffset */
326
        return self::$cache[$this->cacheKey][$name];
327
    }
328
329
    /**
330
     * Updates cache item contents by its name.
331
     *
332
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionObject`.
333
     * @param mixed $value A new value.
334
     */
335
    private function setCacheItem(
336
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionObject'])]
337
        string $name,
338
        mixed $value,
339
    ): void {
340
        if (!$this->useCache()) {
341
            return;
342
        }
343
344
        /** @psalm-suppress PossiblyNullArrayOffset, MixedAssignment */
345
        self::$cache[$this->cacheKey][$name] = $value;
346
    }
347
348
    /**
349
     * Whether the cache is enabled / can be used for a particular object.
350
     *
351
     * @psalm-assert string $this->cacheKey
352
     *
353
     * @return bool `true` if the cache is enabled / can be used and `false` otherwise.
354
     */
355
    private function useCache(): bool
356
    {
357
        return $this->cacheKey !== null;
358
    }
359
}
360