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

Container   F

Complexity

Total Complexity 86

Size/Duplication

Total Lines 510
Duplicated Lines 0 %

Test Coverage

Coverage 86.01%

Importance

Changes 12
Bugs 2 Features 0
Metric Value
eloc 178
dl 0
loc 510
ccs 166
cts 193
cp 0.8601
rs 2
c 12
b 2
f 0
wmc 86

21 Methods

Rating   Name   Duplication   Size   Complexity  
A get() 0 17 6
A has() 0 8 2
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 87 23
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 16 5
A getTaggedServices() 0 11 3
A getVariableType() 0 7 2
A buildPrimitive() 0 11 2
A __construct() 0 11 1

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