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

Container   F

Complexity

Total Complexity 89

Size/Duplication

Total Lines 536
Duplicated Lines 0 %

Test Coverage

Coverage 85.5%

Importance

Changes 24
Bugs 2 Features 0
Metric Value
eloc 188
c 24
b 2
f 0
dl 0
loc 536
ccs 171
cts 200
cp 0.855
rs 2
wmc 89

21 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 11 1
A get() 0 17 6
A has() 0 11 3
A addProviderDefinitions() 0 4 1
A setTags() 0 5 4
A set() 0 20 5
A addProviders() 0 20 6
A validateTags() 0 5 3
A setDefaultDefinitions() 0 3 1
B buildProvider() 0 27 7
D isResolvable() 0 98 25
A setResetter() 0 3 1
A build() 0 25 4
A validateMeta() 0 10 3
A isTagAlias() 0 3 1
A buildInternal() 0 10 2
A setMultiple() 0 7 4
A validateDefinition() 0 18 5
A getTaggedServices() 0 11 3
A getVariableType() 0 7 2
A buildPrimitive() 0 12 2

How to fix   Complexity   

Complex Class

Complex classes like Container often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Container, and based on these observations, apply Extract Interface, too.

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