Passed
Pull Request — master (#458)
by
unknown
03:41 queued 59s
created

ObjectParser::setCacheItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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