Passed
Push — master ( 0d7c55...1e7a50 )
by Dmitriy
01:21 queued 40s
created

DefinitionValidator::validateProperty()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 33
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5.0113

Importance

Changes 0
Metric Value
cc 5
eloc 20
nc 5
nop 4
dl 0
loc 33
ccs 12
cts 13
cp 0.9231
crap 5.0113
rs 9.2888
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Definitions\Helpers;
6
7
use ReflectionClass;
8
use ReflectionException;
9
use Yiisoft\Definitions\ArrayDefinition;
10
use Yiisoft\Definitions\Contract\DefinitionInterface;
11
use Yiisoft\Definitions\Contract\ReferenceInterface;
12
use Yiisoft\Definitions\Exception\InvalidConfigException;
13
14
use function is_array;
15
use function is_callable;
16
use function is_object;
17
use function is_string;
18
19
/**
20
 * Definition validator checks if definition is valid.
21
 */
22
final class DefinitionValidator
23
{
24
    /**
25
     * Validates that definition is valid. Throws exception otherwise.
26
     *
27
     * @param mixed $definition Definition to validate.
28
     *
29
     * @throws InvalidConfigException If definition is not valid.
30
     * @throws ReflectionException
31
     */
32 31
    public static function validate(mixed $definition, ?string $id = null): void
33
    {
34
        // Reference or ready object
35 31
        if (is_object($definition) && self::isValidObject($definition)) {
36
            return;
37
        }
38
39
        // Class
40 31
        if (is_string($definition)) {
41 2
            self::validateString($definition);
42
            return;
43
        }
44
45
        // Callable definition
46 29
        if ($definition !== '' && is_callable($definition, true)) {
47
            return;
48
        }
49
50
        // Array definition
51 29
        if (is_array($definition)) {
52 28
            self::validateArrayDefinition($definition, $id);
53
            return;
54
        }
55
56 1
        throw new InvalidConfigException(
57
            'Invalid definition: '
58 1
            . ($definition === '' ? 'empty string.' : var_export($definition, true))
59
        );
60
    }
61
62
    /**
63
     * Validates that array definition is valid. Throws exception otherwise.
64
     *
65
     * @param array $definition Array definition to validate.
66
     *
67
     * @throws InvalidConfigException If definition is not valid.
68
     * @throws ReflectionException
69
     */
70 31
    public static function validateArrayDefinition(array $definition, ?string $id = null): void
71
    {
72
        /** @var class-string $className */
73 31
        $className = $definition[ArrayDefinition::CLASS_NAME] ?? $id ?? throw new InvalidConfigException(
74
            'Invalid definition: no class name specified.'
75
        );
76 29
        self::validateString($className);
77 27
        $classReflection = new ReflectionClass($className);
78 27
        $classPublicMethods = [];
79 27
        foreach ($classReflection->getMethods() as $reflectionMethod) {
80 23
            if ($reflectionMethod->isPublic() && !self::isMagicMethod($reflectionMethod->getName())) {
81 22
                $classPublicMethods[] = $reflectionMethod->getName();
82
            }
83
        }
84 27
        $classPublicProperties = [];
85 27
        foreach ($classReflection->getProperties() as $reflectionProperty) {
86 22
            if ($reflectionProperty->isPublic()) {
87 20
                $classPublicProperties[] = $reflectionProperty->getName();
88
            }
89
        }
90
91
        /** @var mixed $value */
92 27
        foreach ($definition as $key => $value) {
93 26
            if (!is_string($key)) {
94 1
                throw new InvalidConfigException(
95 1
                    sprintf(
96
                        'Invalid definition: invalid key in array definition. Only string keys are allowed, got %d.',
97
                        $key,
98
                    ),
99
                );
100
            }
101
102
            // Class
103 26
            if ($key === ArrayDefinition::CLASS_NAME) {
104 26
                continue;
105
            }
106
107
            // Constructor arguments
108 23
            if ($key === ArrayDefinition::CONSTRUCTOR) {
109 13
                self::validateConstructor($value);
110 11
                continue;
111
            }
112
113
            // Methods and properties
114 21
            if (str_ends_with($key, '()')) {
115 17
                self::validateMethod($key, $classReflection, $classPublicMethods, $className, $value);
116 11
                continue;
117
            }
118 15
            if (str_starts_with($key, '$')) {
119 15
                self::validateProperty($key, $classReflection, $classPublicProperties, $className);
120 11
                continue;
121
            }
122
123 11
            $possibleOptionsMessage = self::generatePossibleMessage(
124
                $key,
125
                $classPublicMethods,
126
                $classPublicProperties,
127
                $classReflection,
128
                $className
129
            );
130
131 11
            throw new InvalidConfigException(
132 11
                "Invalid definition: key \"$key\" is not allowed. $possibleOptionsMessage",
133
            );
134
        }
135
    }
136
137
    /**
138
     * Deny `DefinitionInterface`, exclude `ReferenceInterface`
139
     */
140 1
    private static function isValidObject(object $value): bool
141
    {
142 1
        return !($value instanceof DefinitionInterface) || $value instanceof ReferenceInterface;
143
    }
144
145 11
    private static function generatePossibleMessage(
146
        string $key,
147
        array $classPublicMethods,
148
        array $classPublicProperties,
149
        ReflectionClass $classReflection,
150
        string $className
151
    ): string {
152 11
        $parsedKey = trim(
153 11
            strtr($key, [
154
                '()' => '',
155
                '$' => '',
156
            ])
157
        );
158 11
        if (in_array($parsedKey, $classPublicMethods, true)) {
159 3
            return sprintf(
160
                'Did you mean "%s"?',
161
                $parsedKey . '()',
162
            );
163
        }
164 8
        if (in_array($parsedKey, $classPublicProperties, true)) {
165 1
            return sprintf(
166
                'Did you mean "%s"?',
167
                '$' . $parsedKey,
168
            );
169
        }
170 7
        if ($classReflection->hasMethod($parsedKey)) {
171 1
            return sprintf(
172
                'Method "%s" must be public to be able to be called.',
173
                $className . '::' . $parsedKey . '()',
174
            );
175
        }
176 6
        if ($classReflection->hasProperty($parsedKey)) {
177 1
            return sprintf(
178
                'Property "%s" must be public to be able to be called.',
179
                $className . '::$' . $parsedKey,
180
            );
181
        }
182
183 5
        return 'The key may be a call of a method or a setting of a property.';
184
    }
185
186
    /**
187
     * @param string[] $classPublicMethods
188
     *
189
     * @throws InvalidConfigException
190
     */
191 17
    private static function validateMethod(
192
        string $key,
193
        ReflectionClass $classReflection,
194
        array $classPublicMethods,
195
        string $className,
196
        mixed $value
197
    ): void {
198 17
        $parsedKey = substr($key, 0, -2);
199 17
        if (!$classReflection->hasMethod($parsedKey)) {
200 4
            if ($classReflection->hasMethod('__call') || $classReflection->hasMethod('__callStatic')) {
201
                /**
202
                 * Magic method may intercept the call, but reflection does not know about it.
203
                 */
204
                return;
205
            }
206 4
            $possiblePropertiesMessage = $classPublicMethods === []
207 1
                ? 'No public methods available to call.'
208 3
                : sprintf(
209
                    'Possible methods to call: %s',
210 3
                    '"' . implode('", "', $classPublicMethods) . '"',
211
                );
212 4
            throw new InvalidConfigException(
213 4
                sprintf(
214
                    'Invalid definition: class "%s" does not have the public method with name "%s". ' . $possiblePropertiesMessage,
215
                    $className,
216
                    $parsedKey,
217
                )
218
            );
219
        }
220 13
        if (!in_array($parsedKey, $classPublicMethods, true)) {
221 1
            throw new InvalidConfigException(
222 1
                sprintf(
223
                    'Invalid definition: method "%s" must be public.',
224
                    $className . '::' . $key,
225
                )
226
            );
227
        }
228 12
        if (!is_array($value)) {
229 1
            throw new InvalidConfigException(
230 1
                sprintf(
231
                    'Invalid definition: incorrect method "%s" arguments. Expected array, got "%s". ' .
232
                    'Probably you should wrap them into square brackets.',
233
                    $key,
234 1
                    get_debug_type($value),
235
                )
236
            );
237
        }
238
    }
239
240
    /**
241
     * @param string[] $classPublicProperties
242
     *
243
     * @throws InvalidConfigException
244
     */
245 15
    private static function validateProperty(
246
        string $key,
247
        ReflectionClass $classReflection,
248
        array $classPublicProperties,
249
        string $className
250
    ): void {
251 15
        $parsedKey = substr($key, 1);
252 15
        if (!$classReflection->hasProperty($parsedKey)) {
253 2
            if ($classReflection->hasMethod('__set')) {
254
                /**
255
                 * Magic method may intercept the call, but reflection does not know about it.
256
                 */
257
                return;
258
            }
259 2
            if ($classPublicProperties === []) {
260 1
                $message = sprintf(
261
                    'Invalid definition: class "%s" does not have any public properties.',
262
                    $className,
263
                );
264
            } else {
265 1
                $message = sprintf(
266
                    'Invalid definition: class "%s" does not have the public property with name "%s". Possible properties to set: %s.',
267
                    $className,
268
                    $parsedKey,
269 1
                    '"' . implode('", "', $classPublicProperties) . '"',
270
                );
271
            }
272 2
            throw new InvalidConfigException($message);
273 13
        } elseif (!in_array($parsedKey, $classPublicProperties, true)) {
274 2
            throw new InvalidConfigException(
275 2
                sprintf(
276
                    'Invalid definition: property "%s" must be public.',
277
                    $className . '::' . $key,
278
                )
279
            );
280
        }
281
    }
282
283
    /**
284
     * @throws InvalidConfigException
285
     */
286 13
    private static function validateConstructor(mixed $value): void
287
    {
288 13
        if (!is_array($value)) {
289 1
            throw new InvalidConfigException(
290 1
                sprintf(
291
                    'Invalid definition: incorrect constructor arguments. Expected array, got %s.',
292 1
                    get_debug_type($value)
293
                )
294
            );
295
        }
296
        /** @var mixed $argument */
297 12
        foreach ($value as $argument) {
298 12
            if (is_object($argument) && !self::isValidObject($argument)) {
299 1
                throw new InvalidConfigException(
300
                    'Only references are allowed in constructor arguments, a definition object was provided: ' .
301 1
                    var_export($argument, true)
302
                );
303
            }
304
        }
305
    }
306
307 23
    private static function isMagicMethod(string $getName): bool
308
    {
309 23
        return in_array($getName, [
310
            '__construct',
311
            '__destruct',
312
            '__call',
313
            '__callStatic',
314
            '__get',
315
            '__set',
316
            '__isset',
317
            '__unset',
318
            '__sleep',
319
            '__wakeup',
320
            '__serialize',
321
            '__unserialize',
322
            '__toString',
323
            '__invoke',
324
            '__set_state',
325
            '__clone',
326
            '__debugInfo',
327
        ], true);
328
    }
329
330
    /**
331
     * @throws InvalidConfigException
332
     */
333 31
    private static function validateString(mixed $class): void
334
    {
335 31
        if (!is_string($class)) {
336 1
            throw new InvalidConfigException(
337 1
                sprintf(
338
                    'Invalid definition: class name must be a non-empty string, got %s.',
339 1
                    get_debug_type($class),
340
                )
341
            );
342
        }
343 30
        if (trim($class) === '') {
344 3
            throw new InvalidConfigException('Invalid definition: class name must be a non-empty string.');
345
        }
346
    }
347
}
348