Passed
Pull Request — master (#643)
by
unknown
02:39
created

ObjectParser::prepareRules()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 22
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

433
            $rule->/** @scrutinizer ignore-call */ 
434
                   afterInitAttribute($this->source, $target);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
434
        }
435
        return $rule;
436
    }
437
438
    /**
439
     * Whether a cache item with a given name exists in the cache. Note that this means existence only and items with
440
     * empty values are treated as present too.
441
     *
442
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
443
     *
444
     * @return bool `true` if an item exists, `false` - if it does not or the cache is disabled in {@see $useCache}.
445
     */
446
    private function hasCacheItem(
447
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
448
        string $name,
449
    ): bool {
450
        if (!$this->useCache()) {
451
            return false;
452
        }
453
454
        if (!array_key_exists($this->cacheKey, self::$cache)) {
455
            return false;
456
        }
457
458
        return array_key_exists($name, self::$cache[$this->cacheKey]);
459
    }
460
461
    /**
462
     * Returns a cache item by its name.
463
     *
464
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
465
     *
466
     * @return mixed Cache item value.
467
     */
468
    private function getCacheItem(
469
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
470
        string $name,
471
    ): mixed {
472
        /** @psalm-suppress PossiblyNullArrayOffset */
473
        return self::$cache[$this->cacheKey][$name];
474
    }
475
476
    /**
477
     * Updates cache item contents by its name.
478
     *
479
     * @param string $name Cache item name. Can be on of: `rules`, `reflectionProperties`, `reflectionSource`.
480
     * @param mixed $value A new value.
481
     */
482
    private function setCacheItem(
483
        #[ExpectedValues(['rules', 'reflectionProperties', 'reflectionSource', 'labels'])]
484
        string $name,
485
        mixed $value,
486
    ): void {
487
        if (!$this->useCache()) {
488
            return;
489
        }
490
491
        /** @psalm-suppress PossiblyNullArrayOffset, MixedAssignment */
492
        self::$cache[$this->cacheKey][$name] = $value;
493
    }
494
495
    /**
496
     * Whether the cache is enabled / can be used for a particular object.
497
     *
498
     * @psalm-assert string $this->cacheKey
499
     *
500
     * @return bool `true` if the cache is enabled / can be used and `false` otherwise.
501
     */
502
    private function useCache(): bool
503
    {
504
        return $this->cacheKey !== null;
505
    }
506
}
507