Passed
Pull Request — master (#240)
by Dmitriy
02:16
created

Container::isResolvable()   D

Complexity

Conditions 23
Paths 29

Size

Total Lines 82
Code Lines 43

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 34.5009

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 23
eloc 43
c 2
b 0
f 0
nc 29
nop 1
dl 0
loc 82
ccs 31
cts 43
cp 0.7209
crap 34.5009
rs 4.1666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Di;
6
7
use Closure;
8
use Psr\Container\ContainerInterface;
9
use ReflectionClass;
10
use ReflectionException;
11
use ReflectionNamedType;
12
use ReflectionUnionType;
13
use Yiisoft\Di\Contracts\ServiceProviderInterface;
14
use Yiisoft\Factory\Definition\ArrayDefinition;
15
use Yiisoft\Factory\Definition\DefinitionValidator;
16
use Yiisoft\Factory\Exception\CircularReferenceException;
17
use Yiisoft\Factory\Exception\InvalidConfigException;
18
use Yiisoft\Factory\Exception\NotFoundException;
19
use Yiisoft\Factory\Exception\NotInstantiableException;
20
21
use function array_key_exists;
22
use function array_keys;
23
use function class_exists;
24
use function get_class;
25
use function implode;
26
use function in_array;
27
use function is_array;
28
use function is_object;
29
use function is_string;
30
31
/**
32
 * Container implements a [dependency injection](http://en.wikipedia.org/wiki/Dependency_injection) container.
33
 */
34
final class Container implements ContainerInterface
35
{
36
    private const META_TAGS = 'tags';
37
    private const META_RESET = 'reset';
38
    private const ALLOWED_META = [self::META_TAGS, self::META_RESET];
39
40
    /**
41
     * @var array object definitions indexed by their types
42
     */
43
    private array $definitions = [];
44
    /**
45
     * @var array used to collect ids instantiated during build
46
     * to detect circular references
47
     */
48
    private array $building = [];
49
50
    /**
51
     * @var bool $validate Validate definitions when set
52
     */
53
    private bool $validate;
54
55
    /**
56
     * @var object[]
57
     */
58
    private array $instances = [];
59
60
    private array $tags;
61
62
    private array $resetters = [];
63
    /** @psalm-suppress PropertyNotSetInConstructor */
64
    private DependencyResolver $dependencyResolver;
65
66
    /**
67
     * Container constructor.
68
     *
69
     * @param array $definitions Definitions to put into container.
70
     * @param array $providers Service providers to get definitions from.
71
     * lookup to when resolving dependencies. If provided the current container
72
     * is no longer queried for dependencies.
73
     *
74
     * @throws InvalidConfigException
75
     *
76
     * @psalm-suppress PropertyNotSetInConstructor
77
     */
78 96
    public function __construct(
79
        array $definitions = [],
80
        array $providers = [],
81
        array $tags = [],
82
        bool $validate = true
83
    ) {
84 96
        $this->tags = $tags;
85 96
        $this->validate = $validate;
86 96
        $this->setDefaultDefinitions();
87 96
        $this->setMultiple($definitions);
88 90
        $this->addProviders($providers);
89 89
    }
90
91
    /**
92
     * Returns a value indicating whether the container has the definition of the specified name.
93
     *
94
     * @param string $id class name, interface name or alias name
95
     *
96
     * @return bool whether the container is able to provide instance of class specified.
97
     *
98
     * @see set()
99
     */
100 29
    public function has($id): bool
101
    {
102 29
        if ($this->isTagAlias($id)) {
103 2
            $tag = substr($id, 4);
104 2
            return isset($this->tags[$tag]);
105
        }
106
107 27
        return $this->isResolvable($id);
108
    }
109
110
    /**
111
    * @param string $id class name, interface name or alias name
112
    */
113 27
    private function isResolvable($id): bool
114
    {
115 27
        if (isset($this->definitions[$id]) || $id === StateResetter::class) {
116 23
            return true;
117
        }
118
119 15
        if (!class_exists($id) || isset($this->building[$id])) {
120 10
            return false;
121
        }
122
123
        try {
124 12
            $reflectionClass = new ReflectionClass($id);
125
        } catch (ReflectionException $e) {
126
            return false;
127
        }
128
129 12
        if (!$reflectionClass->isInstantiable()) {
130
            return false;
131
        }
132
133 12
        $constructor = $reflectionClass->getConstructor();
134
135 12
        if ($constructor === null) {
136
            return true;
137
        }
138
139 12
        $isResolvable = true;
140 12
        $this->building[$id] = 1;
141
142 12
        foreach ($constructor->getParameters() as $parameter) {
143 12
            $type = $parameter->getType();
144
145 12
            if ($parameter->isVariadic() || $parameter->isOptional()) {
146 6
                break;
147
            }
148
149 11
            if ($type === null || $type->isBuiltin()) {
150 4
                $isResolvable = false;
151 4
                break;
152
            }
153
154
            // PHP 8 union type is used as type hint
155
            /** @psalm-suppress UndefinedClass, TypeDoesNotContainType */
156 11
            if ($type instanceof ReflectionUnionType) {
157
                $isUnionTypeResolvable = false;
158
                /** @var ReflectionNamedType $unionType */
159
                foreach ($type->getTypes() as $unionType) {
160
                    if (!$unionType->isBuiltin()) {
161
                        $typeName = $unionType->getName();
162
                        $isUnionTypeResolvable = $this->isResolvable($typeName);
163
                    }
164
                }
165
166
                if (!$isUnionTypeResolvable) {
167
                    $isResolvable = false;
168
                    break;
169
                }
170
            }
171
172
            /** @var ReflectionNamedType|null $type */
173
            // Our parameter has a class type hint
174 11
            if ($type !== null && !$type->isBuiltin()) {
175 11
                $typeName = $type->getName();
176
177 11
                if ($typeName === 'self') {
178 1
                    $isResolvable = true;
179 1
                    break;
180
                }
181
182 11
                if ($typeName === 'self' || !$this->isResolvable($typeName)) {
183 6
                    $isResolvable = false;
184 6
                    break;
185
                }
186
            }
187
        }
188
189 12
        if ($isResolvable) {
190 6
            $this->definitions[$id] = $id;
191
        }
192 12
        unset($this->building[$id]);
193
194 12
        return $isResolvable;
195
    }
196
197
    /**
198
     * Returns an instance by either interface name or alias.
199
     *
200
     * Same instance of the class will be returned each time this method is called.
201
     *
202
     * @param string $id The interface or an alias name that was previously registered.
203
     *
204
     * @throws CircularReferenceException
205
     * @throws InvalidConfigException
206
     * @throws NotFoundException
207
     * @throws NotInstantiableException
208
     *
209
     * @return mixed|object An instance of the requested interface.
210
     *
211
     * @psalm-template T
212
     * @psalm-param string|class-string<T> $id
213
     * @psalm-return ($id is class-string ? T : mixed)
214
     */
215 83
    public function get($id)
216
    {
217 83
        if ($id === StateResetter::class && !isset($this->definitions[$id])) {
218 4
            $resetters = [];
219 4
            foreach ($this->resetters as $serviceId => $callback) {
220 4
                if (isset($this->instances[$serviceId])) {
221 4
                    $resetters[] = $callback->bindTo($this->instances[$serviceId], get_class($this->instances[$serviceId]));
222
                }
223
            }
224 4
            return new StateResetter($resetters, $this);
225
        }
226
227 83
        if (!array_key_exists($id, $this->instances)) {
228 83
            $this->instances[$id] = $this->build($id);
229
        }
230
231 81
        return $this->instances[$id];
232
    }
233
234
    /**
235
     * Sets a definition to the container. Definition may be defined multiple ways.
236
     *
237
     * @param string $id
238
     * @param mixed $definition
239
     *
240
     * @throws InvalidConfigException
241
     *
242
     * @see `DefinitionNormalizer::normalize()`
243
     */
244 96
    protected function set(string $id, $definition): void
245
    {
246 96
        [$definition, $meta] = DefinitionParser::parse($definition);
247 96
        if ($this->validate) {
248 96
            $this->validateDefinition($definition, $id);
249 96
            $this->validateMeta($meta);
250
        }
251
252 96
        if (isset($meta[self::META_TAGS])) {
253 8
            if ($this->validate) {
254 8
                $this->validateTags($meta[self::META_TAGS]);
255
            }
256 8
            $this->setTags($id, $meta[self::META_TAGS]);
257
        }
258 96
        if (isset($meta[self::META_RESET])) {
259 5
            $this->setResetter($id, $meta[self::META_RESET]);
260
        }
261
262 96
        unset($this->instances[$id]);
263 96
        $this->definitions[$id] = $definition;
264 96
    }
265
266
    /**
267
     * Sets multiple definitions at once.
268
     *
269
     * @param array $config definitions indexed by their ids
270
     *
271
     * @throws InvalidConfigException
272
     */
273 96
    protected function setMultiple(array $config): void
274
    {
275 96
        foreach ($config as $id => $definition) {
276 88
            if ($this->validate && !is_string($id)) {
277 1
                throw new InvalidConfigException(sprintf('Key must be a string. %s given.', $this->getVariableType($id)));
278
            }
279 87
            $this->set($id, $definition);
280
        }
281 90
    }
282
283 96
    private function setDefaultDefinitions(): void
284
    {
285 96
        $this->set(ContainerInterface::class, $this);
286 96
    }
287
288
    /**
289
     * @param mixed $definition
290
     *
291
     * @throws InvalidConfigException
292
     */
293 96
    private function validateDefinition($definition, ?string $id = null): void
294
    {
295 96
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
296 39
            [$class, $constructorArguments, $methodsAndProperties] = $definition;
297 39
            $definition = array_merge(
298 39
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
299 39
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
300
                $methodsAndProperties,
301
            );
302
        }
303
304 96
        if ($definition instanceof ExtensibleService) {
305
            throw new InvalidConfigException('Invalid definition. ExtensibleService is only allowed in provider extensions.');
306
        }
307
308 96
        DefinitionValidator::validate($definition, $id);
309 96
    }
310
311
    /**
312
     * @throws InvalidConfigException
313
     */
314 96
    private function validateMeta(array $meta): void
315
    {
316 96
        foreach ($meta as $key => $_value) {
317 16
            if (!in_array($key, self::ALLOWED_META, true)) {
318 3
                throw new InvalidConfigException(
319 3
                    sprintf(
320 3
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
321
                        $key,
322
                        $key,
323
                        $key,
324
                    )
325
                );
326
            }
327
        }
328 96
    }
329
330 8
    private function validateTags(array $tags): void
331
    {
332 8
        foreach ($tags as $tag) {
333 8
            if (!is_string($tag)) {
334
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
335
            }
336
        }
337 8
    }
338
339 8
    private function setTags(string $id, array $tags): void
340
    {
341 8
        foreach ($tags as $tag) {
342 8
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
343 8
                $this->tags[$tag][] = $id;
344
            }
345
        }
346 8
    }
347
348 4
    private function setResetter(string $id, Closure $resetter): void
349
    {
350 4
        $this->resetters[$id] = $resetter;
351 4
    }
352
353
    /**
354
     * Creates new instance by either interface name or alias.
355
     *
356
     * @param string $id The interface or an alias name that was previously registered.
357
     *
358
     * @throws CircularReferenceException
359
     * @throws InvalidConfigException
360
     * @throws NotFoundException
361
     *
362
     * @return mixed|object New built instance of the specified class.
363
     *
364
     * @internal
365
     */
366 83
    private function build(string $id)
367
    {
368 83
        if ($this->isTagAlias($id)) {
369 9
            return $this->getTaggedServices($id);
370
        }
371
372 82
        if (isset($this->building[$id])) {
373 80
            if ($id === ContainerInterface::class) {
374 80
                return $this;
375
            }
376 6
            throw new CircularReferenceException(sprintf(
377 6
                'Circular reference to "%s" detected while building: %s.',
378
                $id,
379 6
                implode(',', array_keys($this->building))
380
            ));
381
        }
382
383 82
        $this->building[$id] = 1;
384
        try {
385 82
            $object = $this->buildInternal($id);
386 80
        } finally {
387 82
            unset($this->building[$id]);
388
        }
389
390 80
        return $object;
391
    }
392
393 89
    private function isTagAlias(string $id): bool
394
    {
395 89
        return strpos($id, 'tag@') === 0;
396
    }
397
398 9
    private function getTaggedServices(string $tagAlias): array
399
    {
400 9
        $tag = substr($tagAlias, 4);
401 9
        $services = [];
402 9
        if (isset($this->tags[$tag])) {
403 8
            foreach ($this->tags[$tag] as $service) {
404 8
                $services[] = $this->get($service);
405
            }
406
        }
407
408 9
        return $services;
409
    }
410
411
    /**
412
     * @param string $id
413
     *
414
     * @throws InvalidConfigException
415
     * @throws NotFoundException
416
     *
417
     * @return mixed|object
418
     */
419 82
    private function buildInternal(string $id)
420
    {
421 82
        if (!isset($this->definitions[$id])) {
422 46
            return $this->buildPrimitive($id);
423
        }
424 80
        $definition = DefinitionNormalizer::normalize($this->definitions[$id], $id);
425
        /** @psalm-suppress RedundantPropertyInitializationCheck */
426 80
        $this->dependencyResolver ??= new DependencyResolver($this->get(ContainerInterface::class));
427
428 80
        return $definition->resolve($this->dependencyResolver);
429
    }
430
431
    /**
432
     * @param string $class
433
     *
434
     * @throws InvalidConfigException
435
     * @throws NotFoundException
436
     *
437
     * @return mixed|object
438
     */
439 46
    private function buildPrimitive(string $class)
440
    {
441 46
        if (class_exists($class)) {
442 44
            $definition = ArrayDefinition::fromPreparedData($class);
443
            /** @psalm-suppress RedundantPropertyInitializationCheck */
444 44
            $this->dependencyResolver ??= new DependencyResolver($this->get(ContainerInterface::class));
445
446 44
            return $definition->resolve($this->dependencyResolver);
447
        }
448
449 3
        throw new NotFoundException($class);
450
    }
451
452 90
    private function addProviders(array $providers): void
453
    {
454 90
        $extensions = [];
455 90
        foreach ($providers as $provider) {
456 5
            $providerInstance = $this->buildProvider($provider);
457 5
            $extensions[] = $providerInstance->getExtensions();
458 5
            $this->addProviderDefinitions($providerInstance);
459
        }
460
461 90
        foreach ($extensions as $providerExtensions) {
462 5
            foreach ($providerExtensions as $id => $extension) {
463 4
                if (!isset($this->definitions[$id])) {
464 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
465
                }
466
467 3
                if (!$this->definitions[$id] instanceof ExtensibleService) {
468 3
                    $this->definitions[$id] = new ExtensibleService($this->definitions[$id]);
469
                }
470
471 3
                $this->definitions[$id]->addExtension($extension);
472
            }
473
        }
474 89
    }
475
476
    /**
477
     * Adds service provider definitions to the container.
478
     *
479
     * @param object $provider
480
     *
481
     * @throws InvalidConfigException
482
     * @throws NotInstantiableException
483
     *
484
     * @see ServiceProviderInterface
485
     */
486 5
    private function addProviderDefinitions($provider): void
487
    {
488 5
        $definitions = $provider->getDefinitions();
489 5
        $this->setMultiple($definitions);
490 5
    }
491
492
    /**
493
     * Builds service provider by definition.
494
     *
495
     * @param mixed $provider Class name or instance of provider.
496
     *
497
     * @throws InvalidConfigException If provider argument is not valid.
498
     *
499
     * @return ServiceProviderInterface Instance of service provider.
500
     *
501
     * @psalm-suppress MoreSpecificReturnType
502
     */
503 5
    private function buildProvider($provider): ServiceProviderInterface
504
    {
505 5
        if ($this->validate && !(is_string($provider) || is_object($provider) && $provider instanceof ServiceProviderInterface)) {
506
            throw new InvalidConfigException(
507
                sprintf(
508
                    'Service provider should be a class name or an instance of %s. %s given.',
509
                    ServiceProviderInterface::class,
510
                    $this->getVariableType($provider)
511
                )
512
            );
513
        }
514
515 5
        $providerInstance = is_object($provider) ? $provider : new $provider();
516 5
        if (!$providerInstance instanceof ServiceProviderInterface) {
517
            throw new InvalidConfigException(
518
                sprintf(
519
                    'Service provider should be an instance of %s. %s given.',
520
                    ServiceProviderInterface::class,
521
                    $this->getVariableType($providerInstance)
522
                )
523
            );
524
        }
525
526
        /**
527
         * @psalm-suppress LessSpecificReturnStatement
528
         */
529 5
        return $providerInstance;
530
    }
531
532
    /**
533
     * @param mixed $variable
534
     */
535 1
    private function getVariableType($variable): string
536
    {
537 1
        if (is_object($variable)) {
538
            return get_class($variable);
539
        }
540
541 1
        return gettype($variable);
542
    }
543
}
544