Passed
Pull Request — master (#445)
by Alexander
15:20 queued 12:57
created

ObjectParser::useCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
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 0
dl 0
loc 3
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 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
 * $rules = [
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
 * $data = [
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
     *
285
     * @see AttributeTranslatorProviderInterface} is implemented. In case of it's missing or {@see $source} being a
286
     * class string, a `null` value is returned.
287
     *
288
     * @return AttributeTranslatorInterface|null An attribute translator instance or `null if it was not provided.
289
     */
290
    public function getAttributeTranslator(): ?AttributeTranslatorInterface
291
    {
292
        return $this->source instanceof AttributeTranslatorProviderInterface
293
            ? $this->source->getAttributeTranslator()
294
            : null;
295
    }
296
297
    /**
298
     * Returns Reflection properties parsed from {@see $source} in accordance with {@see $propertyVisibility} and
299
     * {@see $skipStaticProperties} values. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
300
     *
301
     * @return array<string, ReflectionProperty> A mapping between Reflection property names and their values.
302
     */
303
    private function getReflectionProperties(): array
304
    {
305
        if ($this->hasCacheItem('reflectionProperties')) {
306
            /** @var array<string, ReflectionProperty> */
307
            return $this->getCacheItem('reflectionProperties');
308
        }
309
310
        $reflectionProperties = [];
311
        foreach ($this->getReflectionSource()->getProperties($this->propertyVisibility) as $property) {
312
            if ($this->skipStaticProperties && $property->isStatic()) {
313
                continue;
314
            }
315
316
            if (PHP_VERSION_ID < 80100) {
317
                $property->setAccessible(true);
318
            }
319
320
            $reflectionProperties[$property->getName()] = $property;
321
        }
322
323
        $this->setCacheItem('reflectionProperties', $reflectionProperties);
324
325
        return $reflectionProperties;
326
    }
327
328
    /**
329
     * Returns Reflection of {@see $source}. Repetitive calls utilize cache if it's enabled in {@see $useCache}.
330
     *
331
     * @return ReflectionClass|ReflectionObject Either a Reflection class or an object instance depending on what was
332
     * provided in {@see $source}.
333
     */
334
    private function getReflectionSource(): ReflectionObject|ReflectionClass
335
    {
336
        if ($this->hasCacheItem('reflectionSource')) {
337
            /** @var ReflectionClass|ReflectionObject */
338
            return $this->getCacheItem('reflectionSource');
339
        }
340
341
        $reflectionSource = is_object($this->source)
342
            ? new ReflectionObject($this->source)
343
            : new ReflectionClass($this->source);
344
345
        $this->setCacheItem('reflectionSource', $reflectionSource);
346
347
        return $reflectionSource;
348
    }
349
350
    /**
351
     * @psalm-param RulesCache $source Raw rules containing additional metadata besides rule instances.
352
     *
353
     * @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...
354
     * validation.
355
     */
356
    private function prepareRules(array $source): array
357
    {
358
        $rules = [];
359
        foreach ($source as $key => $data) {
360
            if (is_int($key)) {
361
                /** @psalm-var array{0:RuleInterface,1:Attribute::TARGET_*} $data */
362
                $rules[$key] = $this->prepareRule($data[0], $data[1]);
363
            } else {
364
                /**
365
                 * @psalm-var list<array{0:RuleInterface,1:Attribute::TARGET_*}> $data
366
                 * @psalm-suppress UndefinedInterfaceMethod
367
                 */
368
                foreach ($data as $rule) {
369
                    $rules[$key][] = $this->prepareRule($rule[0], $rule[1]);
370
                }
371
            }
372
        }
373
        return $rules;
374
    }
375
376
    /**
377
     * Prepares a rule instance created from a Reflection attribute to use for the validation.
378
     *
379
     * @param RuleInterface $rule A rule instance.
380
     * @param Attribute::TARGET_* $target {@see Attribute} target.
381
     *
382
     * @return RuleInterface The same rule instance.
383
     */
384
    private function prepareRule(RuleInterface $rule, int $target): RuleInterface
385
    {
386
        if (is_object($this->source) && $rule instanceof AfterInitAttributeEventInterface) {
387
            $rule->afterInitAttribute($this->source, $target);
388
        }
389
        return $rule;
390
    }
391
392
    /**
393
     * Whether a cache item with a given name exists in the cache. Note that this means existence only and items with
394
     * empty values are treated as present too.
395
     *
396
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
397
     *
398
     * @return bool `true` if an item exists, `false` - if it does not or the cache is disabled in {@see $useCache}.
399
     */
400
    private function hasCacheItem(
401
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource'])]
402
        string $name,
403
    ): bool {
404
        if (!$this->useCache()) {
405
            return false;
406
        }
407
408
        if (!array_key_exists($this->cacheKey, self::$cache)) {
409
            return false;
410
        }
411
412
        return array_key_exists($name, self::$cache[$this->cacheKey]);
413
    }
414
415
    /**
416
     * Returns a cache item by its name.
417
     *
418
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
419
     *
420
     * @return mixed Cache item value.
421
     */
422
    private function getCacheItem(
423
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource'])]
424
        string $name,
425
    ): mixed {
426
        /** @psalm-suppress PossiblyNullArrayOffset */
427
        return self::$cache[$this->cacheKey][$name];
428
    }
429
430
    /**
431
     * Updates cache item contents by its name.
432
     *
433
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
434
     * @param mixed $value A new value.
435
     */
436
    private function setCacheItem(
437
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource'])]
438
        string $name,
439
        mixed $value,
440
    ): void {
441
        if (!$this->useCache()) {
442
            return;
443
        }
444
445
        /** @psalm-suppress PossiblyNullArrayOffset, MixedAssignment */
446
        self::$cache[$this->cacheKey][$name] = $value;
447
    }
448
449
    /**
450
     * Whether the cache is enabled / can be used for a particular object.
451
     *
452
     * @psalm-assert string $this->cacheKey
453
     *
454
     * @return bool `true` if the cache is enabled / can be used and `false` otherwise.
455
     */
456
    private function useCache(): bool
457
    {
458
        return $this->cacheKey !== null;
459
    }
460
}
461