Passed
Push — iterable ( 66942d...ecefc6 )
by Dmitriy
03:32 queued 01:02
created

Container   F

Complexity

Total Complexity 87

Size/Duplication

Total Lines 625
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 16
Bugs 2 Features 0
Metric Value
eloc 221
c 16
b 2
f 0
dl 0
loc 625
ccs 204
cts 204
cp 1
rs 2
wmc 87

20 Methods

Rating   Name   Duplication   Size   Complexity  
C get() 0 56 12
A has() 0 16 4
A addDefinition() 0 21 4
A __construct() 0 14 1
B setTags() 0 36 8
B addProviders() 0 44 9
A setDelegates() 0 22 4
A setDefinitionResetter() 0 3 1
A buildProvider() 0 33 6
A validateDefinitionTags() 0 14 4
A build() 0 26 4
A validateMeta() 0 23 5
A validateDefinitionReset() 0 7 2
A addDefinitionToStorage() 0 6 2
A buildInternal() 0 9 2
A validateDefinition() 0 30 5
A getTaggedServices() 0 12 3
A setDefinitionTags() 0 13 5
A addDefinitions() 0 16 4
A getVariableType() 0 3 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 InvalidArgumentException;
9
use Psr\Container\ContainerInterface;
10
use Traversable;
11
use Yiisoft\Definitions\ArrayDefinition;
12
use Yiisoft\Definitions\Exception\CircularReferenceException;
13
use Yiisoft\Definitions\Exception\InvalidConfigException;
14
use Yiisoft\Definitions\Exception\NotInstantiableException;
15
use Yiisoft\Definitions\Helpers\DefinitionValidator;
16
use Yiisoft\Definitions\DefinitionStorage;
17
use Yiisoft\Di\Helpers\DefinitionNormalizer;
18
use Yiisoft\Di\Helpers\DefinitionParser;
19
use Yiisoft\Di\Helpers\TagHelper;
20
21
use function array_key_exists;
22
use function array_keys;
23
use function get_class;
24
use function gettype;
25
use function implode;
26
use function in_array;
27
use function is_array;
28
use function is_callable;
29
use function is_object;
30
use function is_string;
31
32
/**
33
 * Container implements a [dependency injection](http://en.wikipedia.org/wiki/Dependency_injection) container.
34
 */
35
final class Container implements ContainerInterface
36
{
37
    private const META_TAGS = 'tags';
38
    private const META_RESET = 'reset';
39
    private const ALLOWED_META = [self::META_TAGS, self::META_RESET];
40
41
    /**
42
     * @var DefinitionStorage Storage of object definitions.
43
     */
44
    private DefinitionStorage $definitions;
45
46
    /**
47
     * @var array Used to collect IDs of objects instantiated during build
48
     * to detect circular references.
49
     */
50
    private array $building = [];
51
52
    /**
53
     * @var bool $validate If definitions should be validated.
54
     */
55
    private bool $validate;
56
57
    private array $instances = [];
58
59
    private CompositeContainer $delegates;
60
61
    /**
62
     * @var array Tagged service IDs. The structure is `['tagID' => ['service1', 'service2']]`.
63
     * @psalm-var array<string, iterable<string>>
64
     */
65
    private array $tags;
66
67
    /**
68
     * @var Closure[]
69
     */
70
    private array $resetters = [];
71
    private bool $useResettersFromMeta = true;
72
73
    /**
74
     * @param ContainerConfigInterface $config Container configuration.
75
     *
76
     * @throws InvalidConfigException If configuration is not valid.
77
     */
78 137
    public function __construct(ContainerConfigInterface $config)
79
    {
80 137
        $this->definitions = new DefinitionStorage(
81
            [
82 137
                ContainerInterface::class => $this,
83
                StateResetter::class => StateResetter::class,
84
            ],
85 137
            $config->useStrictMode()
86
        );
87 137
        $this->validate = $config->shouldValidate();
88 137
        $this->setTags($config->getTags());
89 134
        $this->addDefinitions($config->getDefinitions());
90 125
        $this->addProviders($config->getProviders());
91 119
        $this->setDelegates($config->getDelegates());
92
    }
93
94
    /**
95
     * Returns a value indicating whether the container has the definition of the specified name.
96
     *
97
     * @param string $id Class name, interface name or alias name.
98
     *
99
     * @return bool Whether the container is able to provide instance of class specified.
100
     *
101
     * @see addDefinition()
102
     */
103 48
    public function has($id): bool
104
    {
105
        /** @psalm-suppress  DocblockTypeContradiction */
106 48
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
107 1
            return false;
108
        }
109
110 47
        if (TagHelper::isTagAlias($id)) {
111 3
            $tag = TagHelper::extractTagFromAlias($id);
112 3
            return isset($this->tags[$tag]);
113
        }
114
115
        try {
116 44
            return $this->definitions->has($id);
117 3
        } catch (CircularReferenceException $e) {
118 3
            return true;
119
        }
120
    }
121
122
    /**
123
     * Returns an instance by either interface name or alias.
124
     *
125
     * Same instance of the class will be returned each time this method is called.
126
     *
127
     * @param string $id The interface or an alias name that was previously registered.
128
     *
129
     * @throws CircularReferenceException
130
     * @throws InvalidConfigException
131
     * @throws NotFoundException
132
     * @throws NotInstantiableException
133
     *
134
     * @return mixed|object An instance of the requested interface.
135
     *
136
     * @psalm-template T
137
     * @psalm-param string|class-string<T> $id
138
     * @psalm-return ($id is class-string ? T : mixed)
139
     */
140 101
    public function get($id)
141
    {
142
        /** @psalm-suppress TypeDoesNotContainType */
143 101
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
144 1
            throw new InvalidArgumentException(
145 1
                sprintf(
146
                    'ID must be a string, %s given.',
147 1
                    $this->getVariableType($id)
148
                )
149
            );
150
        }
151
152 100
        if (!array_key_exists($id, $this->instances)) {
153
            try {
154 100
                $this->instances[$id] = $this->build($id);
155 20
            } catch (NotFoundException $e) {
156 11
                if (!$this->delegates->has($id)) {
157 8
                    throw $e;
158
                }
159
160
                /** @psalm-suppress MixedReturnStatement */
161 3
                return $this->delegates->get($id);
162
            }
163
        }
164
165 91
        if ($id === StateResetter::class) {
166 10
            $delegatesResetter = null;
167 10
            if ($this->delegates->has(StateResetter::class)) {
168 2
                $delegatesResetter = $this->delegates->get(StateResetter::class);
169
            }
170
171
            /** @var StateResetter $mainResetter */
172 10
            $mainResetter = $this->instances[$id];
173
174 10
            if ($this->useResettersFromMeta) {
175
                /** @var StateResetter[] $resetters */
176 7
                $resetters = [];
177 7
                foreach ($this->resetters as $serviceId => $callback) {
178 7
                    if (isset($this->instances[$serviceId])) {
179 7
                        $resetters[$serviceId] = $callback;
180
                    }
181
                }
182 7
                if ($delegatesResetter !== null) {
183 1
                    $resetters[] = $delegatesResetter;
184
                }
185 7
                $mainResetter->setResetters($resetters);
186 5
            } elseif ($delegatesResetter !== null) {
187 1
                $resetter = new StateResetter($this->get(ContainerInterface::class));
188 1
                $resetter->setResetters([$mainResetter, $delegatesResetter]);
189
190 1
                return $resetter;
191
            }
192
        }
193
194
        /** @psalm-suppress MixedReturnStatement */
195 91
        return $this->instances[$id];
196
    }
197
198
    /**
199
     * Sets a definition to the container. Definition may be defined multiple ways.
200
     *
201
     * @param string $id ID to set definition for.
202
     * @param mixed $definition Definition to set.
203
     *
204
     * @throws InvalidConfigException
205
     *
206
     * @see DefinitionNormalizer::normalize()
207
     */
208 108
    private function addDefinition(string $id, $definition): void
209
    {
210
        /** @var mixed $definition */
211 108
        [$definition, $meta] = DefinitionParser::parse($definition);
212 108
        if ($this->validate) {
213 108
            $this->validateDefinition($definition, $id);
214 106
            $this->validateMeta($meta);
215
        }
216
        /**
217
         * @psalm-var array{reset?:Closure,tags?:string[]} $meta
218
         */
219
220 100
        if (isset($meta[self::META_TAGS])) {
221 10
            $this->setDefinitionTags($id, $meta[self::META_TAGS]);
222
        }
223 100
        if (isset($meta[self::META_RESET])) {
224 7
            $this->setDefinitionResetter($id, $meta[self::META_RESET]);
225
        }
226
227 100
        unset($this->instances[$id]);
228 100
        $this->addDefinitionToStorage($id, $definition);
229
    }
230
231
    /**
232
     * Sets multiple definitions at once.
233
     *
234
     * @param iterable $config Definitions indexed by their IDs.
235
     *
236
     * @throws InvalidConfigException
237
     */
238 134
    private function addDefinitions(iterable $config): void
239
    {
240
        /** @var mixed $definition */
241
        /** @psalm-suppress MixedAssignment */
242 134
        foreach ($config as $id => $definition) {
243 109
            if ($this->validate && !is_string($id)) {
244 1
                throw new InvalidConfigException(
245 1
                    sprintf(
246
                        'Key must be a string. %s given.',
247 1
                        $this->getVariableType($id)
248
                    )
249
                );
250
            }
251
252 108
            $id = (string) $id;
253 108
            $this->addDefinition($id, $definition);
254
        }
255
    }
256
257
    /**
258
     * Set container delegates.
259
     *
260
     * Each delegate must is a callable in format "function (ContainerInterface $container): ContainerInterface".
261
     * The container instance returned is used in case a service can not be found in primary container.
262
     *
263
     * @param iterable $delegates
264
     *
265
     * @throws InvalidConfigException
266
     */
267 119
    private function setDelegates(iterable $delegates): void
268
    {
269 119
        $this->delegates = new CompositeContainer();
270 119
        foreach ($delegates as $delegate) {
271 5
            if (!$delegate instanceof Closure) {
272 1
                throw new InvalidConfigException(
273
                    'Delegate must be callable in format "function (ContainerInterface $container): ContainerInterface".'
274
                );
275
            }
276
277
            /** @var ContainerInterface */
278 4
            $delegate = $delegate($this);
279
280 4
            if (!$delegate instanceof ContainerInterface) {
281 1
                throw new InvalidConfigException(
282
                    'Delegate callable must return an object that implements ContainerInterface.'
283
                );
284
            }
285
286 3
            $this->delegates->attach($delegate);
287
        }
288 117
        $this->definitions->setDelegateContainer($this->delegates);
289
    }
290
291
    /**
292
     * @param mixed $definition Definition to validate.
293
     * @param string|null $id ID of the definition to validate.
294
     *
295
     * @throws InvalidConfigException
296
     */
297 108
    private function validateDefinition($definition, ?string $id = null): void
298
    {
299 108
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
300
            /** @var mixed $class */
301 47
            $class = $definition['class'];
302
303
            /** @var mixed $constructorArguments */
304 47
            $constructorArguments = $definition['__construct()'];
305
306
            /**
307
             * @var array $methodsAndProperties Is always array for prepared array definition data.
308
             *
309
             * @see DefinitionParser::parse()
310
             */
311 47
            $methodsAndProperties = $definition['methodsAndProperties'];
312
313 47
            $definition = array_merge(
314 47
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
315 47
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
316
                $methodsAndProperties,
317
            );
318
        }
319
320 108
        if ($definition instanceof ExtensibleService) {
321 1
            throw new InvalidConfigException(
322
                'Invalid definition. ExtensibleService is only allowed in provider extensions.'
323
            );
324
        }
325
326 107
        DefinitionValidator::validate($definition, $id);
327
    }
328
329
    /**
330
     * @throws InvalidConfigException
331
     */
332 106
    private function validateMeta(iterable $meta): void
333
    {
334
        /** @var mixed $value */
335
        /** @psalm-suppress MixedAssignment */
336 106
        foreach ($meta as $key => $value) {
337 23
            $key = (string)$key;
338 23
            if (!in_array($key, self::ALLOWED_META, true)) {
339 3
                throw new InvalidConfigException(
340 3
                    sprintf(
341
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
342
                        $key,
343
                        $key,
344
                        $key,
345
                    )
346
                );
347
            }
348
349 21
            if ($key === self::META_TAGS) {
350 13
                $this->validateDefinitionTags($value);
351
            }
352
353 19
            if ($key === self::META_RESET) {
354 8
                $this->validateDefinitionReset($value);
355
            }
356
        }
357
    }
358
359
    /**
360
     * @param mixed $tags
361
     *
362
     * @throws InvalidConfigException
363
     */
364 13
    private function validateDefinitionTags($tags): void
365
    {
366 13
        if (!is_iterable($tags)) {
367 1
            throw new InvalidConfigException(
368 1
                sprintf(
369
                    'Invalid definition: tags should be iterable object or array of strings, %s given.',
370 1
                    $this->getVariableType($tags)
371
                )
372
            );
373
        }
374
375 12
        foreach ($tags as $tag) {
376 12
            if (!is_string($tag)) {
377 1
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
378
            }
379
        }
380
    }
381
382
    /**
383
     * @param mixed $reset
384
     *
385
     * @throws InvalidConfigException
386
     */
387 8
    private function validateDefinitionReset($reset): void
388
    {
389 8
        if (!$reset instanceof Closure) {
390 1
            throw new InvalidConfigException(
391 1
                sprintf(
392
                    'Invalid definition: "reset" should be closure, %s given.',
393 1
                    $this->getVariableType($reset)
394
                )
395
            );
396
        }
397
    }
398
399
    /**
400
     * @throws InvalidConfigException
401
     */
402 137
    private function setTags(iterable $tags): void
403
    {
404 137
        if ($this->validate) {
405 137
            foreach ($tags as $tag => $services) {
406 6
                if (!is_string($tag)) {
407 1
                    throw new InvalidConfigException(
408 1
                        sprintf(
409
                            'Invalid tags configuration: tag should be string, %s given.',
410 1
                            $this->getVariableType($tag)
411
                        )
412
                    );
413
                }
414 5
                if (!is_iterable($services)) {
415 1
                    throw new InvalidConfigException(
416 1
                        sprintf(
417
                            'Invalid tags configuration: tag should be iterable object or array of service IDs, %s given.',
418 1
                            $this->getVariableType($services)
419
                        )
420
                    );
421
                }
422
                /** @var mixed $service */
423 4
                foreach ($services as $service) {
424 4
                    if (!is_string($service)) {
425 1
                        throw new InvalidConfigException(
426 1
                            sprintf(
427
                                'Invalid tags configuration: service should be defined as class string, %s given.',
428 1
                                $this->getVariableType($service)
429
                            )
430
                        );
431
                    }
432
                }
433
            }
434
        }
435
        /** @psalm-var iterable<string, iterable<string>> $tags */
436
437 134
        $this->tags = $tags instanceof Traversable ? iterator_to_array($tags, true) : $tags ;
0 ignored issues
show
Documentation Bug introduced by
It seems like $tags instanceof Travers...ay($tags, true) : $tags can also be of type iterable. However, the property $tags is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
438
    }
439
440
    /**
441
     * @psalm-param string[] $tags
442
     */
443 10
    private function setDefinitionTags(string $id, iterable $tags): void
444
    {
445 10
        foreach ($tags as $tag) {
446 10
            if (!isset($this->tags[$tag])) {
447 8
                $this->tags[$tag] = [$id];
448 8
                continue;
449
            }
450
451 7
            $tags = $this->tags[$tag];
452 7
            $tags = $tags instanceof Traversable ? iterator_to_array($tags, true) : $tags;
453 7
            if (!in_array($id, $tags, true)) {
454
                /** @psalm-suppress PossiblyInvalidArrayAssignment */
455 7
                $this->tags[$tag][] = $id;
456
            }
457
        }
458
    }
459
460 7
    private function setDefinitionResetter(string $id, Closure $resetter): void
461
    {
462 7
        $this->resetters[$id] = $resetter;
463
    }
464
465
    /**
466
     * Add definition to storage.
467
     *
468
     * @see $definitions
469
     *
470
     * @param string $id ID to set definition for.
471
     * @param mixed|object $definition Definition to set.
472
     */
473 100
    private function addDefinitionToStorage(string $id, $definition): void
474
    {
475 100
        $this->definitions->set($id, $definition);
476
477 100
        if ($id === StateResetter::class) {
478 5
            $this->useResettersFromMeta = false;
479
        }
480
    }
481
482
    /**
483
     * Creates new instance by either interface name or alias.
484
     *
485
     * @param string $id The interface or an alias name that was previously registered.
486
     *
487
     * @throws CircularReferenceException
488
     * @throws InvalidConfigException
489
     * @throws NotFoundException
490
     *
491
     * @return mixed|object New built instance of the specified class.
492
     *
493
     * @internal
494
     */
495 100
    private function build(string $id)
496
    {
497 100
        if (TagHelper::isTagAlias($id)) {
498 11
            return $this->getTaggedServices($id);
499
        }
500
501 99
        if (isset($this->building[$id])) {
502 90
            if ($id === ContainerInterface::class) {
503 90
                return $this;
504
            }
505 4
            throw new CircularReferenceException(sprintf(
506
                'Circular reference to "%s" detected while building: %s.',
507
                $id,
508 4
                implode(', ', array_keys($this->building))
509
            ));
510
        }
511
512 99
        $this->building[$id] = 1;
513
        try {
514
            /** @var mixed $object */
515 99
            $object = $this->buildInternal($id);
516 90
        } finally {
517 99
            unset($this->building[$id]);
518
        }
519
520 90
        return $object;
521
    }
522
523 11
    private function getTaggedServices(string $tagAlias): array
524
    {
525 11
        $tag = TagHelper::extractTagFromAlias($tagAlias);
526 11
        $services = [];
527 11
        if (isset($this->tags[$tag])) {
528 10
            foreach ($this->tags[$tag] as $service) {
529
                /** @var mixed */
530 10
                $services[] = $this->get($service);
531
            }
532
        }
533
534 11
        return $services;
535
    }
536
537
    /**
538
     * @param string $id
539
     *
540
     * @throws InvalidConfigException
541
     * @throws NotFoundException
542
     *
543
     * @return mixed|object
544
     */
545 99
    private function buildInternal(string $id)
546
    {
547 99
        if ($this->definitions->has($id)) {
548 90
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
549
550 90
            return $definition->resolve($this->get(ContainerInterface::class));
551
        }
552
553 11
        throw new NotFoundException($id, $this->definitions->getBuildStack());
554
    }
555
556
    /**
557
     * @throws CircularReferenceException
558
     * @throws InvalidConfigException
559
     */
560 125
    private function addProviders(iterable $providers): void
561
    {
562 125
        $extensions = [];
563
        /** @var mixed $provider */
564 125
        foreach ($providers as $provider) {
565 15
            $providerInstance = $this->buildProvider($provider);
566 13
            $extensions[] = $providerInstance->getExtensions();
567 13
            $this->addDefinitions($providerInstance->getDefinitions());
568
        }
569
570 123
        foreach ($extensions as $providerExtensions) {
571
            /** @var mixed $extension */
572 13
            foreach ($providerExtensions as $id => $extension) {
573 10
                if (!is_string($id)) {
574 1
                    throw new InvalidConfigException(
575 1
                        sprintf('Extension key must be a service ID as string, %s given.', $id)
576
                    );
577
                }
578
579 9
                if ($id === ContainerInterface::class) {
580 1
                    throw new InvalidConfigException('ContainerInterface extensions are not allowed.');
581
                }
582
583 8
                if (!$this->definitions->has($id)) {
584 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
585
                }
586
587 8
                if (!is_callable($extension)) {
588 1
                    throw new InvalidConfigException(
589 1
                        sprintf(
590
                            'Extension of service should be callable, %s given.',
591 1
                            $this->getVariableType($extension)
592
                        )
593
                    );
594
                }
595
596
                /** @var mixed $definition */
597 7
                $definition = $this->definitions->get($id);
598 7
                if (!$definition instanceof ExtensibleService) {
599 7
                    $definition = new ExtensibleService($definition, $id);
600 7
                    $this->addDefinitionToStorage($id, $definition);
601
                }
602
603 7
                $definition->addExtension($extension);
604
            }
605
        }
606
    }
607
608
    /**
609
     * Builds service provider by definition.
610
     *
611
     * @param mixed $provider Class name or instance of provider.
612
     *
613
     * @throws InvalidConfigException If provider argument is not valid.
614
     *
615
     * @return ServiceProviderInterface Instance of service provider.
616
     *
617
     * @psalm-suppress MoreSpecificReturnType
618
     */
619 15
    private function buildProvider($provider): ServiceProviderInterface
620
    {
621 15
        if ($this->validate && !(is_string($provider) || $provider instanceof ServiceProviderInterface)) {
622 1
            throw new InvalidConfigException(
623 1
                sprintf(
624
                    'Service provider should be a class name or an instance of %s. %s given.',
625
                    ServiceProviderInterface::class,
626 1
                    $this->getVariableType($provider)
627
                )
628
            );
629
        }
630
631
        /** @psalm-var class-string|ServiceProviderInterface $provider */
632
633
        /**
634
         * @psalm-suppress MixedMethodCall Service provider defined as class string
635
         * should container public constructor, otherwise throws error.
636
         */
637 14
        $providerInstance = is_object($provider) ? $provider : new $provider();
638 14
        if (!$providerInstance instanceof ServiceProviderInterface) {
639 1
            throw new InvalidConfigException(
640 1
                sprintf(
641
                    'Service provider should be an instance of %s. %s given.',
642
                    ServiceProviderInterface::class,
643 1
                    $this->getVariableType($providerInstance)
644
                )
645
            );
646
        }
647
648
        /**
649
         * @psalm-suppress LessSpecificReturnStatement
650
         */
651 13
        return $providerInstance;
652
    }
653
654
    /**
655
     * @param mixed $variable
656
     */
657 10
    private function getVariableType($variable): string
658
    {
659 10
        return is_object($variable) ? get_class($variable) : gettype($variable);
660
    }
661
}
662