Passed
Pull Request — master (#240)
by Dmitriy
03:33
created

Container::isResolvable()   D

Complexity

Conditions 22
Paths 20

Size

Total Lines 87
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 31.1815

Importance

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