DefinitionValidator::generatePossibleMessage()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 39
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 5

Importance

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