Test Failed
Push — upgrade-psr ( 3d23e3...7af4e6 )
by Sergei
17:47 queued 14:42
created

Container::get()   B

Complexity

Conditions 11
Paths 20

Size

Total Lines 46
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 11.0151

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 11
eloc 25
c 2
b 0
f 0
nc 20
nop 1
dl 0
loc 46
ccs 19
cts 20
cp 0.95
crap 11.0151
rs 7.3166

How to fix   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 InvalidArgumentException;
9
use Psr\Container\ContainerInterface;
10
use Yiisoft\Definitions\ArrayDefinition;
11
use Yiisoft\Definitions\Exception\CircularReferenceException;
12
use Yiisoft\Definitions\Exception\InvalidConfigException;
13
use Yiisoft\Definitions\Exception\NotInstantiableException;
14
use Yiisoft\Definitions\Helpers\DefinitionValidator;
15
use Yiisoft\Definitions\DefinitionStorage;
16
use Yiisoft\Di\Helpers\DefinitionNormalizer;
17
use Yiisoft\Di\Helpers\DefinitionParser;
18
use Yiisoft\Di\Helpers\TagHelper;
19
20
use function array_key_exists;
21
use function array_keys;
22
use function get_class;
23
use function gettype;
24
use function implode;
25
use function in_array;
26
use function is_array;
27
use function is_callable;
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 DefinitionStorage Storage of object definitions.
42
     */
43
    private DefinitionStorage $definitions;
44
45
    /**
46
     * @var array Used to collect IDs of objects instantiated during build
47
     * to detect circular references.
48
     */
49
    private array $building = [];
50
51
    /**
52
     * @var bool $validate If definitions should be validated.
53
     */
54
    private bool $validate;
55
56
    private array $instances = [];
57
58
    private CompositeContainer $delegates;
59
60
    /**
61
     * @var array Tagged service IDs. The structure is `['tagID' => ['service1', 'service2']]`.
62
     * @psalm-var array<string, list<string>>
63
     */
64
    private array $tags;
65
66
    /**
67
     * @var Closure[]
68
     */
69
    private array $resetters = [];
70
    private bool $useResettersFromMeta = true;
71
72
    /**
73
     * @param ContainerConfigInterface $config Container configuration.
74
     *
75
     * @throws InvalidConfigException If configuration is not valid.
76
     */
77
    public function __construct(ContainerConfigInterface $config)
78 95
    {
79
        $this->definitions = new DefinitionStorage(
80
            [
81
                ContainerInterface::class => $this,
82
                StateResetter::class => StateResetter::class,
83
            ],
84
            $config->useStrictMode()
85 95
        );
86 95
        $this->validate = $config->shouldValidate();
87 95
        $this->setTags($config->getTags());
88 95
        $this->addDefinitions($config->getDefinitions());
89 95
        $this->addProviders($config->getProviders());
90 89
        $this->setDelegates($config->getDelegates());
91 88
    }
92 88
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 addDefinition()
101
     */
102
    public function has(string $id): bool
103 26
    {
104
        if (TagHelper::isTagAlias($id)) {
105 26
            $tag = TagHelper::extractTagFromAlias($id);
106 2
            return isset($this->tags[$tag]);
107 2
        }
108
109
        try {
110
            return $this->definitions->has($id);
111 24
        } catch (CircularReferenceException $e) {
112 2
            return true;
113 2
        }
114
    }
115
116
    /**
117
     * Returns an instance by either interface name or alias.
118
     *
119
     * Same instance of the class will be returned each time this method is called.
120
     *
121
     * @param string $id The interface or an alias name that was previously registered.
122
     *
123
     * @throws CircularReferenceException
124
     * @throws InvalidConfigException
125
     * @throws NotFoundException
126
     * @throws NotInstantiableException
127
     *
128
     * @return mixed|object An instance of the requested interface.
129
     *
130
     * @psalm-template T
131
     * @psalm-param string|class-string<T> $id
132
     * @psalm-return ($id is class-string ? T : mixed)
133
     */
134
    public function get(string $id)
135 82
    {
136
        if (!array_key_exists($id, $this->instances)) {
137 82
            try {
138
                $this->instances[$id] = $this->build($id);
139 82
            } catch (NotFoundException $e) {
140 14
                if (!$this->delegates->has($id)) {
141 5
                    throw $e;
142 4
                }
143
144
                /** @psalm-suppress MixedReturnStatement */
145 1
                return $this->delegates->get($id);
146
            }
147
        }
148
149 76
        if ($id === StateResetter::class) {
150 4
            $delegatesResetter = null;
151 4
            if ($this->delegates->has(StateResetter::class)) {
152 4
                $delegatesResetter = $this->delegates->get(StateResetter::class);
153 4
            }
154
155
            /** @var StateResetter $mainResetter */
156 4
            $mainResetter = $this->instances[$id];
157
158
            if ($this->useResettersFromMeta) {
159 4
                /** @var StateResetter[] $resetters */
160
                $resetters = [];
161
                foreach ($this->resetters as $serviceId => $callback) {
162 76
                    if (isset($this->instances[$serviceId])) {
163
                        $resetters[$serviceId] = $callback;
164
                    }
165
                }
166
                if ($delegatesResetter !== null) {
167
                    $resetters[] = $delegatesResetter;
168
                }
169
                $mainResetter->setResetters($resetters);
170
            } elseif ($delegatesResetter !== null) {
171
                $resetter = new StateResetter($this->get(ContainerInterface::class));
172
                $resetter->setResetters([$mainResetter, $delegatesResetter]);
173
174
                return $resetter;
175 95
            }
176
        }
177 95
178 95
        /** @psalm-suppress MixedReturnStatement */
179 95
        return $this->instances[$id];
180 95
    }
181
182
    /**
183 95
     * Sets a definition to the container. Definition may be defined multiple ways.
184 8
     *
185 8
     * @param string $id ID to set definition for.
186
     * @param mixed $definition Definition to set.
187 8
     *
188
     * @throws InvalidConfigException
189 95
     *
190 5
     * @see DefinitionNormalizer::normalize()
191
     */
192
    private function addDefinition(string $id, $definition): void
193 95
    {
194 95
        /** @var mixed $definition */
195 95
        [$definition, $meta] = DefinitionParser::parse($definition);
196
        if ($this->validate) {
197
            $this->validateDefinition($definition, $id);
198
            $this->validateMeta($meta);
199
        }
200
        /**
201
         * @psalm-var array{reset?:Closure,tags?:string[]} $meta
202
         */
203
204 95
        if (isset($meta[self::META_TAGS])) {
205
            $this->setDefinitionTags($id, $meta[self::META_TAGS]);
206 95
        }
207 95
        if (isset($meta[self::META_RESET])) {
208 1
            $this->setDefinitionResetter($id, $meta[self::META_RESET]);
209
        }
210 95
211
        unset($this->instances[$id]);
212 95
        $this->addDefinitionToStorage($id, $definition);
213
    }
214 95
215
    /**
216 95
     * Sets multiple definitions at once.
217 95
     *
218
     * @param array $config Definitions indexed by their IDs.
219
     *
220 95
     * @throws InvalidConfigException
221
     */
222
    private function addDefinitions(array $config): void
223
    {
224
        /** @var mixed $definition */
225
        foreach ($config as $id => $definition) {
226
            if ($this->validate && !is_string($id)) {
227
                throw new InvalidConfigException(
228
                    sprintf(
229
                        'Key must be a string. %s given.',
230
                        $this->getVariableType($id)
231
                    )
232 88
                );
233
            }
234 88
            /** @var string $id */
235 88
236 1
            $this->addDefinition($id, $definition);
237
        }
238
    }
239
240
    /**
241
     * Set container delegates.
242 1
     *
243
     * Each delegate must is a callable in format "function (ContainerInterface $container): ContainerInterface".
244 1
     * The container instance returned is used in case a service can not be found in primary container.
245
     *
246
     * @param array $delegates
247
     *
248
     * @throws InvalidConfigException
249
     */
250 1
    private function setDelegates(array $delegates): void
251
    {
252 88
        $this->delegates = new CompositeContainer();
253 88
        foreach ($delegates as $delegate) {
254
            if (!$delegate instanceof Closure) {
255
                throw new InvalidConfigException(
256
                    'Delegate must be callable in format "function (ContainerInterface $container): ContainerInterface".'
257
                );
258
            }
259
260 95
            /** @var ContainerInterface */
261
            $delegate = $delegate($this);
262 95
263 36
            if (!$delegate instanceof ContainerInterface) {
264 36
                throw new InvalidConfigException(
265 36
                    'Delegate callable must return an object that implements ContainerInterface.'
266 36
                );
267 36
            }
268 36
269
            $this->delegates->attach($delegate);
270
        }
271
        $this->definitions->setDelegateContainer($this->delegates);
272
    }
273 95
274
    /**
275
     * @param mixed $definition Definition to validate.
276
     * @param string|null $id ID of the definition to validate.
277 95
     *
278 95
     * @throws InvalidConfigException
279
     */
280
    private function validateDefinition($definition, ?string $id = null): void
281
    {
282
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
283 95
            /** @var mixed $class */
284
            $class = $definition['class'];
285 95
286 16
            /** @var mixed $constructorArguments */
287 3
            $constructorArguments = $definition['__construct()'];
288 3
289 3
            /**
290
             * @var array $methodsAndProperties Is always array for prepared array definition data.
291
             *
292
             * @see DefinitionParser::parse()
293
             */
294
            $methodsAndProperties = $definition['methodsAndProperties'];
295
296
            $definition = array_merge(
297 95
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
298
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
299 8
                $methodsAndProperties,
300
            );
301 8
        }
302 8
303
        if ($definition instanceof ExtensibleService) {
304
            throw new InvalidConfigException(
305
                'Invalid definition. ExtensibleService is only allowed in provider extensions.'
306 8
            );
307
        }
308 8
309
        DefinitionValidator::validate($definition, $id);
310 8
    }
311 8
312 8
    /**
313
     * @throws InvalidConfigException
314
     */
315 8
    private function validateMeta(array $meta): void
316
    {
317 4
        /** @var mixed $value */
318
        foreach ($meta as $key => $value) {
319 4
            if (!in_array($key, self::ALLOWED_META, true)) {
320 4
                throw new InvalidConfigException(
321
                    sprintf(
322
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
323
                        $key,
324
                        $key,
325
                        $key,
326
                    )
327
                );
328
            }
329
330
            if ($key === self::META_TAGS) {
331
                $this->validateDefinitionTags($value);
332
            }
333
334
            if ($key === self::META_RESET) {
335 82
                $this->validateDefinitionReset($value);
336
            }
337 82
        }
338 9
    }
339
340
    /**
341 81
     * @param mixed $tags
342 75
     *
343 75
     * @throws InvalidConfigException
344
     */
345 4
    private function validateDefinitionTags($tags): void
346 4
    {
347
        if (!is_array($tags)) {
348 4
            throw new InvalidConfigException(
349
                sprintf(
350
                    'Invalid definition: tags should be array of strings, %s given.',
351
                    $this->getVariableType($tags)
352 81
                )
353
            );
354 81
        }
355 75
356 81
        foreach ($tags as $tag) {
357
            if (!is_string($tag)) {
358
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
359 75
            }
360
        }
361
    }
362 87
363
    /**
364 87
     * @param mixed $reset
365
     *
366
     * @throws InvalidConfigException
367 9
     */
368
    private function validateDefinitionReset($reset): void
369 9
    {
370 9
        if (!$reset instanceof Closure) {
371 9
            throw new InvalidConfigException(
372 8
                sprintf(
373 8
                    'Invalid definition: "reset" should be closure, %s given.',
374
                    $this->getVariableType($reset)
375
                )
376
            );
377 9
        }
378
    }
379
380
    /**
381
     * @throws InvalidConfigException
382
     */
383
    private function setTags(array $tags): void
384
    {
385
        if ($this->validate) {
386
            foreach ($tags as $tag => $services) {
387
                if (!is_string($tag)) {
388 81
                    throw new InvalidConfigException(
389
                        sprintf(
390 81
                            'Invalid tags configuration: tag should be string, %s given.',
391 75
                            $tag
392
                        )
393 75
                    );
394
                }
395
                if (!is_array($services)) {
396 5
                    throw new InvalidConfigException(
397
                        sprintf(
398
                            'Invalid tags configuration: tag should contain array of service IDs, %s given.',
399 89
                            $this->getVariableType($services)
400
                        )
401 89
                    );
402 89
                }
403 5
                /** @var mixed $service */
404 5
                foreach ($services as $service) {
405 5
                    if (!is_string($service)) {
406
                        throw new InvalidConfigException(
407
                            sprintf(
408 89
                                'Invalid tags configuration: service should be defined as class string, %s given.',
409 5
                                $this->getVariableType($service)
410 4
                            )
411 1
                        );
412
                    }
413
                }
414 4
            }
415 4
        }
416 4
        /** @psalm-var array<string, list<string>> $tags */
417 4
418
        $this->tags = $tags;
419
    }
420 4
421
    /**
422
     * @psalm-param string[] $tags
423 88
     */
424
    private function setDefinitionTags(string $id, array $tags): void
425
    {
426
        foreach ($tags as $tag) {
427
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
428
                $this->tags[$tag][] = $id;
429
            }
430
        }
431
    }
432
433
    private function setDefinitionResetter(string $id, Closure $resetter): void
434
    {
435 5
        $this->resetters[$id] = $resetter;
436
    }
437 5
438 5
    /**
439 5
     * Add definition to storage.
440
     *
441
     * @see $definitions
442
     *
443
     * @param string $id ID to set definition for.
444
     * @param mixed|object $definition Definition to set.
445
     */
446
    private function addDefinitionToStorage(string $id, $definition): void
447
    {
448
        $this->definitions->set($id, $definition);
449
450
        if ($id === StateResetter::class) {
451
            $this->useResettersFromMeta = false;
452 5
        }
453
    }
454 5
455
    /**
456
     * Creates new instance by either interface name or alias.
457
     *
458
     * @param string $id The interface or an alias name that was previously registered.
459
     *
460
     * @throws CircularReferenceException
461
     * @throws InvalidConfigException
462
     * @throws NotFoundException
463
     *
464 5
     * @return mixed|object New built instance of the specified class.
465 5
     *
466
     * @internal
467
     */
468
    private function build(string $id)
469
    {
470
        if (TagHelper::isTagAlias($id)) {
471
            return $this->getTaggedServices($id);
472
        }
473
474
        if (isset($this->building[$id])) {
475
            if ($id === ContainerInterface::class) {
476
                return $this;
477
            }
478 5
            throw new CircularReferenceException(sprintf(
479
                'Circular reference to "%s" detected while building: %s.',
480
                $id,
481
                implode(', ', array_keys($this->building))
482
            ));
483
        }
484 1
485
        $this->building[$id] = 1;
486 1
        try {
487
            /** @var mixed $object */
488
            $object = $this->buildInternal($id);
489
        } finally {
490
            unset($this->building[$id]);
491
        }
492
493
        return $object;
494
    }
495
496
    private function getTaggedServices(string $tagAlias): array
497
    {
498
        $tag = TagHelper::extractTagFromAlias($tagAlias);
499
        $services = [];
500
        if (isset($this->tags[$tag])) {
501
            foreach ($this->tags[$tag] as $service) {
502
                /** @var mixed */
503
                $services[] = $this->get($service);
504
            }
505
        }
506
507
        return $services;
508
    }
509
510
    /**
511
     * @param string $id
512
     *
513
     * @throws InvalidConfigException
514
     * @throws NotFoundException
515
     *
516
     * @return mixed|object
517
     */
518
    private function buildInternal(string $id)
519
    {
520
        if ($this->definitions->has($id)) {
521
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
522
523
            return $definition->resolve($this->get(ContainerInterface::class));
524
        }
525
526
        throw new NotFoundException($id, $this->definitions->getBuildStack());
527
    }
528
529
    /**
530
     * @throws CircularReferenceException
531
     * @throws InvalidConfigException
532
     */
533
    private function addProviders(array $providers): void
534
    {
535
        $extensions = [];
536
        /** @var mixed $provider */
537
        foreach ($providers as $provider) {
538
            $providerInstance = $this->buildProvider($provider);
539
            $extensions[] = $providerInstance->getExtensions();
540
            $this->addDefinitions($providerInstance->getDefinitions());
541
        }
542
543
        foreach ($extensions as $providerExtensions) {
544
            /** @var mixed $extension */
545
            foreach ($providerExtensions as $id => $extension) {
546
                if (!is_string($id)) {
547
                    throw new InvalidConfigException(
548
                        sprintf('Extension key must be a service ID as string, %s given.', $id)
549
                    );
550
                }
551
552
                if ($id === ContainerInterface::class) {
553
                    throw new InvalidConfigException('ContainerInterface extensions are not allowed.');
554
                }
555
556
                if (!$this->definitions->has($id)) {
557
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
558
                }
559
560
                if (!is_callable($extension)) {
561
                    throw new InvalidConfigException(
562
                        sprintf(
563
                            'Extension of service should be callable, %s given.',
564
                            $this->getVariableType($extension)
565
                        )
566
                    );
567
                }
568
569
                /** @var mixed $definition */
570
                $definition = $this->definitions->get($id);
571
                if (!$definition instanceof ExtensibleService) {
572
                    $definition = new ExtensibleService($definition, $id);
573
                    $this->addDefinitionToStorage($id, $definition);
574
                }
575
576
                $definition->addExtension($extension);
577
            }
578
        }
579
    }
580
581
    /**
582
     * Builds service provider by definition.
583
     *
584
     * @param mixed $provider Class name or instance of provider.
585
     *
586
     * @throws InvalidConfigException If provider argument is not valid.
587
     *
588
     * @return ServiceProviderInterface Instance of service provider.
589
     *
590
     * @psalm-suppress MoreSpecificReturnType
591
     */
592
    private function buildProvider($provider): ServiceProviderInterface
593
    {
594
        if ($this->validate && !(is_string($provider) || $provider instanceof ServiceProviderInterface)) {
595
            throw new InvalidConfigException(
596
                sprintf(
597
                    'Service provider should be a class name or an instance of %s. %s given.',
598
                    ServiceProviderInterface::class,
599
                    $this->getVariableType($provider)
600
                )
601
            );
602
        }
603
604
        /** @psalm-var class-string|ServiceProviderInterface $provider */
605
606
        /**
607
         * @psalm-suppress MixedMethodCall Service provider defined as class string
608
         * should container public constructor, otherwise throws error.
609
         */
610
        $providerInstance = is_object($provider) ? $provider : new $provider();
611
        if (!$providerInstance instanceof ServiceProviderInterface) {
612
            throw new InvalidConfigException(
613
                sprintf(
614
                    'Service provider should be an instance of %s. %s given.',
615
                    ServiceProviderInterface::class,
616
                    $this->getVariableType($providerInstance)
617
                )
618
            );
619
        }
620
621
        /**
622
         * @psalm-suppress LessSpecificReturnStatement
623
         */
624
        return $providerInstance;
625
    }
626
627
    /**
628
     * @param mixed $variable
629
     */
630
    private function getVariableType($variable): string
631
    {
632
        return is_object($variable) ? get_class($variable) : gettype($variable);
633
    }
634
}
635